diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 46f20bb..64b6f16 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1818,6 +1818,28 @@ h3 { 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 { padding: 12px 14px; border-radius: 12px; @@ -1825,6 +1847,15 @@ h3 { background: var(--bg-soft); } +@keyframes drawer-skeleton { + 0% { + background-position: 100% 0; + } + 100% { + background-position: -100% 0; + } +} + @keyframes pulse { 0% { transform: scale(1); diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index b6214eb..2be3da8 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -3,9 +3,11 @@ import { getSubscriptionKey as getLiveSubscriptionKey } from "@islandflow/types" import { NAV_ITEMS, appendHistoryTail, + buildAlertContextPath, buildDefaultFlowFilters, buildOptionTapeQueryParams, classifierToneForFamily, + collectAlertContextEvidence, composeTapeItems, deriveAlertDirection, 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", () => { it("includes only tape channels on /tape", () => { const filters = buildDefaultFlowFilters(); diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index ac2f778..e1ee74c 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -4604,6 +4604,49 @@ type EvidenceItem = | { kind: "print"; id: string; print: OptionPrint } | { 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; + prints: Map; +} => { + const packets = new Map(); + const prints = new Map(); + + 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 = | { kind: "join"; id: string; join: EquityPrintJoin } | { kind: "unknown"; id: string }; @@ -4612,15 +4655,28 @@ type AlertDrawerProps = { alert: AlertEvent; flowPacket: FlowPacket | null; evidence: EvidenceItem[]; + contextStatus: AlertContextStatus; 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 direction = deriveAlertDirection(alert); const severity = normalizeAlertSeverity(alert); const evidencePrints = evidence.filter((item) => item.kind === "print"); 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 ( @@ -4800,7 +4899,7 @@ const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierH

) : ( -

Flow packet not found in persisted alert context.

+

Flow packet not in the current live cache.

)} @@ -4824,7 +4923,7 @@ const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierH )} {unknownCount > 0 ? ( -

+{unknownCount} evidence prints unresolved from persisted context.

+

+{unknownCount} evidence prints not in cache.

) : null} @@ -4927,7 +5026,7 @@ const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDr )} {unknownCount > 0 ? ( -

+{unknownCount} evidence prints unresolved from persisted context.

+

+{unknownCount} evidence prints not in cache.

) : null} @@ -5039,7 +5138,7 @@ const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) = )} {unknownCount > 0 ? ( -

+{unknownCount} evidence refs unresolved from persisted context.

+

+{unknownCount} evidence refs not in cache.

) : null} @@ -5548,12 +5647,17 @@ const useTerminalState = () => { const [pinnedEquityJoinMap, setPinnedEquityJoinMap] = useState< Map> >(() => new Map()); + const [selectedAlertContextStatus, setSelectedAlertContextStatus] = useState({ + traceId: null, + loading: false, + missingRefs: [], + error: null + }); const [optionSupportSmartMoney, setOptionSupportSmartMoney] = useState([]); const [optionSupportClassifierHits, setOptionSupportClassifierHits] = useState([]); const [historicalNbboByTraceId, setHistoricalNbboByTraceId] = useState>( () => new Map() ); - const [selectedAlertContextLoading, setSelectedAlertContextLoading] = useState(false); const resolvedOptionPrintMap = useMemo(() => { const merged = new Map(); @@ -5595,116 +5699,66 @@ const useTerminalState = () => { useEffect(() => { if (!selectedAlert) { + setSelectedAlertContextStatus({ + traceId: null, + loading: false, + missingRefs: [], + error: null + }); return; } - let cancelled = false; - setSelectedAlertContextLoading(true); - void fetch( - buildApiUrl(`/flow/alerts/${encodeURIComponent(selectedAlert.trace_id)}/context`) - ) + + const abort = new AbortController(); + setSelectedAlertContextStatus({ + 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) => { if (!response.ok) { throw new Error(await readErrorDetail(response)); } - return response.json() as Promise<{ - flow_packets?: FlowPacket[]; - option_prints?: OptionPrint[]; - }>; + return response.json(); }) - .then((payload) => { - if (cancelled) { + .then((payload: AlertContextBundle) => { + if (abort.signal.aborted) { return; } + const { packets, prints } = collectAlertContextEvidence(payload); const now = Date.now(); - const nextPackets = new Map(); - for (const packet of payload.flow_packets ?? []) { - nextPackets.set(packet.id, packet); + if (packets.size > 0) { + setPinnedFlowPacketMap((prev) => upsertPinnedEntries(prev, packets, now)); } - const nextPrints = new Map(); - for (const print of payload.option_prints ?? []) { - 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)); + if (prints.size > 0) { + setPinnedOptionPrintMap((prev) => upsertPinnedEntries(prev, prints, now)); } + setSelectedAlertContextStatus({ + traceId: selectedAlert.trace_id, + loading: false, + missingRefs: payload.missing_refs ?? [], + error: null + }); }) .catch((error) => { - incrementRetentionMetric("pinnedFetchFailures", 1); - console.warn("Failed to fetch alert context", error); - }) - .finally(() => { - if (!cancelled) { - setSelectedAlertContextLoading(false); + if (abort.signal.aborted) { + return; } + 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]; - if (packetId && !resolvedFlowPacketMap.has(packetId)) { - 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([[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(); - 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]); + return () => abort.abort(); + }, [selectedAlert]); useEffect(() => { if (!selectedDarkEvent || mode !== "live") { @@ -6851,6 +6905,7 @@ const useTerminalState = () => { packetIdByOptionTraceId, classifierDecorByOptionTraceId, selectedEvidence, + selectedAlertContextStatus, selectedFlowPacket, selectedDarkEvidence, selectedDarkUnderlying, @@ -8564,6 +8619,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) { alert={state.selectedAlert} flowPacket={state.selectedFlowPacket} evidence={state.selectedEvidence} + contextStatus={state.selectedAlertContextStatus} onClose={() => state.setSelectedAlert(null)} /> ) : null} diff --git a/docs/turns/2026-05-17-1101-clickhouse-alert-context.html b/docs/turns/2026-05-17-1101-clickhouse-alert-context.html new file mode 100644 index 0000000..02d3613 --- /dev/null +++ b/docs/turns/2026-05-17-1101-clickhouse-alert-context.html @@ -0,0 +1,194 @@ + + + + + + ClickHouse Alert Context Hydration + + + +
+
+

ClickHouse Alert Context Hydration

+

+ 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. +

+ Validated +
+ +
+

Summary

+

+ 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. +

+
+ +
+

Changes Made

+
    +
  • Added fetchAlertContextByTraceId in storage to load an alert, linked flow packets, linked option prints, and unresolved evidence refs.
  • +
  • Added GET /flow/alerts/:trace_id/context to the API without changing existing alert list, history, replay, or websocket feeds.
  • +
  • Updated the terminal alert selection effect to fetch persisted context in live, replay, and history modes.
  • +
  • Merged hydrated packets and prints into pinned maps so existing evidence consumers share the resolved context.
  • +
  • Adjusted alert drawer copy and loading state to reference persisted context rather than live cache misses.
  • +
  • Expanded alert evidence print rows with execution NBBO side, bid, ask, mid, spread, quote age, underlying spot, bid, ask, and mid where available.
  • +
+
+ +
+

Context

+

+ 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 AlertEvent.evidence_refs. +

+
+ +
+

Important Implementation Details

+

The new API response shape is:

+
{
+  alert: AlertEvent | null,
+  flow_packets: FlowPacket[],
+  option_prints: OptionPrint[],
+  missing_refs: string[]
+}
+

+ Flow packet refs are resolved with both prefixed and unprefixed candidates. Option print refs are resolved by trace_id. Missing refs are returned explicitly instead of failing the whole response. +

+
+ +
+

Expected Impact for End-Users

+

+ 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. +

+
+ +
+

Validation

+
    +
  • bun test packages/storage/tests
  • +
  • bun test services/api/tests
  • +
  • bun test apps/web/app/terminal.test.ts
  • +
  • bun --cwd=apps/web run build
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • The endpoint is detail-time only, which avoids making alert list payloads heavier during bursts.
  • +
  • Malformed trace ids are rejected by route-level validation.
  • +
  • Missing evidence refs remain visible to the drawer as diagnostics rather than hiding partial context.
  • +
  • No schema migration was needed because option prints already persist execution context fields.
  • +
+
+ +
+

Follow-up Work

+

No follow-up beads issue was filed. The requested storage, API, frontend, tests, build, and documentation work is complete.

+
+
+ + diff --git a/docs/turns/2026-05-17-deploy-allowlist-pr-packaging.html b/docs/turns/2026-05-17-deploy-allowlist-pr-packaging.html new file mode 100644 index 0000000..af8f795 --- /dev/null +++ b/docs/turns/2026-05-17-deploy-allowlist-pr-packaging.html @@ -0,0 +1,151 @@ + + + + + + Turn Document - Deploy Allowlist PR Packaging + + + +
+
+

Deploy Allowlist PR Packaging

+

+ Packaged the deploy allowlist cleanup into a PR-ready branch with multiple commits, documented all changes, + and tracked work in Beads issue islandflow-9j5. +

+

Generated: 2026-05-17 11:48 EDT

+
+ +
+

Summary

+

+ Removed deployment/npm/ from the deploy script's remote untracked allowlist so deploy preflight + only tolerates the required signal-cli tarball artifact. +

+
+ +
+

Changes Made

+
    +
  • Updated scripts/deploy.ts to tighten ALLOWED_REMOTE_UNTRACKED.
  • +
  • Created this turn document in docs/turns/ as required by repository workflow.
  • +
  • Tracked and managed the work through Beads issue islandflow-9j5.
  • +
+
+ +
+

Context

+

+ 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. +

+
+ +
+

Important Implementation Details

+

+ The allowlist now contains only: +

+
deployment/docker/signal-cli-0.14.3-Linux-native.tar.gz
+

+ The removed entry: +

+
deployment/npm/
+

+ This change ensures remote preflight fails if deployment/npm/ appears unexpectedly. +

+
+ +
+

Expected Impact for End-Users

+
    +
  • Deployments should fail faster when unexpected remote workspace artifacts exist.
  • +
  • Operators get stricter hygiene checks before production rollouts.
  • +
  • No runtime behavior change to API/web/services outside deploy validation logic.
  • +
+
+ +
+

Validation

+
    +
  • + bun test was run for the repository and reported 2 failing tests plus 1 module-loading error: + services/api/tests/live.test.ts (hot-head cap expectation mismatch) and + apps/web/app/terminal.test.ts (Next navigation export mismatch). +
  • +
  • + The user requested skipping dependency-install remediation before completion, so no additional test-fix work + was performed in this turn. +
  • +
  • git diff review to confirm only intended allowlist and documentation updates were included.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • + 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. +
  • +
  • + A local untracked signal-cli tarball remains in the working tree by design and was not added to Git. +
  • +
+
+ +
+

Follow-up Work

+
    +
  • No additional follow-up issues were created from this scoped cleanup.
  • +
  • If full CI confidence is required, run bun install and bun test in a dependency-ready environment.
  • +
+
+
+ + diff --git a/packages/storage/src/clickhouse.ts b/packages/storage/src/clickhouse.ts index 5d42d3d..bc0061e 100644 --- a/packages/storage/src/clickhouse.ts +++ b/packages/storage/src/clickhouse.ts @@ -746,6 +746,13 @@ export type EquityPrintQueryFilters = { sinceTs?: number; }; +export type AlertContextBundle = { + alert: AlertEvent | null; + flow_packets: FlowPacket[]; + option_prints: OptionPrint[]; + missing_refs: string[]; +}; + const buildOptionPrintFilterConditions = ( filters: OptionPrintQueryFilters | undefined, tracePrefix: string | undefined @@ -1200,6 +1207,101 @@ export const fetchRecentAlerts = async ( 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 => { + 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(); + 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(); + 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 ( client: ClickHouseClient, afterTs: number, @@ -1846,55 +1948,6 @@ export const fetchOptionPrintsByTraceIds = async ( 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 => { - 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(); - 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([ - ...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 ( client: ClickHouseClient, ids: string[] diff --git a/packages/storage/tests/alerts.test.ts b/packages/storage/tests/alerts.test.ts index 9f9449c..f6d8859 100644 --- a/packages/storage/tests/alerts.test.ts +++ b/packages/storage/tests/alerts.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; +import type { ClickHouseClient } from "../src/clickhouse"; import { alertsTableDDL, ALERTS_TABLE, fromAlertRecord, toAlertRecord } from "../src/alerts"; +import { fetchAlertContextByTraceId } from "../src/clickhouse"; +import { toFlowPacketRecord } from "../src/flow-packets"; const alert = { source_ts: 10, @@ -19,6 +22,62 @@ const alert = { 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() { + return resolver(query) as T; + } + }) + }) as ClickHouseClient; + describe("alerts storage helpers", () => { it("includes the correct table name in the DDL", () => { const ddl = alertsTableDDL(); @@ -33,4 +92,51 @@ describe("alerts storage helpers", () => { expect(restored.evidence_refs).toEqual(alert.evidence_refs); 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: [] + }); + }); }); diff --git a/scripts/deploy.ts b/scripts/deploy.ts index cb30de9..d78db01 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -30,8 +30,7 @@ const SSH_OPTIONS = [ "BatchMode=yes" ]; const ALLOWED_REMOTE_UNTRACKED = new Set([ - "deployment/docker/signal-cli-0.14.3-Linux-native.tar.gz", - "deployment/npm/" + "deployment/docker/signal-cli-0.14.3-Linux-native.tar.gz" ]); const PUBLIC_APP_URL = process.env.DEPLOY_PUBLIC_APP_URL?.trim() || "https://flow.deltaisland.io"; diff --git a/services/api/src/alert-context.ts b/services/api/src/alert-context.ts new file mode 100644 index 0000000..2271568 --- /dev/null +++ b/services/api/src/alert-context.ts @@ -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)); +}; diff --git a/services/api/src/index.ts b/services/api/src/index.ts index 5e2dbd4..433222a 100644 --- a/services/api/src/index.ts +++ b/services/api/src/index.ts @@ -47,6 +47,7 @@ import { ensureOptionPrintsTable, fetchAlertsAfter, fetchAlertsBefore, + fetchAlertContextByTraceId, fetchClassifierHitsAfter, fetchClassifierHitsBefore, fetchSmartMoneyEventsAfter, @@ -119,6 +120,7 @@ import { resolveLiveStateConfig, shouldFanoutLiveEvent } from "./live"; +import { isAlertContextPath, parseAlertContextTraceIdPath } from "./alert-context"; import { parseOptionPrintQuery } from "./option-queries"; import { buildSyntheticDerivedStatus, @@ -1488,6 +1490,25 @@ const run = async () => { 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") { try { const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); diff --git a/services/api/tests/alert-context.test.ts b/services/api/tests/alert-context.test.ts new file mode 100644 index 0000000..e1b3c7b --- /dev/null +++ b/services/api/tests/alert-context.test.ts @@ -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(); + }); +});