Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions admin/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,29 @@ a:hover {
font-weight: 600;
color: var(--ink-2);
}
.saved-inputs {
display: grid;
gap: 12px;
margin: 0 0 16px;
padding: 16px;
border: 1px solid var(--line-soft);
border-radius: var(--r-stage);
}
.saved-inputs__form {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 12px;
}
.saved-inputs__form .field {
flex: 1;
min-width: 0;
}
.saved-inputs__result {
display: grid;
justify-items: start;
gap: 8px;
}
.field {
display: grid;
gap: 6px;
Expand Down
64 changes: 38 additions & 26 deletions admin/app/runs/[runId]/console.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { leadArtifact } from '@/lib/blocks';
import { GraphCanvas } from '@/components/graph-canvas';
import { Progress } from '@/components/progress';
import { RunOverview } from '@/components/run-overview';
import { SavedInputs } from '@/components/saved-inputs';
import { StageStrip } from '@/components/stage-strip';
import { Status } from '@/components/status';
import { useNow } from '@/components/use-now';
Expand Down Expand Up @@ -436,6 +437,7 @@ export default function RunConsole({ runId }: { runId: string }) {
? ['failed', 'blocked', 'succeeded', 'canceled'].includes(selected.status)
: false;
const paused = run.live?.paused === true || run.status === 'paused';
const savedDelivery = run.configuration.retainedDelivery?.schema === 'wander.saved-inputs/1';

return (
<main className="page" id="main">
Expand Down Expand Up @@ -526,6 +528,14 @@ export default function RunConsole({ runId }: { runId: string }) {
</header>

<RunOverview runId={runId} onSelectStage={setSelectedId} />
{savedDelivery ? (
<p className="notice">
Prepared from saved inputs. Resume to allow one local packaging attempt. Stage retries are
disabled; reconstruction quality remains unverified.
</p>
) : (
<SavedInputs key={runId} runId={runId} />
)}

{run.liveError ? (
<p className="notice notice--warn" style={{ marginBottom: 16 }}>
Expand Down Expand Up @@ -864,37 +874,39 @@ export default function RunConsole({ runId }: { runId: string }) {
</div>
) : null}

<div className="decide__actions">
{isGate || selected.definition.kind === 'human' ? (
<button
className="button button--approve"
type="button"
disabled={busy || !attempt}
onClick={onApprove}
>
Approve
</button>
) : null}
<button
className={`button${!isGate && canRetry ? ' button--primary' : ''}`}
type="button"
disabled={busy}
onClick={() => onCommand('retry')}
>
Retry stage
</button>
<span className="gap" />
{isGate || selected.definition.kind === 'human' ? (
{!savedDelivery ? (
<div className="decide__actions">
{isGate || selected.definition.kind === 'human' ? (
<button
className="button button--approve"
type="button"
disabled={busy || !attempt}
onClick={onApprove}
>
Approve
</button>
) : null}
<button
className="button button--danger"
className={`button${!isGate && canRetry ? ' button--primary' : ''}`}
type="button"
disabled={busy}
onClick={() => onCommand('reject')}
onClick={() => onCommand('retry')}
>
Reject
Retry stage
</button>
) : null}
</div>
<span className="gap" />
{isGate || selected.definition.kind === 'human' ? (
<button
className="button button--danger"
type="button"
disabled={busy}
onClick={() => onCommand('reject')}
>
Reject
</button>
) : null}
</div>
) : null}
</div>
</>
)}
Expand Down
147 changes: 147 additions & 0 deletions admin/components/saved-inputs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
'use client';

import Link from 'next/link';
import { useRef, useState } from 'react';
import { checkSavedInputs, prepareSavedInputRun } from '@/lib/client';
import { formatBytes } from '@/lib/format';
import type { SavedInputsAvailability } from '@/lib/types';

export function SavedInputs({ runId }: { runId: string }) {
const [availability, setAvailability] = useState<SavedInputsAvailability | null>(null);
const [checking, setChecking] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [name, setName] = useState('');
const [preparedId, setPreparedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const busyRef = useRef(false);

async function check() {
if (busyRef.current) return;
busyRef.current = true;
setChecking(true);
setError(null);
setPreparedId(null);
try {
setAvailability(await checkSavedInputs(runId));
} catch (cause) {
setError(`Saved files could not be checked: ${(cause as Error).message}`);
} finally {
busyRef.current = false;
setChecking(false);
}
}

async function submitPreparation() {
if (!availability?.available || busyRef.current) return;
busyRef.current = true;
setSubmitting(true);
setError(null);
try {
const result = await prepareSavedInputRun(runId, name.trim() || undefined);
setPreparedId(result.id);
} catch (cause) {
setError(`Paused run could not be prepared: ${(cause as Error).message}`);
} finally {
busyRef.current = false;
setSubmitting(false);
}
}

function prepare(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
void submitPreparation();
}

return (
<section className="saved-inputs" aria-labelledby="saved-inputs-title">
<div>
<h2 className="create__title" id="saved-inputs-title">
Prepare a delivery run
</h2>
<p className="field__hint">
Reuses saved reconstruction. Local packaging only; no new inference or quality approval.
</p>
</div>
{!availability ? (
<button className="button button--small" type="button" onClick={check} disabled={checking}>
{checking ? 'Checking saved files…' : 'Use saved inputs'}
</button>
) : availability.available ? (
preparedId ? null : (
<>
<p className="notice notice--ok" role="status">
{availability.fileCount ?? 0} saved files · {formatBytes(availability.byteCount ?? 0)}
</p>
<form className="saved-inputs__form" onSubmit={prepare}>
<div className="field">
<label htmlFor="saved-input-run-name">Run name (optional)</label>
<input
id="saved-input-run-name"
type="text"
className="mono"
placeholder="generated when blank"
pattern="[A-Za-z0-9][A-Za-z0-9_.-]{0,127}"
maxLength={128}
value={name}
disabled={submitting}
onChange={(event) => setName(event.target.value)}
/>
</div>
<button
className="button button--small button--primary"
type="submit"
disabled={submitting}
>
{submitting ? 'Preparing…' : 'Prepare paused run'}
</button>
</form>
</>
)
) : (
<div className="saved-inputs__result" aria-live="polite">
<p className="notice notice--warn">
{availability.reason || 'Saved inputs are unavailable.'}
</p>
<button
className="button button--small"
type="button"
onClick={check}
disabled={checking}
>
{checking ? 'Checking saved files…' : 'Check again'}
</button>
</div>
)}
{error ? (
<div className="saved-inputs__result" role="alert">
<p className="notice notice--error">{error}</p>
{availability?.available && !preparedId ? (
<button
className="button button--small"
type="button"
onClick={() => void submitPreparation()}
disabled={submitting}
>
Retry preparation
</button>
) : (
<button
className="button button--small"
type="button"
onClick={check}
disabled={checking}
>
Retry check
</button>
)}
</div>
) : null}
{preparedId ? (
<p className="notice notice--ok" role="status">
Paused run prepared.{' '}
<Link href={`/runs/${encodeURIComponent(preparedId)}`}>Open prepared run</Link>
</p>
) : null}
</section>
);
}
17 changes: 17 additions & 0 deletions admin/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
ReviewTranscript,
RunArtifact,
RunCommand,
PreparedSavedInputRun,
SavedInputsAvailability,
TranscriptTail,
} from './types';

Expand Down Expand Up @@ -49,6 +51,21 @@ export async function loadRun(runId: string): Promise<PipelineRun> {
)) as PipelineRun;
}

export async function checkSavedInputs(runId: string): Promise<SavedInputsAvailability> {
return (await unwrap(
await fetch(`${ROOT}/runs/${encodeURIComponent(runId)}/saved-inputs`, { cache: 'no-store' }),
)) as SavedInputsAvailability;
}

export async function prepareSavedInputRun(
runId: string,
id?: string,
): Promise<PreparedSavedInputRun> {
return (await send(`/runs/${encodeURIComponent(runId)}/saved-inputs`, {
id,
})) as PreparedSavedInputRun;
}

export async function uploadRun(input: {
file: File;
options: GraphOptions;
Expand Down
18 changes: 17 additions & 1 deletion admin/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,10 @@ export interface PipelineRun {
branchKey?: string | null;
graphVersion: string;
sourceSha256: string;
configuration: { options: GraphOptions };
configuration: {
options: GraphOptions;
retainedDelivery?: { schema: string; sourceRunId: string };
};
budget?: { currency: string; maximum_cost: number | null };
createdAt?: string;
updatedAt?: string;
Expand All @@ -176,6 +179,19 @@ export interface PipelineRun {
liveError?: string | null;
}

export interface SavedInputsAvailability {
available: boolean;
reason?: string;
sourceRunId: string;
fileCount?: number;
byteCount?: number;
}

export interface PreparedSavedInputRun {
id: string;
status: 'paused';
}

export type RunCommand = 'reject' | 'retry' | 'pause' | 'resume' | 'cancel';

/** The outcomes an operator may record for a provider claim that ended unobserved. */
Expand Down
49 changes: 49 additions & 0 deletions admin/tests/saved-inputs-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { checkSavedInputs, prepareSavedInputRun } from '../lib/client';

test('saved input requests use the authenticated same-origin proxy and encode run ids', async () => {
const originalFetch = globalThis.fetch;
const requests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ input, init });
return Response.json({
available: true,
sourceRunId: 'source/one',
fileCount: 3,
byteCount: 12,
});
}) as typeof fetch;
try {
const result = await checkSavedInputs('run/one');
assert.deepEqual(result, {
available: true,
sourceRunId: 'source/one',
fileCount: 3,
byteCount: 12,
});
assert.equal(String(requests[0]?.input), '/api/pipeline/runs/run%2Fone/saved-inputs');
assert.equal(requests[0]?.init?.cache, 'no-store');

await prepareSavedInputRun('run/one', 'delivery-1');
assert.equal(String(requests[1]?.input), '/api/pipeline/runs/run%2Fone/saved-inputs');
assert.equal(requests[1]?.init?.method, 'POST');
assert.deepEqual(JSON.parse(String(requests[1]?.init?.body)), { id: 'delivery-1' });
} finally {
globalThis.fetch = originalFetch;
}
});

test('saved input requests surface proxy detail for retry messaging', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
Response.json(
{ detail: 'source run is still active' },
{ status: 409 },
)) as unknown as typeof fetch;
try {
await assert.rejects(prepareSavedInputRun('source'), /source run is still active/);
} finally {
globalThis.fetch = originalFetch;
}
});
Loading
Loading