From eed12ce335633fd07b03fb464ab026b609301b64 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 19 Aug 2026 14:00:25 -0700 Subject: [PATCH 1/4] Let a Bot work while nobody is watching A Bot only ever acted because somebody typed to it. There was no way to say "every weekday at eight, check the overnight alerts and write me a summary", and no way for another system to hand a Bot a piece of work. The schema module already claimed to hold routines and neither table existed. A coworker that can only work while you watch is half a coworker. Routines are stored as a prompt and a schedule rather than as a script, because the thing being scheduled is a conversation turn: whatever the Bot would have done had somebody typed this at eight o'clock. That keeps a routine exactly as capable as the Bot is, and stops the table becoming a second, weaker way of describing work. The runner is the part that matters. A scheduled run happens server side with no browser in the loop, and every tool call still goes through the existing ComputerGateway. The browser executes the computer tools purely so it can render them, but the decision, the audit row and the action have always happened on the server, so an unattended run does not need a weaker path: it needs the same one and it gets it. What it does not get is the tools that ask a person for something, because there is no person; a model reaching for one is told so plainly rather than having the call silently dropped. An unattended run is exactly the condition a boundary exists for. PolicyContext gains `run.unattended`, always present and never optional, so a deployment can write `run.unattended && intent == "activate"` and let a routine read, browse and write notes while forbidding it to press anything. Present on every context because this engine reads an unevaluable expression as a match, so a field that was sometimes absent would turn a rule about routines into a rule that refuses every attended action too. `missed` is a real status rather than a nicety. A laptop asleep at eight o'clock did not do the eight o'clock work, and the two wrong answers are firing it at noon as though nothing had happened and recording nothing at all. The window is stamped with its own time, which is what makes recording a miss idempotent rather than a row a minute until the next window. One live run per routine is a partial unique index rather than a check in the scheduler. Two ticks overlap, two processes both tick, and the check-then-insert a careful loop would do has a gap in the middle; what fits through it is two emails sent. The second claim loses rather than races. Webhook deliveries arrive on their own Bun server on its own port, serving /health and /hooks/:endpointId and answering 404 to everything else. 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. Secrets are bearer tokens shown once, stored as a SHA-256 and compared over digests so the comparison is fixed-width and constant-time. A new trigger keeps its first authenticated delivery as a sample and runs nothing until somebody has looked at what actually arrived, which is the gate that catches a mistyped hook before it starts real work. --- .env.example | 30 + README.md | 4 + app/src/components/admin/admin-sidebar.tsx | 8 + .../components/app-sidebar/app-sidebar.tsx | 25 + app/src/lib/routines/queries.ts | 174 + app/src/routeTree.gen.ts | 42 + app/src/routes/_authed/_app/routines.tsx | 504 +++ app/src/routes/_authed/admin/audit.tsx | 17 + app/src/routes/_authed/admin/boundaries.tsx | 7 + app/src/routes/_authed/admin/index.tsx | 7 + app/src/routes/_authed/admin/webhooks.tsx | 340 ++ server/drizzle/0001_amusing_wild_child.sql | 58 + server/drizzle/meta/0001_snapshot.json | 2925 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/app.ts | 23 + server/src/audit.ts | 45 + server/src/computer/gateway.ts | 13 + server/src/computer/policy.ts | 19 + server/src/config.ts | 65 + server/src/db/schema/coworker.ts | 220 ++ server/src/index.ts | 123 +- server/src/plugins/store.ts | 4 + server/src/routines/receiver.ts | 251 ++ server/src/routines/routes.ts | 376 +++ server/src/routines/runner.ts | 678 ++++ server/src/routines/schedule.ts | 265 ++ server/src/routines/scheduler.ts | 307 ++ server/src/routines/store.ts | 840 +++++ server/src/routines/webhooks.ts | 235 ++ server/tests/computer-gateway.test.ts | 31 + server/tests/computer-policy.test.ts | 48 + server/tests/routine-runner.test.ts | 583 ++++ server/tests/routine-schedule.test.ts | 371 +++ server/tests/routine-scheduler.test.ts | 426 +++ .../tests/routine-store.integration.test.ts | 468 +++ server/tests/webhook-trigger.test.ts | 275 ++ 36 files changed, 9801 insertions(+), 13 deletions(-) create mode 100644 app/src/lib/routines/queries.ts create mode 100644 app/src/routes/_authed/_app/routines.tsx create mode 100644 app/src/routes/_authed/admin/webhooks.tsx create mode 100644 server/drizzle/0001_amusing_wild_child.sql create mode 100644 server/drizzle/meta/0001_snapshot.json create mode 100644 server/src/routines/receiver.ts create mode 100644 server/src/routines/routes.ts create mode 100644 server/src/routines/runner.ts create mode 100644 server/src/routines/schedule.ts create mode 100644 server/src/routines/scheduler.ts create mode 100644 server/src/routines/store.ts create mode 100644 server/src/routines/webhooks.ts create mode 100644 server/tests/routine-runner.test.ts create mode 100644 server/tests/routine-schedule.test.ts create mode 100644 server/tests/routine-scheduler.test.ts create mode 100644 server/tests/routine-store.integration.test.ts create mode 100644 server/tests/webhook-trigger.test.ts diff --git a/.env.example b/.env.example index 89d8a39..1ebb5b1 100644 --- a/.env.example +++ b/.env.example @@ -149,3 +149,33 @@ 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 clock. `off` leaves routines writable, listable and runnable by hand, and stops anything firing +# on its own. +# +# 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 run is unattended so nobody is there to notice. 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. +# +# 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 710310f..a024e64 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. - **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; + }, + }); +} + +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 the server uses, so the two agree. */ +export function describeSchedule(schedule: RoutineSchedule): string { + if (schedule.type === "once") { + return `Once, at ${schedule.at.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 +