Let the coordinator and workspace admins hand over tasks and routines
delegate_task takes a task_id to hand over a task already on a list: it moves to the worker (a workspace's task stays on its list), goes to doing, and the job is linked back so its end is noted on the task. hand_over_schedule moves a bot's own routine to another bot, keeping its workspace. Users get Hand over in the Tasks UI and both routine lists. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -134,11 +134,11 @@ Server push only. `pushBotNotice` (`server:bot-message`, `server:bot-updated`),
|
|||||||
|
|
||||||
### Schedules (routines in the UI)
|
### Schedules (routines in the UI)
|
||||||
|
|
||||||
`cron_jobs` per user and bot (`schedule` is a `Bun.cron` expression in UTC, `max_runs`, `next_run_at`, `last_error`). Helpers in `src/functions/backend/db/cron-jobs/` (ownership-checked). `nextCronRun` validates and enforces `AppData.CronJobMinIntervalMs`; max `AppData.CronJobMaxPerBot` per bot. `fireCronJob` claims a run atomically, skips while the last job is open, queues a task. A missed run runs once. Runs act on their own (`ScheduledRunNote`); `report_run_failed` retries with doubling backoff. UI: `/admin/routines` and each conversation's Schedules modal (shared `RoutineForm`/`RoutineRow`/`RoutineEditModal`).
|
`cron_jobs` per user and bot (`schedule` is a `Bun.cron` expression in UTC, `max_runs`, `next_run_at`, `last_error`). Helpers in `src/functions/backend/db/cron-jobs/` (ownership-checked). `nextCronRun` validates and enforces `AppData.CronJobMinIntervalMs`; max `AppData.CronJobMaxPerBot` per bot. `fireCronJob` claims a run atomically, skips while the last job is open, queues a task. A missed run runs once. Runs act on their own (`ScheduledRunNote`); `report_run_failed` retries with doubling backoff. `handOverCronJob` (tool `hand_over_schedule`, UI Hand over) moves a routine to another bot: a workspace routine stays in its workspace (`handOverPlace`), its computer is kept only if the new bot reaches it, refused while a run is open. UI: `/admin/routines` and each conversation's Schedules modal (shared `RoutineForm`/`RoutineRow`/`RoutineEditModal`, `HandOverForm`).
|
||||||
|
|
||||||
### Tasks (the to-do tracker)
|
### Tasks (the to-do tracker)
|
||||||
|
|
||||||
`tasks` (one collection per bot or per workspace; exactly one of `bot_id`/`workspace_id`, enforced in `taskScopeValues` since SQLite unique treats NULLs as distinct), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`). **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
|
`tasks` (one collection per bot or per workspace; exactly one of `bot_id`/`workspace_id`, enforced in `taskScopeValues` since SQLite unique treats NULLs as distinct), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot's list (a workspace's stays on its list and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
|
||||||
|
|
||||||
### Envs
|
### Envs
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ Read-only browser of every volume (`src/utils/storage-volumes.ts`: bots, workspa
|
|||||||
- **Catalog:** `harness_models`, synced hourly. OpenRouter "free" means `:free` or `openrouter/free`, never zero prices. Auto picks: `rank-auto-models`.
|
- **Catalog:** `harness_models`, synced hourly. OpenRouter "free" means `:free` or `openrouter/free`, never zero prices. Auto picks: `rank-auto-models`.
|
||||||
- **Calling:** `resolveHarnessCandidates` -> `withCandidates` with process-wide cooldowns; errors are `HarnessError`. Entry points under `text/`, `media/`, `audio/`, `image/`, `embeddings/`. `run-text-loop.ts` sticks to one model, caps steps (`grabHarnessMaxToolSteps`, `grabReplyMaxToolSteps`), trims old tool output to fit context. Models that can't take an image/audio part get it read by vision/transcription first.
|
- **Calling:** `resolveHarnessCandidates` -> `withCandidates` with process-wide cooldowns; errors are `HarnessError`. Entry points under `text/`, `media/`, `audio/`, `image/`, `embeddings/`. `run-text-loop.ts` sticks to one model, caps steps (`grabHarnessMaxToolSteps`, `grabReplyMaxToolSteps`), trims old tool output to fit context. Models that can't take an image/audio part get it read by vision/transcription first.
|
||||||
- **Per-bot models:** workspace row > bot row > default; the coordinator always uses the defaults. Every entry point takes `bot_id` and passes it on. Shared form `settings/models/(partials)/harness-settings-form.tsx`.
|
- **Per-bot models:** workspace row > bot row > default; the coordinator always uses the defaults. Every entry point takes `bot_id` and passes it on. Shared form `settings/models/(partials)/harness-settings-form.tsx`.
|
||||||
- **Tools:** `HarnessTool` built per run; own tools validate with zod. `run-tool-call.ts` reports errors back to the model and caps each call (`AppData.HarnessToolTimeoutMs`). Bot tools in `src/harness/tools/bot/` (memory, instructions, tasks, schedules, `start_task`, coordinator's crew management and `delegate_task` with results relayed back by `relayJobToRequester`).
|
- **Tools:** `HarnessTool` built per run; own tools validate with zod. `run-tool-call.ts` reports errors back to the model and caps each call (`AppData.HarnessToolTimeoutMs`). Bot tools in `src/harness/tools/bot/` (memory, instructions, tasks, schedules, `start_task`, coordinator's crew management, `delegate_task` and `hand_over_schedule`, with results relayed back by `relayJobToRequester`).
|
||||||
- **Extensions:** MCP servers (`mcp_servers`, remote or stdio), custom tools (`custom_tools`: http, script, prompt) and skills (`skills`, runbooks loaded with `load_skill`). Given per bot / per workspace (`botExtensionsSql`); `all_bots` only decides what new bots get. Templates `{{args.x}}`/`{{env.KEY}}` via `fillTemplate`; secrets redacted from results. stdio servers run in a per-process pool. Skill body limits are app settings (`grabSkillBodyMaxChars`, `grabSkillBodyShownChars`).
|
- **Extensions:** MCP servers (`mcp_servers`, remote or stdio), custom tools (`custom_tools`: http, script, prompt) and skills (`skills`, runbooks loaded with `load_skill`). Given per bot / per workspace (`botExtensionsSql`); `all_bots` only decides what new bots get. Templates `{{args.x}}`/`{{env.KEY}}` via `fillTemplate`; secrets redacted from results. stdio servers run in a per-process pool. Skill body limits are app settings (`grabSkillBodyMaxChars`, `grabSkillBodyShownChars`).
|
||||||
- **Bots never add or repoint extensions or set envs.** The coordinator may only change what can't alter where data is sent (name, description, on/off, access). New skills, servers, tools, or changes to a URL, headers, kind, config or a skill's runbook go through `propose_*` -> `extension_proposals` -> `[proposal:ID]` card, which the app describes from the config itself (never from what the bot says) and the super admin confirms (`checkProposalData` re-checks). Stdio servers are never proposed by bots (they run in the app container, which holds the docker socket).
|
- **Bots never add or repoint extensions or set envs.** The coordinator may only change what can't alter where data is sent (name, description, on/off, access). New skills, servers, tools, or changes to a URL, headers, kind, config or a skill's runbook go through `propose_*` -> `extension_proposals` -> `[proposal:ID]` card, which the app describes from the config itself (never from what the bot says) and the super admin confirms (`checkProposalData` re-checks). Stdio servers are never proposed by bots (they run in the app container, which holds the docker socket).
|
||||||
- **Check before and after acting** (`ActionCheckNote`): look for an existing record before creating one, verify every action took effect. Tools enforce it (read-back on memory saves, duplicate checks on bots, schedules, tasks, workspaces).
|
- **Check before and after acting** (`ActionCheckNote`): look for an existing record before creating one, verify every action took effect. Tools enforce it (read-back on memory saves, duplicate checks on bots, schedules, tasks, workspaces).
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
|
import Select from "@moduletrace/twui/form/Select";
|
||||||
|
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||||
|
import type { HandOverTarget } from "@/src/types";
|
||||||
|
|
||||||
|
type Props<T> = {
|
||||||
|
/**
|
||||||
|
* What is handed over, for the confirm ("the task "Draft the post"").
|
||||||
|
*/
|
||||||
|
what: string;
|
||||||
|
/**
|
||||||
|
* Under `/api/admin/`: who it can go to, and the hand-over itself.
|
||||||
|
* Both get `body`; the hand-over also the picked `bot_id`.
|
||||||
|
*/
|
||||||
|
targets_path: string;
|
||||||
|
submit_path: string;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Offer a note added to the brief (a task's hand-over).
|
||||||
|
*/
|
||||||
|
with_note?: boolean;
|
||||||
|
onHandedOver: (params: { saved: T; msg: string }) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the bot a task or routine goes to (the ones already where it runs,
|
||||||
|
* loaded when the form opens) and hands it over. Shared by the task panel
|
||||||
|
* and both routine lists.
|
||||||
|
*/
|
||||||
|
export default function HandOverForm<T extends { [k: string]: any }>({
|
||||||
|
what,
|
||||||
|
targets_path,
|
||||||
|
submit_path,
|
||||||
|
body,
|
||||||
|
with_note,
|
||||||
|
onHandedOver,
|
||||||
|
onCancel,
|
||||||
|
}: Props<T>) {
|
||||||
|
const [targets, setTargets] = useState<HandOverTarget[]>();
|
||||||
|
const [bot_id, setBotId] = useState<number>();
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
postAdminApi<HandOverTarget>({ path: targets_path, body }).then((res) => {
|
||||||
|
if (!res.success) {
|
||||||
|
setError(res.msg || "Couldn't load the bots.");
|
||||||
|
setTargets([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = (res.payload || []) as HandOverTarget[];
|
||||||
|
|
||||||
|
setTargets(list);
|
||||||
|
setBotId(list[0]?.id);
|
||||||
|
});
|
||||||
|
}, [targets_path]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
const target = targets?.find((it) => it.id == bot_id);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
setError("Pick a bot.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.confirm(`Hand ${what} over to ${target.name}?`)) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi<T>({
|
||||||
|
path: submit_path,
|
||||||
|
body: { ...body, bot_id: target.id, instructions: with_note && note.trim() ? note : undefined },
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (!res.success || !res.singleRes) {
|
||||||
|
setError(res.msg || "Couldn't hand it over.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onHandedOver({ saved: res.singleRes, msg: res.msg || `Handed over to ${target.name}.` });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={submit} className="w-full gap-3! items-stretch">
|
||||||
|
<Stack className="gap-3! items-stretch">
|
||||||
|
{!targets ? (
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">Loading the bots...</Span>
|
||||||
|
) : targets.length ? (
|
||||||
|
<Select
|
||||||
|
key={targets.length}
|
||||||
|
label="Hand over to"
|
||||||
|
showLabel
|
||||||
|
options={targets.map((it) => ({ title: it.name, value: String(it.id), default: it.id == bot_id }))}
|
||||||
|
changeHandler={(value) => setBotId(Number(value))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
No other bot works where this runs. Add one there first.
|
||||||
|
</Span>
|
||||||
|
)}
|
||||||
|
{with_note && targets?.length ? (
|
||||||
|
<Textarea
|
||||||
|
label="Note for the brief"
|
||||||
|
value={note}
|
||||||
|
changeHandler={setNote}
|
||||||
|
placeholder="Anything they should know beyond the task's own brief."
|
||||||
|
rows={3}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AddFormActions submit_label="Hand over" loading={loading} error={error} onCancel={onCancel} />
|
||||||
|
</Stack>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ type Props = {
|
|||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
/**
|
||||||
|
* Hand it to another bot where it runs.
|
||||||
|
*/
|
||||||
|
onHandOver?: () => void;
|
||||||
/**
|
/**
|
||||||
* In a conversation's own modal the Chat link and the bot's and
|
* In a conversation's own modal the Chat link and the bot's and
|
||||||
* workspace's names are already the ones on screen.
|
* workspace's names are already the ones on screen.
|
||||||
@@ -21,9 +25,10 @@ type Props = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One routine: its bot's avatar, its name (Paused or Done when it is),
|
* 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.
|
* whose it is and when it runs, then Chat, Pause or Resume, Edit, Hand
|
||||||
|
* over, Delete.
|
||||||
*/
|
*/
|
||||||
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete, compact }: Props) {
|
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete, onHandOver, compact }: Props) {
|
||||||
const title = cron_job.title || `Routine ${cron_job.id}`;
|
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 done = Boolean(cron_job.max_runs && Number(cron_job.run_count || 0) >= cron_job.max_runs);
|
||||||
const chat_url = cron_job.workspace_id
|
const chat_url = cron_job.workspace_id
|
||||||
@@ -57,6 +62,14 @@ export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete,
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
/>
|
/>
|
||||||
<SettingsRowAction title={`Edit ${title}`} label="Edit" disabled={busy} onClick={onEdit} />
|
<SettingsRowAction title={`Edit ${title}`} label="Edit" disabled={busy} onClick={onEdit} />
|
||||||
|
{onHandOver ? (
|
||||||
|
<SettingsRowAction
|
||||||
|
title={`Hand ${title} to another bot`}
|
||||||
|
label="Hand over"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onHandOver}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<SettingsRowAction title={`Delete ${title}`} label="Delete" disabled={busy} onClick={onDelete} />
|
<SettingsRowAction title={`Delete ${title}`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||||
</SettingsListRow>
|
</SettingsListRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import Row from "@moduletrace/twui/layout/Row";
|
|||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||||
|
import { OpenTaskStatuses } from "@/src/dict/task-dict";
|
||||||
import type { TaskDetail, TaskFile } from "@/src/types";
|
import type { TaskDetail, TaskFile } from "@/src/types";
|
||||||
import TaskFileRow from "@/src/components/general/task-file-row";
|
import TaskFileRow from "@/src/components/general/task-file-row";
|
||||||
|
import HandOverForm from "@/src/components/general/hand-over-form";
|
||||||
import TaskForm from "@/src/components/general/task-form";
|
import TaskForm from "@/src/components/general/task-form";
|
||||||
import TaskStatusChip from "@/src/components/general/task-status-chip";
|
import TaskStatusChip from "@/src/components/general/task-status-chip";
|
||||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
@@ -24,10 +26,12 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* One task opened: what it is, its notes oldest first, its files (each
|
* One task opened: what it is, its notes oldest first, its files (each
|
||||||
* opening its bytes), a box to add a note, and edit or delete. A crew
|
* opening its bytes), a box to add a note, and edit or delete. A crew
|
||||||
* manager can file a new deliverable straight onto it.
|
* manager can file a new deliverable straight onto it, or hand an open
|
||||||
|
* one to another bot, which starts on it.
|
||||||
*/
|
*/
|
||||||
export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) {
|
export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [handing_over, setHandingOver] = useState(false);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [status, setStatus] = useState<string>();
|
const [status, setStatus] = useState<string>();
|
||||||
@@ -107,6 +111,23 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (handing_over) {
|
||||||
|
return (
|
||||||
|
<HandOverForm<TaskDetail>
|
||||||
|
what={`"${task.title}"`}
|
||||||
|
targets_path="tasks/hand-over-targets"
|
||||||
|
submit_path="tasks/hand-over-task"
|
||||||
|
body={{ task_id: Number(task.id) }}
|
||||||
|
with_note
|
||||||
|
onCancel={() => setHandingOver(false)}
|
||||||
|
onHandedOver={({ saved, msg }) => {
|
||||||
|
setHandingOver(false);
|
||||||
|
onChanged(saved, msg);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const deliverables = task.files.filter((file) => file.kind != "reference");
|
const deliverables = task.files.filter((file) => file.kind != "reference");
|
||||||
const references = task.files.filter((file) => file.kind == "reference");
|
const references = task.files.filter((file) => file.kind == "reference");
|
||||||
|
|
||||||
@@ -121,6 +142,20 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
|||||||
</Span>
|
</Span>
|
||||||
{can_manage ? (
|
{can_manage ? (
|
||||||
<Row className="shrink-0 gap-1! flex-nowrap">
|
<Row className="shrink-0 gap-1! flex-nowrap">
|
||||||
|
{(OpenTaskStatuses as readonly string[]).includes(task.status || "") ? (
|
||||||
|
<Button
|
||||||
|
title="Hand the task to another bot"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setHandingOver(true)}
|
||||||
|
className="px-2! text-[12px]"
|
||||||
|
>
|
||||||
|
Hand over
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
title="Edit the task"
|
title="Edit the task"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe("CoordinatorSystemPrompt", () => {
|
|||||||
const tools = [
|
const tools = [
|
||||||
"start_task",
|
"start_task",
|
||||||
"delegate_task",
|
"delegate_task",
|
||||||
|
"hand_over_schedule",
|
||||||
"create_bot",
|
"create_bot",
|
||||||
"create_workspace",
|
"create_workspace",
|
||||||
"update_workspace",
|
"update_workspace",
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ Permission to draft is not permission to send, permission to analyze is not perm
|
|||||||
- Delegating into a workspace the worker isn't in adds it to that workspace as a side effect. Say that in your reply when it happens; don't add members silently for a one-off.
|
- Delegating into a workspace the worker isn't in adds it to that workspace as a side effect. Say that in your reply when it happens; don't add members silently for a one-off.
|
||||||
- Give a full brief: objective, inputs, deliverable, acceptance criteria, constraints.
|
- Give a full brief: objective, inputs, deliverable, acceptance criteria, constraints.
|
||||||
- Check \`list_tasks\` first. Never give two bots the same job, and don't restart work an open task already covers.
|
- Check \`list_tasks\` first. Never give two bots the same job, and don't restart work an open task already covers.
|
||||||
|
- When the work is already a task on a list, hand that task over: \`delegate_task\` with its \`task_id\`. It moves to the worker (a workspace's task stays on that workspace's list and is worked there), goes to doing, and the worker moves that same task along. Never copy it into a new one.
|
||||||
|
- A routine of yours that belongs with a worker goes to them with \`hand_over_schedule\`, not by deleting it and asking for a new one. A routine in a workspace keeps running there.
|
||||||
- Track what comes back, judge it against the request, and integrate it. Don't pass a worker's unchecked claim on as fact.
|
- Track what comes back, judge it against the request, and integrate it. Don't pass a worker's unchecked claim on as fact.
|
||||||
- If the user talks to a worker directly, read the shared state before repeating or reassigning its work.
|
- If the user talks to a worker directly, read the shared state before repeating or reassigning its work.
|
||||||
- Never invent a crew member, its progress or its capabilities.
|
- Never invent a crew member, its progress or its capabilities.
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ Every action counts here: saving memory or instructions, creating or changing a
|
|||||||
* `bot_id` is the coordinator itself, so the ref example it copies is its
|
* `bot_id` is the coordinator itself, so the ref example it copies is its
|
||||||
* own rather than a worker's.
|
* own rather than a worker's.
|
||||||
*/
|
*/
|
||||||
export const coordinatorReplyNote = ({ bot_id }: { bot_id: number | string }) => `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it and where: a task with no workspace_id runs in that bot's own chat, on its own memory, files and directly assigned computers; one with workspace_id runs in the workspace, on the workspace's context, files and computers as well as the bot's, and lands in that project's conversation. Never swap one for the other without saying so. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: bot_id })} for yourself: the user sees it as the bot's name, linked to its conversation.`;
|
export const coordinatorReplyNote = ({ bot_id }: { bot_id: number | string }) => `As the coordinator, hand work that fits a crew member's role to them with delegate_task. Work that is already a task on a list goes over as that task (delegate_task with its task_id), never as a copy; a routine of yours that belongs with a worker goes with hand_over_schedule. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it and where: a task with no workspace_id runs in that bot's own chat, on its own memory, files and directly assigned computers; one with workspace_id runs in the workspace, on the workspace's context, files and computers as well as the bot's, and lands in that project's conversation. Never swap one for the other without saying so. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: bot_id })} for yourself: the user sees it as the bot's name, linked to its conversation.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to the coordinator's chat prompt instead while it has no workers,
|
* Added to the coordinator's chat prompt instead while it has no workers,
|
||||||
@@ -163,7 +163,7 @@ export const workspaceAdminNote = ({
|
|||||||
members: { id?: number | "" }[];
|
members: { id?: number | "" }[];
|
||||||
}) => `# Leading ${workspace_name}
|
}) => `# Leading ${workspace_name}
|
||||||
|
|
||||||
You are this workspace's admin: you lead the bots working here (${members.map((m) => botRef({ id: m.id || 0 })).join(", ")}). Hand work that fits a member's role to them with delegate_task rather than doing all of it yourself; they post the result in their conversation here, and it's reported back to you when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters, follow up on anything left and propose what comes next. Set how a member should work here with set_bot_workspace_instructions, keep the workspace's task list honest (create_task can put a task on a member's own list), and keep the workspace's shared context current with update_workspace when you have it. When you name a member, write its ref, so the user sees its name linked to its conversation.`;
|
You are this workspace's admin: you lead the bots working here (${members.map((m) => botRef({ id: m.id || 0 })).join(", ")}). Hand work that fits a member's role to them with delegate_task rather than doing all of it yourself (a task already on a list goes over with its task_id, and one of your routines here with hand_over_schedule); they post the result in their conversation here, and it's reported back to you when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters, follow up on anything left and propose what comes next. Set how a member should work here with set_bot_workspace_instructions, keep the workspace's task list honest (create_task can put a task on a member's own list), and keep the workspace's shared context current with update_workspace when you have it. When you name a member, write its ref, so the user sees its name linked to its conversation.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to a workspace member's prompt while the workspace has an admin,
|
* Added to a workspace member's prompt while the workspace has an admin,
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export const JobStepToolLabels: Record<string, string> = {
|
|||||||
ask_user_to_take_over: "Asking you to take over",
|
ask_user_to_take_over: "Asking you to take over",
|
||||||
start_task: "Starting a task",
|
start_task: "Starting a task",
|
||||||
delegate_task: "Handing work to the crew",
|
delegate_task: "Handing work to the crew",
|
||||||
|
hand_over_schedule: "Handing a routine to the crew",
|
||||||
assign_computer: "Changing computer access",
|
assign_computer: "Changing computer access",
|
||||||
update_memory: "Updating memory",
|
update_memory: "Updating memory",
|
||||||
update_instructions: "Updating instructions",
|
update_instructions: "Updating instructions",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import setBotFace from "../bots/set-bot-face";
|
|||||||
import countOpenBotJobs from "./count-open-bot-jobs";
|
import countOpenBotJobs from "./count-open-bot-jobs";
|
||||||
import grabLockedBotJob from "./grab-locked-bot-job";
|
import grabLockedBotJob from "./grab-locked-bot-job";
|
||||||
import relayJobToRequester from "./relay-job-to-requester";
|
import relayJobToRequester from "./relay-job-to-requester";
|
||||||
|
import noteTrackerTask from "./note-tracker-task";
|
||||||
import jobSessionId from "./job-session-id";
|
import jobSessionId from "./job-session-id";
|
||||||
import grabJobUserId from "./grab-job-user-id";
|
import grabJobUserId from "./grab-job-user-id";
|
||||||
import grabRunningBotJobFace from "./grab-running-bot-job-face";
|
import grabRunningBotJobFace from "./grab-running-bot-job-face";
|
||||||
@@ -40,7 +41,7 @@ type Params = {
|
|||||||
* after a task and idle after a reply; with others open, it shows the one
|
* after a task and idle after a reply; with others open, it shows the one
|
||||||
* still running (a reply in another session mustn't hide a task's row).
|
* still running (a reply in another session mustn't hide a task's row).
|
||||||
* Nobody else's face changes. A handed-over job's result also goes to the bot that handed
|
* Nobody else's face changes. A handed-over job's result also goes to the bot that handed
|
||||||
* it over (`relay`). Throws if this handler no longer holds the job.
|
* it over (`relay`), and a handed-over task left open says so (`noteTrackerTask`). Throws if this handler no longer holds the job.
|
||||||
*/
|
*/
|
||||||
export default async function completeBotJob({
|
export default async function completeBotJob({
|
||||||
job_id,
|
job_id,
|
||||||
@@ -129,6 +130,8 @@ export default async function completeBotJob({
|
|||||||
face: job.kind == "reply" ? "idle" : "done",
|
face: job.kind == "reply" ? "idle" : "done",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await noteTrackerTask({ job });
|
||||||
|
|
||||||
const relay = silent ? null : await relayJobToRequester({ job, text: result });
|
const relay = silent ? null : await relayJobToRequester({ job, text: result });
|
||||||
|
|
||||||
return { message, bot, relay };
|
return { message, bot, relay };
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a job is still queued or working.
|
||||||
|
*/
|
||||||
|
export default async function isBotJobOpen({ job_id }: { job_id: number | string }) {
|
||||||
|
const res = await BunSQLite.sql({
|
||||||
|
sql: `SELECT id FROM bots_queue WHERE id = ? AND status IN ('pending', 'processing') LIMIT 1`,
|
||||||
|
values: [job_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Boolean(res.payload?.[0]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
||||||
|
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
|
import grabBotById from "../bots/grab-bot-by-id";
|
||||||
|
import addTaskNote from "../tasks/add-task-note";
|
||||||
|
import grabTaskRow, { isOpenTask } from "../tasks/grab-task-scope";
|
||||||
|
import updateTask from "../tasks/update-task";
|
||||||
|
import grabJobUserId from "./grab-job-user-id";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
|
||||||
|
/**
|
||||||
|
* Why it failed; left out when it finished.
|
||||||
|
*/
|
||||||
|
failure?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The end of a job worked on a handed-over task (`params.tracker_task_id`,
|
||||||
|
* set by `handOverTask`), written on that task while it's still open: a
|
||||||
|
* run that finished without closing it says so, a failed one blocks it
|
||||||
|
* with the reason. The bot moves the task along itself as it works; this
|
||||||
|
* only keeps the task honest when it didn't. Best effort: never fails
|
||||||
|
* the job's wrap-up.
|
||||||
|
*/
|
||||||
|
export default async function noteTrackerTask({ job, failure }: Params) {
|
||||||
|
const tracker_task_id = Number(parseJobJson({ text: job.params })?.tracker_task_id || 0);
|
||||||
|
|
||||||
|
if (!tracker_task_id || !job.bot_id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await grabTaskRow({ task_id: tracker_task_id });
|
||||||
|
|
||||||
|
if (!isOpenTask({ status: task.status })) return;
|
||||||
|
|
||||||
|
const name = (await grabBotById({ bot_id: job.bot_id }))?.name || "The bot";
|
||||||
|
|
||||||
|
if (failure === undefined) {
|
||||||
|
await addTaskNote({
|
||||||
|
task_id: tracker_task_id,
|
||||||
|
text: `${name}'s run finished; the task is still ${task.status}.`,
|
||||||
|
bot_id: job.bot_id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = `${name} couldn't finish it. ${failure}`.trim().slice(0, AppData.TaskNoteMaxLength);
|
||||||
|
const user_id = await grabJobUserId({ job });
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
await updateTask({
|
||||||
|
user_id,
|
||||||
|
task_id: tracker_task_id,
|
||||||
|
status: "blocked",
|
||||||
|
blocked_reason: reason,
|
||||||
|
updated_by_bot_id: job.bot_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await addTaskNote({ task_id: tracker_task_id, text: reason, bot_id: job.bot_id });
|
||||||
|
} catch (error) {
|
||||||
|
await logError({
|
||||||
|
title: `Couldn't note job #${job.id} on task #${tracker_task_id}`,
|
||||||
|
error,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
|||||||
import insertBotMessage from "../bots/insert-bot-message";
|
import insertBotMessage from "../bots/insert-bot-message";
|
||||||
import setBotFace from "../bots/set-bot-face";
|
import setBotFace from "../bots/set-bot-face";
|
||||||
import relayJobToRequester from "./relay-job-to-requester";
|
import relayJobToRequester from "./relay-job-to-requester";
|
||||||
|
import noteTrackerTask from "./note-tracker-task";
|
||||||
import jobSessionId from "./job-session-id";
|
import jobSessionId from "./job-session-id";
|
||||||
import grabJobUserId from "./grab-job-user-id";
|
import grabJobUserId from "./grab-job-user-id";
|
||||||
import { BotNeedsYouStatusLine } from "@/src/dict/bot-queue-dict";
|
import { BotNeedsYouStatusLine } from "@/src/dict/bot-queue-dict";
|
||||||
@@ -16,7 +17,8 @@ type Params = {
|
|||||||
* Wraps up a job that is out of attempts: stamps `finished_at` (so it's
|
* Wraps up a job that is out of attempts: stamps `finished_at` (so it's
|
||||||
* reported once), tells the user in the bot's conversation and sets the
|
* reported once), tells the user in the bot's conversation and sets the
|
||||||
* bot to needs-you for that user. Its task stays active, since the work isn't done. A
|
* bot to needs-you for that user. Its task stays active, since the work isn't done. A
|
||||||
* handed-over job's failure also goes to the bot that handed it over.
|
* handed-over job's failure also goes to the bot that handed it over, and
|
||||||
|
* blocks the handed-over task it worked on (`noteTrackerTask`).
|
||||||
*/
|
*/
|
||||||
export default async function notifyBotJobFailed({
|
export default async function notifyBotJobFailed({
|
||||||
job,
|
job,
|
||||||
@@ -59,6 +61,8 @@ export default async function notifyBotJobFailed({
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
await noteTrackerTask({ job, failure: failure_message || job.last_error || "" });
|
||||||
|
|
||||||
const relay = await relayJobToRequester({
|
const relay = await relayJobToRequester({
|
||||||
job,
|
job,
|
||||||
text: failure_message || job.last_error || "",
|
text: failure_message || job.last_error || "",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { HandOverTarget } from "@/src/types";
|
||||||
|
import grabBots from "./grab-bots";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
/**
|
||||||
|
* Where the work runs: the bots the user chats with there, or in their
|
||||||
|
* own chat when none.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* The bot it's with now.
|
||||||
|
*/
|
||||||
|
except_bot_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bots a user can hand a task or routine to where it runs, for the
|
||||||
|
* picker. Only ones already there: joining a workspace is the
|
||||||
|
* coordinator's call, not a picker's side effect.
|
||||||
|
*/
|
||||||
|
export default async function grabHandOverTargets({ user_id, workspace_id, except_bot_id }: Params): Promise<HandOverTarget[]> {
|
||||||
|
const bots = await grabBots({ user_id, workspace_id });
|
||||||
|
|
||||||
|
return bots
|
||||||
|
.filter((bot) => bot.id && bot.id != Number(except_bot_id || 0))
|
||||||
|
.map((bot) => ({ id: Number(bot.id), name: bot.name || `Bot ${bot.id}` }));
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import nextCronRun from "@/src/utils/next-cron-run";
|
|||||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
import ensureBotSession from "../bot-sessions/ensure-bot-session";
|
import ensureBotSession from "../bot-sessions/ensure-bot-session";
|
||||||
import enqueueBotJob from "../bots-queue/enqueue-bot-job";
|
import enqueueBotJob from "../bots-queue/enqueue-bot-job";
|
||||||
|
import isBotJobOpen from "../bots-queue/is-bot-job-open";
|
||||||
import grabBotComputers from "../computers/grab-bot-computers";
|
import grabBotComputers from "../computers/grab-bot-computers";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -42,7 +43,7 @@ export default async function fireCronJob({ cron_job, now = Date.now() }: Params
|
|||||||
if (!claimed.payload?.[0]) return;
|
if (!claimed.payload?.[0]) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (cron_job.last_job_id && (await isJobOpen({ job_id: cron_job.last_job_id }))) {
|
if (cron_job.last_job_id && (await isBotJobOpen({ job_id: cron_job.last_job_id }))) {
|
||||||
await saveRun({ id, last_error: `Skipped a run: the last one was still going.` });
|
await saveRun({ id, last_error: `Skipped a run: the last one was still going.` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -99,15 +100,6 @@ export default async function fireCronJob({ cron_job, now = Date.now() }: Params
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isJobOpen({ job_id }: { job_id: number }) {
|
|
||||||
const res = await BunSQLite.sql({
|
|
||||||
sql: `SELECT id FROM bots_queue WHERE id = ? AND status IN ('pending', 'processing') LIMIT 1`,
|
|
||||||
values: [job_id],
|
|
||||||
});
|
|
||||||
|
|
||||||
return Boolean(res.payload?.[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRun({
|
async function saveRun({
|
||||||
id,
|
id,
|
||||||
last_run_at,
|
last_run_at,
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import handOverPlace from "@/src/utils/hand-over-place";
|
||||||
|
import grabUserConversation from "../bot-sessions/grab-user-conversation";
|
||||||
|
import ensureBotSession from "../bot-sessions/ensure-bot-session";
|
||||||
|
import isBotJobOpen from "../bots-queue/is-bot-job-open";
|
||||||
|
import grabBotById from "../bots/grab-bot-by-id";
|
||||||
|
import insertBotMessage from "../bots/insert-bot-message";
|
||||||
|
import grabBotComputers from "../computers/grab-bot-computers";
|
||||||
|
import ensureBotInWorkspace from "../workspaces/ensure-bot-in-workspace";
|
||||||
|
import grabUserCronJob from "./grab-user-cron-job";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The user the routine belongs to.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
cron_job_id: number | string;
|
||||||
|
/**
|
||||||
|
* Only if it's this bot's (a bot handing over its own).
|
||||||
|
*/
|
||||||
|
from_bot_id?: number | string;
|
||||||
|
to_bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* Where it runs from now, for a routine in the bot's own chat: left
|
||||||
|
* out, the new bot's own chat. A routine in a workspace stays there.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Who hands it over, shown in the new bot's conversation.
|
||||||
|
*/
|
||||||
|
handed_by: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves one of the user's routines to another bot. A routine in a
|
||||||
|
* workspace keeps running there (the bot is added first when it isn't in
|
||||||
|
* it); one in a bot's own chat runs in the new bot's, or in the workspace
|
||||||
|
* given. Its computer stays only if the new bot reaches it there; its
|
||||||
|
* schedule, count and next run are kept. Refused while a run is going,
|
||||||
|
* or when the new bot has no room for another. Returns the routine and
|
||||||
|
* what was said about joining.
|
||||||
|
*/
|
||||||
|
export default async function handOverCronJob({
|
||||||
|
user_id,
|
||||||
|
cron_job_id,
|
||||||
|
from_bot_id,
|
||||||
|
to_bot_id,
|
||||||
|
workspace_id,
|
||||||
|
handed_by,
|
||||||
|
}: Params) {
|
||||||
|
const cron_job = await grabUserCronJob({ user_id, cron_job_id, bot_id: from_bot_id });
|
||||||
|
|
||||||
|
if (Number(cron_job.bot_id) == Number(to_bot_id)) {
|
||||||
|
throw new AppError(`${cron_job.bot_name || "That bot"} already runs "${cron_job.title}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const run_workspace_id = handOverPlace({
|
||||||
|
workspace_id: cron_job.workspace_id,
|
||||||
|
asked_workspace_id: workspace_id,
|
||||||
|
label: `Routine #${cron_job.id} "${cron_job.title}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cron_job.last_job_id && (await isBotJobOpen({ job_id: cron_job.last_job_id }))) {
|
||||||
|
throw new AppError(`"${cron_job.title}" has a run going. Hand it over once that's done.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = await grabBotById({ bot_id: to_bot_id });
|
||||||
|
|
||||||
|
if (!bot?.id) {
|
||||||
|
throw new AppError(`Bot not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Number(
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<{ n: number }>({
|
||||||
|
sql: `SELECT COUNT(*) AS n FROM cron_jobs WHERE bot_id = ? AND user_id = ?`,
|
||||||
|
values: [bot.id, user_id],
|
||||||
|
})
|
||||||
|
).payload?.[0]?.n || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (count >= AppData.CronJobMaxPerBot) {
|
||||||
|
throw new AppError(`${bot.name || "That bot"} already has ${AppData.CronJobMaxPerBot} routines.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const joined = run_workspace_id ? await ensureBotInWorkspace({ user_id, bot, workspace_id: run_workspace_id }) : "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user must reach the bot there: its own chat, or the workspace.
|
||||||
|
*/
|
||||||
|
await grabUserConversation({ user_id, bot_id: bot.id, workspace_id: run_workspace_id });
|
||||||
|
|
||||||
|
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id: run_workspace_id });
|
||||||
|
const computer_id =
|
||||||
|
cron_job.computer_id && computers.some((computer) => computer.id == cron_job.computer_id)
|
||||||
|
? cron_job.computer_id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql({
|
||||||
|
sql: `UPDATE cron_jobs SET bot_id = ?, workspace_id = ?, computer_id = ?, last_error = NULL, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [bot.id, run_workspace_id, computer_id, Date.now(), cron_job.id!],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new AppError(`Couldn't hand the routine over: ${res.msg || res.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await ensureBotSession({ user_id, bot_id: bot.id, workspace_id: run_workspace_id });
|
||||||
|
|
||||||
|
await pushBotNotice({
|
||||||
|
bot_id: bot.id,
|
||||||
|
message: await insertBotMessage({
|
||||||
|
bot_id: bot.id,
|
||||||
|
session_id: Number(session.id),
|
||||||
|
workspace_id: run_workspace_id,
|
||||||
|
text: `${handed_by} handed me the routine **${cron_job.title}** (\`${cron_job.schedule}\`, UTC). It runs here from now on.`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
cron_job: await grabUserCronJob({ user_id, cron_job_id: cron_job.id! }),
|
||||||
|
bot_name: bot.name || "The bot",
|
||||||
|
joined,
|
||||||
|
computer_dropped: Boolean(cron_job.computer_id && !computer_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
|
||||||
|
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import handOverPlace from "@/src/utils/hand-over-place";
|
||||||
|
import grabUserConversation from "../bot-sessions/grab-user-conversation";
|
||||||
|
import ensureBotSession from "../bot-sessions/ensure-bot-session";
|
||||||
|
import enqueueBotJob from "../bots-queue/enqueue-bot-job";
|
||||||
|
import grabOpenBotTask from "../bots-queue/grab-open-bot-task";
|
||||||
|
import grabBotById from "../bots/grab-bot-by-id";
|
||||||
|
import insertBotMessage from "../bots/insert-bot-message";
|
||||||
|
import grabBotComputers from "../computers/grab-bot-computers";
|
||||||
|
import ensureBotInWorkspace from "../workspaces/ensure-bot-in-workspace";
|
||||||
|
import addTaskNote from "./add-task-note";
|
||||||
|
import grabTaskRow, { isOpenTask } from "./grab-task-scope";
|
||||||
|
import moveTask from "./move-task";
|
||||||
|
import updateTask from "./update-task";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The user the work runs for. The caller checked they (or the bot's
|
||||||
|
* run) reach the task.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
task_id: number | string;
|
||||||
|
/**
|
||||||
|
* The bot taking it over.
|
||||||
|
*/
|
||||||
|
to_bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* Where the work runs, for a task on a bot's own list: left out, the
|
||||||
|
* new bot's own chat. A task on a workspace's list always runs there.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* More for the brief; the task's own brief comes first.
|
||||||
|
*/
|
||||||
|
instructions?: string;
|
||||||
|
/**
|
||||||
|
* Who hands it over, shown in the new bot's conversation and the
|
||||||
|
* task's log.
|
||||||
|
*/
|
||||||
|
handed_by: string;
|
||||||
|
/**
|
||||||
|
* The bot handing it over and the session it did from: the result is
|
||||||
|
* reported back there (`relayJobToRequester`). None when a user does.
|
||||||
|
*/
|
||||||
|
requested_by_bot_id?: number | string | null;
|
||||||
|
requested_by_session_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HandedOverTask = {
|
||||||
|
task: BUN_SQLITE_COBBLE_TASKS;
|
||||||
|
job_id: number | string;
|
||||||
|
bot_name: string;
|
||||||
|
workspace_id: number | null;
|
||||||
|
/**
|
||||||
|
* Says the bot was added to the workspace first, or "".
|
||||||
|
*/
|
||||||
|
joined: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands an open task to another bot and starts the work: a task on a
|
||||||
|
* workspace's list stays there (the work runs in that workspace), one on
|
||||||
|
* a bot's own list moves to the new bot's. The task goes to doing, its log
|
||||||
|
* says who handed it over, and a job is queued in the new bot's
|
||||||
|
* conversation with the task's brief and `params.tracker_task_id`, so the
|
||||||
|
* bot moves this same task along and the job's end is noted on it.
|
||||||
|
*/
|
||||||
|
export default async function handOverTask({
|
||||||
|
user_id,
|
||||||
|
task_id,
|
||||||
|
to_bot_id,
|
||||||
|
workspace_id,
|
||||||
|
instructions,
|
||||||
|
handed_by,
|
||||||
|
requested_by_bot_id,
|
||||||
|
requested_by_session_id,
|
||||||
|
}: Params): Promise<HandedOverTask> {
|
||||||
|
const task = await grabTaskRow({ task_id });
|
||||||
|
|
||||||
|
if (!isOpenTask({ status: task.status })) {
|
||||||
|
throw new AppError(`Task #${task.id} is ${task.status}. Only an open task can be handed over.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requested_by_bot_id && Number(to_bot_id) == Number(requested_by_bot_id)) {
|
||||||
|
throw new AppError(`Hand the task to another bot, not yourself.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const run_workspace_id = handOverPlace({
|
||||||
|
workspace_id: task.workspace_id,
|
||||||
|
asked_workspace_id: workspace_id,
|
||||||
|
label: `Task #${task.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bot = await grabBotById({ bot_id: to_bot_id });
|
||||||
|
|
||||||
|
if (!bot?.id) {
|
||||||
|
throw new AppError(`Bot not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const joined = run_workspace_id ? await ensureBotInWorkspace({ user_id, bot, workspace_id: run_workspace_id }) : "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user must reach the bot there: its own chat, or the workspace.
|
||||||
|
*/
|
||||||
|
const conversation = await grabUserConversation({ user_id, bot_id: bot.id, workspace_id: run_workspace_id });
|
||||||
|
const session = await ensureBotSession({ user_id, bot_id: bot.id, workspace_id: run_workspace_id });
|
||||||
|
const title = (task.title || "Task").slice(0, 80);
|
||||||
|
const open = await grabOpenBotTask({ bot_id: bot.id, session_id: Number(session.id), title });
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
throw new AppError(
|
||||||
|
`${bot.name} already has "${title}" ${open.status == "pending" ? "queued" : "under way"} (job #${open.id}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const moved = task.workspace_id ? task : await moveTask({ task_id: task.id!, bot_id: bot.id });
|
||||||
|
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id: run_workspace_id });
|
||||||
|
|
||||||
|
const brief = [
|
||||||
|
task.description?.trim(),
|
||||||
|
instructions?.trim(),
|
||||||
|
`This is task #${task.id} on ${task.workspace_id ? "this workspace's" : "your"} list, handed over by ${handed_by}. Move it along with update_task as you work, file what it produces on it, and mark it done once you've verified the work.`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
const job_id = await enqueueBotJob({
|
||||||
|
user_id,
|
||||||
|
bot_id: bot.id,
|
||||||
|
session_id: Number(session.id),
|
||||||
|
title,
|
||||||
|
instructions: brief,
|
||||||
|
params: { tracker_task_id: Number(task.id) },
|
||||||
|
computer_id: computers[0]?.id || undefined,
|
||||||
|
requested_by_bot_id: requested_by_bot_id || undefined,
|
||||||
|
requested_by_session_id: requested_by_session_id || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateTask({
|
||||||
|
user_id,
|
||||||
|
task_id: task.id!,
|
||||||
|
status: "doing",
|
||||||
|
updated_by_bot_id: requested_by_bot_id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await addTaskNote({
|
||||||
|
task_id: task.id!,
|
||||||
|
text: `${handed_by} handed this over to ${bot.name}.`,
|
||||||
|
author: requested_by_bot_id ? "bot" : "user",
|
||||||
|
bot_id: requested_by_bot_id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await pushBotNotice({
|
||||||
|
bot_id: bot.id,
|
||||||
|
message: await insertBotMessage({
|
||||||
|
bot_id: bot.id,
|
||||||
|
session_id: Number(session.id),
|
||||||
|
workspace_id: run_workspace_id,
|
||||||
|
text: `${handed_by} handed me task #${task.id}: **${title}**`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
task: { ...moved, status: "doing" },
|
||||||
|
job_id,
|
||||||
|
bot_name: conversation.bot.name || bot.name || "The bot",
|
||||||
|
workspace_id: run_workspace_id,
|
||||||
|
joined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import findDuplicateTask from "./find-duplicate-task";
|
||||||
|
import grabTaskRow from "./grab-task-scope";
|
||||||
|
import taskScopeValues from "./task-scope-values";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
task_id: number | string;
|
||||||
|
/**
|
||||||
|
* The collection it moves to: a bot's own list, or a workspace's.
|
||||||
|
*/
|
||||||
|
bot_id?: number | string | null;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a task to another collection, with its notes and files. Refused
|
||||||
|
* when the destination already has an open task with that title, so a
|
||||||
|
* hand-over never leaves two of the same. The caller checked who is
|
||||||
|
* asking reaches both. Returns the row as it is now.
|
||||||
|
*/
|
||||||
|
export default async function moveTask({ task_id, bot_id, workspace_id }: Params): Promise<BUN_SQLITE_COBBLE_TASKS> {
|
||||||
|
const task = await grabTaskRow({ task_id });
|
||||||
|
const place = taskScopeValues({ bot_id, workspace_id });
|
||||||
|
|
||||||
|
if (Number(task.bot_id || 0) == Number(place.bot_id || 0) && Number(task.workspace_id || 0) == Number(place.workspace_id || 0)) {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
const there = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
|
||||||
|
sql: place.workspace_id
|
||||||
|
? `SELECT * FROM tasks WHERE workspace_id = ? AND id != ?`
|
||||||
|
: `SELECT * FROM tasks WHERE bot_id = ? AND id != ?`,
|
||||||
|
values: [Number(place.workspace_id || place.bot_id), Number(task.id)],
|
||||||
|
})
|
||||||
|
).payload || [];
|
||||||
|
|
||||||
|
const duplicate = findDuplicateTask({ tasks: there, title: task.title || "", ...place });
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
throw new AppError(`That list already has task #${duplicate.id} "${duplicate.title}" open. Hand that one over instead.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql({
|
||||||
|
sql: `UPDATE tasks SET bot_id = ?, workspace_id = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [place.bot_id, place.workspace_id, Date.now(), Number(task.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new AppError(`Couldn't move the task: ${res.msg || res.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await grabTaskRow({ task_id });
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import pushBotCreated from "@/src/bots-queue/push-bot-created";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import addBotToWorkspace from "./add-bot-to-workspace";
|
||||||
|
import grabUserWorkspaces from "./grab-user-workspaces";
|
||||||
|
import grabWorkspaceBots from "./grab-workspace-bots";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot: BUN_SQLITE_COBBLE_BOTS;
|
||||||
|
workspace_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bot can only work in a workspace it's in. Anyone else is added first
|
||||||
|
* (crew managers only, checked by `addBotToWorkspace`), since handing work
|
||||||
|
* over is exactly the moment someone decides who does the work where.
|
||||||
|
* Returns the sentence saying so, or "" when it was already there.
|
||||||
|
*/
|
||||||
|
export default async function ensureBotInWorkspace({ user_id, bot, workspace_id }: Params) {
|
||||||
|
if (!bot.id) return "";
|
||||||
|
|
||||||
|
const workspaces = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived);
|
||||||
|
const workspace = workspaces.find((w) => w.id == Number(workspace_id));
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new AppError(
|
||||||
|
`No workspace has id ${workspace_id}. Workspaces: ${
|
||||||
|
workspaces.map((w) => `${w.id}: ${w.name}`).join("; ") || "none"
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await grabWorkspaceBots({ user_id, workspace_id: Number(workspace.id) });
|
||||||
|
|
||||||
|
if (members.some((member) => member.id == bot.id)) return "";
|
||||||
|
|
||||||
|
await addBotToWorkspace({ user_id, workspace_id: Number(workspace.id), bot_id: Number(bot.id) });
|
||||||
|
await pushBotCreated({ bot });
|
||||||
|
|
||||||
|
return ` ${bot.name} wasn't in "${workspace.name}", so it was added to it first.`;
|
||||||
|
}
|
||||||
@@ -85,4 +85,27 @@ describe("delegateTaskTool", () => {
|
|||||||
tool.run({ args: { bot_id: 8, title: "Dig", instructions: "x" }, signal }),
|
tool.run({ args: { bot_id: 8, title: "Dig", instructions: "x" }, signal }),
|
||||||
).rejects.toThrow(/No crew member has id 8 in workspace 3. Crew: 5: Researcher/);
|
).rejects.toThrow(/No crew member has id 8 in workspace 3. Crew: 5: Researcher/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("takes the id of a task already on a list in place of a brief", () => {
|
||||||
|
const tool = coordinatorTool();
|
||||||
|
|
||||||
|
expect(tool.schema?.safeParse({ bot_id: 5, task_id: 12 }).success).toBe(true);
|
||||||
|
expect(tool.schema?.safeParse({ bot_id: 5, task_id: 12, instructions: "Start with the intro." }).success).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(tool.description).toContain("task_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without a task_id, still needs a title and instructions", async () => {
|
||||||
|
const tool = delegateTaskTool({
|
||||||
|
user_id: 1,
|
||||||
|
requester_id: 1,
|
||||||
|
session_id: 10,
|
||||||
|
requester_name: "Coordinator",
|
||||||
|
workers: [worker],
|
||||||
|
grabTargets: async () => [worker],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(tool.run({ args: { bot_id: 5 }, signal })).rejects.toThrow(/Give a title and instructions/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
import pushBotCreated from "@/src/bots-queue/push-bot-created";
|
|
||||||
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||||
import enqueueBotJob from "@/src/functions/backend/db/bots-queue/enqueue-bot-job";
|
import enqueueBotJob from "@/src/functions/backend/db/bots-queue/enqueue-bot-job";
|
||||||
import grabOpenBotTask from "@/src/functions/backend/db/bots-queue/grab-open-bot-task";
|
import grabOpenBotTask from "@/src/functions/backend/db/bots-queue/grab-open-bot-task";
|
||||||
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
|
import ensureBotInWorkspace from "@/src/functions/backend/db/workspaces/ensure-bot-in-workspace";
|
||||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
import grabRunTask from "@/src/functions/backend/db/tasks/grab-run-task";
|
||||||
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
import handOverTask from "@/src/functions/backend/db/tasks/hand-over-task";
|
||||||
import insertBotMessage from "@/src/functions/backend/db/bots/insert-bot-message";
|
import insertBotMessage from "@/src/functions/backend/db/bots/insert-bot-message";
|
||||||
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
@@ -48,12 +47,24 @@ type Params = {
|
|||||||
* nothing to pick: the task runs there.
|
* nothing to pick: the task runs there.
|
||||||
*/
|
*/
|
||||||
workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[];
|
workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[];
|
||||||
|
/**
|
||||||
|
* The requester's reach over the task tracker (`grabBotRunPowers`), so
|
||||||
|
* `task_id` only hands over a task its run reaches.
|
||||||
|
*/
|
||||||
|
is_coordinator?: boolean;
|
||||||
|
admin_workspace_ids?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
bot_id: number;
|
bot_id: number;
|
||||||
title: string;
|
/**
|
||||||
instructions: string;
|
* A task already on a list (`list_tasks`) to hand over instead of a
|
||||||
|
* new brief: it moves to the worker (or stays on its workspace's list)
|
||||||
|
* and the worker works that same task.
|
||||||
|
*/
|
||||||
|
task_id?: number;
|
||||||
|
title?: string;
|
||||||
|
instructions?: string;
|
||||||
/**
|
/**
|
||||||
* The workspace the worker runs the task in, from the coordinator's own
|
* The workspace the worker runs the task in, from the coordinator's own
|
||||||
* chat. Left out, the worker runs it in its own chat, on its own
|
* chat. Left out, the worker runs it in its own chat, on its own
|
||||||
@@ -69,7 +80,8 @@ type Args = {
|
|||||||
* The worker runs it on the computer it's on there (if it has one) and
|
* The worker runs it on the computer it's on there (if it has one) and
|
||||||
* posts the result in that conversation, where a note says who asked; the
|
* posts the result in that conversation, where a note says who asked; the
|
||||||
* result (or failure) is also reported back in the coordinator's
|
* result (or failure) is also reported back in the coordinator's
|
||||||
* conversation.
|
* conversation. Given a `task_id`, it hands over a task already on a list
|
||||||
|
* instead (`handOverTask`): the worker works that same task.
|
||||||
*/
|
*/
|
||||||
export default function delegateTaskTool({
|
export default function delegateTaskTool({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -80,6 +92,8 @@ export default function delegateTaskTool({
|
|||||||
workers,
|
workers,
|
||||||
workspaces = [],
|
workspaces = [],
|
||||||
grabTargets = ({ workspace_id }) => grabCrewWorkers({ workspace_id }),
|
grabTargets = ({ workspace_id }) => grabCrewWorkers({ workspace_id }),
|
||||||
|
is_coordinator = false,
|
||||||
|
admin_workspace_ids = [],
|
||||||
}: Params): HarnessTool<Args> {
|
}: Params): HarnessTool<Args> {
|
||||||
const crew = workers
|
const crew = workers
|
||||||
.map((bot) => `${bot.id}: ${bot.name}${bot.role ? ` (${bot.role})` : ""}`)
|
.map((bot) => `${bot.id}: ${bot.name}${bot.role ? ` (${bot.role})` : ""}`)
|
||||||
@@ -90,15 +104,16 @@ export default function delegateTaskTool({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: "delegate_task",
|
name: "delegate_task",
|
||||||
description: `Hand a task to a crew member. They run it in the background and post the result in their own conversation; it's also reported back here when they finish or fail. Write the instructions as a full brief: what to do, where, and how they'll know it's done. Crew: ${crew}.${
|
description: `Hand a task to a crew member. They run it in the background and post the result in their own conversation; it's also reported back here when they finish or fail. Write the instructions as a full brief: what to do, where, and how they'll know it's done. To hand over a task already on a list (list_tasks), pass its task_id instead of writing a new one: it moves to the crew member's list (a workspace's task stays on the workspace's list and runs there), goes to doing, and they work that same task; title and instructions are then optional, the task's brief is used and yours is added to it. Crew: ${crew}.${
|
||||||
workspace_id
|
workspace_id
|
||||||
? ` You are working in a workspace, so the task runs there: in this workspace's context.`
|
? ` You are working in a workspace, so the task runs there: in this workspace's context.`
|
||||||
: ` Leave workspace_id out to run it in the crew member's own chat: only its own memory, files and directly assigned computers apply there, and the user finds the result by talking to it on its own. Add workspace_id to run it in one of the user's workspaces instead: the workspace's shared context, the member's instructions and memory there, its files and its computers (the workspace's own and the member's) all apply, and the result lands in its conversation in that workspace, beside the project it belongs to. Pick a workspace when the work belongs to that project; the member is added to it when it isn't in it yet. Workspaces: ${places || "none"}.`
|
: ` Leave workspace_id out to run it in the crew member's own chat: only its own memory, files and directly assigned computers apply there, and the user finds the result by talking to it on its own. Add workspace_id to run it in one of the user's workspaces instead: the workspace's shared context, the member's instructions and memory there, its files and its computers (the workspace's own and the member's) all apply, and the result lands in its conversation in that workspace, beside the project it belongs to. Pick a workspace when the work belongs to that project; the member is added to it when it isn't in it yet. Workspaces: ${places || "none"}.`
|
||||||
}`,
|
}`,
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
bot_id: z.number().int(),
|
bot_id: z.number().int(),
|
||||||
title: z.string().min(1).max(80),
|
task_id: z.number().int().optional().describe("An open task (list_tasks) to hand over as it is"),
|
||||||
instructions: z.string().min(1).max(8000),
|
title: z.string().min(1).max(80).optional(),
|
||||||
|
instructions: z.string().min(1).max(8000).optional(),
|
||||||
workspace_id: z
|
workspace_id: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
@@ -120,8 +135,28 @@ export default function delegateTaskTool({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A task on a list is read first: one on a workspace's list is
|
||||||
|
* worked in that workspace, so the crew comes from there.
|
||||||
|
*/
|
||||||
|
const task = args.task_id
|
||||||
|
? await grabRunTask({ task_id: args.task_id, bot_id: requester_id, is_coordinator, admin_workspace_ids })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const asked_workspace_id = workspace_id || args.workspace_id;
|
||||||
|
|
||||||
|
if (task?.workspace_id && asked_workspace_id && Number(task.workspace_id) != Number(asked_workspace_id)) {
|
||||||
|
throw new Error(
|
||||||
|
`Task #${task.id} is on workspace ${task.workspace_id}'s list, so it's worked there. Leave workspace_id out${
|
||||||
|
workspace_id ? ", and hand it over from that workspace or your own chat" : ""
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const target_workspace_id = workspace_id
|
const target_workspace_id = workspace_id
|
||||||
? Number(workspace_id)
|
? Number(workspace_id)
|
||||||
|
: task?.workspace_id
|
||||||
|
? Number(task.workspace_id)
|
||||||
: args.workspace_id
|
: args.workspace_id
|
||||||
? Number(args.workspace_id)
|
? Number(args.workspace_id)
|
||||||
: null;
|
: null;
|
||||||
@@ -139,8 +174,32 @@ export default function delegateTaskTool({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
const handed = await handOverTask({
|
||||||
|
user_id,
|
||||||
|
task_id: Number(task.id),
|
||||||
|
to_bot_id: Number(worker.id),
|
||||||
|
workspace_id: target_workspace_id,
|
||||||
|
instructions: args.instructions,
|
||||||
|
handed_by: requester_name,
|
||||||
|
requested_by_bot_id: requester_id,
|
||||||
|
requested_by_session_id: session_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `Task #${task.id} "${task.title}" is with ${worker.name} now (job #${handed.job_id})${
|
||||||
|
handed.workspace_id ? ` in workspace ${handed.workspace_id}` : " in their own chat"
|
||||||
|
}, marked doing.${handed.joined} They work that same task; the result is reported back here.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.title?.trim() || !args.instructions?.trim()) {
|
||||||
|
throw new Error(`Give a title and instructions, or the task_id of a task to hand over.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = args.title;
|
||||||
|
const instructions = args.instructions;
|
||||||
|
|
||||||
const joined = target_workspace_id
|
const joined = target_workspace_id
|
||||||
? await ensureWorkerInWorkspace({ user_id, worker, target_workspace_id })
|
? await ensureBotInWorkspace({ user_id, bot: worker, workspace_id: target_workspace_id })
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const computers = await grabBotComputers({
|
const computers = await grabBotComputers({
|
||||||
@@ -155,12 +214,12 @@ export default function delegateTaskTool({
|
|||||||
const open = await grabOpenBotTask({
|
const open = await grabOpenBotTask({
|
||||||
bot_id: worker.id,
|
bot_id: worker.id,
|
||||||
session_id: Number(worker_session.id),
|
session_id: Number(worker_session.id),
|
||||||
title: args.title,
|
title,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${worker.name} already has task #${open.id} "${args.title}" ${open.status == "pending" ? "queued" : "under way"}. Don't hand it over again; it's reported back here when they finish.`,
|
`${worker.name} already has task #${open.id} "${title}" ${open.status == "pending" ? "queued" : "under way"}. Don't hand it over again; it's reported back here when they finish.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +227,8 @@ export default function delegateTaskTool({
|
|||||||
user_id,
|
user_id,
|
||||||
bot_id: worker.id,
|
bot_id: worker.id,
|
||||||
session_id: Number(worker_session.id),
|
session_id: Number(worker_session.id),
|
||||||
title: args.title,
|
title,
|
||||||
instructions: args.instructions,
|
instructions,
|
||||||
computer_id: computers[0]?.id || undefined,
|
computer_id: computers[0]?.id || undefined,
|
||||||
requested_by_bot_id: requester_id,
|
requested_by_bot_id: requester_id,
|
||||||
requested_by_session_id: session_id,
|
requested_by_session_id: session_id,
|
||||||
@@ -181,7 +240,7 @@ export default function delegateTaskTool({
|
|||||||
bot_id: worker.id,
|
bot_id: worker.id,
|
||||||
session_id: Number(worker_session.id),
|
session_id: Number(worker_session.id),
|
||||||
workspace_id: target_workspace_id,
|
workspace_id: target_workspace_id,
|
||||||
text: `${requester_name} asked me to: **${args.title}**`,
|
text: `${requester_name} asked me to: **${title}**`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,47 +250,3 @@ export default function delegateTaskTool({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnsureParams = {
|
|
||||||
user_id: number | string;
|
|
||||||
worker: BUN_SQLITE_COBBLE_BOTS;
|
|
||||||
target_workspace_id: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A worker can only run a task in a workspace it's in, and a workspace's
|
|
||||||
* members were already listed by `grabCrewWorkers`. Anyone else is added
|
|
||||||
* first, since delegation is exactly the moment the broker decides who
|
|
||||||
* does the work where.
|
|
||||||
*/
|
|
||||||
async function ensureWorkerInWorkspace({
|
|
||||||
user_id,
|
|
||||||
worker,
|
|
||||||
target_workspace_id,
|
|
||||||
}: EnsureParams) {
|
|
||||||
if (!worker.id) return "";
|
|
||||||
|
|
||||||
const workspaces = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived);
|
|
||||||
const workspace = workspaces.find((w) => w.id == target_workspace_id);
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
throw new Error(
|
|
||||||
`No workspace has id ${target_workspace_id}. Workspaces: ${
|
|
||||||
workspaces.map((w) => `${w.id}: ${w.name}`).join("; ") || "none"
|
|
||||||
}. Hand the task over without workspace_id to run it in the crew member's own chat.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const members = await grabWorkspaceBots({ user_id, workspace_id: target_workspace_id });
|
|
||||||
|
|
||||||
if (members.some((bot) => bot.id == worker.id)) return "";
|
|
||||||
|
|
||||||
await addBotToWorkspace({
|
|
||||||
user_id,
|
|
||||||
workspace_id: target_workspace_id,
|
|
||||||
bot_id: Number(worker.id),
|
|
||||||
});
|
|
||||||
await pushBotCreated({ bot: worker });
|
|
||||||
|
|
||||||
return ` ${worker.name} wasn't in "${workspace.name}", so it was added to it first.`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import assignComputerTool from "./assign-computer-tool";
|
|||||||
import createBotTool from "./create-bot-tool";
|
import createBotTool from "./create-bot-tool";
|
||||||
import createWorkspaceTool from "./create-workspace-tool";
|
import createWorkspaceTool from "./create-workspace-tool";
|
||||||
import delegateTaskTool from "./delegate-task-tool";
|
import delegateTaskTool from "./delegate-task-tool";
|
||||||
|
import handOverScheduleTool from "./hand-over-schedule-tool";
|
||||||
import deleteBotTool from "./delete-bot-tool";
|
import deleteBotTool from "./delete-bot-tool";
|
||||||
import deleteWorkspaceTool from "./delete-workspace-tool";
|
import deleteWorkspaceTool from "./delete-workspace-tool";
|
||||||
import findBotTool from "./find-bot-tool";
|
import findBotTool from "./find-bot-tool";
|
||||||
@@ -81,7 +82,7 @@ type Params = {
|
|||||||
* update_workspace (a workspace and its shared context), create_bot to
|
* update_workspace (a workspace and its shared context), create_bot to
|
||||||
* grow the crew, delegate_task to every worker (the workspace's in a
|
* grow the crew, delegate_task to every worker (the workspace's in a
|
||||||
* workspace, where the task runs; from its own chat, in the worker's own
|
* workspace, where the task runs; from its own chat, in the worker's own
|
||||||
* chat or in a workspace it names) plus, in its own chat, find_bot and
|
* chat or in a workspace it names) and hand_over_schedule beside it, plus, in its own chat, find_bot and
|
||||||
* find_workspace,
|
* find_workspace,
|
||||||
* add_bot_to_workspace and remove_bot_from_workspace, assign_computer,
|
* add_bot_to_workspace and remove_bot_from_workspace, assign_computer,
|
||||||
* update_bot, delete_bot and delete_workspace (which workspace, which
|
* update_bot, delete_bot and delete_workspace (which workspace, which
|
||||||
@@ -172,6 +173,14 @@ export default async function grabBotChatTools({
|
|||||||
workspace_id,
|
workspace_id,
|
||||||
requester_name: bot.name || "The coordinator",
|
requester_name: bot.name || "The coordinator",
|
||||||
workers,
|
workers,
|
||||||
|
is_coordinator: true,
|
||||||
|
}));
|
||||||
|
tools.push(handOverScheduleTool({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
requester_name: bot.name || "The coordinator",
|
||||||
|
workers,
|
||||||
}));
|
}));
|
||||||
tools.push(setWorkspaceInstructionsTool({ user_id, workspace_id, workers }));
|
tools.push(setWorkspaceInstructionsTool({ user_id, workspace_id, workers }));
|
||||||
|
|
||||||
@@ -203,6 +212,15 @@ export default async function grabBotChatTools({
|
|||||||
requester_name: bot.name || "The coordinator",
|
requester_name: bot.name || "The coordinator",
|
||||||
workers,
|
workers,
|
||||||
workspaces,
|
workspaces,
|
||||||
|
is_coordinator: true,
|
||||||
|
}),
|
||||||
|
handOverScheduleTool({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
requester_name: bot.name || "The coordinator",
|
||||||
|
workers,
|
||||||
|
workspaces,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-w
|
|||||||
import type { StorageScope } from "@/src/utils/storage-access";
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
import type { HarnessTool } from "../../types";
|
import type { HarnessTool } from "../../types";
|
||||||
import delegateTaskTool from "./delegate-task-tool";
|
import delegateTaskTool from "./delegate-task-tool";
|
||||||
|
import handOverScheduleTool from "./hand-over-schedule-tool";
|
||||||
import setWorkspaceInstructionsTool from "./set-workspace-instructions-tool";
|
import setWorkspaceInstructionsTool from "./set-workspace-instructions-tool";
|
||||||
import updateWorkspaceTool from "./update-workspace-tool";
|
import updateWorkspaceTool from "./update-workspace-tool";
|
||||||
|
|
||||||
@@ -33,8 +34,8 @@ export type WorkspaceAdminTools = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The tools a workspace admin leads its workspace with, each only while
|
* The tools a workspace admin leads its workspace with, each only while
|
||||||
* its types allow it there (`BotRunPowers.can`): delegate_task and
|
* its types allow it there (`BotRunPowers.can`): delegate_task (with
|
||||||
* set_bot_workspace_instructions for the members it may direct
|
* hand_over_schedule beside it) and set_bot_workspace_instructions for the members it may direct
|
||||||
* (`grabDirectableBots`), and update_workspace for this workspace alone,
|
* (`grabDirectableBots`), and update_workspace for this workspace alone,
|
||||||
* only when the user the run works for may change it too (a crew manager).
|
* only when the user the run works for may change it too (a crew manager).
|
||||||
*/
|
*/
|
||||||
@@ -67,6 +68,15 @@ export default async function grabWorkspaceAdminTools({
|
|||||||
requester_name: bot.name || "The workspace's admin",
|
requester_name: bot.name || "The workspace's admin",
|
||||||
workers: members,
|
workers: members,
|
||||||
grabTargets,
|
grabTargets,
|
||||||
|
admin_workspace_ids: powers.admin_workspace_ids,
|
||||||
|
}),
|
||||||
|
handOverScheduleTool({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
requester_name: bot.name || "The workspace's admin",
|
||||||
|
workers: members,
|
||||||
|
grabTargets,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
|
import handOverScheduleTool from "./hand-over-schedule-tool";
|
||||||
|
|
||||||
|
const worker = { id: 5, name: "Researcher", kind: "worker" } as BUN_SQLITE_COBBLE_BOTS;
|
||||||
|
const workspace = { id: 3, name: "Launch", archived: 0 } as BUN_SQLITE_COBBLE_WORKSPACES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What needs no database: the arguments it takes and what it tells the
|
||||||
|
* bot about where a handed-over schedule runs.
|
||||||
|
*/
|
||||||
|
describe("handOverScheduleTool", () => {
|
||||||
|
test("takes a schedule, a crew member and, from its own chat, a workspace", () => {
|
||||||
|
const tool = handOverScheduleTool({
|
||||||
|
user_id: 1,
|
||||||
|
bot_id: 1,
|
||||||
|
requester_name: "Coordinator",
|
||||||
|
workers: [worker],
|
||||||
|
workspaces: [workspace],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tool.name).toBe("hand_over_schedule");
|
||||||
|
expect(tool.schema?.safeParse({ schedule_id: 4, bot_id: 5 }).success).toBe(true);
|
||||||
|
expect(tool.schema?.safeParse({ schedule_id: 4, bot_id: 5, workspace_id: 3 }).success).toBe(true);
|
||||||
|
expect(tool.description).toContain("5: Researcher");
|
||||||
|
expect(tool.description).toContain("3: Launch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("in a workspace, says only the schedules that run there go", () => {
|
||||||
|
const tool = handOverScheduleTool({
|
||||||
|
user_id: 1,
|
||||||
|
bot_id: 7,
|
||||||
|
workspace_id: 3,
|
||||||
|
requester_name: "Lead",
|
||||||
|
workers: [worker],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tool.description).toContain("only the schedules that run here");
|
||||||
|
expect(tool.description).not.toContain("Workspaces:");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
|
import handOverCronJob from "@/src/functions/backend/db/cron-jobs/hand-over-cron-job";
|
||||||
|
import grabUserCronJob from "@/src/functions/backend/db/cron-jobs/grab-user-cron-job";
|
||||||
|
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||||
|
import describeCronJob from "@/src/utils/describe-cron-job";
|
||||||
|
import handOverPlace from "@/src/utils/hand-over-place";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
/**
|
||||||
|
* The bot handing its routine over (the coordinator, or a workspace's
|
||||||
|
* admin).
|
||||||
|
*/
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the run is in: only routines that run there can be
|
||||||
|
* handed over, and they stay there.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
requester_name: string;
|
||||||
|
/**
|
||||||
|
* The bots it can hand routines to (in a workspace, its members).
|
||||||
|
*/
|
||||||
|
workers: BUN_SQLITE_COBBLE_BOTS[];
|
||||||
|
/**
|
||||||
|
* Reads who it may hand to when it does, as `delegate_task` does.
|
||||||
|
*/
|
||||||
|
grabTargets?: (params: { workspace_id: number | null }) => Promise<BUN_SQLITE_COBBLE_BOTS[]>;
|
||||||
|
/**
|
||||||
|
* The user's live workspaces, for a routine of the bot's own chat.
|
||||||
|
*/
|
||||||
|
workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
schedule_id: number;
|
||||||
|
bot_id: number;
|
||||||
|
workspace_id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the coordinator or a workspace admin give one of its own routines
|
||||||
|
* to a crew member (`handOverCronJob`): a routine in a workspace keeps
|
||||||
|
* running there, one in its own chat moves to the member's own chat or a
|
||||||
|
* workspace it names.
|
||||||
|
*/
|
||||||
|
export default function handOverScheduleTool({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
requester_name,
|
||||||
|
workers,
|
||||||
|
workspaces = [],
|
||||||
|
grabTargets = ({ workspace_id }) => grabCrewWorkers({ workspace_id }),
|
||||||
|
}: Params): HarnessTool<Args> {
|
||||||
|
const crew = workers.map((bot) => `${bot.id}: ${bot.name}`).join("; ");
|
||||||
|
const places = workspaces.map((w) => `${w.id}: ${w.name}`).join("; ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "hand_over_schedule",
|
||||||
|
description: `Give one of your own schedules (routines, list_schedules) to a crew member: from then on it runs as theirs, on the same schedule, and its results land in their conversation. Use it instead of deleting a schedule and asking them to make a new one. A schedule that runs in a workspace keeps running there.${
|
||||||
|
workspace_id
|
||||||
|
? ` You are in a workspace, so only the schedules that run here can be handed over.`
|
||||||
|
: ` For one that runs in your own chat, leave workspace_id out to move it to the crew member's own chat, or add it to run it in one of the user's workspaces (the member is added to it when it isn't in it yet). Workspaces: ${places || "none"}.`
|
||||||
|
} Crew: ${crew}.`,
|
||||||
|
schema: z.object({
|
||||||
|
schedule_id: z.number().int(),
|
||||||
|
bot_id: z.number().int(),
|
||||||
|
workspace_id: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.describe("For a schedule of your own chat: the workspace it runs in from now on"),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const cron_job = await grabUserCronJob({ user_id, cron_job_id: args.schedule_id, bot_id });
|
||||||
|
|
||||||
|
if (workspace_id && Number(cron_job.workspace_id || 0) != Number(workspace_id)) {
|
||||||
|
throw new Error(`Schedule #${cron_job.id} doesn't run in this workspace. Only the ones that run here can be handed over from here.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target_workspace_id = handOverPlace({
|
||||||
|
workspace_id: cron_job.workspace_id,
|
||||||
|
asked_workspace_id: workspace_id || args.workspace_id,
|
||||||
|
label: `Schedule #${cron_job.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fresh = await grabTargets({ workspace_id: workspace_id ? Number(workspace_id) : target_workspace_id });
|
||||||
|
const worker = fresh.find((bot) => bot.id == args.bot_id);
|
||||||
|
|
||||||
|
if (!worker?.id) {
|
||||||
|
throw new Error(
|
||||||
|
`No crew member has id ${args.bot_id}. Crew: ${fresh.map((bot) => `${bot.id}: ${bot.name}`).join("; ") || "none"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handed = await handOverCronJob({
|
||||||
|
user_id,
|
||||||
|
cron_job_id: Number(cron_job.id),
|
||||||
|
from_bot_id: bot_id,
|
||||||
|
to_bot_id: Number(worker.id),
|
||||||
|
workspace_id: args.workspace_id,
|
||||||
|
handed_by: requester_name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `Handed over to ${worker.name}: ${describeCronJob({ cron_job: handed.cron_job })}.${handed.joined}${
|
||||||
|
handed.computer_dropped ? ` Its computer isn't one ${worker.name} reaches there, so it runs on their default one.` : ""
|
||||||
|
}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import Button from "@moduletrace/twui/layout/Button";
|
|||||||
import { Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
import type { BotWithLastMessage, CronJobWithNames } from "@/src/types";
|
import type { BotWithLastMessage, CronJobWithNames } from "@/src/types";
|
||||||
|
import HandOverForm from "@/src/components/general/hand-over-form";
|
||||||
import RoutineForm from "@/src/components/general/routine-form";
|
import RoutineForm from "@/src/components/general/routine-form";
|
||||||
import RoutineRow from "@/src/components/general/routine-row";
|
import RoutineRow from "@/src/components/general/routine-row";
|
||||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
@@ -33,12 +34,13 @@ type Props = {
|
|||||||
* The routines that run in this conversation: the schedules whose result
|
* 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
|
* 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
|
* 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
|
* page's props stay small. The user pauses, resumes, edits, hands over
|
||||||
* their own; the bot makes them when asked in chat.
|
* and deletes their own; the bot makes them when asked in chat.
|
||||||
*/
|
*/
|
||||||
export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Props) {
|
export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Props) {
|
||||||
const [open, set_open] = useState(false);
|
const [open, set_open] = useState(false);
|
||||||
const [editing, set_editing] = useState<CronJobWithNames>();
|
const [editing, set_editing] = useState<CronJobWithNames>();
|
||||||
|
const [handing, set_handing] = useState<CronJobWithNames>();
|
||||||
const [busy, set_busy] = useState(false);
|
const [busy, set_busy] = useState(false);
|
||||||
const [status, set_status] = useState<Status>();
|
const [status, set_status] = useState<Status>();
|
||||||
const { routines, setRoutines, loading, error } = useBotRoutines({
|
const { routines, setRoutines, loading, error } = useBotRoutines({
|
||||||
@@ -124,6 +126,7 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
|||||||
|
|
||||||
if (!next) {
|
if (!next) {
|
||||||
set_editing(undefined);
|
set_editing(undefined);
|
||||||
|
set_handing(undefined);
|
||||||
set_status(undefined);
|
set_status(undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -132,9 +135,9 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
|||||||
<Stack className="w-full gap-1! items-stretch">
|
<Stack className="w-full gap-1! items-stretch">
|
||||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||||
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
|
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||||
{editing ? editing.title || "Routine" : title}
|
{editing ? editing.title || "Routine" : handing ? `Hand over ${handing.title || "the routine"}` : title}
|
||||||
</H2>
|
</H2>
|
||||||
{editing ? (
|
{editing || handing ? (
|
||||||
<Button
|
<Button
|
||||||
title="Back to the list"
|
title="Back to the list"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -143,6 +146,7 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
|||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
set_editing(undefined);
|
set_editing(undefined);
|
||||||
|
set_handing(undefined);
|
||||||
set_status(undefined);
|
set_status(undefined);
|
||||||
}}
|
}}
|
||||||
className="px-2! text-[12px]"
|
className="px-2! text-[12px]"
|
||||||
@@ -154,6 +158,8 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
|||||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
{editing
|
{editing
|
||||||
? `${name} gets this brief as a task at every run, and the result lands here.`
|
? `${name} gets this brief as a task at every run, and the result lands here.`
|
||||||
|
: handing
|
||||||
|
? `It keeps its schedule and where it runs; from then on its results land in the new bot's conversation.`
|
||||||
: workspace?.id
|
: workspace?.id
|
||||||
? `Work ${name} does on a schedule in ${place}. Each run is a task, and its result lands here.`
|
? `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.`}
|
: `Work ${name} does on its own, on a schedule. Each run is a task, and its result lands here.`}
|
||||||
@@ -172,6 +178,20 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
|||||||
set_status({ success: true, msg: "Saved." });
|
set_status({ success: true, msg: "Saved." });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : handing ? (
|
||||||
|
<HandOverForm<CronJobWithNames>
|
||||||
|
key={handing.id}
|
||||||
|
what={`"${handing.title}"`}
|
||||||
|
targets_path="routines/hand-over-targets"
|
||||||
|
submit_path="routines/hand-over-routine"
|
||||||
|
body={{ cron_job_id: handing.id }}
|
||||||
|
onCancel={() => set_handing(undefined)}
|
||||||
|
onHandedOver={({ saved, msg }) => {
|
||||||
|
setRoutines((prev) => dropRoutine({ routines: prev, cron_job_id: saved.id }));
|
||||||
|
set_handing(undefined);
|
||||||
|
set_status({ success: true, msg });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SettingsList
|
<SettingsList
|
||||||
is_empty={!routines.length}
|
is_empty={!routines.length}
|
||||||
@@ -185,6 +205,7 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
onToggle={() => toggle({ cron_job }).catch(() => {})}
|
onToggle={() => toggle({ cron_job }).catch(() => {})}
|
||||||
onEdit={() => set_editing(cron_job)}
|
onEdit={() => set_editing(cron_job)}
|
||||||
|
onHandOver={() => set_handing(cron_job)}
|
||||||
onDelete={() => remove({ cron_job }).catch(() => {})}
|
onDelete={() => remove({ cron_job }).catch(() => {})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -194,7 +215,7 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
|||||||
|
|
||||||
<Stack className="mt-3 gap-1! items-stretch">
|
<Stack className="mt-3 gap-1! items-stretch">
|
||||||
<SettingsStatus status={status} />
|
<SettingsStatus status={status} />
|
||||||
{editing ? null : (
|
{editing || handing ? null : (
|
||||||
<Span className="text-[12px] leading-4 text-ink-secondary">
|
<Span className="text-[12px] leading-4 text-ink-secondary">
|
||||||
{`Ask ${name} in chat to set one up, or change one here.`}
|
{`Ask ${name} in chat to set one up, or change one here.`}
|
||||||
</Span>
|
</Span>
|
||||||
|
|||||||
@@ -181,9 +181,19 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
|||||||
task={open_task}
|
task={open_task}
|
||||||
can_manage={can_manage}
|
can_manage={can_manage}
|
||||||
onChanged={(saved, msg) => {
|
onChanged={(saved, msg) => {
|
||||||
|
set_status(msg);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handed to another bot, it's on that bot's list now.
|
||||||
|
*/
|
||||||
|
if (!workspace?.id && saved.bot_id != bot.id) {
|
||||||
|
setTasks((prev) => prev.filter((item) => item.id != saved.id));
|
||||||
|
set_open_task(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
set_open_task(saved);
|
set_open_task(saved);
|
||||||
replace({ task: saved });
|
replace({ task: saved });
|
||||||
set_status(msg);
|
|
||||||
}}
|
}}
|
||||||
onDeleted={() => {
|
onDeleted={() => {
|
||||||
setTasks((prev) => prev.filter((item) => item.id != open_task.id));
|
setTasks((prev) => prev.filter((item) => item.id != open_task.id));
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group"
|
|||||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||||
|
import HandOverForm from "@/src/components/general/hand-over-form";
|
||||||
import RoutineEditModal from "@/src/components/general/routine-edit-modal";
|
import RoutineEditModal from "@/src/components/general/routine-edit-modal";
|
||||||
import RoutineRow from "@/src/components/general/routine-row";
|
import RoutineRow from "@/src/components/general/routine-row";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every routine the user's bots have: chat with the bot, pause or resume
|
* Every routine the user's bots have: chat with the bot, pause or resume
|
||||||
* one, edit it, delete it. Bots create them when asked in chat.
|
* one, edit it, hand it to another bot, delete it. Bots create them when asked in chat.
|
||||||
*/
|
*/
|
||||||
export default function RoutinesSection() {
|
export default function RoutinesSection() {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
@@ -19,6 +21,7 @@ export default function RoutinesSection() {
|
|||||||
items: pageProps?.cron_jobs || [],
|
items: pageProps?.cron_jobs || [],
|
||||||
});
|
});
|
||||||
const [editing, setEditing] = useState<CronJobWithNames>();
|
const [editing, setEditing] = useState<CronJobWithNames>();
|
||||||
|
const [handing, setHanding] = useState<CronJobWithNames>();
|
||||||
|
|
||||||
const replace = ({ cron_job }: { cron_job: CronJobWithNames }) =>
|
const replace = ({ cron_job }: { cron_job: CronJobWithNames }) =>
|
||||||
setItems((prev) => prev.map((item) => (item.id == cron_job.id ? cron_job : item)));
|
setItems((prev) => prev.map((item) => (item.id == cron_job.id ? cron_job : item)));
|
||||||
@@ -53,6 +56,7 @@ export default function RoutinesSection() {
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
onToggle={() => toggle({ cron_job })}
|
onToggle={() => toggle({ cron_job })}
|
||||||
onEdit={() => setEditing(cron_job)}
|
onEdit={() => setEditing(cron_job)}
|
||||||
|
onHandOver={() => setHanding(cron_job)}
|
||||||
onDelete={() =>
|
onDelete={() =>
|
||||||
remove({
|
remove({
|
||||||
id: cron_job.id,
|
id: cron_job.id,
|
||||||
@@ -74,6 +78,28 @@ export default function RoutinesSection() {
|
|||||||
setStatus({ success: true, msg: "Saved." });
|
setStatus({ success: true, msg: "Saved." });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<AddModalShell
|
||||||
|
open={Boolean(handing)}
|
||||||
|
setOpen={(open) => !open && setHanding(undefined)}
|
||||||
|
title={`Hand over ${handing?.title || "the routine"}`}
|
||||||
|
description="It keeps its schedule and where it runs; its results land in the new bot's conversation."
|
||||||
|
>
|
||||||
|
{handing ? (
|
||||||
|
<HandOverForm<CronJobWithNames>
|
||||||
|
key={handing.id}
|
||||||
|
what={`"${handing.title}"`}
|
||||||
|
targets_path="routines/hand-over-targets"
|
||||||
|
submit_path="routines/hand-over-routine"
|
||||||
|
body={{ cron_job_id: handing.id }}
|
||||||
|
onCancel={() => setHanding(undefined)}
|
||||||
|
onHandedOver={({ saved, msg }) => {
|
||||||
|
replace({ cron_job: saved });
|
||||||
|
setHanding(undefined);
|
||||||
|
setStatus({ success: true, msg });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AddModalShell>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import handOverCronJob from "@/src/functions/backend/db/cron-jobs/hand-over-cron-job";
|
||||||
|
import { HandOverCronJobRequestSchema } from "@/src/schemas/cron-job-schema";
|
||||||
|
import type { CronJobWithNames } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands one of the session user's routines to another of their bots where
|
||||||
|
* it runs (`handOverCronJob`). Returns the routine.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<CronJobWithNames>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cron_job_id, bot_id } = parseWithSchema({ schema: HandOverCronJobRequestSchema, data: body });
|
||||||
|
|
||||||
|
const handed = await handOverCronJob({
|
||||||
|
user_id: user.id,
|
||||||
|
cron_job_id,
|
||||||
|
to_bot_id: bot_id,
|
||||||
|
handed_by: user.first_name || user.username || "The user",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
singleRes: handed.cron_job,
|
||||||
|
msg: `${handed.bot_name} runs it now.${
|
||||||
|
handed.computer_dropped ? ` Its computer isn't one ${handed.bot_name} reaches, so it uses their default.` : ""
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabHandOverTargets from "@/src/functions/backend/db/bots/grab-hand-over-targets";
|
||||||
|
import grabUserCronJob from "@/src/functions/backend/db/cron-jobs/grab-user-cron-job";
|
||||||
|
import { CronJobIdRequestSchema } from "@/src/schemas/cron-job-schema";
|
||||||
|
import type { HandOverTarget } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who one of the user's routines can be handed to: the other bots where
|
||||||
|
* it runs. Loaded when the picker opens.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<HandOverTarget>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cron_job_id } = parseWithSchema({ schema: CronJobIdRequestSchema, data: body });
|
||||||
|
const cron_job = await grabUserCronJob({ user_id: user.id, cron_job_id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
payload: await grabHandOverTargets({
|
||||||
|
user_id: user.id,
|
||||||
|
workspace_id: cron_job.workspace_id || null,
|
||||||
|
except_bot_id: cron_job.bot_id || null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabHandOverTargets from "@/src/functions/backend/db/bots/grab-hand-over-targets";
|
||||||
|
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
|
||||||
|
import { TaskIdRequestSchema } from "@/src/schemas/task-schema";
|
||||||
|
import type { HandOverTarget } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who a task can be handed to: the bots in its workspace for a workspace's
|
||||||
|
* task, else the bots the user chats with. Loaded when the picker opens.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<HandOverTarget>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { task_id } = parseWithSchema({ schema: TaskIdRequestSchema, data: body });
|
||||||
|
const task = await assertUserReachesTask({ user_id: user.id, task_id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
payload: await grabHandOverTargets({
|
||||||
|
user_id: user.id,
|
||||||
|
workspace_id: task.workspace_id || null,
|
||||||
|
except_bot_id: task.bot_id || null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import wakeBotsQueue from "@/src/bots-queue/wake-bots-queue";
|
||||||
|
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
|
||||||
|
import grabTask from "@/src/functions/backend/db/tasks/grab-task";
|
||||||
|
import handOverTask from "@/src/functions/backend/db/tasks/hand-over-task";
|
||||||
|
import { HandOverTaskRequestSchema } from "@/src/schemas/task-schema";
|
||||||
|
import type { TaskDetail } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands an open task to another bot and starts the work (`handOverTask`):
|
||||||
|
* the bot works that same task, and its result lands in its conversation.
|
||||||
|
* Crew managers only, like every other change to a task by hand. Returns
|
||||||
|
* the task as it is now.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canManageCrew({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`Only an admin can hand a task over.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { task_id, bot_id, instructions } = parseWithSchema({ schema: HandOverTaskRequestSchema, data: body });
|
||||||
|
|
||||||
|
await assertUserReachesTask({ user_id: user.id, task_id });
|
||||||
|
|
||||||
|
const handed = await handOverTask({
|
||||||
|
user_id: user.id,
|
||||||
|
task_id,
|
||||||
|
to_bot_id: bot_id,
|
||||||
|
instructions,
|
||||||
|
handed_by: user.first_name || user.username || "The user",
|
||||||
|
});
|
||||||
|
|
||||||
|
wakeBotsQueue();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
singleRes: await grabTask({ user_id: user.id, task_id }),
|
||||||
|
msg: `${handed.bot_name} has it now and is starting on it.`,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -17,3 +17,10 @@ export const CronJobIdRequestSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type CronJobIdRequest = z.infer<typeof CronJobIdRequestSchema>;
|
export type CronJobIdRequest = z.infer<typeof CronJobIdRequestSchema>;
|
||||||
|
|
||||||
|
export const HandOverCronJobRequestSchema = z.object({
|
||||||
|
cron_job_id: CronJobIdField,
|
||||||
|
bot_id: z.coerce.number().int().positive("Pick a bot"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type HandOverCronJobRequest = z.infer<typeof HandOverCronJobRequestSchema>;
|
||||||
|
|||||||
@@ -73,3 +73,13 @@ export type CreateTaskRequest = z.infer<typeof CreateTaskRequestSchema>;
|
|||||||
export type UpdateTaskRequest = z.infer<typeof UpdateTaskRequestSchema>;
|
export type UpdateTaskRequest = z.infer<typeof UpdateTaskRequestSchema>;
|
||||||
export type TaskNoteRequest = z.infer<typeof TaskNoteRequestSchema>;
|
export type TaskNoteRequest = z.infer<typeof TaskNoteRequestSchema>;
|
||||||
export type TaskFileRequest = z.infer<typeof TaskFileRequestSchema>;
|
export type TaskFileRequest = z.infer<typeof TaskFileRequestSchema>;
|
||||||
|
|
||||||
|
export const HandOverTaskRequestSchema = z.object({
|
||||||
|
task_id: z.number().int().positive(),
|
||||||
|
bot_id: z.number().int().positive("Pick a bot"),
|
||||||
|
instructions: z.string().max(AppData.TaskDescriptionMaxLength).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TaskIdRequestSchema = z.object({
|
||||||
|
task_id: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ export type BotWithLastMessage = BotWithFace & {
|
|||||||
* A bot's schedule with what the routines page shows beside it: the bot
|
* A bot's schedule with what the routines page shows beside it: the bot
|
||||||
* and the workspace it runs in.
|
* and the workspace it runs in.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* A bot a task or routine can be handed over to, for the picker.
|
||||||
|
*/
|
||||||
|
export type HandOverTarget = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CronJobWithNames = BUN_SQLITE_COBBLE_CRON_JOBS & {
|
export type CronJobWithNames = BUN_SQLITE_COBBLE_CRON_JOBS & {
|
||||||
bot_name?: string | null;
|
bot_name?: string | null;
|
||||||
bot_kind?: string | null;
|
bot_kind?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import handOverPlace from "./hand-over-place";
|
||||||
|
|
||||||
|
describe("handOverPlace", () => {
|
||||||
|
test("work in a workspace stays there", () => {
|
||||||
|
expect(handOverPlace({ workspace_id: 3, label: "Task #1" })).toBe(3);
|
||||||
|
expect(handOverPlace({ workspace_id: 3, asked_workspace_id: 3, label: "Task #1" })).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses to move work out of its workspace", () => {
|
||||||
|
expect(() => handOverPlace({ workspace_id: 3, asked_workspace_id: 4, label: "Task #1" })).toThrow(
|
||||||
|
/Task #1 belongs to workspace 3/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bot's own goes to the asked workspace, else the new bot's own chat", () => {
|
||||||
|
expect(handOverPlace({ workspace_id: null, asked_workspace_id: 5, label: "Routine #2" })).toBe(5);
|
||||||
|
expect(handOverPlace({ workspace_id: null, label: "Routine #2" })).toBeNull();
|
||||||
|
expect(handOverPlace({ workspace_id: "", asked_workspace_id: null, label: "Routine #2" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import AppError from "./app-error";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The workspace the task's list or the routine belongs to now; none
|
||||||
|
* for a bot's own.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* The workspace asked for, if any.
|
||||||
|
*/
|
||||||
|
asked_workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* What is handed over, for the refusal ("Task #4").
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where handed-over work runs from now: a task on a workspace's list or a
|
||||||
|
* routine in a workspace stays in that workspace, and asking for another
|
||||||
|
* is refused; a bot's own goes to the asked workspace, or the new bot's
|
||||||
|
* own chat (null).
|
||||||
|
*/
|
||||||
|
export default function handOverPlace({ workspace_id, asked_workspace_id, label }: Params): number | null {
|
||||||
|
if (workspace_id) {
|
||||||
|
if (asked_workspace_id && Number(asked_workspace_id) != Number(workspace_id)) {
|
||||||
|
throw new AppError(`${label} belongs to workspace ${workspace_id}, and stays there. Leave workspace_id out.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return asked_workspace_id ? Number(asked_workspace_id) : null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user