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.
This commit is contained in:
2026-10-23 07:23:15 +01:00
parent 796c53716f
commit 5beb5f3de4
17 changed files with 566 additions and 18 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Opening a computer:** the app serves each computer to whoever sees it (`computersSql`) under `/computers/<id>/` (`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 <bot>", 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/<id>` 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.
@@ -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 || "");
@@ -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 : (
<SettingsRowAction title={`Chat with ${cron_job.bot_name || "the bot"}`} label="Chat" href={chat_url} />
)}
<SettingsRowAction
title={cron_job.enabled ? `Pause ${title}` : `Resume ${title}`}
label={cron_job.enabled ? "Pause" : "Resume"}
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { CronJobWithNames } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
import routineScopeSql from "@/src/utils/routine-scope/routine-scope-sql";
type Params = {
user_id: number | string;
@@ -8,6 +9,12 @@ type Params = {
* Only this bot's.
*/
bot_id?: number | string;
/**
* With `bot_id`: only the routines of that conversation, here
* (`routineScopeSql`). `null` is the bot's own chat, so it has to be
* given rather than left out to mean "everywhere".
*/
workspace_id?: number | string | null;
/**
* Only this one.
*/
@@ -16,17 +23,30 @@ type Params = {
/**
* The user's schedules with their bot (its face as the user sees it) and
* workspace names, the next to
* run first and paused ones last.
* workspace names, the next to run first and paused ones last. Given a
* `bot_id` and a `workspace_id` (null for its own chat) it is one
* conversation's: the routines that run right there.
*/
export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Params): Promise<CronJobWithNames[]> {
export default async function grabCronJobs({
user_id,
bot_id,
workspace_id,
cron_job_id,
}: Params): Promise<CronJobWithNames[]> {
const face = botFaceSql({ user_id, prefix: "bot_" });
const where = [`c.user_id = ?`];
const values: (number | string)[] = [user_id];
if (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) {
@@ -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<CronJobWithNames[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
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<CronJobWithNames>>(
`/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 };
}
@@ -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<CronJobWithNames>();
const [busy, set_busy] = useState(false);
const [status, set_status] = useState<Status>();
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<CronJobWithNames>({
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 (
<Modal
target={
menu ? (
<HeaderMenuItem icon={Clock} title="Schedules" description={title} loading={loading} />
) : (
<Button
title={title}
type="button"
variant="ghost"
color="text"
className="shrink-0 p-2! text-ink-secondary hover:text-ink hover:bg-sunken rounded-default"
>
<Clock size={18} aria-hidden />
</Button>
)
}
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"
>
<Stack className="w-full gap-1! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
{editing ? editing.title || "Routine" : title}
</H2>
{editing ? (
<Button
title="Back to the list"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => {
set_editing(undefined);
set_status(undefined);
}}
className="px-2! text-[12px]"
>
Back
</Button>
) : null}
</Row>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{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.`}
</Span>
</Stack>
<Stack className="mt-4 max-h-[55vh] w-full gap-0! items-stretch overflow-y-auto">
{editing ? (
<RoutineForm
key={editing.id}
cron_job={editing}
onCancel={() => set_editing(undefined)}
onSaved={({ cron_job }) => {
replace({ cron_job });
set_editing(undefined);
set_status({ success: true, msg: "Saved." });
}}
/>
) : (
<SettingsList
is_empty={!routines.length}
empty={loading ? "Loading routines..." : error || "No routines here yet."}
>
{routines.map((cron_job) => (
<RoutineRow
key={cron_job.id}
cron_job={cron_job}
compact
busy={busy}
onToggle={() => toggle({ cron_job }).catch(() => {})}
onEdit={() => set_editing(cron_job)}
onDelete={() => remove({ cron_job }).catch(() => {})}
/>
))}
</SettingsList>
)}
</Stack>
<Stack className="mt-3 gap-1! items-stretch">
<SettingsStatus status={status} />
{editing ? null : (
<Span className="text-[12px] leading-4 text-ink-secondary">
{`Ask ${name} in chat to set one up, or change one here.`}
</Span>
)}
</Stack>
</Modal>
);
}
@@ -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}
/>
<BotRoutinesModal
menu
bot={bot}
workspace={workspace}
on_open={on_open}
/>
<BotComputersModal
menu
bot={bot}
@@ -6,8 +6,8 @@ import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group"
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import RoutineEditModal from "../(partials)/routine-edit-modal";
import RoutineRow from "../(partials)/routine-row";
import RoutineEditModal from "@/src/components/general/routine-edit-modal";
import RoutineRow from "@/src/components/general/routine-row";
/**
* Every routine the user's bots have: chat with the bot, pause or resume
+47
View File
@@ -0,0 +1,47 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
import grabCronJobs from "@/src/functions/backend/db/cron-jobs/grab-cron-jobs";
import type { CronJobWithNames } from "@/src/types";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* One conversation's routines: the schedules that run there, a bot's own
* ones in its chat (`?workspace_id=` left out) or the ones that run in that
* workspace. Only a user who chats there sees them. The modal loads them
* when it opens, so the conversation page's props stay small.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<CronJobWithNames>> = 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 }) };
}
};
+54
View File
@@ -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);
});
});
+29
View File
@@ -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);
}
@@ -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]);
});
});
@@ -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)],
};
}
+23
View File
@@ -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 };
@@ -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");
});
});
@@ -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,
]