From 9482eda85ef7bf35e2439dbe841d9c1399287aec Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 30 Apr 2026 21:37:32 -0400 Subject: [PATCH] refactor(web): narrow event property reads via shared helpers --- .../app/lib/run-settings-snapshot.ts | 45 ++++------------ apps/fabro-web/app/lib/unknown.ts | 35 ++++++++++++ apps/fabro-web/app/routes/run-settings.tsx | 6 +-- apps/fabro-web/app/routes/run-stages.tsx | 54 +++++++++++++------ 4 files changed, 87 insertions(+), 53 deletions(-) create mode 100644 apps/fabro-web/app/lib/unknown.ts diff --git a/apps/fabro-web/app/lib/run-settings-snapshot.ts b/apps/fabro-web/app/lib/run-settings-snapshot.ts index 14813e383..e1a0490e0 100644 --- a/apps/fabro-web/app/lib/run-settings-snapshot.ts +++ b/apps/fabro-web/app/lib/run-settings-snapshot.ts @@ -1,41 +1,18 @@ import type { WorkflowSettings } from "@qltysh/fabro-api-client"; +import { getObject } from "./unknown"; + export type Snapshot = WorkflowSettings; -export type SnapshotObject = Record; -function isRecord(v: unknown): v is SnapshotObject { - return typeof v === "object" && v !== null && !Array.isArray(v); -} - -function read(o: unknown, key: string): unknown { - if (!isRecord(o)) return undefined; - return o[key]; -} - -export function getString(o: unknown, key: string): string | undefined { - const v = read(o, key); - return typeof v === "string" && v.length > 0 ? v : undefined; -} - -export function getNumber(o: unknown, key: string): number | undefined { - const v = read(o, key); - return typeof v === "number" ? v : undefined; -} - -export function getBool(o: unknown, key: string): boolean | undefined { - const v = read(o, key); - return typeof v === "boolean" ? v : undefined; -} - -export function getObject(o: unknown, key: string): SnapshotObject | undefined { - const v = read(o, key); - return isRecord(v) ? v : undefined; -} - -export function getArray(o: unknown, key: string): unknown[] | undefined { - const v = read(o, key); - return Array.isArray(v) ? v : undefined; -} +export { + getArray, + getBool, + getNumber, + getObject, + getString, + isRecord, + type UnknownRecord, +} from "./unknown"; export function objectKeyCount(o: unknown, key: string): number { const v = getObject(o, key); diff --git a/apps/fabro-web/app/lib/unknown.ts b/apps/fabro-web/app/lib/unknown.ts new file mode 100644 index 000000000..976ce7332 --- /dev/null +++ b/apps/fabro-web/app/lib/unknown.ts @@ -0,0 +1,35 @@ +export type UnknownRecord = Record; + +export function isRecord(v: unknown): v is UnknownRecord { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function read(o: unknown, key: string): unknown { + if (!isRecord(o)) return undefined; + return o[key]; +} + +export function getString(o: unknown, key: string): string | undefined { + const v = read(o, key); + return typeof v === "string" && v.length > 0 ? v : undefined; +} + +export function getNumber(o: unknown, key: string): number | undefined { + const v = read(o, key); + return typeof v === "number" ? v : undefined; +} + +export function getBool(o: unknown, key: string): boolean | undefined { + const v = read(o, key); + return typeof v === "boolean" ? v : undefined; +} + +export function getObject(o: unknown, key: string): UnknownRecord | undefined { + const v = read(o, key); + return isRecord(v) ? v : undefined; +} + +export function getArray(o: unknown, key: string): unknown[] | undefined { + const v = read(o, key); + return Array.isArray(v) ? v : undefined; +} diff --git a/apps/fabro-web/app/routes/run-settings.tsx b/apps/fabro-web/app/routes/run-settings.tsx index 2c96dc0e4..77bc744f4 100644 --- a/apps/fabro-web/app/routes/run-settings.tsx +++ b/apps/fabro-web/app/routes/run-settings.tsx @@ -18,7 +18,7 @@ import { useRunSettings, useRunStages } from "../lib/queries"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; import { type Snapshot, - type SnapshotObject, + type UnknownRecord, getArray, getBool, getObject, @@ -219,7 +219,7 @@ function ArtifactsPanel({ snapshot }: { snapshot: Snapshot }) { ); } -function GoalValue({ goal }: { goal: SnapshotObject | undefined }) { +function GoalValue({ goal }: { goal: UnknownRecord | undefined }) { if (!goal) return None; const type = getString(goal, "type"); const value = getString(goal, "value"); @@ -244,7 +244,7 @@ function AuthorValue({ name, email }: { name?: string; email?: string }) { return {name ?? email}; } -function PullRequestValue({ pr }: { pr: SnapshotObject | undefined }) { +function PullRequestValue({ pr }: { pr: UnknownRecord | undefined }) { const enabled = getBool(pr, "enabled") ?? false; if (!enabled) return ; const draft = getBool(pr, "draft") ?? false; diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 30a0cb733..a3780499c 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -42,6 +42,7 @@ import { CopyButton } from "../components/ui"; import { formatDurationSecs } from "../lib/format"; import { fetchRunCommandLog, useRunEventsList, useRunStageTurns, useRunStages } from "../lib/queries"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; +import { getNumber, getString, type UnknownRecord } from "../lib/unknown"; import type { StageTurn as ApiStageTurn, PaginatedStageTurnList, PaginatedEventList } from "@qltysh/fabro-api-client"; export const handle = { wide: true }; @@ -67,6 +68,11 @@ interface RawEvent { is_error?: boolean; } +function readTermination(props: UnknownRecord): CommandTermination { + const v = props.termination; + return v === "exited" || v === "timed_out" || v === "cancelled" ? v : "exited"; +} + function turnsFromEvents(events: RawEvent[], stageId: string): TurnType[] { const stageEvents = events.filter((e) => e.node_id === stageId); const turns: TurnType[] = []; @@ -79,31 +85,30 @@ function turnsFromEvents(events: RawEvent[], stageId: string): TurnType[] { const props = e.properties ?? {}; switch (e.event) { case "stage.prompt": - turns.push({ kind: "system", content: props.text as string ?? e.text ?? "" }); + turns.push({ kind: "system", content: getString(props, "text") ?? e.text ?? "" }); break; case "agent.message": { - const msg = props.text as string ?? e.text ?? ""; + const msg = getString(props, "text") ?? e.text ?? ""; if (msg) turns.push({ kind: "assistant", content: msg }); break; } case "agent.tool.started": { - const callId = props.tool_call_id as string ?? e.tool_call_id ?? ""; + const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? ""; + const args = props.arguments ?? e.arguments; pendingTools.set(callId, { - toolName: props.tool_name as string ?? e.tool_name ?? "", - input: typeof (props.arguments ?? e.arguments) === "string" - ? (props.arguments ?? e.arguments) as string - : JSON.stringify(props.arguments ?? e.arguments ?? ""), + toolName: getString(props, "tool_name") ?? e.tool_name ?? "", + input: typeof args === "string" ? args : JSON.stringify(args ?? ""), }); break; } case "agent.tool.completed": { - const callId = props.tool_call_id as string ?? e.tool_call_id ?? ""; + const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? ""; const started = pendingTools.get(callId); const output = props.output ?? e.output ?? ""; const result = typeof output === "string" ? output : JSON.stringify(output); const tool: ToolUse = { id: callId, - toolName: started?.toolName ?? props.tool_name as string ?? e.tool_name ?? "", + toolName: started?.toolName ?? getString(props, "tool_name") ?? e.tool_name ?? "", input: started?.input ?? "", result, isError: (props.is_error ?? e.is_error) === true, @@ -115,8 +120,8 @@ function turnsFromEvents(events: RawEvent[], stageId: string): TurnType[] { case "command.started": { pendingCommand = { stageId: e.stage_id ?? `${stageId}@1`, - script: props.script as string ?? "", - language: props.language as string ?? "shell", + script: getString(props, "script") ?? "", + language: getString(props, "language") ?? "shell", }; break; } @@ -126,11 +131,11 @@ function turnsFromEvents(events: RawEvent[], stageId: string): TurnType[] { stageId: pendingCommand?.stageId ?? e.stage_id ?? `${stageId}@1`, script: pendingCommand?.script ?? "", language: pendingCommand?.language ?? "shell", - stdout: props.stdout as string ?? "", - stderr: props.stderr as string ?? "", - exitCode: props.exit_code as number | null ?? null, - durationMs: props.duration_ms as number ?? 0, - termination: props.termination as CommandTermination ?? "exited", + stdout: getString(props, "stdout") ?? "", + stderr: getString(props, "stderr") ?? "", + exitCode: getNumber(props, "exit_code") ?? null, + durationMs: getNumber(props, "duration_ms") ?? 0, + termination: readTermination(props), running: false, }); pendingCommand = undefined; @@ -278,6 +283,12 @@ function decodeBase64Bytes(value: string): Uint8Array { } function trimTextToBytes(text: string, maxBytes: number) { + // Each UTF-16 code unit encodes to at most 3 bytes in UTF-8 (4-byte encodings + // come from surrogate pairs counted as 2 units). Skip the full encode when + // the upper bound is already under the cap. + if (text.length * 3 <= maxBytes) { + return { text, truncated: false }; + } const encoded = new TextEncoder().encode(text); if (encoded.byteLength <= maxBytes) { return { text, truncated: false }; @@ -344,6 +355,17 @@ function useCommandLog( const decoded = decoderRef.current.decode(bytes, { stream: !chunk.eof }); finalPollDoneRef.current = chunk.eof; setState((current) => { + if ( + decoded.length === 0 && + current.eof === chunk.eof && + current.totalBytes === chunk.total_bytes && + current.casRef === chunk.cas_ref && + current.liveStreaming === chunk.live_streaming && + !current.loading && + !current.error + ) { + return current; + } const next = trimTextToBytes(current.text + decoded, LOG_MEMORY_CAP_BYTES); return { text: next.text,