Files
cobble/src/functions/backend/db/bots-queue/complete-bot-job.ts
T
tben d9aea8e9c8 Record what every model call spends, and show it
Nothing anywhere recorded what a model call cost: the harness used a
provider's usage numbers only to decide whether a turn had finished, then
dropped them. So a reply could not say what it took to make, one model
could not be compared with another on price, and there was no way to ask
what a day, a week or a month of the crew's work came to.

- `token_usages` records one row per call: user, bot, workspace, session,
  job and message, the harness, the provider and model that actually
  answered, the prompt and completion counts, and the cost when the
  provider reports one.
- `harness-usage-scope.ts` carries those ids in an AsyncLocalStorage
  scope that `run-bot-job.ts` enters once, so a run's turns, its session
  summary, its transcriptions, images and embeddings are all attributed
  without threading ids through every harness function. Nested runs merge
  rather than replace, and a null genuinely clears a field (a bot's own
  chat has no workspace).
- Work with no run behind it is charged to its owner instead:
  `run-with-fallback-scope.ts` reaches the workspace's creator else the
  bot's, and deliberately does not override a real run, so an ingest a
  bot asked for is not misattributed to the bot's owner.
- All six harnesses record through `withCandidates`'s `on_success`.
  Recording never throws: a model that answered must not lose its answer
  to a failed insert.
- A job's calls land before its message exists, so `complete-bot-job.ts`
  back-fills `message_id` once the message is inserted.
- The message's actions row shows what it cost, beside the timestamp and
  the play button, with the breakdown in its title.
- `/admin/usage` totals the install's spending for 7, 30 or 90 days: the
  total against the previous period, a day-by-day chart, a split per kind
  of call, and a table per model with the price per thousand tokens
  worked out from cost and tokens, which is what makes two models
  comparable. Crew managers only, since it is the whole install's work
  rather than one user's conversation. It sits with Routines and Activity
  in the user popover rather than under Settings, because it is a record
  of what happened, not something to configure.

Days are grouped in local time (`date(created_at / 1000, 'unixepoch',
'localtime')`), matching the local midnights the window builds and the
`YYYY-MM-DD` buckets it looks up. Grouping by the bare UTC day put a late
evening's calls under a name no bucket carried, so they vanished and the
chart's total silently disagreed with the totals card; a test pins it.

`canSeeRailItem` now decides who sees a popover or rail entry. The two
each filtered with their own inline rule and only one understood
`can_view`, so a gated page could be offered in the popover to everyone.
2026-10-23 15:41:41 +01:00

136 lines
4.3 KiB
TypeScript

import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import { BotNeedsYouStatusLine } from "@/src/dict/bot-queue-dict";
import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face";
import countOpenBotJobs from "./count-open-bot-jobs";
import grabLockedBotJob from "./grab-locked-bot-job";
import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id";
import grabJobUserId from "./grab-job-user-id";
import grabRunningBotJobFace from "./grab-running-bot-job-face";
type Params = {
job_id: number | string;
worker: string;
/**
* The completion message for the user (markdown). Also kept as the
* job's `result`.
*/
result: string;
/**
* Flag the message as needing the user.
*/
needs_you?: boolean;
/**
* Finish without posting a message.
*/
silent?: boolean;
/**
* Files the bot saved and asked to send with it (`media` ids).
*/
attachments?: number[];
};
/**
* Marks a job done, closes its task and sends the completion message back
* to the user in the bot's conversation. Unless it has other jobs open
* for that user, the bot shows that user needs-you if asked, else done
* 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).
* 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.
*/
export default async function completeBotJob({
job_id,
worker,
result,
needs_you,
silent,
attachments,
}: Params) {
const job = await grabLockedBotJob({ job_id, worker });
if (!job?.id || !job.bot_id) {
throw new AppError(`This job is no longer held by ${worker}.`);
}
const res = await BunSQLite.queue.complete({
table: "bots_queue",
id: job.id,
});
if (!res.success) {
throw new Error(`Couldn't complete the job: ${res.msg || res.error}`);
}
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE bots_queue SET result = ?, finished_at = ? WHERE id = ?`,
values: [result, now, job.id],
});
if (job.task_id) {
await BunSQLite.sql({
sql: `UPDATE bot_tasks SET status = 'done', updated_at = ? WHERE id = ?`,
values: [now, job.task_id],
});
}
const message = silent
? null
: await insertBotMessage({
bot_id: job.bot_id,
session_id: await jobSessionId({ job }),
workspace_id: job.workspace_id || null,
text: result,
needs_you,
media_ids: attachments,
});
if (job.id && message?.id) {
/**
* The job's model calls were written down as they happened, before
* its message existed. Now that it does, they belong to it, so the
* thread can show what the answer cost.
*/
await BunSQLite.sql({
sql: `UPDATE token_usages SET message_id = ? WHERE job_id = ? AND message_id IS NULL`,
values: [message.id, job.id],
}).catch(() => {});
}
const user_id = await grabJobUserId({ job });
const bot = !user_id
? null
: needs_you
? await setBotFace({
bot_id: job.bot_id,
user_id,
face: "needs-you",
status_line: BotNeedsYouStatusLine,
})
: (await countOpenBotJobs({ bot_id: job.bot_id, user_id }))
? await restoreRunningFace({ bot_id: job.bot_id, user_id })
: await setBotFace({
bot_id: job.bot_id,
user_id,
face: job.kind == "reply" ? "idle" : "done",
});
const relay = silent ? null : await relayJobToRequester({ job, text: result });
return { message, bot, relay };
}
/**
* Puts back the face of a job still running for the user, if any.
*/
async function restoreRunningFace({ bot_id, user_id }: { bot_id: number; user_id: number }) {
const running = await grabRunningBotJobFace({ bot_id, user_id });
return running ? await setBotFace({ bot_id, user_id, ...running }) : null;
}