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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@corbits/chat-ui": "workspace:*",
"@corbits/command-palette": "workspace:*",
"@corbits/routines": "workspace:*",
"@corbits/workflow-catalog": "workspace:*",
"@corbits/settings-ui": "workspace:*",
"@corbits/react-ui": "github:corbitsdev/react-ui#bd5057b0f740947ad117fc2c9cbe82bf992423d2",
"@intx/types": "workspace:*",
Expand Down
209 changes: 139 additions & 70 deletions apps/web/src/pages/routines-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import {
EmptyState,
formatRelativeTime,
Input,
Menu,
MenuContent,
MenuItem,
MenuTrigger,
PageShell,
RunNowButton,
Switch,
Expand Down Expand Up @@ -123,20 +127,45 @@ function TriggerPicker({

return (
<div className="flex-col-gap">
<label htmlFor="routine-cadence" className="form-label">
<span id="routine-cadence-label" className="form-label">
Cadence
</label>
<select
id="routine-cadence"
value={kind}
onChange={(event) => setKind(event.target.value as TriggerKind)}
>
<option value="manual">Manual (run only when triggered)</option>
<option value="interval">Every N minutes/hours</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="cron">Raw cron expression</option>
</select>
</span>
<Menu>
<MenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
id="routine-cadence"
aria-labelledby="routine-cadence-label"
>
{
{
manual: "Manual (run only when triggered)",
interval: "Every N minutes/hours",
daily: "Daily",
weekly: "Weekly",
cron: "Raw cron expression",
}[kind]
}
</Button>
</MenuTrigger>
<MenuContent>
{(
[
["manual", "Manual (run only when triggered)"],
["interval", "Every N minutes/hours"],
["daily", "Daily"],
["weekly", "Weekly"],
["cron", "Raw cron expression"],
] as const
).map(([value, label]) => (
<MenuItem key={value} onSelect={() => setKind(value)}>
{label}
</MenuItem>
))}
</MenuContent>
</Menu>

{value !== null && value.kind === "interval" ? (
<div className="form-row">
Expand All @@ -152,38 +181,52 @@ function TriggerPicker({
})
}
/>
<select
value={value.unit}
onChange={(event) =>
onChange({
...value,
unit: event.target.value as "minutes" | "hours",
})
}
>
<option value="minutes">minutes</option>
<option value="hours">hours</option>
</select>
<Menu>
<MenuTrigger asChild>
<Button type="button" variant="outline" size="sm">
{value.unit}
</Button>
</MenuTrigger>
<MenuContent>
<MenuItem
onSelect={() => onChange({ ...value, unit: "minutes" })}
>
minutes
</MenuItem>
<MenuItem onSelect={() => onChange({ ...value, unit: "hours" })}>
hours
</MenuItem>
</MenuContent>
</Menu>
</div>
) : null}

{value !== null && (value.kind === "daily" || value.kind === "weekly") ? (
<div className="form-row">
{value.kind === "weekly" ? (
<select
value={value.dayOfWeek}
onChange={(event) =>
onChange({ ...value, dayOfWeek: Number(event.target.value) })
}
>
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(
(label, index) => (
<option key={label} value={index}>
{label}
</option>
),
)}
</select>
<Menu>
<MenuTrigger asChild>
<Button type="button" variant="outline" size="sm">
{
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][
value.dayOfWeek
]
}
</Button>
</MenuTrigger>
<MenuContent>
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(
(label, index) => (
<MenuItem
key={label}
onSelect={() => onChange({ ...value, dayOfWeek: index })}
>
{label}
</MenuItem>
),
)}
</MenuContent>
</Menu>
) : null}
<span>At</span>
<Input
Expand Down Expand Up @@ -308,42 +351,68 @@ function CreateRoutineDialog({
</div>

<div className="flex-col-gap">
<label htmlFor="routine-definition" className="form-label">
<span id="routine-definition-label" className="form-label">
Workflow
</label>
<select
id="routine-definition"
value={definitionId}
disabled={busy || definitions.length === 0}
onChange={(event) => setDefinitionId(event.target.value)}
>
{definitions.length === 0 ? (
<option value="">No workflows available</option>
) : (
definitions.map((definition) => (
<option key={definition.id} value={definition.id}>
{definition.name}
</option>
))
)}
</select>
</span>
{definitions.length === 0 ? (
<p className="form-hint" role="status">
No automatable workflows on this bench yet.
</p>
) : (
<Menu>
<MenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
id="routine-definition"
aria-labelledby="routine-definition-label"
disabled={busy}
>
{definitions.find((d) => d.id === definitionId)?.name ??
"Choose a workflow"}
</Button>
</MenuTrigger>
<MenuContent>
{definitions.map((definition) => (
<MenuItem
key={definition.id}
onSelect={() => setDefinitionId(definition.id)}
>
{definition.name}
</MenuItem>
))}
</MenuContent>
</Menu>
)}
</div>

<div className="flex-col-gap">
<label htmlFor="routine-run-mode" className="form-label">
<span id="routine-run-mode-label" className="form-label">
When
</label>
<select
id="routine-run-mode"
value={runMode}
disabled={busy}
onChange={(event) =>
setRunMode(event.target.value as "once" | "schedule")
}
>
<option value="once">Run once, right now</option>
<option value="schedule">On a schedule</option>
</select>
</span>
<Menu>
<MenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
id="routine-run-mode"
aria-labelledby="routine-run-mode-label"
disabled={busy}
>
{runMode === "once" ? "Run once, right now" : "On a schedule"}
</Button>
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={() => setRunMode("once")}>
Run once, right now
</MenuItem>
<MenuItem onSelect={() => setRunMode("schedule")}>
On a schedule
</MenuItem>
</MenuContent>
</Menu>
</div>

{runMode === "schedule" ? (
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/purpose-definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Definitions the Routines picker may offer: automatable workflows only.
// Channel-host plumbing and agent handles never appear — the catalog is the
// allowlist (mirrored from each workflow package's package.json
// corbits.workflow.automatable flag); isChannelHostDefinitionName is a
// second belt for host names that slip past the catalog.

import { isChannelHostDefinitionName } from "@corbits/chat/channel-host-naming";
import { isAutomatableWorkflowName } from "@corbits/workflow-catalog";

export function purposeDefinitions<T extends { readonly name: string }>(
definitions: readonly T[],
): readonly T[] {
return definitions.filter(
(definition) =>
!isChannelHostDefinitionName(definition.name) &&
isAutomatableWorkflowName(definition.name),
);
}
42 changes: 36 additions & 6 deletions apps/web/src/routines-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@
// own `/api/tenants/:tenantId/workflows/definitions` listing (native to
// `@intx/hub-api`, not part of routines), the same catalog a routine's
// `definitionId` points into.
//
// The create-flow picker only surfaces automatable workflows (see
// `purpose-definitions.ts` + `@corbits/workflow-catalog`). Labels prefer
// the catalog display name over raw asset names.

import { type } from "arktype";
import type { ArkErrors } from "arktype";
import { useQuery } from "@tanstack/react-query";
import { workflowDisplayName } from "@corbits/workflow-catalog";
import type { APIQuery } from "./api";
import { toAPIQuery } from "./api";
import { UnauthenticatedError } from "./query-client";
import { purposeDefinitions } from "./purpose-definitions";

export const RoutineTrigger = type({
kind: "'interval'",
Expand Down Expand Up @@ -68,13 +74,19 @@ export const WorkflowDefinitionSummary = type({
id: "string",
name: "string",
status: "string",
"description?": "string | null",
});
export type WorkflowDefinitionSummary = typeof WorkflowDefinitionSummary.infer;

const DefinitionsResponse = type({
const DefinitionsPage = type({
data: WorkflowDefinitionSummary.array(),
"nextCursor?": "string | null",
});

/** One page is enough for a seeded bench; walk cursors so a large catalog
* never silently truncates automatable options. */
const PAGE_LIMIT = 100;

export type CreateRoutineInput = {
readonly name: string;
readonly definitionId: string;
Expand Down Expand Up @@ -201,13 +213,31 @@ export function listRoutineRuns(
).then((page) => page.items);
}

export function listWorkflowDefinitions(
/**
* All automatable workflow definitions for the Routines create picker.
* Walks pagination, filters via the catalog allowlist, and attaches a
* friendly label for Menu items (never a raw id).
*/
export async function listWorkflowDefinitions(
tenantId: string,
): Promise<readonly WorkflowDefinitionSummary[]> {
return request(
`/api/tenants/${tenantId}/workflows/definitions`,
DefinitionsResponse,
).then((page) => page.data);
const collected: WorkflowDefinitionSummary[] = [];
let cursor: string | null = null;
for (;;) {
const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
if (cursor !== null) query.set("cursor", cursor);
const page = await request(
`/api/tenants/${tenantId}/workflows/definitions?${query}`,
DefinitionsPage,
);
collected.push(...page.data);
if (page.nextCursor === undefined || page.nextCursor === null) break;
cursor = page.nextCursor;
}
return purposeDefinitions(collected).map((definition) => ({
...definition,
name: workflowDisplayName(definition.name, definition.description),
}));
}

/**
Expand Down
26 changes: 26 additions & 0 deletions apps/web/test/purpose-definitions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";

import { purposeDefinitions } from "../src/purpose-definitions";

describe("purposeDefinitions", () => {
test("keeps only automatable catalog workflows", () => {
const kept = purposeDefinitions([
{ id: "1", name: "channel-digest" },
{ id: "2", name: "heartbeat" },
{ id: "3", name: "echo" },
{ id: "4", name: "assistant" },
{ id: "5", name: "my-agent-handle" },
]);
expect(kept.map((d) => d.name)).toEqual(["channel-digest", "heartbeat"]);
});

test("drops channel-host definition names even if they look catalog-like", () => {
// isChannelHostDefinitionName owns the host naming contract; anything
// it flags is out regardless of catalog membership.
const kept = purposeDefinitions([
{ id: "1", name: "channel-digest" },
{ id: "2", name: "channel-host-xyz" },
]);
expect(kept.map((d) => d.name)).toEqual(["channel-digest"]);
});
});
Loading
Loading