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, ]