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
34 changes: 6 additions & 28 deletions apps/web/src/global-routines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ import { useBench } from "./bench-context";
import { useFromBench } from "./shell/page-crumbs";
import { WORKFLOWS_PATH_PREFIX } from "./path-ids";
import { tenantKeys } from "./query-client";
import {
listScheduledWorkflows,
runScheduledWorkflowNow,
setScheduledWorkflowStatus,
} from "./routines-api";
import { listScheduledWorkflows, runScheduledWorkflowNow } from "./routines-api";
import type { ScheduledWorkflowDefinition } from "./routines-api";

const ROUTINES_QUERY_SCOPE = "global-page";
Expand Down Expand Up @@ -98,45 +94,27 @@ export function useInvalidateRoutines(): (tenantId: string) => void {

export type RoutineActions = {
readonly runNow: (row: GlobalRoutineRow) => Promise<void>;
readonly setEnabled: (row: GlobalRoutineRow, enabled: boolean) => Promise<void>;
};

export function useRoutineActions(): RoutineActions {
const invalidate = useInvalidateRoutines();
return {
runNow: async (row) => {
try {
await runScheduledWorkflowNow(row.tenantId, row.definition.definitionId);
invalidate(row.tenantId);
toast(`${row.definition.name} started`);
} catch (cause) {
reportError(cause, {
operation: "scheduled_workflow_run_now",
tenantId: row.tenantId,
});
toast(
`Couldn't start ${row.definition.name}: ${describeApiError(cause, "starting this routine")}`,
);
}
},
setEnabled: async (row, enabled) => {
try {
await setScheduledWorkflowStatus(
await runScheduledWorkflowNow(
row.tenantId,
row.definition.definitionId,
enabled ? "deployed" : "stopped",
row.definition.name,
);
invalidate(row.tenantId);
toast(`${row.definition.name} started`);
} catch (cause) {
reportError(cause, {
operation: "scheduled_workflow_set_status",
operation: "scheduled_workflow_run_now",
tenantId: row.tenantId,
});
toast(
`Couldn't ${enabled ? "resume" : "pause"} ${row.definition.name}: ${describeApiError(
cause,
enabled ? "resuming this routine" : "pausing this routine",
)}`,
`Couldn't start ${row.definition.name}: ${describeApiError(cause, "starting this routine")}`,
);
}
},
Expand Down
23 changes: 2 additions & 21 deletions apps/web/src/pages/routine-detail-page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, PageShell, RichEmptyState, RunNowButton, Skeleton } from "@corbits/react-ui";
import { PageShell, RichEmptyState, RunNowButton, Skeleton } from "@corbits/react-ui";
import { type } from "arktype";
import { useState } from "react";
import type { ReactNode } from "react";
Expand Down Expand Up @@ -165,14 +165,11 @@ function RoutineNotice({

export function RoutineDetailPage({
row,
onToggleEnabled,
onRunNow,
}: {
readonly row: GlobalRoutineRow;
readonly onToggleEnabled: (enabled: boolean) => void;
readonly onRunNow: () => Promise<void>;
}) {
const enabled = row.definition.status === "deployed";
const sentence = scheduleSentence(row.definition.schedule);
const runs = useRoutineRuns(row.tenantId, row.definition.definitionId, 1);
const lastRun = runs.kind === "ready" ? runs.data.data[0] : undefined;
Expand All @@ -186,14 +183,6 @@ export function RoutineDetailPage({
]}
actions={
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onToggleEnabled(!enabled)}
>
{enabled ? "Pause" : "Resume"}
</Button>
<RunNowButton variant="outline" size="sm" onRun={onRunNow} />
</div>
}
Expand Down Expand Up @@ -249,13 +238,5 @@ export function RoutineDetailRoute({ segment }: { readonly segment: string }) {
);
}

return (
<RoutineDetailPage
row={resolved}
onToggleEnabled={(enabled) => {
void actions.setEnabled(resolved, enabled);
}}
onRunNow={() => actions.runNow(resolved)}
/>
);
return <RoutineDetailPage row={resolved} onRunNow={() => actions.runNow(resolved)} />;
}
2 changes: 1 addition & 1 deletion apps/web/src/pages/routines-page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Workflows: an ops table of deployed workflow definitions, including
// paused (`stopped`) ones. Pause/resume and run-now are the only writes.
// paused (`stopped`) ones. Run-now is the only write.
import { EmptyState, RichEmptyState, RunNowButton } from "@corbits/react-ui";
import { cronSentence } from "@corbits/workflows/client";
import { useState } from "react";
Expand Down
56 changes: 30 additions & 26 deletions apps/web/src/routines-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// The Routines page's seam to stock workflow deployments. See
// docs/routines-scheduling.md for the schedule join and why run-now/
// pause/resume stay rejected promises.
// run-now goes through the stock trigger route.

import { type } from "arktype";
import { useQuery } from "@tanstack/react-query";
Expand Down Expand Up @@ -183,35 +183,39 @@ export async function listScheduledWorkflows(
});
}

/** No stock route reruns a deployment on demand yet. */
export function runScheduledWorkflowNow(
tenantId: string,
definitionId: string,
): Promise<{ runId: string }> {
return Promise.reject(
new ApiQueryError(
"Running a workflow now has no stock route yet.",
undefined,
`/api/tenants/${tenantId}/workflows/deployments/${encodeURIComponent(definitionId)}/run`,
),
);
}
const TriggerResponse = type({ runId: "string", address: "string", messageId: "string" });

/** No stock route pauses or resumes a deployment yet. */
export function setScheduledWorkflowStatus(
/** Fires the deployment through the stock trigger route. The deployment id
* is its anchor run id; the message mirrors the workflow's cron row when one
* exists so a manual run carries what the schedule would. */
export async function runScheduledWorkflowNow(
tenantId: string,
definitionId: string,
status: "deployed" | "stopped",
): Promise<{ readonly status: string }> {
return Promise.reject(
new ApiQueryError(
status === "deployed"
? "Resuming a deployment has no stock route yet."
: "Pausing a deployment has no stock route yet.",
undefined,
`/api/tenants/${tenantId}/workflows/deployments/${encodeURIComponent(definitionId)}`,
),
definitionName?: string,
): Promise<{ runId: string }> {
const path = `/api/tenants/${tenantId}/workflows/${encodeURIComponent(definitionId)}/mail`;
const schedules = await listCronSchedules(tenantId);
const row = schedules.find(
(schedule) => schedule.stoppedAt === null && schedule.definitionName === definitionName,
);
const content = row === undefined ? "Run now" : `${row.subject}\n\n${row.body}`;
const response = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ content }),
});
if (response.status === 401) throw new UnauthenticatedError();
if (response.status === 409) {
throw new ApiQueryError("This workflow has ended; redeploy to run it again.", 409, path);
}
if (!response.ok) {
throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path);
}
const parsed = TriggerResponse(await response.json());
if (parsed instanceof type.errors) {
throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path);
}
return { runId: parsed.runId };
}

// Keys must be stable arrays under `["tenant", tenantId, ...]` so a bench
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/shell/context-menu/items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ function routineMenu(
onSelect: () => {
void (async () => {
try {
await runScheduledWorkflowNow(tenantId, target.id);
await runScheduledWorkflowNow(tenantId, target.id, target.name);
toast(`${target.name} started`);
actions.onRoutineRan(tenantId);
} catch (cause) {
Expand Down
8 changes: 5 additions & 3 deletions docs/routines-scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ for a name no definition carries is rejected the same way

## Run-now and pause/resume

Neither has a backing stock route (`/deployments` is list/create only; no
per-deployment PATCH or trigger route exists), so both stay rejected
promises naming the missing route.
Run now posts to the stock trigger route
(`POST /workflows/:runId/mail`, `:runId` = the deployment id), carrying the
cron row's subject and body when one exists. A 409 means the run has ended.
Pause/resume has no stock or `@corbits/cron` primitive, so no control is
shown.
Loading