From 0f7d653cd5185397a0671424ab522f2f1431562d Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 14:00:43 -0700 Subject: [PATCH 01/18] feat(work): queue primitives for tasks What a task's conversation needs from the queue, with nothing in OpenBot using it yet: - `claim({ serialBy })` runs one item at a time per group. An item waits while another of its group holds a live lease, or while an older one is unfinished with attempts left. A payload field's name groups one kind by that field; a `SerialOrder` lets several kinds share one order, each with its own attempt cap and its own way of naming its group, so the queue never needs to know what a group is. - `release({ unstarted })` gives back the attempt a claim spent when the work never began. - `renew`, `finish` and `release` act only on a live lease. An expired lease on its last attempt keeps its owner, and a late heartbeat, finish or refund from that owner could otherwise revive it beside the sibling now running in its place. `ThreadLockBusyError` names a lock refused on a turn's first acquisition, the one conflict that is safe to defer. `pendingPersonQuestion` is the person's waiting questions, less any a reply has already answered; the inbox and `respond` use it, which changes nothing until replies carry `questionIds`. --- server/src/approvals/questions.ts | 34 +- server/src/work/queue.ts | 166 ++++++- ...rovals-question-resume.integration.test.ts | 51 +- server/tests/work-queue.integration.test.ts | 468 +++++++++++++++++- toolkit/src/lease.ts | 14 + toolkit/tests/lease.test.ts | 19 +- 6 files changed, 732 insertions(+), 20 deletions(-) diff --git a/server/src/approvals/questions.ts b/server/src/approvals/questions.ts index c3de0b2c4d..940fcb29ef 100644 --- a/server/src/approvals/questions.ts +++ b/server/src/approvals/questions.ts @@ -67,6 +67,29 @@ export function listedQuestion(row: { } : null; } +/** + * The person's questions still waiting on them. + * + * Unfinished, and not answered by a reply already accepted. A reply typed into a task's + * conversation answers the questions waiting there when it was taken: its queued turn names them + * in `questionIds`, and they are answered from that moment, not only once the turn has run and + * finished them. Without this an answered question would sit in the inbox until the turn ran, + * and answering it there would send a second answer. + */ +export function pendingPersonQuestion(owner: string) { + return and( + eq(workItems.kind, "person.question"), + sql`${workItems.payload}->>'actorId' = ${owner}`, + isNull(workItems.finishedAt), + sql`not exists ( + select 1 from ${workItems} accepted_answer + where accepted_answer.kind = 'proactive.task' + and accepted_answer.payload->>'ownerUserId' = ${owner} + and accepted_answer.payload->>'followup' = 'true' + and accepted_answer.payload->'questionIds' @> jsonb_build_array(${workItems.key}) + )`, + ); +} export type ApprovalQuestions = ReturnType; export function createApprovalQuestions( database: Database, @@ -83,7 +106,7 @@ export function createApprovalQuestions( const rows = await database .select() .from(workItems) - .where(and(owned(owner), isNull(workItems.finishedAt))) + .where(pendingPersonQuestion(owner)) .orderBy(desc(workItems.createdAt)) .limit(100); return rows.map(listedQuestion).filter((row) => row !== null); @@ -110,7 +133,14 @@ export function createApprovalQuestions( .for("update") .limit(1); if (!row) throw new ApprovalNotFoundError(); - if (row.finishedAt) + // Read after the lock: under read committed this statement gets a fresh snapshot, so a + // reply accepted while the lock was being waited for counts as the answer it is. + const [pending] = await tx + .select({ key: workItems.key }) + .from(workItems) + .where(and(pendingPersonQuestion(owner), eq(workItems.key, id))) + .limit(1); + if (!pending) throw new ApprovalRefusedError("You already answered this question."); await tx .insert(workItems) diff --git a/server/src/work/queue.ts b/server/src/work/queue.ts index 8676a9c127..780c9a9e7b 100644 --- a/server/src/work/queue.ts +++ b/server/src/work/queue.ts @@ -15,7 +15,18 @@ * Postgres considered expired on arrival, and the next replica to look took the item straight out * from under the first. Both then ran it. Every time this file names a moment it names it in SQL. */ -import { and, eq, gte, isNull, like, lt, or, sql } from "drizzle-orm"; +import { + and, + eq, + gt, + gte, + isNull, + like, + lt, + or, + type SQL, + sql, +} from "drizzle-orm"; import postgres from "postgres"; import type { Database } from "../db/client"; import { workItems } from "../db/schema"; @@ -68,7 +79,8 @@ export type WorkItem = { key: string; payload: Record; /** - * How many times this has been handed out, including now. + * How many times this has been handed out to run, including now. A claim given back with + * `unstarted` does not count. * * ONE MEANS IT HAS CERTAINLY NOT RUN. More than one means a previous owner stopped renewing, and * may already have called a tool or spent money before it did. A caller that cannot tell those @@ -87,6 +99,72 @@ export type WorkItem = { */ export const DEFAULT_MAX_ATTEMPTS = 5; +/** + * Work that has to run one item at a time within a group: a conversation's turns, where a second + * reply must not start while the first is still running, or overtake an older one still waiting. + * + * An item waits while another of its group holds a live lease, or while an older one is unfinished + * and still has attempts left. An older item out of attempts no longer holds the group back, or one + * failure would stop the group for good. + * + * Several kinds can share one order, each with the attempt cap its own worker claims at, which is + * how a sibling of another kind is known to be out of attempts. + */ +export type SerialOrder = { + /** Names the group's lock. The same for every kind that shares the order. */ + name: string; + /** Every kind in the order, the claimed one too, with the attempt cap its worker claims at. */ + kinds: Readonly>; + /** + * The group a row is in, as SQL over the `work_items` row named `row`. Null leaves the row in no + * group, so it runs as it would without an order. + */ + groupOf: (row: SQL) => SQL; +}; + +/** The order a payload field gives: rows of one kind with the same value run one at a time. */ +function orderByField( + kind: string, + field: string, + maxAttempts: number, +): SerialOrder { + return { + name: kind, + kinds: { [kind]: maxAttempts }, + groupOf: (row) => sql`${row}.payload->>${field}`, + }; +} + +/** The row a claim is looking at, and each other row it is weighed against. */ +const due = sql`${sql.identifier("due")}`; +const other = sql`${sql.identifier("other")}`; + +/** + * Whether the row named `due` is held back by another of its group: one that is running, or one + * that is older, unfinished and still has attempts left. Older by when each was made, so an insert + * that commits late keeps its place. + */ +function heldBack(order: SerialOrder) { + const kinds = Object.entries(order.kinds); + const cap = sql`case ${other}.kind ${sql.join( + kinds.map(([name, attempts]) => sql`when ${name} then ${attempts}::int`), + sql` `, + )} end`; + return sql`exists ( + select 1 from "work_items" as ${other} + where ${other}.kind in (${sql.join( + kinds.map(([name]) => sql`${name}`), + sql`, `, + )}) + and (${other}.kind, ${other}.key) != (${due}.kind, ${due}.key) + and ${other}.finished_at is null + and (${other}.attempts < ${cap} or ${other}.lease_until > now()) + and ${order.groupOf(other)} = ${order.groupOf(due)} + and (${other}.lease_until > now() + or (${other}.created_at, ${other}.key, ${other}.kind) < (${due}.created_at, ${due}.key, ${due}.kind)) + )`; +} + export type WorkQueue = { /** * Put work on the queue, or leave what is there. Idempotent on (kind, key). @@ -123,8 +201,16 @@ export type WorkQueue = { leaseMs: number; limit?: number; maxAttempts?: number; + /** + * Run one item at a time per group ({@link SerialOrder}). A payload field's name is the order + * of this kind alone, grouped by that field's value. + */ + serialBy?: string | SerialOrder; }) => Promise; - /** Keep a claim alive while the work runs. False means it was already taken away. */ + /** + * Keep a claim alive while the work runs. False means it expired, finished, or went to somebody + * else. + */ renew: (input: { kind: string; key: string; @@ -132,20 +218,30 @@ export type WorkQueue = { leaseMs: number; }) => Promise; /** - * Done. False means the lease had already gone to somebody else, so this was not ours to finish. + * Done. False means the lease had expired or gone to somebody else, so this was not ours to + * finish. */ finish: (input: { kind: string; key: string; owner: string; }) => Promise; - /** Not done, and worth another go after `delayMs`. False means it was no longer ours. */ + /** + * Not done, and worth another go after `delayMs`. False means the lease had expired or it was no + * longer ours. + */ release: (input: { kind: string; key: string; owner: string; delayMs: number; reason?: string; + /** + * The work never began, so the attempt this claim spent is given back. Only for a refusal + * before anything ran, such as the conversation's lock being held when the turn first asked + * for it: anything later may already have had an effect, and must count. + */ + unstarted?: boolean; }) => Promise; /** * Drop what is done with, older than the retention window. Returns how many went. @@ -191,6 +287,12 @@ export function createWorkQueue(database: Database): WorkQueue { * `finish` and `release` used to match on the key alone, so a replica whose lease had quietly * expired could delete or reschedule an item another replica was in the middle of executing. Only * `renew` got this right; all three ask the same question now. + * + * AND STILL LIVE, on the database's clock as `claim` reads it. The owner alone is not enough: a + * lease that expired on its last attempt is claimed by nobody, so it keeps its owner, while a + * serialised sibling ({@link SerialOrder}) is free to start in its place. A late heartbeat would + * revive it beside that sibling, a late finish or release would write its outcome over the + * sibling's, and a refund would hand it back an attempt it spent. */ const ours = (kind: string, key: string, owner: string) => and( @@ -198,6 +300,7 @@ export function createWorkQueue(database: Database): WorkQueue { eq(workItems.key, key), eq(workItems.claimedBy, owner), isNull(workItems.finishedAt), + gt(workItems.leaseUntil, sql`now()`), ); return { @@ -271,36 +374,68 @@ export function createWorkQueue(database: Database): WorkQueue { leaseMs, limit = 1, maxAttempts = DEFAULT_MAX_ATTEMPTS, + serialBy, }) { + const order = + typeof serialBy === "string" + ? orderByField(kind, serialBy, maxAttempts) + : serialBy; return database.transaction(async (transaction) => { /* * `skip locked` is what makes this concurrent rather than merely correct. Without it a * second replica blocks on the first replica's rows and the queue serialises; with it, it * walks past them and takes the next free ones. + * + * A row its group holds back is left out here, before the limit, rather than taken and + * dropped below: an older insert that committed late would otherwise fill the only slot on + * every sweep and starve every other group. */ - const due = await transaction.execute(sql` - select "kind", "key" - from "work_items" + const selected = await transaction.execute(sql` + select "kind", "key", ${order ? order.groupOf(due) : sql`null`} as "group" + from "work_items" as ${due} where "kind" = ${kind} and "finished_at" is null and "attempts" < ${maxAttempts} and "run_at" <= now() and ("lease_until" is null or "lease_until" <= now()) - order by "run_at" asc + ${order ? sql`and not ${heldBack(order)}` : sql``} + order by "run_at" asc, "created_at" asc, "key" asc limit ${limit} for update skip locked `); const rows = ( - Array.isArray(due) ? due : ((due as { rows?: unknown[] })?.rows ?? []) + Array.isArray(selected) + ? selected + : ((selected as { rows?: unknown[] })?.rows ?? []) ) as { kind: string; key: string; + group: string | null; }[]; if (rows.length === 0) return []; const claimed: WorkItem[] = []; for (const row of rows) { + if (order && row.group !== null) { + /* + * Asked again, one claimer per group at a time. The select above read one snapshot, and + * a sibling inserted or claimed since is not in it, so two replicas could each find the + * group free and each take a row of it. The lock makes them take turns and the check + * after it reads a fresh snapshot. A group another replica is claiming right now is + * skipped rather than waited for, as `skip locked` skips its rows. + */ + const [lock] = await transaction.execute<{ locked: boolean }>(sql` + select pg_try_advisory_xact_lock(hashtext(${order.name}), hashtext(${row.group})) as "locked" + `); + if (!lock?.locked) continue; + const [check] = await transaction.execute<{ blocked: boolean }>(sql` + select ${heldBack(order)} as "blocked" + from "work_items" as ${due} + where ${due}.kind = ${kind} and ${due}.key = ${row.key} + `); + if (check?.blocked !== false) continue; + } const [updated] = await transaction .update(workItems) .set({ @@ -336,9 +471,9 @@ export function createWorkQueue(database: Database): WorkQueue { .update(workItems) .set({ leaseUntil: fromNow(leaseMs), updatedAt: sql`now()` }) /* - * Only while still ours. A lease that expired and was taken by somebody else must not be - * renewed back out from under them, which would put two replicas on one item believing they - * each held it. + * Only while still ours and still live. A lease that expired and was taken by somebody else + * must not be renewed back out from under them, which would put two replicas on one item + * believing they each held it. */ .where(ours(kind, key, owner)) .returning({ key: workItems.key }); @@ -364,7 +499,7 @@ export function createWorkQueue(database: Database): WorkQueue { return Boolean(finished); }, - async release({ kind, key, owner, delayMs, reason }) { + async release({ kind, key, owner, delayMs, reason, unstarted }) { // Freed and pushed out, rather than finished: the work still wants doing, just not immediately // and not by whoever just gave up on it. The reason stays on the row so an item that runs out // of attempts says why rather than simply stopping. @@ -376,6 +511,9 @@ export function createWorkQueue(database: Database): WorkQueue { runAt: fromNow(delayMs), updatedAt: sql`now()`, ...(reason === undefined ? {} : { lastError: reason }), + // Given back once: clearing the owner in the same write means a second release, or a + // stale owner's, matches nothing and refunds nothing. + ...(unstarted ? { attempts: sql`${workItems.attempts} - 1` } : {}), }) .where(ours(kind, key, owner)) .returning({ key: workItems.key }); diff --git a/server/tests/approvals-question-resume.integration.test.ts b/server/tests/approvals-question-resume.integration.test.ts index 62546dffbb..06c7e91a3c 100644 --- a/server/tests/approvals-question-resume.integration.test.ts +++ b/server/tests/approvals-question-resume.integration.test.ts @@ -44,7 +44,11 @@ afterAll(async () => { .delete(workItems) .where( and( - inArray(workItems.kind, ["person.question", "person.response"]), + inArray(workItems.kind, [ + "person.question", + "person.response", + "proactive.task", + ]), inArray(workItems.key, keys), ), ); @@ -105,6 +109,51 @@ test("the question's initiator survives being asked and answered", async () => { }); }); +/* + * A reply typed into the conversation answers what was waiting there when it was accepted, before + * its turn has run. The inbox must not offer the same question again, or answering it there would + * send a second answer to the same thing. + */ +test("a question a reply already answered is neither listed nor answered again", async () => { + const answered = randomUUID(); + const waiting = randomUUID(); + const reply = randomUUID(); + const theirs = randomUUID(); + keys.push(answered, waiting, reply, theirs); + await database.insert(workItems).values([ + { kind: "person.question", key: answered, payload: question() }, + { kind: "person.question", key: waiting, payload: question() }, + { + kind: "proactive.task", + key: reply, + payload: { ownerUserId: owner, followup: true, questionIds: [answered] }, + }, + // Somebody else's reply naming the waiting question answers nothing of this person's. + { + kind: "proactive.task", + key: theirs, + payload: { + ownerUserId: `${prefix}-other`, + followup: true, + questionIds: [waiting], + }, + }, + ]); + const listed = (await questions.list(owner)).map((entry) => entry.id); + expect(listed).toContain(waiting); + expect(listed).not.toContain(answered); + await expect(questions.respond(owner, answered, "Ramen.")).rejects.toThrow( + "You already answered this question.", + ); + const responses = await database + .select({ key: workItems.key }) + .from(workItems) + .where( + and(eq(workItems.kind, "person.response"), eq(workItems.key, answered)), + ); + expect(responses).toEqual([]); +}); + test("an answer to a responsibility's question resumes it with its own tools, run and initiator", async () => { const recorded: unknown[] = []; const store = { diff --git a/server/tests/work-queue.integration.test.ts b/server/tests/work-queue.integration.test.ts index cb0cef9843..ffcb6114cb 100644 --- a/server/tests/work-queue.integration.test.ts +++ b/server/tests/work-queue.integration.test.ts @@ -1,9 +1,9 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { createDatabase } from "../src/db/client"; import { workItems } from "../src/db/schema"; -import { createWorkQueue } from "../src/work/queue"; +import { createWorkQueue, type SerialOrder } from "../src/work/queue"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; /** @@ -27,6 +27,20 @@ beforeEach(async () => { await database.delete(workItems).where(eq(workItems.kind, kind)); }); +async function liveKeys(taskId: string) { + const rows = await database + .select({ key: workItems.key }) + .from(workItems) + .where( + and( + eq(workItems.kind, kind), + sql`${workItems.payload}->>'taskId' = ${taskId}`, + sql`${workItems.finishedAt} is null and ${workItems.leaseUntil} > now()`, + ), + ); + return rows.map((row) => row.key); +} + describe("claiming durable work", () => { test("one replica takes a due item, and it comes back with what it is about", async () => { await queue.offer({ kind, key: "bot-a", payload: { botId: "bot-a" } }); @@ -254,6 +268,31 @@ describe("claiming durable work", () => { expect(row?.by).toBe("replica-2"); }); + /* + * Nobody else has it yet, and it is still not the old owner's: on its last attempt nothing will + * claim it again, so a late heartbeat, finish or release is all that could still change the row, + * and a serialised sibling may already be running in its place. + */ + test("a lease that ran out is not its owner's to renew, finish or release", async () => { + await queue.offer({ kind, key: "bot-a" }); + await queue.claim({ kind, owner: "replica-1", leaseMs: -1 }); + + const mine = { kind, key: "bot-a", owner: "replica-1" }; + expect(await queue.renew({ ...mine, leaseMs: 30_000 })).toBe(false); + expect(await queue.finish(mine)).toBe(false); + expect(await queue.release({ ...mine, delayMs: 0 })).toBe(false); + + const [row] = await database + .select() + .from(workItems) + .where(and(eq(workItems.kind, kind), eq(workItems.key, "bot-a"))); + expect(row).toMatchObject({ + claimedBy: "replica-1", + attempts: 1, + finishedAt: null, + }); + }); + /* * Two clocks pretending to be one. * @@ -431,3 +470,428 @@ describe("offering at most so many under one prefix", () => { expect(results.every((result) => result === "queued")).toBe(true); }); }); + +describe("one at a time in a group", () => { + test("task replies survive a worker restart and concurrent claims keep each task in order", async () => { + for (const key of ["initial", "reply-1", "reply-2"]) + await queue.offer({ + kind, + key, + payload: { taskId: "task-a", text: key }, + }); + const [a, b] = await Promise.all([ + queue.claim({ kind, owner: "a", leaseMs: 30_000, serialBy: "taskId" }), + queue.claim({ kind, owner: "b", leaseMs: 30_000, serialBy: "taskId" }), + ]); + expect([...a, ...b].map((item) => item.key)).toEqual(["initial"]); + const owner = a.length ? "a" : "b"; + await queue.finish({ kind, key: "initial", owner }); + // New process, same durable queue: no browser or prior worker keeps the replies alive. + const restarted = createWorkQueue(database); + for (const key of ["reply-1", "reply-2"]) { + const [next] = await restarted.claim({ + kind, + owner: "restart", + leaseMs: 30_000, + serialBy: "taskId", + }); + expect(next?.key).toBe(key); + expect(next?.payload.text).toBe(key); + expect( + await queue.claim({ + kind, + owner: "other", + leaseMs: 30_000, + serialBy: "taskId", + }), + ).toEqual([]); + await restarted.finish({ kind, key, owner: "restart" }); + } + }); + + test("a delayed older insertion cannot overlap a newer reply already running", async () => { + await queue.offer({ kind, key: "newer", payload: { taskId: "same-task" } }); + expect( + await queue.claim({ + kind, + owner: "first", + leaseMs: 30_000, + serialBy: "taskId", + }), + ).toHaveLength(1); + // An insert transaction can start earlier and commit after the newer row was claimed. + await database.insert(workItems).values({ + kind, + key: "older", + payload: { taskId: "same-task" }, + createdAt: new Date(Date.now() - 10_000), + }); + await queue.offer({ + kind, + key: "unrelated", + payload: { taskId: "another-task" }, + }); + // The blocked older row must not consume the only slot and starve another task. + expect( + ( + await queue.claim({ + kind, + owner: "second", + leaseMs: 30_000, + limit: 1, + serialBy: "taskId", + }) + ).map((item) => item.key), + ).toEqual(["unrelated"]); + expect( + await queue.claim({ + kind, + owner: "second", + leaseMs: 30_000, + serialBy: "taskId", + }), + ).toEqual([]); + await queue.finish({ kind, key: "newer", owner: "first" }); + expect( + ( + await queue.claim({ + kind, + owner: "second", + leaseMs: 30_000, + serialBy: "taskId", + }) + )[0]?.key, + ).toBe("older"); + }); + + test("an expired final-attempt lease cannot revive after its next sibling starts", async () => { + const claim = { kind, leaseMs: 30_000, maxAttempts: 2, serialBy: "taskId" }; + await queue.offer({ kind, key: "first", payload: { taskId: "same-task" } }); + await queue.offer({ kind, key: "next", payload: { taskId: "same-task" } }); + await queue.claim({ ...claim, owner: "original" }); + await queue.release({ kind, key: "first", owner: "original", delayMs: 0 }); + expect(await queue.claim({ ...claim, owner: "original" })).toMatchObject([ + { key: "first", attempts: 2 }, + ]); + expect(await liveKeys("same-task")).toEqual(["first"]); + expect(await queue.claim({ ...claim, owner: "replacement" })).toEqual([]); + expect( + await queue.renew({ kind, key: "first", owner: "original", leaseMs: -1 }), + ).toBe(true); + expect(await liveKeys("same-task")).toEqual([]); + expect(await queue.claim({ ...claim, owner: "replacement" })).toMatchObject( + [{ key: "next", attempts: 1 }], + ); + expect( + await queue.renew({ + kind, + key: "first", + owner: "original", + leaseMs: 30_000, + }), + ).toBe(false); + expect(await liveKeys("same-task")).toEqual(["next"]); + const [expired] = await database + .select() + .from(workItems) + .where(and(eq(workItems.kind, kind), eq(workItems.key, "first"))); + expect(expired).toMatchObject({ + claimedBy: "original", + attempts: 2, + finishedAt: null, + }); + const [next] = await database + .select() + .from(workItems) + .where(and(eq(workItems.kind, kind), eq(workItems.key, "next"))); + await queue.release({ + kind, + key: "first", + owner: "original", + delayMs: 0, + unstarted: true, + }); + const afterRefund = await database + .select() + .from(workItems) + .where(eq(workItems.kind, kind)); + expect(afterRefund.find((row) => row.key === "first")).toEqual(expired); + expect(afterRefund.find((row) => row.key === "next")).toEqual(next); + await queue.finish({ kind, key: "next", owner: "replacement" }); + expect(await queue.claim({ ...claim, owner: "later" })).toEqual([]); + }); + + test.each([1, 2])( + "an expired newer sibling lease allows the delayed older reply at maxAttempts %i", + async (maxAttempts) => { + await queue.offer({ + kind, + key: "newer", + payload: { taskId: "same-task" }, + }); + await queue.claim({ + kind, + owner: "first", + leaseMs: 30_000, + maxAttempts, + serialBy: "taskId", + }); + await database.insert(workItems).values({ + kind, + key: "older", + payload: { taskId: "same-task" }, + createdAt: new Date(Date.now() - 10_000), + }); + // A live sibling blocks even on its last attempt, regardless of insertion order. + expect( + await queue.claim({ + kind, + owner: "second", + leaseMs: 30_000, + limit: 1, + maxAttempts, + serialBy: "taskId", + }), + ).toEqual([]); + await queue.renew({ kind, key: "newer", owner: "first", leaseMs: -1 }); + expect( + ( + await queue.claim({ + kind, + owner: "second", + leaseMs: 30_000, + limit: 1, + maxAttempts, + serialBy: "taskId", + }) + ).map((item) => item.key), + ).toEqual(["older"]); + expect(await liveKeys("same-task")).toEqual(["older"]); + expect( + await queue.renew({ + kind, + key: "newer", + owner: "first", + leaseMs: 30_000, + }), + ).toBe(false); + expect(await liveKeys("same-task")).toEqual(["older"]); + expect( + await queue.renew({ + kind, + key: "older", + owner: "second", + leaseMs: 30_000, + }), + ).toBe(true); + expect(await liveKeys("same-task")).toEqual(["older"]); + }, + ); + + /* + * Two kinds in one order, as a conversation's own turns and the answers to questions asked in it + * can share one: each kind finds its group its own way, an older row of either kind keeps its + * place, and a row out of attempts by its own kind's cap stops holding the group. + */ + test("kinds that share an order take turns by age, each by its own attempt cap", async () => { + const answers = `${kind}.answer`; + const order: SerialOrder = { + name: kind, + kinds: { [kind]: 2, [answers]: 1 }, + groupOf: (row) => + sql`case when ${row}.kind = ${answers} then ${row}.payload->'question'->>'taskId' else ${row}.payload->>'taskId' end`, + }; + const keys = (items: { key: string }[]) => items.map((item) => item.key); + const claimTurn = () => + queue.claim({ + kind, + owner: "worker", + leaseMs: 30_000, + maxAttempts: 2, + serialBy: order, + }); + const claimAnswer = () => + queue.claim({ + kind: answers, + owner: "answerer", + leaseMs: 30_000, + maxAttempts: 1, + serialBy: order, + }); + const answer = (key: string) => + queue.offer({ + kind: answers, + key, + payload: { question: { taskId: "same-task" } }, + }); + try { + await answer("answer"); + await queue.offer({ + kind, + key: "turn", + payload: { taskId: "same-task" }, + }); + await queue.offer({ + kind, + key: "elsewhere", + payload: { taskId: "another-task" }, + }); + // The older answer holds the turn back, and holds back nothing else. + expect(keys(await claimTurn())).toEqual(["elsewhere"]); + expect(await claimTurn()).toEqual([]); + // Running, it still does. + expect(keys(await claimAnswer())).toEqual(["answer"]); + expect(await claimTurn()).toEqual([]); + // Its one attempt failed. Out of attempts, it no longer holds the group. + await queue.release({ + kind: answers, + key: "answer", + owner: "answerer", + delayMs: 0, + reason: "fetch failed", + }); + expect(keys(await claimTurn())).toEqual(["turn"]); + // A running turn holds back an answer that arrives behind it, until it finishes. + await answer("later-answer"); + expect(await claimAnswer()).toEqual([]); + await queue.finish({ kind, key: "turn", owner: "worker" }); + expect(keys(await claimAnswer())).toEqual(["later-answer"]); + } finally { + await database.delete(workItems).where(eq(workItems.kind, answers)); + } + }); +}); + +describe("giving back a claim that never started", () => { + test("unstarted claims defer without spending attempts, and only their owner can refund once", async () => { + await queue.offer({ kind, key: "busy", payload: { messageId: "reply-1" } }); + for (let conflict = 0; conflict < 4; conflict++) { + const [item] = await queue.claim({ + kind, + owner: "worker", + leaseMs: 30_000, + maxAttempts: 2, + }); + expect(item).toMatchObject({ + attempts: 1, + payload: { messageId: "reply-1" }, + }); + expect( + await queue.release({ + kind, + key: "busy", + owner: "stale-worker", + delayMs: 0, + unstarted: true, + }), + ).toBe(false); + expect( + await queue.release({ + kind, + key: "busy", + owner: "worker", + delayMs: 0, + unstarted: true, + }), + ).toBe(true); + expect( + await queue.release({ + kind, + key: "busy", + owner: "worker", + delayMs: 0, + unstarted: true, + }), + ).toBe(false); + } + const [delivered] = await queue.claim({ + kind, + owner: "worker", + leaseMs: 30_000, + maxAttempts: 2, + }); + expect(delivered?.attempts).toBe(1); + await queue.release({ + kind, + key: "busy", + owner: "worker", + delayMs: 0, + reason: "provider failed", + }); + const [retry] = await queue.claim({ + kind, + owner: "worker", + leaseMs: 30_000, + maxAttempts: 2, + }); + expect(retry?.attempts).toBe(2); + await queue.release({ + kind, + key: "busy", + owner: "worker", + delayMs: 0, + unstarted: true, + }); + const [deferred] = await database + .select() + .from(workItems) + .where(and(eq(workItems.kind, kind), eq(workItems.key, "busy"))); + expect(deferred).toMatchObject({ + attempts: 1, + lastError: "provider failed", + }); + await queue.claim({ + kind, + owner: "worker", + leaseMs: 30_000, + maxAttempts: 2, + }); + await queue.release({ + kind, + key: "busy", + owner: "worker", + delayMs: 0, + reason: "provider failed again", + }); + expect( + await queue.claim({ + kind, + owner: "worker", + leaseMs: 30_000, + maxAttempts: 2, + }), + ).toEqual([]); + }); + + test("a deferred claim keeps its backoff and cannot refund a replacement owner's attempt", async () => { + await queue.offer({ kind, key: "replacement" }); + await queue.claim({ kind, owner: "old-worker", leaseMs: -1 }); + await queue.claim({ kind, owner: "new-worker", leaseMs: 30_000 }); + expect( + await queue.release({ + kind, + key: "replacement", + owner: "old-worker", + delayMs: 0, + unstarted: true, + }), + ).toBe(false); + const [owned] = await database + .select() + .from(workItems) + .where(and(eq(workItems.kind, kind), eq(workItems.key, "replacement"))); + expect(owned).toMatchObject({ claimedBy: "new-worker", attempts: 2 }); + expect( + await queue.release({ + kind, + key: "replacement", + owner: "new-worker", + delayMs: 60_000, + unstarted: true, + }), + ).toBe(true); + expect( + await queue.claim({ kind, owner: "next-worker", leaseMs: 30_000 }), + ).toEqual([]); + }); +}); diff --git a/toolkit/src/lease.ts b/toolkit/src/lease.ts index 4b1db0c2e2..a4754e0339 100644 --- a/toolkit/src/lease.ts +++ b/toolkit/src/lease.ts @@ -22,6 +22,20 @@ export function isLockConflict(error: unknown): boolean { ); } +/** + * The lock was refused when the turn first asked for it, so the turn has not run. + * + * The same 409 from the platform later on can follow a tool call or a model's spend; only this one + * is safe to put back on the queue without counting it as an attempt. The platform's error is kept + * as the cause. + */ +export class ThreadLockBusyError extends Error { + override name = "ThreadLockBusyError"; + constructor(cause: unknown) { + super("The conversation is busy with another turn.", { cause }); + } +} + /** * No renewal has succeeded for nearly as long as the lock lives, so it will have lapsed before the * next one could land, whatever the reason was. diff --git a/toolkit/tests/lease.test.ts b/toolkit/tests/lease.test.ts index a86172000f..d344ebfd8f 100644 --- a/toolkit/tests/lease.test.ts +++ b/toolkit/tests/lease.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { isLockConflict, keepLease, LockLapsedError } from "../src/lease"; +import { + isLockConflict, + keepLease, + LockLapsedError, + ThreadLockBusyError, +} from "../src/lease"; function platformError(status: number): Error { const error = new Error(`Intelligence platform error ${status}`); @@ -24,6 +29,18 @@ describe("isLockConflict", () => { }); }); +describe("ThreadLockBusyError", () => { + test("names a refused first acquisition and keeps the platform's answer as its cause", () => { + const conflict = platformError(409); + const busy = new ThreadLockBusyError(conflict); + expect(busy).toBeInstanceOf(Error); + expect(busy.name).toBe("ThreadLockBusyError"); + expect(busy.cause).toBe(conflict); + // Not the platform's error itself, so nothing that stops on a lost lock mistakes it for one. + expect(isLockConflict(busy)).toBe(false); + }); +}); + describe("keepLease", () => { test("renews until stopped", async () => { let renewals = 0; From 79c07f6d3c6b1815f6c4fdafea1325808e7bb957 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 14:09:04 -0700 Subject: [PATCH 02/18] feat(tasks): a table and store for tasks A task is background work a Bot does in a conversation of its own. This adds where tasks are kept and how they are read; nothing opens one yet. The `tasks` table holds what only the person or the task's start can say: its Bot, its conversation, its title and why, where it came from, and `resolved_at`. There is no status column. Every read works the status out from the ledgers that already know it: the task's turns on the work queue, approvals waiting in the inbox, questions its Bot asked, and the last thing said in its conversation. A turn runs at most twice; one out of runs reads as failed, with the reason in plain words. `/api/tasks` serves the person's own tasks: `GET /`, `GET /:id`, and `POST /:id/resolve` and `/reopen` for Mark done and Reopen. Another person's task is the same as no task. Migration 0070 creates the table. --- server/drizzle.config.ts | 1 + server/drizzle/0070_tasks.sql | 24 + server/drizzle/meta/0070_snapshot.json | 10935 +++++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/app.ts | 6 + server/src/db/schema/index.ts | 1 + server/src/db/schema/tasks.ts | 67 + server/src/index.ts | 7 + server/src/tasks/reply-ending.ts | 85 + server/src/tasks/routes.ts | 46 + server/src/tasks/store.ts | 581 ++ server/src/tasks/types.ts | 74 + server/tests/tasks.integration.test.ts | 741 ++ server/tests/tasks.test.ts | 164 + 14 files changed, 12739 insertions(+) create mode 100644 server/drizzle/0070_tasks.sql create mode 100644 server/drizzle/meta/0070_snapshot.json create mode 100644 server/src/db/schema/tasks.ts create mode 100644 server/src/tasks/reply-ending.ts create mode 100644 server/src/tasks/routes.ts create mode 100644 server/src/tasks/store.ts create mode 100644 server/src/tasks/types.ts create mode 100644 server/tests/tasks.integration.test.ts create mode 100644 server/tests/tasks.test.ts diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts index 12a00a4d4b..1ec31e7433 100644 --- a/server/drizzle.config.ts +++ b/server/drizzle.config.ts @@ -42,6 +42,7 @@ export default defineConfig({ "./src/db/schema/shared-use.ts", "./src/db/schema/identity.ts", "./src/db/schema/recorder.ts", + "./src/db/schema/tasks.ts", ], out: "./drizzle", dbCredentials: { diff --git a/server/drizzle/0070_tasks.sql b/server/drizzle/0070_tasks.sql new file mode 100644 index 0000000000..267c1330fd --- /dev/null +++ b/server/drizzle/0070_tasks.sql @@ -0,0 +1,24 @@ +CREATE TABLE "tasks" ( + "id" text PRIMARY KEY NOT NULL, + "owner_user_id" text NOT NULL, + "bot_id" text NOT NULL, + "channel_id" text NOT NULL, + "title" text NOT NULL, + "why" text, + "origin" text NOT NULL, + "suggestion_id" text, + "source_app" text, + "source_link" text, + "resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "tasks_origin_check" CHECK ("tasks"."origin" IN ('asked', 'suggestion')) +); +--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_bot_id_agents_id_fk" FOREIGN KEY ("bot_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_channel_id_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_suggestion_id_proactive_suggestions_id_fk" FOREIGN KEY ("suggestion_id") REFERENCES "public"."proactive_suggestions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "tasks_owner_created_idx" ON "tasks" USING btree ("owner_user_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "tasks_channel_idx" ON "tasks" USING btree ("channel_id");--> statement-breakpoint +CREATE UNIQUE INDEX "tasks_suggestion_idx" ON "tasks" USING btree ("suggestion_id"); \ No newline at end of file diff --git a/server/drizzle/meta/0070_snapshot.json b/server/drizzle/meta/0070_snapshot.json new file mode 100644 index 0000000000..ff856b6548 --- /dev/null +++ b/server/drizzle/meta/0070_snapshot.json @@ -0,0 +1,10935 @@ +{ + "id": "3702aa5e-a48c-4a42-af38-8c66a3d8b489", + "prevId": "ab8a3370-f719-40eb-9066-b19d682c8e6a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "isolated": { + "name": "isolated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_access_requests": { + "name": "app_access_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_key": { + "name": "app_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_title": { + "name": "app_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "app_access_requests_pending_idx": { + "name": "app_access_requests_pending_idx", + "columns": [ + { + "expression": "requested_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"agent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"app_access_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "app_access_requests_status_idx": { + "name": "app_access_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_access_requests_requested_by_users_id_fk": { + "name": "app_access_requests_requested_by_users_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_access_requests_agent_id_agents_id_fk": { + "name": "app_access_requests_agent_id_agents_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_access_requests_kind_check": { + "name": "app_access_requests_kind_check", + "value": "\"app_access_requests\".\"kind\" IN ('enable', 'grant', 'connect_shared')" + }, + "app_access_requests_status_check": { + "name": "app_access_requests_status_check", + "value": "\"app_access_requests\".\"status\" IN ('pending', 'approved', 'dismissed', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_grants": { + "name": "owner_grants", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skipped_tools": { + "name": "skipped_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + }, + "mcp_servers_owner_grants_check": { + "name": "mcp_servers_owner_grants_check", + "value": "\"mcp_servers\".\"owner_grants\" IS NULL OR \"mcp_servers\".\"owner_grants\" IN ('any', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor_name": { + "name": "vendor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pending_authorizations_server_idx": { + "name": "pending_authorizations_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_authorizations_actor_id_users_id_fk": { + "name": "pending_authorizations_actor_id_users_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_authorizations_holder_check": { + "name": "pending_authorizations_holder_check", + "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)" + }, + "pending_authorizations_outcome_check": { + "name": "pending_authorizations_outcome_check", + "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')" + }, + "pending_authorizations_completion_check": { + "name": "pending_authorizations_completion_check", + "value": "(\"pending_authorizations\".\"completed_at\" IS NULL) = (\"pending_authorizations\".\"outcome\" IS NULL)" + }, + "pending_authorizations_user_is_actor_check": { + "name": "pending_authorizations_user_is_actor_check", + "value": "\"pending_authorizations\".\"user_id\" IS NULL OR \"pending_authorizations\".\"user_id\" = \"pending_authorizations\".\"actor_id\"" + } + }, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_as": { + "name": "granted_as", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "plugin_grants_granted_as_check": { + "name": "plugin_grants_granted_as_check", + "value": "\"plugin_grants\".\"granted_as\" IS NULL OR \"plugin_grants\".\"granted_as\" IN ('admin', 'owner')" + } + }, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_open_thread_idx": { + "name": "work_items_open_thread_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'threadId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"work_items\".\"finished_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_open_skills": { + "name": "learning_open_skills", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "skill_name": { + "name": "skill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "learning_open_skills_user_id_users_id_fk": { + "name": "learning_open_skills_user_id_users_id_fk", + "tableFrom": "learning_open_skills", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "learning_open_skills_user_id_skill_name_pk": { + "name": "learning_open_skills_user_id_skill_name_pk", + "columns": [ + "user_id", + "skill_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_person_spaces": { + "name": "learning_person_spaces", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "learning_person_spaces_user_id_users_id_fk": { + "name": "learning_person_spaces_user_id_users_id_fk", + "tableFrom": "learning_person_spaces", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "learning_person_spaces_container_id_unique": { + "name": "learning_person_spaces_container_id_unique", + "nullsNotDistinct": false, + "columns": [ + "container_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_skill_sharing": { + "name": "learning_skill_sharing", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'closed'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "learning_skill_sharing_open_idx": { + "name": "learning_skill_sharing_open_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"learning_skill_sharing\".\"mode\" <> 'closed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "learning_skill_sharing_user_id_users_id_fk": { + "name": "learning_skill_sharing_user_id_users_id_fk", + "tableFrom": "learning_skill_sharing", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "learning_skill_sharing_mode_check": { + "name": "learning_skill_sharing_mode_check", + "value": "\"learning_skill_sharing\".\"mode\" IN ('closed', 'open', 'choose')" + } + }, + "isRLSEnabled": false + }, + "public.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_as": { + "name": "approved_as", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_approvals_approved_as_check": { + "name": "shared_use_approvals_approved_as_check", + "value": "\"shared_use_approvals\".\"approved_as\" IS NULL OR \"shared_use_approvals\".\"approved_as\" IN ('admin', 'owner')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + }, + "public.recorder_connect_codes": { + "name": "recorder_connect_codes", + "schema": "", + "columns": { + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "via": { + "name": "via", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_session": { + "name": "organization_session", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_session_expires_at": { + "name": "organization_session_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "recorder_connect_codes_user_idx": { + "name": "recorder_connect_codes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_connect_codes_user_id_users_id_fk": { + "name": "recorder_connect_codes_user_id_users_id_fk", + "tableFrom": "recorder_connect_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recorder_connections": { + "name": "recorder_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "via": { + "name": "via", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "install_id": { + "name": "install_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_session": { + "name": "organization_session", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_session_expires_at": { + "name": "organization_session_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activity": { + "name": "activity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "activity_at": { + "name": "activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "recorder_connections_user_idx": { + "name": "recorder_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recorder_connections_live_install_idx": { + "name": "recorder_connections_live_install_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "install_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"recorder_connections\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_connections_user_id_users_id_fk": { + "name": "recorder_connections_user_id_users_id_fk", + "tableFrom": "recorder_connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "recorder_connections_token_hash_unique": { + "name": "recorder_connections_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recorder_trajectory_bindings": { + "name": "recorder_trajectory_bindings", + "schema": "", + "columns": { + "trajectory_id": { + "name": "trajectory_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recorder_trajectory_bindings_user_idx": { + "name": "recorder_trajectory_bindings_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_trajectory_bindings_user_id_users_id_fk": { + "name": "recorder_trajectory_bindings_user_id_users_id_fk", + "tableFrom": "recorder_trajectory_bindings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "why": { + "name": "why", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggestion_id": { + "name": "suggestion_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_owner_created_idx": { + "name": "tasks_owner_created_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_channel_idx": { + "name": "tasks_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_suggestion_idx": { + "name": "tasks_suggestion_idx", + "columns": [ + { + "expression": "suggestion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_owner_user_id_users_id_fk": { + "name": "tasks_owner_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_bot_id_agents_id_fk": { + "name": "tasks_bot_id_agents_id_fk", + "tableFrom": "tasks", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_channel_id_channels_id_fk": { + "name": "tasks_channel_id_channels_id_fk", + "tableFrom": "tasks", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_suggestion_id_proactive_suggestions_id_fk": { + "name": "tasks_suggestion_id_proactive_suggestions_id_fk", + "tableFrom": "tasks", + "tableTo": "proactive_suggestions", + "columnsFrom": [ + "suggestion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tasks_origin_check": { + "name": "tasks_origin_check", + "value": "\"tasks\".\"origin\" IN ('asked', 'suggestion')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index ce418593e1..4759b5cbd4 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -491,6 +491,13 @@ "when": 1793224884828, "tag": "0069_isolated_bots", "breakpoints": true + }, + { + "idx": 70, + "version": "7", + "when": 1793312990622, + "tag": "0070_tasks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/app.ts b/server/src/app.ts index 2cdf12f37a..5a4f70ba26 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -143,6 +143,8 @@ import type { IntentRouter } from "./routing/classify"; import { createRoutingRoutes } from "./routing/routes"; import { createCoworkerRoutingService } from "./routing/service"; import type { SelfHostBanner } from "./self-host-banner"; +import { createTaskRoutes } from "./tasks/routes"; +import type { TaskStore } from "./tasks/store"; import { createTeamBotRoutes } from "./team-bots/routes"; import type { TeamBots } from "./team-bots/team-bots"; import type { PackageStatusReader } from "./tenant-package"; @@ -445,6 +447,8 @@ export function createApp( }; memory?: { store: MemoryStore; ingestion: MemoryIngestion }; proactive?: { store: ProactiveStore; engine: ProactiveEngine }; + /** A person's tasks, each in a conversation of its own. See tasks/store.ts. */ + tasks?: TaskStore; responsibilities?: { store: ResponsibilityStore; engine: ResponsibilityEngine; @@ -1719,6 +1723,8 @@ export function createApp( requireUser, ), ); + if (coworker?.tasks) + app.route("/api/tasks", createTaskRoutes(coworker.tasks, requireUser)); if (coworker?.memory) app.route( "/api/memory", diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts index 94f06ced41..53bbf55983 100644 --- a/server/src/db/schema/index.ts +++ b/server/src/db/schema/index.ts @@ -21,6 +21,7 @@ export * from "./recorder"; export * from "./responsibilities"; export * from "./scim"; export * from "./shared-use"; +export * from "./tasks"; export * from "./team-bots"; export * from "./voice"; export * from "./work"; diff --git a/server/src/db/schema/tasks.ts b/server/src/db/schema/tasks.ts new file mode 100644 index 0000000000..bf4af72c2c --- /dev/null +++ b/server/src/db/schema/tasks.ts @@ -0,0 +1,67 @@ +import { sql } from "drizzle-orm"; +import { + check, + index, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { TaskOrigin } from "../../tasks/types"; +import { agents, channels, users } from "./core"; +import { proactiveSuggestions } from "./proactive"; + +/** + * Work a Bot does for a person in the background, each in a conversation of its own, so the + * conversation it was asked in stays one thread. Read by Tasks and the task page (tasks/store.ts). + * + * NO STATUS COLUMN. Whether a task is working, waiting on the person or ready to read is worked out + * on every read from the ledgers that already say so: the work queue, the approval inbox and the + * questions a Bot asked. A copy here would drift from them the first time one changed without it. + * Only the person's own "done" is stored, as `resolved_at`. + * + * `channel_id` is the task's own conversation, made with it, one task per conversation, and the + * task goes with it. `suggestion_id` is the suggestion it was started from: a suggestion is started + * once, so it opens one task, and the task outlives the suggestion. + */ +export const tasks = pgTable( + "tasks", + { + id: text("id").primaryKey(), + ownerUserId: text("owner_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + botId: text("bot_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + channelId: text("channel_id") + .notNull() + .references(() => channels.id, { onDelete: "cascade" }), + title: text("title").notNull(), + why: text("why"), + origin: text("origin").$type().notNull(), + suggestionId: text("suggestion_id").references( + () => proactiveSuggestions.id, + { onDelete: "set null" }, + ), + sourceApp: text("source_app"), + sourceLink: text("source_link"), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("tasks_owner_created_idx").on(table.ownerUserId, table.createdAt), + uniqueIndex("tasks_channel_idx").on(table.channelId), + // Postgres keeps any number of nulls apart, so tasks asked for directly never collide. + uniqueIndex("tasks_suggestion_idx").on(table.suggestionId), + check( + "tasks_origin_check", + sql`${table.origin} IN ('asked', 'suggestion')`, + ), + ], +); diff --git a/server/src/index.ts b/server/src/index.ts index e33d2951f2..fd1a3f1536 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -282,6 +282,7 @@ import { createRoutineStore } from "./routines/store"; import { createIntentRouter } from "./routing/classify"; import { createModelCompleter } from "./routing/model"; import { createSelfHostBanner } from "./self-host-banner"; +import { createTaskStore } from "./tasks/store"; import { createTeamBots } from "./team-bots/team-bots"; import { createPackageStatusReader, @@ -2228,6 +2229,11 @@ const proactiveSweep = repeatAfterEach(async () => { ); } }, 60_000); +/* + * A person's tasks, read from the queue and the approval and question inboxes the sweeps above + * already keep. See tasks/store.ts. + */ +const taskStore = createTaskStore(database); /* * Follow-ups a Bot scheduled for itself: one headless turn each, through the same turn runner as a * routine, into the person's conversation with that Bot. See agents/wake-up.ts. @@ -2749,6 +2755,7 @@ const app = createApp( }, memory: { store: personalMemoryStore, ingestion: memoryIngestion }, proactive: { store: proactiveStore, engine: proactiveEngine }, + tasks: taskStore, responsibilities: { store: responsibilityStore, engine: responsibilityEngine, diff --git a/server/src/tasks/reply-ending.ts b/server/src/tasks/reply-ending.ts new file mode 100644 index 0000000000..90c7e8cf1b --- /dev/null +++ b/server/src/tasks/reply-ending.ts @@ -0,0 +1,85 @@ +/* + * How a Bot's reply ends, read for Tasks: the line a task shows as its next step, and whether the + * reply finished by asking the person something. Words only, so it reads the same wherever the + * reply came from. + */ + +/** How long a kept reply tail may be. A next step is one line on Tasks. */ +const REPLY_TAIL_MAX = 240; +/** A last sentence shorter than this is kept with the one before it. */ +const SHORT_SENTENCE = 20; + +/** A paragraph that is a list: every line of it is an item. */ +const LIST_ITEM = /^\s*(?:[-*+•]|\d+[.)])\s+/; +/** A paragraph that is something the Bot drafted for the person, not what it says to them. */ +const DRAFT = + /^\s*(?:>|```|["“'‘]|\*{0,2}_{0,2}(?:draft|subject|to|dear|hi|hello)\b[^\n]{0,40}?:)/i; +/** What can trail a question and still leave it one: closing quotes, brackets, emoji, spaces. */ +const TRAILING = /(?:[\s"”'’)\]}»\p{Extended_Pictographic}]|\uFE0F|\u200D)+$/u; + +/** Whether a sentence asks something, once what trails its question mark is set aside. */ +const asksIn = (sentence: string) => + sentence.replace(TRAILING, "").endsWith("?"); + +const plain = (line: string) => + line + .replace(/^\s*(?:[-*+>•]|#{1,6}|\d+[.)])\s+/, "") + .replace(/[*_`]+/g, "") + .trim(); + +/** + * How a reply ends, for Tasks: the sentence to show as the next step, and whether it asks the + * person something. + * + * A Bot ends a turn on what comes next, in the last paragraph it writes to the person. Lists and + * drafts that close a reply are what it prepared, not what it says, so they are set aside. Within + * that last paragraph its last question is the next step, even with a remark after it; without + * one, its last sentence is. A short closing sentence keeps the one before it, so "Do you want me + * to?" never stands alone. Null when there is nothing to say. + */ +export function replyEnding(reply: string | null | undefined): { + tail: string | null; + asks: boolean; +} { + const paragraphs = (reply ?? "") + .split(/\n\s*\n/) + .map((paragraph) => paragraph.trim()) + .filter((paragraph) => /[\p{L}\p{N}]/u.test(paragraph)); + const spoken = paragraphs.filter((paragraph) => { + const lines = paragraph.split("\n").filter((line) => line.trim()); + return !( + lines.every((line) => LIST_ITEM.test(line)) || DRAFT.test(paragraph) + ); + }); + const kept = spoken.length ? spoken : paragraphs.slice(-1); + const sentencesOf = (paragraph: string | undefined) => + (paragraph ?? "") + .split("\n") + .map(plain) + .flatMap((line) => line.split(/(?<=[.!?][\s"”'’)\]]*)\s+(?=\S)/)) + .map((sentence) => sentence.trim()) + .filter((sentence) => /[\p{L}\p{N}]/u.test(sentence)); + const sentences = sentencesOf(kept.at(-1)); + const question = sentences.findLastIndex(asksIn); + const at = question >= 0 ? question : sentences.length - 1; + const chosen = sentences[at]; + if (!chosen) return { tail: null, asks: false }; + // A paragraph of its own still leans on the paragraph before it. + const before = sentences[at - 1] ?? sentencesOf(kept.at(-2)).at(-1); + const sentence = + chosen.length < SHORT_SENTENCE && before ? `${before} ${chosen}` : chosen; + return { + tail: + sentence.length > REPLY_TAIL_MAX + ? `${Array.from(sentence) + .slice(0, REPLY_TAIL_MAX - 1) + .join("")}…` + : sentence, + asks: spoken.length > 0 && question >= 0, + }; +} + +/** The sentence `replyEnding` would show, alone. */ +export function replyTail(reply: string | null | undefined): string | null { + return replyEnding(reply).tail; +} diff --git a/server/src/tasks/routes.ts b/server/src/tasks/routes.ts new file mode 100644 index 0000000000..6552c0b048 --- /dev/null +++ b/server/src/tasks/routes.ts @@ -0,0 +1,46 @@ +import { Hono, type MiddlewareHandler } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import type { AppVariables } from "../auth/guards"; +import type { TaskStore } from "./store"; +import { TaskNotFoundError } from "./types"; + +/** `/api/tasks`: the person's own tasks, as Tasks and the task page read them. */ +export function createTaskRoutes( + store: TaskStore, + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, +) { + const routes = new Hono<{ Variables: AppVariables }>(); + routes.use("*", requireUser, bodyLimit({ maxSize: 16_384 })); + routes.onError((error, context) => { + if (error instanceof TaskNotFoundError) + return context.json({ error: error.message }, 404); + console.error( + JSON.stringify({ + type: "tasks-route-error", + error: error.name, + context: { route: context.req.path }, + timestamp: new Date().toISOString(), + }), + ); + return context.json({ error: "Couldn't load your tasks. Try again." }, 503); + }); + routes.get("/", async (context) => + context.json({ tasks: await store.list(context.var.actor.id) }), + ); + routes.get("/:id", async (context) => + context.json({ + task: await store.get(context.var.actor.id, context.req.param("id")), + }), + ); + routes.post("/:id/resolve", async (context) => + context.json({ + task: await store.resolve(context.var.actor.id, context.req.param("id")), + }), + ); + routes.post("/:id/reopen", async (context) => + context.json({ + task: await store.reopen(context.var.actor.id, context.req.param("id")), + }), + ); + return routes; +} diff --git a/server/src/tasks/store.ts b/server/src/tasks/store.ts new file mode 100644 index 0000000000..5f97688760 --- /dev/null +++ b/server/src/tasks/store.ts @@ -0,0 +1,581 @@ +import { and, desc, eq, inArray, isNull, or, type SQL, sql } from "drizzle-orm"; +import { + type PersonQuestion, + questionResponseMessage, +} from "../approvals/questions"; +import type { Database } from "../db/client"; +import { + approvalRequests, + channels, + intelligenceChannelMappings, + tasks, + workItems, +} from "../db/schema"; +import { PROACTIVE_TASK_KIND } from "../proactive/engine"; +import { DEFAULT_MAX_ATTEMPTS } from "../work/queue"; +import { replyEnding, replyTail } from "./reply-ending"; +import { + TASK_RUNS, + TaskNotFoundError, + type TaskRecord, + type TaskStatus, +} from "./types"; + +/* + * The person's tasks: work a Bot does in the background, each in a conversation of its own, so the + * conversation it was asked in stays one thread. The task page is its conversation, where the + * person can reply to steer it; Tasks lists them. + * + * WHAT A TASK IS DOING IS READ, NEVER STORED. The work queue already knows whether the task's turn is + * queued or running, the approval inbox whether it stopped for Allow once, and the question queue + * whether its Bot asked the person something. Copying any of that onto the task would be a second + * ledger that drifts from the first the moment one of them changes without it. The one thing only + * the person can say, that they are done with it, is the one thing kept: `resolvedAt`. It covers + * work accepted by then; a later durable turn supersedes it, without a separate reopening write. + * + * FAILING IS READ THE SAME WAY. A task's turn runs at most twice (`TASK_RUNS`): once, and once more + * if the first run threw or was cut off by a restart. A turn that has run out of runs stays on the + * queue unfinished, with its count and its last error, which is the queue's own terminal state + * (work/queue.ts). That row is the failure and its reason; nothing else records it. + */ + +/** The rule on `TaskStatus`, on its own, so it can be read and tested without a database. */ +export function taskStatus(state: { + resolved: boolean; + needsInput: boolean; + working: boolean; + failed?: boolean; +}): TaskStatus { + if (state.needsInput) return "needs_input"; + if (state.working) return "working"; + if (state.resolved) return "resolved"; + if (state.failed) return "failed"; + return "ready_for_review"; +} + +/** How long a task with no turn on the queue yet still reads as just started. */ +const STARTING_MS = 60_000; +/** A turn whose last run a crash or restart cut off. */ +const CUT_OFF = "the run was cut off partway through"; +/** A task whose turn never reached the queue. */ +const NEVER_STARTED = "it never started"; + +/** + * Why a task's turn couldn't finish, in words that read right after both the row's "Couldn't + * finish:" and the Bot's own "I couldn't finish this:", so never the Bot in the third person. Never + * the error itself: that is an operator's, kept on the queue's row, and can be a status code, a + * JSON body or a stack. Anything not recognised here is said generically. + */ +export function plainFailure(error: string | null | undefined): string { + if (!error) return CUT_OFF; + if (error === NEVER_STARTED) return NEVER_STARTED; + const stopped = /stopped after (\d+)s/.exec(error); + if (stopped) { + const minutes = Math.max(1, Math.round(Number(stopped[1]) / 60)); + return `the run took longer than ${minutes} minute${minutes === 1 ? "" : "s"}`; + } + const rules: [RegExp, string][] = [ + [/finished without saying anything/i, "the run ended without a reply"], + [/stopped to ask a question/i, "the run stopped to ask a question"], + [/no longer available/i, "this conversation is gone"], + [/nothing to start/i, "there was nothing to start"], + [/before (it|retries) could/i, "it failed before it could be tried again"], + [/\bpaused\b/i, "work is paused"], + [/allowed model/i, "the model isn't allowed here"], + [/lock/i, "the conversation was busy with another reply"], + [/cancel|abort/i, "the run was stopped"], + [ + /\b40[13]\b|authentication|api.?key/i, + "the model provider turned the request away", + ], + [ + /fetch failed|ECONN|ETIMEDOUT|ENOTFOUND|socket hang up|unavailable|overloaded|\b50[234]\b/i, + "a service didn't answer", + ], + ]; + return ( + rules.find(([pattern]) => pattern.test(error))?.[1] ?? + "something went wrong during the run" + ); +} + +/** How long the person's own words may run as a task's next step. */ +const QUOTE_MAX = 120; + +/** + * A task's next step, from the last line said in its conversation: the Bot's words end on what comes + * next (`replyTail`); the person's are what the Bot is now working on, quoted. + */ +export function nextStepOf( + said: string | null | undefined, + byBot: boolean, +): string | null { + const text = said?.trim(); + if (!text) return null; + if (byBot) return replyTail(text); + const words = Array.from(text); + return `“${words.length > QUOTE_MAX ? `${words.slice(0, QUOTE_MAX - 1).join("")}…` : text}”`; +} + +type Row = typeof tasks.$inferSelect; +/** What a task's conversation is doing, and its thread, as one read of the ledgers found them. */ +type TaskState = { + /** The person's completion, unless genuinely new durable work superseded it. */ + resolvedAt: Date | null; + queuedReplies: { messageId: string; text: string }[]; + answeredQuestionIds: Set; + needsInput: boolean; + working: boolean; + threadId: string | null; + /** The plain reason, when its turn ran out of runs, and when. */ + failure: string | null; + failedAt: Date | null; + /** See `TaskRecord.next`. */ + next: string | null; +}; + +/** A JSON field of a work item's payload, as text. */ +const payload = (field: string) => sql`${workItems.payload}->>${field}`; +/** The conversation an approval was raised in, as text. */ +const approvalThread = sql`${approvalRequests.action}->>'threadId'`; + +export type TaskStore = ReturnType; + +/** Every read and write is the owner's: another person's task is the same as no task. */ +export function createTaskStore( + database: Database, + options: { now?: () => Date } = {}, +) { + const now = options.now ?? (() => new Date()); + const owned = (owner: string, id: string) => + and(eq(tasks.ownerUserId, owner), eq(tasks.id, id)); + + /** + * What each task is doing, from the ledgers that say so. A handful of queries for the whole list, + * whatever its length, each narrowed to the person first. + */ + async function stateOf( + owner: string, + rows: readonly Row[], + ): Promise> { + const state = new Map( + rows.map((row) => [ + row.id, + { + resolvedAt: row.resolvedAt, + queuedReplies: [], + answeredQuestionIds: new Set(), + needsInput: false, + working: false, + threadId: null, + failure: null, + failedAt: null, + next: null, + }, + ]), + ); + if (rows.length === 0) return state; + const channelIds = rows.map((row) => row.channelId); + const taskOfChannel = new Map(rows.map((row) => [row.channelId, row.id])); + // The last line said in each task's conversation, for its next step. + const lastSaid = await database + .select({ + id: channels.id, + lastMessage: channels.lastMessage, + byBot: sql`${channels.lastMessageAgentId} is not null`, + sourceId: channels.lastMessageSourceId, + afterResolution: sql`coalesce(${channels.lastMessageAt} > ${tasks.resolvedAt}, true)`, + }) + .from(channels) + .innerJoin(tasks, eq(tasks.channelId, channels.id)) + .where(inArray(channels.id, channelIds)); + for (const said of lastSaid) { + const entry = state.get(taskOfChannel.get(said.id) ?? ""); + // The ask a turn starts on is the task itself, not a step: "Just started" says that better. + const ask = !said.byBot && (said.sourceId?.endsWith(":input") ?? false); + if (entry && !ask) entry.next = nextStepOf(said.lastMessage, said.byBot); + } + const threads = await database + .select({ + channelId: intelligenceChannelMappings.channelId, + threadId: intelligenceChannelMappings.threadId, + }) + .from(intelligenceChannelMappings) + .where( + and( + eq(intelligenceChannelMappings.userId, owner), + inArray(intelligenceChannelMappings.channelId, channelIds), + ), + ); + const channelOfThread = new Map( + threads.map((row) => [row.threadId, row.channelId]), + ); + const threadIds = [...channelOfThread.keys()]; + for (const row of threads) { + const taskId = taskOfChannel.get(row.channelId); + const entry = taskId ? state.get(taskId) : undefined; + if (entry) entry.threadId = row.threadId; + } + const mark = ( + channelId: string | null | undefined, + field: "needsInput" | "working", + ) => { + const taskId = channelId ? taskOfChannel.get(channelId) : undefined; + const entry = taskId ? state.get(taskId) : undefined; + if (entry) entry[field] = true; + }; + const inThreads = (expression: SQL) => + threadIds.length > 0 ? inArray(expression, threadIds) : sql`false`; + + // Waiting on the person: an action its turn paused on for Allow once, or a question it asked. + const [approvals, questions] = await Promise.all([ + database + .select({ threadId: approvalThread }) + .from(approvalRequests) + .where( + and( + eq(approvalRequests.ownerUserId, owner), + eq(approvalRequests.status, "pending"), + inThreads(approvalThread), + ), + ), + database + .select({ + key: workItems.key, + channelId: payload("channelId"), + threadId: payload("threadId"), + }) + .from(workItems) + .where( + and( + eq(workItems.kind, "person.question"), + isNull(workItems.finishedAt), + sql`${payload("actorId")} = ${owner}`, + or( + inArray(payload("channelId"), channelIds), + inThreads(payload("threadId")), + ), + ), + ), + ]); + for (const row of approvals) + mark(channelOfThread.get(row.threadId), "needsInput"); + + /* + * Its turns, in creation order for the queued reply list. The effective outcome follows when + * each turn finished or failed: an older insert may commit and run after a newer turn finished. + * The proactive engine owns task turns; an older one may name its suggestion. Question-card + * answers are continuations in the same conversation, owned by the person-response worker. + */ + const suggestionIds = rows.flatMap((row) => + row.suggestionId ? [row.suggestionId] : [], + ); + // The same ledger timestamps order task executions and question-card continuations. + const turnState = { + key: workItems.key, + acceptedAt: sql`extract(epoch from ${workItems.createdAt})`.mapWith( + Number, + ), + // Compare in SQL so a same-millisecond acceptance still follows the exact completion. + afterResolution: sql`coalesce(${workItems.createdAt} > ${tasks.resolvedAt}, false)`, + // A crash fails at lease expiry, not its last heartbeat. Finished rows use their actual + // completion; released rows use the release timestamp. Keep PostgreSQL's sub-ms order. + outcomeAt: sql`extract(epoch from coalesce( + ${workItems.finishedAt}, + case when ${workItems.claimedBy} is not null then ${workItems.leaseUntil} end, + ${workItems.updatedAt} + ))`.mapWith(Number), + attempts: workItems.attempts, + live: sql`coalesce(${workItems.leaseUntil} > now(), false)`, + finished: sql`${workItems.finishedAt} is not null`, + lastError: workItems.lastError, + claimed: sql`${workItems.claimedBy} is not null`, + updatedAt: workItems.updatedAt, + leaseUntil: workItems.leaseUntil, + }; + const responseQuestion = sql`${workItems.payload}->'question'`; + const responseThread = sql`${responseQuestion}->>'threadId'`; + const responseChannel = sql`${responseQuestion}->>'channelId'`; + const [turns, resuming, responses] = await Promise.all([ + database + .select({ + ...turnState, + reply: payload("reply"), + followup: payload("followup"), + questionIds: sql< + string[] | null + >`${workItems.payload}->'questionIds'`, + messageId: payload("messageId"), + taskId: payload("taskId"), + suggestionId: payload("suggestionId"), + }) + .from(workItems) + .leftJoin(tasks, eq(tasks.id, payload("taskId"))) + .where( + and( + eq(workItems.kind, PROACTIVE_TASK_KIND), + or( + inArray( + payload("taskId"), + rows.map((row) => row.id), + ), + suggestionIds.length > 0 + ? and( + sql`${payload("taskId")} is null`, + inArray(payload("suggestionId"), suggestionIds), + ) + : undefined, + ), + ), + ) + .orderBy(desc(workItems.createdAt), desc(workItems.key)), + database + .select({ threadId: approvalThread }) + .from(workItems) + .innerJoin( + approvalRequests, + eq(approvalRequests.id, payload("approvalId")), + ) + .where( + and( + eq(workItems.kind, "approval.resume"), + isNull(workItems.finishedAt), + eq(approvalRequests.ownerUserId, owner), + inThreads(approvalThread), + sql`(${workItems.attempts} < ${DEFAULT_MAX_ATTEMPTS} or ${workItems.leaseUntil} > now())`, + ), + ), + database + .select({ + ...turnState, + taskId: tasks.id, + question: responseQuestion, + response: payload("response"), + }) + .from(workItems) + .innerJoin( + intelligenceChannelMappings, + and( + eq(intelligenceChannelMappings.userId, owner), + eq(intelligenceChannelMappings.threadId, responseThread), + ), + ) + .innerJoin( + tasks, + and( + eq(tasks.ownerUserId, owner), + eq(tasks.channelId, intelligenceChannelMappings.channelId), + ), + ) + .where( + and( + eq(workItems.kind, "person.response"), + sql`${payload("ownerUserId")} = ${owner}`, + sql`${responseQuestion}->>'actorId' = ${owner}`, + inArray( + tasks.id, + rows.map((row) => row.id), + ), + or(isNull(responseChannel), eq(responseChannel, tasks.channelId)), + ), + ), + ]); + const taskOfSuggestion = new Map( + rows.flatMap((row) => + row.suggestionId ? [[row.suggestionId, row.id] as const] : [], + ), + ); + const taskTurns = [ + ...turns.map((turn) => ({ ...turn, maxAttempts: TASK_RUNS })), + ...responses.map(({ question, response, ...turn }) => ({ + ...turn, + // The person-response worker accepts one attempt, unlike a task's own turns. + maxAttempts: 1, + suggestionId: null, + followup: "true", + reply: response, + messageId: questionResponseMessage( + question, + response, + `person-response:${turn.key}`, + ).id, + questionIds: [turn.key], + })), + ].sort((a, b) => b.acceptedAt - a.acceptedAt || b.key.localeCompare(a.key)); + const latestOutcome = new Map(); + const unfinished = new Map(); + for (const turn of taskTurns) { + const taskId = + turn.taskId ?? taskOfSuggestion.get(turn.suggestionId ?? ""); + // Acceptance answers only the captured questions. Keep that fact across exhausted turns + // and later turns that did not carry the original reply's snapshot. + if (taskId && turn.followup === "true") + for (const id of turn.questionIds ?? []) + state.get(taskId)?.answeredQuestionIds.add(id); + if (taskId) { + const entry = state.get(taskId); + if (entry && turn.taskId && turn.afterResolution) + entry.resolvedAt = null; + const latest = latestOutcome.get(taskId); + // Ties retain creation/key order, but insertion order never hides a later terminal turn. + if (!latest || turn.outcomeAt > latest.outcomeAt) + latestOutcome.set(taskId, turn); + } + if ( + taskId && + !turn.finished && + (turn.live || turn.attempts < turn.maxAttempts) && + !unfinished.has(taskId) + ) + unfinished.set(taskId, turn); + if ( + taskId && + turn.reply && + turn.messageId && + !turn.finished && + !turn.live && + turn.attempts < turn.maxAttempts + ) + state.get(taskId)?.queuedReplies.unshift({ + messageId: turn.messageId, + text: turn.reply, + }); + } + for (const question of questions) { + const channelId = + question.channelId ?? channelOfThread.get(question.threadId ?? ""); + const taskId = channelId ? taskOfChannel.get(channelId) : undefined; + if (!taskId || !state.get(taskId)?.answeredQuestionIds.has(question.key)) + mark(channelId, "needsInput"); + } + const at = now().getTime(); + for (const row of rows) { + const entry = state.get(row.id); + if (!entry) continue; + // Any queued/live turn takes priority, including one whose older insert committed late. + const turn = unfinished.get(row.id) ?? latestOutcome.get(row.id); + if (!turn) { + /* + * Opened, and its turn not queued yet: the moment between the two writes that start a task. + * Past that, it never will be, so it failed to start rather than reading Working for ever. + */ + if (at - row.createdAt.getTime() < STARTING_MS) entry.working = true; + else { + entry.failure = plainFailure(NEVER_STARTED); + entry.failedAt = new Date(row.createdAt.getTime() + STARTING_MS); + } + continue; + } + if (turn.finished) continue; + /* + * Working while queued, running, or below its worker's attempt cap after a throw or restart. + * Question-card answers have one attempt; a task's own turns have two. + */ + if (turn.live || turn.attempts < turn.maxAttempts) { + entry.working = true; + continue; + } + /* + * It stays failed until a turn of its own finishes. Anything else said in its conversation + * since is not that turn: the task page sends a reply on a failed task as Try again. + * + * Still claimed means its last run was cut off, not let go after a throw, whatever error an + * earlier run left on the row; it failed when that lease ran out. + */ + const cutOff = turn.claimed && turn.leaseUntil !== null; + entry.failure = plainFailure(cutOff ? null : turn.lastError); + entry.failedAt = + cutOff && turn.leaseUntil ? turn.leaseUntil : turn.updatedAt; + } + for (const row of resuming) + mark(channelOfThread.get(row.threadId), "working"); + for (const said of lastSaid) { + const entry = state.get(taskOfChannel.get(said.id) ?? ""); + if ( + entry && + said.byBot && + replyEnding(said.lastMessage).asks && + !entry.working && + !entry.failure && + !entry.resolvedAt && + // New work must not resurrect a final question the person already dismissed. + said.afterResolution + ) + entry.needsInput = true; + } + return state; + } + + function recordOf(row: Row, state: TaskState | undefined): TaskRecord { + const resolvedAt = state ? state.resolvedAt : row.resolvedAt; + const status = taskStatus({ + resolved: resolvedAt !== null, + needsInput: state?.needsInput ?? false, + working: state?.working ?? false, + failed: (state?.failure ?? null) !== null, + }); + const failedOrDone = status === "failed" || status === "resolved"; + return { + id: row.id, + botId: row.botId, + channelId: row.channelId, + threadId: state?.threadId ?? null, + title: row.title, + why: row.why, + origin: row.origin, + suggestionId: row.suggestionId, + sourceApp: row.sourceApp, + sourceLink: row.sourceLink, + status, + // Kept while it is marked done, too: Reopen brings the failure back, and so may a reply. + failure: failedOrDone ? (state?.failure ?? null) : null, + failedAt: failedOrDone ? (state?.failedAt?.toISOString() ?? null) : null, + next: state?.next ?? null, + queuedReplies: state?.queuedReplies ?? [], + answeredQuestionIds: [...(state?.answeredQuestionIds ?? [])], + resolvedAt: resolvedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } + + async function one(owner: string, id: string): Promise { + const [row] = await database + .select() + .from(tasks) + .where(owned(owner, id)) + .limit(1); + if (!row) throw new TaskNotFoundError(); + return recordOf(row, (await stateOf(owner, [row])).get(id)); + } + + async function setResolved(owner: string, id: string, resolved: boolean) { + // The same clock and precision as durable acceptance, across every server replica. + const at = sql`now()`; + const [row] = await database + .update(tasks) + .set({ resolvedAt: resolved ? at : null, updatedAt: at }) + .where(owned(owner, id)) + .returning({ id: tasks.id }); + if (!row) throw new TaskNotFoundError(); + return one(owner, id); + } + + return { + /** The owner's tasks, newest first. No cap on how many there are; a page is what a list shows. */ + async list(owner: string, limit = 200): Promise { + const rows = await database + .select() + .from(tasks) + .where(eq(tasks.ownerUserId, owner)) + .orderBy(desc(tasks.createdAt), desc(tasks.id)) + .limit(limit); + const state = await stateOf(owner, rows); + return rows.map((row) => recordOf(row, state.get(row.id))); + }, + get: one, + /** The person is done with it. Its conversation stays, and Reopen brings it back. */ + resolve: (owner: string, id: string) => setResolved(owner, id, true), + reopen: (owner: string, id: string) => setResolved(owner, id, false), + }; +} diff --git a/server/src/tasks/types.ts b/server/src/tasks/types.ts new file mode 100644 index 0000000000..961b9cc670 --- /dev/null +++ b/server/src/tasks/types.ts @@ -0,0 +1,74 @@ +/** How a task began: asked for, or started from one of the Bot's suggestions. */ +export type TaskOrigin = "asked" | "suggestion"; + +/** + * How many times a task's turn runs: once, and once more when the first run threw or a restart cut + * it off. The queue's attempt cap for the turn's kind, so a turn out of runs stays on the queue + * unfinished with its count and its reason, which is what reads as `failed` (tasks/store.ts). + */ +export const TASK_RUNS = 2; + +/** + * Where a task stands, worked out on every read, first match wins: + * - `needs_input`: an approval or a question in its conversation waits on the person; + * - `working`: its turn is queued, running, or due its one retry, or an answered approval or question + * is being carried on; + * - `resolved`: the person marked it done; + * - `failed`: its turn ran out of runs without finishing, and nothing has happened in its + * conversation since; + * - `ready_for_review`: its turn finished and wrote a reply. + */ +export type TaskStatus = + | "needs_input" + | "working" + | "resolved" + | "failed" + | "ready_for_review"; + +/** A task as the person reads it. */ +export type TaskRecord = { + id: string; + botId: string; + /** The task's own conversation. */ + channelId: string; + /** + * That conversation's thread, which an approval or a question raised in it names. Null when the + * conversation is gone. + */ + threadId: string | null; + title: string; + why: string | null; + origin: TaskOrigin; + suggestionId: string | null; + sourceApp: string | null; + sourceLink: string | null; + status: TaskStatus; + /** + * Why its turn couldn't finish, in plain words, while it is `failed`, or marked done after failing. + * Null otherwise. + */ + failure: string | null; + /** When it failed, alongside `failure`. */ + failedAt: string | null; + /** + * The run's own next step, one line, for Tasks: the last sentence or question of the Bot's newest + * words in its conversation, or the person's own words in quotes while their reply is being + * worked on. Null before anything has been said there. + */ + next: string | null; + /** The person's replies still waiting their turn, oldest first. */ + queuedReplies: { messageId: string; text: string }[]; + /** Questions the person already answered in accepted replies, even if their turns failed. */ + answeredQuestionIds: string[]; + /** Null once durable work accepted after the person's last completion supersedes it. */ + resolvedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export class TaskNotFoundError extends Error { + constructor() { + super("Couldn't find that task."); + this.name = "TaskNotFoundError"; + } +} diff --git a/server/tests/tasks.integration.test.ts b/server/tests/tasks.integration.test.ts new file mode 100644 index 0000000000..d4599b9a20 --- /dev/null +++ b/server/tests/tasks.integration.test.ts @@ -0,0 +1,741 @@ +import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, like, sql } from "drizzle-orm"; +import { createAgentProfileStore } from "../src/agents/profile-store"; +import type { AgentActor } from "../src/agents/profile-types"; +import { + createApprovalQuestions, + type PersonQuestion, +} from "../src/approvals/questions"; +import type { AuthenticatedActor } from "../src/auth/guards"; +import { createChannelStore } from "../src/channels/routes"; +import { createThreadIdentity } from "../src/channels/thread-identity"; +import { createDatabase } from "../src/db/client"; +import { + agentProfiles, + agents, + approvalRequests, + channels, + intelligenceChannelMappings, + tasks, + users, + workItems, +} from "../src/db/schema"; +import { PROACTIVE_TASK_KIND } from "../src/proactive/engine"; +import { createTaskRoutes } from "../src/tasks/routes"; +import { createTaskStore, type TaskStore } from "../src/tasks/store"; +import type { TaskRecord } from "../src/tasks/types"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/** + * A task is work a Bot does in a conversation of its own. These run against PostgreSQL: the + * conversation is a real channel, and what a task is doing is read from the real work queue, + * approval inbox and question queue. Nothing in OpenBot opens a task yet, so each one here is a row + * written beside a real conversation, and each turn a row written where the engine will queue it. + */ + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const profileStore = createAgentProfileStore( + database, + new URL("https://managed.example.test/ag-ui"), +); +const channelStore = createChannelStore( + database, + profileStore, + createThreadIdentity("test-deployment"), +); +const taskStore = createTaskStore(database); +const prefix = `tasks-${randomUUID()}`; +const owner: AgentActor = { id: `${prefix}-owner`, role: "user" }; +const other: AgentActor = { id: `${prefix}-other`, role: "user" }; +let bot = ""; + +beforeAll(async () => { + await database.insert(users).values([ + { id: owner.id, email: `${owner.id}@example.test` }, + { id: other.id, email: `${other.id}@example.test` }, + ]); + bot = ( + await profileStore.create(owner, { + name: "Researcher", + title: "Looks things up", + roleDescription: "Works for one person.", + visibility: "private", + }) + ).id; +}); + +afterAll(async () => { + try { + await database.delete(workItems).where(like(workItems.key, `${prefix}%`)); + const mine = await database + .select({ id: channels.id }) + .from(channels) + .innerJoin( + intelligenceChannelMappings, + eq(intelligenceChannelMappings.channelId, channels.id), + ) + .where(inArray(intelligenceChannelMappings.userId, [owner.id, other.id])); + if (mine.length > 0) + await database.delete(channels).where( + inArray( + channels.id, + mine.map((row) => row.id), + ), + ); + await database.delete(users).where(inArray(users.id, [owner.id, other.id])); + await database.delete(agentProfiles).where(eq(agentProfiles.agentId, bot)); + await database.delete(agents).where(eq(agents.id, bot)); + } finally { + await database.$client.close(); + } +}); + +/** A task in a conversation of its own, as the engine will open one. */ +async function freshTask(title = `Task ${randomUUID()}`) { + const conversation = await channelStore.create(owner, [bot]); + const id = randomUUID(); + await database.insert(tasks).values({ + id, + ownerUserId: owner.id, + botId: bot, + channelId: conversation.id, + title, + origin: "asked", + }); + return { id, channelId: conversation.id, threadId: conversation.threadId }; +} + +/** A turn for this task on the queue, in whatever state the test needs. */ +async function turnFor( + taskId: string, + state: Partial = {}, + key = `${prefix}:task:${taskId}:${randomUUID()}`, +) { + await database.insert(workItems).values({ + kind: PROACTIVE_TASK_KIND, + key, + payload: { ownerUserId: owner.id, taskId, text: "Prepare the notes." }, + ...state, + }); + return and(eq(workItems.kind, PROACTIVE_TASK_KIND), eq(workItems.key, key)); +} + +/** An approval the task's turn stopped on, waiting for Allow once. */ +async function approvalIn(threadId: string, ownerUserId = owner.id) { + const id = `${prefix}-approval-${randomUUID()}`; + await database.insert(approvalRequests).values({ + id, + ownerUserId, + runId: `${prefix}-run-${randomUUID()}`, + toolCallId: "send", + actionDigest: "digest", + action: { + threadId, + botId: bot, + toolRef: "google-workspace/gmail_send_reply", + } as never, + }); + return id; +} + +const statusOf = async (id: string, store: TaskStore = taskStore) => + (await store.get(owner.id, id)).status; + +describe("status", () => { + /** + * A question the task's Bot asked, and the person's reply that answered it, accepted onto the + * queue the way a reply on the task page is: naming the questions waiting when it was taken. + */ + async function acceptedAnswer() { + const task = await freshTask(); + await turnFor(task.id, { finishedAt: new Date() }); + const questionId = `${prefix}-answered-${randomUUID()}`; + const question = { + ownerUserId: owner.id, + actorId: owner.id, + botId: bot, + threadId: task.threadId, + runId: `task-${task.id}`, + question: "Which deck?", + mode: "completed_question", + }; + await database.insert(workItems).values({ + kind: "person.question", + key: questionId, + payload: question, + }); + const messageId = randomUUID(); + const where = await turnFor( + task.id, + { + payload: { + ownerUserId: owner.id, + taskId: task.id, + text: "Use A", + reply: "Use A", + messageId, + inputId: messageId, + followup: true, + questionIds: [questionId], + runs: 2, + }, + }, + `${prefix}:task:${task.id}:reply:${messageId}`, + ); + return { task, questionId, question, where }; + } + + test("is worked out from the queue, the approval inbox and the question queue, never stored", async () => { + const task = await freshTask(); + // Opened, and its turn not queued yet: just started. + expect(await statusOf(task.id)).toBe("working"); + + // Its turn, queued and then running: working. + const where = await turnFor(task.id); + expect(await statusOf(task.id)).toBe("working"); + await database + .update(workItems) + .set({ + attempts: 1, + claimedBy: "worker", + leaseUntil: sql`now() + interval '5 minutes'`, + }) + .where(where); + expect(await statusOf(task.id)).toBe("working"); + // Finished: the run wrote its reply, so there is something to read. + await database + .update(workItems) + .set({ finishedAt: new Date(), claimedBy: null, leaseUntil: null }) + .where(where); + expect(await statusOf(task.id)).toBe("ready_for_review"); + + // Its reply stopped for Allow once: needs input. + const approvalId = await approvalIn(task.threadId); + expect(await statusOf(task.id)).toBe("needs_input"); + + // Allowed: carried on, so working; done, so ready to read. + await database + .update(approvalRequests) + .set({ status: "approved", decision: "allow_once" }) + .where(eq(approvalRequests.id, approvalId)); + await database.insert(workItems).values({ + kind: "approval.resume", + key: approvalId, + payload: { ownerUserId: owner.id, approvalId }, + }); + expect(await statusOf(task.id)).toBe("working"); + await database + .update(workItems) + .set({ finishedAt: new Date() }) + .where( + and( + eq(workItems.kind, "approval.resume"), + eq(workItems.key, approvalId), + ), + ); + await database + .update(approvalRequests) + .set({ status: "completed" }) + .where(eq(approvalRequests.id, approvalId)); + expect(await statusOf(task.id)).toBe("ready_for_review"); + + // A question its Bot asked in its conversation: needs input until answered. + const question = `${prefix}-question-${randomUUID()}`; + await database.insert(workItems).values({ + kind: "person.question", + key: question, + payload: { + actorId: owner.id, + botId: bot, + threadId: task.threadId, + question: "Which glaze?", + }, + }); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ finishedAt: new Date() }) + .where( + and(eq(workItems.kind, "person.question"), eq(workItems.key, question)), + ); + + // Marked done: resolved. Reopened, it reads as it did. + await taskStore.resolve(owner.id, task.id); + expect(await statusOf(task.id)).toBe("resolved"); + await taskStore.reopen(owner.id, task.id); + expect(await statusOf(task.id)).toBe("ready_for_review"); + + // Nothing about status is a column. + const [row] = await database + .select() + .from(tasks) + .where(eq(tasks.id, task.id)); + expect(Object.keys(row ?? {})).not.toContain("status"); + }); + + test("another person's pending approval on the same thread id is not this task's", async () => { + const task = await freshTask(); + await turnFor(task.id, { attempts: 1, finishedAt: new Date() }); + await approvalIn(task.threadId, other.id); + expect(await statusOf(task.id)).toBe("ready_for_review"); + }); + + test("a question card's answer stays working, and fails once, on its own single attempt", async () => { + const task = await freshTask(); + const unrelated = await freshTask(); + await turnFor(task.id, { finishedAt: new Date() }); + await turnFor(unrelated.id, { finishedAt: new Date() }); + const key = `${prefix}-question-response-${randomUUID()}`; + const question: PersonQuestion = { + actorId: owner.id, + botId: bot, + channelId: task.channelId, + threadId: task.threadId, + runId: `task-${task.id}`, + question: "Which day works?", + mode: "completed_question", + }; + await database + .insert(workItems) + .values({ kind: "person.question", key, payload: question }); + expect(await statusOf(task.id)).toBe("needs_input"); + await createApprovalQuestions(database, async () => {}).respond( + owner.id, + key, + "Friday", + ); + const answer = and( + eq(workItems.kind, "person.response"), + eq(workItems.key, key), + ); + expect(await statusOf(task.id)).toBe("working"); + expect(await statusOf(unrelated.id)).toBe("ready_for_review"); + expect((await taskStore.get(owner.id, task.id)).queuedReplies).toEqual([ + { messageId: `person-response:${key}`, text: "Friday" }, + ]); + // Its one attempt, running. + await database + .update(workItems) + .set({ + attempts: 1, + claimedBy: "answerer", + leaseUntil: sql`now() + interval '1 minute'`, + }) + .where(answer); + expect(await statusOf(task.id)).toBe("working"); + // Let go after a throw: out of attempts, so failed, in plain words. + await database + .update(workItems) + .set({ claimedBy: null, leaseUntil: null, lastError: "fetch failed" }) + .where(answer); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "failed", + failure: "a service didn't answer", + answeredQuestionIds: [key], + }); + await database.delete(workItems).where(answer); + }); + + test("a new question keeps priority over an accepted answer while working and after exhaustion", async () => { + const { task, question, where } = await acceptedAnswer(); + expect(await statusOf(task.id)).toBe("working"); + const newId = `${prefix}-new-${randomUUID()}`; + await database.insert(workItems).values({ + kind: "person.question", + key: newId, + payload: question, + }); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ attempts: 2, lastError: "fetch failed" }) + .where(where); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "needs_input", + failure: null, + failedAt: null, + }); + await database + .update(workItems) + .set({ finishedAt: sql`now()` }) + .where( + and(eq(workItems.kind, "person.question"), eq(workItems.key, newId)), + ); + expect(await statusOf(task.id)).toBe("failed"); + }); + + test("pending approval keeps priority over an accepted answer while working and after exhaustion", async () => { + const { task, where } = await acceptedAnswer(); + const approvalId = await approvalIn(task.threadId); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ attempts: 2, lastError: "fetch failed" }) + .where(where); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(approvalRequests) + .set({ status: "completed" }) + .where(eq(approvalRequests.id, approvalId)); + expect(await statusOf(task.id)).toBe("failed"); + }); + + test("an exhausted accepted answer still counts its questions answered", async () => { + const { task, questionId, where } = await acceptedAnswer(); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "working", + answeredQuestionIds: [questionId], + }); + await database + .update(workItems) + .set({ attempts: 2, lastError: "fetch failed" }) + .where(where); + // The failed run did not finish the question; the accepted answer is what the read knows. + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "failed", + failure: "a service didn't answer", + answeredQuestionIds: [questionId], + }); + }); + + test("a legacy answer without a snapshot never hides an unknown pending question", async () => { + const { task, where } = await acceptedAnswer(); + await database + .update(workItems) + .set({ payload: sql`${workItems.payload} - 'questionIds'` }) + .where(where); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ attempts: 2, lastError: "fetch failed" }) + .where(where); + expect(await statusOf(task.id)).toBe("needs_input"); + }); + + test("carries the run's own next step: the Bot's last sentence, the person's reply quoted, never the ask", async () => { + const task = await freshTask(`Next ${randomUUID()}`); + const nextOf = async () => (await taskStore.get(owner.id, task.id)).next; + expect(await nextOf()).toBeNull(); + const at = Date.now(); + // The ask the turn starts on is the task, not a step. + await channelStore.recordActivity( + owner, + task.channelId, + { text: "Draft a reply to Danny.", agentId: null, at: new Date(at) }, + { id: `task-${task.id}:input` }, + ); + expect(await nextOf()).toBeNull(); + await channelStore.recordActivity(owner, task.channelId, { + text: "I drafted the reply. **Send the reply to Danny?** It tells him Friday works.", + agentId: bot, + at: new Date(at + 1_000), + }); + expect(await nextOf()).toBe("Send the reply to Danny?"); + await channelStore.recordActivity(owner, task.channelId, { + text: "Make it shorter", + agentId: null, + at: new Date(at + 2_000), + }); + expect(await nextOf()).toBe("“Make it shorter”"); + // Read the same way on the list. + expect( + (await taskStore.list(owner.id)).find((row) => row.id === task.id)?.next, + ).toBe("“Make it shorter”"); + }); + + test("a finished reply asks for input, and a queued reply clears that question while working", async () => { + const task = await freshTask(); + await turnFor(task.id, { finishedAt: new Date() }); + await channelStore.recordActivity(owner, task.channelId, { + text: "Here is the draft.\n\nWhich one should I use? 🙂", + agentId: bot, + at: new Date(), + }); + expect(await statusOf(task.id)).toBe("needs_input"); + expect((await taskStore.get(owner.id, task.id)).next).toContain( + "Which one", + ); + await turnFor(task.id, { + payload: { + taskId: task.id, + ownerUserId: owner.id, + reply: "Use A", + messageId: "reply-a", + }, + }); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "working", + queuedReplies: [{ messageId: "reply-a", text: "Use A" }], + }); + }); + + test("marked done, a final question stays dismissed until new work follows it", async () => { + const task = await freshTask(); + await turnFor(task.id, { attempts: 1, finishedAt: new Date() }); + // Said well before it was marked done, and the new question well after, whatever the gap + // between this process's clock and the database's. + await channelStore.recordActivity(owner, task.channelId, { + text: "Should I send the old draft?", + agentId: bot, + at: new Date(Date.now() - 60_000), + }); + expect(await statusOf(task.id)).toBe("needs_input"); + const resolved = await taskStore.resolve(owner.id, task.id); + expect(resolved.status).toBe("resolved"); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "resolved", + resolvedAt: resolved.resolvedAt, + }); + + // A turn accepted after it was marked done supersedes that, without a write of its own... + const reply = await turnFor(task.id); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "working", + resolvedAt: null, + }); + // ...and cannot bring back the question the person already put away. + await database + .update(workItems) + .set({ attempts: 1, finishedAt: sql`now()` }) + .where(reply); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "ready_for_review", + resolvedAt: null, + }); + // A question the new turn ends on is the person's to answer again. + const text = "Should I send the revised draft?"; + await channelStore.recordActivity(owner, task.channelId, { + text, + agentId: bot, + at: new Date(Date.now() + 60_000), + }); + const expected = { status: "needs_input", resolvedAt: null, next: text }; + expect(await taskStore.get(owner.id, task.id)).toMatchObject(expected); + expect( + (await taskStore.list(owner.id)).find((row) => row.id === task.id), + ).toMatchObject(expected); + }); +}); + +describe("a task whose turn fails", () => { + test("reads Working while its one retry is due, then Failed with the reason in plain words, never Ready for review", async () => { + const task = await freshTask(); + // The first run threw: let go with its reason, and due once more. + const where = await turnFor(task.id, { + attempts: 1, + lastError: "fetch failed", + }); + expect(await statusOf(task.id)).toBe("working"); + // A restart cut the first run off: the lease ran out, so the retry is due. + await database + .update(workItems) + .set({ + lastError: null, + claimedBy: "gone", + leaseUntil: sql`now() - interval '1 second'`, + }) + .where(where); + expect(await statusOf(task.id)).toBe("working"); + // The retry running. + await database + .update(workItems) + .set({ + attempts: 2, + claimedBy: "worker", + leaseUntil: sql`now() + interval '3 minutes'`, + }) + .where(where); + expect(await statusOf(task.id)).toBe("working"); + // The retry threw too: out of runs, so it failed, and says why without the error itself. + await database + .update(workItems) + .set({ + claimedBy: null, + leaseUntil: null, + lastError: "The routine's turn was stopped after 300s.", + }) + .where(where); + const failed = await taskStore.get(owner.id, task.id); + expect(failed.status).toBe("failed"); + expect(failed.failure).toBe("the run took longer than 5 minutes"); + expect(failed.failedAt).not.toBeNull(); + + // Marked done, it is done, and its failure stays on the record; reopened, it is failed again. + expect(await taskStore.resolve(owner.id, task.id)).toMatchObject({ + status: "resolved", + failure: "the run took longer than 5 minutes", + }); + expect((await taskStore.reopen(owner.id, task.id)).status).toBe("failed"); + + // A newer turn of its own counts: working, then ready once it finished. + const again = await turnFor(task.id); + expect(await statusOf(task.id)).toBe("working"); + await database + .update(workItems) + .set({ attempts: 1, finishedAt: new Date() }) + .where(again); + const done = await taskStore.get(owner.id, task.id); + expect(done.status).toBe("ready_for_review"); + expect(done.failure).toBeNull(); + expect(done.failedAt).toBeNull(); + }); + + test("whose last run a restart cut off fails, saying so, at the moment its lease ran out", async () => { + const task = await freshTask(); + const expired = new Date(Date.now() - 60_000); + // The first run threw and left its error; the second was cut off, and that is what is said. + await turnFor(task.id, { + attempts: 2, + claimedBy: "gone", + leaseUntil: expired, + lastError: "fetch failed", + updatedAt: new Date(Date.now() - 4 * 60_000), + }); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "failed", + failure: "the run was cut off partway through", + failedAt: expired.toISOString(), + }); + }); + + test("whose turn never reached the queue fails to start, once it should have", async () => { + const task = await freshTask(); + expect(await statusOf(task.id)).toBe("working"); + const later = createTaskStore(database, { + now: () => new Date(Date.now() + 2 * 60_000), + }); + expect(await later.get(owner.id, task.id)).toMatchObject({ + status: "failed", + failure: "it never started", + }); + }); + + test("stays Failed whatever is said in its conversation, until a turn of its own finishes", async () => { + const task = await freshTask(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + await channelStore.recordActivity( + owner, + task.channelId, + { + text: "I couldn't finish this: a service didn't answer.", + agentId: bot, + at: new Date(), + }, + { id: `task-${task.id}-x:failed` }, + ); + // A Bot answer from the browser is not the task's turn: it may not have had the task at all. + await channelStore.recordActivity(owner, task.channelId, { + text: "Try it with the other glaze.", + agentId: null, + at: new Date(Date.now() + 1_000), + }); + await channelStore.recordActivity(owner, task.channelId, { + text: "Evening. Here's what's in your inbox.", + agentId: bot, + at: new Date(Date.now() + 2_000), + }); + expect(await statusOf(task.id)).toBe("failed"); + // Its own turn finishing is. + const again = await turnFor(task.id); + await database + .update(workItems) + .set({ attempts: 1, finishedAt: new Date() }) + .where(again); + expect(await statusOf(task.id)).toBe("ready_for_review"); + }); +}); + +describe("/api/tasks", () => { + const routes = createTaskRoutes(taskStore, async (context, next) => { + context.set("actor", { + id: context.req.header("x-person") ?? owner.id, + } as AuthenticatedActor); + await next(); + }); + const call = (person: string, path: string, method = "GET") => + routes.request(path, { method, headers: { "x-person": person } }); + + test("lists the person's own tasks, newest first, and resolves and reopens them", async () => { + const first = await freshTask("Older"); + await Bun.sleep(5); + const second = await freshTask("Newer"); + const listed = (await (await call(owner.id, "/")).json()) as { + tasks: TaskRecord[]; + }; + const ids = listed.tasks.map((task) => task.id); + expect(ids.indexOf(second.id)).toBeGreaterThanOrEqual(0); + expect(ids.indexOf(second.id)).toBeLessThan(ids.indexOf(first.id)); + expect(listed.tasks.every((task) => typeof task.status === "string")).toBe( + true, + ); + // Each names its conversation's thread, which its approvals and questions carry. + expect(listed.tasks.find((task) => task.id === second.id)?.threadId).toBe( + second.threadId, + ); + const one = (await (await call(owner.id, `/${second.id}`)).json()) as { + task: TaskRecord; + }; + expect(one.task).toEqual( + listed.tasks.find((task) => task.id === second.id) as TaskRecord, + ); + + await turnFor(second.id, { attempts: 1, finishedAt: new Date() }); + const resolved = (await ( + await call(owner.id, `/${second.id}/resolve`, "POST") + ).json()) as { task: TaskRecord }; + expect(resolved.task.status).toBe("resolved"); + expect(resolved.task.resolvedAt).not.toBeNull(); + const reopened = (await ( + await call(owner.id, `/${second.id}/reopen`, "POST") + ).json()) as { task: TaskRecord }; + expect(reopened.task.status).toBe("ready_for_review"); + expect(reopened.task.resolvedAt).toBeNull(); + }); + + test("another person's task is the same as no task", async () => { + const mine = await freshTask("Mine"); + for (const [path, method] of [ + [`/${mine.id}`, "GET"], + [`/${mine.id}/resolve`, "POST"], + [`/${mine.id}/reopen`, "POST"], + ] as const) { + const response = await call(other.id, path, method); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: "Couldn't find that task.", + }); + } + const theirs = (await (await call(other.id, "/")).json()) as { + tasks: TaskRecord[]; + }; + expect(theirs.tasks).toEqual([]); + expect((await taskStore.get(owner.id, mine.id)).resolvedAt).toBeNull(); + }); + + test("a failure reading them says so in plain words, and logs no detail", async () => { + const failing = createTaskRoutes( + { + ...taskStore, + list: async () => { + throw new Error("connection to 10.0.0.4 refused"); + }, + }, + async (context, next) => { + context.set("actor", { id: owner.id } as AuthenticatedActor); + await next(); + }, + ); + const logged = spyOn(console, "error").mockImplementation(() => {}); + try { + const response = await failing.request("/"); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "Couldn't load your tasks. Try again.", + }); + expect(JSON.stringify(logged.mock.calls)).not.toContain("10.0.0.4"); + } finally { + logged.mockRestore(); + } + }); +}); diff --git a/server/tests/tasks.test.ts b/server/tests/tasks.test.ts new file mode 100644 index 0000000000..22d46c51d4 --- /dev/null +++ b/server/tests/tasks.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import { replyEnding, replyTail } from "../src/tasks/reply-ending"; +import { nextStepOf, plainFailure, taskStatus } from "../src/tasks/store"; + +describe("plainFailure", () => { + test("says why a run couldn't finish without the error itself", () => { + expect(plainFailure("The routine's turn was stopped after 300s.")).toBe( + "the run took longer than 5 minutes", + ); + expect(plainFailure("The turn finished without saying anything.")).toBe( + "the run ended without a reply", + ); + // Never the Bot in the third person: each reads after "I couldn't finish this:" too. + expect( + plainFailure("This Bot or its channel is no longer available to you."), + ).toBe("this conversation is gone"); + expect(plainFailure("This Bot is paused.")).toBe("work is paused"); + expect( + plainFailure( + "This Bot uses gpt-x, which isn't an allowed model. Ask an admin to allow it.", + ), + ).toBe("the model isn't allowed here"); + expect( + plainFailure("This turn failed before it could be tried again."), + ).toBe("it failed before it could be tried again"); + expect(plainFailure("fetch failed")).toBe("a service didn't answer"); + expect(plainFailure("invalid x-api-key")).toBe( + "the model provider turned the request away", + ); + expect(plainFailure('429 {"error":{"type":"rate_limit"}} at line 3')).toBe( + "something went wrong during the run", + ); + // A run cut off by a crash leaves no error of its own. + expect(plainFailure(null)).toBe("the run was cut off partway through"); + }); +}); + +describe("taskStatus", () => { + const at = (needsInput: boolean, working: boolean, resolved: boolean) => + taskStatus({ needsInput, working, resolved }); + + test("puts what waits on the person first, then work under way, then the person's done", () => { + expect(at(false, false, false)).toBe("ready_for_review"); + expect( + taskStatus({ + needsInput: false, + working: false, + resolved: false, + failed: true, + }), + ).toBe("failed"); + // Marked done beats failed; a retry under way beats both. + expect( + taskStatus({ + needsInput: false, + working: false, + resolved: true, + failed: true, + }), + ).toBe("resolved"); + expect( + taskStatus({ + needsInput: false, + working: true, + resolved: false, + failed: true, + }), + ).toBe("working"); + expect(at(false, false, true)).toBe("resolved"); + expect(at(false, true, false)).toBe("working"); + expect(at(false, true, true)).toBe("working"); + expect(at(true, true, false)).toBe("needs_input"); + expect(at(true, false, true)).toBe("needs_input"); + }); +}); + +describe("a task's next step", () => { + test("is the Bot's last sentence, or the person's own words quoted", () => { + expect(nextStepOf("I drafted it. Send it to Danny?", true)).toBe( + "I drafted it. Send it to Danny?", + ); + expect(nextStepOf(" Make it shorter ", false)).toBe("“Make it shorter”"); + const long = nextStepOf("a".repeat(500), false); + expect(Array.from(long ?? "")).toHaveLength(122); + expect(long?.endsWith("…”")).toBe(true); + expect(nextStepOf(" ", true)).toBeNull(); + expect(nextStepOf(null, false)).toBeNull(); + }); + + test("keeps a reply's last sentence, without its Markdown", () => { + expect( + replyTail( + "I drafted the reply.\n\n- **Rosa:** she needs a yes.\n\nShould I send it now?", + ), + ).toBe("Should I send it now?"); + expect( + replyTail("Sent. It's in her thread. Tell me if Rosa answers by Friday."), + ).toBe("Tell me if Rosa answers by Friday."); + // A short closing question is kept with what it asks about. + expect( + replyTail("I can draft a note to Shopify support.\n\nDo you want me to?"), + ).toBe("I can draft a note to Shopify support. Do you want me to?"); + expect(replyTail("1. Check the `order` page.")).toBe( + "Check the order page.", + ); + expect(replyTail(" \n\n")).toBeNull(); + expect(replyTail(undefined)).toBeNull(); + expect(replyTail("x".repeat(500))?.length).toBe(240); + }); +}); + +describe("how a reply ends", () => { + test("on its last question even before a remark, past lists and drafts, through emoji and quotes", () => { + expect( + replyEnding( + "I read it. Should Sam have Friday off? If so, someone else has to finish packing. I haven't replied to Sam.", + ), + ).toEqual({ tail: "Should Sam have Friday off?", asks: true }); + expect( + replyEnding("Here are the options:\n\n- Send draft A\n- Send draft B"), + ).toEqual({ tail: "Here are the options:", asks: false }); + expect( + replyEnding( + "I drafted it. Want me to send it?\n\n**Draft:** Hi Leah, thanks!", + ), + ).toEqual({ tail: "I drafted it. Want me to send it?", asks: true }); + expect(replyEnding("Done. Shall I?")).toEqual({ + tail: "Done. Shall I?", + asks: true, + }); + expect(replyEnding("Should I send it (to Rosa)?)").asks).toBe(true); + expect(replyEnding("Is this ok? 🙂")).toEqual({ + tail: "Is this ok?", + asks: true, + }); + expect(replyEnding('Which one: "A" or "B?"').asks).toBe(true); + expect( + replyEnding( + "Paste the key terms here and I'll go through them with you.", + ), + ).toEqual({ + tail: "Paste the key terms here and I'll go through them with you.", + asks: false, + }); + }); + + test("asks for input only from a spoken final paragraph, past draft quotes and list items", () => { + for (const text of [ + "Which one should I use? 🙂", + "Ready. Shall I send it? Let me know.", + "Which date works?\n\n- Friday\n- Monday", + "Which version?\n\n> Would you like a quote?", + ]) + expect(replyEnding(text).asks).toBe(true); + for (const text of [ + "- Is Friday okay?", + "> Is Friday okay?", + "“Can we book Friday?”", + "Done.\n\n- Why not Friday?", + "I found the date.\n\n**Draft:** Can we book Friday?", + ]) + expect(replyEnding(text).asks).toBe(false); + }); +}); From 8941266aba91fac17ce2f6c2692475c6c95b9b24 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 01:55:19 -0700 Subject: [PATCH 03/18] fix(toolkit): name a lock refused on a turn's first ask The thread lock refused when a turn first asks for it now throws `ThreadLockBusyError`, with the platform's 409 as its cause. That one conflict means the turn never ran, so a queue can put it back without spending an attempt. The same 409 later in a run, or on a renewal, may follow a tool call or a model's spend, and is raised as it comes. A turn also takes `systemMessages`: instructions the server resolved for it, such as the skills picked for a reply to a task. They are kept as history rows and given to the model as context, since a built-in Bot's model leaves system rows out. `keepLease` gives up exactly one interval before the lock lapses, not one tick after: with renewals failing, the `>` let the lock expire on the tick it should have stopped. --- server/tests/routine-run-turn.test.ts | 33 +++++++++ toolkit/src/lease.ts | 6 +- toolkit/src/run-turn.ts | 75 +++++++++++++++++---- toolkit/tests/lease.test.ts | 97 ++++++++++++++++++++++++++- 4 files changed, 194 insertions(+), 17 deletions(-) diff --git a/server/tests/routine-run-turn.test.ts b/server/tests/routine-run-turn.test.ts index 1628847f1b..3ec95a75f2 100644 --- a/server/tests/routine-run-turn.test.ts +++ b/server/tests/routine-run-turn.test.ts @@ -721,6 +721,39 @@ describe("the lock is released on every exit path", () => { }); describe("cleanup only runs for a lock that was actually taken", () => { + test("only a lock refused on the first ask is safe to defer", async () => { + const conflict = lockConflict(); + const busy = harness({ acquireFails: () => conflict }); + await expect(busy.run()).rejects.toMatchObject({ + name: "ThreadLockBusyError", + cause: conflict, + }); + expect(busy.calls.runs).toEqual([]); + expect(busy.calls.cleaned).toEqual([]); + + // A provider's own 409, or the platform's once the run is under way, is raised as it comes. + const providerConflict = Object.assign(new Error("Provider conflict"), { + status: 409, + }); + await expect( + harness({ acquireFails: () => providerConflict }).run(), + ).rejects.toBe(providerConflict); + await expect( + harness({ drive: ({ observer }) => observer.error(conflict) }).run(), + ).rejects.toBe(conflict); + await expect( + harness({ + renew: () => { + throw conflict; + }, + heartbeatMs: 1, + drive: ({ agent, observer }) => { + agent.onAbort = () => observer.complete(); + }, + }).run(), + ).rejects.toBe(conflict); + }); + test("a rejected acquire never cleans up, renews, or starts the heartbeat", async () => { // `ɵcleanupThreadLock` is DELETE on the platform's lock endpoint. If cleanup ran when the // acquire itself failed, it would delete whoever DOES hold the lock — the person's own browser diff --git a/toolkit/src/lease.ts b/toolkit/src/lease.ts index a4754e0339..8401eeaf69 100644 --- a/toolkit/src/lease.ts +++ b/toolkit/src/lease.ts @@ -55,8 +55,8 @@ export class LockLapsedError extends Error { * Lost means one of two things, and either one stops the heartbeat and calls `onLost`, once: * * - A renewal is refused as a conflict ({@link isLockConflict}): somebody else holds it now. - * - No renewal has succeeded for so long that the lock will have lapsed before the next tick: - * longer than `lifetimeMs - everyMs`, whatever the failures were — network errors, 5xx, 401, a + * - No renewal has succeeded for so long that the lock will have lapsed by the next tick: + * at least `lifetimeMs - everyMs`, whatever the failures were — network errors, 5xx, 401, a * request that never settles. Checked on each tick rather than when a renewal fails, because a * renewal that hangs never fails. One interval short of the lifetime, so the turn stops while the * lock is still its own rather than up to one interval after it has gone. @@ -90,7 +90,7 @@ export function keepLease(input: { onLost(error); }; heartbeat = setInterval(() => { - if (now() - renewedAt > giveUpAfterMs) { + if (now() - renewedAt >= giveUpAfterMs) { lose(new LockLapsedError(lifetimeMs)); return; } diff --git a/toolkit/src/run-turn.ts b/toolkit/src/run-turn.ts index cff83a08c5..2b3dd5c11b 100644 --- a/toolkit/src/run-turn.ts +++ b/toolkit/src/run-turn.ts @@ -67,7 +67,7 @@ import { } from "./headless-tools"; import { sanitizeSeededHistory } from "./history-sanitize"; import type { AuditInitiator } from "./initiator"; -import { keepLease } from "./lease"; +import { isLockConflict, keepLease, ThreadLockBusyError } from "./lease"; import { TURN_LIMITS } from "./limits"; import type { ComponentCatalog, TurnAdmission } from "./ports"; import { historyOrEmpty } from "./thread-history"; @@ -87,6 +87,12 @@ export type TurnRunner = (input: { messageId: string; }; userMessage?: Message; + /** + * Instructions the server resolved for this turn, such as the skills a person picked for a reply + * to a task. Kept as rows of the turn's history and given to the model as context. Never from the + * browser: only what the server itself looked up and checked belongs here. + */ + systemMessages?: Extract[]; /** Durable inbound delivery id; reused by lock, runner and emitted events. */ runId?: string; /** How deep in a Bot-to-Bot chain this turn is. Absent is a person's own turn, depth zero. */ @@ -290,9 +296,16 @@ export function turnMessages(input: { historicIds: ReadonlySet; /** This turn's own message; ignored for a continuation. */ turn: Message; + systemMessages?: Parameters[0]["systemMessages"]; continuation?: Parameters[0]["continuation"]; }): { messages: Message[]; persistedInputMessages: Message[] } { - const { seeded, historicIds, turn, continuation } = input; + const { + seeded, + historicIds, + turn, + continuation, + systemMessages = [], + } = input; const messages: Message[] = continuation ? sanitizeSeededHistory([ ...continuation.snapshot.messages, @@ -303,7 +316,11 @@ export function turnMessages(input: { ...continuation.result, }, ]) - : [...seeded, turn]; + : [ + ...seeded, + ...systemMessages.filter((message) => !historicIds.has(message.id)), + turn, + ]; return { messages, persistedInputMessages: messages.filter( @@ -319,16 +336,35 @@ export function turnInput(input: { messages: Message[]; state: unknown; tools: readonly HeadlessTool[]; + systemMessages?: Parameters[0]["systemMessages"]; continuation?: Parameters[0]["continuation"]; }): RunAgentInput { - const { threadId, runId, messages, state, tools, continuation } = input; + const { + threadId, + runId, + messages, + state, + tools, + systemMessages = [], + continuation, + } = input; return { threadId, runId, messages, state, tools: tools.filter((tool) => !tool.hidden).map((tool) => tool.definition), - context: continuation?.snapshot.context ?? [], + /* + * A built-in Bot's model leaves system rows of the history out, so the instructions resolved for + * this turn go to it as context too. Only those: never a system row from the history, and never + * one a browser wrote. A continuation keeps the context it was captured with. + */ + context: + continuation?.snapshot.context ?? + systemMessages.map((message, index) => ({ + description: `Selected skill ${index + 1}`, + value: message.content, + })), forwardedProps: continuation?.snapshot.forwardedProps, ...(continuation ? { @@ -386,14 +422,24 @@ async function takeLease(input: { heartbeatMs, onLost, } = input; - await intelligence.ɵacquireThreadLock({ - threadId, - runId, - userId, - agentId, - ttlSeconds, - ...(learningContainerId ? { learningContainerId } : {}), - }); + try { + await intelligence.ɵacquireThreadLock({ + threadId, + runId, + userId, + agentId, + ttlSeconds, + ...(learningContainerId ? { learningContainerId } : {}), + }); + } catch (error) { + /* + * Refused on the first ask, the turn has not run, so this one conflict is safe to put back on + * a queue without counting it. The same 409 later in the run, or on a renewal, may follow a + * tool call or a model's spend, and is raised as it comes. + */ + if (isLockConflict(error)) throw new ThreadLockBusyError(error); + throw error; + } const heartbeat = keepLease({ renew: () => intelligence.ɵrenewThreadLock({ threadId, runId, ttlSeconds }), @@ -599,6 +645,7 @@ export function createTurnRunner(options: { continuation, signal, userMessage, + systemMessages, runId: suppliedRunId, depth, onText, @@ -683,6 +730,7 @@ export function createTurnRunner(options: { seeded, historicIds: new Set(history.messages.map((message) => message.id)), turn, + systemMessages, ...(continuation ? { continuation } : {}), }); @@ -732,6 +780,7 @@ export function createTurnRunner(options: { messages, state: agent.state, tools: headlessTools, + systemMessages, ...(continuation ? { continuation } : {}), }); diff --git a/toolkit/tests/lease.test.ts b/toolkit/tests/lease.test.ts index d344ebfd8f..b8da702532 100644 --- a/toolkit/tests/lease.test.ts +++ b/toolkit/tests/lease.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { isLockConflict, keepLease, @@ -15,6 +15,30 @@ function platformError(status: number): Error { const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +// Drive the real interval callback against an explicit clock without waiting for production TTLs. +function manualLease(input: Omit[0], "now">) { + let clock = 0; + const interval = spyOn(globalThis, "setInterval"); + try { + const lease = keepLease({ ...input, now: () => clock }); + const callback = interval.mock.calls[0]?.[0]; + if (typeof callback !== "function") { + lease.stop(); + throw new Error("No lease heartbeat scheduled"); + } + return { + stop: lease.stop, + tickAt: async (time: number) => { + clock = time; + callback(); + await Promise.resolve(); + }, + }; + } finally { + interval.mockRestore(); + } +} + describe("isLockConflict", () => { test("only the platform's 409 is a lock that belongs to somebody else", () => { expect(isLockConflict(platformError(409))).toBe(true); @@ -42,6 +66,77 @@ describe("ThreadLockBusyError", () => { }); describe("keepLease", () => { + for (const everyMs of [30_000, 15_000]) { + for (const renewal of ["rejected", "pending"] as const) { + test(`${renewal} renewals lose a 120s lease exactly one ${everyMs}ms interval before expiry`, async () => { + const lost: unknown[] = []; + let renewals = 0; + const lease = manualLease({ + renew: () => { + renewals += 1; + return renewal === "rejected" + ? Promise.reject(platformError(503)) + : new Promise(() => {}); + }, + everyMs, + lifetimeMs: 120_000, + onLost: (error) => lost.push(error), + }); + try { + const boundary = 120_000 - everyMs; + for (let time = everyMs; time < boundary; time += everyMs) + await lease.tickAt(time); + await lease.tickAt(boundary - 1); + expect(lost).toEqual([]); + const beforeBoundary = renewals; + + await lease.tickAt(boundary); + expect(lost).toHaveLength(1); + expect(lost[0]).toBeInstanceOf(LockLapsedError); + expect(renewals).toBe(beforeBoundary); + + await lease.tickAt(boundary + 1); + await lease.tickAt(120_000); + expect(lost).toHaveLength(1); + expect(renewals).toBe(beforeBoundary); + } finally { + lease.stop(); + } + }); + } + + test(`a successful ${everyMs}ms renewal moves the exact safety boundary forward`, async () => { + const lost: unknown[] = []; + let renewals = 0; + const lease = manualLease({ + renew: async () => { + renewals += 1; + if (renewals !== 2) throw platformError(503); + }, + everyMs, + lifetimeMs: 120_000, + onLost: (error) => lost.push(error), + }); + try { + await lease.tickAt(everyMs); + await lease.tickAt(2 * everyMs); + await lease.tickAt(120_000 - everyMs); + expect(lost).toEqual([]); + + const boundary = 120_000 + everyMs; + await lease.tickAt(boundary - 1); + expect(lost).toEqual([]); + const beforeBoundary = renewals; + await lease.tickAt(boundary); + expect(lost).toHaveLength(1); + expect(lost[0]).toBeInstanceOf(LockLapsedError); + expect(renewals).toBe(beforeBoundary); + } finally { + lease.stop(); + } + }); + } + test("renews until stopped", async () => { let renewals = 0; const lease = keepLease({ From 5abbd15dc66a3e7921be652ce0907c5eb3fbe017 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 01:55:33 -0700 Subject: [PATCH 04/18] feat(tasks): Start on a suggestion opens a task of its own Started work used to run in the conversation the suggestion was delivered to, so that conversation filled with the work. Start now opens a task: a new conversation with the Bot and the record that names it, written in one transaction so a lookup never sees one without the other. Its turn runs there, queued and picked up straight away. The proactive engine runs a task's turn at most twice: once, and once more after a throw or a restart. A turn refused the conversation's lock on its first ask is given back without spending a run. Out of runs, it stays on the queue with its reason, which is what reads as failed, and the Bot says so in the conversation. A turn queued before retries existed is never run a second time. Try again (`again`) reruns the failed turn, keyed so two at once run once. A reply (`reply`) is taken onto the queue before it is answered, with the questions it answers and any files and skills checked and kept, so it survives a restart. A task's turns, its replies and the answers to question cards asked in it share one order (`TASK_ORDER`), so none overtakes another. A task's conversation stays out of the person's own: `direct()` never picks one, the roster marks it with `taskId`, research never adopts it, and its title comes from the ask inside the task frame. While a task's turn holds its lease, the conversation's work list says so; the engine says it is no longer busy once the turn is off the queue, so that list is read after the turn has left it. `POST /api/proactive/suggestions/:id/start` answers `{ suggestion, task }`, `GET /api/proactive/suggestions/:id` reads one suggestion whatever its status, and `/api/tasks/:id/reply` and `/again` steer a task. --- CHANGELOG.md | 13 +- server/src/activity/activity.ts | 14 +- server/src/app.ts | 8 +- server/src/approvals/person-response-sweep.ts | 5 +- server/src/channels/routes.ts | 34 +- server/src/channels/summary.ts | 7 +- server/src/channels/working.ts | 60 +- server/src/index.ts | 75 +- server/src/proactive/engine.ts | 621 ++- server/src/proactive/routes.ts | 19 +- server/src/tasks/reply-context.ts | 87 + server/src/tasks/routes.ts | 120 +- server/src/tasks/store.ts | 185 +- server/src/tasks/threads.ts | 202 + server/src/tasks/types.ts | 13 + .../channel-activity.integration.test.ts | 1 + server/tests/channel-routes.test.ts | 1 + .../tests/channel-summary.integration.test.ts | 24 + server/tests/proactive.test.ts | 103 +- server/tests/task-threads.integration.test.ts | 3415 +++++++++++++++++ server/tests/task-turn.test.ts | 164 + server/tests/tasks.test.ts | 7 + shared/task-brief.test.ts | 100 + shared/task-brief.ts | 121 + shared/task-reply.ts | 10 + 25 files changed, 5286 insertions(+), 123 deletions(-) create mode 100644 server/src/tasks/reply-context.ts create mode 100644 server/src/tasks/threads.ts create mode 100644 server/tests/task-threads.integration.test.ts create mode 100644 server/tests/task-turn.test.ts create mode 100644 shared/task-brief.test.ts create mode 100644 shared/task-brief.ts create mode 100644 shared/task-reply.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 27745c6f84..69451e390f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -**Before upgrading.** Sixteen things change for an existing deployment: +**Before upgrading.** Seventeen things change for an existing deployment: - Bots now share one computer per person instead of each having its own. Migration `0069_isolated_bots` adds `agent_profiles.isolated`, off for every existing Bot. With the supervisor (laptop, Desktop, Compose), a person's computer is created the first time one of their @@ -64,6 +64,9 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. research run replaces them. - Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. +- Migration `0070_tasks` adds one table, `tasks`, naming each task's conversation (below). It starts + empty. A suggestion started before the upgrade and still queued opens a task when it runs, rather + than running in the conversation it was suggested in. - On Helm's `shared` computer and the docker-compose `agent-computer`, each Bot's files now live in `computers/` on the workspace volume (below). Files saved before the upgrade stay at the root of the volume, where no Bot sees them; move them into a Bot's folder to give them back. @@ -78,6 +81,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `COMPUTER_MEMORY_HIGH_PERCENT=0` and `COMPUTER_TAB_IDLE_MS=0` (Helm: `computers.memoryHighPercent` and `computers.tabIdleMs`) keep the old behaviour. +### Started work gets a conversation of its own + +Start on a suggestion now opens a task: a new conversation with the Bot where the work runs, so the +conversation the suggestion came from stays as it was. A task's status is worked out from the work +queue and the approval and question inboxes on every read. A turn that fails is tried once more, then +reads as failed with the reason in plain words and can be tried again; replying in a task's +conversation steers it, in order, even across a restart. The new `/api/tasks` routes serve it. + ### A computer closes browsers nobody is using when its memory runs high A computer runs one browser for each Bot that is browsing, and the only limits were a count and half diff --git a/server/src/activity/activity.ts b/server/src/activity/activity.ts index de20ded105..a2973f9110 100644 --- a/server/src/activity/activity.ts +++ b/server/src/activity/activity.ts @@ -12,6 +12,7 @@ import { and, desc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm"; import { HANDOFF_KIND } from "../agents/handoff"; import { WAKE_UP_KIND, type WakeUpPayload } from "../agents/wake-up"; +import { pendingPersonQuestion } from "../approvals/questions"; import type { Database } from "../db/client"; import { approvalRequests, @@ -338,9 +339,8 @@ export function createActivityStore(database: Database) { .from(workItems) .where( and( - eq(workItems.kind, "person.question"), - isNull(workItems.finishedAt), - sql`${workItems.payload}->>'actorId' = ${ownerUserId}`, + // Less any a reply in a task's conversation has already answered. + pendingPersonQuestion(ownerUserId), sql`coalesce(${workItems.payload}->>'sourceBotId', ${workItems.payload}->>'botId') = ${agentId}`, ), ) @@ -478,13 +478,7 @@ export function createActivityStore(database: Database) { total: sql`count(*)::int`, }) .from(workItems) - .where( - and( - eq(workItems.kind, "person.question"), - isNull(workItems.finishedAt), - sql`${workItems.payload}->>'actorId' = ${ownerUserId}`, - ), - ) + .where(pendingPersonQuestion(ownerUserId)) .groupBy( sql`coalesce(${workItems.payload}->>'sourceBotId', ${workItems.payload}->>'botId')`, ); diff --git a/server/src/app.ts b/server/src/app.ts index 5a4f70ba26..edfa88b053 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1724,7 +1724,13 @@ export function createApp( ), ); if (coworker?.tasks) - app.route("/api/tasks", createTaskRoutes(coworker.tasks, requireUser)); + app.route( + "/api/tasks", + // A task's turns run on the proactive engine, so replying and Try again are its. + createTaskRoutes(coworker.tasks, requireUser, { + retry: coworker.proactive?.engine, + }), + ); if (coworker?.memory) app.route( "/api/memory", diff --git a/server/src/approvals/person-response-sweep.ts b/server/src/approvals/person-response-sweep.ts index 14684d1137..033c76dcfb 100644 --- a/server/src/approvals/person-response-sweep.ts +++ b/server/src/approvals/person-response-sweep.ts @@ -4,6 +4,7 @@ import type { RunAssertion } from "../agents/callback-token"; import type { AgentActor } from "../agents/profile-types"; import type { ChannelStore } from "../channels/routes"; import type { DeliveryScope } from "../delivery/types"; +import { PERSON_RESPONSE_KIND, TASK_ORDER } from "../proactive/engine"; import type { ResponsibilityStore } from "../responsibilities/types"; import type { WorkQueue } from "../work/queue"; import { resumePersonQuestion } from "./question-resume"; @@ -55,11 +56,13 @@ export function createPersonResponseTick(deps: { return async () => { try { const [item] = await approvalQueue.claim({ - kind: "person.response", + kind: PERSON_RESPONSE_KIND, owner: personResponseOwner, leaseMs: 10 * 60_000, limit: 1, maxAttempts: 1, + // An answer to a question asked in a task's conversation takes its turn there, in order. + serialBy: TASK_ORDER, }); if (!item) return; try { diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index 7d0ba8fd92..38f05f3f80 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -27,6 +27,7 @@ import { channelMemberships, channels, intelligenceChannelMappings, + tasks, } from "../db/schema"; import { parsePageLimit } from "../paging"; import { @@ -67,8 +68,19 @@ export type ChannelSummary = AgentChannel & { pinned: boolean; /** When the caller last had this channel open, or null for never. The caller's, only. */ lastReadAt: Date | null; + /** + * The task this conversation is the work of, or null for an ordinary one. A task works in a + * conversation of its own (tasks/store.ts), which the app lists under Tasks rather than among + * the person's conversations. + */ + taskId: string | null; }; +/** The task a channel is the conversation of, if any: at most one, by the task table's unique index. */ +const TASK_OF_CHANNEL = sql< + string | null +>`(select ${tasks.id} from ${tasks} where ${tasks.channelId} = ${channels.id})`; + /** What a client that ran an agent reports back about the message it just saw. */ export type ChannelActivity = { text: string; @@ -170,7 +182,15 @@ const ROSTER_ORDER = [ type ChannelTransaction = Parameters[0]>[0]; export type ChannelStore = { - create(actor: AgentActor, agentIds: string[]): Promise; + /** + * A new conversation with these Bots. On the caller's transaction when given one, so a record + * that has to name the conversation (a task's) is written with it and never seen without it. + */ + create( + actor: AgentActor, + agentIds: string[], + transaction?: ChannelTransaction, + ): Promise; /** * The one conversation this person has with this Bot alone, made if they have not had one yet. * @@ -406,7 +426,8 @@ export function createChannelStore( }; const store: ChannelStore = { - create(actor, agentIds) { + create(actor, agentIds, transaction) { + if (transaction) return makeChannel(transaction, actor, agentIds); return database.transaction( async (transaction) => makeChannel(transaction, actor, agentIds), { isolationLevel: "read committed" }, @@ -459,6 +480,12 @@ export function createChannelStore( and( isNull(channels.deletedAt), sql`(select count(*) from ${channelAgents} where ${channelAgents.channelId} = ${channels.id}) = 1`, + /* + * Not a task's. A task works in a conversation of its own with the same Bot, usually + * the most recent one, so without this the person's conversation with the Bot would + * become whichever task ran last. + */ + sql`${TASK_OF_CHANNEL} is null`, ), ) .orderBy(...ROSTER_ORDER) @@ -599,6 +626,7 @@ export function createChannelStore( createdAt: channels.createdAt, pinnedAt: channelMemberships.pinnedAt, lastReadAt: channelMemberships.lastReadAt, + taskId: TASK_OF_CHANNEL, }) .from(channels) .innerJoin( @@ -658,6 +686,7 @@ export function createChannelStore( createdAt: row.createdAt, pinned: row.pinnedAt !== null, lastReadAt: row.lastReadAt, + taskId: row.taskId, }); } return { channels: [...summaries.values()], nextCursor }; @@ -1383,6 +1412,7 @@ function channelSummaryDto( pinned: channel.pinned, // Serialised as ISO-8601 like lastMessageAt, so the browser can compare the two as strings. lastReadAt: channel.lastReadAt?.toISOString() ?? null, + taskId: channel.taskId, work: work.map(channelWorkDto), /* * Only ever true from here. A person's own run is not on any row this can read, so a roster diff --git a/server/src/channels/summary.ts b/server/src/channels/summary.ts index 749a8719b2..dcbd55c7f3 100644 --- a/server/src/channels/summary.ts +++ b/server/src/channels/summary.ts @@ -9,6 +9,7 @@ */ import { readFiring } from "@openbot/shared/routine-firing"; +import { readTaskBrief } from "@openbot/shared/task-brief"; import { and, asc, eq, isNull, notExists, sql } from "drizzle-orm"; import type { Database } from "../db/client"; import { @@ -302,9 +303,9 @@ async function openingOf( const rawAsked = textOf(question.content); if (!rawAsked) return null; // A channel a routine opened has the firing frame wrapped around its first message, not a - // person's words. Unwrapped before it can become the title, same as the transcript unwraps it - // before it can become what a person reads. - const asked = readFiring(rawAsked) ?? rawAsked; + // person's words, and so does a task's, round the person's ask. Unwrapped before it can become + // the title, same as the transcript unwraps it before it can become what a person reads. + const asked = readFiring(rawAsked) ?? readTaskBrief(rawAsked) ?? rawAsked; const replied = answer ? textOf(answer.content) : ""; return oneLine( diff --git a/server/src/channels/working.ts b/server/src/channels/working.ts index 19e735f360..263553f091 100644 --- a/server/src/channels/working.ts +++ b/server/src/channels/working.ts @@ -17,6 +17,10 @@ * and one entry here. A hop whose answer is on its way back is the same entry too: the relay is * keyed `relay:`, so it is folded into the hop it carries the answer for, and the timer a * person is watching does not restart when the answer turns around. + * + * A TASK'S TURN IS THE SAME. A task works in a conversation of its own, its turn run by the server + * with no browser streaming it (tasks/store.ts). While that turn holds its lease on the queue, the + * conversation is working on it, and the row says so for as long as the lease does. */ import { TURN_LIMITS } from "@openbot/toolkit/limits"; import { @@ -35,7 +39,8 @@ import { import { alias } from "drizzle-orm/pg-core"; import { HANDOFF_KIND } from "../agents/handoff"; import type { Database } from "../db/client"; -import { agents, routineRuns, routines, workItems } from "../db/schema"; +import { agents, routineRuns, routines, tasks, workItems } from "../db/schema"; +import { PROACTIVE_TASK_KIND } from "../proactive/engine"; import { DEFAULT_MAX_ATTEMPTS } from "../work/queue"; /** Something a conversation is waiting on. */ @@ -62,6 +67,18 @@ export type ChannelWork = botId: string; botName: string | null; since: Date; + } + | { + /** A task's own turn, running in the task's conversation. */ + kind: "task"; + /** The turn's work item. A reply and a Try again are turns of their own. */ + key: string; + taskId: string; + /** The task's Bot. */ + botId: string; + botName: string | null; + /** When the turn was taken onto the queue. */ + since: Date; }; /** The same, on the wire. */ @@ -250,6 +267,47 @@ export async function workIn( }); } + /* + * A task's turn while it runs: unfinished, and its lease live. Queued and not yet claimed, or let + * go after a throw and due its retry, nothing is running in the conversation, and the task's own + * status says what it is waiting for. Only the person's own task, in their own thread. + */ + const turns = await database + .select({ + key: workItems.key, + taskId: tasks.id, + channelId: tasks.channelId, + ownerUserId: tasks.ownerUserId, + botId: tasks.botId, + botName: agents.name, + since: workItems.createdAt, + }) + .from(workItems) + .innerJoin(tasks, eq(tasks.id, sql`${workItems.payload}->>'taskId'`)) + .leftJoin(agents, eq(agents.id, tasks.botId)) + .where( + and( + eq(workItems.kind, PROACTIVE_TASK_KIND), + isNull(workItems.finishedAt), + gt(workItems.leaseUntil, sql`now()`), + inArray( + tasks.channelId, + channels.map((channel) => channel.id), + ), + ), + ); + for (const turn of turns) { + if (ownerByChannel.get(turn.channelId) !== turn.ownerUserId) continue; + add(turn.channelId, { + kind: "task", + key: turn.key, + taskId: turn.taskId, + botId: turn.botId, + botName: turn.botName ?? null, + since: turn.since, + }); + } + for (const list of found.values()) list.sort((left, right) => left.since.getTime() - right.since.getTime()); return found; diff --git a/server/src/index.ts b/server/src/index.ts index fd1a3f1536..da7cedc176 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -282,7 +282,9 @@ import { createRoutineStore } from "./routines/store"; import { createIntentRouter } from "./routing/classify"; import { createModelCompleter } from "./routing/model"; import { createSelfHostBanner } from "./self-host-banner"; +import { createTaskReplyContext } from "./tasks/reply-context"; import { createTaskStore } from "./tasks/store"; +import { createTaskThreads, createTaskTurn } from "./tasks/threads"; import { createTeamBots } from "./team-bots/team-bots"; import { createPackageStatusReader, @@ -1404,6 +1406,13 @@ const deliveryScopeFor: DeliveryScopeResolver = async ( : null; }; +/* + * A person's tasks, read from the queue and the approval and question inboxes the sweeps keep. See + * tasks/store.ts. Made this early because research delivery below asks it which conversations are + * a task's own. + */ +const taskStore = createTaskStore(database); + /* * Memory sources and background research, on by default for each person and Bot, and never switched * back on once the person turns one down. See memory/defaults.ts. @@ -1416,9 +1425,11 @@ const memoryDefaults = createMemoryDefaults({ channelBots: async (ownerUserId, channelId) => (await channelStore.get(await actorFor(ownerUserId), channelId)) ?.agentIds ?? null, - // The same rule research delivery uses, read when a person speaks (long after it is defined). + // The same rule research delivery uses, read when a person speaks (long after it is defined). Never + // a task's own conversation: research writes to the person, and a task's turn works for them. canDeliver: async (ownerUserId, channelId, agentId) => - (await deliveryScopeFor(ownerUserId, channelId, agentId)) !== null, + (await deliveryScopeFor(ownerUserId, channelId, agentId)) !== null && + !(await taskStore.isTaskConversation(ownerUserId, channelId)), mintThreadId: () => threadIdentity.mint(), // Read when a Bot turn starts, long after `teamBots` below is defined. publishedByAnother: (ownerUserId, agentId) => @@ -2161,6 +2172,22 @@ deliveryNotifications = (scope, input) => deliveryRouter.notify(scope, input); * work queue as routines. Suggestions go to the web inbox and the owner's delivery routing. */ const proactiveStore = createProactiveStore(database); +// The files and skills a reply in a task's conversation names, checked as a conversation checks them. +const taskReplyContext = createTaskReplyContext({ + load: (scope, id) => + loadAttachmentForTurn( + database, + { actorId: scope.ownerUserId, threadId: scope.threadId }, + id, + ), + markSent: (scope, ids) => + markAttachmentsSent( + database, + { actorId: scope.ownerUserId, threadId: scope.threadId }, + ids, + ), + skillsFor: (agentId) => pluginStore.listForAgent(agentId), +}); const proactiveEngine = createProactiveEngine({ store: proactiveStore, memory: personalMemoryStore, @@ -2180,30 +2207,21 @@ const proactiveEngine = createProactiveEngine({ }), resolveScope: deliveryScopeFor, notify: (scope, input) => deliveryNotifications(scope, input), - startTask: async (scope, input) => { - const actor = await actorFor(scope.ownerUserId); - await channelStore.recordActivity( - actor, - scope.channelId, - { text: input.text, agentId: null, at: new Date() }, - { id: `${input.runId}:input` }, - ); - const result = await approvalContinuationRunner({ - ...scope, - routineId: input.runId, - instruction: input.text, - userMessage: { id: input.runId, role: "user", content: input.text }, - initiator: PERSON_INITIATOR, - signal: input.signal, - }); - if (result.replyText) - await channelStore.recordActivity( - actor, - scope.channelId, - { text: result.replyText, agentId: scope.agentId, at: new Date() }, - { id: `${input.runId}:reply` }, - ); - }, + // Started work runs as a task, in a conversation of its own. See tasks/threads.ts. + tasks: createTaskThreads({ + database, + store: taskStore, + channels: channelStore, + actorFor, + scopeFor: deliveryScopeFor, + }), + startTask: createTaskTurn({ + channels: channelStore, + actorFor, + runTurn: approvalContinuationRunner, + replyContext: taskReplyContext, + }), + acceptReplyContext: taskReplyContext.accept, catalogue: pluginStore, deniedToolNames: async (ownerUserId, agentId) => { const [tools, allowed] = await Promise.all([ @@ -2229,11 +2247,6 @@ const proactiveSweep = repeatAfterEach(async () => { ); } }, 60_000); -/* - * A person's tasks, read from the queue and the approval and question inboxes the sweeps above - * already keep. See tasks/store.ts. - */ -const taskStore = createTaskStore(database); /* * Follow-ups a Bot scheduled for itself: one headless turn each, through the same turn runner as a * routine, into the person's conversation with that Bot. See agents/wake-up.ts. diff --git a/server/src/proactive/engine.ts b/server/src/proactive/engine.ts index 67ed6318c3..163b32d1b3 100644 --- a/server/src/proactive/engine.ts +++ b/server/src/proactive/engine.ts @@ -7,15 +7,32 @@ * Automatic Learning apply unchanged. The restriction is the initiator (see `restriction.ts`), which * the turn runner's `buildAgentFor` narrows on; this module adds two tools of its own and nothing * that can write outside OpenBot. + * + * STARTED WORK IS A TASK. Start on a suggestion, `start_task` from a conversation, and the + on + * Tasks each open a task: a conversation of its own with the Bot, and the record that names it + * (tasks/store.ts). Its turn runs there, queued as `PROACTIVE_TASK_KIND` and picked up straight away, + * so the conversation the person asked in stays one thread rather than splintering into the work. */ -import type { HeadlessTool } from "@openbot/toolkit/headless-tools"; +import { createHash } from "node:crypto"; +import { frameTaskBrief, frameTaskReply } from "@openbot/shared/task-brief"; +import type { + DurableTaskReply, + TaskReplyContext, +} from "@openbot/shared/task-reply"; +import { + type HeadlessTool, + HeadlessToolSuspension, +} from "@openbot/toolkit/headless-tools"; +import { ThreadLockBusyError } from "@openbot/toolkit/lease"; import type { TurnRunner } from "@openbot/toolkit/run-turn"; +import { sql } from "drizzle-orm"; import { z } from "zod"; import type { AuditInitiator } from "../audit"; import type { MemoryStore } from "../memory/store"; import { REFUSAL_MARKER } from "../plugins/tools"; -import type { WorkQueue } from "../work/queue"; +import { TASK_RUNS, type TaskOrigin } from "../tasks/types"; +import type { SerialOrder, WorkQueue } from "../work/queue"; import { proactiveInitiator, proactiveRunIdOf, @@ -27,13 +44,79 @@ import { MAX_SUGGESTIONS_PER_RUN, ProactiveRefusedError, type ProactiveSetting, + type ProactiveSuggestion, proactiveMemoryInputSchema, suggestionInputSchema, } from "./types"; export const PROACTIVE_RUN_KIND = "proactive.run"; export const PROACTIVE_TASK_KIND = "proactive.task"; +/** The answer to a question card, carried on by the person-response sweep. */ +export const PERSON_RESPONSE_KIND = "person.response"; const RUN_LEASE_MS = 10 * 60_000; +/** + * A task's lease, shorter than research's so a turn a restart cut off is picked up again within + * minutes. Longer than the thread lock's two minutes (`TURN_LIMITS.lockTtlSeconds`), so the dead + * run's lock on the conversation has gone by the time the retry asks for it. + */ +const TASK_LEASE_MS = 3 * 60_000; +/** How long a task's turn that threw waits before its one retry. */ +const TASK_RETRY_MS = 10_000; + +/** + * One task's conversation takes one turn at a time: its own turns, the person's replies, and the + * answers to question cards its Bot asked there. Claimed under this by the task worker below and by + * the person-response sweep (approvals/person-response-sweep.ts). + * + * Two kinds share the order. A task's turn names its task in its payload. An answer names only the + * question it answers, which names the thread it was asked in, so its task is the one whose + * conversation that thread is, read the way the task's status reads it (tasks/store.ts): the + * person's own mapping, the person's own task, and the question's channel when it names one. An + * answer to a question asked anywhere else is in no task, and runs as it always has. + * + * Each kind has the attempt cap its worker claims at: a task's turn runs `TASK_RUNS` times, an + * answer once. + */ +export const TASK_ORDER: SerialOrder = { + name: PROACTIVE_TASK_KIND, + kinds: { [PROACTIVE_TASK_KIND]: TASK_RUNS, [PERSON_RESPONSE_KIND]: 1 }, + groupOf: (row) => sql`case when ${row}.kind = ${PERSON_RESPONSE_KIND} then ( + select task.id from "tasks" as task + inner join "intelligence_channel_mappings" as mapping + on mapping.channel_id = task.channel_id + where task.owner_user_id = ${row}.payload->>'ownerUserId' + and mapping.user_id = task.owner_user_id + and ${row}.payload->'question'->>'actorId' = task.owner_user_id + and mapping.thread_id = ${row}.payload->'question'->>'threadId' + and (${row}.payload->'question'->>'channelId' is null + or ${row}.payload->'question'->>'channelId' = task.channel_id) + ) else ${row}.payload->>'taskId' end`, +}; + +/** + * The message id one run of a task's turn is drawn under, and its work item's readers find it by. + * + * Stable across a claim given back unstarted and across a restart, so a turn deferred behind a busy + * conversation keeps its message. Distinct for a real retry and for each Try again, so a rerun never + * collides with what the run before it left in the conversation. A reply's first run keeps the id + * the browser drew it under. + */ +export function taskExecutionId(input: { + taskId: string; + workKey: string; + attempt: number; + messageId?: string; + again?: boolean; +}): string { + const { taskId, workKey, attempt, messageId, again } = input; + if (messageId && attempt === 1) return messageId; + if (!again && attempt <= 1) return `task-${taskId}`; + const digest = createHash("sha256") + .update(`${workKey}:${attempt}`) + .digest("hex") + .slice(0, 16); + return `task-${taskId}-${digest}`; +} /** A person pressing "Run now" twice in a minute gets one run. */ const RUN_NOW_GAP_MS = 10 * 60_000; @@ -44,6 +127,74 @@ export type ProactiveScope = { threadId: string; }; +/** A task as the engine needs it: which one, its conversation, and its title. */ +export type OpenedTask = { id: string; channelId: string; title: string }; + +/** + * What a task's turn was asked, kept on its work item and carried to a rerun without the run's own + * identity, so Try again asks the same thing again. + */ +export const taskInputSchema = z.object({ + suggestionId: z.string().min(1).optional(), + text: z.string().optional(), + attachmentIds: z.array(z.string()).optional(), + skillIds: z.array(z.string()).optional(), + /** A reply the person sent in the task's conversation, rather than the task's own ask. */ + followup: z.boolean().optional(), + /** Questions waiting when the reply was taken, which it answers. Unchanged across reruns. */ + questionIds: z.array(z.string().min(1)).optional(), + /** The reply the person sent, shared by its retries and by a Try again without new words. */ + inputId: z.string().min(1).optional(), + /** The person's words with a Try again, or a reply's words as they read them. */ + reply: z.string().min(1).optional(), +}); +export type TaskInput = z.infer; + +/** Where a task lives: opened with a conversation of its own, and found again to run its turn. */ +export type TaskThreads = { + /** The person's questions waiting in the task's conversation, which a reply taken now answers. */ + pendingQuestions?(ownerUserId: string, taskId: string): Promise; + /** Marks those questions answered, once the reply that answers them runs. */ + answerQuestions?(ownerUserId: string, ids: readonly string[]): Promise; + /** A new conversation with this Bot, and the task record that names it. */ + open(input: { + ownerUserId: string; + agentId: string; + title: string; + why: string | null; + origin: TaskOrigin; + suggestionId: string | null; + sourceApp: string | null; + sourceLink: string | null; + }): Promise; + /** The task's own conversation, or null once the person can no longer reach it or its Bot. */ + scope(ownerUserId: string, taskId: string): Promise; + /** + * What a failed task's turn was asked, and how many turns it has had. Throws unless the task is + * the person's and failed, or was marked done after failing. + */ + failedTurn( + ownerUserId: string, + taskId: string, + ): Promise<{ ask: TaskInput; turns: number }>; + /** + * A task's turn has come off the queue, finished or let go: its conversation is no longer busy + * with it. Said here rather than by the turn as it ends, because what the conversation is waiting + * on is read from the queue (channels/working.ts), and until the turn is off it the turn is still + * on that list. + */ + settled?(ownerUserId: string, taskId: string): Promise; +}; + +/** Work handed to a task of its own: the ask in the person's words, and where it came from. */ +export type AskedTask = { + title: string; + instruction: string; + why?: string | undefined; + source?: string | undefined; + link?: string | undefined; +}; + export type ProactiveEngineDeps = { store: ProactiveStore; memory: Pick; @@ -61,10 +212,30 @@ export type ProactiveEngineDeps = { scope: ProactiveScope, input: { id: string; text: string; kind: "reply" }, ): Promise; - /** A person-started turn in their channel with this text as their message. */ + /** Opens tasks and finds their conversations again. See `TaskThreads`. */ + tasks: TaskThreads; + /** + * A person-started turn in a task's conversation with this text as their message. `final` is its + * last run: if it fails, the task has failed, and the Bot says so in the conversation. + */ startTask( scope: ProactiveScope, - input: { runId: string; text: string; signal: AbortSignal }, + input: { + runId: string; + text: string; + /** The reply this run carries, as the person sent it, under the id it was taken with. */ + acceptedReply?: { id: string; text: string }; + signal: AbortSignal; + final: boolean; + } & TaskReplyContext, + ): Promise; + /** + * Checks the files and skills a reply names, and keeps the files, before the reply can wait on + * the queue. Absent, a reply with either is refused. + */ + acceptReplyContext?( + scope: ProactiveScope, + input: TaskReplyContext, ): Promise; catalogue: { listServers(): Promise< @@ -75,6 +246,8 @@ export type ProactiveEngineDeps = { deniedToolNames?(ownerUserId: string, agentId: string): Promise; mintThreadId(): string; now?: () => Date; + /** How long a task's turn that threw waits for its retry. Tests pass 0. */ + taskRetryMs?: number; }; /** What a setting suggested before, as `ProactiveStore.history` answers it. */ @@ -124,6 +297,18 @@ export function researchInstruction( ].join("\n"); } +/** The first paragraph of a suggestion's detail: why it matters, without the step after it. */ +function whyOf(detail: string): string | null { + return detail.split(/\n\s*\n/)[0]?.trim() || null; +} + +/** What a started suggestion says to the Bot, as the person's ask: the title, the detail, the source. */ +function suggestionAsk( + suggestion: Pick, +) { + return `${suggestion.title}\n\n${suggestion.detail}${suggestion.sourceLink ? `\n\nSource: ${suggestion.sourceLink}` : ""}`; +} + export function createProactiveEngine(deps: ProactiveEngineDeps) { const now = deps.now ?? (() => new Date()); /** Which setting a run belongs to, for the run's own tools. Local: the turn runs in this process. */ @@ -337,19 +522,49 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { } } + /** + * Claim one queued item of this kind and run it. Research runs once: a background read is not + * worth repeating. A task's turn runs up to `TASK_RUNS` times (see `drainTask`). + */ async function drain( kind: string, work: ( payload: Record, signal: AbortSignal, + attempt: number, + workKey: string, ) => Promise, + options: { + leaseMs: number; + maxAttempts: number; + retryMs: number; + /** Told when a run failed and the item has another run left. */ + onRetry?: () => void; + /** Told once the item is off the queue for now, finished or let go. Never throws. */ + onSettled?: (payload: Record) => Promise; + serialBy?: SerialOrder; + /** + * A turn refused the conversation's lock on its first ask is given back without spending + * its attempt. Only for work that is a single turn, with nothing earlier to repeat. + */ + deferBusy?: boolean; + } = { leaseMs: RUN_LEASE_MS, maxAttempts: 1, retryMs: 60_000 }, ) { + /* + * One owner per claim when a claim can be given back. A refund repeats the attempt number, so a + * late result from a run this worker already gave up on would otherwise match the claim that + * replaced it, on the queue's own ownership check. + */ + const claimOwner = options.deferBusy + ? `${deps.owner}:${crypto.randomUUID()}` + : deps.owner; const [item] = await deps.queue.claim({ kind, - owner: deps.owner, - leaseMs: RUN_LEASE_MS, + owner: claimOwner, + leaseMs: options.leaseMs, limit: 1, - maxAttempts: 1, + maxAttempts: options.maxAttempts, + ...(options.serialBy ? { serialBy: options.serialBy } : {}), }); if (!item) return false; const controller = new AbortController(); @@ -358,8 +573,8 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { .renew({ kind, key: item.key, - owner: deps.owner, - leaseMs: RUN_LEASE_MS, + owner: claimOwner, + leaseMs: options.leaseMs, }) .then( (kept) => { @@ -367,23 +582,34 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { }, () => controller.abort(), ); - }, RUN_LEASE_MS / 3); + }, options.leaseMs / 3); renew.unref?.(); try { - await work(item.payload, controller.signal); - await deps.queue.finish({ kind, key: item.key, owner: deps.owner }); + await work(item.payload, controller.signal, item.attempts, item.key); + await deps.queue.finish({ kind, key: item.key, owner: claimOwner }); } catch (error) { - // One attempt only: a background run is not worth repeating a partial read for. - await deps.queue.release({ + /* + * Only the turn's first ask for the lock can tell work that never began. A provider's own 409, + * or the lock lost once the run was under way, still spends its attempt and keeps its reason. + */ + const unstarted = + options.deferBusy && error instanceof ThreadLockBusyError; + const released = await deps.queue.release({ kind, key: item.key, - owner: deps.owner, - delayMs: 60_000, - reason: error instanceof Error ? error.message : String(error), + owner: claimOwner, + delayMs: options.retryMs, + ...(unstarted + ? { unstarted: true } + : { reason: error instanceof Error ? error.message : String(error) }), }); - throw error; + if (released && (unstarted || item.attempts < options.maxAttempts)) + options.onRetry?.(); + if (!unstarted) throw error; } finally { clearInterval(renew); + if (options.onSettled) + await options.onSettled(item.payload).catch(() => undefined); } return true; } @@ -392,11 +618,170 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { settingId: z.string().min(1), runId: z.string().min(1), }); - const taskSchema = z.object({ + /* + * A task's turn. One started from a suggestion carries the suggestion's id, and its ask is read + * from it; a correction carries the earlier ask in its text, as an asked task does. One queued + * before tasks existed has no `taskId`, and gets its task when it runs, so started work never + * lands in the person's own conversation again. + */ + const taskSchema = taskInputSchema.extend({ ownerUserId: z.string().min(1), - suggestionId: z.string().min(1), + taskId: z.string().min(1).optional(), + /** Queued by Try again, beside the turn that failed. */ + again: z.boolean().optional(), + /** The id the person's reply was drawn under; its first run records the message with it. */ + messageId: z.string().min(1).optional(), + /** + * How many runs this turn was queued with. A turn queued without it is from before retries, + * when one failure was final, so it is never run a second time. + */ + runs: z.number().int().positive().optional(), }); + /** Opens the task a started suggestion becomes, in its own conversation with the suggesting Bot. */ + function openFor(ownerUserId: string, suggestion: ProactiveSuggestion) { + return deps.tasks.open({ + ownerUserId, + agentId: suggestion.agentId, + title: suggestion.title, + why: whyOf(suggestion.detail), + origin: "suggestion", + suggestionId: suggestion.id, + sourceApp: suggestion.sourceApp, + sourceLink: suggestion.sourceLink, + }); + } + + /** + * Runs one queued task's turn in its own conversation, as the person's message. A rerun, whether + * the retry or Try again, is a new run with its own id, so it never collides with what the one + * before left in the conversation, and it says the ask again. + */ + async function runTask( + payload: Record, + signal: AbortSignal, + attempt: number, + workKey: string, + ) { + const work = taskSchema.parse(payload); + /* + * A turn from before retries failed once and was left, as final then. Run again now, it would + * act weeks late on whatever it was asked; refused instead, before anything is opened for it, + * so it stays failed with Try again where it has a task and opens none where it has not. + */ + if (attempt > 1 && work.runs === undefined) + throw new ProactiveRefusedError( + "This turn failed before it could be tried again.", + ); + let ask = work.text; + let taskId = work.taskId; + if (work.suggestionId) { + const suggestion = await deps.store.suggestion( + work.ownerUserId, + work.suggestionId, + ); + ask ??= suggestionAsk(suggestion); + taskId ??= (await openFor(work.ownerUserId, suggestion)).id; + } + if ( + !taskId || + ask === undefined || + (!ask && !(work.followup && work.attachmentIds?.length)) + ) + throw new ProactiveRefusedError("This task has nothing to start."); + const scope = await deps.tasks.scope(work.ownerUserId, taskId); + if (!scope) + throw new ProactiveRefusedError( + "This Bot or its channel is no longer available to you.", + ); + // A reply from before replies named their questions answers none: which ones were waiting + // when it was sent cannot be told from what is waiting now. + const priorQuestions = work.followup ? (work.questionIds ?? []) : []; + // The reply already answers them. Marked first, so a write that fails after a turn that + // succeeded can never make the turn, and what it did, run again. + await deps.tasks.answerQuestions?.(work.ownerUserId, priorQuestions); + const runId = taskExecutionId({ + taskId, + workKey, + attempt, + ...(work.messageId ? { messageId: work.messageId } : {}), + ...(work.again ? { again: work.again } : {}), + }); + /* + * A turn that stops on a request only the person can answer (an approval, a question) has + * ended where it should, not failed: finished, never retried. A retry would ask, or send, + * again. The task reads as needing input from that request (tasks/store.ts). + */ + try { + await deps.startTask(scope, { + runId, + ...(work.inputId && work.reply + ? { acceptedReply: { id: work.inputId, text: work.reply } } + : {}), + // A reply is the person's own message; the ask goes with it for the model, not in their bubble. + text: work.followup + ? ask + : work.reply + ? frameTaskReply(ask, work.reply) + : frameTaskBrief(ask), + ...(work.attachmentIds?.length + ? { attachmentIds: work.attachmentIds } + : {}), + ...(work.skillIds?.length ? { skillIds: work.skillIds } : {}), + signal, + final: attempt >= TASK_RUNS, + }); + } catch (error) { + if (!(error instanceof HeadlessToolSuspension)) throw error; + } + } + + /** One queued task's turn, run up to `TASK_RUNS` times, the retry kicked off after its wait. */ + function drainTask() { + const retryMs = deps.taskRetryMs ?? TASK_RETRY_MS; + return drain(PROACTIVE_TASK_KIND, runTask, { + leaseMs: TASK_LEASE_MS, + serialBy: TASK_ORDER, + deferBusy: true, + maxAttempts: TASK_RUNS, + retryMs, + onRetry: () => track(Bun.sleep(retryMs).then(startSoon)), + onSettled: async (payload) => { + const work = taskSchema.safeParse(payload); + if (work.success && work.data.taskId) + await deps.tasks.settled?.(work.data.ownerUserId, work.data.taskId); + }, + }); + } + + /* + * A task starts now, not at the next sweep a minute away: the person was just told it is under + * way. Each start claims one queued turn, so tasks started together run side by side, one per + * start, and the sweep stays the backstop for any a crash left behind. + */ + const kicked = new Set>(); + function track(work: Promise) { + const tracked = work.finally(() => kicked.delete(tracked)); + kicked.add(tracked); + } + function startSoon() { + track( + drainTask() + .then((ran) => { + if (ran) startSoon(); + }) + .catch((error: unknown) => { + console.error( + JSON.stringify({ + type: "proactive-task-error", + error: error instanceof Error ? error.name : "UnknownError", + timestamp: new Date().toISOString(), + }), + ); + }), + ); + } + return { researchInstruction, /** The headless tools for one proactive turn; empty for any other initiator. */ @@ -465,8 +850,14 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { "dismissed", ); }, - /** The person chose to act on it: a normal, person-started turn in their channel. */ - async start(ownerUserId: string, suggestionId: string) { + /** + * The person chose to act on it: a task of its own, opened now, with a person-started turn in + * its conversation, so the conversation the suggestion was delivered to stays as it was. + */ + async start( + ownerUserId: string, + suggestionId: string, + ): Promise<{ suggestion: ProactiveSuggestion; task: OpenedTask }> { const suggestion = await deps.store.suggestion(ownerUserId, suggestionId); const setting = await deps.store.get(ownerUserId, suggestion.settingId); if ( @@ -479,17 +870,160 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { throw new ProactiveRefusedError( "This Bot or its channel is no longer available to you.", ); + // Only one Start wins: the suggestion moves from open to started once. const started = await deps.store.resolveSuggestion( ownerUserId, suggestionId, "started", ); + const task = await openFor(ownerUserId, started); await deps.queue.offer({ kind: PROACTIVE_TASK_KIND, key: suggestionId, - payload: { ownerUserId, suggestionId }, + payload: { + ownerUserId, + suggestionId, + taskId: task.id, + runs: TASK_RUNS, + }, + }); + startSoon(); + return { suggestion: started, task }; + }, + /** + * Work asked for in so many words, by `start_task` or the + on Tasks: opened as a task with its + * own conversation and started there. No cap on how many a person has. + */ + async startAsked( + ownerUserId: string, + agentId: string, + input: AskedTask, + ): Promise { + const task = await deps.tasks.open({ + ownerUserId, + agentId, + title: input.title, + why: input.why ?? null, + origin: "asked", + suggestionId: null, + sourceApp: input.source ?? null, + sourceLink: input.link ?? null, + }); + await deps.queue.offer({ + kind: PROACTIVE_TASK_KIND, + key: `task:${task.id}`, + payload: { + ownerUserId, + taskId: task.id, + text: input.instruction, + runs: TASK_RUNS, + }, + }); + startSoon(); + return task; + }, + /** + * Try again: a task whose turn couldn't finish runs it once more, in the same conversation, as a + * turn of its own beside the one that failed. Keyed on how many turns the task has had, so two + * at once run once and the second is refused. A reply the person typed on the failed task is + * their own message in the conversation, with the ask beside it for the model, so the run has + * the task and their words both. + */ + async again( + ownerUserId: string, + taskId: string, + input: { reply?: string; messageId?: string } = {}, + ): Promise { + const { ask, turns } = await deps.tasks.failedTurn(ownerUserId, taskId); + let text = ask.text; + if (input.reply) { + if (ask.followup) { + // A reply's turn already carries everything it was asked in its text. + text = frameTaskReply(text ?? "", input.reply); + } else if (ask.reply) { + // An earlier correction may never have reached the model's history. Kept in the ask + // before the new words replace it. + if (text === undefined && ask.suggestionId) + text = suggestionAsk( + await deps.store.suggestion(ownerUserId, ask.suggestionId), + ); + text = frameTaskReply(text ?? "", ask.reply); + } + } + const queued = await deps.queue.offer({ + kind: PROACTIVE_TASK_KIND, + key: `task:${taskId}:again:${turns}`, + payload: { + ownerUserId, + taskId, + ...ask, + ...(text === undefined ? {} : { text }), + ...(input.reply + ? { + reply: input.reply, + inputId: input.messageId ?? crypto.randomUUID(), + } + : {}), + ...(input.messageId ? { messageId: input.messageId } : {}), + again: true, + runs: TASK_RUNS, + }, }); - return started; + /* + * Two at once: the other got there first. Said, not answered as done, so a reply that lost + * the race goes back to the person rather than vanishing. + */ + if (queued !== "queued") + throw new ProactiveRefusedError( + "This task is already being tried again.", + ); + startSoon(); + }, + /** + * The person replied in a task's conversation. Taken onto the queue before it is answered, in + * the task's own order, so it waits behind a turn still running there and survives a restart. + * Keyed by the message, so sending it twice takes it once. + */ + async reply(ownerUserId: string, taskId: string, input: DurableTaskReply) { + const scope = await deps.tasks.scope(ownerUserId, taskId); + if (!scope) + throw new ProactiveRefusedError( + "This task is no longer available to you.", + ); + if (input.attachmentIds?.length || input.skillIds?.length) { + if (!deps.acceptReplyContext) + throw new ProactiveRefusedError( + "Files and skills are unavailable for this task.", + ); + await deps.acceptReplyContext(scope, input); + } + // The questions waiting as it is taken are the ones it answers, whenever it runs. + const questionIds = + (await deps.tasks.pendingQuestions?.(ownerUserId, taskId)) ?? []; + await deps.queue.offer({ + kind: PROACTIVE_TASK_KIND, + key: `task:${taskId}:reply:${input.messageId}`, + payload: { + ownerUserId, + taskId, + text: input.reply, + reply: input.reply || "Sent attachments", + ...(input.attachmentIds?.length + ? { attachmentIds: input.attachmentIds } + : {}), + ...(input.skillIds?.length ? { skillIds: input.skillIds } : {}), + messageId: input.messageId, + inputId: input.messageId, + followup: true, + questionIds, + runs: TASK_RUNS, + }, + }); + startSoon(); + }, + /** Settles once every task started straight away has finished its turn. For tests and shutdown. */ + async idle() { + while (kicked.size > 0) await Promise.allSettled([...kicked]); }, /** One scheduling pass: offer what is due, then run at most one research turn and one task. */ async sweep() { @@ -501,32 +1035,19 @@ export function createProactiveEngine(deps: ProactiveEngineDeps) { payload: { settingId: setting.id, runId }, }); } - await drain(PROACTIVE_RUN_KIND, async (payload, signal) => { - const { settingId, runId } = payloadSchema.parse(payload); - await run(settingId, runId, signal); - }); - await drain(PROACTIVE_TASK_KIND, async (payload, signal) => { - const { ownerUserId, suggestionId } = taskSchema.parse(payload); - const suggestion = await deps.store.suggestion( - ownerUserId, - suggestionId, - ); - const setting = await deps.store.get(ownerUserId, suggestion.settingId); - const scope = await deps.resolveScope( - ownerUserId, - setting.channelId, - suggestion.agentId, - ); - if (!scope) - throw new ProactiveRefusedError( - "This Bot or its channel is no longer available to you.", - ); - await deps.startTask(scope, { - runId: `suggestion-${suggestion.id}`, - text: `${suggestion.title}\n\n${suggestion.detail}${suggestion.sourceLink ? `\n\nSource: ${suggestion.sourceLink}` : ""}`, - signal, - }); - }); + /* + * Side by side, so a research run that throws or takes its ten minutes never holds back a + * task's turn, which is the only path a turn cut off by a restart is retried by. + */ + const outcomes = await Promise.allSettled([ + drain(PROACTIVE_RUN_KIND, async (payload, signal) => { + const { settingId, runId } = payloadSchema.parse(payload); + await run(settingId, runId, signal); + }), + drainTask(), + ]); + for (const outcome of outcomes) + if (outcome.status === "rejected") throw outcome.reason; }, }; } diff --git a/server/src/proactive/routes.ts b/server/src/proactive/routes.ts index f64c3616df..7f17c1a1a1 100644 --- a/server/src/proactive/routes.ts +++ b/server/src/proactive/routes.ts @@ -5,7 +5,10 @@ import type { ProactiveEngine } from "./engine"; import type { ProactiveStore } from "./store"; import { ProactiveNotFoundError, ProactiveRefusedError } from "./types"; -/** `/api/proactive`: a person's background-research settings and the suggestions they produced. */ +/** + * `/api/proactive`: a person's background-research settings, the suggestions they produced, and + * Start, which opens one as a task. + */ export function createProactiveRoutes( store: ProactiveStore, engine: ProactiveEngine, @@ -68,14 +71,24 @@ export function createProactiveRoutes( suggestions: await store.suggestions(context.var.actor.id), }), ); - routes.post("/suggestions/:id/start", async (context) => + // One of the person's suggestions, whatever its status, so a card can say Started or Dismissed. + routes.get("/suggestions/:id", async (context) => context.json({ - suggestion: await engine.start( + suggestion: await store.suggestion( context.var.actor.id, context.req.param("id"), ), }), ); + /* + * Starting one opens it as a task, in a conversation of its own (engine.ts), and answers with + * both: the suggestion, now started, and the task to open. + */ + routes.post("/suggestions/:id/start", async (context) => + context.json( + await engine.start(context.var.actor.id, context.req.param("id")), + ), + ); routes.post("/suggestions/:id/dismiss", async (context) => context.json({ suggestion: await engine.dismiss( diff --git a/server/src/tasks/reply-context.ts b/server/src/tasks/reply-context.ts new file mode 100644 index 0000000000..ad1c0a3ac0 --- /dev/null +++ b/server/src/tasks/reply-context.ts @@ -0,0 +1,87 @@ +import { + type AttachmentPart, + attachmentUrl, + classifyAttachment, +} from "@openbot/shared/attachments"; +import type { TaskReplyContext } from "@openbot/shared/task-reply"; +import { + newInlineBudget, + resolveAttachmentParts, + type StoredAttachment, +} from "../channels/attachment-parts"; +import type { ProactiveScope } from "../proactive/engine"; +import { ProactiveRefusedError } from "../proactive/types"; + +/** + * The files and skills a reply in a task's conversation names, checked the way an ordinary + * conversation checks them: each file the person's own in that conversation, each skill granted to + * the task's Bot. + * + * Only references are kept with the reply on the queue. The file's bytes and the skill's + * instructions are read when the turn runs, and checked again then, so a grant taken away while + * the reply waited is not used. + */ +export function createTaskReplyContext(deps: { + load(scope: ProactiveScope, id: string): Promise; + markSent(scope: ProactiveScope, ids: readonly string[]): Promise; + skillsFor( + agentId: string, + ): Promise<{ skills: { slug: string; instructions: string }[] }>; +}) { + async function resolve(scope: ProactiveScope, input: TaskReplyContext) { + const skillIds = [...new Set(input.skillIds ?? [])]; + const granted = skillIds.length + ? (await deps.skillsFor(scope.agentId)).skills + : []; + const skillInstructions = skillIds.map((id) => { + const skill = granted.find((entry) => entry.slug === id); + if (!skill) + throw new ProactiveRefusedError( + "A selected skill is no longer available to this Bot. Remove it and send again.", + ); + return skill.instructions; + }); + const attachments: AttachmentPart[] = []; + // Through the same checks a conversation's own files take, the per-run inline budget included. + const ids = [...new Set(input.attachmentIds ?? [])]; + await resolveAttachmentParts( + ids.map((id) => ({ + type: "document", + source: { type: "url", value: attachmentUrl(id) }, + })), + async (id) => { + const stored = await deps.load(scope, id); + if (!stored) + throw new ProactiveRefusedError( + "An attachment is no longer available in this conversation. Attach it again.", + ); + attachments.push({ + type: + classifyAttachment(stored.mimeType) === "image" + ? "image" + : "document", + source: { + type: "url", + value: attachmentUrl(id), + mimeType: stored.mimeType, + }, + metadata: { attachmentId: id, filename: stored.name }, + }); + return stored; + }, + "fail", + newInlineBudget(), + ); + return { attachments, skillInstructions }; + } + return { + resolve, + /** Checked before the reply is taken, and its files kept, since it may wait across a restart. */ + async accept(scope: ProactiveScope, input: TaskReplyContext) { + await resolve(scope, input); + if (input.attachmentIds?.length) + await deps.markSent(scope, input.attachmentIds); + }, + }; +} +export type TaskReplyContextReader = ReturnType; diff --git a/server/src/tasks/routes.ts b/server/src/tasks/routes.ts index 6552c0b048..3dbc020583 100644 --- a/server/src/tasks/routes.ts +++ b/server/src/tasks/routes.ts @@ -1,19 +1,91 @@ +import { MAX_ATTACHMENTS_PER_MESSAGE } from "@openbot/shared/attachments"; +import type { DurableTaskReply } from "@openbot/shared/task-reply"; import { Hono, type MiddlewareHandler } from "hono"; import { bodyLimit } from "hono/body-limit"; +import { z } from "zod"; +import { AgentNotFoundError } from "../agents/profile-store"; import type { AppVariables } from "../auth/guards"; +import { ProactiveRefusedError } from "../proactive/types"; import type { TaskStore } from "./store"; import { TaskNotFoundError } from "./types"; -/** `/api/tasks`: the person's own tasks, as Tasks and the task page read them. */ +/** Replies to a task, and Try again: the proactive engine's `reply` and `again`. */ +export type TaskRetry = { + reply( + ownerUserId: string, + taskId: string, + input: DurableTaskReply, + ): Promise; + again( + ownerUserId: string, + taskId: string, + input?: { reply?: string; messageId?: string }, + ): Promise; +}; + +/** The longest reply a task takes. */ +const MAX_REPLY = 4000; +/** + * The id the browser drew the person's message under, so the message the run records is the same + * one and is not drawn twice. + */ +const messageId = z.string().regex(/^[A-Za-z0-9-]{8,64}$/); + +const againSchema = z + .object({ + reply: z.string().trim().max(MAX_REPLY).optional(), + messageId: messageId.optional(), + }) + .strict(); + +/** A reply carries words, files, or both, and the skills picked for it. Try again takes words only. */ +const replySchema = z + .object({ + reply: z.string().trim().max(MAX_REPLY).default(""), + messageId, + attachmentIds: z + .array(z.uuid()) + .max(MAX_ATTACHMENTS_PER_MESSAGE) + .optional(), + skillIds: z.array(z.string().min(1).max(200)).max(32).optional(), + }) + .strict() + .refine( + (input) => input.reply.length > 0 || (input.attachmentIds?.length ?? 0) > 0, + ); + +/** A request the routes refuse as it stands: the person can fix it and send it again. */ +export class TaskRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = "TaskRefusedError"; + } +} + +/** `/api/tasks`: the person's own tasks, as Tasks and the task page read and steer them. */ export function createTaskRoutes( store: TaskStore, requireUser: MiddlewareHandler<{ Variables: AppVariables }>, + options: { + /** Absent, replying and Try again are not served: there is nothing to run them. */ + retry?: TaskRetry | undefined; + } = {}, ) { const routes = new Hono<{ Variables: AppVariables }>(); routes.use("*", requireUser, bodyLimit({ maxSize: 16_384 })); routes.onError((error, context) => { if (error instanceof TaskNotFoundError) return context.json({ error: error.message }, 404); + // Asked for something the task's state refuses: Try again on one that did not fail, say. + if (error instanceof ProactiveRefusedError) + return context.json({ error: error.message }, 409); + if (error instanceof AgentNotFoundError) + return context.json( + { error: "Couldn't find that Bot. Reload the page." }, + 404, + ); + if (error instanceof TaskRefusedError) + return context.json({ error: error.message }, 400); console.error( JSON.stringify({ type: "tasks-route-error", @@ -32,6 +104,52 @@ export function createTaskRoutes( task: await store.get(context.var.actor.id, context.req.param("id")), }), ); + const retry = options.retry; + if (retry) { + /* + * A reply in the task's conversation, to steer it. Taken onto the queue and answered at once, + * with the task as it now reads: its turn runs when the conversation is free, and until then + * the reply is in `queuedReplies`. + */ + routes.post("/:id/reply", async (context) => { + const owner = context.var.actor.id; + const id = context.req.param("id"); + const parsed = replySchema.safeParse( + await context.req.json().catch(() => null), + ); + if (!parsed.success) + throw new TaskRefusedError( + `A reply of at most ${MAX_REPLY} characters or an attachment, and its message id, are required.`, + ); + await store.get(owner, id); + await retry.reply(owner, id, parsed.data); + return context.json({ task: await store.get(owner, id) }, 202); + }); + /* + * Try again: the failed turn runs once more, in the task's own conversation. A reply typed on a + * failed task comes here too, and goes with the ask, so the run has the task and the person's + * words both. + */ + routes.post("/:id/again", async (context) => { + const owner = context.var.actor.id; + const id = context.req.param("id"); + const body = context.req + .header("content-type") + ?.includes("application/json") + ? await context.req.json().catch(() => null) + : {}; + const parsed = againSchema.safeParse(body ?? {}); + if (!parsed.success) + throw new TaskRefusedError( + `Say it in at most ${MAX_REPLY} characters.`, + ); + await retry.again(owner, id, { + ...(parsed.data.reply ? { reply: parsed.data.reply } : {}), + ...(parsed.data.messageId ? { messageId: parsed.data.messageId } : {}), + }); + return context.json({ task: await store.get(owner, id) }); + }); + } routes.post("/:id/resolve", async (context) => context.json({ task: await store.resolve(context.var.actor.id, context.req.param("id")), diff --git a/server/src/tasks/store.ts b/server/src/tasks/store.ts index 5f97688760..e2ab86d809 100644 --- a/server/src/tasks/store.ts +++ b/server/src/tasks/store.ts @@ -1,6 +1,8 @@ +import { randomUUID } from "node:crypto"; import { and, desc, eq, inArray, isNull, or, type SQL, sql } from "drizzle-orm"; import { type PersonQuestion, + pendingPersonQuestion, questionResponseMessage, } from "../approvals/questions"; import type { Database } from "../db/client"; @@ -11,10 +13,16 @@ import { tasks, workItems, } from "../db/schema"; -import { PROACTIVE_TASK_KIND } from "../proactive/engine"; +import { + PROACTIVE_TASK_KIND, + type TaskInput, + taskInputSchema, +} from "../proactive/engine"; +import { ProactiveRefusedError } from "../proactive/types"; import { DEFAULT_MAX_ATTEMPTS } from "../work/queue"; import { replyEnding, replyTail } from "./reply-ending"; import { + type NewTask, TASK_RUNS, TaskNotFoundError, type TaskRecord, @@ -82,7 +90,10 @@ export function plainFailure(error: string | null | undefined): string { [/before (it|retries) could/i, "it failed before it could be tried again"], [/\bpaused\b/i, "work is paused"], [/allowed model/i, "the model isn't allowed here"], - [/lock/i, "the conversation was busy with another reply"], + [ + /lock|busy with another turn/i, + "the conversation was busy with another reply", + ], [/cancel|abort/i, "the run was stopped"], [ /\b40[13]\b|authentication|api.?key/i, @@ -122,6 +133,10 @@ type Row = typeof tasks.$inferSelect; type TaskState = { /** The person's completion, unless genuinely new durable work superseded it. */ resolvedAt: Date | null; + /** What the turn its status is read from was asked: what Try again asks again. */ + turnInput: Record | null; + /** How many turns of its own it has had, which keys Try again so two at once run once. */ + turns: number; queuedReplies: { messageId: string; text: string }[]; answeredQuestionIds: Set; needsInput: boolean; @@ -140,6 +155,7 @@ const payload = (field: string) => sql`${workItems.payload}->>${field}`; const approvalThread = sql`${approvalRequests.action}->>'threadId'`; export type TaskStore = ReturnType; +type TaskTransaction = Parameters[0]>[0]; /** Every read and write is the owner's: another person's task is the same as no task. */ export function createTaskStore( @@ -163,6 +179,8 @@ export function createTaskStore( row.id, { resolvedAt: row.resolvedAt, + turnInput: null, + turns: 0, queuedReplies: [], answeredQuestionIds: new Set(), needsInput: false, @@ -300,6 +318,7 @@ export function createTaskStore( database .select({ ...turnState, + input: workItems.payload, reply: payload("reply"), followup: payload("followup"), questionIds: sql< @@ -386,21 +405,33 @@ export function createTaskStore( ), ); const taskTurns = [ - ...turns.map((turn) => ({ ...turn, maxAttempts: TASK_RUNS })), - ...responses.map(({ question, response, ...turn }) => ({ - ...turn, - // The person-response worker accepts one attempt, unlike a task's own turns. - maxAttempts: 1, - suggestionId: null, - followup: "true", - reply: response, - messageId: questionResponseMessage( + ...turns.map((turn) => ({ ...turn, own: true, maxAttempts: TASK_RUNS })), + ...responses.map(({ question, response, ...turn }) => { + const message = questionResponseMessage( question, response, `person-response:${turn.key}`, - ).id, - questionIds: [turn.key], - })), + ); + return { + ...turn, + own: false, + // The person-response worker accepts one attempt, unlike a task's own turns. + maxAttempts: 1, + suggestionId: null, + followup: "true", + reply: response, + messageId: message.id, + questionIds: [turn.key], + // Tried again, the answer is carried on as a reply of the person's, in its own words. + input: { + text: message.content, + followup: true, + questionIds: [turn.key], + inputId: message.id, + reply: response, + } as Record, + }; + }), ].sort((a, b) => b.acceptedAt - a.acceptedAt || b.key.localeCompare(a.key)); const latestOutcome = new Map(); const unfinished = new Map(); @@ -414,8 +445,10 @@ export function createTaskStore( state.get(taskId)?.answeredQuestionIds.add(id); if (taskId) { const entry = state.get(taskId); - if (entry && turn.taskId && turn.afterResolution) - entry.resolvedAt = null; + if (entry && turn.taskId) { + if (turn.own) entry.turns += 1; + if (turn.afterResolution) entry.resolvedAt = null; + } const latest = latestOutcome.get(taskId); // Ties retain creation/key order, but insertion order never hides a later terminal turn. if (!latest || turn.outcomeAt > latest.outcomeAt) @@ -454,6 +487,7 @@ export function createTaskStore( if (!entry) continue; // Any queued/live turn takes priority, including one whose older insert committed late. const turn = unfinished.get(row.id) ?? latestOutcome.get(row.id); + entry.turnInput = turn?.input ?? null; if (!turn) { /* * Opened, and its turn not queued yet: the moment between the two writes that start a task. @@ -539,14 +573,20 @@ export function createTaskStore( }; } - async function one(owner: string, id: string): Promise { + async function oneWithState(owner: string, id: string) { const [row] = await database .select() .from(tasks) .where(owned(owner, id)) .limit(1); if (!row) throw new TaskNotFoundError(); - return recordOf(row, (await stateOf(owner, [row])).get(id)); + const state = (await stateOf(owner, [row])).get(id); + if (!state) throw new TaskNotFoundError(); + return { record: recordOf(row, state), state }; + } + + async function one(owner: string, id: string): Promise { + return (await oneWithState(owner, id)).record; } async function setResolved(owner: string, id: string, resolved: boolean) { @@ -562,6 +602,115 @@ export function createTaskStore( } return { + /** + * Records a task opened in a conversation made for it. On the transaction that made the + * conversation, so neither is ever there without the other. + */ + async create( + input: NewTask, + executor: Database | TaskTransaction = database, + ): Promise { + const at = now(); + const [row] = await executor + .insert(tasks) + .values({ id: randomUUID(), ...input, createdAt: at, updatedAt: at }) + .returning(); + if (!row) throw new Error("The task was not saved."); + return recordOf(row, undefined); + }, + /** + * The person's questions waiting in a task's conversation, less any a reply already answered: + * the ones a reply taken now answers. + */ + async pendingQuestions(owner: string, id: string): Promise { + const task = await one(owner, id); + const rows = await database + .select({ key: workItems.key }) + .from(workItems) + .where( + and( + pendingPersonQuestion(owner), + sql`${workItems.payload}->>'mode' = 'completed_question'`, + or( + sql`${workItems.payload}->>'channelId' = ${task.channelId}`, + task.threadId + ? sql`${workItems.payload}->>'threadId' = ${task.threadId}` + : sql`false`, + ), + ), + ); + return rows.map((row) => row.key); + }, + /** Marks the person's questions answered, by a reply that has now run. */ + async answerQuestions(owner: string, ids: readonly string[]) { + if (ids.length === 0) return; + await database + .update(workItems) + .set({ finishedAt: sql`now()`, updatedAt: sql`now()` }) + .where( + and( + eq(workItems.kind, "person.question"), + inArray(workItems.key, [...ids]), + sql`${workItems.payload}->>'actorId' = ${owner}`, + isNull(workItems.finishedAt), + ), + ); + }, + /** Whether this conversation of the person's is a task's own. */ + async isTaskConversation(owner: string, channelId: string) { + const [row] = await database + .select({ id: tasks.id }) + .from(tasks) + .where( + and(eq(tasks.ownerUserId, owner), eq(tasks.channelId, channelId)), + ) + .limit(1); + return row !== undefined; + }, + /** Where a task lives, without working out what it is doing: for running its turn. */ + async locate( + owner: string, + id: string, + ): Promise<{ channelId: string; botId: string } | null> { + const [row] = await database + .select({ channelId: tasks.channelId, botId: tasks.botId }) + .from(tasks) + .where(owned(owner, id)) + .limit(1); + return row ?? null; + }, + /** + * What a failed task's turn was asked to do, to run it again, and how many turns it has had: + * the ask its failed turn carried, or, for one never queued, its suggestion or its title. + * Refused unless it failed, so Try again never starts a second run beside a live one. + * + * A task marked done after it failed is taken too. Reading what to ask leaves it done; only the + * rerun, once it is on the queue, makes it not done. + */ + async failedTurn( + owner: string, + id: string, + ): Promise<{ ask: TaskInput; turns: number }> { + const { record: task, state } = await oneWithState(owner, id); + const failed = + task.status === "failed" || + (task.status === "resolved" && task.failure !== null); + if (!failed) + throw new ProactiveRefusedError( + "Only a task that couldn't finish can be tried again.", + ); + const input = taskInputSchema.parse(state.turnInput ?? {}); + const suggestionId = input.suggestionId ?? task.suggestionId; + // An empty text is a reply of files alone, and is kept. Only a missing ask falls back. + const ask = + input.text !== undefined + ? input + : { + ...input, + ...(suggestionId ? { suggestionId } : { text: task.title }), + }; + return { ask, turns: state.turns }; + }, /** The owner's tasks, newest first. No cap on how many there are; a page is what a list shows. */ async list(owner: string, limit = 200): Promise { const rows = await database diff --git a/server/src/tasks/threads.ts b/server/src/tasks/threads.ts new file mode 100644 index 0000000000..8341997dee --- /dev/null +++ b/server/src/tasks/threads.ts @@ -0,0 +1,202 @@ +import { frameTaskInput, readTaskBrief } from "@openbot/shared/task-brief"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; +import { ThreadLockBusyError } from "@openbot/toolkit/lease"; +import type { TurnRunner } from "@openbot/toolkit/run-turn"; +import type { AgentActor } from "../agents/profile-types"; +import { PERSON_INITIATOR } from "../audit"; +import type { ChannelStore } from "../channels/routes"; +import type { Database } from "../db/client"; +import type { + ProactiveEngineDeps, + ProactiveScope, + TaskThreads, +} from "../proactive/engine"; +import type { TaskReplyContextReader } from "./reply-context"; +import { replyTail } from "./reply-ending"; +import { plainFailure, type TaskStore } from "./store"; + +/* + * The two halves of a task the proactive engine leaves to the server's assembly: making its + * conversation, and running its turn there. + */ + +/** + * Opens a task in a new conversation with the Bot, and finds that conversation again to run it. + * + * A NEW CONVERSATION EVERY TIME, never the person's own with the Bot, so the work stays out of the + * conversation it was asked in. The channel store leaves task conversations out when it looks for + * that one (`direct`), and the roster marks them (`taskId`), so the app never mistakes one for the + * person's own. + */ +export function createTaskThreads(deps: { + database: Pick; + store: Pick< + TaskStore, + "create" | "locate" | "failedTurn" | "pendingQuestions" | "answerQuestions" + >; + channels: Pick; + actorFor: (ownerUserId: string) => Promise; + /** The conversation as a turn needs it, or null once the person cannot reach it or its Bot. */ + scopeFor: ( + ownerUserId: string, + channelId: string, + agentId: string, + ) => Promise; +}): TaskThreads { + async function scope(ownerUserId: string, taskId: string) { + const task = await deps.store.locate(ownerUserId, taskId); + return task ? deps.scopeFor(ownerUserId, task.channelId, task.botId) : null; + } + return { + pendingQuestions: (ownerUserId, taskId) => + deps.store.pendingQuestions(ownerUserId, taskId), + answerQuestions: (ownerUserId, ids) => + deps.store.answerQuestions(ownerUserId, ids), + async open(input) { + const actor = await deps.actorFor(input.ownerUserId); + /* + * One transaction for both. `direct` tells a task's conversation by its task row, so a + * conversation committed before its row could be taken for the person's own with the Bot by a + * lookup that ran in between. + */ + return deps.database.transaction(async (transaction) => { + const channel = await deps.channels.create( + actor, + [input.agentId], + transaction, + ); + const task = await deps.store.create( + { + ownerUserId: input.ownerUserId, + botId: input.agentId, + channelId: channel.id, + title: input.title, + why: input.why, + origin: input.origin, + suggestionId: input.suggestionId, + sourceApp: input.sourceApp, + sourceLink: input.sourceLink, + }, + transaction, + ); + return { id: task.id, channelId: task.channelId, title: task.title }; + }); + }, + scope, + failedTurn: (ownerUserId, taskId) => + deps.store.failedTurn(ownerUserId, taskId), + async settled(ownerUserId, taskId) { + const found = await scope(ownerUserId, taskId); + if (found) await deps.channels.signalBusy(found.threadId, false); + }, + }; +} + +/** + * A task's turn: the person's ask as their message in the task's conversation, a person-started turn + * through the runner every started task uses, and the Bot's reply recorded, which is what tells an + * open conversation to read the thread again. + * + * The conversation reads as busy from the moment it starts, since no browser streams this turn. The + * engine says it is no longer busy once the turn is off the queue (`TaskThreads.settled`). + * + * STOPPING FOR THE PERSON IS NOT FAILING. A turn that reaches Allow once, or asks a question, ends + * with the request waiting on them; answering it carries the turn on (approvals/resume-sweep.ts). So + * it is recorded as the Bot's line and the turn is done, rather than thrown as an error the queue + * would log and hold. + * + * FAILING ON ITS LAST RUN IS SAID. A turn that throws is thrown on, for the queue to hold and retry. + * On its last run (`final`) the Bot's line says it couldn't finish and why, in plain words, so the + * conversation does not end on the person's ask with nothing after it. A turn refused the + * conversation's lock on its first ask never ran, and is put back on the queue without a word. + */ +export function createTaskTurn(deps: { + channels: Pick; + actorFor: (ownerUserId: string) => Promise; + runTurn: TurnRunner; + /** Resolves a reply's files and skills. Absent, a reply with either fails. */ + replyContext?: Pick; +}): ProactiveEngineDeps["startTask"] { + return async (scope, input) => { + const actor = await deps.actorFor(scope.ownerUserId); + const record = (text: string, agentId: string | null, id: string) => + deps.channels.recordActivity( + actor, + scope.channelId, + { text, agentId, at: new Date() }, + { id }, + ); + // The conversation's line is the person's ask, never the frame the model reads around it. + const brief = readTaskBrief(input.text); + await record( + input.acceptedReply?.text ?? brief ?? (input.text || "Sent attachments"), + null, + brief === null ? input.runId : `${input.runId}:input`, + ); + await deps.channels.signalBusy(scope.threadId, true).catch(() => {}); + try { + const context = + input.attachmentIds?.length || input.skillIds?.length + ? await (deps.replyContext?.resolve(scope, input) ?? + Promise.reject( + new Error("Files and skills are unavailable for this task."), + )) + : { attachments: [], skillInstructions: [] }; + /* + * A reply carries who sent it and what they said in its message, so the reply reads the same + * however many times it is run. The runner's instruction and any files are the person's input + * as it was. + */ + const text = input.acceptedReply + ? frameTaskInput(input.text, input.acceptedReply) + : input.text; + const content = context.attachments.length + ? [ + ...(text ? [{ type: "text" as const, text }] : []), + ...context.attachments, + ] + : text; + const result = await deps.runTurn({ + ...scope, + routineId: input.runId, + instruction: input.text, + userMessage: { id: input.runId, role: "user", content }, + ...(context.skillInstructions.length + ? { + systemMessages: context.skillInstructions.map( + (instructions, index) => ({ + id: `${input.runId}:skill:${index}`, + role: "system" as const, + content: instructions, + }), + ), + } + : {}), + initiator: PERSON_INITIATOR, + signal: input.signal, + }); + /* + * Recorded as how the reply ends, its next step, rather than how it starts: Tasks shows this + * line after the task's status (`TaskRecord.next`), and a conversation's preview keeps only + * its first 200 characters, which for a long reply would be its opening. + */ + if (result.replyText) + await record( + replyTail(result.replyText) ?? result.replyText, + scope.agentId, + `${input.runId}:reply`, + ); + } catch (error) { + if (!(error instanceof HeadlessToolSuspension)) { + if (input.final && !(error instanceof ThreadLockBusyError)) + await record( + `I couldn't finish this: ${plainFailure(error instanceof Error ? error.message : String(error))}.`, + scope.agentId, + `${input.runId}:failed`, + ).catch(() => {}); + throw error; + } + await record(error.message, scope.agentId, `${input.runId}:waiting`); + } + }; +} diff --git a/server/src/tasks/types.ts b/server/src/tasks/types.ts index 961b9cc670..cf993170ec 100644 --- a/server/src/tasks/types.ts +++ b/server/src/tasks/types.ts @@ -66,6 +66,19 @@ export type TaskRecord = { updatedAt: string; }; +/** What a task is opened with. Everything else about it is read from the ledgers. */ +export type NewTask = { + ownerUserId: string; + botId: string; + channelId: string; + title: string; + why: string | null; + origin: TaskOrigin; + suggestionId: string | null; + sourceApp: string | null; + sourceLink: string | null; +}; + export class TaskNotFoundError extends Error { constructor() { super("Couldn't find that task."); diff --git a/server/tests/channel-activity.integration.test.ts b/server/tests/channel-activity.integration.test.ts index 30e523692f..a3893e44b1 100644 --- a/server/tests/channel-activity.integration.test.ts +++ b/server/tests/channel-activity.integration.test.ts @@ -247,6 +247,7 @@ describe("channel activity", () => { createdAt: expect.any(Date), pinned: false, lastReadAt: null, + taskId: null, }, ]); }); diff --git a/server/tests/channel-routes.test.ts b/server/tests/channel-routes.test.ts index 2109e1ac3a..355303a06c 100644 --- a/server/tests/channel-routes.test.ts +++ b/server/tests/channel-routes.test.ts @@ -215,6 +215,7 @@ describe("what a channel is waiting on", () => { createdAt: since, pinned: false, lastReadAt: null, + taskId: null, }); const store = fakeStore({ list: async () => ({ diff --git a/server/tests/channel-summary.integration.test.ts b/server/tests/channel-summary.integration.test.ts index 367b898bd4..f51e3f49c3 100644 --- a/server/tests/channel-summary.integration.test.ts +++ b/server/tests/channel-summary.integration.test.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { FIRING_FRAME, frameFiring } from "@openbot/shared/routine-firing"; +import { frameTaskBrief, TASK_FRAME } from "@openbot/shared/task-brief"; import { eq } from "drizzle-orm"; import { createAgentProfileStore } from "../src/agents/profile-store"; import type { AgentActor } from "../src/agents/profile-types"; @@ -327,6 +328,29 @@ describe("naming a claimed conversation", () => { expect((await summaryOf(channel.id))?.summary).toBe("Standup summary"); }); + test("titles a task's conversation from the person's ask, not the task frame", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await offer(channel.id); + + let excerptSeen = ""; + await summariseClaimedChannels( + options({ + // A task's first message is the person's ask inside the frame that says it is a task: see + // `shared/task-brief.ts`. + transcript: transcriptOf(frameTaskBrief("Draft a reply to Rosa.")), + title: async (excerpt) => { + excerptSeen = excerpt; + return "Reply to Rosa"; + }, + }), + ); + + expect(excerptSeen).toContain("Draft a reply to Rosa."); + expect(excerptSeen).not.toContain(TASK_FRAME[0]); + expect((await summaryOf(channel.id))?.summary).toBe("Reply to Rosa"); + }); + test("two replicas racing for the same conversation name it once", async () => { const owner = await createUser(); const channel = await createUsedChannel(owner); diff --git a/server/tests/proactive.test.ts b/server/tests/proactive.test.ts index 76677b69fd..3b2199acc2 100644 --- a/server/tests/proactive.test.ts +++ b/server/tests/proactive.test.ts @@ -6,12 +6,16 @@ import { type RunAgentInput, } from "@ag-ui/client"; import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import { createTurnRunner, type RunnerLike } from "@openbot/toolkit/run-turn"; import { Observable } from "rxjs"; import { z } from "zod"; import { serverTurnAdmission } from "../src/agents/turn-admission"; import type { ApprovalRecord } from "../src/approvals/types"; -import { createProactiveEngine } from "../src/proactive/engine"; +import { + createProactiveEngine, + type TaskThreads, +} from "../src/proactive/engine"; import { createPrivateShareCheck } from "../src/proactive/private-share"; import { createReadOnlyClassifier, @@ -24,6 +28,16 @@ import type { ProactiveStore } from "../src/proactive/store"; import type { ProactiveSetting } from "../src/proactive/types"; import type { WorkQueue } from "../src/work/queue"; +/** These engines never start a task; one that tried would fail the test loudly. */ +const NO_TASKS: TaskThreads = { + open: async () => { + throw new Error("This test never opens a task."); + }, + scope: async () => null, + failedTurn: async () => { + throw new Error("This test never retries a task."); + }, +}; const catalogue = (effects: Record) => ({ listServers: async () => [ { @@ -326,6 +340,7 @@ describe("proactive run", () => { notify: async (_scope, input) => { notified.push(input.text); }, + tasks: NO_TASKS, startTask: async () => { writes.push("task"); }, @@ -440,6 +455,7 @@ describe("proactive run", () => { threadId: "t", }), notify: async () => undefined, + tasks: NO_TASKS, startTask: async () => undefined, catalogue: { listServers: async () => [ @@ -578,6 +594,7 @@ describe("proactive run", () => { notify: async (_scope, message) => { seen.notified.push(message.text); }, + tasks: NO_TASKS, startTask: async () => undefined, catalogue: { listServers: async () => [] }, mintThreadId: () => "t", @@ -740,3 +757,87 @@ describe("private information sharing check", () => { expect(opened).toHaveLength(0); }); }); + +describe("a started task's turn", () => { + /** An engine with one task's turn queued, whose turn does what `startTask` does. */ + function withTask(startTask: () => Promise) { + const calls: { name: string; args: unknown[] }[] = []; + const record = + (name: string, answer: unknown = undefined) => + async (...args: unknown[]) => { + calls.push({ name, args }); + return answer; + }; + const store = { + claimDue: async () => [], + } as unknown as ProactiveStore; + let queued = [ + { + kind: "proactive.task", + key: "task:t1", + payload: { + ownerUserId: "owner", + taskId: "t1", + text: "Reply to Rosa", + runs: 2, + }, + }, + ]; + const queue = { + offer: record("offer", "queued"), + claim: async ({ kind }: { kind: string }) => { + const items = queued.filter((item) => item.kind === kind); + queued = queued.filter((item) => item.kind !== kind); + return items.map((item) => ({ ...item, attempts: 1 })); + }, + renew: async () => true, + finish: record("finish", true), + release: record("release", true), + purge: async () => 0, + } as unknown as WorkQueue; + const engine = createProactiveEngine({ + store, + memory: { + formMemory: async () => ({ + id: "m", + duplicate: false, + forgotten: false, + }), + }, + queue, + owner: "replica", + runTurn: (async () => ({ replyText: "" })) as never, + resolveScope: async () => null, + notify: async () => undefined, + tasks: { + ...NO_TASKS, + scope: async (ownerUserId) => ({ + ownerUserId, + channelId: "task-channel", + agentId: "bot", + threadId: "task-thread", + }), + }, + startTask, + catalogue: { listServers: async () => [] }, + mintThreadId: () => "t", + taskRetryMs: 0, + }); + return { engine, calls }; + } + const named = (calls: { name: string; args: unknown[] }[], name: string) => + calls.filter((call) => call.name === name).map((call) => call.args); + + test("stopped on an approval, it is finished, never released to be run (and sent) again", async () => { + const { engine, calls } = withTask(async () => { + throw new HeadlessToolSuspension( + "This action is waiting for your approval.", + { kind: "approval", requestId: "approval-1" }, + ); + }); + await engine.sweep(); + await engine.idle(); + expect(named(calls, "finish")).toHaveLength(1); + expect(named(calls, "release")).toEqual([]); + }); +}); diff --git a/server/tests/task-threads.integration.test.ts b/server/tests/task-threads.integration.test.ts new file mode 100644 index 0000000000..99120e80a0 --- /dev/null +++ b/server/tests/task-threads.integration.test.ts @@ -0,0 +1,3415 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + spyOn, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + frameTaskBrief, + frameTaskInput, + frameTaskReply, + readTaskBrief, + readTaskFrame, + TASK_REPLY_LINE, +} from "@openbot/shared/task-brief"; +import type { TaskReplyContext } from "@openbot/shared/task-reply"; +import { ThreadLockBusyError } from "@openbot/toolkit/lease"; +import type { TurnRunner } from "@openbot/toolkit/run-turn"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { createAgentProfileStore } from "../src/agents/profile-store"; +import type { AgentActor } from "../src/agents/profile-types"; +import { createPersonResponseTick } from "../src/approvals/person-response-sweep"; +import { + createApprovalQuestions, + type PersonQuestion, + questionResponseMessage, +} from "../src/approvals/questions"; +import { createAuditStore } from "../src/audit"; +import type { AuthenticatedActor } from "../src/auth/guards"; +import { + loadAttachmentForTurn, + markAttachmentsSent, +} from "../src/channels/attachments"; +import { createChannelStore } from "../src/channels/routes"; +import { createThreadIdentity } from "../src/channels/thread-identity"; +import { createCredentialStore } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { + agentProfiles, + agents, + approvalRequests, + attachments, + channels, + intelligenceChannelMappings, + pluginGrants, + skills, + tasks, + users, + workItems, +} from "../src/db/schema"; +import { createPluginStore } from "../src/plugins/store"; +import { + createProactiveEngine, + PROACTIVE_RUN_KIND, + PROACTIVE_TASK_KIND, + type ProactiveScope, +} from "../src/proactive/engine"; +import { createProactiveRoutes } from "../src/proactive/routes"; +import { createProactiveStore } from "../src/proactive/store"; +import { + ProactiveNotFoundError, + ProactiveRefusedError, +} from "../src/proactive/types"; +import { createResponsibilityStore } from "../src/responsibilities/store"; +import { createTaskReplyContext } from "../src/tasks/reply-context"; +import { createTaskRoutes } from "../src/tasks/routes"; +import { createTaskStore } from "../src/tasks/store"; +import { createTaskThreads, createTaskTurn } from "../src/tasks/threads"; +import type { TaskRecord } from "../src/tasks/types"; +import { + createWorkQueue, + type SerialOrder, + type WorkItem, + type WorkQueue, +} from "../src/work/queue"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/** + * A task is work a Bot does in a conversation of its own, so the conversation it was asked in + * stays one thread. These run + * against PostgreSQL: the conversation is a real channel, and what a task is doing is read from the + * real work queue, approval inbox and question queue. + */ + +// Keep production kinds visible to status readers and first-day transactions, while every SQL +// queue claim uses this file's private table. Other integration files keep their public queue. +const queueSchema = `task_threads_${randomUUID().replaceAll("-", "_")}`; +const databaseUrl = new URL(testDatabaseUrl()); +databaseUrl.searchParams.set("search_path", `${queueSchema},public`); +const database = createDatabase(databaseUrl.toString(), TEST_POOL); +const engines = new Set>(); +const profileStore = createAgentProfileStore( + database, + new URL("https://managed.example.test/ag-ui"), +); +const channelStore = createChannelStore( + database, + profileStore, + createThreadIdentity("test-deployment"), +); +const taskStore = createTaskStore(database); +const proactiveStore = createProactiveStore(database); +const prefix = `task-threads-${randomUUID()}`; +const owner: AgentActor = { id: `${prefix}-owner`, role: "user" }; +const other: AgentActor = { id: `${prefix}-other`, role: "user" }; +let bot = ""; + +const actorFor = async (id: string): Promise => ({ + id, + role: "user", +}); +const scopeFor = async ( + ownerUserId: string, + channelId: string, + agentId: string, +): Promise => { + const channel = await channelStore.get( + await actorFor(ownerUserId), + channelId, + ); + return channel?.agentIds.includes(agentId) + ? { ownerUserId, channelId, agentId, threadId: channel.threadId } + : null; +}; +/** + * What each task's conversation was told about being busy, by thread. Recorded rather than sent: + * an announcement reads the conversation's work list and notifies, and on a two-connection pool a + * burst of them alongside the engine's own reads trips Bun's SQL client, which then decodes one + * statement's rows with another's column types and stops answering. One real announcement is + * exercised on its own, below. + */ +const busySignals: { threadId: string; busy: boolean }[] = []; +const threads = createTaskThreads({ + database, + store: taskStore, + channels: { + create: channelStore.create, + signalBusy: async (threadId, busy) => { + busySignals.push({ threadId, busy }); + }, + }, + actorFor, + scopeFor, +}); + +/** + * The queue in memory, so a task started here is only ever claimed by this file's engine: the shared + * test database can hold other files' queued work, which a real claim would pick up instead. + */ +function memoryQueue() { + const items = new Map< + string, + WorkItem & { finished: boolean; claimed: boolean; lastError?: string } + >(); + const queue = { + offer: async (item: { + kind: string; + key: string; + payload?: Record; + }) => { + const id = `${item.kind}:${item.key}`; + if (items.has(id)) return "already"; + items.set(id, { + kind: item.kind, + key: item.key, + payload: item.payload ?? {}, + attempts: 0, + finished: false, + claimed: false, + }); + return "queued"; + }, + claim: async ({ + kind, + maxAttempts = 5, + }: { + kind: string; + maxAttempts?: number; + }) => { + const due = [...items.values()].find( + (item) => + item.kind === kind && + !item.finished && + !item.claimed && + item.attempts < maxAttempts, + ); + if (!due) return []; + due.claimed = true; + due.attempts += 1; + return [{ ...due }]; + }, + renew: async () => true, + finish: async ({ kind, key }: { kind: string; key: string }) => { + const item = items.get(`${kind}:${key}`); + if (item) item.finished = true; + return true; + }, + release: async ({ + kind, + key, + reason, + }: { + kind: string; + key: string; + reason?: string; + }) => { + const item = items.get(`${kind}:${key}`); + if (item) { + item.claimed = false; + if (reason !== undefined) item.lastError = reason; + } + return true; + }, + purge: async () => 0, + }; + return { queue: queue as unknown as WorkQueue, items }; +} + +type Started = { + scope: ProactiveScope; + runId: string; + text: string; + acceptedReply?: { id: string; text: string }; + final: boolean; +} & TaskReplyContext; + +/** An engine on the memory queue. `turn` is each task turn's outcome; by default it succeeds. */ +function engineFor( + turn: ( + started: Started, + signal: AbortSignal, + ) => Promise = async () => {}, + durable?: WorkQueue, + replyContext?: ReturnType, +) { + const memory = memoryQueue(); + const queue = durable ?? memory.queue; + const items = memory.items; + const started: Started[] = []; + const engine = createProactiveEngine({ + acceptReplyContext: replyContext?.accept, + store: { + ...proactiveStore, + // Task-only sweeps must not admit other tests' shared research schedules. + claimDue: async () => [], + }, + memory: { + formMemory: async () => ({ id: "m", duplicate: false, forgotten: false }), + }, + queue, + owner: "worker", + runTurn: async () => { + throw new Error("No research runs here."); + }, + resolveScope: scopeFor, + notify: async () => undefined, + tasks: threads, + startTask: async (scope, input) => { + const run = { + scope, + runId: input.runId, + text: input.text, + ...(input.acceptedReply ? { acceptedReply: input.acceptedReply } : {}), + final: input.final, + ...(input.attachmentIds ? { attachmentIds: input.attachmentIds } : {}), + ...(input.skillIds ? { skillIds: input.skillIds } : {}), + }; + started.push(run); + await turn(run, input.signal); + }, + catalogue: { listServers: async () => [] }, + mintThreadId: () => `${prefix}-research-thread`, + taskRetryMs: 0, + }); + engines.add(engine); + return { engine, items, started }; +} + +const chatOf = () => channelStore.direct(owner, bot); + +/** A message's words: a string, or the text parts of structured content. */ +const textIn = (content: unknown): string => + typeof content === "string" + ? content + : Array.isArray(content) + ? content + .flatMap((part) => + part?.type === "text" && typeof part.text === "string" + ? [part.text] + : [], + ) + .join("\n") + : ""; + +/** + * A claim on a queue whose kinds carry a test's own prefix, so its rows are only ever this test's. + * The order a task's turns are claimed under names its kinds, so it is renamed to match; its groups + * are read the same way, by the task a payload names. + */ +function prefixedClaim( + input: Parameters[0], + prefix: string, +): Parameters[0] { + const { serialBy } = input; + const order: string | SerialOrder | undefined = + serialBy === undefined || typeof serialBy === "string" + ? serialBy + : { + ...serialBy, + name: prefix + serialBy.name, + kinds: Object.fromEntries( + Object.entries(serialBy.kinds).map(([kind, cap]) => [ + prefix + kind, + cap, + ]), + ), + }; + return { + ...input, + kind: prefix + input.kind, + ...(order === undefined ? {} : { serialBy: order }), + }; +} + +/** One of the person's suggestions from the Bot, as research leaves one, ready to Start. */ +async function suggestionFor(title: string, detail = "") { + const chat = await chatOf(); + const setting = + (await proactiveStore.list(owner.id)).find((row) => row.agentId === bot) ?? + (await proactiveStore.create( + owner.id, + { agentId: bot, channelId: chat.id, enabled: false }, + chat.threadId, + )); + return proactiveStore.addSuggestion({ + ownerUserId: owner.id, + agentId: bot, + settingId: setting.id, + runId: `run-${randomUUID()}`, + title, + detail, + sourceApp: null, + sourceRef: null, + sourceLink: null, + }); +} + +/** A turn for this task on the real queue, in whatever state the test needs. */ +async function turnFor( + taskId: string, + state: Partial = {}, + key = `task:${taskId}`, +) { + await database.insert(workItems).values({ + kind: PROACTIVE_TASK_KIND, + key, + payload: { ownerUserId: owner.id, taskId, text: "Prepare the notes." }, + ...state, + }); + return and(eq(workItems.kind, PROACTIVE_TASK_KIND), eq(workItems.key, key)); +} + +beforeAll(async () => { + await database.execute(sql`create schema ${sql.identifier(queueSchema)}`); + await database.execute(sql` + create table ${sql.identifier(queueSchema)}.work_items + (like public.work_items including all) + `); + await database.insert(users).values([ + { id: owner.id, email: `${owner.id}@example.test` }, + { id: other.id, email: `${other.id}@example.test` }, + ]); + bot = ( + await profileStore.create(owner, { + name: "Assistant", + title: "Works in the background", + roleDescription: "Works for one person.", + visibility: "private", + }) + ).id; +}); + +afterEach(async () => { + // Settle immediate turns and retries before removing even unfinished status-only fixtures. + await Promise.all([...engines].map((engine) => engine.idle())); + engines.clear(); + await database.execute( + sql`truncate ${sql.identifier(queueSchema)}.work_items`, + ); +}); + +afterAll(async () => { + try { + const mine = await database + .select({ id: channels.id }) + .from(channels) + .innerJoin( + intelligenceChannelMappings, + eq(intelligenceChannelMappings.channelId, channels.id), + ) + .where(inArray(intelligenceChannelMappings.userId, [owner.id, other.id])); + if (mine.length > 0) + await database.delete(channels).where( + inArray( + channels.id, + mine.map((row) => row.id), + ), + ); + await database.delete(users).where(inArray(users.id, [owner.id, other.id])); + await database.delete(agentProfiles).where(eq(agentProfiles.agentId, bot)); + await database.delete(agents).where(eq(agents.id, bot)); + } finally { + try { + await database.execute( + sql`drop schema if exists ${sql.identifier(queueSchema)} cascade`, + ); + } finally { + await database.$client.close(); + } + } +}); + +describe("a task asked for", () => { + test("has no cap: every call opens another task in another conversation", async () => { + const { engine, started } = engineFor(); + const opened = await Promise.all( + Array.from({ length: 12 }, (_, index) => + engine.startAsked(owner.id, bot, { + title: `Task ${index}`, + instruction: `Do thing ${index}`, + }), + ), + ); + await engine.idle(); + expect(new Set(opened.map((task) => task.channelId)).size).toBe(12); + expect(started).toHaveLength(12); + }); + + test("Chat's conversation with the Bot stays Chat's, however recently a task ran", async () => { + const chat = await chatOf(); + const { engine } = engineFor(); + const task = await engine.startAsked(owner.id, bot, { + title: "Prepare notes for the Hearth & Co visit", + instruction: "Prepare notes for the Hearth & Co visit.", + }); + await engine.idle(); + await channelStore.recordActivity(owner, task.channelId, { + text: "Here are the notes.", + agentId: bot, + at: new Date(Date.now() + 60_000), + }); + expect((await chatOf()).id).toBe(chat.id); + + // The roster still lists it, marked, so the app can keep it out of Chat and the menu. + const page = await channelStore.list(owner, { limit: 200 }); + expect(page.channels.find((row) => row.id === task.channelId)?.taskId).toBe( + task.id, + ); + expect(page.channels.find((row) => row.id === chat.id)?.taskId).toBeNull(); + // Research's defaults never adopt a task's conversation as the person's own. + expect(await taskStore.isTaskConversation(owner.id, task.channelId)).toBe( + true, + ); + expect(await taskStore.isTaskConversation(owner.id, chat.id)).toBe(false); + expect(await taskStore.isTaskConversation(other.id, task.channelId)).toBe( + false, + ); + }); +}); + +describe("Start on a suggestion", () => { + test("opens a task in a conversation of its own instead of running in Chat", async () => { + const chat = await chatOf(); + const setting = + (await proactiveStore.list(owner.id)).find( + (row) => row.agentId === bot, + ) ?? + (await proactiveStore.create( + owner.id, + { agentId: bot, channelId: chat.id, enabled: false }, + chat.threadId, + )); + const add = (title: string) => + proactiveStore.addSuggestion({ + ownerUserId: owner.id, + agentId: bot, + settingId: setting.id, + runId: `run-${randomUUID()}`, + title, + detail: "Ravi publishes in 7 days.\n\nNext: Reply to Ravi.", + sourceApp: "Gmail", + sourceRef: null, + sourceLink: "https://mail.google.com/mail/u/0/#inbox/n26", + }); + const fromTasks = await add("Triage the order-form report"); + const another = await add("Answer Sam about Friday off"); + const { engine, started } = engineFor(); + + const { suggestion, task } = await engine.start(owner.id, fromTasks.id); + await engine.idle(); + expect(suggestion.status).toBe("started"); + const record = await taskStore.get(owner.id, task.id); + expect(record).toMatchObject({ + origin: "suggestion", + suggestionId: fromTasks.id, + title: "Triage the order-form report", + why: "Ravi publishes in 7 days.", + sourceApp: "Gmail", + sourceLink: "https://mail.google.com/mail/u/0/#inbox/n26", + }); + expect(record.channelId).not.toBe(chat.id); + expect(started).toHaveLength(1); + expect(started[0]?.scope.channelId).toBe(record.channelId); + expect(readTaskBrief(started[0]?.text ?? "")).toContain( + "Triage the order-form report", + ); + + // Started once: a second Start is refused and opens nothing. + await expect(engine.start(owner.id, fromTasks.id)).rejects.toThrow( + "already started", + ); + // Somebody else's suggestion is no suggestion at all. + await expect(engine.start(other.id, another.id)).rejects.toBeInstanceOf( + ProactiveNotFoundError, + ); + expect((await proactiveStore.suggestion(owner.id, another.id)).status).toBe( + "open", + ); + + // Nobody else's Start touched it: it still opens a task of its own. + const second = await engine.start(owner.id, another.id); + await engine.idle(); + expect(second.task.channelId).not.toBe(record.channelId); + expect(started).toHaveLength(2); + }); +}); + +describe("Start, through the routes", () => { + test("answers with the suggestion and the task it opened, and reads a suggestion whatever its status", async () => { + const { engine, started } = engineFor(); + const routes = createProactiveRoutes( + proactiveStore, + engine, + async (context, next) => { + context.set("actor", { + id: context.req.header("x-person") ?? owner.id, + } as AuthenticatedActor); + await next(); + }, + ); + const suggestion = await suggestionFor( + "Reply to Ravi about the deadline", + "Ravi publishes in 7 days.\n\nNext: Reply to Ravi.", + ); + const read = (person = owner.id) => + routes.request(`/suggestions/${suggestion.id}`, { + headers: { "x-person": person }, + }); + expect( + ((await (await read()).json()) as { suggestion: { status: string } }) + .suggestion.status, + ).toBe("open"); + + const response = await routes.request( + `/suggestions/${suggestion.id}/start`, + { + method: "POST", + }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + suggestion: { id: string; status: string }; + task: { id: string; channelId: string; title: string }; + }; + await engine.idle(); + expect(body.suggestion).toMatchObject({ + id: suggestion.id, + status: "started", + }); + expect(body.task.title).toBe("Reply to Ravi about the deadline"); + expect((await taskStore.get(owner.id, body.task.id)).suggestionId).toBe( + suggestion.id, + ); + expect(started.map((run) => run.scope.channelId)).toEqual([ + body.task.channelId, + ]); + // Started, it is still the person's to read, and still nobody else's. + expect( + ((await (await read()).json()) as { suggestion: { status: string } }) + .suggestion.status, + ).toBe("started"); + expect((await read(other.id)).status).toBe(404); + }); +}); + +describe("while a task's turn runs", () => { + test("its conversation is working on it, for as long as the turn holds its lease, and only for its person", async () => { + const task = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: `Working ${randomUUID()}`, + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + const conversation = await channelStore.get(owner, task.channelId); + const thread = { + id: task.channelId, + threadId: conversation?.threadId ?? "", + }; + const workOf = async (actor = owner) => + (await channelStore.workIn(actor, [thread])).get(task.channelId) ?? []; + // Queued, nothing runs in the conversation yet. + const where = await turnFor(task.id); + expect(await workOf()).toEqual([]); + await database + .update(workItems) + .set({ + attempts: 1, + claimedBy: "worker", + leaseUntil: sql`now() + interval '1 minute'`, + }) + .where(where); + expect(await workOf()).toEqual([ + { + kind: "task", + key: `task:${task.id}`, + taskId: task.id, + botId: bot, + botName: "Assistant", + since: expect.any(Date), + }, + ]); + // Another person reading the same conversation's id hears nothing of it. + expect(await workOf(other)).toEqual([]); + // Its lease run out, or its turn finished: nothing is running. + await database + .update(workItems) + .set({ leaseUntil: sql`now() - interval '1 second'` }) + .where(where); + expect(await workOf()).toEqual([]); + }); + + test("its conversation is told it is no longer busy once the turn is off the queue, and its work list says so", async () => { + const { engine, items } = engineFor(); + const task = await engine.startAsked(owner.id, bot, { + title: "Prepare the notes", + instruction: "Prepare the notes.", + }); + await engine.idle(); + const scope = await threads.scope(owner.id, task.id); + // Said after the turn was finished on the queue, never before. + expect(items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}`)?.finished).toBe( + true, + ); + expect(busySignals).toContainEqual({ + threadId: scope?.threadId ?? "", + busy: false, + }); + + // The real announcement, on its own: the turn is finished, so the list it carries is empty. + const real = createTaskThreads({ + database, + store: taskStore, + channels: channelStore, + actorFor, + scopeFor, + }); + await real.settled?.(owner.id, task.id); + expect( + ( + await channelStore.workIn(owner, [ + { id: task.channelId, threadId: scope?.threadId ?? "" }, + ]) + ).get(task.channelId), + ).toBeUndefined(); + }); +}); + +describe("status", () => { + async function freshTask() { + const opened = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: `Status ${randomUUID()}`, + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + const conversation = await channelStore.get(owner, opened.channelId); + return { id: opened.id, threadId: conversation?.threadId ?? "" }; + } + const statusOf = async (id: string) => + (await taskStore.get(owner.id, id)).status; + + async function acceptedAnswer() { + const task = await freshTask(); + await turnFor(task.id, { finishedAt: new Date() }); + const questionId = `${prefix}-answered-${randomUUID()}`; + const question = { + ownerUserId: owner.id, + actorId: owner.id, + botId: bot, + threadId: task.threadId, + runId: `task-${task.id}`, + question: "Which deck?", + mode: "completed_question", + }; + await database.insert(workItems).values({ + kind: "person.question", + key: questionId, + payload: question, + }); + // Persist through the real acceptance path, then advance only this turn's ledger below. + const queue: WorkQueue = { + ...createWorkQueue(database), + claim: async () => [], + }; + const { engine } = engineFor(async () => {}, queue); + const messageId = randomUUID(); + await engine.reply(owner.id, task.id, { reply: "Use A", messageId }); + await engine.idle(); + const where = and( + eq(workItems.kind, PROACTIVE_TASK_KIND), + eq(workItems.key, `task:${task.id}:reply:${messageId}`), + ); + return { task, questionId, question, where, engine }; + } + + test("question-card responses stay working, fail once, and retry the accepted answer", async () => { + const task = await freshTask(); + const unrelated = await freshTask(); + await turnFor(task.id, { finishedAt: new Date() }); + await turnFor(unrelated.id, { finishedAt: new Date() }); + const { channelId } = (await taskStore.locate(owner.id, task.id))!; + const key = `${prefix}-question-response-${randomUUID()}`; + const question: PersonQuestion = { + actorId: owner.id, + botId: bot, + channelId, + threadId: task.threadId, + runId: `task-${task.id}`, + question: + "Which day works after preparing the notes, reviewing the deck, and confirming every attendee can join? ".repeat( + 3, + ), + mode: "completed_question", + }; + const response = "Friday"; + const message = questionResponseMessage( + question, + response, + `person-response:${key}`, + ); + const queue = createWorkQueue(database); + await queue.offer({ kind: "person.question", key, payload: question }); + await channelStore.recordActivity(owner, channelId, { + text: question.question, + agentId: bot, + at: new Date(), + }); + await createApprovalQuestions(database, async () => {}).respond( + owner.id, + key, + response, + ); + expect(await statusOf(task.id)).toBe("working"); + expect(await statusOf(unrelated.id)).toBe("ready_for_review"); + await queue.claim({ + kind: "person.response", + owner: "answerer", + leaseMs: 60_000, + maxAttempts: 1, + }); + expect(await statusOf(task.id)).toBe("working"); + await queue.release({ + kind: "person.response", + key, + owner: "answerer", + delayMs: 0, + reason: "fetch failed", + }); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "failed", + failure: "a service didn't answer", + }); + const taskTurn = createTaskTurn({ + actorFor, + channels: channelStore, + runTurn: async (input) => { + if (input.userMessage?.role !== "user") + throw new Error("Task user message is missing"); + return { replyText: "The deck is ready." }; + }, + }); + const { engine, started } = engineFor( + async ({ scope, ...input }, signal) => { + await taskTurn(scope, { ...input, signal }); + }, + queue, + ); + await engine.again(owner.id, task.id); + await engine.idle(); + expect({ started, status: await statusOf(task.id) }).toMatchObject({ + started: [ + { + text: message.content, + acceptedReply: { id: message.id, text: response }, + }, + ], + status: "ready_for_review", + }); + }); + + test("question-card answers execute before later task composer replies", async () => { + const task = await freshTask(); + await turnFor(task.id, { finishedAt: new Date() }); + const queue = createWorkQueue(database); + const key = randomUUID(); + const question: PersonQuestion = { + actorId: owner.id, + botId: bot, + threadId: task.threadId, + runId: `task-${task.id}`, + question: "Deck?", + mode: "completed_question", + }; + await queue.offer({ kind: "person.question", key, payload: question }); + const questions = createApprovalQuestions(database, async () => {}); + await questions.respond(owner.id, key, "Pottery."); + const starts: string[] = []; + const { engine } = engineFor(async ({ text }) => { + starts.push(text); + }, queue); + const reply = { reply: "Use revised.", messageId: randomUUID() }; + const replyKey = `task:${task.id}:reply:${reply.messageId}`; + const answer = "In response to your question: Deck?\n\nPottery."; + await engine.reply(owner.id, task.id, reply); + const rows = sql` + select kind, key, attempts, finished_at is not null as finished, + coalesce(lease_until > now(), false) as live, last_error as error + from work_items where kind in ('person.response', 'proactive.task') + and key in (${key}, ${replyKey}) order by created_at, key + `; + expect([...(await database.execute(rows))]).toMatchObject([ + { kind: "person.response", key, attempts: 0, finished: false }, + { kind: PROACTIVE_TASK_KIND, key: replyKey }, + ]); + await engine.idle(); + await engine.sweep(); + expect(starts).toEqual([]); + const entered = Promise.withResolvers(); + const held = Promise.withResolvers(); + const answering = createPersonResponseTick({ + approvalQueue: queue, + personResponseOwner: "answerer", + authoriseQuestion: async () => {}, + approvalContinuationRunner: async (input) => { + starts.push(textIn(input.userMessage?.content)); + entered.resolve(); + await held.promise; + return { replyText: "" }; + }, + responsibilityStore: createResponsibilityStore(database, { + resolveTarget: async () => null, + }), + responsibilityAnswerTurn: () => { + throw new Error("Unexpected responsibility"); + }, + sourceForCoordinationRun: async () => null, + channelStore, + actorFor, + notify: async () => {}, + })(); + try { + await Promise.race([entered.promise, answering]); + await engine.sweep(); + const running = [...(await database.execute(rows))]; + expect({ starts, work: running }).toMatchObject({ + starts: [answer], + work: [ + { attempts: 1, live: true, finished: false, error: null }, + { attempts: 0 }, + ], + }); + } finally { + held.resolve(); + await answering; + } + await engine.sweep(); + const finished = [...(await database.execute(rows))]; + expect({ starts, work: finished }).toMatchObject({ + starts: [answer, reply.reply], + work: [ + { attempts: 1, finished: true, error: null }, + { attempts: 1, finished: true, error: null }, + ], + }); + }); + + test("an exhausted accepted answer exposes its failure and remains recoverable through Try again", async () => { + const { task, questionId, where, engine } = await acceptedAnswer(); + expect(await statusOf(task.id)).toBe("working"); + await database.update(workItems).set({ attempts: 1 }).where(where); + expect(await statusOf(task.id)).toBe("working"); + await database + .update(workItems) + .set({ + attempts: 2, + claimedBy: "worker", + leaseUntil: sql`now() + interval '1 minute'`, + }) + .where(where); + expect(await statusOf(task.id)).toBe("working"); + const failedAt = new Date(); + await database + .update(workItems) + .set({ + attempts: 2, + claimedBy: null, + leaseUntil: null, + lastError: "fetch failed", + updatedAt: failedAt, + }) + .where(where); + const routes = createTaskRoutes( + taskStore, + async (context, next) => { + context.set("actor", { + id: owner.id, + email: `${owner.id}@example.test`, + role: "user", + }); + await next(); + }, + { retry: engine }, + ); + const failed = (await (await routes.request(`/${task.id}`)).json()) as { + task: TaskRecord; + }; + expect(failed.task).toMatchObject({ + status: "failed", + failure: "a service didn't answer", + failedAt: failedAt.toISOString(), + answeredQuestionIds: [questionId], + }); + const listed = (await (await routes.request("/")).json()) as { + tasks: TaskRecord[]; + }; + expect(listed.tasks.find((row) => row.id === task.id)).toEqual(failed.task); + // The failed run did not finish the question; the accepted answer is what the read knows. + expect( + await database + .select({ finishedAt: workItems.finishedAt }) + .from(workItems) + .where( + and( + eq(workItems.kind, "person.question"), + eq(workItems.key, questionId), + ), + ), + ).toEqual([{ finishedAt: null }]); + const response = await routes.request(`/${task.id}/again`, { + method: "POST", + }); + await engine.idle(); + expect(response.status).toBe(200); + expect( + ((await response.json()) as { task: TaskRecord }).task, + ).toMatchObject({ + status: "working", + failure: null, + failedAt: null, + answeredQuestionIds: [questionId], + }); + await database + .update(workItems) + .set({ attempts: 1, finishedAt: new Date() }) + .where( + and( + eq(workItems.kind, PROACTIVE_TASK_KIND), + eq(workItems.key, `task:${task.id}:again:2`), + ), + ); + expect((await taskStore.get(owner.id, task.id)).status).toBe( + "ready_for_review", + ); + }); + + test("a new question keeps priority over an accepted answer while working and after exhaustion", async () => { + const { task, question, where } = await acceptedAnswer(); + const newId = `${prefix}-new-${randomUUID()}`; + await database.insert(workItems).values({ + kind: "person.question", + key: newId, + payload: question, + }); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ attempts: 2, lastError: "fetch failed" }) + .where(where); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "needs_input", + failure: null, + failedAt: null, + }); + await taskStore.answerQuestions(owner.id, [newId]); + expect(await statusOf(task.id)).toBe("failed"); + }); + + test("pending approval keeps priority over an accepted answer while working and after exhaustion", async () => { + const { task, where } = await acceptedAnswer(); + const approvalId = `${prefix}-answer-approval-${randomUUID()}`; + await database.insert(approvalRequests).values({ + id: approvalId, + ownerUserId: owner.id, + runId: `task-${task.id}`, + toolCallId: "send", + actionDigest: "digest", + action: { + actorId: owner.id, + botId: bot, + threadId: task.threadId, + runId: `task-${task.id}`, + toolCallId: "send", + actionDigest: "digest", + toolRef: "gmail/send", + effect: "write", + scope: "recipient@example.test", + args: {}, + }, + }); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ attempts: 2, lastError: "fetch failed" }) + .where(where); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(approvalRequests) + .set({ status: "completed" }) + .where(eq(approvalRequests.id, approvalId)); + expect(await statusOf(task.id)).toBe("failed"); + }); + + test("a legacy answer without a snapshot never hides an unknown pending question", async () => { + const { task, where } = await acceptedAnswer(); + await database + .update(workItems) + .set({ payload: sql`${workItems.payload} - 'questionIds'` }) + .where(where); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ attempts: 2, lastError: "fetch failed" }) + .where(where); + expect(await statusOf(task.id)).toBe("needs_input"); + }); + + test("carries the run's own next step: the Bot's last sentence, the person's reply quoted, never the ask", async () => { + const opened = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: `Next ${randomUUID()}`, + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + const nextOf = async () => (await taskStore.get(owner.id, opened.id)).next; + expect(await nextOf()).toBeNull(); + const at = Date.now(); + // The ask the turn starts on is the task, not a step. + await channelStore.recordActivity( + owner, + opened.channelId, + { text: "Draft a reply to Danny.", agentId: null, at: new Date(at) }, + { id: `task-${opened.id}:input` }, + ); + expect(await nextOf()).toBeNull(); + await channelStore.recordActivity(owner, opened.channelId, { + text: "I drafted the reply. **Send the reply to Danny?** It tells him Friday works.", + agentId: bot, + at: new Date(at + 1_000), + }); + expect(await nextOf()).toBe("Send the reply to Danny?"); + await channelStore.recordActivity(owner, opened.channelId, { + text: "Make it shorter", + agentId: null, + at: new Date(at + 2_000), + }); + expect(await nextOf()).toBe("“Make it shorter”"); + // Read the same way on the list. + expect( + (await taskStore.list(owner.id)).find((task) => task.id === opened.id) + ?.next, + ).toBe("“Make it shorter”"); + }); + + test("a finished reply asks for input, and queued replies clear that question while working", async () => { + const task = await freshTask(); + const where = await turnFor(task.id); + await database + .update(workItems) + .set({ finishedAt: new Date() }) + .where(where); + const located = await taskStore.locate(owner.id, task.id); + await channelStore.recordActivity(owner, located!.channelId, { + text: "Here is the draft.\n\nWhich one should I use? 🙂", + agentId: bot, + at: new Date(), + }); + expect(await statusOf(task.id)).toBe("needs_input"); + expect((await taskStore.get(owner.id, task.id)).next).toContain( + "Which one", + ); + await database.insert(workItems).values({ + kind: PROACTIVE_TASK_KIND, + key: `reply-${task.id}`, + payload: { + taskId: task.id, + ownerUserId: owner.id, + reply: "Use A", + messageId: "reply-a", + }, + }); + expect(await statusOf(task.id)).toBe("working"); + }); + + async function resolvedReply() { + const task = await freshTask(); + const queue: WorkQueue = { + ...createWorkQueue(database), + claim: async () => [], + }; + const { engine } = engineFor(async () => {}, queue); + const input = { reply: "Prepare the first draft", messageId: randomUUID() }; + await engine.reply(owner.id, task.id, input); + await engine.idle(); + const taskTurns = and( + eq(workItems.kind, PROACTIVE_TASK_KIND), + sql`${workItems.payload}->>'taskId' = ${task.id}`, + ); + await database + .update(workItems) + .set({ attempts: 1, finishedAt: sql`now()` }) + .where(taskTurns); + const located = await taskStore.locate(owner.id, task.id); + await channelStore.recordActivity(owner, located!.channelId, { + text: "Should I send the old draft?", + agentId: bot, + at: new Date(), + }); + const resolved = await taskStore.resolve(owner.id, task.id); + expect(resolved.status).toBe("resolved"); + const accepted = () => database.select().from(workItems).where(taskTurns); + return { + task, + queue, + engine, + input, + resolved, + accepted, + taskTurns, + channelId: located!.channelId, + }; + } + + test("new durable work after resolution exposes its final question", async () => { + const text = "Should I send the revised draft?"; + const { task, engine, accepted, taskTurns, channelId } = + await resolvedReply(); + await engine.reply(owner.id, task.id, { + reply: "Revise it", + messageId: randomUUID(), + }); + await engine.idle(); + expect(await accepted()).toHaveLength(2); + const queued = await taskStore.get(owner.id, task.id); + expect( + (await accepted()).find((row) => row.finishedAt === null), + ).toMatchObject({ + attempts: 0, + }); + await database + .update(workItems) + .set({ finishedAt: sql`now()` }) + .where(taskTurns); + // Accepted work supersedes completion, but cannot resurrect the old dismissed question. + const withoutNewOutput = await taskStore.get(owner.id, task.id); + await channelStore.recordActivity(owner, channelId, { + text, + agentId: bot, + at: new Date(), + }); + const expected = { status: "needs_input", resolvedAt: null, next: text }; + expect(await taskStore.get(owner.id, task.id)).toMatchObject(expected); + expect(queued).toMatchObject({ status: "working", resolvedAt: null }); + expect(withoutNewOutput).toMatchObject({ + status: "ready_for_review", + resolvedAt: null, + }); + expect( + (await taskStore.list(owner.id)).find((row) => row.id === task.id), + ).toMatchObject(expected); + }); + + test("resolution keeps an unanswered final question dismissed without new durable work", async () => { + const { task, resolved, accepted } = await resolvedReply(); + expect(await accepted()).toHaveLength(1); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "resolved", + resolvedAt: resolved.resolvedAt, + }); + }); + + test("replaying an accepted reply after resolution neither accepts new work nor reopens it", async () => { + const { task, engine, input, resolved, accepted } = await resolvedReply(); + await engine.reply(owner.id, task.id, input); + await engine.idle(); + expect(await accepted()).toHaveLength(1); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "resolved", + resolvedAt: resolved.resolvedAt, + }); + }); + + test("rejected reply context neither accepts new work nor reopens a resolved task", async () => { + const { task, engine, resolved, accepted } = await resolvedReply(); + await expect( + engine.reply(owner.id, task.id, { + reply: "Use this file", + messageId: randomUUID(), + attachmentIds: [randomUUID()], + }), + ).rejects.toThrow("Files and skills are unavailable for this task."); + expect(await accepted()).toHaveLength(1); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "resolved", + resolvedAt: resolved.resolvedAt, + }); + }); + + test("a refused reply offer neither accepts new work nor reopens a resolved task", async () => { + const { task, queue, resolved, accepted } = await resolvedReply(); + const refused = engineFor(async () => {}, { + ...queue, + offer: (input) => + queue.offer({ + ...input, + atMost: { keyPrefix: `task:${task.id}:reply:`, max: 0 }, + }), + }); + await refused.engine.reply(owner.id, task.id, { + reply: "Revise it", + messageId: randomUUID(), + }); + await refused.engine.idle(); + expect(await accepted()).toHaveLength(1); + expect(await taskStore.get(owner.id, task.id)).toMatchObject({ + status: "resolved", + resolvedAt: resolved.resolvedAt, + }); + }); + + test("is worked out from the queue, the approval inbox and the question queue, never stored", async () => { + const task = await freshTask(); + // Opened, and its turn not queued yet: just started. + expect(await statusOf(task.id)).toBe("working"); + + // Its turn, queued and then running: working. + const where = await turnFor(task.id); + expect(await statusOf(task.id)).toBe("working"); + await database + .update(workItems) + .set({ + attempts: 1, + claimedBy: "worker", + leaseUntil: sql`now() + interval '5 minutes'`, + }) + .where(where); + expect(await statusOf(task.id)).toBe("working"); + // Finished: the run wrote its reply, so there is something to read. + await database + .update(workItems) + .set({ finishedAt: new Date(), claimedBy: null, leaseUntil: null }) + .where(where); + expect(await statusOf(task.id)).toBe("ready_for_review"); + + // Its reply stopped for Allow once: needs input. + const approvalId = `${prefix}-approval-${randomUUID()}`; + await database.insert(approvalRequests).values({ + id: approvalId, + ownerUserId: owner.id, + runId: `task-${task.id}`, + toolCallId: "send", + actionDigest: "digest", + action: { + threadId: task.threadId, + botId: bot, + toolRef: "google-workspace/gmail_send_reply", + } as never, + }); + expect(await statusOf(task.id)).toBe("needs_input"); + + // Allowed: carried on, so working; done, so ready to read. + await database + .update(approvalRequests) + .set({ status: "approved", decision: "allow_once" }) + .where(eq(approvalRequests.id, approvalId)); + await database.insert(workItems).values({ + kind: "approval.resume", + key: approvalId, + payload: { ownerUserId: owner.id, approvalId }, + }); + expect(await statusOf(task.id)).toBe("working"); + await database + .update(workItems) + .set({ finishedAt: new Date() }) + .where( + and( + eq(workItems.kind, "approval.resume"), + eq(workItems.key, approvalId), + ), + ); + await database + .update(approvalRequests) + .set({ status: "completed" }) + .where(eq(approvalRequests.id, approvalId)); + expect(await statusOf(task.id)).toBe("ready_for_review"); + + // A question its Bot asked in its conversation: needs input until answered. + const question = `${prefix}-question-${randomUUID()}`; + await database.insert(workItems).values({ + kind: "person.question", + key: question, + payload: { + actorId: owner.id, + botId: bot, + threadId: task.threadId, + question: "Which glaze?", + }, + }); + expect(await statusOf(task.id)).toBe("needs_input"); + await database + .update(workItems) + .set({ finishedAt: new Date() }) + .where( + and(eq(workItems.kind, "person.question"), eq(workItems.key, question)), + ); + + // Marked done: resolved. Something waiting on the person still shows over it. + await taskStore.resolve(owner.id, task.id); + expect(await statusOf(task.id)).toBe("resolved"); + await taskStore.reopen(owner.id, task.id); + expect(await statusOf(task.id)).toBe("ready_for_review"); + + // Nothing about status is a column. + const [row] = await database + .select() + .from(tasks) + .where(eq(tasks.id, task.id)); + expect(Object.keys(row ?? {})).not.toContain("status"); + }); + + test("another person's pending approval on the same thread id is not this task's", async () => { + const task = await freshTask(); + await turnFor(task.id, { attempts: 1, finishedAt: new Date() }); + await database.insert(approvalRequests).values({ + id: `${prefix}-theirs-${randomUUID()}`, + ownerUserId: other.id, + runId: "theirs", + toolCallId: "send", + actionDigest: "digest", + action: { threadId: task.threadId, botId: bot } as never, + }); + expect(await statusOf(task.id)).toBe("ready_for_review"); + }); +}); + +describe("/api/tasks", () => { + const routes = createTaskRoutes(taskStore, async (context, next) => { + context.set("actor", { + id: context.req.header("x-person") ?? owner.id, + } as AuthenticatedActor); + await next(); + }); + const call = (person: string, path: string, method = "GET") => + routes.request(path, { method, headers: { "x-person": person } }); + + test("lists the person's own tasks, newest first, and resolves and reopens them", async () => { + const first = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: "Older", + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + await Bun.sleep(5); + const second = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: "Newer", + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + const listed = (await (await call(owner.id, "/")).json()) as { + tasks: TaskRecord[]; + }; + const ids = listed.tasks.map((task) => task.id); + expect(ids.indexOf(second.id)).toBeLessThan(ids.indexOf(first.id)); + expect(listed.tasks.every((task) => typeof task.status === "string")).toBe( + true, + ); + + // Each names its conversation's thread, which its approvals and questions carry. + const conversation = await channelStore.get(owner, second.channelId); + expect(listed.tasks.find((task) => task.id === second.id)?.threadId).toBe( + conversation?.threadId ?? "missing", + ); + + await turnFor(second.id, { attempts: 1, finishedAt: new Date() }); + const resolved = (await ( + await call(owner.id, `/${second.id}/resolve`, "POST") + ).json()) as { task: TaskRecord }; + expect(resolved.task.status).toBe("resolved"); + expect(resolved.task.resolvedAt).not.toBeNull(); + const reopened = (await ( + await call(owner.id, `/${second.id}/reopen`, "POST") + ).json()) as { task: TaskRecord }; + expect(reopened.task.status).toBe("ready_for_review"); + expect(reopened.task.resolvedAt).toBeNull(); + }); + + test("another person's task is the same as no task", async () => { + const mine = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title: "Mine", + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + expect((await call(other.id, `/${mine.id}`)).status).toBe(404); + expect((await call(other.id, `/${mine.id}/resolve`, "POST")).status).toBe( + 404, + ); + expect((await call(other.id, `/${mine.id}/reopen`, "POST")).status).toBe( + 404, + ); + const theirs = (await (await call(other.id, "/")).json()) as { + tasks: TaskRecord[]; + }; + expect(theirs.tasks).toEqual([]); + expect((await taskStore.get(owner.id, mine.id)).resolvedAt).toBeNull(); + }); +}); + +describe("a task whose turn fails", () => { + async function opened(title = `Failing ${randomUUID()}`) { + const task = await threads.open({ + ownerUserId: owner.id, + agentId: bot, + title, + why: null, + origin: "asked", + suggestionId: null, + sourceApp: null, + sourceLink: null, + }); + return task; + } + const read = (id: string, store = taskStore) => store.get(owner.id, id); + + test("reads Working while its one retry is due, then Failed with the reason in plain words, never Ready for review", async () => { + const task = await opened(); + // The first run threw: let go with its reason, and due once more. + const where = await turnFor(task.id, { + attempts: 1, + lastError: "fetch failed", + }); + expect((await read(task.id)).status).toBe("working"); + // A restart cut the first run off: the lease ran out, so the retry is due. + await database + .update(workItems) + .set({ + lastError: null, + claimedBy: "gone", + leaseUntil: sql`now() - interval '1 second'`, + }) + .where(where); + expect((await read(task.id)).status).toBe("working"); + // The retry running. + await database + .update(workItems) + .set({ + attempts: 2, + claimedBy: "worker", + leaseUntil: sql`now() + interval '3 minutes'`, + }) + .where(where); + expect((await read(task.id)).status).toBe("working"); + // The retry threw too: out of runs, so it failed, and says why without the error itself. + await database + .update(workItems) + .set({ + claimedBy: null, + leaseUntil: null, + lastError: "The routine's turn was stopped after 300s.", + }) + .where(where); + const failed = await read(task.id); + expect(failed.status).toBe("failed"); + expect(failed.failure).toBe("the run took longer than 5 minutes"); + expect(failed.failedAt).not.toBeNull(); + + // Marked done, it is done; reopened, it is failed again. + expect((await taskStore.resolve(owner.id, task.id)).status).toBe( + "resolved", + ); + // Its failure stays on the record while it is done, so a reply can still run it. + expect((await taskStore.resolve(owner.id, task.id)).failure).toBe( + "the run took longer than 5 minutes", + ); + expect((await taskStore.reopen(owner.id, task.id)).status).toBe("failed"); + + // Try again's turn is the newest, so it counts: working, then ready once it finished. + const again = await turnFor(task.id, {}, `task:${task.id}:again:1`); + expect((await read(task.id)).status).toBe("working"); + await database + .update(workItems) + .set({ attempts: 1, finishedAt: new Date() }) + .where(again); + const done = await read(task.id); + expect(done.status).toBe("ready_for_review"); + expect(done.failure).toBeNull(); + expect(done.failedAt).toBeNull(); + }); + + test("whose last run a restart cut off fails, saying so, at the moment its lease ran out", async () => { + const task = await opened(); + const expired = new Date(Date.now() - 60_000); + // The first run threw and left its error; the second was cut off, and that is what is said. + await turnFor(task.id, { + attempts: 2, + claimedBy: "gone", + leaseUntil: expired, + lastError: "fetch failed", + updatedAt: new Date(Date.now() - 4 * 60_000), + }); + expect(await read(task.id)).toMatchObject({ + status: "failed", + failure: "the run was cut off partway through", + failedAt: expired.toISOString(), + }); + }); + + test("whose turn never reached the queue fails to start, once it should have", async () => { + const task = await opened(); + expect((await read(task.id)).status).toBe("working"); + const later = createTaskStore(database, { + now: () => new Date(Date.now() + 2 * 60_000), + }); + expect(await read(task.id, later)).toMatchObject({ + status: "failed", + failure: "it never started", + }); + }); + + test("stays Failed whatever is said in its conversation, until a turn of its own finishes", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + await channelStore.recordActivity( + owner, + task.channelId, + { + text: "I couldn't finish this: a service didn't answer.", + agentId: bot, + at: new Date(), + }, + { id: `task-${task.id}-x:failed` }, + ); + // A Bot answer from the browser is not the task's turn: it may not have had the task at all. + await channelStore.recordActivity(owner, task.channelId, { + text: "Try it with the other glaze.", + agentId: null, + at: new Date(Date.now() + 1_000), + }); + await channelStore.recordActivity(owner, task.channelId, { + text: "Evening. Here's what's in your inbox.", + agentId: bot, + at: new Date(Date.now() + 2_000), + }); + expect((await read(task.id)).status).toBe("failed"); + // Its own turn finishing is. + const again = await turnFor(task.id, {}, `task:${task.id}:again:1`); + await database + .update(workItems) + .set({ attempts: 1, finishedAt: new Date() }) + .where(again); + expect((await read(task.id)).status).toBe("ready_for_review"); + }); + + test("runs once more after throwing, and the second run is its last", async () => { + const { engine, items, started } = engineFor(async () => { + throw new Error("The routine's turn was stopped after 300s."); + }); + const task = await engine.startAsked(owner.id, bot, { + title: "Prepare notes for the Hearth & Co visit", + instruction: "Prepare notes for the Hearth & Co visit.", + }); + await engine.idle(); + expect(started.map((run) => run.final)).toEqual([false, true]); + expect(started[0]?.runId).toBe(`task-${task.id}`); + // The retry is a run of its own, so it never collides with the first in the conversation. + expect(started[1]?.runId).toStartWith(`task-${task.id}-`); + expect(started[1]?.runId).not.toBe(started[0]?.runId); + expect(started[1]?.text).toBe(started[0]?.text); + // Out of runs: unfinished on the queue, with its reason, which is what reads as Failed. + expect( + items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}`)?.payload, + ).toMatchObject({ runs: 2 }); + expect(items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}`)).toMatchObject({ + attempts: 2, + finished: false, + lastError: "The routine's turn was stopped after 300s.", + }); + }); + + test("whose first run a restart cut off is picked up by the sweep, as its last run", async () => { + const task = await opened(); + const { engine, items, started } = engineFor(); + // Claimed once by a process that died: its lease has run out. + items.set(`${PROACTIVE_TASK_KIND}:task:${task.id}`, { + kind: PROACTIVE_TASK_KIND, + key: `task:${task.id}`, + payload: { + ownerUserId: owner.id, + taskId: task.id, + text: "Go on.", + runs: 2, + }, + attempts: 1, + finished: false, + claimed: false, + }); + await engine.sweep(); + expect(started).toHaveLength(1); + expect(started[0]?.final).toBe(true); + expect(started[0]?.runId).toStartWith(`task-${task.id}-`); + expect(started[0]?.text).toBe(frameTaskBrief("Go on.")); + expect(items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}`)?.finished).toBe( + true, + ); + }); + + test("a turn from before retries is never run a second time, and stays failed", async () => { + const task = await opened(); + const { engine, items, started } = engineFor(); + // Failed once, when one failure was final, and left unfinished. + items.set(`${PROACTIVE_TASK_KIND}:task:${task.id}`, { + kind: PROACTIVE_TASK_KIND, + key: `task:${task.id}`, + payload: { ownerUserId: owner.id, taskId: task.id, text: "Go on." }, + attempts: 1, + finished: false, + claimed: false, + }); + await engine.sweep().catch(() => undefined); + expect(started).toHaveLength(0); + expect(items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}`)).toMatchObject({ + attempts: 2, + finished: false, + lastError: "This turn failed before it could be tried again.", + }); + }); + + test("a research run that throws in the sweep does not hold back a task's turn", async () => { + const task = await opened(); + const { engine, items, started } = engineFor(); + items.set(`${PROACTIVE_RUN_KIND}:broken`, { + kind: PROACTIVE_RUN_KIND, + key: "broken", + payload: {}, + attempts: 0, + finished: false, + claimed: false, + }); + items.set(`${PROACTIVE_TASK_KIND}:task:${task.id}`, { + kind: PROACTIVE_TASK_KIND, + key: `task:${task.id}`, + payload: { + ownerUserId: owner.id, + taskId: task.id, + text: "Go on.", + runs: 2, + }, + attempts: 0, + finished: false, + claimed: false, + }); + await expect(engine.sweep()).rejects.toThrow(); + expect(started).toHaveLength(1); + expect(items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}`)?.finished).toBe( + true, + ); + }); + + test("a reply on a failed task is Try again: the person's own message, with the ask beside it for the model", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + const { engine, items, started } = engineFor(); + await engine.again(owner.id, task.id, { + reply: "Use the Oat Satin glaze.", + messageId: "reply-0123456789", + }); + await engine.idle(); + expect(started).toHaveLength(1); + expect(started[0]?.text).toBe( + frameTaskReply("Prepare the notes.", "Use the Oat Satin glaze."), + ); + // Their bubble is their words, under the id the browser already drew it with. + expect(readTaskBrief(started[0]?.text ?? "")).toBe( + "Use the Oat Satin glaze.", + ); + expect(started[0]?.runId).toBe("reply-0123456789"); + expect( + items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}:again:1`)?.payload, + ).toMatchObject({ + reply: "Use the Oat Satin glaze.", + again: true, + runs: 2, + }); + }); + + test.each(["Review the blue ledger.", ""])( + "Try again preserves the exhausted reply envelope for text %j", + async (text) => { + const task = await opened(); + const questionId = `accepted-question-${randomUUID()}`; + const futureQuestionId = `future-question-${randomUUID()}`; + const question = { + ownerUserId: owner.id, + actorId: owner.id, + channelId: task.channelId, + mode: "completed_question", + }; + await database.insert(workItems).values({ + kind: "person.question", + key: questionId, + payload: question, + }); + const envelope = { + text, + reply: text || "Sent attachments", + attachmentIds: ["12345678-1234-4123-8123-123456789abc"], + skillIds: ["review-blue-ledger"], + followup: true, + questionIds: [questionId], + }; + const messageId = `accepted-reply-${randomUUID()}`; + await turnFor(task.id, { + payload: { + ownerUserId: owner.id, + taskId: task.id, + ...envelope, + messageId, + runs: 2, + }, + attempts: 2, + lastError: "Failed before input persistence", + }); + expect(await taskStore.failedTurn(owner.id, task.id)).toEqual({ + ask: envelope, + turns: 1, + }); + let attempts = 0; + const { engine, items, started } = engineFor(async () => { + if (++attempts === 1) { + await database.insert(workItems).values({ + kind: "person.question", + key: futureQuestionId, + payload: question, + }); + throw new Error("Retry before delivering the preserved input"); + } + }); + await engine.again(owner.id, task.id); + await engine.idle(); + expect( + items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}:again:1`)?.payload, + ).toEqual({ + ownerUserId: owner.id, + taskId: task.id, + ...envelope, + again: true, + runs: 2, + }); + expect(started).toHaveLength(2); + for (const run of started) { + expect(run).toMatchObject({ + text, + attachmentIds: envelope.attachmentIds, + skillIds: envelope.skillIds, + }); + expect(run.runId).not.toBe(messageId); + } + expect(started[0]!.runId).not.toBe(started[1]!.runId); + expect(await taskStore.pendingQuestions(owner.id, task.id)).toEqual([ + futureQuestionId, + ]); + }, + ); + + test.each(["asked", "suggestion"])( + "successive initial %s corrections survive absent model history", + async (origin) => { + let failing = true; + const persisted: unknown[] = []; + const received: string[] = []; + const taskTurn = createTaskTurn({ + actorFor, + channels: channelStore, + runTurn: async (input) => { + if (failing) throw new Error("Failed before history persistence"); + received.push(input.instruction); + persisted.push(input.userMessage); + return { replyText: "" }; + }, + }); + const { engine, started } = engineFor( + async ({ scope, ...input }, signal) => { + await taskTurn(scope, { ...input, signal }); + }, + createWorkQueue(database), + ); + const suggestion = + origin === "suggestion" ? await suggestionFor("Draft the email") : null; + const task = suggestion + ? (await engine.start(owner.id, suggestion.id)).task + : await engine.startAsked(owner.id, bot, { + title: "Draft the email", + instruction: "Draft the email", + }); + await engine.idle(); + await engine.again(owner.id, task.id, { reply: "Write it in French" }); + await engine.idle(); + const { ask } = await taskStore.failedTurn(owner.id, task.id); + expect(ask).toMatchObject({ + reply: "Write it in French", + ...(suggestion + ? { suggestionId: suggestion.id } + : { text: "Draft the email" }), + }); + expect({ persisted, attempts: started.length }).toEqual({ + persisted: [], + attempts: 4, + }); + failing = false; + const messageId = randomUUID(); + await engine.again(owner.id, task.id, { + reply: "Use a formal tone", + messageId, + }); + await engine.idle(); + expect( + received.map((text) => + text.match(/Draft the email|Write it in French|Use a formal tone/g), + ), + ).toEqual([ + ["Draft the email", "Write it in French", "Use a formal tone"], + ]); + expect({ + final: started.slice(4), + identities: new Set(started.map((run) => run.runId)).size, + }).toMatchObject({ + final: [{ runId: messageId, scope: { channelId: task.channelId } }], + identities: 5, + }); + }, + ); + + test("successive corrections preserve failed followup context and display only the latest reply", async () => { + const task = await opened(); + const questionId = `accepted-question-${randomUUID()}`; + const context = { + attachmentIds: ["12345678-1234-4123-8123-123456789abc"], + skillIds: ["review-blue-ledger"], + followup: true, + questionIds: [questionId], + }; + await database.insert(workItems).values({ + kind: "person.question", + key: questionId, + payload: { + ownerUserId: owner.id, + actorId: owner.id, + channelId: task.channelId, + mode: "completed_question", + }, + }); + const original = "Review the blue ledger."; + await turnFor(task.id, { + payload: { + ownerUserId: owner.id, + taskId: task.id, + text: original, + reply: original, + ...context, + runs: 2, + }, + attempts: 2, + lastError: "fetch failed", + }); + const { engine, started } = engineFor(async () => { + throw new Error("fetch failed"); + }, createWorkQueue(database)); + const corrections = [ + "Use the revised totals.", + `Use October's exchange rate. Keep this quote:\n\n${TASK_REPLY_LINE}\n\nQuoted example.`, + ]; + const earlier = [original]; + for (const [index, reply] of corrections.entries()) { + const messageId = `revised-reply-${index}`; + await engine.again(owner.id, task.id, { reply, messageId }); + await engine.idle(); + expect(started).toHaveLength((index + 1) * 2); + expect(started[index * 2]!.runId).toBe(messageId); + const failed = await taskStore.failedTurn(owner.id, task.id); + expect(failed).toMatchObject({ + ask: { ...context, reply }, + turns: index + 2, + }); + for (const run of started.slice(index * 2)) { + expect(run).toMatchObject({ + attachmentIds: context.attachmentIds, + skillIds: context.skillIds, + }); + for (const text of earlier) expect(run.text).toContain(text); + expect(readTaskBrief(run.text)).toBe(reply); + } + earlier.push(reply); + } + expect(await taskStore.pendingQuestions(owner.id, task.id)).toEqual([]); + }); + + test("accepted replies keep one identity across retries and Try again, including equal-text corrections", async () => { + const task = await opened(); + const reply = "Use October's exchange rate."; + const messages: { id: string; role: string; content?: unknown }[] = [ + { + id: "initial", + role: "user", + content: frameTaskBrief("Prepare the notes."), + }, + ]; + const received: Parameters[0][] = []; + const context = replyContextForTest(); + const taskTurn = createTaskTurn({ + actorFor, + channels: channelStore, + replyContext: context, + runTurn: async (input) => { + if (input.userMessage?.role !== "user") + throw new Error("Task user message is missing"); + received.push(input); + messages.push(input.userMessage); + throw new Error("fetch failed"); + }, + }); + const { engine, started } = engineFor( + async ({ scope, ...input }, signal) => { + await taskTurn(scope, { ...input, signal }); + }, + createWorkQueue(database), + context, + ); + await engine.reply(owner.id, task.id, { reply, messageId: "accepted-a" }); + await engine.idle(); + await engine.again(owner.id, task.id); + await engine.idle(); + await engine.again(owner.id, task.id, { reply, messageId: "accepted-b" }); + await engine.idle(); + expect(new Set(started.map((run) => run.runId)).size).toBe(6); + expect(started[0]?.runId).toBe("accepted-a"); + expect(started[4]?.runId).toBe("accepted-b"); + expect(started.map((run) => run.acceptedReply?.id)).toEqual([ + "accepted-a", + "accepted-a", + "accepted-a", + "accepted-a", + "accepted-b", + "accepted-b", + ]); + expect( + messages + .filter((message) => message.role === "user") + .slice(1) + .map((message) => readTaskFrame(String(message.content))?.inputId), + ).toEqual(started.map((run) => run.acceptedReply?.id)); + const quoted = frameTaskBrief("quoted task text"); + await engine.reply(owner.id, task.id, { + reply: quoted, + messageId: "accepted-quote", + }); + await engine.idle(); + const quotedInputs = messages + .filter((message) => message.role === "user") + .slice(-2); + for (const message of quotedInputs) + expect(readTaskBrief(String(message.content))).toBe(quoted); + const file = await attachmentForTask(task.channelId); + await engine.reply(owner.id, task.id, { + reply: "", + messageId: "accepted-file", + attachmentIds: [file.id], + }); + await engine.idle(); + const fileInputs = received.slice(-2); + expect( + fileInputs.map((input) => { + const content = input.userMessage?.content; + const text = Array.isArray(content) + ? content.flatMap((part) => (part.type === "text" ? [part.text] : [])) + : [content]; + return readTaskFrame(String(text[0] ?? ""))?.inputId; + }), + ).toEqual(["accepted-file", "accepted-file"]); + const document = { + type: "document", + source: { + type: "url", + value: `/api/attachments/${file.id}`, + mimeType: "text/plain", + }, + metadata: { attachmentId: file.id, filename: "notes.txt" }, + }; + expect( + fileInputs.map((input) => { + const content = input.userMessage?.content; + return Array.isArray(content) + ? content.filter((part) => part.type !== "text") + : content; + }), + ).toEqual([[document], [document]]); + expect(fileInputs.map((input) => input.instruction)).toEqual(["", ""]); + }); + + test("marked done after failing, it keeps its failure, and a reply runs it and makes it not done", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + const done = await taskStore.resolve(owner.id, task.id); + expect(done).toMatchObject({ + status: "resolved", + failure: "a service didn't answer", + }); + expect(done.failedAt).not.toBeNull(); + const queue = createWorkQueue(database); + const { engine, started } = engineFor(async () => {}, queue); + await engine.again(owner.id, task.id, { reply: "Please do it now." }); + await engine.idle(); + expect(started).toHaveLength(1); + expect((await taskStore.get(owner.id, task.id)).resolvedAt).toBeNull(); + // Done and never failed has nothing to try again. + const fine = await opened(); + await turnFor(fine.id, { attempts: 1, finishedAt: new Date() }); + await taskStore.resolve(owner.id, fine.id); + await expect(engine.again(owner.id, fine.id)).rejects.toThrow( + "Only a task that couldn't finish can be tried again.", + ); + }); + + test("a refused Try again offer preserves the failed task's completion", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + const done = await taskStore.resolve(owner.id, task.id); + const queue = createWorkQueue(database); + const { engine } = engineFor(async () => {}, { + ...queue, + offer: (input) => + queue.offer({ + ...input, + atMost: { keyPrefix: `task:${task.id}:again:`, max: 0 }, + }), + }); + await expect(engine.again(owner.id, task.id)).rejects.toThrow( + "This task is already being tried again.", + ); + const turns = await database + .select() + .from(workItems) + .where(sql`${workItems.payload}->>'taskId' = ${task.id}`); + expect(turns).toHaveLength(1); + const [persisted] = await database + .select({ resolvedAt: tasks.resolvedAt }) + .from(tasks) + .where(eq(tasks.id, task.id)); + expect(persisted?.resolvedAt?.toISOString() ?? null).toBe(done.resolvedAt); + const expected = { status: "resolved", resolvedAt: done.resolvedAt }; + const detail = await taskStore.get(owner.id, task.id); + const listed = (await taskStore.list(owner.id)).find( + (row) => row.id === task.id, + ); + expect([detail, listed]).toMatchObject([expected, expected]); + }); + + test("a turn shaped as main queued it, from before tasks and retries, opens nothing and never runs again", async () => { + const { engine, items, started } = engineFor(); + const suggestionId = `${prefix}-main-suggestion-${randomUUID()}`; + // Main's Start: keyed by the suggestion, no task, no run count, failed once and left. + items.set(`${PROACTIVE_TASK_KIND}:${suggestionId}`, { + kind: PROACTIVE_TASK_KIND, + key: suggestionId, + payload: { ownerUserId: owner.id, suggestionId }, + attempts: 1, + finished: false, + claimed: false, + }); + const tasksBefore = await database + .select({ id: tasks.id }) + .from(tasks) + .where(eq(tasks.ownerUserId, owner.id)); + await engine.sweep().catch(() => undefined); + const tasksAfter = await database + .select({ id: tasks.id }) + .from(tasks) + .where(eq(tasks.ownerUserId, owner.id)); + expect(tasksAfter).toHaveLength(tasksBefore.length); + expect(started).toHaveLength(0); + expect(items.get(`${PROACTIVE_TASK_KIND}:${suggestionId}`)).toMatchObject({ + attempts: 2, + finished: false, + lastError: "This turn failed before it could be tried again.", + }); + }); + + test("Try again runs it once more in its own conversation, and only when it failed", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + const { engine, items, started } = engineFor(); + + // Somebody else's task is no task. + await expect(engine.again(other.id, task.id)).rejects.toThrow( + "Couldn't find that task.", + ); + // Pressed twice at once, it is one run, and the second is told so rather than answered as done. + const both = await Promise.allSettled([ + engine.again(owner.id, task.id), + engine.again(owner.id, task.id), + ]); + expect(both.map((outcome) => outcome.status).sort()).toEqual([ + "fulfilled", + "rejected", + ]); + expect( + both.find((outcome) => outcome.status === "rejected")?.reason, + ).toBeInstanceOf(ProactiveRefusedError); + await engine.idle(); + expect(started).toHaveLength(1); + expect(started[0]?.scope.channelId).toBe(task.channelId); + expect(started[0]?.text).toBe(frameTaskBrief("Prepare the notes.")); + expect(started[0]?.runId).toStartWith(`task-${task.id}-`); + expect(started[0]?.final).toBe(false); + expect( + items.get(`${PROACTIVE_TASK_KIND}:task:${task.id}:again:1`)?.finished, + ).toBe(true); + + // One that did not fail has nothing to try again. + const fine = await opened(); + await turnFor(fine.id, { attempts: 1, finishedAt: new Date() }); + await expect(engine.again(owner.id, fine.id)).rejects.toThrow( + "Only a task that couldn't finish can be tried again.", + ); + }); + + test("Try again is served to the task's owner, for a failed task only", async () => { + const task = await opened(); + await turnFor(task.id, { attempts: 2, lastError: "fetch failed" }); + const fine = await opened(); + await turnFor(fine.id, { attempts: 1, finishedAt: new Date() }); + const { engine, started } = engineFor(); + const routes = createTaskRoutes( + taskStore, + async (context, next) => { + context.set("actor", { + id: context.req.header("x-person") ?? owner.id, + } as AuthenticatedActor); + await next(); + }, + { retry: engine }, + ); + const again = (person: string, id: string) => + routes.request(`/${id}/again`, { + method: "POST", + headers: { "x-person": person }, + }); + + expect((await again(other.id, task.id)).status).toBe(404); + const refused = await again(owner.id, fine.id); + expect(refused.status).toBe(409); + expect(((await refused.json()) as { error: string }).error).toBe( + "Only a task that couldn't finish can be tried again.", + ); + const tooLong = await routes.request(`/${task.id}/again`, { + method: "POST", + headers: { "x-person": owner.id, "content-type": "application/json" }, + body: JSON.stringify({ reply: "x".repeat(4001) }), + }); + expect(tooLong.status).toBe(400); + const badId = await routes.request(`/${task.id}/again`, { + method: "POST", + headers: { "x-person": owner.id, "content-type": "application/json" }, + body: JSON.stringify({ reply: "Hi", messageId: "