fabro/apps/fabro-web/app/lib/mutations.ts
Bryan Helmkamp e8e681adbf
Remove the retry, rewind, fork and timeline endpoints and commands
The legacy executor replayed a run from a checkpoint; Petri resumes a
run from its records instead, and the checkpoint timeline, rewind, fork
and retry were the operations that replay carried. The server dropped
their handlers with the executor; this removes the rest:

- the API spec's `/runs/{id}/retry`, `/rewind`, `/fork` and `/timeline`
  paths with the `ForkRequest`, `ForkResponse`, `RewindRequest`,
  `RewindResponse` and `TimelineEntryResponse` schemas, and the
  generated TypeScript models;
- `fabro rewind` and `fabro fork` (with the checkpoint timeline printer
  and the repo-origin check only they used), their reference pages and
  the checkpoints guide's rewind and fork sections;
- `fabro-client`'s `rewind_run`, `fork_run` and `run_timeline`;
- the web app's Retry action on the run list and the run page.

`Run.retried_from` stays on the run type: a run that was retried before
the cutover would still name its source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:54:45 -04:00

208 lines
5.5 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,
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);
}
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;
// 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,
});
},
);
}