refactor(web): narrow event property reads via shared helpers

This commit is contained in:
Bryan Helmkamp 2026-04-30 21:37:32 -04:00
parent de64136a0e
commit 9482eda85e
No known key found for this signature in database
4 changed files with 87 additions and 53 deletions

View file

@ -1,41 +1,18 @@
import type { WorkflowSettings } from "@qltysh/fabro-api-client";
import { getObject } from "./unknown";
export type Snapshot = WorkflowSettings;
export type SnapshotObject = Record<string, unknown>;
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);

View file

@ -0,0 +1,35 @@
export type UnknownRecord = Record<string, unknown>;
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;
}

View file

@ -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 <Muted>None</Muted>;
const type = getString(goal, "type");
const value = getString(goal, "value");
@ -244,7 +244,7 @@ function AuthorValue({ name, email }: { name?: string; email?: string }) {
return <span className="text-fg-2">{name ?? email}</span>;
}
function PullRequestValue({ pr }: { pr: SnapshotObject | undefined }) {
function PullRequestValue({ pr }: { pr: UnknownRecord | undefined }) {
const enabled = getBool(pr, "enabled") ?? false;
if (!enabled) return <Toggle on={false} />;
const draft = getBool(pr, "draft") ?? false;

View file

@ -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,