Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/workflows-local-explorer-step-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": minor
---

Add full step output retrieval to the Workflows local explorer

The Workflows instance details response truncates streamed step outputs to a short preview (or a placeholder for binary streams). Mirroring the production `/step` endpoint, the local explorer now exposes `GET /workflows/{workflow_name}/instances/{instance_id}/step`, which returns a flat `{ status, error, output }` body for a single step. The explorer UI fetches this on demand when a step whose inline preview was truncated is expanded.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, test } from "vitest";
import { isTruncatedStreamPreview } from "../../components/workflows/helpers";
import {
getStepDisplayName,
getStepKey,
Expand Down Expand Up @@ -106,3 +107,31 @@ describe("getRestartFromStepParam", () => {
});
});
});

describe("isTruncatedStreamPreview", () => {
test("detects a truncated text preview", ({ expect }) => {
expect(isTruncatedStreamPreview("some output[truncated output]")).toBe(
true
);
});

test("detects a binary stream placeholder", ({ expect }) => {
expect(
isTruncatedStreamPreview("[ReadableStream (binary): 2048 bytes]")
).toBe(true);
});

test("detects an incomplete stream placeholder", ({ expect }) => {
expect(isTruncatedStreamPreview("[ReadableStream: 100 bytes]")).toBe(true);
});

test("returns false for a complete inline value", ({ expect }) => {
expect(isTruncatedStreamPreview("hello world")).toBe(false);
});

test("returns false for non-string values", ({ expect }) => {
expect(isTruncatedStreamPreview({ foo: "bar" })).toBe(false);
expect(isTruncatedStreamPreview(undefined)).toBe(false);
expect(isTruncatedStreamPreview(42)).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { useState, type JSX } from "react";
export function CopyButton({
text,
label = "Copy",
disabled = false,
}: {
text: string;
label?: string;
disabled?: boolean;
}): JSX.Element {
const [copied, setCopied] = useState(false);

Expand All @@ -19,7 +21,8 @@ export function CopyButton({

return (
<button
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-kumo-subtle transition-colors hover:bg-kumo-fill"
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-kumo-subtle transition-colors hover:bg-kumo-fill disabled:cursor-not-allowed disabled:opacity-40"
disabled={disabled}
onClick={handleCopy}
title={label}
>
Expand Down
174 changes: 163 additions & 11 deletions packages/local-explorer-ui/src/components/workflows/StepRow.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { Loader, Tooltip } from "@cloudflare/kumo";
import { ArrowClockwiseIcon, CheckIcon, PlusIcon } from "@phosphor-icons/react";
import { memo, type JSX } from "react";
import { memo, useEffect, useState, type JSX } from "react";
import { workflowsGetStepOutput } from "../../api";
import { CopyButton } from "./CopyButton";
import { formatDuration, formatJson } from "./helpers";
import {
formatDuration,
formatJson,
isTruncatedStreamPreview,
} from "./helpers";
import { ScrollableCodeBlock } from "./ScrollableCodeBlock";
import { Timestamp } from "./Timestamp";
import type { StepData } from "./types";
Expand Down Expand Up @@ -73,11 +78,15 @@ export const StepRow = memo(function StepRow({
isExpanded,
onToggleExpanded,
onRestartFromStep,
workflowName,
instanceId,
}: {
step: StepData;
isExpanded: boolean;
onToggleExpanded: () => void;
onRestartFromStep?: (step: StepData) => void;
workflowName?: string;
instanceId?: string;
}): JSX.Element {
const hasDetails =
step.type === "step" ||
Expand Down Expand Up @@ -151,7 +160,13 @@ export const StepRow = memo(function StepRow({
<div className="-mx-1 -mb-1">
<div className="mt-1 h-2 rounded-t-lg border-t border-kumo-fill" />
<div className="px-4 pt-3 pb-4">
{step.type === "step" && <StepDoDetails step={step} />}
{step.type === "step" && (
<StepDoDetails
step={step}
workflowName={workflowName}
instanceId={instanceId}
/>
)}
{step.type === "waitForEvent" && (
<WaitForEventDetails step={step} />
)}
Expand All @@ -165,24 +180,139 @@ export const StepRow = memo(function StepRow({
function StepCodeCard({
label,
content,
loading = false,
note,
}: {
label: string;
content: string;
loading?: boolean;
note?: string;
}): JSX.Element {
return (
<div>
<h5 className="mb-2 text-sm font-medium text-kumo-default">{label}</h5>
<div className="relative overflow-hidden rounded-lg border border-kumo-fill bg-kumo-base">
<ScrollableCodeBlock content={content} />
{loading ? (
<div className="flex items-center gap-2 p-3 text-sm text-kumo-subtle">
<Loader size={14} />
Loading full output…
</div>
) : (
<ScrollableCodeBlock content={content} />
)}
<div className="absolute top-1.5 right-1.5">
<CopyButton text={content} label={`Copy ${label.toLowerCase()}`} />
<CopyButton
text={content}
label={`Copy ${label.toLowerCase()}`}
disabled={loading}
/>
</div>
</div>
{note && <p className="mt-1 text-xs text-kumo-subtle">{note}</p>}
</div>
);
}

function StepDoDetails({ step }: { step: StepData }): JSX.Element {
type FullOutputState =
| { status: "idle" }
| { status: "loading" }
| { status: "loaded"; text: string; note?: string }
| { status: "error" };

function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(
...bytes.subarray(offset, offset + chunkSize)
);
}
return btoa(binary);
}

// Lazily fetch the full output when the inline value is only a truncated stream
// preview. Streamed outputs come back as octet-stream bytes, else flat JSON.
function useFullStepOutput(
step: StepData,
workflowName?: string,
instanceId?: string
): FullOutputState {
const needsFetch =
step.success === true && isTruncatedStreamPreview(step.output);
const [state, setState] = useState<FullOutputState>({ status: "idle" });

useEffect(() => {
if (!needsFetch || !workflowName || !instanceId || !step.name) {
return;
}
let active = true;
setState({ status: "loading" });
void workflowsGetStepOutput({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think 1gb of download all at once would crash the tab?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Funny enough, prod dash doesn't work for this specific edge case of 1GB step. It does not crash the tab but has a timeout of 60s for which 1GB can't be downloaded. We could cap and suggest the download to a file?

path: { workflow_name: workflowName, instance_id: instanceId },
query: { name: step.name, type: "step" },
parseAs: "arrayBuffer",
throwOnError: false,
})
.then((res) => {
if (!active) {
return;
}
const buffer = res.data as unknown as ArrayBuffer | undefined;
if (!res.response.ok || !buffer) {
setState({ status: "error" });
return;
}
const bytes = new Uint8Array(buffer);
const contentType = res.response.headers.get("content-type") ?? "";
if (contentType.includes("application/octet-stream")) {
try {
const text = new TextDecoder("utf-8", { fatal: true }).decode(
bytes
);
setState({ status: "loaded", text });
} catch {
setState({
status: "loaded",
text: bytesToBase64(bytes),
note: `Binary output (${bytes.byteLength} bytes), base64-encoded`,
});
}
return;
}
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as {
result?: { output?: unknown };
};
setState({
status: "loaded",
text: formatJson(parsed.result?.output),
});
})
.catch(() => {
if (active) {
setState({ status: "error" });
}
});
return () => {
active = false;
};
}, [needsFetch, workflowName, instanceId, step.name]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think restart from step can display stale output? it omits step output/version


return needsFetch ? state : { status: "idle" };
}

function StepDoDetails({
step,
workflowName,
instanceId,
}: {
step: StepData;
workflowName?: string;
instanceId?: string;
}): JSX.Element {
const fullOutput = useFullStepOutput(step, workflowName, instanceId);
const isTruncated =
step.success === true && isTruncatedStreamPreview(step.output);

// Get error text from last failed attempt
const failedAttempt =
step.success === false && step.attempts
Expand All @@ -191,10 +321,27 @@ function StepDoDetails({ step }: { step: StepData }): JSX.Element {
const errorText = failedAttempt?.error
? `${failedAttempt.error.name}: ${failedAttempt.error.message}`
: null;
const outputText =
step.success === true && step.output !== undefined
? formatJson(step.output)
: null;

// Resolve the output card: full fetched value when the inline output is a
// truncated stream preview, otherwise the inline value.
let outputLoading = false;
let outputNote: string | undefined;
let outputText: string | null = null;
if (step.success === true) {
if (isTruncated) {
if (fullOutput.status === "loaded") {
outputText = fullOutput.text;
outputNote = fullOutput.note;
} else if (fullOutput.status === "error") {
outputText = formatJson(step.output);
} else {
outputLoading = true;
outputText = "";
}
} else if (step.output !== undefined) {
outputText = formatJson(step.output);
}
}

// Left side: output or error. Right side: config.
const leftLabel = errorText ? "Error" : "Output";
Expand All @@ -207,7 +354,12 @@ function StepDoDetails({ step }: { step: StepData }): JSX.Element {
<div
className={configContent ? "grid grid-cols-1 gap-4 md:grid-cols-2" : ""}
>
<StepCodeCard label={leftLabel} content={leftContent} />
<StepCodeCard
label={leftLabel}
content={leftContent}
loading={!errorText && outputLoading}
note={errorText ? undefined : outputNote}
/>
{configContent && (
<StepCodeCard label="Config" content={configContent} />
)}
Expand Down
10 changes: 10 additions & 0 deletions packages/local-explorer-ui/src/components/workflows/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ export function timeAgo(dateString: string | undefined): string {
return `${days}d ago`;
}

const STREAM_PREVIEW_TRUNCATED_MARKER = "[truncated output]";

export function isTruncatedStreamPreview(value: unknown): value is string {
return (
typeof value === "string" &&
(value.endsWith(STREAM_PREVIEW_TRUNCATED_MARKER) ||
value.startsWith("[ReadableStream"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think a regex would be more correct here?

Suggested change
value.startsWith("[ReadableStream"))
/^\[ReadableStream:\s\d+\s\w+]$/.test(value)

(Then we can also have some other tests that make sure that incorrect string starting with [ReadableStream are not accepted)

);
}

export function formatJson(value: unknown): string {
if (value === null || value === undefined) {
return "N/A";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,13 @@ const STEP_TYPE_FILTERS = [
const StepHistory = memo(function StepHistory({
steps,
onRestartFromStep,
workflowName,
instanceId,
}: {
steps: InstanceDetails["steps"];
onRestartFromStep?: (step: StepData) => void;
workflowName: string;
instanceId: string;
}) {
const stepList = steps ?? [];
const [search, setSearch] = useState("");
Expand Down Expand Up @@ -361,6 +365,8 @@ const StepHistory = memo(function StepHistory({
isExpanded={expandedStepKeys.has(key)}
onToggleExpanded={() => toggleStepExpanded(key)}
onRestartFromStep={onRestartFromStep}
workflowName={workflowName}
instanceId={instanceId}
/>
);
})
Expand Down Expand Up @@ -840,6 +846,8 @@ function InstanceDetailView() {
<StepHistory
steps={details.steps}
onRestartFromStep={handleRestartFromStep}
workflowName={params.workflowName}
instanceId={instanceId}
/>
</div>
</div>
Expand Down
Loading
Loading