resolve main merge conflicts for alert context and beads state
This commit is contained in:
commit
dc932cf18e
11 changed files with 843 additions and 153 deletions
|
|
@ -1818,6 +1818,28 @@ h3 {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer-context-loading {
|
||||||
|
padding: 12px 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-skeleton {
|
||||||
|
width: 64%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, var(--bg-soft), rgba(245, 166, 35, 0.14), var(--bg-soft));
|
||||||
|
background-size: 180% 100%;
|
||||||
|
animation: drawer-skeleton 1.2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-skeleton-wide {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-evidence-context {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
.drawer-row {
|
.drawer-row {
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
@ -1825,6 +1847,15 @@ h3 {
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes drawer-skeleton {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -100% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { getSubscriptionKey as getLiveSubscriptionKey } from "@islandflow/types"
|
||||||
import {
|
import {
|
||||||
NAV_ITEMS,
|
NAV_ITEMS,
|
||||||
appendHistoryTail,
|
appendHistoryTail,
|
||||||
|
buildAlertContextPath,
|
||||||
buildDefaultFlowFilters,
|
buildDefaultFlowFilters,
|
||||||
buildOptionTapeQueryParams,
|
buildOptionTapeQueryParams,
|
||||||
classifierToneForFamily,
|
classifierToneForFamily,
|
||||||
|
collectAlertContextEvidence,
|
||||||
composeTapeItems,
|
composeTapeItems,
|
||||||
deriveAlertDirection,
|
deriveAlertDirection,
|
||||||
countActiveFlowFilterGroups,
|
countActiveFlowFilterGroups,
|
||||||
|
|
@ -95,6 +97,44 @@ describe("pinned evidence pruning", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("alert context hydration helpers", () => {
|
||||||
|
it("builds the persisted ClickHouse context endpoint path", () => {
|
||||||
|
expect(buildAlertContextPath("alert:large_call/one")).toBe(
|
||||||
|
"/flow/alerts/alert%3Alarge_call%2Fone/context"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges hydrated packets and prints into pinned evidence maps", () => {
|
||||||
|
const packet = {
|
||||||
|
trace_id: "flowpacket:1",
|
||||||
|
id: "flowpacket:1",
|
||||||
|
members: ["print:1"],
|
||||||
|
source_ts: 1,
|
||||||
|
ingest_ts: 2,
|
||||||
|
seq: 1,
|
||||||
|
features: {},
|
||||||
|
join_quality: {}
|
||||||
|
} as any;
|
||||||
|
const print = makeOptionPrint({
|
||||||
|
trace_id: "print:1",
|
||||||
|
execution_nbbo_bid: 1.2,
|
||||||
|
execution_nbbo_ask: 1.3,
|
||||||
|
execution_underlying_spot: 450.05
|
||||||
|
});
|
||||||
|
|
||||||
|
const evidence = collectAlertContextEvidence({
|
||||||
|
alert: makeAlert({ evidence_refs: ["flowpacket:1", "print:1"] }),
|
||||||
|
flow_packets: [packet],
|
||||||
|
option_prints: [print],
|
||||||
|
missing_refs: []
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(evidence.packets.get("flowpacket:1")).toBe(packet);
|
||||||
|
expect(evidence.prints.get("print:1")?.execution_nbbo_bid).toBe(1.2);
|
||||||
|
expect(evidence.prints.get("print:1")?.execution_underlying_spot).toBe(450.05);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("live manifest", () => {
|
describe("live manifest", () => {
|
||||||
it("includes only tape channels on /tape", () => {
|
it("includes only tape channels on /tape", () => {
|
||||||
const filters = buildDefaultFlowFilters();
|
const filters = buildDefaultFlowFilters();
|
||||||
|
|
|
||||||
|
|
@ -4604,6 +4604,49 @@ type EvidenceItem =
|
||||||
| { kind: "print"; id: string; print: OptionPrint }
|
| { kind: "print"; id: string; print: OptionPrint }
|
||||||
| { kind: "unknown"; id: string };
|
| { kind: "unknown"; id: string };
|
||||||
|
|
||||||
|
type AlertContextBundle = {
|
||||||
|
alert: AlertEvent | null;
|
||||||
|
flow_packets: FlowPacket[];
|
||||||
|
option_prints: OptionPrint[];
|
||||||
|
missing_refs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlertContextStatus = {
|
||||||
|
traceId: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
missingRefs: string[];
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildAlertContextPath = (traceId: string): string =>
|
||||||
|
`/flow/alerts/${encodeURIComponent(traceId)}/context`;
|
||||||
|
|
||||||
|
export const collectAlertContextEvidence = (
|
||||||
|
bundle: AlertContextBundle
|
||||||
|
): {
|
||||||
|
packets: Map<string, FlowPacket>;
|
||||||
|
prints: Map<string, OptionPrint>;
|
||||||
|
} => {
|
||||||
|
const packets = new Map<string, FlowPacket>();
|
||||||
|
const prints = new Map<string, OptionPrint>();
|
||||||
|
|
||||||
|
for (const packet of bundle.flow_packets) {
|
||||||
|
if (packet.id) {
|
||||||
|
packets.set(packet.id, packet);
|
||||||
|
}
|
||||||
|
if (packet.trace_id) {
|
||||||
|
packets.set(packet.trace_id, packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const print of bundle.option_prints) {
|
||||||
|
if (print.trace_id) {
|
||||||
|
prints.set(print.trace_id, print);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { packets, prints };
|
||||||
|
};
|
||||||
|
|
||||||
type DarkEvidenceItem =
|
type DarkEvidenceItem =
|
||||||
| { kind: "join"; id: string; join: EquityPrintJoin }
|
| { kind: "join"; id: string; join: EquityPrintJoin }
|
||||||
| { kind: "unknown"; id: string };
|
| { kind: "unknown"; id: string };
|
||||||
|
|
@ -4612,15 +4655,28 @@ type AlertDrawerProps = {
|
||||||
alert: AlertEvent;
|
alert: AlertEvent;
|
||||||
flowPacket: FlowPacket | null;
|
flowPacket: FlowPacket | null;
|
||||||
evidence: EvidenceItem[];
|
evidence: EvidenceItem[];
|
||||||
|
contextStatus: AlertContextStatus;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps) => {
|
const formatOptionalMoney = (value: unknown): string | null => {
|
||||||
|
const parsed = parseNumber(value, Number.NaN);
|
||||||
|
return Number.isFinite(parsed) ? `$${formatPrice(parsed)}` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatOptionalMs = (value: unknown): string | null => {
|
||||||
|
const parsed = parseNumber(value, Number.NaN);
|
||||||
|
return Number.isFinite(parsed) ? `${Math.round(parsed)}ms` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: AlertDrawerProps) => {
|
||||||
const primary = alert.hits[0];
|
const primary = alert.hits[0];
|
||||||
const direction = deriveAlertDirection(alert);
|
const direction = deriveAlertDirection(alert);
|
||||||
const severity = normalizeAlertSeverity(alert);
|
const severity = normalizeAlertSeverity(alert);
|
||||||
const evidencePrints = evidence.filter((item) => item.kind === "print");
|
const evidencePrints = evidence.filter((item) => item.kind === "print");
|
||||||
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
||||||
|
const isContextLoading = contextStatus.traceId === alert.trace_id && contextStatus.loading;
|
||||||
|
const missingRefs = contextStatus.traceId === alert.trace_id ? contextStatus.missingRefs : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="drawer">
|
<aside className="drawer">
|
||||||
|
|
@ -4639,7 +4695,17 @@ const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps)
|
||||||
<span className={`pill severity-${severity}`}>{severity}</span>
|
<span className={`pill severity-${severity}`}>{severity}</span>
|
||||||
<span className="drawer-chip">Score {Math.round(alert.score)}</span>
|
<span className="drawer-chip">Score {Math.round(alert.score)}</span>
|
||||||
<span className={`pill direction-${direction}`}>{direction}</span>
|
<span className={`pill direction-${direction}`}>{direction}</span>
|
||||||
|
{isContextLoading ? <span className="drawer-chip">Loading context</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{isContextLoading ? (
|
||||||
|
<div className="drawer-section drawer-context-loading" aria-label="Loading persisted evidence">
|
||||||
|
<div className="drawer-skeleton drawer-skeleton-wide" />
|
||||||
|
<div className="drawer-skeleton" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{contextStatus.traceId === alert.trace_id && contextStatus.error ? (
|
||||||
|
<p className="drawer-empty">Persisted context could not be loaded: {contextStatus.error}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="drawer-section">
|
<div className="drawer-section">
|
||||||
<h4>Classifier hits</h4>
|
<h4>Classifier hits</h4>
|
||||||
|
|
@ -4692,14 +4758,14 @@ const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="drawer-empty">Flow packet not found in persisted alert context.</p>
|
<p className="drawer-empty">Persisted flow packet is not available for this alert.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="drawer-section">
|
<div className="drawer-section">
|
||||||
<h4>Evidence prints</h4>
|
<h4>Evidence prints</h4>
|
||||||
{evidencePrints.length === 0 ? (
|
{evidencePrints.length === 0 ? (
|
||||||
<p className="drawer-empty">No persisted evidence prints available yet.</p>
|
<p className="drawer-empty">Persisted evidence prints are not available for this alert.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="drawer-list">
|
<div className="drawer-list">
|
||||||
{evidencePrints.slice(0, 6).map((item) => (
|
{evidencePrints.slice(0, 6).map((item) => (
|
||||||
|
|
@ -4709,6 +4775,36 @@ const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps)
|
||||||
<span>${formatPrice(item.print.price)}</span>
|
<span>${formatPrice(item.print.price)}</span>
|
||||||
<span>{formatSize(item.print.size)}x</span>
|
<span>{formatSize(item.print.size)}x</span>
|
||||||
<span>{item.print.exchange}</span>
|
<span>{item.print.exchange}</span>
|
||||||
|
{item.print.execution_nbbo_side ? <span>Side {item.print.execution_nbbo_side}</span> : null}
|
||||||
|
{formatOptionalMs(item.print.execution_nbbo_age_ms) ? (
|
||||||
|
<span>Quote {formatOptionalMs(item.print.execution_nbbo_age_ms)}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="drawer-row-meta drawer-evidence-context">
|
||||||
|
{formatOptionalMoney(item.print.execution_nbbo_bid) ? (
|
||||||
|
<span>Bid {formatOptionalMoney(item.print.execution_nbbo_bid)}</span>
|
||||||
|
) : null}
|
||||||
|
{formatOptionalMoney(item.print.execution_nbbo_ask) ? (
|
||||||
|
<span>Ask {formatOptionalMoney(item.print.execution_nbbo_ask)}</span>
|
||||||
|
) : null}
|
||||||
|
{formatOptionalMoney(item.print.execution_nbbo_mid) ? (
|
||||||
|
<span>Mid {formatOptionalMoney(item.print.execution_nbbo_mid)}</span>
|
||||||
|
) : null}
|
||||||
|
{formatOptionalMoney(item.print.execution_nbbo_spread) ? (
|
||||||
|
<span>Spr {formatOptionalMoney(item.print.execution_nbbo_spread)}</span>
|
||||||
|
) : null}
|
||||||
|
{formatOptionalMoney(item.print.execution_underlying_spot) ? (
|
||||||
|
<span>Spot {formatOptionalMoney(item.print.execution_underlying_spot)}</span>
|
||||||
|
) : null}
|
||||||
|
{formatOptionalMoney(item.print.execution_underlying_bid) ? (
|
||||||
|
<span>U Bid {formatOptionalMoney(item.print.execution_underlying_bid)}</span>
|
||||||
|
) : null}
|
||||||
|
{formatOptionalMoney(item.print.execution_underlying_ask) ? (
|
||||||
|
<span>U Ask {formatOptionalMoney(item.print.execution_underlying_ask)}</span>
|
||||||
|
) : null}
|
||||||
|
{formatOptionalMoney(item.print.execution_underlying_mid) ? (
|
||||||
|
<span>U Mid {formatOptionalMoney(item.print.execution_underlying_mid)}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="drawer-note">{formatTime(item.print.ts)}</p>
|
<p className="drawer-note">{formatTime(item.print.ts)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4716,7 +4812,10 @@ const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{unknownCount > 0 ? (
|
{unknownCount > 0 ? (
|
||||||
<p className="drawer-empty">+{unknownCount} evidence prints unresolved from persisted context.</p>
|
<p className="drawer-empty">+{unknownCount} evidence refs unresolved in persisted context.</p>
|
||||||
|
) : null}
|
||||||
|
{missingRefs.length > 0 ? (
|
||||||
|
<p className="drawer-empty">Missing refs: {missingRefs.slice(0, 4).join(", ")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -4800,7 +4899,7 @@ const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierH
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="drawer-empty">Flow packet not found in persisted alert context.</p>
|
<p className="drawer-empty">Flow packet not in the current live cache.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -4824,7 +4923,7 @@ const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierH
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{unknownCount > 0 ? (
|
{unknownCount > 0 ? (
|
||||||
<p className="drawer-empty">+{unknownCount} evidence prints unresolved from persisted context.</p>
|
<p className="drawer-empty">+{unknownCount} evidence prints not in cache.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -4927,7 +5026,7 @@ const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDr
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{unknownCount > 0 ? (
|
{unknownCount > 0 ? (
|
||||||
<p className="drawer-empty">+{unknownCount} evidence prints unresolved from persisted context.</p>
|
<p className="drawer-empty">+{unknownCount} evidence prints not in cache.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -5039,7 +5138,7 @@ const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) =
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{unknownCount > 0 ? (
|
{unknownCount > 0 ? (
|
||||||
<p className="drawer-empty">+{unknownCount} evidence refs unresolved from persisted context.</p>
|
<p className="drawer-empty">+{unknownCount} evidence refs not in cache.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -5548,12 +5647,17 @@ const useTerminalState = () => {
|
||||||
const [pinnedEquityJoinMap, setPinnedEquityJoinMap] = useState<
|
const [pinnedEquityJoinMap, setPinnedEquityJoinMap] = useState<
|
||||||
Map<string, PinnedEntry<EquityPrintJoin>>
|
Map<string, PinnedEntry<EquityPrintJoin>>
|
||||||
>(() => new Map());
|
>(() => new Map());
|
||||||
|
const [selectedAlertContextStatus, setSelectedAlertContextStatus] = useState<AlertContextStatus>({
|
||||||
|
traceId: null,
|
||||||
|
loading: false,
|
||||||
|
missingRefs: [],
|
||||||
|
error: null
|
||||||
|
});
|
||||||
const [optionSupportSmartMoney, setOptionSupportSmartMoney] = useState<SmartMoneyEvent[]>([]);
|
const [optionSupportSmartMoney, setOptionSupportSmartMoney] = useState<SmartMoneyEvent[]>([]);
|
||||||
const [optionSupportClassifierHits, setOptionSupportClassifierHits] = useState<ClassifierHitEvent[]>([]);
|
const [optionSupportClassifierHits, setOptionSupportClassifierHits] = useState<ClassifierHitEvent[]>([]);
|
||||||
const [historicalNbboByTraceId, setHistoricalNbboByTraceId] = useState<Map<string, OptionNBBO | null>>(
|
const [historicalNbboByTraceId, setHistoricalNbboByTraceId] = useState<Map<string, OptionNBBO | null>>(
|
||||||
() => new Map()
|
() => new Map()
|
||||||
);
|
);
|
||||||
const [selectedAlertContextLoading, setSelectedAlertContextLoading] = useState(false);
|
|
||||||
|
|
||||||
const resolvedOptionPrintMap = useMemo(() => {
|
const resolvedOptionPrintMap = useMemo(() => {
|
||||||
const merged = new Map<string, OptionPrint>();
|
const merged = new Map<string, OptionPrint>();
|
||||||
|
|
@ -5595,116 +5699,66 @@ const useTerminalState = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedAlert) {
|
if (!selectedAlert) {
|
||||||
|
setSelectedAlertContextStatus({
|
||||||
|
traceId: null,
|
||||||
|
loading: false,
|
||||||
|
missingRefs: [],
|
||||||
|
error: null
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
|
||||||
setSelectedAlertContextLoading(true);
|
const abort = new AbortController();
|
||||||
void fetch(
|
setSelectedAlertContextStatus({
|
||||||
buildApiUrl(`/flow/alerts/${encodeURIComponent(selectedAlert.trace_id)}/context`)
|
traceId: selectedAlert.trace_id,
|
||||||
)
|
loading: true,
|
||||||
|
missingRefs: [],
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
incrementRetentionMetric("pinnedFetchMisses", selectedAlert.evidence_refs.length);
|
||||||
|
|
||||||
|
void fetch(buildApiUrl(buildAlertContextPath(selectedAlert.trace_id)), { signal: abort.signal })
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readErrorDetail(response));
|
throw new Error(await readErrorDetail(response));
|
||||||
}
|
}
|
||||||
return response.json() as Promise<{
|
return response.json();
|
||||||
flow_packets?: FlowPacket[];
|
|
||||||
option_prints?: OptionPrint[];
|
|
||||||
}>;
|
|
||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload: AlertContextBundle) => {
|
||||||
if (cancelled) {
|
if (abort.signal.aborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { packets, prints } = collectAlertContextEvidence(payload);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const nextPackets = new Map<string, FlowPacket>();
|
if (packets.size > 0) {
|
||||||
for (const packet of payload.flow_packets ?? []) {
|
setPinnedFlowPacketMap((prev) => upsertPinnedEntries(prev, packets, now));
|
||||||
nextPackets.set(packet.id, packet);
|
|
||||||
}
|
}
|
||||||
const nextPrints = new Map<string, OptionPrint>();
|
if (prints.size > 0) {
|
||||||
for (const print of payload.option_prints ?? []) {
|
setPinnedOptionPrintMap((prev) => upsertPinnedEntries(prev, prints, now));
|
||||||
if (print.trace_id) {
|
|
||||||
nextPrints.set(print.trace_id, print);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (nextPackets.size > 0) {
|
|
||||||
setPinnedFlowPacketMap((prev) => upsertPinnedEntries(prev, nextPackets, now));
|
|
||||||
}
|
|
||||||
if (nextPrints.size > 0) {
|
|
||||||
setPinnedOptionPrintMap((prev) => upsertPinnedEntries(prev, nextPrints, now));
|
|
||||||
}
|
}
|
||||||
|
setSelectedAlertContextStatus({
|
||||||
|
traceId: selectedAlert.trace_id,
|
||||||
|
loading: false,
|
||||||
|
missingRefs: payload.missing_refs ?? [],
|
||||||
|
error: null
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
incrementRetentionMetric("pinnedFetchFailures", 1);
|
if (abort.signal.aborted) {
|
||||||
console.warn("Failed to fetch alert context", error);
|
return;
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setSelectedAlertContextLoading(false);
|
|
||||||
}
|
}
|
||||||
|
incrementRetentionMetric("pinnedFetchFailures", 1);
|
||||||
|
console.warn("Failed to fetch persisted alert context", error);
|
||||||
|
setSelectedAlertContextStatus({
|
||||||
|
traceId: selectedAlert.trace_id,
|
||||||
|
loading: false,
|
||||||
|
missingRefs: [],
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const packetId = selectedAlert.evidence_refs[0];
|
return () => abort.abort();
|
||||||
if (packetId && !resolvedFlowPacketMap.has(packetId)) {
|
}, [selectedAlert]);
|
||||||
incrementRetentionMetric("pinnedFetchMisses", 1);
|
|
||||||
void fetch(buildApiUrl(`/flow/packets/${encodeURIComponent(packetId)}`))
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await readErrorDetail(response));
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then((payload: { data?: FlowPacket | null }) => {
|
|
||||||
if (!payload.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
const next = new Map<string, FlowPacket>([[payload.data.id, payload.data]]);
|
|
||||||
setPinnedFlowPacketMap((prev) => upsertPinnedEntries(prev, next, now));
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
incrementRetentionMetric("pinnedFetchFailures", 1);
|
|
||||||
console.warn("Failed to fetch flow packet evidence", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const missingPrintIds = selectedAlert.evidence_refs.filter(
|
|
||||||
(id) => !resolvedFlowPacketMap.has(id) && !resolvedOptionPrintMap.has(id)
|
|
||||||
);
|
|
||||||
if (missingPrintIds.length > 0) {
|
|
||||||
incrementRetentionMetric("pinnedFetchMisses", missingPrintIds.length);
|
|
||||||
const url = new URL(buildApiUrl("/option-prints/by-trace"));
|
|
||||||
for (const traceId of missingPrintIds) {
|
|
||||||
url.searchParams.append("trace_id", traceId);
|
|
||||||
}
|
|
||||||
void fetch(url.toString())
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await readErrorDetail(response));
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then((payload: { data?: OptionPrint[] }) => {
|
|
||||||
const next = new Map<string, OptionPrint>();
|
|
||||||
for (const item of payload.data ?? []) {
|
|
||||||
if (!item || !item.trace_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next.set(item.trace_id, item);
|
|
||||||
}
|
|
||||||
if (next.size > 0) {
|
|
||||||
const now = Date.now();
|
|
||||||
setPinnedOptionPrintMap((prev) => upsertPinnedEntries(prev, next, now));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
incrementRetentionMetric("pinnedFetchFailures", 1);
|
|
||||||
console.warn("Failed to fetch option print evidence", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [selectedAlert, resolvedFlowPacketMap, resolvedOptionPrintMap]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDarkEvent || mode !== "live") {
|
if (!selectedDarkEvent || mode !== "live") {
|
||||||
|
|
@ -6851,6 +6905,7 @@ const useTerminalState = () => {
|
||||||
packetIdByOptionTraceId,
|
packetIdByOptionTraceId,
|
||||||
classifierDecorByOptionTraceId,
|
classifierDecorByOptionTraceId,
|
||||||
selectedEvidence,
|
selectedEvidence,
|
||||||
|
selectedAlertContextStatus,
|
||||||
selectedFlowPacket,
|
selectedFlowPacket,
|
||||||
selectedDarkEvidence,
|
selectedDarkEvidence,
|
||||||
selectedDarkUnderlying,
|
selectedDarkUnderlying,
|
||||||
|
|
@ -8564,6 +8619,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
||||||
alert={state.selectedAlert}
|
alert={state.selectedAlert}
|
||||||
flowPacket={state.selectedFlowPacket}
|
flowPacket={state.selectedFlowPacket}
|
||||||
evidence={state.selectedEvidence}
|
evidence={state.selectedEvidence}
|
||||||
|
contextStatus={state.selectedAlertContextStatus}
|
||||||
onClose={() => state.setSelectedAlert(null)}
|
onClose={() => state.setSelectedAlert(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
194
docs/turns/2026-05-17-1101-clickhouse-alert-context.html
Normal file
194
docs/turns/2026-05-17-1101-clickhouse-alert-context.html
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>ClickHouse Alert Context Hydration</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #06080b;
|
||||||
|
--panel: #111820;
|
||||||
|
--panel-soft: #0d141b;
|
||||||
|
--border: rgba(255, 255, 255, 0.12);
|
||||||
|
--text: #e6edf4;
|
||||||
|
--dim: #90a0b2;
|
||||||
|
--faint: #6e7b8c;
|
||||||
|
--accent: #f5a623;
|
||||||
|
--accent-soft: rgba(245, 166, 35, 0.14);
|
||||||
|
--ok: #25c17a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 42px 22px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding-bottom: 22px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
max-width: 74ch;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 2px 5px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid rgba(245, 166, 35, 0.28);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border: 1px solid rgba(37, 193, 122, 0.34);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--ok);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<h1>ClickHouse Alert Context Hydration</h1>
|
||||||
|
<p class="summary">
|
||||||
|
Alert detail drawers now fetch persisted investigative context from ClickHouse by alert trace id, then merge linked flow packets and option prints into the existing pinned evidence maps.
|
||||||
|
</p>
|
||||||
|
<span class="status">Validated</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Summary</h2>
|
||||||
|
<p>
|
||||||
|
This change makes alert details durable. Selecting an alert no longer depends only on the live cache to resolve evidence; the terminal asks the API for a ClickHouse-backed alert context bundle and uses that bundle to populate the existing drawer, classifier support, smart-money support, and prefetch evidence stores.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Changes Made</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Added <code>fetchAlertContextByTraceId</code> in storage to load an alert, linked flow packets, linked option prints, and unresolved evidence refs.</li>
|
||||||
|
<li>Added <code>GET /flow/alerts/:trace_id/context</code> to the API without changing existing alert list, history, replay, or websocket feeds.</li>
|
||||||
|
<li>Updated the terminal alert selection effect to fetch persisted context in live, replay, and history modes.</li>
|
||||||
|
<li>Merged hydrated packets and prints into pinned maps so existing evidence consumers share the resolved context.</li>
|
||||||
|
<li>Adjusted alert drawer copy and loading state to reference persisted context rather than live cache misses.</li>
|
||||||
|
<li>Expanded alert evidence print rows with execution NBBO side, bid, ask, mid, spread, quote age, underlying spot, bid, ask, and mid where available.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Context</h2>
|
||||||
|
<p>
|
||||||
|
Alert rows intentionally remain lightweight for live bursts. The detail drawer is the right place to hydrate heavier investigative context because it runs only when a user asks for a specific alert. The authoritative linkage remains <code>AlertEvent.evidence_refs</code>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Important Implementation Details</h2>
|
||||||
|
<p>The new API response shape is:</p>
|
||||||
|
<pre><code>{
|
||||||
|
alert: AlertEvent | null,
|
||||||
|
flow_packets: FlowPacket[],
|
||||||
|
option_prints: OptionPrint[],
|
||||||
|
missing_refs: string[]
|
||||||
|
}</code></pre>
|
||||||
|
<p>
|
||||||
|
Flow packet refs are resolved with both prefixed and unprefixed candidates. Option print refs are resolved by <code>trace_id</code>. Missing refs are returned explicitly instead of failing the whole response.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Expected Impact for End-Users</h2>
|
||||||
|
<p>
|
||||||
|
Alert details should feel more trustworthy after cache churn or replay navigation. Users can select an older or non-hot alert and still see the preserved evidence context needed to evaluate the signal.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Validation</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>bun test packages/storage/tests</code></li>
|
||||||
|
<li><code>bun test services/api/tests</code></li>
|
||||||
|
<li><code>bun test apps/web/app/terminal.test.ts</code></li>
|
||||||
|
<li><code>bun --cwd=apps/web run build</code></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Issues, Limitations, and Mitigations</h2>
|
||||||
|
<ul>
|
||||||
|
<li>The endpoint is detail-time only, which avoids making alert list payloads heavier during bursts.</li>
|
||||||
|
<li>Malformed trace ids are rejected by route-level validation.</li>
|
||||||
|
<li>Missing evidence refs remain visible to the drawer as diagnostics rather than hiding partial context.</li>
|
||||||
|
<li>No schema migration was needed because option prints already persist execution context fields.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Follow-up Work</h2>
|
||||||
|
<p>No follow-up beads issue was filed. The requested storage, API, frontend, tests, build, and documentation work is complete.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
151
docs/turns/2026-05-17-deploy-allowlist-pr-packaging.html
Normal file
151
docs/turns/2026-05-17-deploy-allowlist-pr-packaging.html
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Turn Document - Deploy Allowlist PR Packaging</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0a1118;
|
||||||
|
--panel: #121b24;
|
||||||
|
--panel-2: #0d151e;
|
||||||
|
--border: rgba(255, 255, 255, 0.14);
|
||||||
|
--text: #e6edf3;
|
||||||
|
--muted: #95a8bb;
|
||||||
|
--accent: #89d1ff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", Tahoma, sans-serif;
|
||||||
|
background: linear-gradient(180deg, #09121a 0%, #060b10 100%);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
width: min(960px, calc(100vw - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 0 40px;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
h1, h2 { margin-top: 0; }
|
||||||
|
h2 { font-size: 1rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||||
|
p, li { line-height: 1.6; }
|
||||||
|
code, pre { font-family: "IBM Plex Mono", Menlo, monospace; }
|
||||||
|
code { color: var(--accent); }
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.meta { color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<h1>Deploy Allowlist PR Packaging</h1>
|
||||||
|
<p>
|
||||||
|
Packaged the deploy allowlist cleanup into a PR-ready branch with multiple commits, documented all changes,
|
||||||
|
and tracked work in Beads issue <code>islandflow-9j5</code>.
|
||||||
|
</p>
|
||||||
|
<p class="meta">Generated: 2026-05-17 11:48 EDT</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Summary</h2>
|
||||||
|
<p>
|
||||||
|
Removed <code>deployment/npm/</code> from the deploy script's remote untracked allowlist so deploy preflight
|
||||||
|
only tolerates the required signal-cli tarball artifact.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Changes Made</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Updated <code>scripts/deploy.ts</code> to tighten <code>ALLOWED_REMOTE_UNTRACKED</code>.</li>
|
||||||
|
<li>Created this turn document in <code>docs/turns/</code> as required by repository workflow.</li>
|
||||||
|
<li>Tracked and managed the work through Beads issue <code>islandflow-9j5</code>.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Context</h2>
|
||||||
|
<p>
|
||||||
|
The deploy preflight checks remote repository cleanliness before rollout. Keeping broad allowlist exceptions
|
||||||
|
can hide stale or accidental files on the target host and reduce deployment confidence.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Important Implementation Details</h2>
|
||||||
|
<p>
|
||||||
|
The allowlist now contains only:
|
||||||
|
</p>
|
||||||
|
<pre><code>deployment/docker/signal-cli-0.14.3-Linux-native.tar.gz</code></pre>
|
||||||
|
<p>
|
||||||
|
The removed entry:
|
||||||
|
</p>
|
||||||
|
<pre><code>deployment/npm/</code></pre>
|
||||||
|
<p>
|
||||||
|
This change ensures remote preflight fails if <code>deployment/npm/</code> appears unexpectedly.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Expected Impact for End-Users</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Deployments should fail faster when unexpected remote workspace artifacts exist.</li>
|
||||||
|
<li>Operators get stricter hygiene checks before production rollouts.</li>
|
||||||
|
<li>No runtime behavior change to API/web/services outside deploy validation logic.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Validation</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<code>bun test</code> was run for the repository and reported 2 failing tests plus 1 module-loading error:
|
||||||
|
<code>services/api/tests/live.test.ts</code> (hot-head cap expectation mismatch) and
|
||||||
|
<code>apps/web/app/terminal.test.ts</code> (Next navigation export mismatch).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The user requested skipping dependency-install remediation before completion, so no additional test-fix work
|
||||||
|
was performed in this turn.
|
||||||
|
</li>
|
||||||
|
<li><code>git diff</code> review to confirm only intended allowlist and documentation updates were included.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Issues, Limitations, and Mitigations</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
This turn did not add new deploy integration tests for the allowlist branch logic. Mitigation: kept the
|
||||||
|
change scoped to one constant and validated via repository test run plus manual diff inspection.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
A local untracked signal-cli tarball remains in the working tree by design and was not added to Git.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Follow-up Work</h2>
|
||||||
|
<ul>
|
||||||
|
<li>No additional follow-up issues were created from this scoped cleanup.</li>
|
||||||
|
<li>If full CI confidence is required, run <code>bun install</code> and <code>bun test</code> in a dependency-ready environment.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -746,6 +746,13 @@ export type EquityPrintQueryFilters = {
|
||||||
sinceTs?: number;
|
sinceTs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AlertContextBundle = {
|
||||||
|
alert: AlertEvent | null;
|
||||||
|
flow_packets: FlowPacket[];
|
||||||
|
option_prints: OptionPrint[];
|
||||||
|
missing_refs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
const buildOptionPrintFilterConditions = (
|
const buildOptionPrintFilterConditions = (
|
||||||
filters: OptionPrintQueryFilters | undefined,
|
filters: OptionPrintQueryFilters | undefined,
|
||||||
tracePrefix: string | undefined
|
tracePrefix: string | undefined
|
||||||
|
|
@ -1200,6 +1207,101 @@ export const fetchRecentAlerts = async (
|
||||||
return AlertEventSchema.array().parse(alerts);
|
return AlertEventSchema.array().parse(alerts);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeAlertEvidenceRefs = (refs: string[]): string[] => {
|
||||||
|
return Array.from(new Set(refs.map((ref) => ref.trim()).filter(Boolean)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const flowPacketCandidatesFromRef = (ref: string): string[] => {
|
||||||
|
if (!ref) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (ref.startsWith("flowpacket:")) {
|
||||||
|
const raw = ref.slice("flowpacket:".length);
|
||||||
|
return raw ? [ref, raw] : [ref];
|
||||||
|
}
|
||||||
|
return [ref, `flowpacket:${ref}`];
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionPrintCandidatesFromRef = (ref: string): string[] => {
|
||||||
|
if (!ref || ref.startsWith("flowpacket:")) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [ref];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAlertContextByTraceId = async (
|
||||||
|
client: ClickHouseClient,
|
||||||
|
traceId: string
|
||||||
|
): Promise<AlertContextBundle> => {
|
||||||
|
const normalizedTraceId = traceId.trim();
|
||||||
|
if (!normalizedTraceId) {
|
||||||
|
return {
|
||||||
|
alert: null,
|
||||||
|
flow_packets: [],
|
||||||
|
option_prints: [],
|
||||||
|
missing_refs: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertResult = await client.query({
|
||||||
|
query: `SELECT * FROM ${ALERTS_TABLE} WHERE trace_id = ${quoteString(normalizedTraceId)} ORDER BY source_ts DESC, seq DESC LIMIT 1`,
|
||||||
|
format: "JSONEachRow"
|
||||||
|
});
|
||||||
|
const alertRows = await alertResult.json<unknown[]>();
|
||||||
|
const alertRecord = alertRows
|
||||||
|
.map(normalizeAlertRow)
|
||||||
|
.find((record): record is AlertRecord => record !== null);
|
||||||
|
const alert = alertRecord ? AlertEventSchema.parse(fromAlertRecord(alertRecord)) : null;
|
||||||
|
|
||||||
|
if (!alert) {
|
||||||
|
return {
|
||||||
|
alert: null,
|
||||||
|
flow_packets: [],
|
||||||
|
option_prints: [],
|
||||||
|
missing_refs: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const refs = normalizeAlertEvidenceRefs(alert.evidence_refs);
|
||||||
|
const packetLookupIds = Array.from(new Set(refs.flatMap(flowPacketCandidatesFromRef)));
|
||||||
|
const printLookupIds = Array.from(new Set(refs.flatMap(optionPrintCandidatesFromRef)));
|
||||||
|
|
||||||
|
const [flowPackets, optionPrints] = await Promise.all([
|
||||||
|
packetLookupIds.length > 0
|
||||||
|
? client
|
||||||
|
.query({
|
||||||
|
query: `SELECT * FROM ${FLOW_PACKETS_TABLE} WHERE id IN (${buildStringList(packetLookupIds)}) ORDER BY source_ts DESC, seq DESC LIMIT ${clampLookupLimit(packetLookupIds.length)}`,
|
||||||
|
format: "JSONEachRow"
|
||||||
|
})
|
||||||
|
.then(async (result) => {
|
||||||
|
const rows = await result.json<unknown[]>();
|
||||||
|
const records = rows
|
||||||
|
.map(normalizeFlowPacketRow)
|
||||||
|
.filter((record): record is FlowPacketRecord => record !== null);
|
||||||
|
return FlowPacketSchema.array().parse(records.map(fromFlowPacketRecord));
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
printLookupIds.length > 0
|
||||||
|
? fetchOptionPrintsByTraceIds(client, printLookupIds)
|
||||||
|
: Promise.resolve([])
|
||||||
|
]);
|
||||||
|
|
||||||
|
const packetIds = new Set(flowPackets.flatMap((packet) => [packet.id, packet.trace_id]));
|
||||||
|
const printIds = new Set(optionPrints.map((print) => print.trace_id));
|
||||||
|
const missingRefs = refs.filter((ref) => {
|
||||||
|
const packetResolved = flowPacketCandidatesFromRef(ref).some((candidate) => packetIds.has(candidate));
|
||||||
|
const printResolved = optionPrintCandidatesFromRef(ref).some((candidate) => printIds.has(candidate));
|
||||||
|
return !packetResolved && !printResolved;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
alert,
|
||||||
|
flow_packets: flowPackets,
|
||||||
|
option_prints: optionPrints,
|
||||||
|
missing_refs: missingRefs
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchOptionPrintsAfter = async (
|
export const fetchOptionPrintsAfter = async (
|
||||||
client: ClickHouseClient,
|
client: ClickHouseClient,
|
||||||
afterTs: number,
|
afterTs: number,
|
||||||
|
|
@ -1846,55 +1948,6 @@ export const fetchOptionPrintsByTraceIds = async (
|
||||||
return OptionPrintSchema.array().parse(rows.map(normalizeOptionRow));
|
return OptionPrintSchema.array().parse(rows.map(normalizeOptionRow));
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AlertContextBundle = {
|
|
||||||
alert: AlertEvent | null;
|
|
||||||
flow_packets: FlowPacket[];
|
|
||||||
option_prints: OptionPrint[];
|
|
||||||
missing_refs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchAlertContextByTraceId = async (
|
|
||||||
client: ClickHouseClient,
|
|
||||||
traceId: string
|
|
||||||
): Promise<AlertContextBundle> => {
|
|
||||||
const normalizedTraceId = traceId.trim();
|
|
||||||
if (!normalizedTraceId) {
|
|
||||||
return { alert: null, flow_packets: [], option_prints: [], missing_refs: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const alertResult = await client.query({
|
|
||||||
query: `SELECT * FROM ${ALERTS_TABLE} WHERE trace_id = ${quoteString(normalizedTraceId)} ORDER BY source_ts DESC, seq DESC LIMIT 1`,
|
|
||||||
format: "JSONEachRow"
|
|
||||||
});
|
|
||||||
const alertRows = await alertResult.json<unknown[]>();
|
|
||||||
const alertRecord = alertRows
|
|
||||||
.map(normalizeAlertRow)
|
|
||||||
.find((row): row is AlertRecord => row !== null);
|
|
||||||
const alert = alertRecord ? AlertEventSchema.parse(fromAlertRecord(alertRecord)) : null;
|
|
||||||
if (!alert) {
|
|
||||||
return { alert: null, flow_packets: [], option_prints: [], missing_refs: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const refs = Array.from(new Set(alert.evidence_refs.map((id) => id.trim()).filter(Boolean)));
|
|
||||||
const packetIds = refs.filter((id) => id.startsWith("flowpacket:"));
|
|
||||||
const printIds = refs.filter((id) => !id.startsWith("flowpacket:"));
|
|
||||||
const [flow_packets, option_prints] = await Promise.all([
|
|
||||||
packetIds.length > 0
|
|
||||||
? fetchFlowPacketsByIds(client, packetIds)
|
|
||||||
: Promise.resolve([] as FlowPacket[]),
|
|
||||||
printIds.length > 0
|
|
||||||
? fetchOptionPrintsByTraceIds(client, printIds)
|
|
||||||
: Promise.resolve([] as OptionPrint[])
|
|
||||||
]);
|
|
||||||
|
|
||||||
const resolvedRefs = new Set<string>([
|
|
||||||
...flow_packets.map((packet) => packet.id),
|
|
||||||
...option_prints.map((print) => print.trace_id)
|
|
||||||
]);
|
|
||||||
const missing_refs = refs.filter((id) => !resolvedRefs.has(id));
|
|
||||||
return { alert, flow_packets, option_prints, missing_refs };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchEquityPrintJoinsByIds = async (
|
export const fetchEquityPrintJoinsByIds = async (
|
||||||
client: ClickHouseClient,
|
client: ClickHouseClient,
|
||||||
ids: string[]
|
ids: string[]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import type { ClickHouseClient } from "../src/clickhouse";
|
||||||
import { alertsTableDDL, ALERTS_TABLE, fromAlertRecord, toAlertRecord } from "../src/alerts";
|
import { alertsTableDDL, ALERTS_TABLE, fromAlertRecord, toAlertRecord } from "../src/alerts";
|
||||||
|
import { fetchAlertContextByTraceId } from "../src/clickhouse";
|
||||||
|
import { toFlowPacketRecord } from "../src/flow-packets";
|
||||||
|
|
||||||
const alert = {
|
const alert = {
|
||||||
source_ts: 10,
|
source_ts: 10,
|
||||||
|
|
@ -19,6 +22,62 @@ const alert = {
|
||||||
evidence_refs: ["flowpacket:1", "print:1"]
|
evidence_refs: ["flowpacket:1", "print:1"]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const packet = {
|
||||||
|
source_ts: 11,
|
||||||
|
ingest_ts: 21,
|
||||||
|
seq: 2,
|
||||||
|
trace_id: "flowpacket:1",
|
||||||
|
id: "flowpacket:1",
|
||||||
|
members: ["print:1"],
|
||||||
|
features: {
|
||||||
|
option_contract_id: "SPY-2026-06-19-500-C",
|
||||||
|
count: 1,
|
||||||
|
total_size: 50
|
||||||
|
},
|
||||||
|
join_quality: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const print = {
|
||||||
|
source_ts: 12,
|
||||||
|
ingest_ts: 22,
|
||||||
|
seq: 3,
|
||||||
|
trace_id: "print:1",
|
||||||
|
ts: 12,
|
||||||
|
option_contract_id: "SPY-2026-06-19-500-C",
|
||||||
|
price: 1.45,
|
||||||
|
size: 50,
|
||||||
|
exchange: "XTEST",
|
||||||
|
conditions: [],
|
||||||
|
nbbo_side: "A",
|
||||||
|
execution_nbbo_bid: 1.4,
|
||||||
|
execution_nbbo_ask: 1.5,
|
||||||
|
execution_nbbo_mid: 1.45,
|
||||||
|
execution_nbbo_spread: 0.1,
|
||||||
|
execution_nbbo_age_ms: 14,
|
||||||
|
execution_nbbo_side: "A",
|
||||||
|
execution_underlying_spot: 500.25,
|
||||||
|
execution_underlying_bid: 500.2,
|
||||||
|
execution_underlying_ask: 500.3,
|
||||||
|
execution_underlying_mid: 500.25,
|
||||||
|
execution_underlying_age_ms: 9,
|
||||||
|
execution_iv: 0.31,
|
||||||
|
signal_reasons: [],
|
||||||
|
signal_pass: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeClient = (resolver: (query: string) => unknown[]): ClickHouseClient =>
|
||||||
|
({
|
||||||
|
exec: async () => {},
|
||||||
|
insert: async () => {},
|
||||||
|
ping: async () => ({ success: true }),
|
||||||
|
close: async () => {},
|
||||||
|
query: async ({ query }: { query: string }) => ({
|
||||||
|
async json<T>() {
|
||||||
|
return resolver(query) as T;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}) as ClickHouseClient;
|
||||||
|
|
||||||
describe("alerts storage helpers", () => {
|
describe("alerts storage helpers", () => {
|
||||||
it("includes the correct table name in the DDL", () => {
|
it("includes the correct table name in the DDL", () => {
|
||||||
const ddl = alertsTableDDL();
|
const ddl = alertsTableDDL();
|
||||||
|
|
@ -33,4 +92,51 @@ describe("alerts storage helpers", () => {
|
||||||
expect(restored.evidence_refs).toEqual(alert.evidence_refs);
|
expect(restored.evidence_refs).toEqual(alert.evidence_refs);
|
||||||
expect(restored.severity).toBe(alert.severity);
|
expect(restored.severity).toBe(alert.severity);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fetches persisted alert context and reports unresolved refs", async () => {
|
||||||
|
const contextAlert = {
|
||||||
|
...alert,
|
||||||
|
trace_id: "alert:ctx",
|
||||||
|
evidence_refs: ["flowpacket:1", "print:1", "print:missing"]
|
||||||
|
};
|
||||||
|
const queries: string[] = [];
|
||||||
|
const client = makeClient((query) => {
|
||||||
|
queries.push(query);
|
||||||
|
if (query.includes(ALERTS_TABLE)) {
|
||||||
|
return [toAlertRecord(contextAlert)];
|
||||||
|
}
|
||||||
|
if (query.includes("flow_packets")) {
|
||||||
|
return [toFlowPacketRecord(packet)];
|
||||||
|
}
|
||||||
|
if (query.includes("option_prints")) {
|
||||||
|
return [print];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const bundle = await fetchAlertContextByTraceId(client, "alert:ctx");
|
||||||
|
|
||||||
|
expect(bundle.alert?.trace_id).toBe("alert:ctx");
|
||||||
|
expect(bundle.flow_packets.map((item) => item.id)).toEqual(["flowpacket:1"]);
|
||||||
|
expect(bundle.option_prints.map((item) => item.trace_id)).toEqual(["print:1"]);
|
||||||
|
expect(bundle.option_prints[0]?.execution_nbbo_side).toBe("A");
|
||||||
|
expect(bundle.option_prints[0]?.execution_nbbo_bid).toBe(1.4);
|
||||||
|
expect(bundle.option_prints[0]?.execution_underlying_spot).toBe(500.25);
|
||||||
|
expect(bundle.option_prints[0]?.execution_iv).toBe(0.31);
|
||||||
|
expect(bundle.missing_refs).toEqual(["print:missing"]);
|
||||||
|
expect(queries[0]).toContain("trace_id = 'alert:ctx'");
|
||||||
|
expect(queries[1]).toContain("id IN");
|
||||||
|
expect(queries[2]).toContain("trace_id IN ('print:1', 'print:missing')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty context when the alert is missing", async () => {
|
||||||
|
const bundle = await fetchAlertContextByTraceId(makeClient(() => []), "alert:missing");
|
||||||
|
|
||||||
|
expect(bundle).toEqual({
|
||||||
|
alert: null,
|
||||||
|
flow_packets: [],
|
||||||
|
option_prints: [],
|
||||||
|
missing_refs: []
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,7 @@ const SSH_OPTIONS = [
|
||||||
"BatchMode=yes"
|
"BatchMode=yes"
|
||||||
];
|
];
|
||||||
const ALLOWED_REMOTE_UNTRACKED = new Set([
|
const ALLOWED_REMOTE_UNTRACKED = new Set([
|
||||||
"deployment/docker/signal-cli-0.14.3-Linux-native.tar.gz",
|
"deployment/docker/signal-cli-0.14.3-Linux-native.tar.gz"
|
||||||
"deployment/npm/"
|
|
||||||
]);
|
]);
|
||||||
const PUBLIC_APP_URL =
|
const PUBLIC_APP_URL =
|
||||||
process.env.DEPLOY_PUBLIC_APP_URL?.trim() || "https://flow.deltaisland.io";
|
process.env.DEPLOY_PUBLIC_APP_URL?.trim() || "https://flow.deltaisland.io";
|
||||||
|
|
|
||||||
21
services/api/src/alert-context.ts
Normal file
21
services/api/src/alert-context.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const alertContextTraceIdSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(256)
|
||||||
|
.regex(/^[A-Za-z0-9][A-Za-z0-9:_./-]*$/);
|
||||||
|
|
||||||
|
export const isAlertContextPath = (pathname: string): boolean => {
|
||||||
|
return /^\/flow\/alerts\/[^/]+\/context$/.test(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseAlertContextTraceIdPath = (pathname: string): string | null => {
|
||||||
|
if (!isAlertContextPath(pathname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedTraceId = pathname.slice("/flow/alerts/".length, -"/context".length);
|
||||||
|
return alertContextTraceIdSchema.parse(decodeURIComponent(encodedTraceId));
|
||||||
|
};
|
||||||
|
|
@ -47,6 +47,7 @@ import {
|
||||||
ensureOptionPrintsTable,
|
ensureOptionPrintsTable,
|
||||||
fetchAlertsAfter,
|
fetchAlertsAfter,
|
||||||
fetchAlertsBefore,
|
fetchAlertsBefore,
|
||||||
|
fetchAlertContextByTraceId,
|
||||||
fetchClassifierHitsAfter,
|
fetchClassifierHitsAfter,
|
||||||
fetchClassifierHitsBefore,
|
fetchClassifierHitsBefore,
|
||||||
fetchSmartMoneyEventsAfter,
|
fetchSmartMoneyEventsAfter,
|
||||||
|
|
@ -119,6 +120,7 @@ import {
|
||||||
resolveLiveStateConfig,
|
resolveLiveStateConfig,
|
||||||
shouldFanoutLiveEvent
|
shouldFanoutLiveEvent
|
||||||
} from "./live";
|
} from "./live";
|
||||||
|
import { isAlertContextPath, parseAlertContextTraceIdPath } from "./alert-context";
|
||||||
import { parseOptionPrintQuery } from "./option-queries";
|
import { parseOptionPrintQuery } from "./option-queries";
|
||||||
import {
|
import {
|
||||||
buildSyntheticDerivedStatus,
|
buildSyntheticDerivedStatus,
|
||||||
|
|
@ -1488,6 +1490,25 @@ const run = async () => {
|
||||||
return jsonResponse({ data });
|
return jsonResponse({ data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && isAlertContextPath(url.pathname)) {
|
||||||
|
try {
|
||||||
|
const traceId = parseAlertContextTraceIdPath(url.pathname);
|
||||||
|
if (traceId === null) {
|
||||||
|
return jsonResponse({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
const data = await fetchAlertContextByTraceId(clickhouse, traceId);
|
||||||
|
return jsonResponse(data);
|
||||||
|
} catch (error) {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: "invalid alert context query",
|
||||||
|
detail: error instanceof Error ? error.message : String(error)
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/history/options") {
|
if (req.method === "GET" && url.pathname === "/history/options") {
|
||||||
try {
|
try {
|
||||||
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
|
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
|
||||||
|
|
|
||||||
18
services/api/tests/alert-context.test.ts
Normal file
18
services/api/tests/alert-context.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { isAlertContextPath, parseAlertContextTraceIdPath } from "../src/alert-context";
|
||||||
|
|
||||||
|
describe("alert context route helpers", () => {
|
||||||
|
it("extracts a valid alert trace id from the context endpoint path", () => {
|
||||||
|
expect(parseAlertContextTraceIdPath("/flow/alerts/alert%3Actx%2Fone/context")).toBe("alert:ctx/one");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unrelated alert paths", () => {
|
||||||
|
expect(isAlertContextPath("/flow/alerts")).toBe(false);
|
||||||
|
expect(parseAlertContextTraceIdPath("/flow/alerts/alert:ctx")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed trace ids safely", () => {
|
||||||
|
expect(() => parseAlertContextTraceIdPath("/flow/alerts/%20/context")).toThrow();
|
||||||
|
expect(() => parseAlertContextTraceIdPath("/flow/alerts/%24bad/context")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue