From 5beb5f3de44e91ebb401e06728de4e3fe8085637 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Fri, 23 Oct 2026 07:23:15 +0100 Subject: [PATCH] Let a conversation's menu show the routines that run in it The header menu had Tasks but nothing for the schedules whose results land in that conversation. It now has Schedules, right under Tasks, opened in a modal that lists only what runs there: a bot's own chat holds the routines that run nowhere, a workspace only that bot's routines in it (`routineScopeSql`, tested against SQLite). The list loads when the modal opens, through `api/admin/bots/list-routines`, which checks the user chats in that conversation first, so the page props stay small. `grabCronJobs` gained an optional `workspace_id`: left out it still means everywhere. The routines page's row, form and edit modal moved to `src/components/general/`, and its subtitle to `src/utils/`, since both the page and the modal use them. --- AGENTS.md | 2 +- .../general}/routine-edit-modal.tsx | 0 .../general}/routine-form.tsx | 3 +- .../general}/routine-row.tsx | 21 +- .../backend/db/cron-jobs/grab-cron-jobs.ts | 30 ++- .../bots/[bot_id]/(hooks)/use-bot-routines.ts | 54 +++++ .../(partials)/bot-routines-modal.tsx | 205 ++++++++++++++++++ .../(partials)/conversation-header-menu.tsx | 7 + .../routines/(sections)/routines-section.tsx | 4 +- src/pages/api/admin/bots/list-routines.ts | 47 ++++ src/utils/routine-list-changes.test.ts | 54 +++++ src/utils/routine-list-changes.ts | 29 +++ .../routine-scope/routine-scope-sql.test.ts | 44 ++++ src/utils/routine-scope/routine-scope-sql.ts | 25 +++ src/utils/routine-scope/types.ts | 23 ++ .../routine-subtitle.test.ts | 17 ++ .../(utils) => utils}/routine-subtitle.ts | 19 +- 17 files changed, 566 insertions(+), 18 deletions(-) rename src/{pages/admin/routines/(partials) => components/general}/routine-edit-modal.tsx (100%) rename src/{pages/admin/routines/(partials) => components/general}/routine-form.tsx (97%) rename src/{pages/admin/routines/(partials) => components/general}/routine-row.tsx (76%) create mode 100644 src/pages/admin/bots/[bot_id]/(hooks)/use-bot-routines.ts create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/bot-routines-modal.tsx create mode 100644 src/pages/api/admin/bots/list-routines.ts create mode 100644 src/utils/routine-list-changes.test.ts create mode 100644 src/utils/routine-list-changes.ts create mode 100644 src/utils/routine-scope/routine-scope-sql.test.ts create mode 100644 src/utils/routine-scope/routine-scope-sql.ts create mode 100644 src/utils/routine-scope/types.ts rename src/{pages/admin/routines/(utils) => utils}/routine-subtitle.test.ts (68%) rename src/{pages/admin/routines/(utils) => utils}/routine-subtitle.ts (53%) diff --git a/AGENTS.md b/AGENTS.md index d06b0dc..8c9827b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **Opening a computer:** the app serves each computer to whoever sees it (`computersSql`) under `/computers//` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, access via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`). - **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to ", which sends a chat message so the bot carries on; a task stops and reports instead of waiting. - **Health:** `containers.status` is docker's state with `running` narrowed by the health check (`starting`, `unhealthy`; `inspectDockerContainer`). Only a `running` computer is reached: `grabComputerMcpUrl` returns `ready: false` otherwise (re-reading a stale state from docker first, `refreshComputerContainer`, which `sync-computers` uses too), and callers say why (`computerNotReadyMessage`). -- **Schedules (routines in the UI):** `cron_jobs` (per user and bot, `workspace_id` null for its own chat, `title`, `instructions` (the brief), `schedule` (a `Bun.cron` expression, UTC), `computer_id` (not a foreign key; null runs on the bot's current one), `enabled`, `max_runs` (1 for a one-off), `run_count`, `next_run_at`, `last_run_at`, `last_job_id`, `last_error`). Helpers in `src/functions/backend/db/cron-jobs/` (`create-cron-job`, `update-cron-job`, `delete-cron-job`, `grab-cron-jobs`, `grab-user-cron-job`, `fire-cron-job`), all ownership-checked, and with `bot_id` limited to that bot's own. `nextCronRun` (`src/utils/next-cron-run.ts`, `Bun.cron.parse`) validates a schedule and refuses one that runs more often than `AppData.CronJobMinIntervalMs`; a bot has at most `AppData.CronJobMaxPerBot`. Every bot gets `create_schedule`, `list_schedules`, `update_schedule`, `delete_schedule` (`grabBotTools`, every run; descriptions via `describe-cron-job.ts`). The `run-cron-jobs` cron fires due ones (`fireCronJob`: claims the run atomically by moving `next_run_at`, skips it while the last run's job is still open, queues the brief as a task with `enqueueBotJob` in the bot's active session there, turns off after `max_runs` or when there's no next run, keeps why it couldn't in `last_error`, logs each run under `cron`). A run missed while the server was down runs once, then carries on from now. A run acts on its own (`ScheduledRunNote`: the brief is already approved, so it sends rather than drafts); a run that can't do its brief calls `report_run_failed` (scheduled runs only), which throws `ScheduledRunFailedError` so the queue retries it, `AppData.ScheduledRunRetryMs` apart and doubling (`scheduledRunRetryAt`); once out of attempts the user gets one "failed after N tries" message and the schedule's `last_error` keeps why (`recordCronJobFailure`). `/admin/routines` lists them (pause/resume, edit, delete; `api/admin/routines/update-routine`, `delete-routine`); bots create them from chat. +- **Schedules (routines in the UI):** `cron_jobs` (per user and bot, `workspace_id` null for its own chat, `title`, `instructions` (the brief), `schedule` (a `Bun.cron` expression, UTC), `computer_id` (not a foreign key; null runs on the bot's current one), `enabled`, `max_runs` (1 for a one-off), `run_count`, `next_run_at`, `last_run_at`, `last_job_id`, `last_error`). Helpers in `src/functions/backend/db/cron-jobs/` (`create-cron-job`, `update-cron-job`, `delete-cron-job`, `grab-cron-jobs`, `grab-user-cron-job`, `fire-cron-job`), all ownership-checked, and with `bot_id` limited to that bot's own. `nextCronRun` (`src/utils/next-cron-run.ts`, `Bun.cron.parse`) validates a schedule and refuses one that runs more often than `AppData.CronJobMinIntervalMs`; a bot has at most `AppData.CronJobMaxPerBot`. Every bot gets `create_schedule`, `list_schedules`, `update_schedule`, `delete_schedule` (`grabBotTools`, every run; descriptions via `describe-cron-job.ts`). The `run-cron-jobs` cron fires due ones (`fireCronJob`: claims the run atomically by moving `next_run_at`, skips it while the last run's job is still open, queues the brief as a task with `enqueueBotJob` in the bot's active session there, turns off after `max_runs` or when there's no next run, keeps why it couldn't in `last_error`, logs each run under `cron`). A run missed while the server was down runs once, then carries on from now. A run acts on its own (`ScheduledRunNote`: the brief is already approved, so it sends rather than drafts); a run that can't do its brief calls `report_run_failed` (scheduled runs only), which throws `ScheduledRunFailedError` so the queue retries it, `AppData.ScheduledRunRetryMs` apart and doubling (`scheduledRunRetryAt`); once out of attempts the user gets one "failed after N tries" message and the schedule's `last_error` keeps why (`recordCronJobFailure`). `/admin/routines` lists every one of the user's (pause/resume, edit, delete; `api/admin/routines/update-routine`, `delete-routine`); bots create them from chat, and each conversation's own `(partials)/bot-routines-modal.tsx` ("Schedules" in the header menu, beside Tasks) lists the ones that run there (a bot's own chat or one workspace, `routineScopeSql` in `src/utils/routine-scope/`) through `api/admin/bots/list-routines`, which owns them through `grabUserConversation` and `grabCronJobs`'s `workspace_id` filter and loads only when the modal opens. It shares `RoutineForm`, `RoutineRow` and `RoutineEditModal` with the routines page (both in `src/components/general/`). - **Tasks (the crew's to-do list):** `tasks` is the tracker, one collection per bot (`bot_id`) and one per workspace (`workspace_id`; exactly one is set, enforced in `taskScopeValues`, since SQLite treats NULL in a unique constraint as distinct - the `envs` precedent). It is not `bot_tasks` or `bots_queue`: those record a queued job, this records the work. Fields: `title`, `description` (the brief, markdown), `status` (`TaskStatuses` in `src/dict/task-dict.ts`: `todo|doing|blocked|done|cancelled`), `blocked_reason` (only while blocked), `origin` (`user|bot`), `created_by_bot_id`/`created_by_user_id` (not foreign keys), `completed_at`. `task_notes` is the progress log (author `user|bot`, `bot_id`, `text`); `task_media` links a file to a task as a `deliverable` or a `reference` (`task_id`, `media_id` FK, `bot_file_id` not a FK, `kind`, `name`, `added_by_bot_id`), the bytes being a private `media` row (paradigm `task-media`) served at `/media/` through `grabTaskMediaFile` (registered in `handle-media-server.ts` beside `grabBotFileMedia`). Visibility is SQL in `src/utils/task-access/` (`taskScopesSql` for a user: `chatBotsSql` bots plus `workspacesSql` workspaces; `botTaskScopesSql` for a run: its own plus every workspace it works in, everything for the coordinator; tested against SQLite). A workspace's tasks are visible to every bot working there and to the coordinator. Helpers in `src/functions/backend/db/tasks/`: `create-task` (`assertCrewManager` for a manual add, `TaskMaxPerScope` cap), `grab-tasks` (filters, joins the bot's face, the workspace and the latest note), `grab-task`, `update-task` (stamps `completed_at`, clears `blocked_reason` when it leaves blocked), `delete-task`, `add-task-note`, `attach-task-file`/`detach-task-file` (a bot file resolved through `grabBotFile` so a run can't link outside its folders; a chat upload must be the user's own media; `TaskMediaMaxPerTask`), `grab-task-media`, `grab-task-media-file`, `find-duplicate-task`, `purge-task-media` (`grabTaskMediaIds`, read before `deleteBot`/`deleteWorkspace` so a task's own chat uploads aren't stranded; a bot file keeps its folder). Every run gets `list_tasks`, `create_task`, `update_task` (`src/harness/tools/bot/tasks/`, from `grabBotTools`): a worker may only add to its own list or the workspace it works in, the coordinator to any worker's, any workspace's, or its own, and `update_task` takes `status`, `blocked_reason`, a `note` and `deliverable_file_ids`/`reference_file_ids` (`[file:ID]` refs) in one call. `TasksNote` (every reply and task) says how to keep the list honest; `TaskReminderNote` (chat only) tells the bot to check `list_tasks` on a greeting or when proposing next steps and mention at most `AppData.TaskReminderDigestCount` open tasks in one line, blocked first, never repeating a reminder and staying quiet mid-task. The user adds and changes tasks by hand in a workspace's dashboard (`(partials)/workspace-tasks-group.tsx`) and from the conversation header's menu (`(partials)/bot-tasks-modal.tsx`, loading its list when it opens so the page props stay small); both compose the shared `TaskForm`, `TaskListItem`, `TaskDetailPanel`, `TaskStatusChip` and `TaskFileRow` in `src/components/general/`. API in `api/admin/tasks/` (`list-tasks`, `get-task`, `save-task`, `delete-task`, `add-note`, `attach-file`, `detach-file`), each `userAuth` → `isReadOnlyUser` → `assertUserReachesTask` → `safeErrorMessage`. Live pushes for task changes are not built yet. - **Not built yet:** task steps (nothing writes `bot_task_steps` yet), live `server:task-changed` pushes, and a `/admin/tasks` page. diff --git a/src/pages/admin/routines/(partials)/routine-edit-modal.tsx b/src/components/general/routine-edit-modal.tsx similarity index 100% rename from src/pages/admin/routines/(partials)/routine-edit-modal.tsx rename to src/components/general/routine-edit-modal.tsx diff --git a/src/pages/admin/routines/(partials)/routine-form.tsx b/src/components/general/routine-form.tsx similarity index 97% rename from src/pages/admin/routines/(partials)/routine-form.tsx rename to src/components/general/routine-form.tsx index 7bb97ad..03b245e 100644 --- a/src/pages/admin/routines/(partials)/routine-form.tsx +++ b/src/components/general/routine-form.tsx @@ -13,7 +13,8 @@ type Props = { }; /** - * A routine's name, schedule (a cron expression in UTC) and brief. + * A routine's name, schedule (a cron expression in UTC) and brief. The + * routines page and the conversation's routines modal both edit with it. */ export default function RoutineForm({ cron_job, onCancel, onSaved }: Props) { const [title, setTitle] = useState(cron_job.title || ""); diff --git a/src/pages/admin/routines/(partials)/routine-row.tsx b/src/components/general/routine-row.tsx similarity index 76% rename from src/pages/admin/routines/(partials)/routine-row.tsx rename to src/components/general/routine-row.tsx index 083ed96..8fb0f34 100644 --- a/src/pages/admin/routines/(partials)/routine-row.tsx +++ b/src/components/general/routine-row.tsx @@ -1,10 +1,10 @@ -import BotAvatar from "@/src/components/general/bot-avatar"; import type { BotFace } from "@/src/dict/bot-dict"; import botStoneProps from "@/src/utils/bot-stone-props"; import type { CronJobWithNames } from "@/src/types"; import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row"; import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action"; -import routineSubtitle from "../(utils)/routine-subtitle"; +import routineSubtitle from "@/src/utils/routine-subtitle"; +import BotAvatar from "./bot-avatar"; type Props = { cron_job: CronJobWithNames; @@ -12,13 +12,18 @@ type Props = { onToggle: () => void; onEdit: () => void; onDelete: () => void; + /** + * In a conversation's own modal the Chat link and the bot's and + * workspace's names are already the ones on screen. + */ + compact?: boolean; }; /** * One routine: its bot's avatar, its name (Paused or Done when it is), * whose it is and when it runs, then Chat, Pause or Resume, Edit, Delete. */ -export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete }: Props) { +export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete, compact }: Props) { const title = cron_job.title || `Routine ${cron_job.id}`; const done = Boolean(cron_job.max_runs && Number(cron_job.run_count || 0) >= cron_job.max_runs); const chat_url = cron_job.workspace_id @@ -36,9 +41,15 @@ export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete } title={title} badge={cron_job.enabled ? undefined : done ? "Done" : "Paused"} - subtitle={routineSubtitle({ cron_job })} + subtitle={routineSubtitle({ + cron_job, + show_bot: !compact, + show_workspace: !compact, + })} > - + {compact ? null : ( + + )} { +export default async function grabCronJobs({ + user_id, + bot_id, + workspace_id, + cron_job_id, +}: Params): Promise { const face = botFaceSql({ user_id, prefix: "bot_" }); const where = [`c.user_id = ?`]; const values: (number | string)[] = [user_id]; if (bot_id) { - where.push(`c.bot_id = ?`); - values.push(bot_id); + if (workspace_id === undefined) { + where.push(`c.bot_id = ?`); + values.push(bot_id); + } else { + const scope = routineScopeSql({ bot_id, workspace_id }); + + where.push(scope.sql); + values.push(...scope.values); + } } if (cron_job_id) { diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-bot-routines.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-bot-routines.ts new file mode 100644 index 0000000..8b26bfa --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-bot-routines.ts @@ -0,0 +1,54 @@ +import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import { useCallback, useEffect, useState } from "react"; +import type { CronJobWithNames } from "@/src/types"; + +type Params = { + bot_id: number; + workspace_id?: number | null; + /** + * Load only once the modal opens, so the conversation's page props stay + * small. + */ + enabled?: boolean; +}; + +/** + * A conversation's routines: the schedules that run there, a bot's own in + * its chat or the ones that run in the workspace. Loaded on open and + * refreshed after every change, never part of the page props. + */ +export default function useBotRoutines({ bot_id, workspace_id, enabled = true }: Params) { + const [routines, setRoutines] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + const load = useCallback(async () => { + setLoading(true); + setError(undefined); + + const query = new URLSearchParams({ bot_id: String(bot_id) }); + + if (workspace_id) query.set("workspace_id", String(workspace_id)); + + const res = await fetchApi<{}, APIResponseObject>( + `/api/admin/bots/list-routines?${query.toString()}`, + { method: "GET" }, + ).catch(() => null); + + setLoading(false); + + if (!res?.success) { + setError(res?.msg || `Couldn't load the routines.`); + return; + } + + setRoutines(res.payload || []); + }, [bot_id, workspace_id]); + + useEffect(() => { + if (enabled) load().catch(() => {}); + }, [enabled, load]); + + return { routines, setRoutines, loading, error, reload: load }; +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-routines-modal.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-routines-modal.tsx new file mode 100644 index 0000000..d750e9c --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-routines-modal.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState } from "react"; +import Modal from "@moduletrace/twui/elements/Modal"; +import H2 from "@moduletrace/twui/layout/H2"; +import Row from "@moduletrace/twui/layout/Row"; +import Span from "@moduletrace/twui/layout/Span"; +import Stack from "@moduletrace/twui/layout/Stack"; +import Button from "@moduletrace/twui/layout/Button"; +import { Clock } from "lucide-react"; +import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; +import type { BotWithLastMessage, CronJobWithNames } from "@/src/types"; +import RoutineForm from "@/src/components/general/routine-form"; +import RoutineRow from "@/src/components/general/routine-row"; +import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; +import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list"; +import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status"; +import { dropRoutine, replaceRoutine } from "@/src/utils/routine-list-changes"; +import useBotRoutines from "../(hooks)/use-bot-routines"; +import HeaderMenuItem from "./header-menu-item"; + +type Status = { error?: boolean; success?: boolean; msg?: string }; + +type Props = { + bot: BotWithLastMessage; + workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null; + /** + * A row in the conversation header's menu instead of an icon beside it. + */ + menu?: boolean; + on_open?: () => void; +}; + +/** + * The routines that run in this conversation: the schedules whose result + * lands here, a bot's own on its chat or the ones it runs in this + * workspace. Open it from the header menu; the list loads then, so the + * page's props stay small. The user pauses, resumes, edits and deletes + * their own; the bot makes them when asked in chat. + */ +export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Props) { + const [open, set_open] = useState(false); + const [editing, set_editing] = useState(); + const [busy, set_busy] = useState(false); + const [status, set_status] = useState(); + const { routines, setRoutines, loading, error } = useBotRoutines({ + bot_id: Number(bot.id), + workspace_id: workspace?.id ? Number(workspace.id) : null, + enabled: open, + }); + + const name = bot.name || "This bot"; + const place = workspace?.name || "this workspace"; + const title = workspace?.id ? `${place} routines` : `${name}'s routines`; + + useEffect(() => { + if (open) on_open?.(); + }, [open]); + + const replace = ({ cron_job }: { cron_job: CronJobWithNames }) => + setRoutines((prev) => replaceRoutine({ routines: prev, cron_job })); + + const toggle = async ({ cron_job }: { cron_job: CronJobWithNames }) => { + if (busy) return; + + set_busy(true); + set_status(undefined); + + const res = await postAdminApi({ + path: "routines/update-routine", + body: { cron_job_id: cron_job.id, enabled: !cron_job.enabled }, + }); + + set_busy(false); + + if (!res.success || !res.singleRes) { + set_status({ error: true, msg: res.msg || "That didn't work." }); + return; + } + + replace({ cron_job: res.singleRes }); + set_status({ success: true, msg: cron_job.enabled ? "Paused." : "Resumed." }); + }; + + const remove = async ({ cron_job }: { cron_job: CronJobWithNames }) => { + if (busy) return; + if (!window.confirm(`Delete the routine "${cron_job.title}"? A run already started carries on.`)) return; + + set_busy(true); + set_status(undefined); + + const res = await postAdminApi({ path: "routines/delete-routine", body: { cron_job_id: cron_job.id } }); + + set_busy(false); + + if (!res.success) { + set_status({ error: true, msg: res.msg || "That didn't work." }); + return; + } + + setRoutines((prev) => dropRoutine({ routines: prev, cron_job_id: cron_job.id })); + set_status({ success: true, msg: "Deleted." }); + }; + + return ( + + ) : ( + + ) + } + targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }} + open={open} + setOpen={(next) => { + set_open(next); + + if (!next) { + set_editing(undefined); + set_status(undefined); + } + }} + className="max-w-xl bg-paper p-6" + > + + +

+ {editing ? editing.title || "Routine" : title} +

+ {editing ? ( + + ) : null} +
+ + {editing + ? `${name} gets this brief as a task at every run, and the result lands here.` + : workspace?.id + ? `Work ${name} does on a schedule in ${place}. Each run is a task, and its result lands here.` + : `Work ${name} does on its own, on a schedule. Each run is a task, and its result lands here.`} + +
+ + + {editing ? ( + set_editing(undefined)} + onSaved={({ cron_job }) => { + replace({ cron_job }); + set_editing(undefined); + set_status({ success: true, msg: "Saved." }); + }} + /> + ) : ( + + {routines.map((cron_job) => ( + toggle({ cron_job }).catch(() => {})} + onEdit={() => set_editing(cron_job)} + onDelete={() => remove({ cron_job }).catch(() => {})} + /> + ))} + + )} + + + + + {editing ? null : ( + + {`Ask ${name} in chat to set one up, or change one here.`} + + )} + +
+ ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx index cff7df2..320990f 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx @@ -12,6 +12,7 @@ import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; import BotComputersModal from "./bot-computers-modal"; import BotFilesModal from "./bot-files-modal"; import BotTasksModal from "./bot-tasks-modal"; +import BotRoutinesModal from "./bot-routines-modal"; import BotModelsModal from "./bot-models-modal"; import HeaderMenuItem from "./header-menu-item"; import NewSessionButton from "./new-session-button"; @@ -108,6 +109,12 @@ export default function ConversationHeaderMenu({ workspace={workspace} on_open={on_open} /> + > = async ({ req }) => { + try { + if (req.method != "GET") { + return { success: false, msg: `Method not allowed` }; + } + + const { user } = await userAuth({ req }); + + if (!user?.logged_in_status || !user.id) { + return { success: false, msg: `Unauthorized`, logoutUser: true }; + } + + const url = new URL(req.url); + const bot_id = Number(url.searchParams.get("bot_id")) || undefined; + const workspace_id = Number(url.searchParams.get("workspace_id")) || undefined; + + if (!bot_id) { + return { success: false, msg: `Pick a bot.` }; + } + + const conversation = await grabUserConversation({ user_id: user.id, bot_id, workspace_id }); + + return { + success: true, + payload: await grabCronJobs({ + user_id: user.id, + bot_id: conversation.bot.id, + workspace_id: conversation.workspace_id, + }), + }; + } catch (error: unknown) { + return { success: false, msg: safeErrorMessage({ error }) }; + } +}; diff --git a/src/utils/routine-list-changes.test.ts b/src/utils/routine-list-changes.test.ts new file mode 100644 index 0000000..6902f54 --- /dev/null +++ b/src/utils/routine-list-changes.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import type { CronJobWithNames } from "@/src/types"; +import { dropRoutine, replaceRoutine } from "./routine-list-changes"; + +function routine({ id, title }: { id: number | string; title: string }): CronJobWithNames { + return { ...({ id, title, enabled: 1, schedule: "0 9 * * *" } as CronJobWithNames) }; +} + +const routines = [routine({ id: 1, title: "Morning" }), routine({ id: 2, title: "Evening" })]; + +describe("replaceRoutine", () => { + test("swaps in the saved version in the same place", () => { + const saved = routine({ id: 2, title: "Evening, paused" }); + const next = replaceRoutine({ routines, cron_job: saved }); + + expect(next.map((item) => item.id)).toEqual([1, 2]); + expect(next[1]!.title).toBe("Evening, paused"); + }); + + test("matches a string id against a numeric one", () => { + const next = replaceRoutine({ + routines: [routine({ id: "1", title: "Morning" }), routine({ id: 2, title: "Evening" })], + cron_job: routine({ id: 1, title: "Morning, paused" }), + }); + + expect(next[0]!.title).toBe("Morning, paused"); + expect(next.length).toBe(2); + }); + + test("leaves every other routine alone", () => { + expect(replaceRoutine({ routines, cron_job: routine({ id: 99, title: "Not here" }) })).toEqual(routines); + }); + + test("does not change the list it was given", () => { + const copy = [...routines]; + replaceRoutine({ routines, cron_job: routine({ id: 1, title: "Changed" }) }); + + expect(routines).toEqual(copy); + }); +}); + +describe("dropRoutine", () => { + test("removes the deleted one and keeps the rest", () => { + expect(dropRoutine({ routines, cron_job_id: 1 }).map((item) => item.id)).toEqual([2]); + }); + + test("matches a string id", () => { + expect(dropRoutine({ routines, cron_job_id: "2" }).map((item) => item.id)).toEqual([1]); + }); + + test("an id that isn't there changes nothing", () => { + expect(dropRoutine({ routines, cron_job_id: 42 })).toEqual(routines); + }); +}); diff --git a/src/utils/routine-list-changes.ts b/src/utils/routine-list-changes.ts new file mode 100644 index 0000000..716320a --- /dev/null +++ b/src/utils/routine-list-changes.ts @@ -0,0 +1,29 @@ +import type { CronJobWithNames } from "@/src/types"; + +/** + * Replaces one routine in a list with its saved version, keeping the order. + * Only the row that changed moves, so a pause or an edit never reshuffles + * the list under the pointer. + */ +export function replaceRoutine({ + routines, + cron_job, +}: { + routines: CronJobWithNames[]; + cron_job: CronJobWithNames; +}): CronJobWithNames[] { + return routines.map((item) => (item.id == cron_job.id ? cron_job : item)); +} + +/** + * Drops a deleted routine from a list. + */ +export function dropRoutine({ + routines, + cron_job_id, +}: { + routines: CronJobWithNames[]; + cron_job_id: string | number | undefined; +}): CronJobWithNames[] { + return routines.filter((item) => item.id != cron_job_id); +} diff --git a/src/utils/routine-scope/routine-scope-sql.test.ts b/src/utils/routine-scope/routine-scope-sql.test.ts new file mode 100644 index 0000000..bb3defa --- /dev/null +++ b/src/utils/routine-scope/routine-scope-sql.test.ts @@ -0,0 +1,44 @@ +import { Database } from "bun:sqlite"; +import { beforeAll, describe, expect, test } from "bun:test"; +import routineScopeSql from "./routine-scope-sql"; +import type { SqlCondition } from "./types"; + +const db = new Database(":memory:"); + +beforeAll(() => { + db.run(`CREATE TABLE cron_jobs (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER)`); + db.run(`INSERT INTO cron_jobs VALUES + (1, 2, NULL), + (2, 2, 10), + (3, 2, 11), + (4, 3, NULL), + (5, 3, 10)`); +}); + +function ids({ condition }: { condition: SqlCondition }) { + return db + .query(`SELECT c.id FROM cron_jobs c WHERE ${condition.sql} ORDER BY c.id`) + .all(...condition.values) + .map((row) => (row as { id: number }).id); +} + +describe("routineScopeSql", () => { + test("a bot's own chat holds only the routines that run nowhere", () => { + expect(ids({ condition: routineScopeSql({ bot_id: 2 }) })).toEqual([1]); + expect(ids({ condition: routineScopeSql({ bot_id: 3, workspace_id: null }) })).toEqual([4]); + }); + + test("a workspace holds only that bot's routines there", () => { + expect(ids({ condition: routineScopeSql({ bot_id: 2, workspace_id: 10 }) })).toEqual([2]); + expect(ids({ condition: routineScopeSql({ bot_id: 3, workspace_id: 10 }) })).toEqual([5]); + }); + + test("another bot's routines in the same workspace are not this conversation's", () => { + expect(ids({ condition: routineScopeSql({ bot_id: 2, workspace_id: 11 }) })).toEqual([3]); + expect(ids({ condition: routineScopeSql({ bot_id: 9, workspace_id: 11 }) })).toEqual([]); + }); + + test("a string id from a query string still matches", () => { + expect(ids({ condition: routineScopeSql({ bot_id: "2", workspace_id: "10" }) })).toEqual([2]); + }); +}); diff --git a/src/utils/routine-scope/routine-scope-sql.ts b/src/utils/routine-scope/routine-scope-sql.ts new file mode 100644 index 0000000..9860aa7 --- /dev/null +++ b/src/utils/routine-scope/routine-scope-sql.ts @@ -0,0 +1,25 @@ +import type { RoutineScopeSqlParams, SqlCondition } from "./types"; + +/** + * The routines of one conversation: that bot's schedules in that workspace, + * or, for its own chat, the ones that run nowhere (a routine made there + * stays there even if the bot later joins a workspace). `workspace_id` is + * NULL in SQLite rather than 0, so the two cases can't share a comparison. + */ +export default function routineScopeSql({ + bot_id, + workspace_id, + alias = "c", +}: RoutineScopeSqlParams): SqlCondition { + if (workspace_id) { + return { + sql: `(${alias}.bot_id = ? AND ${alias}.workspace_id = ?)`, + values: [Number(bot_id), Number(workspace_id)], + }; + } + + return { + sql: `(${alias}.bot_id = ? AND ${alias}.workspace_id IS NULL)`, + values: [Number(bot_id)], + }; +} diff --git a/src/utils/routine-scope/types.ts b/src/utils/routine-scope/types.ts new file mode 100644 index 0000000..1b1751b --- /dev/null +++ b/src/utils/routine-scope/types.ts @@ -0,0 +1,23 @@ +import type { SqlCondition } from "@/src/utils/task-access/types"; + +/** + * The conversation a routines list belongs to: a bot's own chat (no + * workspace), or that bot's conversation in one workspace. + */ +export type RoutineConversationScope = { + bot_id: number | string; + /** + * Null or undefined for the bot's own chat. A workspace id keeps only + * the routines running there. + */ + workspace_id?: number | string | null; +}; + +export type RoutineScopeSqlParams = RoutineConversationScope & { + /** + * The table alias the condition reads. + */ + alias?: string; +}; + +export type { SqlCondition }; diff --git a/src/pages/admin/routines/(utils)/routine-subtitle.test.ts b/src/utils/routine-subtitle.test.ts similarity index 68% rename from src/pages/admin/routines/(utils)/routine-subtitle.test.ts rename to src/utils/routine-subtitle.test.ts index 8e60bb6..d2af9bf 100644 --- a/src/pages/admin/routines/(utils)/routine-subtitle.test.ts +++ b/src/utils/routine-subtitle.test.ts @@ -30,4 +30,21 @@ describe("routineSubtitle", () => { "Scout · @daily UTC", ); }); + + test("leaves out the bot and workspace in a conversation's own modal", () => { + expect( + routineSubtitle({ + cron_job: { + bot_name: "Scout", + workspace_name: "Launch", + schedule: "0 9 * * *", + enabled: 1, + next_run_at: now + 30 * 60 * 1000, + }, + now, + show_bot: false, + show_workspace: false, + }), + ).toBe("0 9 * * * UTC · next in 30m"); + }); }); diff --git a/src/pages/admin/routines/(utils)/routine-subtitle.ts b/src/utils/routine-subtitle.ts similarity index 53% rename from src/pages/admin/routines/(utils)/routine-subtitle.ts rename to src/utils/routine-subtitle.ts index b0d5652..c0e9429 100644 --- a/src/pages/admin/routines/(utils)/routine-subtitle.ts +++ b/src/utils/routine-subtitle.ts @@ -1,22 +1,33 @@ import type { CronJobWithNames } from "@/src/types"; -import timeUntil from "@/src/utils/time-until"; +import timeUntil from "./time-until"; type Params = { cron_job: CronJobWithNames; now?: number; + /** + * Shown by default; left out where the conversation already is the + * bot's (or the workspace's). + */ + show_bot?: boolean; + show_workspace?: boolean; }; /** * The line under a routine's name: whose it is, where it runs, its * schedule, then when it runs next or why it last couldn't. */ -export default function routineSubtitle({ cron_job, now = Date.now() }: Params) { +export default function routineSubtitle({ + cron_job, + now = Date.now(), + show_bot = true, + show_workspace = true, +}: Params) { const next = cron_job.enabled && cron_job.next_run_at ? `next ${timeUntil({ at: cron_job.next_run_at, now })}` : ""; return [ - cron_job.bot_name || "A bot", - cron_job.workspace_name || "", + show_bot ? cron_job.bot_name || "A bot" : "", + show_workspace ? cron_job.workspace_name || "" : "", `${cron_job.schedule} UTC`, cron_job.last_error || next, ]