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:
@@ -8,6 +8,7 @@ import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Clock } from "lucide-react";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, CronJobWithNames } from "@/src/types";
|
||||
import HandOverForm from "@/src/components/general/hand-over-form";
|
||||
import RoutineForm from "@/src/components/general/routine-form";
|
||||
import RoutineRow from "@/src/components/general/routine-row";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
@@ -33,12 +34,13 @@ type Props = {
|
||||
* The routines that run in this conversation: the schedules whose result
|
||||
* lands here, a bot's own on its chat or the ones it runs in this
|
||||
* workspace. Open it from the header menu; the list loads then, so the
|
||||
* page's props stay small. The user pauses, resumes, edits and deletes
|
||||
* their own; the bot makes them when asked in chat.
|
||||
* page's props stay small. The user pauses, resumes, edits, hands over
|
||||
* and deletes their own; the bot makes them when asked in chat.
|
||||
*/
|
||||
export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Props) {
|
||||
const [open, set_open] = useState(false);
|
||||
const [editing, set_editing] = useState<CronJobWithNames>();
|
||||
const [handing, set_handing] = useState<CronJobWithNames>();
|
||||
const [busy, set_busy] = useState(false);
|
||||
const [status, set_status] = useState<Status>();
|
||||
const { routines, setRoutines, loading, error } = useBotRoutines({
|
||||
@@ -124,6 +126,7 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
||||
|
||||
if (!next) {
|
||||
set_editing(undefined);
|
||||
set_handing(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">
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||
{editing ? editing.title || "Routine" : title}
|
||||
{editing ? editing.title || "Routine" : handing ? `Hand over ${handing.title || "the routine"}` : title}
|
||||
</H2>
|
||||
{editing ? (
|
||||
{editing || handing ? (
|
||||
<Button
|
||||
title="Back to the list"
|
||||
type="button"
|
||||
@@ -143,6 +146,7 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
||||
size="small"
|
||||
onClick={() => {
|
||||
set_editing(undefined);
|
||||
set_handing(undefined);
|
||||
set_status(undefined);
|
||||
}}
|
||||
className="px-2! text-[12px]"
|
||||
@@ -154,7 +158,9 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{editing
|
||||
? `${name} gets this brief as a task at every run, and the result lands here.`
|
||||
: workspace?.id
|
||||
: handing
|
||||
? `It keeps its schedule and where it runs; from then on its results land in the new bot's conversation.`
|
||||
: workspace?.id
|
||||
? `Work ${name} does on a schedule in ${place}. Each run is a task, and its result lands here.`
|
||||
: `Work ${name} does on its own, on a schedule. Each run is a task, and its result lands here.`}
|
||||
</Span>
|
||||
@@ -172,6 +178,20 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
||||
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
|
||||
is_empty={!routines.length}
|
||||
@@ -185,6 +205,7 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
|
||||
busy={busy}
|
||||
onToggle={() => toggle({ cron_job }).catch(() => {})}
|
||||
onEdit={() => set_editing(cron_job)}
|
||||
onHandOver={() => set_handing(cron_job)}
|
||||
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">
|
||||
<SettingsStatus status={status} />
|
||||
{editing ? null : (
|
||||
{editing || handing ? null : (
|
||||
<Span className="text-[12px] leading-4 text-ink-secondary">
|
||||
{`Ask ${name} in chat to set one up, or change one here.`}
|
||||
</Span>
|
||||
|
||||
@@ -181,9 +181,19 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
||||
task={open_task}
|
||||
can_manage={can_manage}
|
||||
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);
|
||||
replace({ task: saved });
|
||||
set_status(msg);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
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 SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
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 RoutineRow from "@/src/components/general/routine-row";
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
@@ -19,6 +21,7 @@ export default function RoutinesSection() {
|
||||
items: pageProps?.cron_jobs || [],
|
||||
});
|
||||
const [editing, setEditing] = useState<CronJobWithNames>();
|
||||
const [handing, setHanding] = useState<CronJobWithNames>();
|
||||
|
||||
const replace = ({ cron_job }: { cron_job: CronJobWithNames }) =>
|
||||
setItems((prev) => prev.map((item) => (item.id == cron_job.id ? cron_job : item)));
|
||||
@@ -53,6 +56,7 @@ export default function RoutinesSection() {
|
||||
busy={busy}
|
||||
onToggle={() => toggle({ cron_job })}
|
||||
onEdit={() => setEditing(cron_job)}
|
||||
onHandOver={() => setHanding(cron_job)}
|
||||
onDelete={() =>
|
||||
remove({
|
||||
id: cron_job.id,
|
||||
@@ -74,6 +78,28 @@ export default function RoutinesSection() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user