5 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 3f656baad1 Make Duration sortable on the runs page
It was left out because duration isn't a stored column, just
started_at/ended_at math - now expressed as a SQL case expression so it
can be sorted the same way as the other columns. Still-running rows
sort by elapsed-so-far; never-started (queued) rows sort as NULL, which
puts them out of the way at whichever end matches the current
direction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:20:06 +02:00
valknarandClaude Sonnet 5 3131d8ce5b Add search, filtering, sorting, and pagination to Run History
The runs page was a flat top-100 list with no way to narrow it down.
It's now driven entirely by the URL (?q=&status=&scriptId=&sort=&dir=&page=),
so filtered/sorted views are shareable and survive back/forward
navigation:

- free-text search across script name, triggered-by, and the resolved
  command
- status and script dropdown filters
- sortable Script/Status/Triggered-by/Started column headers
- offset pagination (25/page) with a real total count, clamped so an
  out-of-range page falls back to the last valid one instead of
  showing a misleading "no results"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:17:49 +02:00
valknarandClaude Sonnet 5 d4e4c2c7d2 Show the full resolved command and variables on the run detail page
The command lived in a narrow truncate'd grid cell, so anything but a
short invocation was unreadable. It now gets its own scrollable code
block, plus a breakdown of each variable (using the script's configured
labels when available) and the value it resolved to for that run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:11:27 +02:00
valknarandClaude Sonnet 5 004586ee64 Enforce required on string/multiselect variables without explicit bounds
required:true only worked by accident: a bare z.string() or
z.array(...) accepts "" / [] just fine, so a required field with no
minLength (e.g. secret tokens like webhookToken, apiKey) could be
submitted empty and the run would start anyway. Required now implies
min(1) when no stricter bound is already configured, for both string
and multiselect fields. This is the single schema shared by the client
form resolver and the server run-creation route, so both now reject it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:06:39 +02:00
valknarandClaude Sonnet 5 e6008a22cd Fix live output missing for fast-finishing scripts
Scripts that finish in milliseconds (e.g. notify.js) could complete and
broadcast all their output/status before a client's WS subscribe message
even arrived, leaving the run page's terminal permanently blank with no
way to catch up. The server now answers every subscribe with whatever
log bytes were written past what the client's server-rendered page
already had, plus the run's current status, before it starts streaming
live broadcasts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:02:57 +02:00
10 changed files with 523 additions and 98 deletions
+55 -24
View File
@@ -1,20 +1,26 @@
export const dynamic = "force-dynamic";
import fs from "node:fs";
import type { Metadata } from "next";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
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";
const MAX_INITIAL_LOG_BYTES = 200_000;
interface RunDetailPageProps {
params: Promise<{ runId: string }>;
}
function formatVariableValue(value: unknown): string {
if (value === undefined || value === null || value === "") return "—";
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
}
export async function generateMetadata({
params,
}: RunDetailPageProps): Promise<Metadata> {
@@ -27,34 +33,35 @@ export async function generateMetadata({
return { title: run ? `${run.scriptName} run` : "Run not found" };
}
function readTail(logFilePath: string): string {
if (!fs.existsSync(logFilePath)) return "";
const { size } = fs.statSync(logFilePath);
const start = Math.max(0, size - MAX_INITIAL_LOG_BYTES);
const fd = fs.openSync(logFilePath, "r");
try {
const buffer = Buffer.alloc(size - start);
fs.readSync(fd, buffer, 0, buffer.length, start);
return (start > 0 ? "... (truncated)\n" : "") + buffer.toString("utf-8");
} finally {
fs.closeSync(fd);
}
}
export default async function RunDetailPage({ params }: RunDetailPageProps) {
const { runId } = await params;
const db = getDb();
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
if (!run) notFound();
const initialLog = readTail(run.logFilePath);
const { text: initialLog, size: initialLogBytes } = readLogTail(
run.logFilePath,
);
const script = getScript(run.scriptId);
const variableEntries = script
? script.variables.map((variable) => ({
key: variable.name,
label: variable.label ?? variable.name,
value: run.variables[variable.name],
}))
: Object.entries(run.variables).map(([key, value]) => ({
key,
label: key,
value,
}));
return (
<div className="mx-auto flex max-w-3xl flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>{run.scriptName}</CardTitle>
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
<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>
<dd>{run.triggeredBy}</dd>
@@ -65,21 +72,45 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
{run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}
</dd>
</div>
<div>
<dt className="font-medium">Command</dt>
<dd className="truncate font-mono">{run.resolvedCommand}</dd>
</div>
<div>
<dt className="font-medium">Run ID</dt>
<dd className="truncate font-mono">{run.id}</dd>
</div>
</dl>
</CardHeader>
<CardContent>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs font-medium">
Command
</span>
<pre className="bg-muted overflow-x-auto rounded-md p-3 font-mono text-xs whitespace-pre">
{run.resolvedCommand}
</pre>
</div>
{variableEntries.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs font-medium">
Variables
</span>
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2">
{variableEntries.map(({ key, label, value }) => (
<div key={key} className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">{label}</dt>
<dd className="font-mono break-all">
{formatVariableValue(value)}
</dd>
</div>
))}
</dl>
</div>
)}
<RunTerminal
runId={run.id}
initialStatus={run.status}
initialLog={initialLog}
initialLogBytes={initialLogBytes}
initialExitCode={run.exitCode}
/>
</CardContent>
+223 -46
View File
@@ -2,10 +2,14 @@ export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { desc } from "drizzle-orm";
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from "lucide-react";
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { RunStatusBadge } from "@/components/runs/run-status-badge";
import { RunStatusBadge, runStatusLabels } from "@/components/runs/run-status-badge";
import { RunsToolbar } from "@/components/runs/runs-toolbar";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
Table,
TableBody,
@@ -17,6 +21,28 @@ import {
export const metadata: Metadata = { title: "Run History" };
const PAGE_SIZE = 25;
// Duration isn't a stored column - it's derived from started_at/ended_at (both unix seconds), so
// it needs a SQL expression rather than a plain column reference to be sortable. Still-running
// rows (ended_at IS NULL) count as elapsed-so-far; never-started (queued) rows sort as NULL,
// which SQLite puts first in ASC / last in DESC - keeping them out of the way either direction.
const durationExpr = sql`case when ${runs.startedAt} is null then null else coalesce(${runs.endedAt}, unixepoch()) - ${runs.startedAt} end`;
const SORT_COLUMNS = {
script: runs.scriptName,
status: runs.status,
triggeredBy: runs.triggeredBy,
started: runs.startedAt,
duration: durationExpr,
} as const;
type SortKey = keyof typeof SORT_COLUMNS;
const DEFAULT_SORT: SortKey = "started";
function isSortKey(value: string | undefined): value is SortKey {
return !!value && value in SORT_COLUMNS;
}
function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
if (!startedAt) return "-";
const end = endedAt ?? new Date();
@@ -29,62 +55,213 @@ function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
return `${minutes}m ${seconds % 60}s`;
}
export default function RunsPage() {
interface RunsPageProps {
searchParams: Promise<{
q?: string;
status?: string;
scriptId?: string;
sort?: string;
dir?: string;
page?: string;
}>;
}
export default async function RunsPage({ searchParams }: RunsPageProps) {
const params = await searchParams;
const search = params.q?.trim() ?? "";
const statusFilter = params.status ?? "all";
const scriptFilter = params.scriptId ?? "all";
const sort = isSortKey(params.sort) ? params.sort : DEFAULT_SORT;
const dir = params.dir === "asc" ? "asc" : "desc";
const requestedPage = Math.max(1, Math.floor(Number(params.page)) || 1);
const db = getDb();
const conditions: SQL[] = [];
if (search) {
const term = `%${search}%`;
conditions.push(
or(
like(runs.scriptName, term),
like(runs.triggeredBy, term),
like(runs.resolvedCommand, term),
)!,
);
}
if (statusFilter !== "all" && statusFilter in runStatusLabels) {
conditions.push(eq(runs.status, statusFilter as keyof typeof runStatusLabels));
}
if (scriptFilter !== "all") {
conditions.push(eq(runs.scriptId, scriptFilter));
}
const whereClause = conditions.length ? and(...conditions) : undefined;
const orderColumn = SORT_COLUMNS[sort];
const orderBy = [
dir === "asc" ? asc(orderColumn) : desc(orderColumn),
desc(runs.createdAt),
];
const [{ total }] = db
.select({ total: sql<number>`count(*)` })
.from(runs)
.where(whereClause)
.all();
const scripts = db
.selectDistinct({ id: runs.scriptId, name: runs.scriptName })
.from(runs)
.orderBy(asc(runs.scriptName))
.all();
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const page = Math.min(requestedPage, totalPages);
const rows = db
.select()
.from(runs)
.orderBy(desc(runs.createdAt))
.limit(100)
.where(whereClause)
.orderBy(...orderBy)
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
.all();
function hrefFor(overrides: Record<string, string | null>): string {
const query = new URLSearchParams();
if (search) query.set("q", search);
if (statusFilter !== "all") query.set("status", statusFilter);
if (scriptFilter !== "all") query.set("scriptId", scriptFilter);
if (sort !== DEFAULT_SORT) query.set("sort", sort);
if (dir !== "desc") query.set("dir", dir);
if (page !== 1) query.set("page", String(page));
for (const [key, value] of Object.entries(overrides)) {
if (value === null) query.delete(key);
else query.set(key, value);
}
const qs = query.toString();
return qs ? `/runs?${qs}` : "/runs";
}
function sortHeader(key: SortKey, label: string) {
const active = sort === key;
const nextDir = active && dir === "asc" ? "desc" : "asc";
const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
return (
<Link
href={hrefFor({ sort: key, dir: nextDir, page: null })}
className={cn(
"flex items-center gap-1 hover:text-foreground",
active && "text-foreground",
)}
>
{label}
<Icon className="size-3.5" />
</Link>
);
}
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
<RunsToolbar scripts={scripts} />
{rows.length === 0 ? (
<p className="text-muted-foreground py-12 text-center">No runs yet.</p>
<p className="text-muted-foreground py-12 text-center">
{total === 0 && !search && statusFilter === "all" && scriptFilter === "all"
? "No runs yet."
: "No runs match these filters."}
</p>
) : (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Script</TableHead>
<TableHead>Status</TableHead>
<TableHead>Triggered by</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((run) => (
<TableRow key={run.id} className="cursor-pointer">
<TableCell>
<Link
href={`/runs/${run.id}`}
className="font-medium hover:underline"
>
{run.scriptName}
</Link>
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground">
{run.triggeredBy}
</TableCell>
<TableCell className="text-muted-foreground">
{run.startedAt
? new Date(run.startedAt).toLocaleString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDuration(run.startedAt, run.endedAt)}
</TableCell>
<>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{sortHeader("script", "Script")}</TableHead>
<TableHead>{sortHeader("status", "Status")}</TableHead>
<TableHead>{sortHeader("triggeredBy", "Triggered by")}</TableHead>
<TableHead>{sortHeader("started", "Started")}</TableHead>
<TableHead>{sortHeader("duration", "Duration")}</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableHeader>
<TableBody>
{rows.map((run) => (
<TableRow key={run.id} className="cursor-pointer">
<TableCell>
<Link
href={`/runs/${run.id}`}
className="font-medium hover:underline"
>
{run.scriptName}
</Link>
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground">
{run.triggeredBy}
</TableCell>
<TableCell className="text-muted-foreground">
{run.startedAt
? new Date(run.startedAt).toLocaleString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDuration(run.startedAt, run.endedAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="text-muted-foreground flex items-center justify-between text-sm">
<span>
Page {page} of {totalPages} · {total} run{total === 1 ? "" : "s"}
</span>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link
href={hrefFor({ page: String(page - 1) })}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<ChevronLeft className="size-3.5" />
Previous
</Link>
) : (
<span
aria-disabled
className={buttonVariants({
variant: "outline",
size: "sm",
className: "pointer-events-none opacity-50",
})}
>
<ChevronLeft className="size-3.5" />
Previous
</span>
)}
{page < totalPages ? (
<Link
href={hrefFor({ page: String(page + 1) })}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Next
<ChevronRight className="size-3.5" />
</Link>
) : (
<span
aria-disabled
className={buttonVariants({
variant: "outline",
size: "sm",
className: "pointer-events-none opacity-50",
})}
>
Next
<ChevronRight className="size-3.5" />
</span>
)}
</div>
</div>
</>
)}
</div>
);
+2 -2
View File
@@ -12,7 +12,7 @@ const styles: Record<RunStatus, string> = {
interrupted: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
};
const labels: Record<RunStatus, string> = {
export const runStatusLabels: Record<RunStatus, string> = {
queued: "Queued",
running: "Running",
succeeded: "Succeeded",
@@ -31,7 +31,7 @@ export function RunStatusBadge({ status }: { status: RunStatus }) {
{status === "running" && (
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-current" />
)}
{labels[status]}
{runStatusLabels[status]}
</Badge>
);
}
+32 -18
View File
@@ -14,6 +14,7 @@ interface RunTerminalProps {
runId: string;
initialStatus: RunStatus;
initialLog: string;
initialLogBytes: number;
initialExitCode: number | null;
}
@@ -24,31 +25,44 @@ export function RunTerminal({
runId,
initialStatus,
initialLog,
initialLogBytes,
initialExitCode,
}: RunTerminalProps) {
const [status, setStatus] = useState<RunStatus>(initialStatus);
const [exitCode, setExitCode] = useState<number | null>(initialExitCode);
const termRef = useRef<XtermViewHandle>(null);
const { cancel } = useRunSocket(runId, (message: ServerMessage) => {
if (message.type === "output") {
// Scripts that colorize their own output (via ANSI codes) render as-is; stderr additionally
// gets wrapped in red so failures stand out even from tools that don't colorize themselves.
const chunk =
message.stream === "stderr"
? `${ANSI_RED}${message.chunk}${ANSI_RESET}`
: message.chunk;
termRef.current?.write(chunk);
} else if (message.type === "status") {
setStatus(message.status);
setExitCode(message.exitCode ?? null);
if (message.status !== "queued" && message.status !== "running") {
toast.info(`Run ${message.status.replace("_", " ")}`);
const { cancel } = useRunSocket(
runId,
(message: ServerMessage) => {
if (message.type === "output") {
// Scripts that colorize their own output (via ANSI codes) render as-is; stderr additionally
// gets wrapped in red so failures stand out even from tools that don't colorize themselves.
const chunk =
message.stream === "stderr"
? `${ANSI_RED}${message.chunk}${ANSI_RESET}`
: message.chunk;
termRef.current?.write(chunk);
} else if (message.type === "status") {
// The server also resends the run's current status right after subscribing, so a client
// that connects after a fast run has already finished still gets the real status instead
// of being stuck on whatever was known at page render. Only toast when it's new info.
const changed = message.status !== status;
setStatus(message.status);
setExitCode(message.exitCode ?? null);
if (
changed &&
message.status !== "queued" &&
message.status !== "running"
) {
toast.info(`Run ${message.status.replace("_", " ")}`);
}
} else if (message.type === "error") {
toast.error(message.message);
}
} else if (message.type === "error") {
toast.error(message.message);
}
});
},
initialLogBytes,
);
const isActive = status === "queued" || status === "running";
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { runStatusValues } from "@/lib/db/schema";
import { runStatusLabels } from "./run-status-badge";
interface RunsToolbarProps {
scripts: { id: string; name: string }[];
}
export function RunsToolbar({ scripts }: RunsToolbarProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const urlQ = searchParams.get("q") ?? "";
const [search, setSearch] = useState(urlQ);
// Tracks the URL value `search` was last synced from, so a change to the URL from elsewhere
// (back/forward, clearing filters) can be reflected without stomping on in-progress typing.
const [syncedQ, setSyncedQ] = useState(urlQ);
if (urlQ !== syncedQ) {
setSyncedQ(urlQ);
setSearch(urlQ);
}
function updateParams(updates: Record<string, string | null>) {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === null) params.delete(key);
else params.set(key, value);
}
// Any filter/search change makes the current page number meaningless.
params.delete("page");
router.push(`${pathname}?${params.toString()}`);
}
useEffect(() => {
const timeout = setTimeout(() => {
if (search === urlQ) return;
updateParams({ q: search || null });
}, 300);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-debounce when the typed value changes
}, [search]);
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
placeholder="Search script, command, or triggered by..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="sm:max-w-xs"
/>
<Select
value={searchParams.get("status") ?? "all"}
onValueChange={(value) =>
updateParams({ status: value === "all" ? null : value })
}
>
<SelectTrigger className="sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{runStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{runStatusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={searchParams.get("scriptId") ?? "all"}
onValueChange={(value) =>
updateParams({ scriptId: value === "all" ? null : value })
}
>
<SelectTrigger className="sm:w-48">
<SelectValue placeholder="Script" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All scripts</SelectItem>
{scripts.map((script) => (
<SelectItem key={script.id} value={script.id}>
{script.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
+12 -1
View File
@@ -6,10 +6,15 @@ import type { ServerMessage } from "@/lib/ws/protocol";
export function useRunSocket(
runId: string,
onMessage: (message: ServerMessage) => void,
/** Byte offset of the log content the caller already has (e.g. from server-rendered initial
* log) - lets the server catch the socket up on anything written/broadcast before it
* subscribed, instead of resending from the start. */
afterBytes = 0,
) {
const wsRef = useRef<WebSocket | null>(null);
const [connected, setConnected] = useState(false);
const onMessageRef = useRef(onMessage);
const afterBytesRef = useRef(afterBytes);
useEffect(() => {
onMessageRef.current = onMessage;
@@ -22,7 +27,13 @@ export function useRunSocket(
ws.onopen = () => {
setConnected(true);
ws.send(JSON.stringify({ type: "subscribe", runId }));
ws.send(
JSON.stringify({
type: "subscribe",
runId,
afterBytes: afterBytesRef.current,
}),
);
};
ws.onmessage = (event) => {
+49
View File
@@ -0,0 +1,49 @@
import fs from "node:fs";
const MAX_TAIL_BYTES = 200_000;
export interface LogTail {
text: string;
/** Total file size at read time - callers use this as an offset to later fetch only what's
* been appended since (see `readLogSince`). */
size: number;
}
/** Reads up to the last `maxBytes` of a log file. */
export function readLogTail(
logFilePath: string,
maxBytes = MAX_TAIL_BYTES,
): LogTail {
if (!fs.existsSync(logFilePath)) return { text: "", size: 0 };
const { size } = fs.statSync(logFilePath);
const start = Math.max(0, size - maxBytes);
const fd = fs.openSync(logFilePath, "r");
try {
const buffer = Buffer.alloc(size - start);
fs.readSync(fd, buffer, 0, buffer.length, start);
return {
text: (start > 0 ? "... (truncated)\n" : "") + buffer.toString("utf-8"),
size,
};
} finally {
fs.closeSync(fd);
}
}
/** Reads whatever has been appended to the log file after byte offset `fromByte`. Used to catch
* up a WS subscriber that connects after some output has already been written and broadcast -
* fast scripts can finish (and emit their whole output) before a client's WS subscribe message
* reaches the server, so a plain live-broadcast-only subscription would leave it stuck blank. */
export function readLogSince(logFilePath: string, fromByte: number): string {
if (!fs.existsSync(logFilePath)) return "";
const { size } = fs.statSync(logFilePath);
if (size <= fromByte) return "";
const fd = fs.openSync(logFilePath, "r");
try {
const buffer = Buffer.alloc(size - fromByte);
fs.readSync(fd, buffer, 0, buffer.length, fromByte);
return buffer.toString("utf-8");
} finally {
fs.closeSync(fd);
}
}
+11 -3
View File
@@ -7,7 +7,12 @@ function fieldSchema(variable: VariableConfig): z.ZodTypeAny {
switch (variable.type) {
case "string": {
let s = z.string();
if (variable.minLength !== undefined) s = s.min(variable.minLength);
// `required` alone has to reject "" even when the author didn't set an explicit
// minLength - otherwise an empty string satisfies a bare z.string() and the run starts
// with a "required" field effectively unset.
const minLength =
variable.minLength ?? (variable.required ? 1 : undefined);
if (minLength !== undefined) s = s.min(minLength);
if (variable.maxLength !== undefined) s = s.max(variable.maxLength);
if (variable.pattern) s = s.regex(new RegExp(variable.pattern));
field = s;
@@ -26,9 +31,12 @@ function fieldSchema(variable: VariableConfig): z.ZodTypeAny {
case "enum":
field = z.enum(variable.choices as [string, ...string[]]);
break;
case "multiselect":
field = z.array(z.enum(variable.choices as [string, ...string[]]));
case "multiselect": {
let arr = z.array(z.enum(variable.choices as [string, ...string[]]));
if (variable.required) arr = arr.min(1);
field = arr;
break;
}
}
if (!variable.required) {
+1 -1
View File
@@ -1,7 +1,7 @@
import type { RunStatus } from "../db/schema";
export type ClientMessage =
| { type: "subscribe"; runId: string }
| { type: "subscribe"; runId: string; afterBytes?: number }
| { type: "unsubscribe"; runId: string }
| { type: "cancel"; runId: string };
+38 -3
View File
@@ -1,5 +1,6 @@
import type { IncomingMessage } from "node:http";
import { WebSocketServer, WebSocket } from "ws";
import { eq } from "drizzle-orm";
import { getConfig } from "../config/load";
import {
extractCookieValue,
@@ -8,14 +9,48 @@ import {
} from "../auth/session";
import { hashToken, verifyTokenHash } from "../auth/tokens";
import { getDb } from "../db/client";
import { apiTokens } from "../db/schema";
import { apiTokens, runs } from "../db/schema";
import { runEvents } from "../runner/events";
import { cancelRun } from "../runner/registry";
import { readLogSince } from "../runner/log-file";
import { isClientMessage, type ServerMessage } from "./protocol";
const subscriptions = new Map<string, Set<WebSocket>>();
function subscribe(runId: string, ws: WebSocket) {
/** A run can start and finish (emitting all its output+status over `runEvents`) before a
* client's `subscribe` message even reaches the server - fast scripts routinely beat the
* WS handshake + subscribe round trip. So every subscribe is answered with a catch-up: whatever
* log bytes exist past what the client already has (`afterBytes`, from its server-rendered
* initial log), plus the run's current status - before the socket starts receiving live
* broadcasts. Read-then-register (rather than register-then-read) trades a theoretical
* microsecond gap for guaranteeing no duplicated output. */
function subscribe(runId: string, ws: WebSocket, afterBytes: number) {
const run = getDb().select().from(runs).where(eq(runs.id, runId)).get();
if (run) {
const catchUp = readLogSince(run.logFilePath, afterBytes);
if (catchUp) {
ws.send(
JSON.stringify({
type: "output",
runId,
stream: "stdout",
chunk: catchUp,
seq: -1,
ts: Date.now(),
} satisfies ServerMessage),
);
}
ws.send(
JSON.stringify({
type: "status",
runId,
status: run.status,
exitCode: run.exitCode,
ts: Date.now(),
} satisfies ServerMessage),
);
}
let set = subscriptions.get(runId);
if (!set) {
set = new Set();
@@ -80,7 +115,7 @@ export function attachWsServer(wss: WebSocketServer) {
switch (parsed.type) {
case "subscribe":
subscribe(parsed.runId, ws);
subscribe(parsed.runId, ws, parsed.afterBytes ?? 0);
break;
case "unsubscribe":
unsubscribe(parsed.runId, ws);