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
8 changes: 6 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ A workflow run can be in one of the following states:
to claim it.
- **`running`**: The workflow run is actively being executed by a worker.
- **`sleeping`**: The workflow run is waiting for a duration to elapse
(`step.sleep`). The `availableAt` timestamp controls when it becomes available
again.
(`step.sleep`) or waiting for a child workflow result (`step.invokeWorkflow`).
The `availableAt` timestamp controls when it becomes available again.
- **`completed`**: The workflow run has completed successfully.
- **`failed`**: The workflow run has failed after exhausting retries or deadline
reached.
Expand Down Expand Up @@ -220,6 +220,10 @@ worker slot for other work - it's not a blocking sleep but a durable pause.
await step.sleep("wait-one-hour", "1h");
```

**`step.invokeWorkflow(name, options)`**: Starts a child workflow and waits for
it durably. When the timeout is reached (default 7d), the parent step fails but
the child workflow continues running independently.

## 4. Error Handling & Retries

### 4.1. Step Failures & Retries
Expand Down
12 changes: 12 additions & 0 deletions openworkflow/hello-world-parent.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { backend, ow } from "./client.js";
import { helloWorldParent } from "./hello-world-parent.js";

console.log("Running hello-world-parent workflow...");
const handle = await ow.runWorkflow(helloWorldParent.spec, {});

console.log("Waiting for result...");
const result = await handle.result();

console.log(`Workflow result: ${JSON.stringify(result, null, 2)}`);

await backend.stop();
18 changes: 18 additions & 0 deletions openworkflow/hello-world-parent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { helloWorld } from "./hello-world.js";
import { defineWorkflow } from "openworkflow";

/**
* Example workflow that invokes hello-world as a child workflow.
*/
export const helloWorldParent = defineWorkflow(
{ name: "hello-world-parent" },
async ({ step, run }) => {
console.log(`[run ${run.id}]`);

const childResult = await step.invokeWorkflow("hello-world-child", {
workflow: helloWorld,
});
Comment thread
jamescmartinez marked this conversation as resolved.

return { childResult, parentMessage: "Hello from the parent workflow!" };
},
);
32 changes: 21 additions & 11 deletions packages/dashboard/src/components/run-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,22 @@ import { CaretRightIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import type { WorkflowRun } from "openworkflow/internal";

export interface ChildRunRelation {
parentRunId: string;
parentWorkflowName?: string | undefined;
}

export interface RunListProps {
runs: WorkflowRun[];
childRunRelationsByRunId?: Record<string, ChildRunRelation | undefined>;
title?: string;
showHeader?: boolean;
showCount?: boolean;
}

export function RunList({
runs,
childRunRelationsByRunId,
title = "Workflow Runs",
showHeader = true,
showCount = true,
Expand Down Expand Up @@ -60,6 +67,7 @@ export function RunList({
const StatusIcon = config.icon;
const duration = computeDuration(run.startedAt, run.finishedAt);
const startedAt = formatRelativeTime(run.startedAt);
const childRunRelation = childRunRelationsByRunId?.[run.id];

return (
<Link
Expand All @@ -82,27 +90,29 @@ export function RunList({
<div className="mb-1 flex items-center gap-3">
<span className="font-medium">{run.workflowName}</span>
{run.version && (
<Badge
variant="outline"
className="border-border font-mono text-xs"
>
{run.version}
</Badge>
<Badge variant="outline">{run.version}</Badge>
)}
<span className="text-muted-foreground font-mono text-sm">
{run.id}
</span>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-xs">
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
<Badge
variant="outline"
className={cn(
"text-xs capitalize",
config.badgeClass,
)}
className={cn("capitalize", config.badgeClass)}
>
{config.label}
</Badge>
{childRunRelation && (
<Badge variant="outline">
{childRunRelation.parentWorkflowName && (
<span className="mr-2 font-medium">
[{childRunRelation.parentWorkflowName}]
</span>
)}
<span>{childRunRelation.parentRunId}</span>
</Badge>
)}
</div>
</div>

Expand Down
1 change: 0 additions & 1 deletion packages/dashboard/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ function Badge({
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
// @ts-expect-error - render is not typed properly
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
Expand Down
15 changes: 15 additions & 0 deletions packages/dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,19 @@ export const listStepAttemptsServerFn = createServerFn({ method: "GET" })
return result;
});

/**
* Get a single step attempt by ID.
*/
export const getStepAttemptServerFn = createServerFn({ method: "GET" })
.inputValidator(z.object({ stepAttemptId: z.string() }))
.handler(async ({ data }): Promise<StepAttempt | null> => {
const backend = await getBackend();
const stepAttempt = await backend.getStepAttempt({
stepAttemptId: data.stepAttemptId,
});
return stepAttempt;
});

/**
* Create a new workflow run.
*/
Expand Down Expand Up @@ -154,6 +167,8 @@ export const createWorkflowRunServerFn = createServerFn({ method: "POST" })
config: {},
context: null,
input: parsedInput,
parentStepAttemptNamespaceId: null,
parentStepAttemptId: null,
availableAt,
deadlineAt,
});
Expand Down
61 changes: 58 additions & 3 deletions packages/dashboard/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AppLayout } from "@/components/app-layout";
import { CreateRunForm } from "@/components/create-run-form";
import { RunList } from "@/components/run-list";
import { RunList, type ChildRunRelation } from "@/components/run-list";
import { Button } from "@/components/ui/button";
import {
Dialog,
Expand All @@ -11,12 +11,15 @@ import {
} from "@/components/ui/dialog";
import { WorkflowStats } from "@/components/workflow-stats";
import {
getStepAttemptServerFn,
getWorkflowRunCountsServerFn,
getWorkflowRunServerFn,
listWorkflowRunsServerFn,
} from "@/lib/api";
import { usePolling } from "@/lib/use-polling";
import { PlusIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import type { StepAttempt, WorkflowRun } from "openworkflow/internal";
import { useState } from "react";

export const Route = createFileRoute("/")({
Expand All @@ -26,16 +29,64 @@ export const Route = createFileRoute("/")({
listWorkflowRunsServerFn({ data: { limit: 100 } }),
getWorkflowRunCountsServerFn(),
]);
const runs = runsResponse.data;
const childRuns = runs.filter(
(run): run is WorkflowRun & { parentStepAttemptId: string } =>
run.parentStepAttemptId !== null && run.parentStepAttemptId !== "",
);
const parentStepAttemptIds = [
...new Set(childRuns.map((childRun) => childRun.parentStepAttemptId)),
];
const parentStepAttemptsById: Record<string, StepAttempt | null> = {};
await Promise.all(
parentStepAttemptIds.map(async (parentStepAttemptId) => {
parentStepAttemptsById[parentStepAttemptId] =
await getStepAttemptServerFn({
data: { stepAttemptId: parentStepAttemptId },
});
}),
);
const parentRunIds = [
...new Set(
Object.values(parentStepAttemptsById)
.map((parentStepAttempt) => parentStepAttempt?.workflowRunId)
.filter((parentRunId): parentRunId is string => !!parentRunId),
),
];
const parentRunsById: Record<string, WorkflowRun | null> = {};
await Promise.all(
parentRunIds.map(async (parentRunId) => {
parentRunsById[parentRunId] = await getWorkflowRunServerFn({
data: { workflowRunId: parentRunId },
});
}),
);
const childRunRelationsByRunId: Record<string, ChildRunRelation> = {};
for (const childRun of childRuns) {
const parentStepAttempt =
parentStepAttemptsById[childRun.parentStepAttemptId];
if (!parentStepAttempt) {
continue;
}

const parentRun = parentRunsById[parentStepAttempt.workflowRunId];
childRunRelationsByRunId[childRun.id] = {
parentRunId: parentStepAttempt.workflowRunId,
parentWorkflowName: parentRun?.workflowName ?? undefined,
};
}

return {
runsResponse,
workflowRunCounts,
childRunRelationsByRunId,
};
},
});

function HomePage() {
const { runsResponse, workflowRunCounts } = Route.useLoaderData();
const { runsResponse, workflowRunCounts, childRunRelationsByRunId } =
Route.useLoaderData();
const { data: runs } = runsResponse;
const [isCreateRunOpen, setIsCreateRunOpen] = useState(false);
usePolling();
Expand Down Expand Up @@ -63,7 +114,11 @@ function HomePage() {
</div>

<WorkflowStats workflowRunCounts={workflowRunCounts} />
<RunList runs={runs} showHeader={false} />
<RunList
runs={runs}
childRunRelationsByRunId={childRunRelationsByRunId}
showHeader={false}
/>
</div>

<DialogContent size="lg" className="gap-0 p-0">
Expand Down
Loading
Loading