fabro/apps/fabro-web/app/lib/mutations.ts
Bryan Helmkamp 1cdd78926e
Read Usage in the web app and rename the Billing tab to Usage
The run detail tab, route, query hook, and query key say usage. Every
read of input_tokens, output_tokens, total_tokens, reasoning_tokens,
cache_read_tokens, cache_write_tokens, and total_usd_micros moves to
usage.tokens and usage.cost, with lib/usage.ts replacing lib/billing.ts:
totalTokens sums the five buckets, and costSourceTag names a cost that
the provider reported or that was summed from differently sourced parts.
The Usage tab and the stage popover show that tag next to such a cost.

Test fixtures build a Usage through makeUsage; the failing set of the
web tests is unchanged from main (the same 13 environment failures).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 12:40:04 -06:00

220 lines
5.8 KiB
TypeScript

import useSWRMutation from "swr/mutation";
import { useSWRConfig, type ScopedMutator } from "swr";
import type {
PreviewUrlResponse,
Run,
SteerRunRequest,
SubmitAnswerRequest,
UpdateRunRequest,
} from "@qltysh/fabro-api-client";
import {
apiData,
authApi,
humanInTheLoopApi,
runsApi,
} from "./api-client";
import { mutateRunListCaches } from "./board-cache";
import { queryKeys } from "./query-keys";
import type { LifecycleAction, LifecycleActionError } from "./run-actions";
import {
approveRun,
archiveRun,
cancelRun,
denyRun,
isLifecycleActionError,
retryRun,
unarchiveRun,
} from "./run-actions";
export type PreviewRunArg = {
port: number;
expires_in_secs: number;
signed?: boolean;
};
export type PreviewMutationResult = {
intent: "preview";
url: string;
};
export type LifecycleMutationResult =
| {
intent: LifecycleAction;
ok: true;
run: Run;
}
| {
intent: LifecycleAction;
ok: false;
error: LifecycleActionError | null;
};
export function usePreviewRun(id: string | undefined) {
return useSWRMutation(
id ? queryKeys.runs.preview(id) : null,
async (_key, { arg }: { arg: PreviewRunArg }): Promise<PreviewMutationResult> => {
const result = await apiData<PreviewUrlResponse>(() =>
humanInTheLoopApi.generatePreviewUrl(id!, arg),
);
return { intent: "preview", url: result.url };
},
);
}
export function useCancelRun(id: string | undefined) {
return useLifecycleMutation(id, "cancel", cancelRun);
}
export function useApproveRun(id: string | undefined) {
return useLifecycleMutation(id, "approve", approveRun);
}
export function useDenyRun(id: string | undefined) {
return useLifecycleMutation(id, "deny", denyRun);
}
export function useArchiveRun(id: string | undefined) {
return useLifecycleMutation(id, "archive", archiveRun);
}
export function useUnarchiveRun(id: string | undefined) {
return useLifecycleMutation(id, "unarchive", unarchiveRun);
}
export function useRetryRun(id: string | undefined) {
return useLifecycleMutation(id, "retry", retryRun, (run, mutate) => {
void mutate(queryKeys.runs.detail(run.id), run, { revalidate: false });
if (run.parent_id) {
mutateRunListCaches(mutate);
}
});
}
function useLifecycleMutation(
id: string | undefined,
intent: LifecycleAction,
action: (id: string) => Promise<Run>,
onSuccessExtra?: (run: Run, mutate: ScopedMutator) => void,
) {
const { mutate } = useSWRConfig();
const key = id ? queryKeys.runs[intent](id) : null;
return useSWRMutation(
key,
async (): Promise<LifecycleMutationResult> => {
if (!id) {
return { intent, ok: false, error: null };
}
try {
return { intent, ok: true, run: await action(id) };
} catch (error) {
return {
intent,
ok: false,
error: isLifecycleActionError(error) ? error : null,
};
}
},
{
onSuccess: (result) => {
if (!id || !result.ok) return;
if (intent !== "retry") {
// Keep the returned lifecycle state visible while revalidation
// observes the durable follow-up event (notably a 202 cancel).
void mutate(queryKeys.runs.detail(id), result.run, { revalidate: true });
void mutate(queryKeys.runs.usage(id));
}
mutateRunListCaches(mutate);
onSuccessExtra?.(result.run, mutate);
},
},
);
}
export function useUpdateRunTitle(id: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
id ? queryKeys.runs.updateTitle(id) : null,
async (_key, { arg }: { arg: UpdateRunRequest }): Promise<Run> => {
if (!id) throw new Error("id is required");
return apiData(() => runsApi.updateRun(id, arg));
},
{
onSuccess: (run) => {
if (!id) return;
void mutate(queryKeys.runs.detail(id), run, { revalidate: false });
mutateRunListCaches(mutate);
},
},
);
}
export type SubmitInterviewAnswerArg = {
questionId: string;
answer: SubmitAnswerRequest;
};
export function useSubmitInterviewAnswer(runId: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
runId ? `interview-answer:${runId}` : null,
async (_key: string, { arg }: { arg: SubmitInterviewAnswerArg }) => {
if (!runId) throw new Error("runId is required");
await apiData(() =>
humanInTheLoopApi.submitRunAnswer(runId, arg.questionId, arg.answer),
);
},
{
onSuccess: () => {
if (!runId) return;
void mutate(queryKeys.runs.questions(runId, 25, 0));
void mutate(queryKeys.runs.detail(runId));
},
},
);
}
export function useInterruptRun(runId: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
runId ? `interrupt-run:${runId}` : null,
async (_key: string) => {
if (!runId) throw new Error("runId is required");
await apiData(() => humanInTheLoopApi.interruptRun(runId));
},
{
onSuccess: () => {
if (!runId) return;
void mutate(queryKeys.runs.detail(runId));
},
},
);
}
export function useSteerRun(runId: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
runId ? `steer-run:${runId}` : null,
async (_key: string, { arg }: { arg: SteerRunRequest }) => {
if (!runId) throw new Error("runId is required");
await apiData(() => humanInTheLoopApi.steerRun(runId, arg));
},
{
onSuccess: () => {
if (!runId) return;
void mutate(queryKeys.runs.detail(runId));
},
},
);
}
export function useLoginDevToken() {
return useSWRMutation(
queryKeys.auth.loginDevToken(),
async (_key, { arg }: { arg: { token: string } }) => {
return apiData(() => authApi.loginDevToken(arg), {
redirectOnUnauthorized: false,
});
},
);
}