diff --git a/.env.example b/.env.example index 465d645..8e6c5df 100644 --- a/.env.example +++ b/.env.example @@ -229,3 +229,37 @@ SUPERVISOR_TOKEN= # Set to runsc to run every computer under gVisor, if the host has it. Unset, a computer is an # ordinary container and shares the host kernel, which is worth knowing when the Bot is not ours. COMPUTER_RUNTIME= + +# --------------------------------------------------------------------------- +# Routines: work a Bot does without being asked +# --------------------------------------------------------------------------- + +# The switch. `off` leaves routines writable and readable, and stops every way of running one: the +# clock, Run now, and a webhook delivery alike. +# +# All three, because this is what a second copy of a deployment wants. Restoring a database dump onto +# a development machine otherwise means somebody's real routines browsing their real systems at eight +# o'clock, and the restored webhook triggers keep their endpoint ids and their secrets, so the +# senders that were configured against them go on setting Bots working. The run is unattended either +# way, so nobody is there to notice. With this off the receiver below is not started at all. It is a +# whole-deployment switch for that reason, and it is announced at boot rather than being a silent +# absence of activity. +# ROUTINE_SCHEDULER=off + +# Where webhook deliveries arrive. Defaults to one above PORT. Not started when ROUTINE_SCHEDULER is +# off, because a delivery does not go through the clock and closing one door is not closing the other. +# +# A port of its own, not a path on the API. This is the one surface in the product meant to be +# reachable by a third party, and the way to keep the rest of the API away from it is for the rest of +# the API not to be on it: this listener serves /health and /hooks/:endpointId and answers 404 to +# everything else, including anything that looks like an API path. +# +# Each trigger has a random endpoint id and a bearer secret, shown once when it is created or +# rotated and stored only as a hash. A new trigger keeps its first authenticated delivery as a sample +# and runs nothing until somebody has looked at what arrived and confirmed it in /admin/webhooks. +# ROUTINE_WEBHOOK_PORT=3002 + +# What the receiver binds to. 127.0.0.1 unless this says otherwise, the same posture as everything +# else here: an endpoint reachable from the internet should be a decision somebody made, not one they +# inherited by starting the server. Put a reverse proxy in front of it rather than opening it wide. +# ROUTINE_WEBHOOK_HOST=127.0.0.1 diff --git a/README.md b/README.md index 244670c..1d45916 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), | `/channel/:id` | Converse with one coworker and view its live screen/profile panel. | | `/bot` | Direct chat with a Bot; `?agent=` selects one. | | `/skills` | Create and enable personal skills. | +| `/routines` | Schedule unattended work, run one now, and read its run history. | | `/settings` | User preferences. | | `/admin/connectors` | Configure deployment knowledge sources. | | `/admin/credentials` | Store write-only encrypted credentials. | @@ -120,6 +121,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), | `/admin/components` | Publish components and govern which Bots may use them. | | `/admin/playground` | Draft and publish sandboxed components in the browser. | | `/admin/plugins` | Configure MCP servers, MCP grants, and deployment skills. | +| `/admin/webhooks` | Create webhook triggers, confirm a first delivery, rotate secrets. | | `/admin/audit` | Review permitted, refused, and failed actions. | ## Features @@ -133,6 +135,8 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), - **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component. - **Governed MCP**: a curated catalogue ships for Atlassian, Box, Slack, Salesforce and ServiceNow. Custom servers must pass URL checks, and any tool not positively classified as a read is treated as a write. - **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer. +- **Routines run unattended, through the same gateway**: a scheduled run drives the Bot server side with no browser in the loop, and every tool call still goes through the gateway, so the same policy decides it and the same audit row records it. A window the deployment slept through is recorded as `missed` rather than fired late. `run.unattended` is a policy attribute, so a deployment can write `run.unattended && intent == "activate"` to let a routine read and never press. +- **Webhook triggers on their own port**: deliveries arrive on `ROUTINE_WEBHOOK_PORT` (default `PORT + 1`), which serves `/health` and `/hooks/:endpointId` and nothing else. A bearer secret is stored only as a hash and compared in constant time, and a new trigger keeps its FIRST authenticated delivery as a sample and runs nothing until a person has looked at it. Triggers are an administrator's: they are the deployment's exposure rather than one person's work, and `/admin/webhooks` lists every one of them. - **An audit trail you can read**: `/admin/audit` lists what was permitted, what was refused and what failed, and every refusal carries the rule that caused it. - **Credentials encrypted at rest**: stored through `/admin/credentials`, never returned by an API, and redacted from audit events. - **Loopback by default**: computers bind to `127.0.0.1` and require a per-container token, so nothing reaches a logged-in browser by knowing its port. diff --git a/app/src/components/admin/admin-sidebar.tsx b/app/src/components/admin/admin-sidebar.tsx index 1a5559e..fd5105c 100644 --- a/app/src/components/admin/admin-sidebar.tsx +++ b/app/src/components/admin/admin-sidebar.tsx @@ -8,6 +8,7 @@ import { IconPlugConnected, IconPuzzle, IconShieldCheck, + IconWebhook, } from "@tabler/icons-react"; import { Link, type LinkOptions } from "@tanstack/react-router"; import type * as React from "react"; @@ -84,6 +85,13 @@ const GROUPS: { icon: IconCode, linkOptions: { to: "/admin/playground" }, }, + // Under what Bots can do rather than what they can reach: a trigger does not grant a Bot + // anything, it lets somebody else start work the Bot could already do. + { + title: "Webhooks", + icon: IconWebhook, + linkOptions: { to: "/admin/webhooks" }, + }, ], }, { diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index eed2823..36277b0 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -1,5 +1,6 @@ import { IconBolt, + IconClockPlay, IconLogout, IconPlus, IconSearch, @@ -286,6 +287,30 @@ export function AppSidebar({ ...props }: React.ComponentProps) { Skills + + {/* + * Beside Skills rather than inside Admin, and for the same reason: a routine is work a + * person schedules for their own Bot, not a deployment-wide setting. What an + * administrator governs is the boundary those runs meet, which is already in Admin. + */} + ( + + )} + > +
+ +
+ Routines +
+
| null; + eventTypes: string[]; + deliveryCount: number; + lastReceivedAt: string | null; + createdAt: string; +}; + +export const routineKeys = { + all: ["routines"] as const, + list: () => ["routines", "list"] as const, + runs: (routineId: string) => ["routines", "runs", routineId] as const, + triggers: () => ["routines", "triggers"] as const, +}; + +export function routineListQueryOptions() { + return queryOptions({ + queryKey: routineKeys.list(), + /* + * Refetched while the page is open, because a routine's state changes without anybody on this + * screen doing anything: the clock fires, a run finishes, a delivery arrives. A page that only + * updated when you pressed something would show a run as still going for as long as you looked + * at it. + */ + refetchInterval: 15_000, + queryFn: async (): Promise => { + const response = await fetch("/api/routines", { credentials: "include" }); + if (!response.ok) throw new Error("Routines could not be loaded."); + return ((await response.json()) as { routines: Routine[] }).routines; + }, + }); +} + +export function routineRunsQueryOptions(routineId: string) { + return queryOptions({ + queryKey: routineKeys.runs(routineId), + enabled: routineId.length > 0, + refetchInterval: 15_000, + queryFn: async (): Promise => { + const response = await fetch( + `/api/routines/${encodeURIComponent(routineId)}/runs`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error("The run history could not be loaded."); + return ((await response.json()) as { runs: RoutineRun[] }).runs; + }, + }); +} + +/** + * Every trigger in the deployment, which is what the administrators' page shows. + * + * Not this person's own, unlike the routines above. A trigger is a URL somebody outside can call, + * so the useful list is all of them; the server refuses this to anybody who is not an administrator. + */ +export function webhookTriggerQueryOptions() { + return queryOptions({ + queryKey: routineKeys.triggers(), + queryFn: async (): Promise => { + const response = await fetch("/api/routines/triggers", { + credentials: "include", + }); + if (!response.ok) throw new Error("Triggers could not be loaded."); + return ((await response.json()) as { triggers: WebhookTrigger[] }) + .triggers; + }, + }); +} + +/** + * Call the routines API and surface the server's own sentence when it refuses. + * + * The server refuses for reasons this page cannot check, and its wording is the only useful part of + * a failure. Paraphrasing it into "That did not work" throws away the sentence somebody needs. + */ +export async function callRoutines( + path: string, + init: RequestInit, +): Promise { + const response = await fetch(`/api/routines${path}`, { + credentials: "include", + headers: { "content-type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => null)) as { + error?: string; + detail?: string; + } | null; + if (!response.ok) { + throw new Error(body?.error ?? body?.detail ?? "That did not work."); + } + return body; +} + +/** + * How a schedule reads on screen. + * + * The same words as routines/schedule.ts on the server, written twice because the two sides share no + * module and the browser cannot import from the server's. Nothing enforces the agreement, so the + * cheap thing that can be done is done: the same normalisation, through `Date`, rather than a string + * slice that quietly disagrees the moment a schedule arrives spelled `+00:00` instead of `Z`. + */ +export function describeSchedule(schedule: RoutineSchedule): string { + if (schedule.type === "once") { + return `Once, at ${new Date(schedule.at).toISOString().replace("T", " ").slice(0, 16)} UTC`; + } + if (schedule.weekdays.length === 0) return "Never, no days are selected"; + if (schedule.weekdays.length === 7) + return `Every day at ${schedule.time} UTC`; + return `${schedule.weekdays + .map((day) => DAY_NAMES[day] ?? String(day)) + .join(", ")} at ${schedule.time} UTC`; +} + +export const DAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; + +/** Weekdays, as the button row offers them: Monday first, which is how people read a week. */ +export const WEEK = [1, 2, 3, 4, 5, 6, 0] as const; diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 3feb12c..adc9020 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as AuthedAdminRouteRouteImport } from './routes/_authed/admin/rou import { Route as AuthedSettingsRouteRouteImport } from './routes/_authed/settings/route' import { Route as AuthedAppIndexRouteImport } from './routes/_authed/_app/index' import { Route as AuthedAppBotRouteImport } from './routes/_authed/_app/bot' +import { Route as AuthedAppRoutinesRouteImport } from './routes/_authed/_app/routines' import { Route as AuthedAppSkillsRouteImport } from './routes/_authed/_app/skills' import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' import { Route as AuthedAdminAuditRouteImport } from './routes/_authed/admin/audit' @@ -26,6 +27,7 @@ import { Route as AuthedAdminConnectorsRouteImport } from './routes/_authed/admi import { Route as AuthedAdminCredentialsRouteImport } from './routes/_authed/admin/credentials' import { Route as AuthedAdminPlaygroundRouteImport } from './routes/_authed/admin/playground' import { Route as AuthedAdminPluginsRouteImport } from './routes/_authed/admin/plugins' +import { Route as AuthedAdminWebhooksRouteImport } from './routes/_authed/admin/webhooks' import { Route as AuthedSettingsIndexRouteImport } from './routes/_authed/settings/index' import { Route as AuthedAppAgentsIndexRouteImport } from './routes/_authed/_app/agents/index' import { Route as AuthedAppChannelChannelIdRouteImport } from './routes/_authed/_app/channel/$channelId' @@ -65,6 +67,11 @@ const AuthedAppBotRoute = AuthedAppBotRouteImport.update({ path: '/bot', getParentRoute: () => AuthedAppRoute, } as any) +const AuthedAppRoutinesRoute = AuthedAppRoutinesRouteImport.update({ + id: '/routines', + path: '/routines', + getParentRoute: () => AuthedAppRoute, +} as any) const AuthedAppSkillsRoute = AuthedAppSkillsRouteImport.update({ id: '/skills', path: '/skills', @@ -115,6 +122,11 @@ const AuthedAdminPluginsRoute = AuthedAdminPluginsRouteImport.update({ path: '/plugins', getParentRoute: () => AuthedAdminRouteRoute, } as any) +const AuthedAdminWebhooksRoute = AuthedAdminWebhooksRouteImport.update({ + id: '/webhooks', + path: '/webhooks', + getParentRoute: () => AuthedAdminRouteRoute, +} as any) const AuthedSettingsIndexRoute = AuthedSettingsIndexRouteImport.update({ id: '/', path: '/', @@ -149,6 +161,7 @@ export interface FileRoutesByFullPath { '/admin': typeof AuthedAdminRouteRouteWithChildren '/settings': typeof AuthedSettingsRouteRouteWithChildren '/bot': typeof AuthedAppBotRoute + '/routines': typeof AuthedAppRoutinesRoute '/skills': typeof AuthedAppSkillsRoute '/admin/audit': typeof AuthedAdminAuditRoute '/admin/boundaries': typeof AuthedAdminBoundariesRoute @@ -158,6 +171,7 @@ export interface FileRoutesByFullPath { '/admin/credentials': typeof AuthedAdminCredentialsRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/plugins': typeof AuthedAdminPluginsRoute + '/admin/webhooks': typeof AuthedAdminWebhooksRoute '/admin/': typeof AuthedAdminIndexRoute '/settings/': typeof AuthedSettingsIndexRoute '/channel/$channelId': typeof AuthedAppChannelChannelIdRoute @@ -169,6 +183,7 @@ export interface FileRoutesByTo { '/': typeof AuthedAppIndexRoute '/sign': typeof SignRoute '/bot': typeof AuthedAppBotRoute + '/routines': typeof AuthedAppRoutinesRoute '/skills': typeof AuthedAppSkillsRoute '/admin/audit': typeof AuthedAdminAuditRoute '/admin/boundaries': typeof AuthedAdminBoundariesRoute @@ -178,6 +193,7 @@ export interface FileRoutesByTo { '/admin/credentials': typeof AuthedAdminCredentialsRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/plugins': typeof AuthedAdminPluginsRoute + '/admin/webhooks': typeof AuthedAdminWebhooksRoute '/admin': typeof AuthedAdminIndexRoute '/settings': typeof AuthedSettingsIndexRoute '/channel/$channelId': typeof AuthedAppChannelChannelIdRoute @@ -193,6 +209,7 @@ export interface FileRoutesById { '/_authed/settings': typeof AuthedSettingsRouteRouteWithChildren '/_authed/_app': typeof AuthedAppRouteWithChildren '/_authed/_app/bot': typeof AuthedAppBotRoute + '/_authed/_app/routines': typeof AuthedAppRoutinesRoute '/_authed/_app/skills': typeof AuthedAppSkillsRoute '/_authed/admin/audit': typeof AuthedAdminAuditRoute '/_authed/admin/boundaries': typeof AuthedAdminBoundariesRoute @@ -202,6 +219,7 @@ export interface FileRoutesById { '/_authed/admin/credentials': typeof AuthedAdminCredentialsRoute '/_authed/admin/playground': typeof AuthedAdminPlaygroundRoute '/_authed/admin/plugins': typeof AuthedAdminPluginsRoute + '/_authed/admin/webhooks': typeof AuthedAdminWebhooksRoute '/_authed/_app/': typeof AuthedAppIndexRoute '/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/settings/': typeof AuthedSettingsIndexRoute @@ -218,6 +236,7 @@ export interface FileRouteTypes { | '/admin' | '/settings' | '/bot' + | '/routines' | '/skills' | '/admin/audit' | '/admin/boundaries' @@ -227,6 +246,7 @@ export interface FileRouteTypes { | '/admin/credentials' | '/admin/playground' | '/admin/plugins' + | '/admin/webhooks' | '/admin/' | '/settings/' | '/channel/$channelId' @@ -238,6 +258,7 @@ export interface FileRouteTypes { | '/' | '/sign' | '/bot' + | '/routines' | '/skills' | '/admin/audit' | '/admin/boundaries' @@ -247,6 +268,7 @@ export interface FileRouteTypes { | '/admin/credentials' | '/admin/playground' | '/admin/plugins' + | '/admin/webhooks' | '/admin' | '/settings' | '/channel/$channelId' @@ -261,6 +283,7 @@ export interface FileRouteTypes { | '/_authed/settings' | '/_authed/_app' | '/_authed/_app/bot' + | '/_authed/_app/routines' | '/_authed/_app/skills' | '/_authed/admin/audit' | '/_authed/admin/boundaries' @@ -270,6 +293,7 @@ export interface FileRouteTypes { | '/_authed/admin/credentials' | '/_authed/admin/playground' | '/_authed/admin/plugins' + | '/_authed/admin/webhooks' | '/_authed/_app/' | '/_authed/admin/' | '/_authed/settings/' @@ -335,6 +359,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAppBotRouteImport parentRoute: typeof AuthedAppRoute } + '/_authed/_app/routines': { + id: '/_authed/_app/routines' + path: '/routines' + fullPath: '/routines' + preLoaderRoute: typeof AuthedAppRoutinesRouteImport + parentRoute: typeof AuthedAppRoute + } '/_authed/_app/skills': { id: '/_authed/_app/skills' path: '/skills' @@ -405,6 +436,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminPluginsRouteImport parentRoute: typeof AuthedAdminRouteRoute } + '/_authed/admin/webhooks': { + id: '/_authed/admin/webhooks' + path: '/webhooks' + fullPath: '/admin/webhooks' + preLoaderRoute: typeof AuthedAdminWebhooksRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } '/_authed/settings/': { id: '/_authed/settings/' path: '/' @@ -465,6 +503,7 @@ interface AuthedAdminRouteRouteChildren { AuthedAdminCredentialsRoute: typeof AuthedAdminCredentialsRoute AuthedAdminPlaygroundRoute: typeof AuthedAdminPlaygroundRoute AuthedAdminPluginsRoute: typeof AuthedAdminPluginsRoute + AuthedAdminWebhooksRoute: typeof AuthedAdminWebhooksRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute } @@ -477,6 +516,7 @@ const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = { AuthedAdminCredentialsRoute: AuthedAdminCredentialsRoute, AuthedAdminPlaygroundRoute: AuthedAdminPlaygroundRoute, AuthedAdminPluginsRoute: AuthedAdminPluginsRoute, + AuthedAdminWebhooksRoute: AuthedAdminWebhooksRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute, } @@ -496,6 +536,7 @@ const AuthedSettingsRouteRouteWithChildren = interface AuthedAppRouteChildren { AuthedAppBotRoute: typeof AuthedAppBotRoute + AuthedAppRoutinesRoute: typeof AuthedAppRoutinesRoute AuthedAppSkillsRoute: typeof AuthedAppSkillsRoute AuthedAppIndexRoute: typeof AuthedAppIndexRoute AuthedAppChannelChannelIdRoute: typeof AuthedAppChannelChannelIdRoute @@ -505,6 +546,7 @@ interface AuthedAppRouteChildren { const AuthedAppRouteChildren: AuthedAppRouteChildren = { AuthedAppBotRoute: AuthedAppBotRoute, + AuthedAppRoutinesRoute: AuthedAppRoutinesRoute, AuthedAppSkillsRoute: AuthedAppSkillsRoute, AuthedAppIndexRoute: AuthedAppIndexRoute, AuthedAppChannelChannelIdRoute: AuthedAppChannelChannelIdRoute, diff --git a/app/src/routes/_authed/_app/routines.tsx b/app/src/routes/_authed/_app/routines.tsx new file mode 100644 index 0000000..13ec58d --- /dev/null +++ b/app/src/routes/_authed/_app/routines.tsx @@ -0,0 +1,504 @@ +import { IconPlus } from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; +import { + PageEmpty, + PageRows, + PageSection, + PageShell, +} from "@/components/layout/page-shell"; +import { StaggerItem } from "@/components/layout/stagger"; +import { Button } from "@/components/ui/button"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { agentListQueryOptions } from "@/lib/agents/queries"; +import { + callRoutines, + DAY_NAMES, + describeSchedule, + type Routine, + type RoutineRun, + routineKeys, + routineListQueryOptions, + routineRunsQueryOptions, + WEEK, +} from "@/lib/routines/queries"; + +/** + * Work a Bot does without being asked. + * + * The page is built around one question that is easy to answer badly: what happened last time? A + * routine you are watching is not the point of a routine, so the row leads with when it is next due + * and what came of the last run, and the prompt, which is the part you already know, is behind the + * run history rather than in front of it. + * + * `missed` is drawn as its own thing rather than folded in with a failure. A deployment that was not + * running at eight o'clock did not do the eight o'clock work, and that is a fact about the machine + * rather than about the Bot: showing it as an error sends somebody to read a prompt that is fine. + */ +export const Route = createFileRoute("/_authed/_app/routines")({ + component: RoutinesPage, +}); + +function RoutinesPage() { + const queryClient = useQueryClient(); + const routines = useQuery(routineListQueryOptions()); + const agents = useQuery(agentListQueryOptions()); + const [error, setError] = useState(null); + const [writing, setWriting] = useState(false); + /** Which routine's history is open. One at a time: this is a list, not a dashboard. */ + const [open, setOpen] = useState(null); + + const act = useMutation({ + mutationFn: (run: () => Promise) => run(), + onError: (caught: Error) => setError(caught.message), + onSuccess: () => { + setError(null); + void queryClient.invalidateQueries({ queryKey: routineKeys.all }); + }, + }); + + const rows = routines.data ?? []; + + return ( + setWriting(true)} size="sm" variant="ghost"> + + New routine + + } + description="A routine is something a Bot does on its own, on a schedule. Nobody is watching a scheduled run, so it cannot ask you anything: everything it does goes through the same boundary as work you watch, and every action is in Audit." + title="Routines" + > + {error ? ( +

+ {error} +

+ ) : null} + + {writing ? ( + ({ + id: agent.id, + name: agent.name, + }))} + onCancel={() => setWriting(false)} + onCreate={async (values) => { + await act.mutateAsync(() => + callRoutines("", { + method: "POST", + body: JSON.stringify(values), + }), + ); + setWriting(false); + }} + /> + ) : null} + + + {routines.isPending ? ( + Loading your routines… + ) : routines.isError ? ( +

+ Routines could not be loaded. +

+ ) : rows.length === 0 ? ( + + Nothing yet. A routine is a sentence you would otherwise type every + morning. + + ) : ( + + {rows.map((routine, index) => ( + + + act.mutate(() => + callRoutines(`/${routine.id}`, { method: "DELETE" }), + ) + } + onRunNow={() => + act.mutate(() => + callRoutines(`/${routine.id}/run`, { method: "POST" }), + ) + } + onToggle={() => + act.mutate(() => + callRoutines(`/${routine.id}`, { + method: "PATCH", + body: JSON.stringify({ enabled: !routine.enabled }), + }), + ) + } + onOpen={() => + setOpen(open === routine.id ? null : routine.id) + } + opened={open === routine.id} + routine={routine} + /> + {index !== rows.length - 1 && } + + ))} + + )} +
+
+ ); +} + +function RoutineRow({ + routine, + opened, + busy, + onOpen, + onRunNow, + onToggle, + onDelete, +}: { + routine: Routine; + opened: boolean; + busy: boolean; + onOpen: () => void; + onRunNow: () => void; + onToggle: () => void; + onDelete: () => void; +}) { + const runs = useQuery({ + ...routineRunsQueryOptions(routine.id), + enabled: opened, + }); + + return ( +
+ + + + {routine.name} + {!routine.enabled ? ( + + paused + + ) : null} + + + {describeSchedule(routine.schedule)} + {/* + * Next due and last run, side by side, because they are the two things somebody opens + * this page to find out and reading them apart makes you hold one in your head. + */} + {routine.enabled && routine.nextDueAt ? ( + <> · next {shortTime(routine.nextDueAt)} + ) : null} + +

+ +

+
+ + + + + +
+ + {opened ? ( +
+ {/* + * The prompt lives here rather than on the row. It is the part somebody already knows, + * and it is often several sentences; putting it in the list would make every row a + * paragraph and bury the two facts the list exists to show. + */} +

What it is told

+

{routine.prompt}

+ +

Recent runs

+ {runs.isPending ? ( +

Loading…

+ ) : (runs.data?.length ?? 0) === 0 ? ( +

+ It has not run yet. +

+ ) : ( +
    + {runs.data?.map((run) => ( +
  • + + {STATUS_LABEL[run.status]} + + + {" "} + · {shortTime(run.startedAt)} · {TRIGGER_LABEL[run.trigger]} + + {run.summary ? ( +

    + {run.summary} +

    + ) : null} + {run.error ? ( +

    + {run.error} +

    + ) : null} +
  • + ))} +
+ )} + +
+ {/* + * Deleting takes the run history with it and there is no undo, so it is down here rather + * than on the row, and it names the routine it destroys: a button pressed against the + * wrong row is the ordinary way this goes wrong. + */} + +
+
+ ) : null} +
+ ); +} + +/** What became of the last run, in one line, or the honest absence of one. */ +function LastRun({ run }: { run: RoutineRun | null }) { + if (!run) { + return Has not run yet; + } + return ( + <> + + {STATUS_LABEL[run.status]} + + + {" "} + · {shortTime(run.startedAt)} + + {run.summary ? ( + · {oneLine(run.summary)} + ) : null} + + ); +} + +function NewRoutine({ + bots, + onCreate, + onCancel, +}: { + bots: { id: string; name: string }[]; + onCreate: (values: { + agentId: string; + name: string; + prompt: string; + schedule: { type: "daily"; time: string; weekdays: number[] }; + }) => Promise; + onCancel: () => void; +}) { + const [agentId, setAgentId] = useState(bots[0]?.id ?? ""); + const [name, setName] = useState(""); + const [prompt, setPrompt] = useState(""); + const [time, setTime] = useState("08:00"); + // Weekdays by default, because the routine everybody writes first is a working-day one. + const [weekdays, setWeekdays] = useState([1, 2, 3, 4, 5]); + + const toggleDay = (day: number) => + setWeekdays((current) => + current.includes(day) + ? current.filter((one) => one !== day) + : [...current, day].sort((left, right) => left - right), + ); + + return ( + +
{ + event.preventDefault(); + void onCreate({ + agentId, + name, + prompt, + schedule: { type: "daily", time, weekdays }, + }); + }} + > + + + Bot + {/* + * A plain select rather than the styled one, because this list is the roster and it can + * be long; the native control is the one that behaves on a phone and with a keyboard. + */} + + + It runs as you, and can reach what you can reach. + + + + + Name + setName(event.target.value)} + placeholder="Overnight alerts" + value={name} + /> + + + + What to do +