Add a Re-run button to the run detail page

Re-run takes you to the script's form pre-filled with that run's
variable values, rather than re-executing immediately - this is
necessary, not just cautious: stored run.variables already have secret
fields redacted to "***" (see the run detail command/variables view),
so a true one-click re-run would either fail validation or, worse,
silently pass the literal string "***" to the script as a real secret.
Pre-filling instead lets the user review/tweak values and forces secret
fields to be re-entered, both of which are new safe defaults now that
required fields are actually enforced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 00:32:30 +02:00
co-authored by Claude Sonnet 5
parent 75b92d103c
commit d7c0be6b22
3 changed files with 78 additions and 8 deletions
+21 -1
View File
@@ -1,14 +1,23 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { RotateCw } from "lucide-react";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { getScript } from "@/lib/config/load";
import { readLogTail } from "@/lib/runner/log-file";
import { RunTerminal } from "@/components/runs/run-terminal";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface RunDetailPageProps {
params: Promise<{ runId: string }>;
@@ -61,6 +70,17 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
<Card>
<CardHeader>
<CardTitle>{run.scriptName}</CardTitle>
{script && (
<CardAction>
<Link
href={`/scripts/${run.scriptId}?fromRun=${run.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<RotateCw className="size-3.5" />
Re-run
</Link>
</CardAction>
)}
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-3">
<div>
<dt className="font-medium">Triggered by</dt>
+24 -2
View File
@@ -1,9 +1,12 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { getScript } from "@/lib/config/load";
import { serializeScriptForClient } from "@/lib/config/serialize";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { DynamicForm } from "@/components/forms/dynamic-form";
import {
Card,
@@ -15,6 +18,7 @@ import {
interface ScriptPageProps {
params: Promise<{ scriptId: string }>;
searchParams: Promise<{ fromRun?: string }>;
}
export async function generateMetadata({
@@ -25,13 +29,31 @@ export async function generateMetadata({
return { title: script?.name ?? "Script not found" };
}
export default async function ScriptPage({ params }: ScriptPageProps) {
export default async function ScriptPage({
params,
searchParams,
}: ScriptPageProps) {
const { scriptId } = await params;
const { fromRun } = await searchParams;
const script = getScript(scriptId);
if (!script) notFound();
const clientScript = serializeScriptForClient(script);
// Only trust a previous run's variables as prefill if it's actually a run of this same
// script - a `fromRun` id for a different script's run wouldn't line up with these variables.
let initialValues: Record<string, unknown> | undefined;
if (fromRun) {
const previousRun = getDb()
.select({ scriptId: runs.scriptId, variables: runs.variables })
.from(runs)
.where(eq(runs.id, fromRun))
.get();
if (previousRun?.scriptId === scriptId) {
initialValues = previousRun.variables;
}
}
return (
<div className="mx-auto max-w-2xl">
<Card>
@@ -42,7 +64,7 @@ export default async function ScriptPage({ params }: ScriptPageProps) {
)}
</CardHeader>
<CardContent>
<DynamicForm script={clientScript} />
<DynamicForm script={clientScript} initialValues={initialValues} />
</CardContent>
</Card>
</div>
+33 -5
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Play, Loader2 } from "lucide-react";
import { Play, Loader2, Info } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
@@ -19,22 +19,40 @@ function emptyValueFor(variable: ClientScript["variables"][number]): unknown {
return "";
}
function defaultValuesFor(script: ClientScript): Record<string, unknown> {
/** `initialValues` comes from a previous run's (already-redacted) variables when re-running -
* secret fields are deliberately excluded there (their stored value is just "***", not the real
* one), so those always fall through to the normal empty/default state and have to be re-entered. */
function defaultValuesFor(
script: ClientScript,
initialValues?: Record<string, unknown>,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
for (const variable of script.variables) {
values[variable.name] = variable.default ?? emptyValueFor(variable);
const fromPreviousRun =
initialValues && !variable.secret
? initialValues[variable.name]
: undefined;
values[variable.name] =
fromPreviousRun ?? variable.default ?? emptyValueFor(variable);
}
return values;
}
export function DynamicForm({ script }: { script: ClientScript }) {
export function DynamicForm({
script,
initialValues,
}: {
script: ClientScript;
initialValues?: Record<string, unknown>;
}) {
const router = useRouter();
const [submitError, setSubmitError] = useState<string | null>(null);
const schema = buildVariableSchemaFromList(script.variables);
const hasSecretVariable = script.variables.some((v) => v.secret);
const form = useForm({
resolver: zodResolver(schema),
defaultValues: defaultValuesFor(script),
defaultValues: defaultValuesFor(script, initialValues),
});
async function onSubmit(values: Record<string, unknown>) {
@@ -67,6 +85,16 @@ export function DynamicForm({ script }: { script: ClientScript }) {
<AlertDescription>{submitError}</AlertDescription>
</Alert>
)}
{initialValues && (
<Alert>
<Info />
<AlertDescription>
Pre-filled from a previous run.
{hasSecretVariable &&
" Secret fields aren't carried over and need to be entered again."}
</AlertDescription>
</Alert>
)}
{script.variables.length === 0 && (
<p className="text-muted-foreground text-sm">
This script takes no parameters.