Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
596a417229 | ||
|
|
623112fd19 | ||
|
|
3e78d51b56 |
@@ -153,7 +153,7 @@ A secret URL, `POST /api/hooks/<token>` (`src/pages/api/hooks/[token].ts`, publi
|
||||
|
||||
### Tasks (the to-do tracker)
|
||||
|
||||
`tasks` (a bot's own list is `bot_id` alone; a workspace's task has `workspace_id` plus the `bot_id` that has it there, null while shared; a bot reaches its own list and its own plus shared tasks in its workspaces, a workspace admin all of that workspace's, `botTaskScopesSql`; a shared task becomes the bot's when it starts it; removing or deleting a bot makes its workspace tasks shared again), `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/`); a card's title opens `TaskDetailModal` with everything about the task and its status moves (a modal over the list, never swapped in place). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, 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.
|
||||
`tasks` (**every task belongs to a bot**, the coordinator included, never shared: a bot's own list is `bot_id` alone, a workspace's task has `workspace_id` plus the `bot_id` that has it there, enforced by `taskScopeValues`; a worker reaches only its own, a workspace admin all of that workspace's, the coordinator all, `botTaskScopesSql`; a bot removed from a workspace or deleted passes its tasks there to the workspace's admin bot, else the coordinator (`passOnBotTasks`, also run at boot for rows without a bot)), `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/`); a card's title opens `TaskDetailModal` with everything about the task and its status moves (a modal over the list, never swapped in place). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, 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
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cobble",
|
||||
"version": "0.7.4",
|
||||
"version": "0.7.5",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
||||
<Span className="min-w-0 truncate">{task.bot_name || `Bot ${task.bot_id}`}</Span>
|
||||
</>
|
||||
) : (
|
||||
<Span className="text-ink-secondary">Shared, nobody has taken it up yet</Span>
|
||||
<Span className="text-ink-secondary">No bot</Span>
|
||||
)}
|
||||
</TaskFactRow>
|
||||
<TaskFactRow label="Where">
|
||||
|
||||
@@ -18,6 +18,11 @@ type Props = {
|
||||
*/
|
||||
bot_id?: number | null;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* Adding to a workspace without a `bot_id`: the bots there to pick who
|
||||
* has it, since every task belongs to a bot.
|
||||
*/
|
||||
bots?: { id?: number | string | null; name?: string | null }[];
|
||||
/**
|
||||
* Edits this task; adds one when not given.
|
||||
*/
|
||||
@@ -34,6 +39,7 @@ type Props = {
|
||||
export default function TaskForm({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
bots = [],
|
||||
task,
|
||||
onSaved,
|
||||
onCancel,
|
||||
@@ -46,6 +52,10 @@ export default function TaskForm({
|
||||
const [blocked_reason, setBlockedReason] = useState(
|
||||
task?.blocked_reason || "",
|
||||
);
|
||||
const pick_bot = !task && !bot_id && Boolean(workspace_id);
|
||||
const [picked_bot_id, setPickedBotId] = useState<number | undefined>(
|
||||
bots[0]?.id ? Number(bots[0].id) : undefined,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
@@ -57,6 +67,11 @@ export default function TaskForm({
|
||||
return;
|
||||
}
|
||||
|
||||
if (pick_bot && !picked_bot_id) {
|
||||
setError("Pick the bot that has it.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
@@ -67,7 +82,7 @@ export default function TaskForm({
|
||||
method: "POST",
|
||||
body: {
|
||||
task_id: task?.id ? Number(task.id) : undefined,
|
||||
bot_id: task ? undefined : bot_id || undefined,
|
||||
bot_id: task ? undefined : bot_id || picked_bot_id || undefined,
|
||||
workspace_id: task ? undefined : workspace_id || undefined,
|
||||
title,
|
||||
description,
|
||||
@@ -100,6 +115,19 @@ export default function TaskForm({
|
||||
maxLength={AppData.TaskTitleMaxLength}
|
||||
disabled={loading}
|
||||
/>
|
||||
{pick_bot ? (
|
||||
<Select
|
||||
title="Who has it"
|
||||
label="Who has it"
|
||||
showLabel
|
||||
options={bots.map((bot) => ({
|
||||
title: bot.name || `Bot ${bot.id}`,
|
||||
value: String(bot.id),
|
||||
default: Number(bot.id) == picked_bot_id,
|
||||
}))}
|
||||
changeHandler={(value) => setPickedBotId(Number(value) || undefined)}
|
||||
/>
|
||||
) : null}
|
||||
<CodeEditor
|
||||
mode="markdown"
|
||||
className="h-[500px]"
|
||||
|
||||
@@ -220,7 +220,7 @@ You are working through a task on your own; the user isn't watching. Use your to
|
||||
*/
|
||||
export const TasksNote = `# Tasks
|
||||
|
||||
Tasks are the crew's shared to-do list, one per bot and one per workspace. A workspace's tasks are seen by every bot working there, and the coordinator sees everything.
|
||||
Tasks are the crew's to-do list. Every task belongs to one bot, on its own list or in a workspace. You see your own; a workspace's admin sees every task in that workspace, and the coordinator sees everything.
|
||||
|
||||
- list_tasks shows what's open: yours, a workspace's, or (as the coordinator) all of it. Check it before you plan work, so you don't start something already tracked.
|
||||
- create_task adds one, with a brief: what to do, where, and what done looks like. Only add a task for work that will take more than this reply; never one for something you're doing now.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* A task's status. Tasks are the crew's shared to-do list, one per bot and
|
||||
* one per workspace, and every move through this list is a step in the
|
||||
* A task's status. Tasks are the crew's to-do list, every task one bot's
|
||||
* (on its own list or in a workspace), and every move through this list is a step in the
|
||||
* work: `blocked` is what a bot (or the user) sets when only the user can
|
||||
* unblock it, and `done` is only ever set once the work was verified.
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
||||
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
|
||||
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
|
||||
import passOnBotTasks from "@/src/functions/backend/db/tasks/pass-on-bot-tasks";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import { clearEnvCache } from "../envs/grab-all-envs";
|
||||
import grabUserBot from "./grab-user-bot";
|
||||
@@ -40,19 +41,18 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
|
||||
);
|
||||
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
|
||||
|
||||
/**
|
||||
* Its tasks in workspaces belong to the workspace: they go to each one's
|
||||
* admin bot (else the coordinator) instead of cascading away with its
|
||||
* own list.
|
||||
*/
|
||||
await passOnBotTasks({ bot_id: bot.id, bot_name: bot.name });
|
||||
|
||||
/**
|
||||
* Explicitly, since foreign keys are off outside the server process.
|
||||
*/
|
||||
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
|
||||
|
||||
/**
|
||||
* Its tasks in workspaces belong to the workspace: they stay, shared
|
||||
* again, instead of cascading away with its own list.
|
||||
*/
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE tasks SET bot_id = NULL, updated_at = ? WHERE bot_id = ? AND workspace_id IS NOT NULL`,
|
||||
values: [Date.now(), bot.id],
|
||||
});
|
||||
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE bot_id = ?`, values: [bot.id] });
|
||||
await BunSQLite.sql({ sql: `DELETE FROM secret_requests WHERE bot_id = ?`, values: [bot.id] });
|
||||
await BunSQLite.sql({ sql: `DELETE FROM bot_graphics WHERE bot_id = ?`, values: [bot.id] });
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import passOnBotTasks from "./pass-on-bot-tasks";
|
||||
|
||||
/**
|
||||
* At boot: every task belongs to a bot. Workspace tasks left without one
|
||||
* by earlier versions ("shared") go to the workspace's admin bot, else the
|
||||
* coordinator.
|
||||
*/
|
||||
export default async function assignOwnerlessTasks() {
|
||||
return await passOnBotTasks({});
|
||||
}
|
||||
@@ -27,7 +27,7 @@ type Params = {
|
||||
|
||||
/**
|
||||
* Adds a task to a bot's own list or a workspace's, there for one of its
|
||||
* bots (`bot_id` too) or shared. A user adds one to a bot they chat with or
|
||||
* bots (`bot_id` too): every task belongs to a bot. A user adds one to a bot they chat with or
|
||||
* a workspace they're in; a bot's run adds one to its own list or to a
|
||||
* workspace it works in (its tool checks that). A workspace's task can
|
||||
* only go to a bot working there. Caps a list at `TaskMaxPerScope` open and
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import grabWorkspaceAdmins from "../bot-types/grab-workspace-admins";
|
||||
|
||||
type Params = {
|
||||
workspace_id: number | string;
|
||||
/**
|
||||
* The bot leaving, never its own heir.
|
||||
*/
|
||||
except_bot_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Who takes over a workspace task whose bot is gone: the workspace's admin
|
||||
* bot, else the coordinator. Every task belongs to a bot, so none is left
|
||||
* without one.
|
||||
*/
|
||||
export default async function grabTaskHeir({ workspace_id, except_bot_id }: Params) {
|
||||
const admins = await grabWorkspaceAdmins({ workspace_id });
|
||||
const admin = admins.find((bot) => Number(bot.id) != Number(except_bot_id || 0));
|
||||
|
||||
if (admin?.id) return admin;
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||
sql: `SELECT * FROM bots WHERE kind = 'coordinator' ORDER BY id ASC LIMIT 1`,
|
||||
});
|
||||
|
||||
return res.payload?.[0] || null;
|
||||
}
|
||||
@@ -27,10 +27,6 @@ type Params = {
|
||||
* Only the tasks this bot has (anywhere, unless narrowed).
|
||||
*/
|
||||
only_bot_id?: number | string | null;
|
||||
/**
|
||||
* With `only_bot_id`: also the shared ones nobody has yet.
|
||||
*/
|
||||
with_unassigned?: boolean;
|
||||
/**
|
||||
* Only one workspace's tasks.
|
||||
*/
|
||||
@@ -61,7 +57,6 @@ export default async function grabTasks({
|
||||
is_coordinator,
|
||||
admin_workspace_ids,
|
||||
only_bot_id,
|
||||
with_unassigned,
|
||||
only_workspace_id,
|
||||
own_list,
|
||||
statuses,
|
||||
@@ -82,7 +77,7 @@ export default async function grabTasks({
|
||||
}
|
||||
|
||||
if (only_bot_id) {
|
||||
where.push(with_unassigned ? `(t.bot_id = ? OR t.bot_id IS NULL)` : `t.bot_id = ?`);
|
||||
where.push(`t.bot_id = ?`);
|
||||
values.push(Number(only_bot_id));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ type Params = {
|
||||
task_id: number | string;
|
||||
/**
|
||||
* Where it goes: a bot's own list (`bot_id` alone), or a workspace,
|
||||
* with the bot that has it there (none: unassigned).
|
||||
* with the bot that has it there (always one).
|
||||
*/
|
||||
bot_id?: number | string | null;
|
||||
workspace_id?: number | string | null;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
|
||||
import addTaskNote from "./add-task-note";
|
||||
import grabTaskHeir from "./grab-task-heir";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The bot leaving; none for tasks that have no bot at all.
|
||||
*/
|
||||
bot_id?: number | string | null;
|
||||
/**
|
||||
* Only its tasks in this workspace; none for every workspace.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Who is leaving, for the note on each task.
|
||||
*/
|
||||
bot_name?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gives a leaving bot's workspace tasks (or ones with no bot) to the
|
||||
* workspace's admin bot, else the coordinator, with a note on each. Its own
|
||||
* list is left alone. Returns how many moved.
|
||||
*/
|
||||
export default async function passOnBotTasks({ bot_id, workspace_id, bot_name }: Params) {
|
||||
const where = [`workspace_id IS NOT NULL`, bot_id ? `bot_id = ?` : `bot_id IS NULL`];
|
||||
const values: number[] = bot_id ? [Number(bot_id)] : [];
|
||||
|
||||
if (workspace_id) {
|
||||
where.push(`workspace_id = ?`);
|
||||
values.push(Number(workspace_id));
|
||||
}
|
||||
|
||||
const tasks =
|
||||
(
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
|
||||
sql: `SELECT * FROM tasks WHERE ${where.join(" AND ")}`,
|
||||
values,
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
let moved = 0;
|
||||
|
||||
for (const task of tasks) {
|
||||
const heir = await grabTaskHeir({ workspace_id: task.workspace_id!, except_bot_id: bot_id });
|
||||
|
||||
if (!heir?.id) continue;
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE tasks SET bot_id = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [Number(heir.id), Date.now(), Number(task.id)],
|
||||
});
|
||||
|
||||
await addTaskNote({
|
||||
task_id: task.id!,
|
||||
text: bot_name ? `${bot_name} left; this is now ${heir.name}'s.` : `This is now ${heir.name}'s.`,
|
||||
author: "user",
|
||||
}).catch(() => {});
|
||||
|
||||
moved++;
|
||||
}
|
||||
|
||||
return moved;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import taskScopeValues from "./task-scope-values";
|
||||
|
||||
describe("taskScopeValues", () => {
|
||||
test("a task on a bot's own list or in a workspace has its bot", () => {
|
||||
expect(taskScopeValues({ bot_id: 3 })).toEqual({ bot_id: 3, workspace_id: null });
|
||||
expect(taskScopeValues({ bot_id: "3", workspace_id: "11" })).toEqual({ bot_id: 3, workspace_id: 11 });
|
||||
});
|
||||
|
||||
test("a workspace task without a bot is refused", () => {
|
||||
expect(() => taskScopeValues({ workspace_id: 11 })).toThrow();
|
||||
expect(() => taskScopeValues({})).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -6,17 +6,17 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a task lives and who has it. A bot's own list is `bot_id` alone; a
|
||||
* workspace's task has `workspace_id`, plus `bot_id` when a bot there has
|
||||
* it (none: shared, not yet anyone's). Checked in the helpers rather than
|
||||
* the schema.
|
||||
* Where a task lives and who has it. Every task belongs to a bot: a bot's
|
||||
* own list is `bot_id` alone, a workspace's task has `workspace_id` plus
|
||||
* the `bot_id` that has it there. Checked in the helpers rather than the
|
||||
* schema.
|
||||
*/
|
||||
export default function taskScopeValues({ bot_id, workspace_id }: Params) {
|
||||
const bot = bot_id ? Number(bot_id) : null;
|
||||
const workspace = workspace_id ? Number(workspace_id) : null;
|
||||
|
||||
if (!bot && !workspace) {
|
||||
throw new AppError(`Say which bot or workspace the task is for.`);
|
||||
if (!bot) {
|
||||
throw new AppError(`Say which bot has the task.`);
|
||||
}
|
||||
|
||||
return { bot_id: bot, workspace_id: workspace };
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserWorkspace from "./grab-user-workspace";
|
||||
import grabBotById from "../bots/grab-bot-by-id";
|
||||
import passOnBotTasks from "../tasks/pass-on-bot-tasks";
|
||||
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
||||
|
||||
type Params = {
|
||||
@@ -31,12 +33,11 @@ export default async function removeBotFromWorkspace({
|
||||
});
|
||||
|
||||
/**
|
||||
* The tasks it had there stay in the workspace, shared again.
|
||||
* The tasks it had there stay in the workspace, passed to its admin bot
|
||||
* (else the coordinator), read before its admin type goes below.
|
||||
*/
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE tasks SET bot_id = NULL, updated_at = ? WHERE workspace_id = ? AND bot_id = ?`,
|
||||
values: [Date.now(), workspace.id, bot_id],
|
||||
});
|
||||
const bot = await grabBotById({ bot_id });
|
||||
await passOnBotTasks({ bot_id, workspace_id: workspace.id, bot_name: bot?.name });
|
||||
|
||||
/**
|
||||
* Its types there (workspace admin) go with its place.
|
||||
|
||||
@@ -39,19 +39,18 @@ type Args = {
|
||||
description: string;
|
||||
bot_id?: number;
|
||||
workspace_id?: number;
|
||||
shared?: boolean;
|
||||
status?: TaskStatus;
|
||||
};
|
||||
|
||||
type Place = { bot_id?: number; workspace_id?: number };
|
||||
type Place = { bot_id: number; workspace_id?: number };
|
||||
|
||||
/**
|
||||
* Worked out from the arguments and who is asking: where the task goes and
|
||||
* who has it, or why it can't. Outside a workspace a worker adds only to
|
||||
* its own list. In one the task stays in that workspace: its own by
|
||||
* default, a member's as the workspace's admin, or shared (nobody's yet).
|
||||
* The coordinator adds to any worker's own list or any workspace, for one
|
||||
* of its bots or shared.
|
||||
* who has it, or why it can't. Every task belongs to a bot. Outside a
|
||||
* workspace a worker adds only to its own list. In one the task stays in
|
||||
* that workspace: its own by default, or a member's as the workspace's
|
||||
* admin. The coordinator adds to any worker's own list or any workspace,
|
||||
* for one of its bots or itself.
|
||||
*/
|
||||
function resolvePlace({
|
||||
args,
|
||||
@@ -69,22 +68,18 @@ function resolvePlace({
|
||||
const target_bot = args.bot_id ? Number(args.bot_id) : null;
|
||||
const target_workspace = args.workspace_id ? Number(args.workspace_id) : null;
|
||||
|
||||
if (args.shared && target_bot) {
|
||||
return `A shared task is nobody's yet: leave bot_id out, or drop shared to give it to that bot.`;
|
||||
}
|
||||
|
||||
if (is_coordinator) {
|
||||
const place_workspace = target_workspace || (workspace_id ? Number(workspace_id) : null);
|
||||
|
||||
if (place_workspace) {
|
||||
return target_bot ? { workspace_id: place_workspace, bot_id: target_bot } : { workspace_id: place_workspace };
|
||||
return { workspace_id: place_workspace, bot_id: target_bot || Number(bot_id) };
|
||||
}
|
||||
|
||||
return { bot_id: target_bot || Number(bot_id) };
|
||||
}
|
||||
|
||||
if (!workspace_id) {
|
||||
if (target_workspace || args.shared) {
|
||||
if (target_workspace) {
|
||||
return `You're not in a workspace here, so a task can only go on your own list (bot_id ${bot_id}).`;
|
||||
}
|
||||
|
||||
@@ -99,14 +94,12 @@ function resolvePlace({
|
||||
return `You can only add tasks in this workspace (#${workspace_id}).`;
|
||||
}
|
||||
|
||||
if (args.shared) return { workspace_id: Number(workspace_id) };
|
||||
|
||||
const is_member = members.some((member) => member.id == target_bot);
|
||||
|
||||
if (target_bot && target_bot != Number(bot_id) && !is_member) {
|
||||
return members.length
|
||||
? `You can only give a task here to yourself (#${bot_id}) or a member (${members.map((m) => m.id).join(", ")}), or leave it shared.`
|
||||
: `You can only give a task here to yourself (#${bot_id}), or leave it shared.`;
|
||||
? `You can only give a task here to yourself (#${bot_id}) or a member (${members.map((m) => m.id).join(", ")}).`
|
||||
: `You can only give a task here to yourself (#${bot_id}).`;
|
||||
}
|
||||
|
||||
return { workspace_id: Number(workspace_id), bot_id: target_bot || Number(bot_id) };
|
||||
@@ -114,8 +107,8 @@ function resolvePlace({
|
||||
|
||||
/**
|
||||
* Lets a bot add a task to a list it works on: its own, or in a workspace
|
||||
* its own there (a member's as its admin) or a shared one. The coordinator
|
||||
* can add to any worker's list and any workspace.
|
||||
* its own there (a member's as its admin). The coordinator can add to any
|
||||
* worker's list and any workspace. Every task belongs to a bot.
|
||||
*/
|
||||
export default function createTaskTool({
|
||||
user_id,
|
||||
@@ -134,8 +127,8 @@ export default function createTaskTool({
|
||||
return {
|
||||
name: "create_task",
|
||||
description: is_coordinator
|
||||
? `Add a task: to a worker's own list (bot_id: ${crew || "no workers yet"}), to a workspace (workspace_id: ${places || "no workspaces yet"}) for one of its bots (with bot_id) or shared (without), or to your own list (leave both ids out${workspace_id ? "; here, leaving them out makes it a shared task in this workspace" : ""}). Write the description as a brief: what to do, where, and how you'll know it's done.`
|
||||
: `Add a task: ${workspace_id ? `in this workspace, yours by default${team ? `, a member's as this workspace's admin (bot_id: ${team})` : ""}, or shared (shared: true) for whichever bot here takes it up` : `on your own list (bot_id ${bot_id}, the default)`}. Write the description as a brief: what to do, where, and how you'll know it's done.`,
|
||||
? `Add a task: to a worker's own list (bot_id: ${crew || "no workers yet"}), to a workspace (workspace_id: ${places || "no workspaces yet"}) for one of its bots (with bot_id; without, it's yours there), or to your own list (leave both ids out${workspace_id ? "; here, leaving them out makes it yours in this workspace" : ""}). Every task belongs to one bot. Write the description as a brief: what to do, where, and how you'll know it's done.`
|
||||
: `Add a task: ${workspace_id ? `in this workspace, yours by default${team ? `, or a member's as this workspace's admin (bot_id: ${team})` : ""}` : `on your own list (bot_id ${bot_id}, the default)`}. Write the description as a brief: what to do, where, and how you'll know it's done.`,
|
||||
schema: z.object({
|
||||
title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength).describe(`A short label`),
|
||||
description: z
|
||||
@@ -144,7 +137,7 @@ export default function createTaskTool({
|
||||
.describe(`The brief, in markdown: what to do, where, and what done looks like`),
|
||||
bot_id: z.number().int().optional().describe(
|
||||
is_coordinator
|
||||
? `Who has it: a worker's own list, or with workspace_id a bot working there. Leave out for your own list or a shared workspace task.`
|
||||
? `Who has it: a worker's own list, or with workspace_id a bot working there. Leave out to have it yourself.`
|
||||
: team
|
||||
? `Who has it here: you (${bot_id}, the default) or a member (${team}).`
|
||||
: `Your own id (${bot_id}); leaving it out has the same effect.`,
|
||||
@@ -158,10 +151,6 @@ export default function createTaskTool({
|
||||
? `The workspace it's in, when it isn't a bot's own list.`
|
||||
: `This workspace (${workspace_id || "none"}); leaving it out has the same effect.`,
|
||||
),
|
||||
shared: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(`In a workspace: nobody's yet, for whichever bot there takes it up. Defaults to false.`),
|
||||
status: z.enum(TaskStatusValues).optional().describe(`Defaults to to do`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
@@ -201,7 +190,7 @@ export default function createTaskTool({
|
||||
});
|
||||
|
||||
const where = place.workspace_id
|
||||
? `in workspace ${place.workspace_id}, ${!place.bot_id ? "shared" : place.bot_id == Number(bot_id) ? "yours" : `bot ${place.bot_id}'s`}`
|
||||
? `in workspace ${place.workspace_id}, ${place.bot_id == Number(bot_id) ? "yours" : `bot ${place.bot_id}'s`}`
|
||||
: place.bot_id == Number(bot_id)
|
||||
? "to your list"
|
||||
: `to bot ${place.bot_id}'s list`;
|
||||
|
||||
@@ -25,8 +25,8 @@ type Params = {
|
||||
|
||||
/**
|
||||
* The task tools every run gets (`src/harness/tools/bot/tasks/`): the
|
||||
* to-do list (a bot's own, and in each workspace its tasks there and the
|
||||
* shared ones), so a chat reply, a task and a scheduled run all read it,
|
||||
* to-do list (every task belongs to one bot: its own list and its tasks in
|
||||
* each workspace; an admin's whole workspace), so a chat reply, a task and a scheduled run all read it,
|
||||
* move it along and file what they produced. The coordinator's create_task
|
||||
* may target any worker or workspace; its update_task may link any file it
|
||||
* can reach. A workspace admin's may give tasks there to the other members.
|
||||
|
||||
@@ -27,14 +27,15 @@ type Args = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot read the tasks it can work on: its own list and every
|
||||
* workspace it works in, or for the coordinator the whole install. Used
|
||||
* Lets a bot read the tasks it can work on: its own (its list and what it
|
||||
* has in workspaces), every task of a workspace it's admin of, or for the
|
||||
* coordinator the whole install. Used
|
||||
* both to plan work and to remind the user of what's pending.
|
||||
*/
|
||||
export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_workspace_ids, workspace_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "list_tasks",
|
||||
description: `List tasks: yours (your own list and the ones you have in workspaces), the ones in ${workspace_id ? "this workspace" : "a workspace"}, or ${is_coordinator ? "every task in the install" : "every task you can reach (yours, and in your workspaces the shared ones nobody has yet)"}. Each line is its id, status, title, whose it is, its files and its latest note. Open ones come first (blocked, then doing, then to do). Use it to plan work, and to check what's still pending when the user says hello or asks what's next.`,
|
||||
description: `List tasks: yours (your own list and the ones you have in workspaces), the ones in ${workspace_id ? "this workspace" : "a workspace"}, or ${is_coordinator ? "every task in the install" : "every task you can reach (yours, and as a workspace's admin every task there)"}. Each line is its id, status, title, whose it is, its files and its latest note. Open ones come first (blocked, then doing, then to do). Use it to plan work, and to check what's still pending when the user says hello or asks what's next.`,
|
||||
schema: z.object({
|
||||
scope: z
|
||||
.enum(["mine", "workspace", "all"])
|
||||
@@ -74,7 +75,7 @@ export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_w
|
||||
|
||||
return tasks
|
||||
.map((task) => {
|
||||
const who = task.bot_id ? task.bot_name || `bot ${task.bot_id}` : "shared, nobody's yet";
|
||||
const who = task.bot_name || `bot ${task.bot_id}`;
|
||||
const where = task.workspace_id
|
||||
? `in ${names.get(Number(task.workspace_id)) || `workspace ${task.workspace_id}`}, ${who}`
|
||||
: `on ${who}'s list`;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { type TaskStatus, TaskStatusValues } from "@/src/dict/task-dict";
|
||||
import addTaskNote from "@/src/functions/backend/db/tasks/add-task-note";
|
||||
import attachTaskFile from "@/src/functions/backend/db/tasks/attach-task-file";
|
||||
import grabRunTask from "@/src/functions/backend/db/tasks/grab-run-task";
|
||||
import moveTask from "@/src/functions/backend/db/tasks/move-task";
|
||||
import grabTaskMedia from "@/src/functions/backend/db/tasks/grab-task-media";
|
||||
import updateTask from "@/src/functions/backend/db/tasks/update-task";
|
||||
import type { StorageScope } from "@/src/utils/storage-access";
|
||||
@@ -41,7 +40,7 @@ type Args = {
|
||||
export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_workspace_ids, scopes = [] }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "update_task",
|
||||
description: `Move a task along. Set its status (blocked needs a reason: what the user has to do), log a note of what you did or found, and file the files it produced. Setting a shared workspace task to doing makes it yours. Call it as the work advances, not only at the end; every note is what the user sees on the task. Mark done only once you've verified the work (see the check note): starting a task isn't finishing it. To file a deliverable, save it first with write_file, make_pdf or generate_image, then pass its [file:ID] number.`,
|
||||
description: `Move a task along. Set its status (blocked needs a reason: what the user has to do), log a note of what you did or found, and file the files it produced. Call it as the work advances, not only at the end; every note is what the user sees on the task. Mark done only once you've verified the work (see the check note): starting a task isn't finishing it. To file a deliverable, save it first with write_file, make_pdf or generate_image, then pass its [file:ID] number.`,
|
||||
schema: z.object({
|
||||
task_id: z.number().int(),
|
||||
status: z.enum(TaskStatusValues).optional(),
|
||||
@@ -68,15 +67,6 @@ export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_
|
||||
const task = await grabRunTask({ task_id: args.task_id, bot_id, is_coordinator, admin_workspace_ids });
|
||||
const said: string[] = [];
|
||||
|
||||
/**
|
||||
* A shared task in a workspace is nobody's until a bot starts on
|
||||
* it: then it's that bot's.
|
||||
*/
|
||||
if (args.status == "doing" && task.workspace_id && !task.bot_id && !is_coordinator) {
|
||||
await moveTask({ task_id: task.id!, bot_id, workspace_id: task.workspace_id });
|
||||
said.push(`it's yours now`);
|
||||
}
|
||||
|
||||
if (args.status || args.blocked_reason !== undefined) {
|
||||
if (args.status == "blocked" && !args.blocked_reason?.trim()) {
|
||||
return `Blocked needs a reason: say in blocked_reason what the user has to do.`;
|
||||
|
||||
@@ -42,7 +42,7 @@ export const MessageMarkdownComponents: Components = {
|
||||
</MarkdownList>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-sunken text-ink break-words">
|
||||
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-sunken text-ink wrap-anywhere">
|
||||
{children}
|
||||
</Span>
|
||||
),
|
||||
@@ -58,7 +58,7 @@ export const MessageMarkdownComponents: Components = {
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<Stack className="w-full max-w-full overflow-x-auto items-start">
|
||||
<table className="text-[13px] leading-5 text-ink border-collapse">{children}</table>
|
||||
<table className="text-[13px] leading-5 text-ink border-collapse wrap-break-word">{children}</table>
|
||||
</Stack>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
|
||||
@@ -39,7 +39,7 @@ export const UserMarkdownComponents: Components = {
|
||||
</MarkdownList>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-on-ink/15 text-on-ink break-words">
|
||||
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-on-ink/15 text-on-ink wrap-anywhere">
|
||||
{children}
|
||||
</Span>
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ type Params = {
|
||||
|
||||
/**
|
||||
* A conversation's tasks: a bot's own list, or in a workspace the ones it
|
||||
* has there plus the shared ones nobody has yet. Loaded on open and refreshed after every change,
|
||||
* has there. Loaded on open and refreshed after every change,
|
||||
* never part of the page props.
|
||||
*/
|
||||
export default function useBotTasks({ bot_id, workspace_id, enabled = true }: Params) {
|
||||
|
||||
@@ -65,7 +65,7 @@ function BotMessageLine({ message, bot_name, at }: Props) {
|
||||
) : null}
|
||||
<Stack
|
||||
{...{ [QuotableAttribute]: "" }}
|
||||
className="w-full gap-2.5! items-start break-words"
|
||||
className="w-full gap-2.5! items-start wrap-anywhere [&>*]:max-w-full"
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={RemarkPlugins}
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function BotRefChip({ bot_id }: Props) {
|
||||
: `/admin/bots/${bot.id}`
|
||||
}
|
||||
title={`Open ${bot.name}`}
|
||||
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
|
||||
className="inline-flex h-6 max-w-full gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
|
||||
>
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
@@ -43,9 +43,10 @@ export default function BotRefChip({ bot_id }: Props) {
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
label={bot.name || ""}
|
||||
size={18}
|
||||
className="shrink-0"
|
||||
/>
|
||||
{bot.name}
|
||||
{workspace_id && bot.is_admin ? <AdminShield size={13} /> : null}
|
||||
<Span className="min-w-0 truncate">{bot.name}</Span>
|
||||
{workspace_id && bot.is_admin ? <AdminShield size={13} className="shrink-0" /> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ type Props = {
|
||||
|
||||
/**
|
||||
* The tasks this conversation works on: a bot's own list, or in a workspace
|
||||
* the ones it has there plus the shared ones nobody has yet. Open it from the header menu; the list loads
|
||||
* the ones it has there. Open it from the header menu; the list loads
|
||||
* then, so the page's props stay small. A crew manager adds, moves, edits
|
||||
* and deletes; a card opens the task in a modal of its own with everything
|
||||
* about it.
|
||||
@@ -157,7 +157,7 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
||||
</Row>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{workspace?.id
|
||||
? `What ${name} has in ${workspace.name || "this workspace"}, and the shared tasks there nobody has taken up yet. The workspace shows every bot's.`
|
||||
? `What ${name} has in ${workspace.name || "this workspace"}. The workspace shows every bot's.`
|
||||
: `What ${name} is working through, and what it's waiting on.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
@@ -38,14 +38,15 @@ export default function ComputerRefChip({ computer_id }: Props) {
|
||||
openComputer({ computer_id: computer.id || 0, mode: "browser" })
|
||||
}
|
||||
className={twMerge(
|
||||
"inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold whitespace-nowrap text-ink dark:text-ink",
|
||||
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold whitespace-nowrap text-ink dark:text-ink",
|
||||
"[&_.twui-button-content-wrapper]:min-w-0",
|
||||
current
|
||||
? "bg-sunken hover:bg-sunken outline-0"
|
||||
: "bg-transparent hover:bg-sunken",
|
||||
)}
|
||||
>
|
||||
<Monitor size={14} aria-hidden className="shrink-0" />
|
||||
{name}
|
||||
<Span className="min-w-0 truncate">{name}</Span>
|
||||
{current ? <Check size={14} aria-hidden className="shrink-0" /> : null}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ type Props = {
|
||||
*/
|
||||
function CrewMessageLine({ message }: Props) {
|
||||
return (
|
||||
<Stack className="w-full gap-2.5! items-start break-words">
|
||||
<Stack className="w-full gap-2.5! items-start wrap-anywhere [&>*]:max-w-full">
|
||||
<Markdown remarkPlugins={RemarkPlugins} components={MessageMarkdownComponents}>
|
||||
{linkComputerRefs({ text: message.text || "" })}
|
||||
</Markdown>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
@@ -43,13 +44,16 @@ export default function HookProposalRefChip({ proposal_id }: Props) {
|
||||
color="gray"
|
||||
size="smaller"
|
||||
title={`Review: ${title}`}
|
||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
||||
className={twMerge(
|
||||
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
|
||||
"[&_.twui-button-content-wrapper]:min-w-0",
|
||||
)}
|
||||
>
|
||||
<Webhook size={14} aria-hidden className="shrink-0" />
|
||||
{`${title} · ${status}`}
|
||||
<Span className="min-w-0 truncate">{`${title} · ${status}`}</Span>
|
||||
</Button>
|
||||
}
|
||||
targetWrapperProps={{ className: "inline-flex align-middle" }}
|
||||
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-xl bg-paper p-6"
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ink underline underline-offset-2 hover:opacity-80 break-words"
|
||||
className="text-ink underline underline-offset-2 hover:opacity-80 wrap-anywhere"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function MarkdownList({ children, ordered, start, className }: Pr
|
||||
start={start}
|
||||
items={items}
|
||||
className={twMerge(
|
||||
`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`,
|
||||
`self-stretch gap-1! pl-5 my-0 text-sm leading-5 text-ink [&>li]:max-w-full ${ordered ? "list-decimal" : "list-disc"}`,
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
@@ -47,13 +48,16 @@ export default function ProposalRefChip({ proposal_id }: Props) {
|
||||
color="gray"
|
||||
size="smaller"
|
||||
title={`Review: ${title}`}
|
||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
||||
className={twMerge(
|
||||
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
|
||||
"[&_.twui-button-content-wrapper]:min-w-0",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden className="shrink-0" />
|
||||
{`${title} · ${status}`}
|
||||
<Span className="min-w-0 truncate">{`${title} · ${status}`}</Span>
|
||||
</Button>
|
||||
}
|
||||
targetWrapperProps={{ className: "inline-flex align-middle" }}
|
||||
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-xl bg-paper p-6"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
@@ -46,13 +47,16 @@ export default function SecretRequestRefChip({ request_id }: Props) {
|
||||
color="gray"
|
||||
size="smaller"
|
||||
title={view.status == "pending" ? `Enter ${view.key}` : `${view.key}: ${status}`}
|
||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
||||
className={twMerge(
|
||||
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
|
||||
"[&_.twui-button-content-wrapper]:min-w-0",
|
||||
)}
|
||||
>
|
||||
<KeyRound size={14} aria-hidden className="shrink-0" />
|
||||
{`${view.key} · ${status}`}
|
||||
<Span className="min-w-0 truncate">{`${view.key} · ${status}`}</Span>
|
||||
</Button>
|
||||
}
|
||||
targetWrapperProps={{ className: "inline-flex align-middle" }}
|
||||
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-xl bg-paper p-6"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useContext } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { Hand } from "lucide-react";
|
||||
@@ -34,10 +35,13 @@ export default function TakeOverChip({ computer_id, mode }: Props) {
|
||||
size="smaller"
|
||||
title={`Open ${name} (${mode_title}) and take over`}
|
||||
onClick={() => openComputer({ computer_id, mode, take_over: true })}
|
||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
||||
className={twMerge(
|
||||
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
|
||||
"[&_.twui-button-content-wrapper]:min-w-0",
|
||||
)}
|
||||
>
|
||||
<Hand size={14} aria-hidden className="shrink-0" />
|
||||
{`Take over ${name} · ${mode_title}`}
|
||||
<Span className="min-w-0 truncate">{`Take over ${name} · ${mode_title}`}</Span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function UserBubble({ text }: { text: string }) {
|
||||
{...{ [QuotableAttribute]: "" }}
|
||||
className={twMerge(
|
||||
"max-w-[80%] min-w-0 gap-2.5! items-stretch rounded-default-lg",
|
||||
"bg-ink text-on-ink px-4 py-3 text-sm leading-5 break-words",
|
||||
"bg-ink text-on-ink px-4 py-3 text-sm leading-5 wrap-anywhere",
|
||||
"conversation-user-bubble",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function UserMarkdownLink(props: ComponentProps<"a">) {
|
||||
href={props.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 dark:text-blue-600 underline underline-offset-2 hover:opacity-80 break-words"
|
||||
className="text-blue-400 dark:text-blue-600 underline underline-offset-2 hover:opacity-80 wrap-anywhere"
|
||||
>
|
||||
{props.children}
|
||||
</Link>
|
||||
|
||||
@@ -24,10 +24,10 @@ export default function WorkspaceRefChip({ workspace_id }: Props) {
|
||||
<Link
|
||||
href={`/admin/workspaces/${workspace.id}`}
|
||||
title={`Open ${workspace.name}`}
|
||||
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
|
||||
className="inline-flex h-6 max-w-full gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
|
||||
>
|
||||
<WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" />
|
||||
{workspace.name}
|
||||
<Span className="min-w-0 truncate">{workspace.name}</Span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,14 +22,19 @@ type Props = {
|
||||
* Its tasks, open first.
|
||||
*/
|
||||
tasks: TaskWithNames[];
|
||||
/**
|
||||
* The bots working there, to pick who has a new task.
|
||||
*/
|
||||
bots: { id?: number | string | null; name?: string | null }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Every task in a workspace, whichever bot has it (or shared, nobody's
|
||||
* yet). Each bot there sees its own and the shared ones. Add and change one by hand here; the bots
|
||||
* Every task in a workspace, whichever bot has it: every task belongs to
|
||||
* one bot, which sees only its own (the workspace's admin bot sees all).
|
||||
* Add and change one by hand here; the bots
|
||||
* keep it up to date themselves as they work.
|
||||
*/
|
||||
export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
||||
export default function WorkspaceTasksGroup({ workspace_id, tasks, bots }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [items, setItems] = useState(tasks);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -89,18 +94,19 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
||||
<SettingsGroup
|
||||
id="tasks"
|
||||
title="Tasks"
|
||||
description="Every task in this workspace, whichever bot has it. A bot sees its own here and the shared ones nobody has taken up yet."
|
||||
description="Every task in this workspace, whichever bot has it. Each bot sees only its own; the workspace's admin bot and the coordinator see them all."
|
||||
>
|
||||
<Stack className="w-full gap-2! items-stretch">
|
||||
{can_manage ? (
|
||||
<Row className="justify-end">
|
||||
<Button
|
||||
title="Add a task"
|
||||
title={bots.length ? "Add a task" : "Add a bot to this workspace first"}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setAdding(true)}
|
||||
disabled={!bots.length}
|
||||
>
|
||||
Add task
|
||||
</Button>
|
||||
@@ -138,10 +144,11 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
||||
open={adding}
|
||||
setOpen={setAdding}
|
||||
title="Add a task"
|
||||
description="It's added shared: whichever bot here takes it up has it. Hand it over to give it to one."
|
||||
description="Pick the bot that has it. Hand it over later to give it to another."
|
||||
>
|
||||
<TaskForm
|
||||
workspace_id={workspace_id}
|
||||
bots={bots}
|
||||
onCancel={() => setAdding(false)}
|
||||
onSaved={(task, msg) => {
|
||||
replace({ task });
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function WorkspaceDashboard() {
|
||||
<WorkspaceTasksGroup
|
||||
workspace_id={workspace_id}
|
||||
tasks={pageProps?.workspace_tasks || []}
|
||||
bots={pageProps?.workspace_bots || []}
|
||||
/>
|
||||
<WorkspaceActivityGroup
|
||||
workspace_id={workspace_id}
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bune
|
||||
|
||||
/**
|
||||
* The user's tasks: a bot's own list (`?bot_id=`), a workspace's
|
||||
* (`?workspace_id=`), one bot's there with the unassigned ones
|
||||
* (`?workspace_id=&bot_id=`), or every task they reach. `?status=` narrows to one
|
||||
* (`?workspace_id=`), one bot's there (`?workspace_id=&bot_id=`), or every
|
||||
* task they reach. `?status=` narrows to one
|
||||
* status, `?open=1` to the open ones.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> = async ({ req }) => {
|
||||
@@ -33,7 +33,6 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> =
|
||||
payload: await grabTasks({
|
||||
user_id: user.id,
|
||||
only_bot_id: bot_id,
|
||||
with_unassigned: Boolean(bot_id && workspace_id),
|
||||
only_workspace_id: workspace_id,
|
||||
own_list: Boolean(bot_id && !workspace_id),
|
||||
statuses: status ? [status] : undefined,
|
||||
|
||||
@@ -11,8 +11,8 @@ import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Adds a task to a bot's list (`bot_id`), a workspace (`workspace_id`,
|
||||
* shared) or a bot in a workspace (both), or changes one (`task_id`). Returns the task with its
|
||||
* Adds a task to a bot's list (`bot_id`) or a bot's in a workspace (with
|
||||
* `workspace_id`), or changes one (`task_id`). Returns the task with its
|
||||
* notes and files.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
|
||||
|
||||
@@ -6,9 +6,9 @@ const status_values = TaskStatuses.map((status) => status.value) as [string, ...
|
||||
const media_kind_values = TaskMediaKinds.map((kind) => kind.value) as [string, ...string[]];
|
||||
|
||||
/**
|
||||
* Where it goes: a bot's own list (`bot_id`), a workspace (`workspace_id`,
|
||||
* shared), or a bot's task in a workspace (both). The helper refuses
|
||||
* neither (`taskScopeValues`).
|
||||
* Where it goes: a bot's own list (`bot_id`) or a bot's task in a
|
||||
* workspace (both). Every task has a bot: the helper refuses one without
|
||||
* (`taskScopeValues`).
|
||||
*/
|
||||
const scope = {
|
||||
bot_id: z.number().int().positive().optional(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import fileIngest from "./file-ingest";
|
||||
import liveRelay from "./live-relay";
|
||||
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
|
||||
import migrateBotTypes from "./functions/backend/db/bot-types/migrate-bot-types";
|
||||
import assignOwnerlessTasks from "./functions/backend/db/tasks/assign-ownerless-tasks";
|
||||
import clearImplicitSessionLinks from "./functions/backend/db/bot-sessions/clear-implicit-session-links";
|
||||
import dropStoredFileText from "./functions/backend/db/stored-text/drop-stored-file-text";
|
||||
import syncKnowledgebaseFts from "./functions/backend/db/knowledgebases/fts/sync-knowledgebase-fts";
|
||||
@@ -38,6 +39,9 @@ await enableForeignKeys();
|
||||
await migrateBotTypes().catch((error) =>
|
||||
logError({ title: "Could not migrate bot types", error }).catch(() => {}),
|
||||
);
|
||||
await assignOwnerlessTasks().catch((error) =>
|
||||
logError({ title: "Could not assign tasks without a bot", error }).catch(() => {}),
|
||||
);
|
||||
await clearImplicitSessionLinks().catch((error) =>
|
||||
logError({ title: "Could not clear implicit session links", error }).catch(() => {}),
|
||||
);
|
||||
|
||||
@@ -19,11 +19,10 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The tasks a bot's run reaches: its own list, and in every workspace it
|
||||
* works in the tasks it has there plus the shared ones nobody has yet.
|
||||
* Another bot's task in a workspace is that bot's. A workspace admin
|
||||
* reaches every task in that workspace; the coordinator every task in the
|
||||
* install.
|
||||
* The tasks a bot's run reaches. Every task belongs to one bot: a worker
|
||||
* reaches its own list and the tasks it has in the workspaces it still
|
||||
* works in, never another bot's. A workspace admin also reaches every task
|
||||
* in that workspace; the coordinator every task in the install.
|
||||
*/
|
||||
export default function botTaskScopesSql({
|
||||
bot_id,
|
||||
@@ -42,7 +41,7 @@ export default function botTaskScopesSql({
|
||||
sql: `(
|
||||
(${alias}.bot_id = ? AND ${alias}.workspace_id IS NULL)
|
||||
OR (${alias}.workspace_id IS NOT NULL
|
||||
AND (${alias}.bot_id IS NULL OR ${alias}.bot_id = ?)
|
||||
AND ${alias}.bot_id = ?
|
||||
AND ${alias}.workspace_id IN (SELECT workspace_id FROM workspace_bots WHERE bot_id = ?))
|
||||
${admin_sql}
|
||||
)`,
|
||||
|
||||
@@ -21,7 +21,7 @@ beforeAll(() => {
|
||||
db.run(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER, created_by_bot_id INTEGER)`);
|
||||
db.run(`INSERT INTO tasks VALUES
|
||||
(100, 1, NULL, NULL), (101, 2, NULL, NULL), (102, 3, NULL, NULL),
|
||||
(110, NULL, 10, NULL), (111, NULL, 11, NULL),
|
||||
(110, 3, 10, NULL), (111, 1, 11, NULL),
|
||||
(112, 3, 11, NULL), (113, 2, 11, NULL)`);
|
||||
});
|
||||
|
||||
@@ -54,13 +54,13 @@ describe("taskScopesSql", () => {
|
||||
});
|
||||
|
||||
describe("botTaskScopesSql", () => {
|
||||
test("a worker reaches its own list, its tasks in its workspaces and the shared ones there", () => {
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 3, is_coordinator: false }) })).toEqual([102, 110, 111, 112]);
|
||||
test("a worker reaches its own list and its tasks in its workspaces, never another bot's there", () => {
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 3, is_coordinator: false }) })).toEqual([102, 110, 112]);
|
||||
});
|
||||
|
||||
test("another bot's task in a workspace stays that bot's", () => {
|
||||
db.run(`INSERT INTO workspace_bots VALUES (11, 2)`);
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 2, is_coordinator: false }) })).toEqual([101, 111, 113]);
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 2, is_coordinator: false }) })).toEqual([101, 113]);
|
||||
db.run(`DELETE FROM workspace_bots WHERE bot_id = 2`);
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("botTaskScopesSql for a workspace admin", () => {
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false, admin_workspace_ids: [11] }) })).toEqual([
|
||||
111, 112, 113,
|
||||
]);
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false }) })).toEqual([111]);
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false }) })).toEqual([]);
|
||||
db.run(`DELETE FROM workspace_bots WHERE bot_id = 4`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,8 @@ export type SqlCondition = {
|
||||
|
||||
/**
|
||||
* Where a task is: a bot's own list (`bot_id` alone), or a workspace
|
||||
* (`workspace_id`, with the bot that has it, if any).
|
||||
* (`workspace_id`, with the `bot_id` that has it there). Every task has a
|
||||
* bot.
|
||||
*/
|
||||
export type TaskScope = {
|
||||
bot_id?: number | null;
|
||||
|
||||
Reference in New Issue
Block a user