From 128214799fce60e0eb8e54583858f6e0aa80988f Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 04:08:10 -0400 Subject: [PATCH 1/4] Add durable draft resources with SDK and CLI parity --- .../cache-owners/cache-owner-registry.test.ts | 2 + .../cache-owners/realtime-cache-registry.ts | 4 + .../cache-owners/system-cache-effects.ts | 2 + apps/app/src/hooks/queries/query-keys.ts | 1 + .../__tests__/command-output/draft.test.ts | 410 ++ apps/cli/src/commands/thread/draft.ts | 305 + apps/cli/src/commands/thread/index.ts | 2 + apps/server/package.json | 2 +- apps/server/src/routes/drafts.ts | 103 + apps/server/src/server.ts | 2 + .../src/services/drafts/draft-records.ts | 94 + .../src/services/drafts/draft-submit.ts | 172 + .../services/drafts/draft-thread-record.ts | 44 + .../server/src/services/plugins/plugin-api.ts | 14 + .../src/services/threads/thread-create.ts | 28 +- apps/server/test/public/public-drafts.test.ts | 583 ++ .../test/services/plugins/plugin-sdk.test.ts | 58 + packages/bb-app/src/public-sdk.ts | 2 + packages/db/drizzle/0127_draft_resources.sql | 26 + packages/db/drizzle/meta/0127_snapshot.json | 5169 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/data/drafts.ts | 288 + packages/db/src/data/index.ts | 23 + packages/db/src/data/project-attachments.ts | 17 + packages/db/src/schema.ts | 40 + packages/db/test/data/drafts.test.ts | 409 ++ packages/db/test/draft-migration.test.ts | 98 + packages/db/test/migrate.test.ts | 10 +- packages/domain/src/change-kinds.ts | 1 + packages/domain/src/plugin-sdk-version.ts | 2 +- packages/plugin-sdk/package.json | 2 +- packages/sdk/src/areas/drafts.ts | 125 + packages/sdk/src/core.ts | 3 + packages/sdk/src/public-types.ts | 6 + packages/sdk/test/drafts.test.ts | 260 + packages/sdk/test/public-types.test.ts | 1 + packages/server-contract/src/api-types.ts | 1 + packages/server-contract/src/api/drafts.ts | 144 + packages/server-contract/src/public-api.ts | 72 + .../src/templates/bb-guide-overview.md | 3 +- .../src/templates/bb-guide-threads.md | 66 +- plugins/bb-guide/skills/bb-cli/SKILL.md | 6 +- .../skills/bb-cli/references/command-index.md | 11 + .../bb-cli/references/thread-creation.md | 41 + pnpm-lock.yaml | 6 +- 45 files changed, 8649 insertions(+), 16 deletions(-) create mode 100644 apps/cli/src/__tests__/command-output/draft.test.ts create mode 100644 apps/cli/src/commands/thread/draft.ts create mode 100644 apps/server/src/routes/drafts.ts create mode 100644 apps/server/src/services/drafts/draft-records.ts create mode 100644 apps/server/src/services/drafts/draft-submit.ts create mode 100644 apps/server/src/services/drafts/draft-thread-record.ts create mode 100644 apps/server/test/public/public-drafts.test.ts create mode 100644 packages/db/drizzle/0127_draft_resources.sql create mode 100644 packages/db/drizzle/meta/0127_snapshot.json create mode 100644 packages/db/src/data/drafts.ts create mode 100644 packages/db/test/data/drafts.test.ts create mode 100644 packages/db/test/draft-migration.test.ts create mode 100644 packages/sdk/src/areas/drafts.ts create mode 100644 packages/sdk/test/drafts.test.ts create mode 100644 packages/server-contract/src/api/drafts.ts diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts index d61247921c..0cac2043d7 100644 --- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts +++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts @@ -139,6 +139,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "pluginSettingsViewQueryKey", ], "hooks/cache-owners/realtime-cache-registry.ts": [ + "allDraftQueryKeyPrefix", "allHostQueryKeyPrefix", "allMachineEnvironmentQueryKeyPrefix", "allPluginCatalogSearchQueryKeyPrefix", @@ -180,6 +181,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { ], "hooks/cache-owners/skills-cache-effects.ts": ["projectSkillsQueryKey"], "hooks/cache-owners/system-cache-effects.ts": [ + "allDraftQueryKeyPrefix", "allEnvironmentDiffFilesQueryKeyPrefix", "allEnvironmentDiffPatchQueryKeyPrefix", "allEnvironmentFilePreviewQueryKeyPrefix", diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 9719570b0d..4105bb6050 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -32,6 +32,7 @@ import { iterateThreadListCacheEntries, } from "./thread-list-cache-data"; import { + allDraftQueryKeyPrefix, allHostQueryKeyPrefix, allMachineEnvironmentQueryKeyPrefix, allPluginCatalogSearchQueryKeyPrefix, @@ -523,6 +524,9 @@ export const REALTIME_HOST_CHANGE_REGISTRY = { } satisfies HostChangeRegistry; export const REALTIME_SYSTEM_CHANGE_REGISTRY = { + "drafts-changed": { + dirty: [() => [allDraftQueryKeyPrefix()]], + }, "config-changed": { dirty: [ dirtySystemConfigQueries, diff --git a/apps/app/src/hooks/cache-owners/system-cache-effects.ts b/apps/app/src/hooks/cache-owners/system-cache-effects.ts index fca2bc85e2..4f9a7f1f0a 100644 --- a/apps/app/src/hooks/cache-owners/system-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/system-cache-effects.ts @@ -2,6 +2,7 @@ import type { QueryKey } from "@tanstack/react-query"; import type { Environment } from "@bb/domain"; import type { SystemConfigResponse } from "@bb/server-contract"; import { + allDraftQueryKeyPrefix, allEnvironmentDiffFilesQueryKeyPrefix, allEnvironmentDiffPatchQueryKeyPrefix, allEnvironmentFilePreviewQueryKeyPrefix, @@ -183,6 +184,7 @@ export function resetModelCatalogsAfterStreamerModeChange({ function getServerReconnectInvalidationQueryKeys(): QueryKey[] { return [ + allDraftQueryKeyPrefix(), hostsQueryKey(), allHostQueryKeyPrefix(), projectsQueryKey(), diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 4a3a124fcc..9c6526dbe1 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -11,6 +11,7 @@ import { } from "@/lib/path-list-options"; const HOSTS_QUERY_KEY = "hosts"; +export const allDraftQueryKeyPrefix = () => ["drafts"] as const; const HOST_QUERY_KEY = "host"; const HOST_DIRECTORY_QUERY_KEY = "hostDirectory"; const HOST_CLONE_DEFAULT_PATH_QUERY_KEY = "hostCloneDefaultPath"; diff --git a/apps/cli/src/__tests__/command-output/draft.test.ts b/apps/cli/src/__tests__/command-output/draft.test.ts new file mode 100644 index 0000000000..5cf9c27ef8 --- /dev/null +++ b/apps/cli/src/__tests__/command-output/draft.test.ts @@ -0,0 +1,410 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Command } from "commander"; +import { draftSchema } from "@bb/server-contract"; +import { describe, expect, it, vi } from "vitest"; +import { + collectLogLines, + runCommand, + setupCommandOutputTestEnvironment, + stubServerApi, + type CommandRegistrar, +} from "../helpers/command-output-harness.js"; +import { makeThread } from "../helpers/command-output-fixtures.js"; +import { registerDraftCommands } from "../../commands/thread/draft.js"; + +const draft = draftSchema.parse({ + id: "drf_cli_roundtrip", + revision: 4, + createdAt: 1, + updatedAt: 2, + content: { + projectId: "proj_saved", + sectionId: "section_saved", + prompt: { + text: "Original text", + attachments: [ + { + type: "localFile", + path: "/uploads/notes.txt", + name: "notes.txt", + sizeBytes: 10, + }, + ], + }, + options: { + providerId: "codex", + model: "saved-model", + reasoningLevel: "high", + permissionMode: "auto", + environment: { type: "reuse", environmentId: "env_saved" }, + }, + }, +}); + +describe("bb thread draft commands", () => { + setupCommandOutputTestEnvironment(); + + const register: CommandRegistrar = (program) => + registerDraftCommands(program.command("thread"), () => "http://server"); + + it("prints complete generated and caller-supplied identities for reuse", async () => { + const ids = [ + "drf_c4f849da-569e-4822-bb8d-b143438b20b7", + `drf_${"a".repeat(128)}`, + ]; + stubServerApi({ + "v1.drafts.$get": vi.fn(async () => ({ + drafts: ids.map((id) => ({ ...draft, id })), + nextOffset: null, + })), + }); + await runCommand(["thread", "draft", "list"], register); + const output = collectLogLines(vi.mocked(console.log)).join("\n"); + for (const id of ids) expect(output).toContain(id); + }); + + it("creates a retryable identity from a content file without losing composer choices", async () => { + const post = vi.fn(async () => ({ id: draft.id, draft })); + stubServerApi({ "v1.drafts.$post": post }); + const directory = await mkdtemp(join(process.cwd(), ".draft-command-")); + try { + const file = join(directory, "content.json"); + await writeFile(file, JSON.stringify(draft.content)); + await runCommand( + [ + "thread", + "draft", + "create", + "--id", + draft.id, + "--content-file", + file, + "--json", + ], + register, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + expect(post).toHaveBeenCalledWith({ + json: { id: draft.id, content: draft.content }, + }); + expect(JSON.parse(collectLogLines(vi.mocked(console.log))[0]!)).toEqual({ + id: draft.id, + draft, + }); + }); + + it("acknowledges consumed identities without reporting that a draft was saved", async () => { + const post = vi.fn(async () => ({ id: draft.id, draft: null })); + stubServerApi({ "v1.drafts.$post": post }); + await runCommand( + ["thread", "draft", "create", "--id", draft.id, "--text", "Retry"], + register, + ); + expect(collectLogLines(vi.mocked(console.log))).toEqual([ + `${draft.id} was already consumed or deleted.`, + ]); + }); + + it("requires revisions before any mutation can run", async () => { + for (const command of ["update", "delete", "submit"]) { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeErr: vi.fn() }); + register(program); + await expect( + program.parseAsync([ + "node", + "bb", + "thread", + "draft", + command, + draft.id, + ]), + ).rejects.toMatchObject({ + code: "commander.missingMandatoryOptionValue", + }); + } + }); + + it("preserves attachments, destination and options when replacing plain text", async () => { + const content = { + ...draft.content, + prompt: { ...draft.content.prompt, text: "Replacement\ntext" }, + }; + const get = vi.fn(async () => draft); + const patch = vi.fn(async () => ({ ...draft, revision: 5, content })); + stubServerApi({ "v1.drafts.:id.$get": get, "v1.drafts.:id.$patch": patch }); + vi.stubEnv("BB_PROJECT_ID", "proj_unrelated"); + + await runCommand( + [ + "thread", + "draft", + "update", + draft.id, + "--expected-revision", + "4", + "--text", + "Replacement\ntext", + "--json", + ], + register, + ); + + expect(patch).toHaveBeenCalledWith({ + param: { id: draft.id }, + json: { expectedRevision: 4, content }, + }); + }); + + it("refuses a stale text edit before it can overwrite unseen draft content", async () => { + const patch = vi.fn(); + stubServerApi({ + "v1.drafts.:id.$get": vi.fn(async () => ({ ...draft, revision: 5 })), + "v1.drafts.:id.$patch": patch, + }); + await expect( + runCommand( + [ + "thread", + "draft", + "update", + draft.id, + "--expected-revision", + "4", + "--text", + "Unseen edit", + ], + register, + ), + ).rejects.toThrow("process.exit:1"); + expect(patch).not.toHaveBeenCalled(); + expect(collectLogLines(vi.mocked(console.error)).join("\n")).toContain( + "Draft changed to revision 5", + ); + }); + + it("refuses to silently discard or misplace mentions during text-only edits", async () => { + const patch = vi.fn(); + stubServerApi({ + "v1.drafts.:id.$get": vi.fn(async () => ({ + ...draft, + content: { + ...draft.content, + prompt: { + ...draft.content.prompt, + mentions: [ + { + start: 0, + end: 8, + resource: { + kind: "thread", + threadId: "thr_original", + label: "Original", + }, + }, + ], + }, + }, + })), + "v1.drafts.:id.$patch": patch, + }); + await expect( + runCommand( + [ + "thread", + "draft", + "update", + draft.id, + "--expected-revision", + "4", + "--text", + "Replacement", + ], + register, + ), + ).rejects.toThrow("process.exit:1"); + expect(patch).not.toHaveBeenCalled(); + expect(collectLogLines(vi.mocked(console.error)).join("\n")).toContain( + "--content-file", + ); + }); + + it("uses complete replacement files without merging stale server state", async () => { + const content = { ...draft.content, projectId: null, sectionId: null }; + const get = vi.fn(); + const patch = vi.fn(async () => ({ ...draft, revision: 5, content })); + stubServerApi({ "v1.drafts.:id.$get": get, "v1.drafts.:id.$patch": patch }); + const directory = await mkdtemp(join(process.cwd(), ".draft-command-")); + try { + const file = join(directory, "content.json"); + await writeFile(file, JSON.stringify(content)); + await runCommand( + [ + "thread", + "draft", + "update", + draft.id, + "--expected-revision", + "4", + "--content-file", + file, + ], + register, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + expect(get).not.toHaveBeenCalled(); + expect(patch).toHaveBeenCalledWith({ + param: { id: draft.id }, + json: { expectedRevision: 4, content }, + }); + }); + + it("keeps the server revision guard after the text-edit read", async () => { + const patch = vi.fn(async () => + Response.json({ message: "Draft changed" }, { status: 409 }), + ); + stubServerApi({ + "v1.drafts.:id.$get": vi.fn(async () => draft), + "v1.drafts.:id.$patch": patch, + }); + await expect( + runCommand( + [ + "thread", + "draft", + "update", + draft.id, + "--expected-revision", + "4", + "--text", + "Replacement", + ], + register, + ), + ).rejects.toThrow("process.exit:1"); + expect(patch).toHaveBeenCalledTimes(1); + expect(collectLogLines(vi.mocked(console.error)).join("\n")).toContain( + "HTTP 409", + ); + }); + + it("passes discovery filters and pagination without using thread context", async () => { + const get = vi.fn(async () => ({ drafts: [draft], nextOffset: 12 })); + stubServerApi({ "v1.drafts.$get": get }); + await runCommand( + [ + "thread", + "draft", + "list", + "--project", + "proj_saved", + "--query", + "Original", + "--include-empty", + "--limit", + "2", + "--offset", + "10", + "--json", + ], + register, + ); + expect(get).toHaveBeenCalledWith({ + query: { + projectId: "proj_saved", + query: "Original", + includeEmpty: "true", + limit: "2", + offset: "10", + }, + }); + expect(JSON.parse(collectLogLines(vi.mocked(console.log))[0]!)).toEqual({ + drafts: [draft], + nextOffset: 12, + }); + }); + + it.each(["0", "-1", "1.5", "2x", "9007199254740992"])( + "rejects invalid expected revision %s without calling the server", + async (revision) => { + const post = vi.fn(); + stubServerApi({ "v1.drafts.:id.submit.$post": post }); + await expect( + runCommand( + [ + "thread", + "draft", + "submit", + draft.id, + "--expected-revision", + revision, + ], + register, + ), + ).rejects.toThrow("process.exit:1"); + expect(post).not.toHaveBeenCalled(); + }, + ); + + it("submits as the CLI and retains newer edits in JSON output", async () => { + const thread = { + ...makeThread({ + id: "thr_submitted", + projectId: "proj_saved", + providerId: "codex", + }), + runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, + }; + const newer = { ...draft, revision: 5 }; + const post = vi.fn(async () => ({ thread, draft: newer })); + stubServerApi({ "v1.drafts.:id.submit.$post": post }); + await runCommand( + [ + "thread", + "draft", + "submit", + draft.id, + "--expected-revision", + "4", + "--json", + ], + register, + ); + expect(post).toHaveBeenCalledWith({ + param: { id: draft.id }, + json: { expectedRevision: 4, origin: "cli" }, + }); + expect(JSON.parse(collectLogLines(vi.mocked(console.log))[0]!)).toEqual({ + thread, + draft: newer, + }); + }); + + it("deletes only the requested revision with explicit noninteractive confirmation", async () => { + const remove = vi.fn(async () => ({ id: draft.id, revision: 5 })); + stubServerApi({ "v1.drafts.:id.$delete": remove }); + await runCommand( + [ + "thread", + "draft", + "delete", + draft.id, + "--expected-revision", + "4", + "--yes", + "--json", + ], + register, + ); + expect(remove).toHaveBeenCalledWith({ + param: { id: draft.id }, + json: { expectedRevision: 4 }, + }); + }); +}); diff --git a/apps/cli/src/commands/thread/draft.ts b/apps/cli/src/commands/thread/draft.ts new file mode 100644 index 0000000000..096ed6ae93 --- /dev/null +++ b/apps/cli/src/commands/thread/draft.ts @@ -0,0 +1,305 @@ +import { readFile } from "node:fs/promises"; +import { Command } from "commander"; +import { draftContentSchema, type DraftContent } from "@bb/server-contract"; +import { action } from "../../action.js"; +import { createCliBbSdk } from "../../client.js"; +import { renderBorderlessTable } from "../../table.js"; +import { + confirmDestructiveAction, + outputJson, + type JsonOutputOptions, +} from "../helpers.js"; + +interface DraftContentOptions extends JsonOutputOptions { + contentFile?: string; + project?: string; + section?: string; + text?: string; +} + +interface DraftCreateOptions extends DraftContentOptions { + id?: string; +} + +interface DraftRevisionOptions extends JsonOutputOptions { + expectedRevision: string; +} + +interface DraftUpdateOptions + extends DraftContentOptions, DraftRevisionOptions {} + +interface DraftDeleteOptions extends DraftRevisionOptions { + yes?: boolean; +} + +interface DraftListOptions extends JsonOutputOptions { + project?: string; + query?: string; + includeEmpty?: boolean; + limit?: string; + offset?: string; +} + +function parseInteger(value: string, flag: string, minimum: number): number { + const parsed = Number(value); + if ( + !/^\d+$/.test(value) || + !Number.isSafeInteger(parsed) || + parsed < minimum + ) { + throw new Error(`${flag} must be an integer of at least ${minimum}.`); + } + return parsed; +} + +function contentOptions(command: Command): Command { + return command + .option( + "--text ", + "Prompt text; use --content-file for text with mentions", + ) + .option( + "--project ", + "Project destination (no implicit current project)", + ) + .option("--section ", "Section destination") + .option( + "--content-file ", + "Local JSON content object to replace all content", + ) + .option("--json", "Print machine-readable JSON output"); +} + +function revisionOption(command: Command): Command { + return command.requiredOption( + "--expected-revision ", + "Draft revision returned by show; refuses unseen edits", + ); +} + +async function readContent( + options: DraftContentOptions, +): Promise { + if (options.contentFile === undefined) return draftContentSchema.parse({}); + const contents = await readFile(options.contentFile, "utf8"); + return draftContentSchema.strict().parse(JSON.parse(contents)); +} + +function applyContentOptions( + content: DraftContent, + options: DraftContentOptions, +): DraftContent { + if ( + options.text !== undefined && + options.text !== content.prompt.text && + content.prompt.mentions.length > 0 + ) { + throw new Error( + "Use --content-file to replace text with mentions and their positions together.", + ); + } + return draftContentSchema.parse({ + ...content, + projectId: options.project ?? content.projectId, + sectionId: options.section ?? content.sectionId, + prompt: { + ...content.prompt, + text: options.text ?? content.prompt.text, + }, + }); +} + +export function registerDraftCommands( + program: Command, + getUrl: () => string, +): void { + const draft = program + .command("draft") + .description("Manage saved drafts with or without an app connected"); + + contentOptions(draft.command("create")) + .description("Save a draft without starting a thread") + .option("--id ", "Stable creation identity for retry or import") + .action( + action(async (options: DraftCreateOptions) => { + const content = applyContentOptions( + await readContent(options), + options, + ); + const result = await createCliBbSdk(getUrl()).drafts.create({ + ...(options.id === undefined ? {} : { id: options.id }), + content, + }); + if (outputJson(options, result)) return; + console.log( + result.draft === null + ? `${result.id} was already consumed or deleted.` + : `Saved ${result.id} at revision ${result.draft.revision}.`, + ); + }), + ); + + draft + .command("list") + .description("List saved drafts with text or attachments") + .option("--project ", "Filter by project; defaults to all projects") + .option("--query ", "Search prompt text and attachment names") + .option("--include-empty", "Include blank and option-only drafts") + .option("--limit ", "Maximum results (1–200; default 50)") + .option("--offset ", "Pagination offset") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (options: DraftListOptions) => { + const result = await createCliBbSdk(getUrl()).drafts.list({ + projectId: options.project, + query: options.query, + includeEmpty: options.includeEmpty, + limit: + options.limit === undefined + ? undefined + : parseInteger(options.limit, "--limit", 1), + offset: + options.offset === undefined + ? undefined + : parseInteger(options.offset, "--offset", 0), + }); + if (outputJson(options, result)) return; + if (result.drafts.length === 0) { + console.log("No drafts."); + return; + } + console.log( + renderBorderlessTable( + { + head: ["ID", "REVISION", "PROJECT", "PROMPT"], + colWidths: [ + Math.max(2, ...result.drafts.map((entry) => entry.id.length)), + 10, + 24, + 60, + ], + trimTrailingWhitespace: true, + }, + result.drafts.map((entry) => [ + entry.id, + String(entry.revision), + entry.content.projectId ?? "—", + entry.content.prompt.text.replace(/\s+/g, " "), + ]), + ), + ); + if (result.nextOffset !== null) { + console.error(`More drafts: use --offset ${result.nextOffset}.`); + } + }), + ); + + draft + .command("show ") + .alias("get") + .description("Inspect a draft's revision and complete content") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (draftId: string, options: JsonOutputOptions) => { + const result = await createCliBbSdk(getUrl()).drafts.get({ draftId }); + if (outputJson(options, result)) return; + console.log(`${result.id} at revision ${result.revision}`); + console.log(JSON.stringify(result.content, null, 2)); + }), + ); + + revisionOption(contentOptions(draft.command("update "))) + .description("Edit a known revision; unsupplied fields stay unchanged") + .action( + action(async (draftId: string, options: DraftUpdateOptions) => { + const expectedRevision = parseInteger( + options.expectedRevision, + "--expected-revision", + 1, + ); + if ( + options.contentFile === undefined && + options.text === undefined && + options.project === undefined && + options.section === undefined + ) { + throw new Error( + "Provide --text, --project, --section or --content-file.", + ); + } + const sdk = createCliBbSdk(getUrl()); + let content: DraftContent; + if (options.contentFile !== undefined) { + content = await readContent(options); + } else { + const current = await sdk.drafts.get({ draftId }); + if (current.revision !== expectedRevision) { + throw new Error( + `Draft changed to revision ${current.revision}; inspect it before retrying.`, + ); + } + content = current.content; + } + const result = await sdk.drafts.update({ + draftId, + expectedRevision, + content: applyContentOptions(content, options), + }); + if (outputJson(options, result)) return; + console.log(`Saved ${result.id} at revision ${result.revision}.`); + }), + ); + + revisionOption(draft.command("delete ")) + .description("Delete only the draft revision you inspected") + .option("--yes", "Skip the confirmation prompt") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (draftId: string, options: DraftDeleteOptions) => { + const expectedRevision = parseInteger( + options.expectedRevision, + "--expected-revision", + 1, + ); + if ( + !options.yes && + !(await confirmDestructiveAction( + `Delete draft ${draftId}? This cannot be undone.`, + )) + ) { + console.log("Aborted."); + return; + } + const result = await createCliBbSdk(getUrl()).drafts.delete({ + draftId, + expectedRevision, + }); + if (outputJson(options, result)) return; + console.log(`Deleted ${result.id} at revision ${result.revision}.`); + }), + ); + + revisionOption(draft.command("submit ")) + .description("Start a thread once from an inspected draft revision") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (draftId: string, options: DraftRevisionOptions) => { + const result = await createCliBbSdk(getUrl()).drafts.submit({ + draftId, + expectedRevision: parseInteger( + options.expectedRevision, + "--expected-revision", + 1, + ), + origin: "cli", + }); + if (outputJson(options, result)) return; + console.log(`Submitted ${draftId} as thread ${result.thread.id}.`); + if (result.draft !== null) { + console.log( + `Newer draft edits remain at revision ${result.draft.revision}.`, + ); + } + }), + ); +} diff --git a/apps/cli/src/commands/thread/index.ts b/apps/cli/src/commands/thread/index.ts index 7d79d82c2b..3169153b56 100644 --- a/apps/cli/src/commands/thread/index.ts +++ b/apps/cli/src/commands/thread/index.ts @@ -2,6 +2,7 @@ import { registerContextCommand } from "./context.js"; import { Command } from "commander"; import { registerActionsCommands } from "./actions.js"; import { registerCountCommand } from "./count.js"; +import { registerDraftCommands } from "./draft.js"; import { registerInteractionCommands } from "./interactions.js"; import { registerListCommand } from "./list.js"; import { registerOpenCommand } from "./open.js"; @@ -23,6 +24,7 @@ export function registerThreadCommands( registerListCommand(thread, getUrl); registerCountCommand(thread, getUrl); registerContextCommand(thread, getUrl); + registerDraftCommands(thread, getUrl); registerShowCommand(thread, getUrl); registerOpenCommand(thread, getUrl); registerPaneCommand(thread, getUrl); diff --git a/apps/server/package.json b/apps/server/package.json index 85e01c5770..6cb35ffad3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,6 +15,7 @@ "test:provider-corpus": "vitest run --config vitest.config.ts test/provider-corpus" }, "dependencies": { + "@bb/client-core": "workspace:*", "@bb/config": "workspace:*", "@bb/core-ui": "workspace:*", "@bb/db": "workspace:*", @@ -54,7 +55,6 @@ }, "devDependencies": { "@bb/agent-runtime": "workspace:*", - "@bb/client-core": "workspace:*", "@bb/scripts": "workspace:*", "@bb/test-helpers": "workspace:*", "@bb/tsconfig": "workspace:*", diff --git a/apps/server/src/routes/drafts.ts b/apps/server/src/routes/drafts.ts new file mode 100644 index 0000000000..14fc26aa82 --- /dev/null +++ b/apps/server/src/routes/drafts.ts @@ -0,0 +1,103 @@ +import { deleteDraft, listDrafts, updateDraft } from "@bb/db"; +import { + publicApiRoutes, + typedRoutes, + type PublicApiSchema, +} from "@bb/server-contract"; +import type { Hono } from "hono"; +import { ApiError } from "../errors.js"; +import type { AppDeps } from "../types.js"; +import { + createDraftResource, + draftStorageFields, + requireDraft, + requireDraftRevision, + toDraft, +} from "../services/drafts/draft-records.js"; +import { submitDraft } from "../services/drafts/draft-submit.js"; + +function pageNumber(value: string | undefined, fallback: number): number { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new ApiError(400, "invalid_request", "Invalid draft pagination"); + } + return parsed; +} + +export function registerDraftRoutes(app: Hono, deps: AppDeps): void { + const { get, post, patch, del } = typedRoutes(app, { + onValidationError: (message) => + new ApiError(400, "invalid_request", message), + }); + const routes = publicApiRoutes.drafts; + + post(routes.create, (context, payload) => + context.json(createDraftResource(deps, payload), 201), + ); + + get(routes.list, (context, query) => { + const limit = pageNumber(query.limit, 50); + const offset = pageNumber(query.offset, 0); + if (limit < 1 || limit > 200) { + throw new ApiError(400, "invalid_request", "Draft limit must be 1–200"); + } + const rows = listDrafts(deps.db, { + projectId: query.projectId, + query: query.query, + includeEmpty: query.includeEmpty === "true", + limit: limit + 1, + offset, + }); + return context.json({ + drafts: rows.slice(0, limit).map(toDraft), + nextOffset: rows.length > limit ? offset + limit : null, + }); + }); + + get(routes.get, (context) => + context.json(requireDraft(deps, context.req.param("id"))), + ); + + patch(routes.update, (context, payload) => { + const id = context.req.param("id"); + requireDraftRevision(deps, id, payload.expectedRevision); + const row = updateDraft(deps.db, { + id, + expectedRevision: payload.expectedRevision, + ...draftStorageFields(payload.content), + }); + if (row === null) { + throw new ApiError(409, "draft_revision_conflict", "Draft changed"); + } + deps.hub.notifySystem(["drafts-changed"]); + return context.json(toDraft(row)); + }); + + del(routes.delete, (context, payload) => { + const id = context.req.param("id"); + requireDraftRevision(deps, id, payload.expectedRevision); + const row = deleteDraft(deps.db, { + id, + expectedRevision: payload.expectedRevision, + }); + if (row === null) { + throw new ApiError(409, "draft_revision_conflict", "Draft changed"); + } + deps.hub.notifySystem(["drafts-changed"]); + return context.json({ id, revision: row.revision }); + }); + + post(routes.submit, async (context, payload) => + context.json( + await submitDraft(deps, { + draftId: context.req.param("id"), + revision: payload.expectedRevision, + origin: payload.origin, + ...(payload.originPluginId !== undefined + ? { originPluginId: payload.originPluginId } + : {}), + }), + ), + ); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 254d7fb350..4962504b06 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -20,6 +20,7 @@ import { registerFileRoutes } from "./routes/files.js"; import { registerHostRoutes } from "./routes/hosts.js"; import { registerProjectRoutes } from "./routes/projects.js"; import { registerThreadSectionRoutes } from "./routes/thread-sections.js"; +import { registerDraftRoutes } from "./routes/drafts.js"; import { registerSystemRoutes } from "./routes/system.js"; import { registerUiPreferenceRoutes } from "./routes/ui-preferences.js"; import { registerTerminalRoutes } from "./routes/terminals.js"; @@ -760,6 +761,7 @@ export function createApp( }); registerProjectRoutes(publicApi, deps); registerThreadSectionRoutes(publicApi, deps); + registerDraftRoutes(publicApi, deps); registerFileRoutes(publicApi, deps); registerHostRoutes(publicApi, deps, pluginService); registerDesktopBrowserRoutes(publicApi, deps); diff --git a/apps/server/src/services/drafts/draft-records.ts b/apps/server/src/services/drafts/draft-records.ts new file mode 100644 index 0000000000..75f09bd6bf --- /dev/null +++ b/apps/server/src/services/drafts/draft-records.ts @@ -0,0 +1,94 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createDraft, getDraft, getStoredDraft, type DraftRow } from "@bb/db"; +import { + draftContentSchema, + type Draft, + type DraftContent, + type DraftCreateResponse, +} from "@bb/server-contract"; +import { ApiError } from "../../errors.js"; +import type { AppDeps } from "../../types.js"; + +export function toDraft(row: DraftRow): Draft { + if (row.payloadJson === null) { + throw new ApiError(410, "draft_gone", "Draft was deleted or submitted"); + } + const payload: unknown = JSON.parse(row.payloadJson); + return { + id: row.id, + revision: row.revision, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + content: draftContentSchema.parse(payload), + }; +} + +export function requireDraft(deps: Pick, id: string): Draft { + const row = getDraft(deps.db, id); + if (row !== null) return toDraft(row); + if (getStoredDraft(deps.db, id) !== null) { + throw new ApiError(410, "draft_gone", "Draft was deleted or submitted"); + } + throw new ApiError(404, "draft_not_found", "Draft not found"); +} + +export function draftStorageFields(content: DraftContent) { + const { prompt } = content; + return { + projectId: content.projectId, + payloadJson: JSON.stringify(content), + searchText: [ + prompt.text, + ...prompt.attachments.map((attachment) => attachment.name), + ].join("\n"), + hasInput: + prompt.text.length > 0 || + prompt.mentions.length > 0 || + prompt.attachments.length > 0, + }; +} + +export function requireDraftRevision( + deps: Pick, + id: string, + expectedRevision: number, +): Draft { + const current = requireDraft(deps, id); + if (current.revision !== expectedRevision) { + throw new ApiError( + 409, + "draft_revision_conflict", + "Draft changed. Read the current revision before saving or submitting.", + { details: { draft: current } }, + ); + } + return current; +} + +export function createDraftResource( + deps: Pick, + args: { id?: string; content: DraftContent }, +): DraftCreateResponse { + const fields = draftStorageFields(args.content); + const creationFingerprint = createHash("sha256") + .update(fields.payloadJson) + .digest("hex"); + const row = createDraft(deps.db, { + id: args.id ?? `drf_${randomUUID()}`, + ...fields, + creationFingerprint, + }); + if (row.creationFingerprint !== creationFingerprint) { + throw new ApiError( + 409, + "draft_identity_conflict", + "This draft ID already belongs to a different creation request", + ); + } + deps.hub.notifySystem(["drafts-changed"]); + return { + id: row.id, + draft: + row.deletedAt === null && row.submittedAt === null ? toDraft(row) : null, + }; +} diff --git a/apps/server/src/services/drafts/draft-submit.ts b/apps/server/src/services/drafts/draft-submit.ts new file mode 100644 index 0000000000..7ee6afb67c --- /dev/null +++ b/apps/server/src/services/drafts/draft-submit.ts @@ -0,0 +1,172 @@ +import { promptDraftToInput } from "@bb/client-core"; +import { + completeDraftSubmissionReceipt, + markThreadDeleted, + getEnvironment, + failDraftSubmissionReceipt, + getDraft, + getDraftSubmissionReceipt, + getThread, + hasQueuedThreadMessages, + type DbConnection, + type DraftSubmissionReceiptRow, +} from "@bb/db"; +import { + createThreadRequestSchema, + type Draft, + type DraftSubmitResponse, + type ThreadCreateOrigin, +} from "@bb/server-contract"; +import { ApiError } from "../../errors.js"; +import type { AppDeps } from "../../types.js"; +import { requestThreadStorageDeletion } from "../threads/thread-lifecycle.js"; +import { createThreadFromRequest } from "../threads/thread-create.js"; +import { toThreadResponseFromThread } from "../threads/thread-runtime-display.js"; +import { requireDraftRevision, toDraft } from "./draft-records.js"; +import type { DraftSubmissionSource } from "./draft-thread-record.js"; + +interface SubmitDraftArgs extends DraftSubmissionSource { + origin: ThreadCreateOrigin; + originPluginId?: string; +} + +const submissions = new WeakMap< + DbConnection, + Map> +>(); + +function remainingDraft(deps: Pick, id: string): Draft | null { + const row = getDraft(deps.db, id); + return row === null ? null : toDraft(row); +} + +function failedSubmission(): never { + throw new ApiError( + 409, + "draft_submission_failed", + "Submission did not complete. The draft was preserved. Save a new revision before submitting again.", + ); +} + +function readSubmission( + deps: AppDeps, + receipt: DraftSubmissionReceiptRow, +): DraftSubmitResponse { + if (receipt.status === "failed") return failedSubmission(); + const thread = getThread(deps.db, receipt.threadId); + if (receipt.status === "pending") { + if ( + thread === null || + (thread.status === "pending" && + !hasQueuedThreadMessages(deps.db, thread.id)) + ) { + if (thread !== null) { + const deleted = markThreadDeleted(deps.db, deps.hub, { + threadId: thread.id, + }); + if (deleted) { + requestThreadStorageDeletion( + deps, + deleted, + deleted.environmentId + ? getEnvironment(deps.db, deleted.environmentId) + : null, + ); + } + } + failDraftSubmissionReceipt(deps.db, receipt); + return failedSubmission(); + } + completeDraftSubmissionReceipt(deps.db, receipt); + deps.hub.notifySystem(["drafts-changed"]); + } + if (thread === null || thread.deletedAt !== null) { + throw new ApiError( + 410, + "draft_submitted_thread_gone", + "This draft revision was submitted, but its thread has since been deleted", + { details: { threadId: receipt.threadId } }, + ); + } + return { + thread: toThreadResponseFromThread(deps, { thread }), + draft: remainingDraft(deps, receipt.draftId), + }; +} + +async function performSubmission( + deps: AppDeps, + args: SubmitDraftArgs, +): Promise { + const receipt = getDraftSubmissionReceipt(deps.db, args); + if (receipt !== null) return readSubmission(deps, receipt); + const draft = requireDraftRevision(deps, args.draftId, args.revision); + const { content } = draft; + const { options } = content; + const request = createThreadRequestSchema.safeParse({ + projectId: content.projectId, + sectionId: content.sectionId, + input: promptDraftToInput(content.prompt), + origin: args.origin, + originPluginId: args.originPluginId, + environment: + options.environment?.type === "provider" + ? { + ...options.environment, + machine: options.environment.machine ?? undefined, + } + : options.environment, + providerId: options.providerId ?? undefined, + model: options.model ?? undefined, + reasoningLevel: options.reasoningLevel ?? undefined, + serviceTier: options.serviceTier ?? undefined, + permissionMode: options.permissionMode ?? undefined, + title: options.title ?? undefined, + parentThreadId: options.parentThreadId ?? undefined, + sourceThreadId: options.sourceThreadId ?? undefined, + sourceSeqEnd: options.sourceSeqEnd ?? undefined, + originKind: options.originKind, + sendAt: options.sendAt ?? undefined, + }); + if (!request.success) { + throw new ApiError( + 400, + "draft_not_ready", + "Choose a project, environment and prompt before submitting the draft", + { details: request.error.issues }, + ); + } + const thread = await createThreadFromRequest(deps, request.data, { + draftSubmission: { draftId: args.draftId, revision: args.revision }, + }); + completeDraftSubmissionReceipt(deps.db, { + draftId: args.draftId, + revision: args.revision, + }); + deps.hub.notifySystem(["drafts-changed"]); + return { + thread: toThreadResponseFromThread(deps, { thread }), + draft: remainingDraft(deps, args.draftId), + }; +} + +export async function submitDraft( + deps: AppDeps, + args: SubmitDraftArgs, +): Promise { + let pending = submissions.get(deps.db); + if (pending === undefined) { + pending = new Map(); + submissions.set(deps.db, pending); + } + const key = `${args.draftId}:${args.revision}`; + const existing = pending.get(key); + if (existing !== undefined) return existing; + const submission = performSubmission(deps, args); + pending.set(key, submission); + try { + return await submission; + } finally { + pending.delete(key); + } +} diff --git a/apps/server/src/services/drafts/draft-thread-record.ts b/apps/server/src/services/drafts/draft-thread-record.ts new file mode 100644 index 0000000000..17a94f7749 --- /dev/null +++ b/apps/server/src/services/drafts/draft-thread-record.ts @@ -0,0 +1,44 @@ +import { + createDraftSubmissionReceipt, + getDraft, + getDraftSubmissionReceipt, +} from "@bb/db"; +import type { Thread } from "@bb/domain"; +import { ApiError } from "../../errors.js"; +import type { AppDeps } from "../../types.js"; + +export interface DraftSubmissionSource { + draftId: string; + revision: number; +} + +export function createDraftThreadRecord( + deps: Pick, + source: DraftSubmissionSource, + create: () => Thread, +): Thread { + return deps.db.transaction( + (tx) => { + if (getDraftSubmissionReceipt(tx, source) !== null) { + throw new ApiError( + 409, + "draft_submission_exists", + "This draft revision already has a submission; retry to read its result", + true, + ); + } + const current = getDraft(tx, source.draftId); + if (current === null || current.revision !== source.revision) { + throw new ApiError( + 409, + "draft_revision_conflict", + "Draft changed before submission. Its current contents were preserved.", + ); + } + const thread = create(); + createDraftSubmissionReceipt(tx, { ...source, threadId: thread.id }); + return thread; + }, + { behavior: "immediate" }, + ); +} diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts index 92ea52611c..ac655f14e9 100644 --- a/apps/server/src/services/plugins/plugin-api.ts +++ b/apps/server/src/services/plugins/plugin-api.ts @@ -112,6 +112,7 @@ import type { } from "@get-bb/plugin-sdk/internal/host-policy"; import type { BbSdk, + DraftSubmitArgs, ThreadForkArgs, ThreadPluginMetadataArgs, ThreadPluginMetadataUpdateArgs, @@ -333,6 +334,19 @@ function withPluginThreadAttribution< function wrapSdkForPlugin(sdk: BbSdk, pluginId: string): PluginBbSdk { return { ...sdk, + drafts: { + ...sdk.drafts, + submit(args: DraftSubmitArgs) { + const origin = args.origin ?? "plugin"; + return sdk.drafts.submit({ + ...args, + origin, + ...(origin === "plugin" + ? { originPluginId: args.originPluginId ?? pluginId } + : {}), + }); + }, + }, threads: { ...sdk.threads, async getPluginMetadata( diff --git a/apps/server/src/services/threads/thread-create.ts b/apps/server/src/services/threads/thread-create.ts index 0914d5b04a..cb15d4e61a 100644 --- a/apps/server/src/services/threads/thread-create.ts +++ b/apps/server/src/services/threads/thread-create.ts @@ -5,6 +5,7 @@ import { getEnvironment, getProjectSourceByHost, getThread, + failDraftSubmissionReceipt, } from "@bb/db"; import type { ProjectExecutionDefaults, @@ -71,10 +72,15 @@ import { getEnvironmentProvider, listEnvironmentCompositions, } from "../plugins/plugin-environment-provider-registry.js"; +import { + createDraftThreadRecord, + type DraftSubmissionSource, +} from "../drafts/draft-thread-record.js"; type ThreadCreateDeps = LoggedPendingInteractionWorkSessionDeps; interface CreateProvisioningThreadArgs { + draftSubmission?: DraftSubmissionSource; environmentId: string | null; executionDefaults: Parameters< typeof buildExecutionOptions @@ -384,10 +390,15 @@ async function createPendingThreadAndAttemptFirstDispatch( sourceThreadId: args.request.sourceThreadId, }); } - const thread = createThreadRecord(deps, { - request: args.request, - environmentId: args.environmentId, - }); + const createRecord = () => + createThreadRecord(deps, { + request: args.request, + environmentId: args.environmentId, + }); + const thread = + args.draftSubmission === undefined + ? createRecord() + : createDraftThreadRecord(deps, args.draftSubmission, createRecord); let execution: Awaited>; try { if ( @@ -472,6 +483,11 @@ async function createPendingThreadAndAttemptFirstDispatch( ? getEnvironment(deps.db, deleted.environmentId) : null, ); + if (args.draftSubmission !== undefined) { + failDraftSubmissionReceipt(deps.db, args.draftSubmission); + deps.hub.notifySystem(["drafts-changed"]); + } + throw error; } rememberProjectExecutionDefaultsForCreate(deps, { @@ -521,6 +537,7 @@ export async function createThreadFromRequest( deps: ThreadCreateDeps, rawRequestInput: ThreadCreateServiceRequestInput, options: { + draftSubmission?: DraftSubmissionSource; providerInput?: ThreadCreateServiceRequestInput["input"]; forkSourceEnvironmentId?: string; } = {}, @@ -770,6 +787,9 @@ export async function createThreadFromRequest( } const createArgs = { + ...(options.draftSubmission !== undefined + ? { draftSubmission: options.draftSubmission } + : {}), environmentId, environmentIntent, executionDefaults: resolvedExecutionDefaults, diff --git a/apps/server/test/public/public-drafts.test.ts b/apps/server/test/public/public-drafts.test.ts new file mode 100644 index 0000000000..da598eb08c --- /dev/null +++ b/apps/server/test/public/public-drafts.test.ts @@ -0,0 +1,583 @@ +import { + createDraftSubmissionReceipt, + getDraftSubmissionReceipt, + getStoredDraft, + getThread, + listThreads, +} from "@bb/db"; +import { + draftCreateResponseSchema, + draftListResponseSchema, + draftSchema, + draftSubmitResponseSchema, + type Draft, + type DraftContentInput, +} from "@bb/server-contract"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + setPluginHookProvider, + type PluginHookRegistration, +} from "../../src/services/plugins/plugin-hook-registry.js"; +import { setPluginEnvironmentProviderBridge } from "../../src/services/plugins/plugin-environment-provider-registry.js"; +import { setPluginMachineProviderBridge } from "../../src/services/plugins/plugin-machine-provider-registry.js"; +import { + validatePluginEnvironmentProviderDeclaration, + validatePluginMachineProviderDeclaration, +} from "@get-bb/plugin-sdk/internal/host-policy"; +import type { PluginHookName } from "@get-bb/plugin-sdk"; +import { readJson } from "../helpers/json.js"; +import { + seedEnvironment, + seedHostSession, + seedProjectWithSource, + seedThread, +} from "../helpers/seed.js"; +import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; + +function request( + harness: TestAppHarness, + path: string, + method: string, + body: unknown, +) { + return harness.app.request(`/api/v1/drafts${path}`, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function create( + harness: TestAppHarness, + content: DraftContentInput = {}, + id?: string, +): Promise { + const response = await request(harness, "", "POST", { id, content }); + expect(response.status).toBe(201); + const { draft } = draftCreateResponseSchema.parse(await readJson(response)); + if (draft === null) throw new Error("Expected a new draft"); + return draft; +} + +function readyContent(harness: TestAppHarness): DraftContentInput { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { hostId: host.id }); + const environment = seedEnvironment(harness.deps, { + projectId: project.id, + hostId: host.id, + }); + return { + projectId: project.id, + prompt: { text: "Keep the accepted snapshot" }, + options: { + providerId: "codex", + model: "gpt-5", + environment: { type: "reuse", environmentId: environment.id }, + sendAt: Date.now() + 86_400_000, + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + setPluginHookProvider(undefined); + setPluginEnvironmentProviderBridge(undefined); + setPluginMachineProviderBridge(undefined); +}); + +describe("durable draft resources", () => { + it("keeps incomplete independent contents and filters saved drafts", async () => { + await withTestHarness(async (harness) => { + const first = await create(harness, { + projectId: "proj_unavailable", + sectionId: "section_unavailable", + prompt: { text: "Release plan" }, + options: { + providerId: "removed-provider", + environment: { + type: "provider", + environmentProviderId: "removed-environment", + machine: null, + inputs: { branch: "topic" }, + }, + }, + }); + const second = await create(harness, { prompt: { text: "Other work" } }); + const empty = await create(harness); + expect(new Set([first.id, second.id, empty.id]).size).toBe(3); + const response = await harness.app.request( + "/api/v1/drafts?query=release&projectId=proj_unavailable", + ); + expect( + draftListResponseSchema.parse(await readJson(response)).drafts, + ).toEqual([first]); + const all = draftListResponseSchema.parse( + await readJson(await harness.app.request("/api/v1/drafts")), + ); + expect(all.drafts.map((draft) => draft.id).sort()).toEqual( + [first.id, second.id].sort(), + ); + const withEmpty = draftListResponseSchema.parse( + await readJson( + await harness.app.request("/api/v1/drafts?includeEmpty=true"), + ), + ); + expect(withEmpty.drafts).toHaveLength(3); + const submit = await request(harness, `/${first.id}/submit`, "POST", { + expectedRevision: 1, + }); + expect(submit.status).toBe(404); + expect(await readJson(submit)).toMatchObject({ + code: "project_not_found", + }); + expect( + draftSchema.parse( + await readJson( + await harness.app.request(`/api/v1/drafts/${first.id}`), + ), + ), + ).toEqual(first); + }); + }); + + it("acknowledges singleton import retries without reverting edits or resurrecting consumed data", async () => { + await withTestHarness(async (harness) => { + const content = { prompt: { text: "Legacy singleton" } }; + const draft = await create(harness, content, "drf_legacy_singleton"); + const newerContent = { + ...draft.content, + prompt: { ...draft.content.prompt, text: "Newer server edit" }, + }; + const update = await request(harness, `/${draft.id}`, "PATCH", { + expectedRevision: 1, + content: newerContent, + }); + expect(update.status).toBe(200); + const replay = await create(harness, content, draft.id); + expect(replay.content.prompt.text).toBe("Newer server edit"); + expect(replay.revision).toBe(2); + const collision = await request(harness, "", "POST", { + id: draft.id, + content: newerContent, + }); + expect(collision.status).toBe(409); + expect( + ( + await request(harness, `/${draft.id}`, "DELETE", { + expectedRevision: 2, + }) + ).status, + ).toBe(200); + const acknowledged = await request(harness, "", "POST", { + id: draft.id, + content, + }); + expect( + draftCreateResponseSchema.parse(await readJson(acknowledged)), + ).toEqual({ id: draft.id, draft: null }); + expect(getStoredDraft(harness.db, draft.id)?.payloadJson).toBeNull(); + }); + }); + + it("rejects stale writes, deletion and submission without losing the winning edit", async () => { + await withTestHarness(async (harness) => { + const draft = await create(harness, { prompt: { text: "Original" } }); + const content = { + ...draft.content, + prompt: { ...draft.content.prompt, text: "Winner" }, + }; + expect( + ( + await request(harness, `/${draft.id}`, "PATCH", { + expectedRevision: 1, + content, + }) + ).status, + ).toBe(200); + expect( + ( + await request(harness, `/${draft.id}`, "PATCH", { + expectedRevision: 1, + content: draft.content, + }) + ).status, + ).toBe(409); + expect( + ( + await request(harness, `/${draft.id}`, "DELETE", { + expectedRevision: 1, + }) + ).status, + ).toBe(409); + expect( + ( + await request(harness, `/${draft.id}/submit`, "POST", { + expectedRevision: 1, + }) + ).status, + ).toBe(409); + const current = draftSchema.parse( + await readJson(await harness.app.request(`/api/v1/drafts/${draft.id}`)), + ); + expect(current.content.prompt.text).toBe("Winner"); + expect(current.revision).toBe(2); + }); + }); + + it("submits provider-owned machine selections through the existing creation contract", async () => { + await withTestHarness(async (harness) => { + const ready = readyContent(harness); + const target = { + pluginId: "draft-machine-fixture", + provider: validatePluginEnvironmentProviderDeclaration({ + id: "draft-workspace-fixture", + displayName: "Draft workspace fixture", + description: "Prepare a workspace for this draft.", + icon: "Folder", + requires: {}, + create: async () => ({ status: "failed", message: "Not dispatched" }), + remove: async () => ({ status: "removed" }), + }), + }; + const machine = { + pluginId: "draft-machine-fixture", + provider: validatePluginMachineProviderDeclaration({ + id: "fixture-machine", + displayName: "Draft machine fixture", + description: "Prepare a machine for this draft.", + icon: "Cloud", + create: async () => ({ status: "failed", message: "Not dispatched" }), + reconcileCleanup: async () => ({ status: "removed" }), + remove: async () => ({ status: "removed" }), + }), + }; + setPluginMachineProviderBridge({ + listMachineProviders: () => [machine], + getMachineProvider: (id) => + id === machine.provider.id ? machine : undefined, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + setPluginEnvironmentProviderBridge({ + listEnvironmentProviders: () => [target], + getEnvironmentProvider: (id) => + id === target.provider.id ? target : undefined, + listEnvironmentCompositions: () => [ + { + pluginId: "draft-machine-fixture", + composition: { + id: "draft-machine-fixture", + displayName: "Draft machine fixture", + description: "Prepare a workspace for this draft.", + icon: "Cloud", + machineProviderId: "fixture-machine", + environmentProviderId: target.provider.id, + }, + }, + ], + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + const draft = await create(harness, { + ...ready, + options: { + ...ready.options, + environment: { + type: "provider", + environmentProviderId: "draft-machine-fixture", + inputs: null, + }, + }, + }); + expect(draft.content.options.environment).toMatchObject({ + machine: null, + }); + const response = await request(harness, `/${draft.id}/submit`, "POST", { + expectedRevision: draft.revision, + }); + const body = await readJson(response); + expect(response.status, JSON.stringify(body)).toBe(200); + expect(draftSubmitResponseSchema.parse(body).draft).toBeNull(); + }); + }); + + it("submits concurrent and retried requests once without consuming a second draft", async () => { + await withTestHarness(async (harness) => { + const draft = await create(harness, readyContent(harness)); + const other = await create(harness, { + prompt: { text: "Do not consume" }, + }); + const submit = () => + request(harness, `/${draft.id}/submit`, "POST", { + expectedRevision: draft.revision, + }); + const responses = await Promise.all([submit(), submit()]); + expect(responses.map((response) => response.status)).toEqual([200, 200]); + const results = await Promise.all( + responses.map(async (response) => + draftSubmitResponseSchema.parse(await readJson(response)), + ), + ); + const replay = draftSubmitResponseSchema.parse( + await readJson(await submit()), + ); + expect(results[0]?.thread.id).toBe(results[1]?.thread.id); + expect(replay.thread.id).toBe(results[0]?.thread.id); + expect(replay.draft).toBeNull(); + expect( + listThreads(harness.db, { projectId: draft.content.projectId! }), + ).toHaveLength(1); + expect( + (await harness.app.request(`/api/v1/drafts/${draft.id}`)).status, + ).toBe(410); + expect( + draftSchema.parse( + await readJson( + await harness.app.request(`/api/v1/drafts/${other.id}`), + ), + ), + ).toEqual(other); + }); + }); + + it("rechecks the revision after asynchronous creation preflight", async () => { + await withTestHarness(async (harness) => { + const draft = await create(harness, readyContent(harness)); + let release = () => {}; + let entered = () => {}; + const started = new Promise((resolve) => { + entered = resolve; + }); + const blocked = new Promise((resolve) => { + release = resolve; + }); + vi.spyOn( + harness.deps.providerRegistry, + "whenRegistrationsSettled", + ).mockImplementationOnce(async () => { + entered(); + await blocked; + }); + const submitting = request(harness, `/${draft.id}/submit`, "POST", { + expectedRevision: 1, + }); + await started; + try { + const content = { + ...draft.content, + prompt: { ...draft.content.prompt, text: "Edited during submit" }, + }; + expect( + ( + await request(harness, `/${draft.id}`, "PATCH", { + expectedRevision: 1, + content, + }) + ).status, + ).toBe(200); + } finally { + release(); + } + expect((await submitting).status).toBe(409); + expect( + listThreads(harness.db, { projectId: draft.content.projectId! }), + ).toHaveLength(0); + }); + }); + + it("preserves newer contents saved after the thread is reserved", async () => { + await withTestHarness(async (harness) => { + const content = readyContent(harness); + const draft = await create(harness, { + ...content, + options: { ...content.options, sendAt: null }, + }); + let release = () => {}; + let entered = () => {}; + const started = new Promise((resolve) => { + entered = resolve; + }); + const blocked = new Promise((resolve) => { + release = resolve; + }); + const registry: { [K in PluginHookName]: PluginHookRegistration[] } = { + "message.dispatch": [ + { + pluginId: "draft-test", + handler: async () => { + entered(); + await blocked; + return { action: "proceed" }; + }, + }, + ], + }; + setPluginHookProvider({ + listHooks: (hook) => registry[hook], + invokeHook: async (_id, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + const submitting = request(harness, `/${draft.id}/submit`, "POST", { + expectedRevision: 1, + }); + await started; + const newer = { + ...draft.content, + prompt: { ...draft.content.prompt, text: "Keep this newer edit" }, + }; + try { + expect( + ( + await request(harness, `/${draft.id}`, "PATCH", { + expectedRevision: 1, + content: newer, + }) + ).status, + ).toBe(200); + } finally { + release(); + } + const response = await submitting; + expect(response.status).toBe(200); + const result = draftSubmitResponseSchema.parse(await readJson(response)); + expect(result.draft?.revision).toBe(2); + expect(result.draft?.content).toEqual(newer); + expect( + getDraftSubmissionReceipt(harness.db, { + draftId: draft.id, + revision: 1, + })?.status, + ).toBe("submitted"); + }); + }); + + it("recovers interrupted reservations without duplicating threads or dropping the draft", async () => { + await withTestHarness(async (harness) => { + const draft = await create(harness, readyContent(harness)); + const thread = seedThread(harness.deps, { + projectId: draft.content.projectId!, + status: "pending", + }); + createDraftSubmissionReceipt(harness.db, { + draftId: draft.id, + revision: 1, + threadId: thread.id, + }); + const response = await request(harness, `/${draft.id}/submit`, "POST", { + expectedRevision: 1, + }); + expect(response.status).toBe(409); + expect(getThread(harness.db, thread.id)?.deletedAt).toEqual( + expect.any(Number), + ); + expect(getStoredDraft(harness.db, draft.id)?.payloadJson).not.toBeNull(); + expect( + ( + await request(harness, `/${draft.id}/submit`, "POST", { + expectedRevision: 1, + }) + ).status, + ).toBe(409); + expect( + listThreads(harness.db, { projectId: draft.content.projectId! }), + ).toHaveLength(0); + }); + }); + + it("preserves rejected submissions and requires a new revision for an intentional retry", async () => { + await withTestHarness(async (harness) => { + const content = readyContent(harness); + const draft = await create(harness, { + ...content, + options: { ...content.options, sendAt: null }, + }); + const registry: { [K in PluginHookName]: PluginHookRegistration[] } = { + "message.dispatch": [ + { + pluginId: "draft-test", + handler: () => ({ + action: "reject", + message: "Temporarily blocked", + }), + }, + ], + }; + setPluginHookProvider({ + listHooks: (hook) => registry[hook], + invokeHook: async (_id, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + const submit = () => + request(harness, `/${draft.id}/submit`, "POST", { + expectedRevision: 1, + }); + expect((await submit()).status).toBe(409); + expect( + getDraftSubmissionReceipt(harness.db, { + draftId: draft.id, + revision: 1, + })?.status, + ).toBe("failed"); + setPluginHookProvider(undefined); + setPluginEnvironmentProviderBridge(undefined); + expect((await submit()).status).toBe(409); + expect( + listThreads(harness.db, { projectId: draft.content.projectId! }), + ).toHaveLength(0); + expect( + ( + await request(harness, `/${draft.id}`, "PATCH", { + expectedRevision: 1, + content: draft.content, + }) + ).status, + ).toBe(200); + expect( + ( + await request(harness, `/${draft.id}/submit`, "POST", { + expectedRevision: 2, + }) + ).status, + ).toBe(200); + expect( + listThreads(harness.db, { projectId: draft.content.projectId! }), + ).toHaveLength(1); + }); + }); + + it("enforces the existing browser-origin guard for draft access", async () => { + await withTestHarness(async (harness) => { + const draft = await create(harness, { + prompt: { text: "Private draft" }, + }); + for (const path of ["", `/${draft.id}`]) { + const response = await harness.app.request(`/api/v1/drafts${path}`, { + headers: { origin: "https://untrusted.example" }, + }); + expect(response.status).toBe(403); + } + const response = await harness.app.request(`/api/v1/drafts/${draft.id}`, { + method: "DELETE", + headers: { + origin: "https://untrusted.example", + "content-type": "application/json", + }, + body: JSON.stringify({ expectedRevision: 1 }), + }); + expect(response.status).toBe(403); + expect(getStoredDraft(harness.db, draft.id)?.deletedAt).toBeNull(); + }); + }); +}); diff --git a/apps/server/test/services/plugins/plugin-sdk.test.ts b/apps/server/test/services/plugins/plugin-sdk.test.ts index 4e8fa42b63..fdd0e57e9a 100644 --- a/apps/server/test/services/plugins/plugin-sdk.test.ts +++ b/apps/server/test/services/plugins/plugin-sdk.test.ts @@ -561,6 +561,64 @@ describe("plugin bb.sdk bind gate", () => { }); describe("plugin bb.sdk against a running server", () => { + it("attributes submitted drafts to the plugin while preserving explicit origins", async () => { + const server = await startTestServer(); + const workDir = await mkdtemp(join(tmpdir(), "bb-plugin-drafts-live-")); + try { + const { host } = seedHostSession(server.deps); + const { project } = seedProjectWithSource(server.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(server.deps, { + hostId: host.id, + projectId: project.id, + }); + server.pluginService.bindSdk({ baseUrl: server.baseUrl }); + const rootDir = await writePlugin(workDir, { + name: "bb-plugin-drafter", + serverSource: `export default function plugin() {}`, + }); + await server.pluginService.installPath(rootDir); + const api = requireApi(server.pluginService, "drafter"); + for (const override of [ + {}, + { origin: "plugin" as const, originPluginId: "explicit-plugin" }, + { origin: "sdk" as const }, + ]) { + const { draft } = await api.sdk.drafts.create({ + content: { + projectId: project.id, + prompt: { text: "Plugin draft" }, + options: { + providerId: "codex", + model: "gpt-5", + environment: { type: "reuse", environmentId: environment.id }, + sendAt: Date.now() + 86_400_000, + }, + }, + }); + if (!draft) throw new Error("Expected a new draft"); + const { thread } = await api.sdk.drafts.submit({ + draftId: draft.id, + expectedRevision: draft.revision, + ...override, + }); + const originPluginId = + override.origin === "sdk" + ? null + : (override.originPluginId ?? "drafter"); + expect(thread.originPluginId).toBe(originPluginId); + expect(getThread(server.db, thread.id)?.originPluginId).toBe( + originPluginId, + ); + } + } finally { + await server.pluginService.stop(); + await server.close(); + await rm(workDir, { recursive: true, force: true }); + } + }); + it("returns the server-side Standard Schema output after the host JSON wire", async () => { const server = await startTestServer(); const workDir = await mkdtemp(join(tmpdir(), "bb-plugin-host-transform-")); diff --git a/packages/bb-app/src/public-sdk.ts b/packages/bb-app/src/public-sdk.ts index ab4db94e77..ec9dfe24c0 100644 --- a/packages/bb-app/src/public-sdk.ts +++ b/packages/bb-app/src/public-sdk.ts @@ -60,6 +60,7 @@ export type ThreadWaitUnreachableErrorConstructor = typeof ThreadWaitUnreachableError; export class BBSdk implements BbSdk { + readonly drafts: BbSdk["drafts"]; readonly environments: BbSdk["environments"]; readonly experimental_desktopBrowsers: BbSdk["experimental_desktopBrowsers"]; readonly experimental_server: BbSdk["experimental_server"]; @@ -94,6 +95,7 @@ export class BBSdk implements BbSdk { this.system = sdk.system; this.terminals = sdk.terminals; this.theme = sdk.theme; + this.drafts = sdk.drafts; this.threadSections = sdk.threadSections; this.threads = sdk.threads; this.subscribe = sdk.subscribe; diff --git a/packages/db/drizzle/0127_draft_resources.sql b/packages/db/drizzle/0127_draft_resources.sql new file mode 100644 index 0000000000..430becc3cf --- /dev/null +++ b/packages/db/drizzle/0127_draft_resources.sql @@ -0,0 +1,26 @@ +CREATE TABLE `draft_submission_receipts` ( + `draft_id` text NOT NULL, + `revision` integer NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + PRIMARY KEY(`draft_id`, `revision`) +); +--> statement-breakpoint +CREATE TABLE `drafts` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text, + `payload_json` text, + `creation_fingerprint` text NOT NULL, + `search_text` text NOT NULL, + `has_input` integer NOT NULL, + `revision` integer DEFAULT 1 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `deleted_at` integer, + `submitted_at` integer +); +--> statement-breakpoint +CREATE INDEX `drafts_live_updated_idx` ON `drafts` (`updated_at`,`id`) WHERE "drafts"."deleted_at" IS NULL AND "drafts"."submitted_at" IS NULL;--> statement-breakpoint +CREATE INDEX `drafts_live_project_updated_idx` ON `drafts` (`project_id`,`updated_at`,`id`) WHERE "drafts"."deleted_at" IS NULL AND "drafts"."submitted_at" IS NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0127_snapshot.json b/packages/db/drizzle/meta/0127_snapshot.json new file mode 100644 index 0000000000..5879687bb0 --- /dev/null +++ b/packages/db/drizzle/meta/0127_snapshot.json @@ -0,0 +1,5169 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9f786378-fdff-498a-ac48-bd304744f1d7", + "prevId": "48979c46-bc2b-411d-a6cc-18dfcb7cd457", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "caffeinate": { + "name": "caffeinate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_keyboard_hints": { + "name": "show_keyboard_hints", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "steer_active_thread_on_enter": { + "name": "steer_active_thread_on_enter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_unhandled_provider_events": { + "name": "show_unhandled_provider_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "codex_memory_enabled": { + "name": "codex_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "claude_code_memory_enabled": { + "name": "claude_code_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "codex_subagents_disabled": { + "name": "codex_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_subagents_disabled": { + "name": "claude_code_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_workflows_disabled": { + "name": "claude_code_workflows_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "keybinding_overrides": { + "name": "keybinding_overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_settings_values": { + "name": "app_settings_values", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_theme": { + "name": "app_theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "favicon_color": { + "name": "favicon_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_unique": { + "name": "apikey_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "referenceId" + ], + "isUnique": false + }, + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "configId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "apikey_referenceId_user_id_fk": { + "name": "apikey_referenceId_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "referenceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "draft_submission_receipts": { + "name": "draft_submission_receipts", + "columns": { + "draft_id": { + "name": "draft_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "draft_submission_receipts_draft_id_revision_pk": { + "columns": [ + "draft_id", + "revision" + ], + "name": "draft_submission_receipts_draft_id_revision_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "drafts": { + "name": "drafts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creation_fingerprint": { + "name": "creation_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_input": { + "name": "has_input", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "drafts_live_updated_idx": { + "name": "drafts_live_updated_idx", + "columns": [ + "updated_at", + "id" + ], + "isUnique": false, + "where": "\"drafts\".\"deleted_at\" IS NULL AND \"drafts\".\"submitted_at\" IS NULL" + }, + "drafts_live_project_updated_idx": { + "name": "drafts_live_project_updated_idx", + "columns": [ + "project_id", + "updated_at", + "id" + ], + "isUnique": false, + "where": "\"drafts\".\"deleted_at\" IS NULL AND \"drafts\".\"submitted_at\" IS NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_hook_operations": { + "name": "environment_hook_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_variables": { + "name": "environment_variables", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environment_variables_global_name": { + "name": "environment_variables_global_name", + "columns": [ + "name" + ], + "isUnique": true, + "where": "\"environment_variables\".\"project_id\" IS NULL" + }, + "environment_variables_project_name": { + "name": "environment_variables_project_name", + "columns": [ + "project_id", + "name" + ], + "isUnique": true, + "where": "\"environment_variables\".\"project_id\" IS NOT NULL" + } + }, + "foreignKeys": { + "environment_variables_project_id_projects_id_fk": { + "name": "environment_variables_project_id_projects_id_fk", + "tableFrom": "environment_variables", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_git_repo": { + "name": "is_git_repo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_worktree": { + "name": "is_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_base_branch": { + "name": "merge_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_id": { + "name": "environment_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_plugin_id": { + "name": "environment_provider_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_owns_path": { + "name": "provider_owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "environment_provider_selection": { + "name": "environment_provider_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_instance_key": { + "name": "environment_provider_instance_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retire_at": { + "name": "retire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_message": { + "name": "teardown_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_thread_id": { + "name": "owner_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "claim_path": { + "name": "claim_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provisioning'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environments_project_host_path_idx": { + "name": "environments_project_host_path_idx", + "columns": [ + "project_id", + "host_id", + "path" + ], + "isUnique": true + }, + "environments_host_path_lookup_idx": { + "name": "environments_host_path_lookup_idx", + "columns": [ + "host_id", + "path" + ], + "isUnique": false + }, + "environments_owner_thread_idx": { + "name": "environments_owner_thread_idx", + "columns": [ + "owner_thread_id" + ], + "isUnique": true + }, + "environments_claim_idx": { + "name": "environments_claim_idx", + "columns": [ + "host_id", + "claim_path" + ], + "isUnique": false + }, + "environments_project_idx": { + "name": "environments_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "environments_provider_instance_idx": { + "name": "environments_provider_instance_idx", + "columns": [ + "environment_provider_id", + "environment_provider_instance_key" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environments_project_id_projects_id_fk": { + "name": "environments_project_id_projects_id_fk", + "tableFrom": "environments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "events_thread_sequence_idx": { + "name": "events_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": true + }, + "events_delegating_item_lookup_idx": { + "name": "events_delegating_item_lookup_idx", + "columns": [ + "thread_id", + "item_id", + "sequence", + "item_kind" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" IN ('toolCall', 'delegation')" + }, + "events_plan_steps_thread_sequence_idx": { + "name": "events_plan_steps_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "(\"events\".\"item_kind\" = 'planSteps' AND \"events\".\"type\" = 'item/completed') OR \"events\".\"type\" = 'turn/plan/updated'" + }, + "events_parent_tool_call_thread_parent_sequence_idx": { + "name": "events_parent_tool_call_thread_parent_sequence_idx", + "columns": [ + "thread_id", + "parent_tool_call_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL" + }, + "events_thread_type_item_kind_sequence_idx": { + "name": "events_thread_type_item_kind_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_kind", + "sequence" + ], + "isUnique": false + }, + "events_background_task_thread_type_item_sequence_idx": { + "name": "events_background_task_thread_type_item_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'backgroundTask'" + }, + "events_thread_type_sequence_idx": { + "name": "events_thread_type_sequence_idx", + "columns": [ + "thread_id", + "type", + "sequence" + ], + "isUnique": false + }, + "events_thread_turn_type_item_sequence_idx": { + "name": "events_thread_turn_type_item_sequence_idx", + "columns": [ + "thread_id", + "turn_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false + }, + "events_item_lifecycle_thread_item_sequence_idx": { + "name": "events_item_lifecycle_thread_item_sequence_idx", + "columns": [ + "thread_id", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')" + }, + "events_environment_idx": { + "name": "events_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "events_provider_identity_idx": { + "name": "events_provider_identity_idx", + "columns": [ + "provider_thread_id", + "created_at" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'thread/identity'" + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'item/completed'" + }, + "events_thread_state_thread_sequence_idx": { + "name": "events_thread_state_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared', 'thread/extensionState/updated')" + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "events_scope_shape_check": { + "name": "events_scope_shape_check", + "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" + } + } + }, + "host_daemon_sessions": { + "name": "host_daemon_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_dir": { + "name": "data_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heartbeat_interval_ms": { + "name": "heartbeat_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_timeout_ms": { + "name": "lease_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_daemon_sessions_host_status_idx": { + "name": "host_daemon_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "host_daemon_sessions_host_latest_idx": { + "name": "host_daemon_sessions_host_latest_idx", + "columns": [ + "host_id", + "updated_at", + "created_at", + "id" + ], + "isUnique": false + }, + "host_daemon_sessions_closed_prune_idx": { + "name": "host_daemon_sessions_closed_prune_idx", + "columns": [ + "status", + "closed_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_daemon_sessions_host_id_hosts_id_fk": { + "name": "host_daemon_sessions_host_id_hosts_id_fk", + "tableFrom": "host_daemon_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hosts": { + "name": "hosts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connect_machine_id": { + "name": "connect_machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_provider_id": { + "name": "machine_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "launch_key": { + "name": "launch_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_inputs": { + "name": "machine_inputs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_attempt": { + "name": "machine_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "machine_operation_id": { + "name": "machine_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_provider_id": { + "name": "server_access_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_grant_id": { + "name": "server_access_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspend_retry_at": { + "name": "suspend_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remove_retry_at": { + "name": "remove_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_permission_mode": { + "name": "max_permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_rejected_protocol_version": { + "name": "last_rejected_protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hosts_last_seen_idx": { + "name": "hosts_last_seen_idx", + "columns": [ + "last_seen_at" + ], + "isUnique": false + }, + "hosts_live_launch_key_idx": { + "name": "hosts_live_launch_key_idx", + "columns": [ + "launch_key" + ], + "isUnique": true, + "where": "\"hosts\".\"destroyed_at\" is null" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "catalog_marketplace_name": { + "name": "catalog_marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'path'" + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_builtin_name": { + "name": "source_builtin_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_package": { + "name": "source_npm_package", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_registry": { + "name": "source_npm_registry", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_requested_spec": { + "name": "source_npm_requested_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_spec_kind": { + "name": "source_npm_spec_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_url": { + "name": "source_git_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_subdirectory": { + "name": "source_git_subdirectory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_requested_ref": { + "name": "source_git_requested_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_ref_kind": { + "name": "source_git_ref_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_range": { + "name": "source_git_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_tag_prefix": { + "name": "source_git_tag_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_resolved_tag": { + "name": "source_git_resolved_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_integrity": { + "name": "npm_integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_update_check_at": { + "name": "last_update_check_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "available_compatible_version": { + "name": "available_compatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "newest_incompatible_version": { + "name": "newest_incompatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "update_status_detail": { + "name": "update_status_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_version": { + "name": "last_failure_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_detail": { + "name": "last_failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_artifact_id": { + "name": "active_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalization_version": { + "name": "normalization_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "root_dir": { + "name": "root_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_active_artifact_id_plugin_artifacts_id_fk": { + "name": "plugins_active_artifact_id_plugin_artifacts_id_fk", + "tableFrom": "plugins", + "tableTo": "plugin_artifacts", + "columnsFrom": [ + "active_artifact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_scan_cursors": { + "name": "maintenance_scan_cursors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_created_at": { + "name": "last_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_scan_cursors_path_idx": { + "name": "maintenance_scan_cursors_path_idx", + "columns": [ + "policy", + "version", + "item_kind", + "output_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_interactions": { + "name": "pending_interactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provider'" + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "renderer_id": { + "name": "renderer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_interactions_provider_request_idx": { + "name": "pending_interactions_provider_request_idx", + "columns": [ + "provider_id", + "provider_thread_id", + "provider_request_id" + ], + "isUnique": true + }, + "pending_interactions_thread_created_idx": { + "name": "pending_interactions_thread_created_idx", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_thread_status_created_idx": { + "name": "pending_interactions_thread_status_created_idx", + "columns": [ + "thread_id", + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_status_created_idx": { + "name": "pending_interactions_status_created_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_plugin_status_created_idx": { + "name": "pending_interactions_plugin_status_created_idx", + "columns": [ + "plugin_id", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pending_interactions_thread_id_threads_id_fk": { + "name": "pending_interactions_thread_id_threads_id_fk", + "tableFrom": "pending_interactions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_artifacts": { + "name": "plugin_artifacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_checkout_root": { + "name": "git_checkout_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "validation_result": { + "name": "validation_result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validated_at": { + "name": "validated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "plugin_artifacts_plugin_idx": { + "name": "plugin_artifacts_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_kv": { + "name": "plugin_kv", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_kv_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_kv_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplace_icons": { + "name": "plugin_marketplace_icons", + "columns": { + "marketplace_name": { + "name": "marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_marketplace_icons_marketplace_name_entry_id_pk": { + "columns": [ + "marketplace_name", + "entry_id" + ], + "name": "plugin_marketplace_icons_marketplace_name_entry_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplaces": { + "name": "plugin_marketplaces", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'https'" + }, + "manifest_url": { + "name": "manifest_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_git_ref": { + "name": "source_git_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_commit": { + "name": "source_git_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stats_json": { + "name": "stats_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_successful_refresh_at": { + "name": "last_successful_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_attempted_refresh_at": { + "name": "last_attempted_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_schedules": { + "name": "plugin_schedules", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_schedules_plugin_id_name_pk": { + "columns": [ + "plugin_id", + "name" + ], + "name": "plugin_schedules_plugin_id_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_settings_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_settings_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_state_snapshots": { + "name": "plugin_state_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_artifact_id": { + "name": "from_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_artifact_id": { + "name": "to_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_path": { + "name": "snapshot_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "database_path": { + "name": "database_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_path": { + "name": "state_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secrets_path": { + "name": "secrets_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registration_path": { + "name": "registration_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollback_candidate_version": { + "name": "rollback_candidate_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_source_fingerprint": { + "name": "rollback_source_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_bb_version": { + "name": "rollback_bb_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_sdk_version": { + "name": "rollback_sdk_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_detail": { + "name": "rollback_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_until": { + "name": "retained_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_state_snapshots_plugin_idx": { + "name": "plugin_state_snapshots_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "plugin_state_snapshots_retention_idx": { + "name": "plugin_state_snapshots_retention_idx", + "columns": [ + "retained_until" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachment_backfills": { + "name": "project_attachment_backfills", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_cursor": { + "name": "thread_cursor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_cursor": { + "name": "input_cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_id": { + "name": "input_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_sequence": { + "name": "input_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_attachment_backfills_project_id_projects_id_fk": { + "name": "project_attachment_backfills_project_id_projects_id_fk", + "tableFrom": "project_attachment_backfills", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachment_threads": { + "name": "project_attachment_threads", + "columns": { + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_attachment_threads_thread_idx": { + "name": "project_attachment_threads_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_attachment_threads_attachment_id_project_attachments_id_fk": { + "name": "project_attachment_threads_attachment_id_project_attachments_id_fk", + "tableFrom": "project_attachment_threads", + "tableTo": "project_attachments", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_attachment_threads_thread_id_threads_id_fk": { + "name": "project_attachment_threads_thread_id_threads_id_fk", + "tableFrom": "project_attachment_threads", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_attachment_threads_attachment_id_thread_id_pk": { + "columns": [ + "attachment_id", + "thread_id" + ], + "name": "project_attachment_threads_attachment_id_thread_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachments": { + "name": "project_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stored_path": { + "name": "stored_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ready_at": { + "name": "ready_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletion_claimed_at": { + "name": "deletion_claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_attachments_project_path_idx": { + "name": "project_attachments_project_path_idx", + "columns": [ + "project_id", + "stored_path" + ], + "isUnique": true + }, + "project_attachments_project_created_idx": { + "name": "project_attachments_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + }, + "project_attachments_deletion_idx": { + "name": "project_attachments_deletion_idx", + "columns": [ + "project_id", + "deletion_claimed_at", + "id" + ], + "isUnique": false, + "where": "\"project_attachments\".\"deletion_claimed_at\" IS NOT NULL" + } + }, + "foreignKeys": { + "project_attachments_project_id_projects_id_fk": { + "name": "project_attachments_project_id_projects_id_fk", + "tableFrom": "project_attachments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_attachments_size_check": { + "name": "project_attachments_size_check", + "value": "\"project_attachments\".\"size_bytes\" >= 0" + } + } + }, + "project_execution_defaults": { + "name": "project_execution_defaults", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_execution_defaults_project_idx": { + "name": "project_execution_defaults_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_execution_defaults_project_id_projects_id_fk": { + "name": "project_execution_defaults_project_id_projects_id_fk", + "tableFrom": "project_execution_defaults", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_sources": { + "name": "project_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owns_path": { + "name": "owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_sources_project_idx": { + "name": "project_sources_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_sources_host_idx": { + "name": "project_sources_host_idx", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "project_sources_project_host_idx": { + "name": "project_sources_project_host_idx", + "columns": [ + "project_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_sources_project_id_projects_id_fk": { + "name": "project_sources_project_id_projects_id_fk", + "tableFrom": "project_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_sources_shape_check": { + "name": "project_sources_shape_check", + "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'V'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_updated_idx": { + "name": "projects_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "projects_deleted_idx": { + "name": "projects_deleted_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "projects_sort_idx": { + "name": "projects_sort_idx", + "columns": [ + "sort_key", + "id" + ], + "isUnique": false + }, + "projects_personal_singleton_idx": { + "name": "projects_personal_singleton_idx", + "columns": [ + "kind" + ], + "isUnique": true, + "where": "\"projects\".\"kind\" = 'personal'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_history_entries": { + "name": "prompt_history_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "prompt_history_entries_thread_request_idx": { + "name": "prompt_history_entries_thread_request_idx", + "columns": [ + "thread_id", + "request_sequence" + ], + "isUnique": true + }, + "prompt_history_entries_project_scope_created_idx": { + "name": "prompt_history_entries_project_scope_created_idx", + "columns": [ + "project_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + }, + "prompt_history_entries_thread_scope_created_idx": { + "name": "prompt_history_entries_thread_scope_created_idx", + "columns": [ + "thread_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "prompt_history_entries_project_id_projects_id_fk": { + "name": "prompt_history_entries_project_id_projects_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "provider_model_catalogs": { + "name": "provider_model_catalogs", + "columns": { + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "models_json": { + "name": "models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_only_models_json": { + "name": "selected_only_models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "provider_model_catalogs_host_id_hosts_id_fk": { + "name": "provider_model_catalogs_host_id_hosts_id_fk", + "tableFrom": "provider_model_catalogs", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "provider_model_catalogs_host_id_provider_id_scope_key_pk": { + "columns": [ + "host_id", + "provider_id", + "scope_key" + ], + "name": "provider_model_catalogs_host_id_provider_id_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "system_notice": { + "name": "system_notice", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_thread_id": { + "name": "sender_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_plugin_id": { + "name": "origin_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_by_initiator": { + "name": "requested_by_initiator", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_by_thread_id": { + "name": "requested_by_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_with_next": { + "name": "group_with_next", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "send_at": { + "name": "send_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "waiting_on": { + "name": "waiting_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wait_holder": { + "name": "wait_holder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inline'" + }, + "retry_of_turn_request_id": { + "name": "retry_of_turn_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_attempt": { + "name": "retry_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_reason": { + "name": "retry_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queued_thread_messages_thread_created_idx": { + "name": "queued_thread_messages_thread_created_idx", + "columns": [ + "thread_id", + "created_at", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_thread_sort_idx": { + "name": "queued_thread_messages_thread_sort_idx", + "columns": [ + "thread_id", + "sort_key", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_due_idx": { + "name": "queued_thread_messages_due_idx", + "columns": [ + "send_at", + "id" + ], + "isUnique": false, + "where": "\"queued_thread_messages\".\"send_at\" IS NOT NULL AND \"queued_thread_messages\".\"claimed_at\" IS NULL AND \"queued_thread_messages\".\"claim_token\" IS NULL" + }, + "queued_thread_messages_wait_holder_idx": { + "name": "queued_thread_messages_wait_holder_idx", + "columns": [ + "wait_holder", + "id" + ], + "isUnique": false, + "where": "\"queued_thread_messages\".\"wait_holder\" IS NOT NULL" + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "retained_event_outputs": { + "name": "retained_event_outputs", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "retained_event_outputs_expiry_idx": { + "name": "retained_event_outputs_expiry_idx", + "columns": [ + "expires_at", + "event_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "retained_event_outputs_event_id_events_id_fk": { + "name": "retained_event_outputs_event_id_events_id_fk", + "tableFrom": "retained_event_outputs", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_experiments": { + "name": "system_experiments", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daemon_session_id": { + "name": "daemon_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_cwd": { + "name": "initial_cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cols": { + "name": "cols", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rows": { + "name": "rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_input_at": { + "name": "last_user_input_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_thread_status_updated_idx": { + "name": "terminal_sessions_thread_status_updated_idx", + "columns": [ + "thread_id", + "status", + "updated_at" + ], + "isUnique": false + }, + "terminal_sessions_environment_status_idx": { + "name": "terminal_sessions_environment_status_idx", + "columns": [ + "environment_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_host_status_idx": { + "name": "terminal_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_daemon_session_idx": { + "name": "terminal_sessions_daemon_session_idx", + "columns": [ + "daemon_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_thread_id_threads_id_fk": { + "name": "terminal_sessions_thread_id_threads_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "host_daemon_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_conversation_outlines": { + "name": "thread_conversation_outlines", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "projection_key": { + "name": "projection_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items_json": { + "name": "items_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_conversation_outlines_thread_id_threads_id_fk": { + "name": "thread_conversation_outlines_thread_id_threads_id_fk", + "tableFrom": "thread_conversation_outlines", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_dynamic_context_file_states": { + "name": "thread_dynamic_context_file_states", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_status": { + "name": "content_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_dynamic_context_file_states_thread_file_idx": { + "name": "thread_dynamic_context_file_states_thread_file_idx", + "columns": [ + "thread_id", + "file_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "thread_dynamic_context_file_states_thread_id_threads_id_fk": { + "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", + "tableFrom": "thread_dynamic_context_file_states", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_plugin_metadata": { + "name": "thread_plugin_metadata", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_plugin_metadata_thread_id_threads_id_fk": { + "name": "thread_plugin_metadata_thread_id_threads_id_fk", + "tableFrom": "thread_plugin_metadata", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "thread_plugin_metadata_thread_id_plugin_id_pk": { + "columns": [ + "thread_id", + "plugin_id" + ], + "name": "thread_plugin_metadata_thread_id_plugin_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_pruning_cursors": { + "name": "thread_pruning_cursors", + "columns": { + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_thread_id": { + "name": "last_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "current_thread_id": { + "name": "current_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upper_sequence": { + "name": "upper_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cycle": { + "name": "cycle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latest_root_sequence": { + "name": "latest_root_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latest_context_sequence": { + "name": "latest_context_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_event_id": { + "name": "probe_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_phase": { + "name": "probe_phase", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_sequence": { + "name": "probe_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_witness_id": { + "name": "probe_witness_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_pruning_cursors_thread_idx": { + "name": "thread_pruning_cursors_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_pruning_cursors_thread_id_threads_id_fk": { + "name": "thread_pruning_cursors_thread_id_threads_id_fk", + "tableFrom": "thread_pruning_cursors", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "thread_pruning_cursors_policy_scope_pk": { + "columns": [ + "policy", + "scope" + ], + "name": "thread_pruning_cursors_policy_scope_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "thread_pruning_cursors_scope_check": { + "name": "thread_pruning_cursors_scope_check", + "value": "\"thread_pruning_cursors\".\"scope\" = coalesce(\"thread_pruning_cursors\".\"thread_id\", '')" + } + } + }, + "thread_search_segments": { + "name": "thread_search_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_seq": { + "name": "source_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_search_segments_source_idx": { + "name": "thread_search_segments_source_idx", + "columns": [ + "thread_id", + "source_kind", + "source_key" + ], + "isUnique": true + }, + "thread_search_segments_thread_source_seq_idx": { + "name": "thread_search_segments_thread_source_seq_idx", + "columns": [ + "thread_id", + "source_seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_search_segments_thread_id_threads_id_fk": { + "name": "thread_search_segments_thread_id_threads_id_fk", + "tableFrom": "thread_search_segments", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_sections": { + "name": "thread_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_sections_name_idx": { + "name": "thread_sections_name_idx", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_tabs": { + "name": "thread_tabs", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tabs_json": { + "name": "tabs_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_tabs_thread_id_threads_id_fk": { + "name": "thread_tabs_thread_id_threads_id_fk", + "tableFrom": "thread_tabs", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_override": { + "name": "model_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_level_override": { + "name": "reasoning_level_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title_fallback": { + "name": "title_fallback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'starting'" + }, + "startup_context": { + "name": "startup_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lifecycle_owner_thread_id": { + "name": "lifecycle_owner_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_plugin_id": { + "name": "origin_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'visible'" + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_sort_key": { + "name": "pin_sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_deleted_at": { + "name": "storage_deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_attention_at": { + "name": "latest_attention_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "threads_project_id_idx": { + "name": "threads_project_id_idx", + "columns": [ + "project_id", + "id" + ], + "isUnique": false + }, + "threads_project_updated_idx": { + "name": "threads_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "threads_project_archived_deleted_idx": { + "name": "threads_project_archived_deleted_idx", + "columns": [ + "project_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_pin_sort_idx": { + "name": "threads_pin_sort_idx", + "columns": [ + "archived_at", + "deleted_at", + "pin_sort_key", + "id" + ], + "isUnique": false, + "where": "\"threads\".\"pinned_at\" IS NOT NULL" + }, + "threads_environment_idx": { + "name": "threads_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "threads_lifecycle_owner_idx": { + "name": "threads_lifecycle_owner_idx", + "columns": [ + "lifecycle_owner_thread_id" + ], + "isUnique": false + }, + "threads_parent_idx": { + "name": "threads_parent_idx", + "columns": [ + "parent_thread_id" + ], + "isUnique": false + }, + "threads_source_origin_idx": { + "name": "threads_source_origin_idx", + "columns": [ + "source_thread_id", + "origin_kind" + ], + "isUnique": false + }, + "threads_origin_plugin_archived_idx": { + "name": "threads_origin_plugin_archived_idx", + "columns": [ + "origin_plugin_id", + "archived_at" + ], + "isUnique": false + }, + "threads_section_archived_deleted_idx": { + "name": "threads_section_archived_deleted_idx", + "columns": [ + "section_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_archived_status_idx": { + "name": "threads_archived_status_idx", + "columns": [ + "archived_at", + "status" + ], + "isUnique": false + }, + "threads_environment_archived_deleted_idx": { + "name": "threads_environment_archived_deleted_idx", + "columns": [ + "environment_id", + "archived_at", + "deleted_at" + ], + "isUnique": false + }, + "threads_active_maintenance_idx": { + "name": "threads_active_maintenance_idx", + "columns": [ + "status" + ], + "isUnique": false, + "where": "\"threads\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_section_id_thread_sections_id_fk": { + "name": "threads_section_id_thread_sections_id_fk", + "tableFrom": "threads", + "tableTo": "thread_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_lifecycle_owner_thread_id_threads_id_fk": { + "name": "threads_lifecycle_owner_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "lifecycle_owner_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 6a30ce673b..62d03097de 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -890,6 +890,13 @@ "when": 1789607725218, "tag": "0126_overconfident_vin_gonzales", "breakpoints": true + }, + { + "idx": 127, + "version": "6", + "when": 1789718797921, + "tag": "0127_draft_resources", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/drafts.ts b/packages/db/src/data/drafts.ts new file mode 100644 index 0000000000..f2eb313640 --- /dev/null +++ b/packages/db/src/data/drafts.ts @@ -0,0 +1,288 @@ +import { and, desc, eq, isNull, sql, type SQL } from "drizzle-orm"; +import type { DbQueryConnection } from "../connection.js"; +import { drafts, draftSubmissionReceipts } from "../schema.js"; + +export type DraftRow = typeof drafts.$inferSelect; +export type DraftSubmissionReceiptRow = + typeof draftSubmissionReceipts.$inferSelect; + +interface DraftContents { + projectId: string | null; + payloadJson: string | null; + searchText: string; + hasInput: boolean; +} + +export interface CreateDraftInput extends DraftContents { + id: string; + creationFingerprint: string; +} + +export interface UpdateDraftInput extends DraftContents { + id: string; + expectedRevision: number; +} + +export interface DeleteDraftInput { + id: string; + expectedRevision: number; +} + +export interface ListDraftsInput { + projectId?: string | null; + query?: string; + includeEmpty: boolean; + limit: number; + offset: number; +} + +export interface DraftSubmissionIdentity { + draftId: string; + revision: number; +} + +export interface CreateDraftSubmissionReceiptInput + extends DraftSubmissionIdentity { + threadId: string; +} + +function liveDrafts(...conditions: (SQL | undefined)[]) { + return and( + ...conditions, + isNull(drafts.deletedAt), + isNull(drafts.submittedAt), + ); +} + +export function getStoredDraft( + db: DbQueryConnection, + id: string, +): DraftRow | null { + return db.select().from(drafts).where(eq(drafts.id, id)).get() ?? null; +} + +export function getDraft( + db: DbQueryConnection, + id: string, +): DraftRow | null { + return ( + db + .select() + .from(drafts) + .where(liveDrafts(eq(drafts.id, id))) + .get() ?? null + ); +} + +export function createDraft( + db: DbQueryConnection, + input: CreateDraftInput, +): DraftRow { + const now = Date.now(); + const created = db + .insert(drafts) + .values({ + ...input, + revision: 1, + createdAt: now, + updatedAt: now, + deletedAt: null, + submittedAt: null, + }) + .onConflictDoNothing({ target: drafts.id }) + .returning() + .get(); + if (created) return created; + const existing = getStoredDraft(db, input.id); + if (!existing) { + throw new Error(`Draft ${input.id} disappeared after create`); + } + return existing; +} + +export function listDrafts( + db: DbQueryConnection, + input: ListDraftsInput, +): DraftRow[] { + return db + .select() + .from(drafts) + .where( + liveDrafts( + input.projectId === undefined + ? undefined + : input.projectId === null + ? isNull(drafts.projectId) + : eq(drafts.projectId, input.projectId), + input.includeEmpty ? undefined : eq(drafts.hasInput, true), + input.query + ? sql`instr(lower(${drafts.searchText}), lower(${input.query})) > 0` + : undefined, + ), + ) + .orderBy(desc(drafts.updatedAt), desc(drafts.id)) + .limit(input.limit) + .offset(input.offset) + .all(); +} + +export function updateDraft( + db: DbQueryConnection, + input: UpdateDraftInput, +): DraftRow | null { + return ( + db + .update(drafts) + .set({ + projectId: input.projectId, + payloadJson: input.payloadJson, + searchText: input.searchText, + hasInput: input.hasInput, + revision: sql`${drafts.revision} + 1`, + updatedAt: Date.now(), + }) + .where( + liveDrafts( + eq(drafts.id, input.id), + eq(drafts.revision, input.expectedRevision), + ), + ) + .returning() + .get() ?? null + ); +} + +export function deleteDraft( + db: DbQueryConnection, + input: DeleteDraftInput, +): DraftRow | null { + const now = Date.now(); + return ( + db + .update(drafts) + .set({ + payloadJson: null, + searchText: "", + hasInput: false, + revision: sql`${drafts.revision} + 1`, + deletedAt: now, + updatedAt: now, + }) + .where( + liveDrafts( + eq(drafts.id, input.id), + eq(drafts.revision, input.expectedRevision), + ), + ) + .returning() + .get() ?? null + ); +} + +export function getDraftSubmissionReceipt( + db: DbQueryConnection, + input: DraftSubmissionIdentity, +): DraftSubmissionReceiptRow | null { + return ( + db + .select() + .from(draftSubmissionReceipts) + .where( + and( + eq(draftSubmissionReceipts.draftId, input.draftId), + eq(draftSubmissionReceipts.revision, input.revision), + ), + ) + .get() ?? null + ); +} + +export function createDraftSubmissionReceipt( + db: DbQueryConnection, + input: CreateDraftSubmissionReceiptInput, +): DraftSubmissionReceiptRow { + const now = Date.now(); + const created = db + .insert(draftSubmissionReceipts) + .values({ + ...input, + status: "pending", + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing({ + target: [ + draftSubmissionReceipts.draftId, + draftSubmissionReceipts.revision, + ], + }) + .returning() + .get(); + if (created) return created; + const existing = getDraftSubmissionReceipt(db, input); + if (!existing) { + throw new Error( + `Draft submission ${input.draftId} disappeared after create`, + ); + } + return existing; +} + +export function completeDraftSubmissionReceipt( + db: DbQueryConnection, + input: DraftSubmissionIdentity, +): DraftSubmissionReceiptRow | null { + return db.transaction((tx) => { + const now = Date.now(); + const completed = tx + .update(draftSubmissionReceipts) + .set({ status: "submitted", updatedAt: now }) + .where( + and( + eq(draftSubmissionReceipts.draftId, input.draftId), + eq(draftSubmissionReceipts.revision, input.revision), + eq(draftSubmissionReceipts.status, "pending"), + ), + ) + .returning() + .get(); + if (!completed) return getDraftSubmissionReceipt(tx, input); + tx.update(drafts) + .set({ + payloadJson: null, + searchText: "", + hasInput: false, + revision: sql`${drafts.revision} + 1`, + submittedAt: now, + updatedAt: now, + }) + .where( + liveDrafts( + eq(drafts.id, input.draftId), + eq(drafts.revision, input.revision), + ), + ) + .run(); + return completed; + }); +} + +export function failDraftSubmissionReceipt( + db: DbQueryConnection, + input: DraftSubmissionIdentity, +): DraftSubmissionReceiptRow | null { + return ( + db + .update(draftSubmissionReceipts) + .set({ status: "failed", updatedAt: Date.now() }) + .where( + and( + eq(draftSubmissionReceipts.draftId, input.draftId), + eq(draftSubmissionReceipts.revision, input.revision), + eq(draftSubmissionReceipts.status, "pending"), + ), + ) + .returning() + .get() ?? getDraftSubmissionReceipt(db, input) + ); +} diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 37ec1ba0df..7a0bcaeeda 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -1,3 +1,26 @@ +export { + completeDraftSubmissionReceipt, + createDraft, + createDraftSubmissionReceipt, + deleteDraft, + failDraftSubmissionReceipt, + getDraft, + getDraftSubmissionReceipt, + getStoredDraft, + listDrafts, + updateDraft, +} from "./drafts.js"; +export type { + CreateDraftInput, + CreateDraftSubmissionReceiptInput, + DeleteDraftInput, + DraftRow, + DraftSubmissionIdentity, + DraftSubmissionReceiptRow, + ListDraftsInput, + UpdateDraftInput, +} from "./drafts.js"; + export { createProject, ensurePersonalProject, diff --git a/packages/db/src/data/project-attachments.ts b/packages/db/src/data/project-attachments.ts index 87b5d890b4..02bc3c27d0 100644 --- a/packages/db/src/data/project-attachments.ts +++ b/packages/db/src/data/project-attachments.ts @@ -6,6 +6,7 @@ import { } from "@bb/domain"; import type { DbConnection, DbQueryConnection } from "../connection.js"; import { + drafts, projectAttachments, projectAttachmentThreads, projectAttachmentBackfills, @@ -149,6 +150,22 @@ export function claimProjectAttachments( lt(projectAttachments.createdAt, now - PROJECT_ATTACHMENT_GRACE_MS), isNull(projectAttachments.deletionClaimedAt), unowned, + notExists( + tx + .select({ id: drafts.id }) + .from(drafts) + .where( + and( + eq(drafts.projectId, projectAttachments.projectId), + isNull(drafts.deletedAt), + isNull(drafts.submittedAt), + sql`EXISTS ( + SELECT 1 FROM json_each(${drafts.payloadJson}, '$.prompt.attachments') AS attachment + WHERE json_extract(attachment.value, '$.path') = ${projectAttachments.storedPath} + )`, + ), + ), + ), ), ) .orderBy(asc(projectAttachments.createdAt)) diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 68c9b15233..203cafbc4b 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -569,6 +569,46 @@ export const environments = sqliteTable( ], ); +export const drafts = sqliteTable( + "drafts", + { + id: text("id").primaryKey(), + projectId: text("project_id"), + payloadJson: text("payload_json"), + creationFingerprint: text("creation_fingerprint").notNull(), + searchText: text("search_text").notNull(), + hasInput: integer("has_input", { mode: "boolean" }).notNull(), + revision: integer("revision").notNull().default(1), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + deletedAt: integer("deleted_at"), + submittedAt: integer("submitted_at"), + }, + (table) => [ + index("drafts_live_updated_idx") + .on(table.updatedAt, table.id) + .where(sql`${table.deletedAt} IS NULL AND ${table.submittedAt} IS NULL`), + index("drafts_live_project_updated_idx") + .on(table.projectId, table.updatedAt, table.id) + .where(sql`${table.deletedAt} IS NULL AND ${table.submittedAt} IS NULL`), + ], +); + +export const draftSubmissionReceipts = sqliteTable( + "draft_submission_receipts", + { + draftId: text("draft_id").notNull(), + revision: integer("revision").notNull(), + threadId: text("thread_id").notNull(), + status: text("status") + .$type<"pending" | "submitted" | "failed">() + .notNull(), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [primaryKey({ columns: [table.draftId, table.revision] })], +); + export const threads = sqliteTable( "threads", { diff --git a/packages/db/test/data/drafts.test.ts b/packages/db/test/data/drafts.test.ts new file mode 100644 index 0000000000..67da6044ea --- /dev/null +++ b/packages/db/test/data/drafts.test.ts @@ -0,0 +1,409 @@ +import { eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + claimProjectAttachments, + recordProjectAttachment, + projectAttachmentBackfills, + PROJECT_ATTACHMENT_GRACE_MS, + completeDraftSubmissionReceipt, + createConnection, + createDraft, + createDraftSubmissionReceipt, + createProject, + createThread, + deleteDraft, + drafts, + failDraftSubmissionReceipt, + getDraft, + getDraftSubmissionReceipt, + getStoredDraft, + listDrafts, + migrate, + noopNotifier, + projects, + threads, + updateDraft, + upsertHost, + type CreateDraftInput, + type DbConnection, +} from "../../src/index.js"; + +function draftInput( + overrides: Partial = {}, +): CreateDraftInput { + return { + id: "draft_one", + projectId: "project_missing", + payloadJson: '{"text":"Remember this"}', + creationFingerprint: "initial-payload-fingerprint", + searchText: "Remember this", + hasInput: true, + ...overrides, + }; +} + +function createProjectFixture(db: DbConnection) { + const host = upsertHost(db, noopNotifier, { + name: "Draft storage host", + type: "persistent", + }); + return createProject(db, noopNotifier, { + name: "Draft storage project", + source: { type: "local_path", hostId: host.id, path: "/tmp/draft-storage" }, + }).project; +} + +const listInput = { includeEmpty: false, limit: 50, offset: 0 }; + +describe("draft resources", () => { + let db: DbConnection; + + beforeEach(() => { + db = createConnection(":memory:"); + migrate(db); + }); + + afterEach(() => { + db.$client.close(); + }); + + it("retains saved draft attachments until their final draft reference is removed", () => { + const project = createProjectFixture(db); + db.insert(projectAttachmentBackfills).values({ + projectId: project.id, + phase: "done", + threadCursor: "", + inputCursor: 0, + inputId: "", + inputSequence: 0, + attemptedAt: 0, + error: null, + }).run(); + const attachment = recordProjectAttachment(db, { + projectId: project.id, + storedPath: "saved.txt", + originalName: "saved.txt", + mimeType: "text/plain", + sizeBytes: 12, + createdAt: 0, + readyAt: 1, + }); + const content = draftInput({ + projectId: project.id, + payloadJson: JSON.stringify({ prompt: { attachments: [{ path: attachment.storedPath }] } }), + }); + const first = createDraft(db, content); + const second = createDraft(db, { ...content, id: "draft_two" }); + const now = PROJECT_ATTACHMENT_GRACE_MS + 1; + expect(claimProjectAttachments(db, project.id, now)).toEqual([]); + deleteDraft(db, { id: first.id, expectedRevision: first.revision }); + expect(claimProjectAttachments(db, project.id, now)).toEqual([]); + updateDraft(db, { + ...content, + id: second.id, + expectedRevision: second.revision, + payloadJson: JSON.stringify({ prompt: { attachments: [] } }), + }); + expect(claimProjectAttachments(db, project.id, now)).toEqual([ + { ...attachment, deletionClaimedAt: now }, + ]); + }); + + it("replays stable-ID creation without overwriting contents or identity", () => { + const initial = createDraft(db, draftInput()); + const changed = updateDraft(db, { + ...draftInput({ payloadJson: '{"text":"A newer edit"}' }), + expectedRevision: initial.revision, + }); + expect(changed?.revision).toBe(2); + expect( + createDraft( + db, + draftInput({ + payloadJson: '{"text":"A conflicting create"}', + creationFingerprint: "different-fingerprint", + }), + ), + ).toEqual(changed); + expect(getStoredDraft(db, initial.id)?.creationFingerprint).toBe( + initial.creationFingerprint, + ); + }); + + it("preserves blank identities while excluding them from the saved list", () => { + const blank = createDraft( + db, + draftInput({ + projectId: null, + payloadJson: '{"text":"","providerId":"chosen-provider"}', + searchText: "", + hasInput: false, + }), + ); + expect(getDraft(db, blank.id)).toEqual(blank); + expect(listDrafts(db, listInput)).toEqual([]); + expect(listDrafts(db, { ...listInput, includeEmpty: true })).toEqual([ + blank, + ]); + }); + + it("rejects stale update and delete snapshots and never resurrects a tombstone", () => { + const initial = createDraft(db, draftInput()); + const otherClient = getDraft(db, initial.id); + expect(otherClient).not.toBeNull(); + const changed = updateDraft(db, { + ...draftInput({ payloadJson: '{"text":"Winning edit"}' }), + expectedRevision: initial.revision, + }); + expect(changed?.revision).toBe(2); + expect( + updateDraft(db, { + ...draftInput({ payloadJson: '{"text":"Stale edit"}' }), + expectedRevision: otherClient!.revision, + }), + ).toBeNull(); + expect( + deleteDraft(db, { + id: initial.id, + expectedRevision: otherClient!.revision, + }), + ).toBeNull(); + expect(getDraft(db, initial.id)).toEqual(changed); + + const deleted = deleteDraft(db, { + id: initial.id, + expectedRevision: changed!.revision, + }); + expect(deleted).toMatchObject({ + revision: 3, + payloadJson: null, + searchText: "", + hasInput: false, + deletedAt: expect.any(Number), + creationFingerprint: initial.creationFingerprint, + }); + expect(getDraft(db, initial.id)).toBeNull(); + expect(listDrafts(db, { ...listInput, includeEmpty: true })).toEqual([]); + expect(createDraft(db, draftInput())).toEqual(deleted); + expect( + updateDraft(db, { + ...draftInput(), + expectedRevision: changed!.revision, + }), + ).toBeNull(); + expect( + updateDraft(db, { + ...draftInput(), + expectedRevision: deleted!.revision, + }), + ).toBeNull(); + expect( + deleteDraft(db, { id: initial.id, expectedRevision: deleted!.revision }), + ).toBeNull(); + expect(getStoredDraft(db, initial.id)).toEqual(deleted); + }); + + it("filters literal queries and destinations before ordering and pagination", () => { + for (const input of [ + draftInput({ + id: "draft_a", + projectId: "project_a", + searchText: "100% _Alpha", + }), + draftInput({ + id: "draft_b", + projectId: "project_a", + searchText: "100% _Beta", + }), + draftInput({ + id: "draft_c", + projectId: "project_a", + searchText: "100 percent", + }), + draftInput({ + id: "draft_d", + projectId: "project_b", + searchText: "100% _Alpha", + }), + draftInput({ + id: "draft_e", + projectId: null, + searchText: "100% _Alpha", + }), + draftInput({ + id: "draft_empty", + projectId: "project_a", + hasInput: false, + }), + draftInput({ + id: "draft_deleted", + projectId: "project_a", + searchText: "100% _Alpha", + }), + draftInput({ + id: "draft_submitted", + projectId: "project_a", + searchText: "100% _Alpha", + }), + ]) { + createDraft(db, input); + } + deleteDraft(db, { id: "draft_deleted", expectedRevision: 1 }); + createDraftSubmissionReceipt(db, { + draftId: "draft_submitted", + revision: 1, + threadId: "thread_submitted", + }); + completeDraftSubmissionReceipt(db, { + draftId: "draft_submitted", + revision: 1, + }); + db.update(drafts).set({ updatedAt: 100 }).run(); + db.update(drafts) + .set({ updatedAt: 200 }) + .where(eq(drafts.id, "draft_a")) + .run(); + + const filtered = { ...listInput, projectId: "project_a", query: "100% _" }; + expect(listDrafts(db, filtered).map((row) => row.id)).toEqual([ + "draft_a", + "draft_b", + ]); + expect( + listDrafts(db, { ...filtered, limit: 1, offset: 1 }).map((row) => row.id), + ).toEqual(["draft_b"]); + expect( + listDrafts(db, { ...listInput, projectId: null }).map((row) => row.id), + ).toEqual(["draft_e"]); + expect( + listDrafts(db, { ...listInput, query: "ALPHA" }).map((row) => row.id), + ).toEqual(["draft_a", "draft_e", "draft_d"]); + }); + + it("retains a draft's destination and contents when its project is removed", () => { + const project = createProjectFixture(db); + const draft = createDraft(db, draftInput({ projectId: project.id })); + db.delete(projects).where(eq(projects.id, project.id)).run(); + expect(getDraft(db, draft.id)).toEqual(draft); + }); + + it("preserves receipt identity even if the created thread is later removed", () => { + const project = createProjectFixture(db); + const thread = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + }); + const draft = createDraft(db, draftInput({ projectId: project.id })); + const identity = { draftId: draft.id, revision: draft.revision }; + const receipt = createDraftSubmissionReceipt(db, { + ...identity, + threadId: thread.id, + }); + expect( + createDraftSubmissionReceipt(db, { + ...identity, + threadId: "thread_retry", + }), + ).toEqual(receipt); + db.delete(threads).where(eq(threads.id, thread.id)).run(); + expect(getDraftSubmissionReceipt(db, identity)).toEqual(receipt); + }); + + it("rolls receipt creation back with its enclosing transaction", () => { + const draft = createDraft(db, draftInput()); + const identity = { draftId: draft.id, revision: draft.revision }; + expect(() => + db.transaction((tx) => { + createDraftSubmissionReceipt(tx, { + ...identity, + threadId: "thread_one", + }); + throw new Error("Thread creation failed"); + }), + ).toThrow("Thread creation failed"); + expect(getDraftSubmissionReceipt(db, identity)).toBeNull(); + expect(getDraft(db, draft.id)).toEqual(draft); + }); + + it("consumes matching contents once and prevents subsequent stale writes", () => { + const draft = createDraft(db, draftInput()); + const identity = { draftId: draft.id, revision: draft.revision }; + createDraftSubmissionReceipt(db, { ...identity, threadId: "thread_one" }); + const receipt = completeDraftSubmissionReceipt(db, identity); + expect(receipt?.status).toBe("submitted"); + expect(getDraft(db, draft.id)).toBeNull(); + const consumed = getStoredDraft(db, draft.id); + expect(consumed).toMatchObject({ + revision: 2, + payloadJson: null, + searchText: "", + hasInput: false, + submittedAt: expect.any(Number), + }); + expect(completeDraftSubmissionReceipt(db, identity)).toEqual(receipt); + expect(failDraftSubmissionReceipt(db, identity)).toEqual(receipt); + expect(createDraft(db, draftInput())).toEqual(consumed); + expect( + updateDraft(db, { ...draftInput(), expectedRevision: draft.revision }), + ).toBeNull(); + expect(getStoredDraft(db, draft.id)).toEqual(consumed); + }); + + it("completes an older receipt while keeping newer edits live", () => { + const draft = createDraft(db, draftInput()); + const identity = { draftId: draft.id, revision: draft.revision }; + createDraftSubmissionReceipt(db, { ...identity, threadId: "thread_one" }); + const newer = updateDraft(db, { + ...draftInput({ payloadJson: '{"text":"Keep my next thought"}' }), + expectedRevision: draft.revision, + }); + expect(completeDraftSubmissionReceipt(db, identity)?.status).toBe( + "submitted", + ); + expect(getDraft(db, draft.id)).toEqual(newer); + expect(listDrafts(db, listInput)).toEqual([newer]); + completeDraftSubmissionReceipt(db, identity); + expect(getDraft(db, draft.id)).toEqual(newer); + }); + + it("does not change a deleted draft when an older submission completes", () => { + const draft = createDraft(db, draftInput()); + const identity = { draftId: draft.id, revision: draft.revision }; + createDraftSubmissionReceipt(db, { ...identity, threadId: "thread_one" }); + const deleted = deleteDraft(db, { + id: draft.id, + expectedRevision: draft.revision, + }); + expect(completeDraftSubmissionReceipt(db, identity)?.status).toBe( + "submitted", + ); + expect(getStoredDraft(db, draft.id)).toEqual(deleted); + }); + + it("leaves failed contents intact and keeps terminal receipts immutable", () => { + const draft = createDraft(db, draftInput()); + const identity = { draftId: draft.id, revision: draft.revision }; + createDraftSubmissionReceipt(db, { ...identity, threadId: "thread_one" }); + const failed = failDraftSubmissionReceipt(db, identity); + expect(failed?.status).toBe("failed"); + expect(completeDraftSubmissionReceipt(db, identity)).toEqual(failed); + expect(getDraft(db, draft.id)).toEqual(draft); + expect( + createDraftSubmissionReceipt(db, { + ...identity, + threadId: "thread_retry", + }), + ).toEqual(failed); + const next = updateDraft(db, { + ...draftInput(), + expectedRevision: draft.revision, + }); + expect( + createDraftSubmissionReceipt(db, { + draftId: draft.id, + revision: next!.revision, + threadId: "thread_next_revision", + }), + ).toMatchObject({ status: "pending", threadId: "thread_next_revision" }); + }); +}); diff --git a/packages/db/test/draft-migration.test.ts b/packages/db/test/draft-migration.test.ts new file mode 100644 index 0000000000..40268c043b --- /dev/null +++ b/packages/db/test/draft-migration.test.ts @@ -0,0 +1,98 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readMigrationFiles } from "drizzle-orm/migrator"; +import { describe, expect, it } from "vitest"; +import { + createConnection, + createDraft, + createProject, + createThread, + getAppSettings, + getProject, + getThread, + listStoredUiPreferences, + migrate, + noopNotifier, + overwriteStoredUiPreference, + upsertHost, +} from "../src/index.js"; + +describe("draft storage migration", () => { + it("upgrades a persisted pre-draft database without changing existing state", () => { + const db = createConnection(":memory:"); + let priorState: Buffer; + let project: ReturnType["project"]; + let thread: ReturnType; + let settings: ReturnType; + let preferences: ReturnType; + try { + migrate(db); + const host = upsertHost(db, noopNotifier, { + name: "Existing host", + type: "persistent", + }); + project = createProject(db, noopNotifier, { + name: "Existing project", + source: { + type: "local_path", + hostId: host.id, + path: "/tmp/existing-project", + }, + }).project; + thread = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + title: "Existing conversation", + status: "idle", + }); + settings = getAppSettings(db); + overwriteStoredUiPreference(db, { + key: "sidebar.organizationMode", + valueJson: '"machine"', + }); + preferences = listStoredUiPreferences(db); + const migrations = readMigrationFiles({ + migrationsFolder: resolve( + dirname(fileURLToPath(import.meta.url)), + "../drizzle", + ), + }); + const addedDrafts = migrations.find((entry) => + entry.sql.some((statement) => + statement.includes("CREATE TABLE `drafts`"), + ), + ); + if (!addedDrafts) throw new Error("Draft migration is missing"); + db.$client.exec( + "DROP TABLE draft_submission_receipts; DROP TABLE drafts", + ); + db.$client + .prepare("DELETE FROM __drizzle_migrations WHERE created_at >= ?") + .run(addedDrafts.folderMillis); + priorState = db.$client.serialize(); + } finally { + db.$client.close(); + } + + const upgraded = createConnection(priorState); + try { + migrate(upgraded); + expect(getProject(upgraded, project.id)).toEqual(project); + expect(getThread(upgraded, thread.id)).toEqual(thread); + expect(getAppSettings(upgraded)).toEqual(settings); + expect(listStoredUiPreferences(upgraded)).toEqual(preferences); + expect( + createDraft(upgraded, { + id: "draft_after_upgrade", + projectId: project.id, + payloadJson: '{"text":"Survives upgrade"}', + searchText: "Survives upgrade", + hasInput: true, + creationFingerprint: "upgrade-fingerprint", + }), + ).toMatchObject({ revision: 1, projectId: project.id }); + } finally { + upgraded.$client.close(); + } + }); +}); diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 4da166327e..d1972acfc3 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -722,6 +722,11 @@ function dropMarketplaceStatsColumn(db: DbConnection): void { } } +function rewindDraftResourcesMigration(db: DbConnection): void { + db.$client.exec("DROP TABLE IF EXISTS draft_submission_receipts"); + db.$client.exec("DROP TABLE IF EXISTS drafts"); +} + /** * Undo migration 0110, the dispatch-queue rework. * @@ -750,6 +755,7 @@ function rewindEnvironmentProvisioningMigration(db: DbConnection): void { "ALTER TABLE threads DROP COLUMN lifecycle_owner_thread_id", ); } + rewindDraftResourcesMigration(db); const columns = db.$client .prepare<[], TableInfoRow>("PRAGMA table_info(environments)") .all(); @@ -868,9 +874,7 @@ function rewindMachineProvidersMigration(db: DbConnection): void { "requested_by_thread_id", ]) { if (!queuedDispatchOrigin.some((column) => column.name === name)) continue; - db.$client.exec( - `ALTER TABLE queued_thread_messages DROP COLUMN ${name}`, - ); + db.$client.exec(`ALTER TABLE queued_thread_messages DROP COLUMN ${name}`); } db.$client.exec("DROP TABLE IF EXISTS thread_pruning_cursors"); db.$client.exec("DROP TABLE IF EXISTS project_attachment_threads"); diff --git a/packages/domain/src/change-kinds.ts b/packages/domain/src/change-kinds.ts index 79602457f7..8da7e4504a 100644 --- a/packages/domain/src/change-kinds.ts +++ b/packages/domain/src/change-kinds.ts @@ -59,6 +59,7 @@ export const HOST_CHANGE_KINDS = [ export type HostChangeKind = (typeof HOST_CHANGE_KINDS)[number]; export const SYSTEM_CHANGE_KINDS = [ + "drafts-changed", "config-changed", "plugins-changed", "provider-registrations-changed", diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index ced1ec830e..1b24b366d9 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.4.101"; +export const PLUGIN_SDK_VERSION = "0.4.102"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 5b1ee619ee..9ebccab837 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.101", + "version": "0.4.102", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/sdk/src/areas/drafts.ts b/packages/sdk/src/areas/drafts.ts new file mode 100644 index 0000000000..b602bf51c1 --- /dev/null +++ b/packages/sdk/src/areas/drafts.ts @@ -0,0 +1,125 @@ +import { + draftCreateResponseSchema, + draftDeleteResponseSchema, + draftListResponseSchema, + draftSchema, + draftSubmitResponseSchema, + type Draft, + type DraftCreateRequest, + type DraftCreateResponse, + type DraftDeleteRequest, + type DraftDeleteResponse, + type DraftListResponse, + type DraftSubmitRequest, + type DraftSubmitResponse, + type DraftUpdateRequest, +} from "@bb/server-contract"; +import { signalRequestArgs, type CreateSdkAreaArgs } from "./common.js"; + +export type DraftCreateArgs = DraftCreateRequest; +export type DraftCreateResult = DraftCreateResponse; +export type DraftDeleteArgs = DraftDeleteRequest & { draftId: string }; +export type DraftDeleteResult = DraftDeleteResponse; +export interface DraftGetArgs { + draftId: string; + signal?: AbortSignal; +} +export type DraftGetResult = Draft; +export interface DraftListArgs { + projectId?: string; + query?: string; + includeEmpty?: boolean; + limit?: number; + offset?: number; + signal?: AbortSignal; +} +export type DraftListResult = DraftListResponse; +export type DraftUpdateArgs = DraftUpdateRequest & { draftId: string }; +export type DraftUpdateResult = Draft; +export type DraftSubmitArgs = DraftSubmitRequest & { draftId: string }; +export type DraftSubmitResult = DraftSubmitResponse; + +export interface DraftsArea { + create(args?: DraftCreateArgs): Promise; + list(args?: DraftListArgs): Promise; + get(args: DraftGetArgs): Promise; + update(args: DraftUpdateArgs): Promise; + delete(args: DraftDeleteArgs): Promise; + submit(args: DraftSubmitArgs): Promise; +} + +export function createDraftsArea(args: CreateSdkAreaArgs): DraftsArea { + const { transport } = args; + return { + async create(input = {}) { + const body = await transport.readJson( + transport.api.v1.drafts.$post({ json: input }), + ); + return draftCreateResponseSchema.parse(body); + }, + async list(input) { + const body = await transport.readJson( + transport.api.v1.drafts.$get( + { + query: { + ...(input?.projectId === undefined + ? {} + : { projectId: input.projectId }), + ...(input?.query === undefined ? {} : { query: input.query }), + ...(input?.includeEmpty === undefined + ? {} + : { includeEmpty: input.includeEmpty ? "true" : "false" }), + ...(input?.limit === undefined + ? {} + : { limit: String(input.limit) }), + ...(input?.offset === undefined + ? {} + : { offset: String(input.offset) }), + }, + }, + ...signalRequestArgs(input?.signal), + ), + ); + return draftListResponseSchema.parse(body); + }, + async get(input) { + const body = await transport.readJson( + transport.api.v1.drafts[":id"].$get( + { param: { id: input.draftId } }, + ...signalRequestArgs(input.signal), + ), + ); + return draftSchema.parse(body); + }, + async update(input) { + const { draftId, ...json } = input; + const body = await transport.readJson( + transport.api.v1.drafts[":id"].$patch({ + param: { id: draftId }, + json, + }), + ); + return draftSchema.parse(body); + }, + async delete(input) { + const { draftId, ...json } = input; + const body = await transport.readJson( + transport.api.v1.drafts[":id"].$delete({ + param: { id: draftId }, + json, + }), + ); + return draftDeleteResponseSchema.parse(body); + }, + async submit(input) { + const { draftId, ...json } = input; + const body = await transport.readJson( + transport.api.v1.drafts[":id"].submit.$post({ + param: { id: draftId }, + json, + }), + ); + return draftSubmitResponseSchema.parse(body); + }, + }; +} diff --git a/packages/sdk/src/core.ts b/packages/sdk/src/core.ts index f5ba66a779..8982bc2bff 100644 --- a/packages/sdk/src/core.ts +++ b/packages/sdk/src/core.ts @@ -3,6 +3,7 @@ import { type ExperimentalDesktopBrowsersArea, } from "./areas/desktop-browsers.js"; import type { BbSdkContext, BbSdkTransport } from "./transport.js"; +import { createDraftsArea, type DraftsArea } from "./areas/drafts.js"; import { createEnvironmentsArea, type EnvironmentsArea, @@ -43,6 +44,7 @@ export interface CreateBbSdkWithGuideArgs extends CreateBbSdkArgs { } export interface BbSdkAreas extends BbRealtime { + drafts: DraftsArea; experimental_desktopBrowsers: ExperimentalDesktopBrowsersArea; experimental_server: ExperimentalServerArea; environments: EnvironmentsArea; @@ -74,6 +76,7 @@ export function createBbSdk( transport: args.transport, }); const areas: BbSdkAreas = { + drafts: createDraftsArea(sdkContext), experimental_desktopBrowsers: createDesktopBrowsersArea(sdkContext), experimental_server: createServerArea(sdkContext), environments: createEnvironmentsArea(sdkContext), diff --git a/packages/sdk/src/public-types.ts b/packages/sdk/src/public-types.ts index cba8853903..0a5fe02951 100644 --- a/packages/sdk/src/public-types.ts +++ b/packages/sdk/src/public-types.ts @@ -14,6 +14,11 @@ export type { } from "@bb/domain"; export type { CreateExecutionInputSources, + Draft, + DraftContent, + DraftContentInput, + DraftOptions, + DraftPrompt, EnvironmentArgs, ExistingThreadExecutionInputSources, UnmanagedBranchSpec, @@ -21,6 +26,7 @@ export type { } from "@bb/server-contract"; export type * from "./realtime.js"; +export type * from "./areas/drafts.js"; export type * from "./areas/environments.js"; export type * from "./areas/files.js"; export type * from "./areas/guide.js"; diff --git a/packages/sdk/test/drafts.test.ts b/packages/sdk/test/drafts.test.ts new file mode 100644 index 0000000000..18f6f4c999 --- /dev/null +++ b/packages/sdk/test/drafts.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import type { DraftContent, DraftUpdateArgs } from "@bb/sdk"; +import type { DraftUpdateArgs as BrowserDraftUpdateArgs } from "@bb/sdk/browser"; +import type { DraftUpdateArgs as CoreDraftUpdateArgs } from "@bb/sdk/core"; +import type { DraftUpdateArgs as NodeDraftUpdateArgs } from "@bb/sdk/node"; +import { draftSchema } from "@bb/server-contract"; +import type { ThreadWithRuntime } from "@bb/domain"; +import { createBbSdk } from "../src/core.js"; +import { BbHttpError, type FetchImplementation } from "../src/response.js"; +import { createHttpTransport } from "../src/transport-http.js"; + +const draft = draftSchema.parse({ + id: "drf_sdk_roundtrip", + revision: 7, + createdAt: 1, + updatedAt: 2, + content: { + projectId: "proj_test", + sectionId: "section_test", + prompt: { + text: "@Previous\nFollow up", + mentions: [ + { + start: 0, + end: 9, + resource: { + kind: "thread", + threadId: "thr_previous", + label: "Previous", + }, + }, + ], + attachments: [ + { + type: "localFile", + path: "/uploads/a.txt", + name: "a.txt", + sizeBytes: 42, + }, + ], + }, + options: { + providerId: "provider_test", + model: "model_test", + reasoningLevel: "high", + serviceTier: "fast", + permissionMode: "auto", + environment: { + type: "provider", + environmentProviderId: "custom-worktree", + machine: null, + inputs: { branch: "feature" }, + }, + title: "Draft example", + parentThreadId: "thr_parent", + sourceThreadId: null, + sourceSeqEnd: null, + originKind: null, + sendAt: 10, + }, + }, +}); + +const thread: ThreadWithRuntime = { + id: "thr_submitted", + projectId: "proj_test", + environmentId: null, + providerId: "provider_test", + title: "Draft example", + titleFallback: null, + sectionId: "section_test", + status: "pending", + parentThreadId: "thr_parent", + sourceThreadId: null, + originKind: null, + originPluginId: null, + visibility: "visible", + archivedAt: null, + pinnedAt: null, + deletedAt: null, + lastReadAt: null, + latestAttentionAt: 1, + createdAt: 1, + updatedAt: 1, + runtime: { displayStatus: "pending", hostReconnectGraceExpiresAt: null }, +}; + +function sdkWithResponses(responses: Response[]) { + const fetch = vi.fn(async () => { + const response = responses.shift(); + if (response === undefined) throw new Error("Unexpected SDK request"); + return response; + }); + return { + fetch, + sdk: createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + runtime: "node", + fetch, + }), + }), + }; +} + +describe("draft SDK", () => { + it("exposes revision-required content mutations through every public entrypoint", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf(draft.content).toEqualTypeOf(); + }); + + it("round trips complete content without leaking draftId into strict mutation bodies", async () => { + const updated = { ...draft, revision: 8 }; + const { sdk, fetch } = sdkWithResponses([ + Response.json({ id: draft.id, draft }), + Response.json(updated), + Response.json({ id: draft.id, revision: 9 }), + ]); + await expect( + sdk.drafts.create({ id: draft.id, content: draft.content }), + ).resolves.toEqual({ id: draft.id, draft }); + await expect( + sdk.drafts.update({ + draftId: draft.id, + expectedRevision: 7, + content: draft.content, + }), + ).resolves.toEqual(updated); + await sdk.drafts.delete({ draftId: draft.id, expectedRevision: 8 }); + + expect( + fetch.mock.calls.map(([url, init]) => ({ + url: String(url), + method: init?.method, + body: JSON.parse(String(init?.body)), + })), + ).toEqual([ + { + url: "http://bb.test/api/v1/drafts", + method: "POST", + body: { id: draft.id, content: draft.content }, + }, + { + url: `http://bb.test/api/v1/drafts/${draft.id}`, + method: "PATCH", + body: { expectedRevision: 7, content: draft.content }, + }, + { + url: `http://bb.test/api/v1/drafts/${draft.id}`, + method: "DELETE", + body: { expectedRevision: 8 }, + }, + ]); + }); + + it("preserves explicit false and zero query values and read cancellation", async () => { + const { sdk, fetch } = sdkWithResponses([ + Response.json({ drafts: [draft], nextOffset: 1 }), + Response.json(draft), + Response.json({ drafts: [], nextOffset: null }), + ]); + const controller = new AbortController(); + await expect( + sdk.drafts.list({ + projectId: "proj_test", + query: "Follow up & next", + includeEmpty: false, + limit: 1, + offset: 0, + signal: controller.signal, + }), + ).resolves.toEqual({ drafts: [draft], nextOffset: 1 }); + await expect( + sdk.drafts.get({ draftId: draft.id, signal: controller.signal }), + ).resolves.toEqual(draft); + await sdk.drafts.list(); + + const listUrl = new URL(String(fetch.mock.calls[0]?.[0])); + expect(Object.fromEntries(listUrl.searchParams)).toEqual({ + projectId: "proj_test", + query: "Follow up & next", + includeEmpty: "false", + limit: "1", + offset: "0", + }); + expect(fetch.mock.calls[0]?.[1]?.signal).toBe(controller.signal); + expect(fetch.mock.calls[1]?.[1]?.signal).toBe(controller.signal); + const defaultListUrl = new URL(String(fetch.mock.calls[2]?.[0])); + expect(defaultListUrl.pathname).toBe("/api/v1/drafts"); + expect(defaultListUrl.search).toBe(""); + }); + + it("keeps consumed creation receipts and repeated submit results intact", async () => { + const newer = { ...draft, revision: 8 }; + const { sdk, fetch } = sdkWithResponses([ + Response.json({ id: draft.id, draft: null }), + Response.json({ thread, draft: newer }), + Response.json({ thread, draft: newer }), + ]); + await expect( + sdk.drafts.create({ id: draft.id, content: draft.content }), + ).resolves.toEqual({ id: draft.id, draft: null }); + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect( + sdk.drafts.submit({ + draftId: draft.id, + expectedRevision: 7, + origin: "sdk", + }), + ).resolves.toEqual({ thread, draft: newer }); + } + expect( + fetch.mock.calls.slice(1).map(([url, init]) => ({ + url: String(url), + method: init?.method, + body: JSON.parse(String(init?.body)), + })), + ).toEqual( + Array.from({ length: 2 }, () => ({ + url: `http://bb.test/api/v1/drafts/${draft.id}/submit`, + method: "POST", + body: { expectedRevision: 7, origin: "sdk" }, + })), + ); + }); + + it("surfaces revision conflicts without retrying or losing the response body", async () => { + const conflict = { + code: "draft_revision_conflict", + message: "Draft changed", + draft, + }; + const { sdk, fetch } = sdkWithResponses([ + Response.json(conflict, { status: 409 }), + ]); + const request = sdk.drafts.update({ + draftId: draft.id, + expectedRevision: 6, + content: draft.content, + }); + await expect(request).rejects.toBeInstanceOf(BbHttpError); + await expect(request).rejects.toMatchObject({ + status: 409, + body: conflict, + }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("rejects malformed successful responses before callers treat them as durable", async () => { + const { sdk } = sdkWithResponses([ + Response.json({ ...draft, revision: 0 }), + Response.json({ id: draft.id }), + ]); + await expect(sdk.drafts.get({ draftId: draft.id })).rejects.toThrow(); + await expect(sdk.drafts.create()).rejects.toThrow(); + }); +}); diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index 32ffc833bb..a4fa8d99fa 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -221,6 +221,7 @@ interface NodeSurface { } type ExpectedBbSdkKey = + | "drafts" | "experimental_desktopBrowsers" | "experimental_server" | "environments" diff --git a/packages/server-contract/src/api-types.ts b/packages/server-contract/src/api-types.ts index 0e56c3299c..8428757543 100644 --- a/packages/server-contract/src/api-types.ts +++ b/packages/server-contract/src/api-types.ts @@ -9,5 +9,6 @@ export * from "./api/system.js"; export * from "./api/ui-preferences.js"; export * from "./api/terminals.js"; export * from "./api/threads.js"; +export * from "./api/drafts.js"; export * from "./api/desktop-browsers.js"; export * from "./api/server-move.js"; diff --git a/packages/server-contract/src/api/drafts.ts b/packages/server-contract/src/api/drafts.ts new file mode 100644 index 0000000000..f7156b0cc2 --- /dev/null +++ b/packages/server-contract/src/api/drafts.ts @@ -0,0 +1,144 @@ +import { + environmentMachineSelectionSchema, + jsonValueSchema, + permissionModeInputSchema, + promptTextMentionSchema, + reasoningLevelSchema, + serviceTierSchema, + threadOriginKindSchema, + threadWithRuntimeSchema, +} from "@bb/domain"; +import { z } from "zod"; +import { uploadedPromptAttachmentSchema } from "./projects.js"; +import { + hostEnvironmentSchema, + projectDefaultEnvironmentSchema, + reuseEnvironmentSchema, +} from "./shared.js"; +import { threadCreateOriginSchema } from "./threads.js"; + +export const draftIdSchema = z.string().regex(/^drf_[A-Za-z0-9_-]{8,128}$/); + +export const draftPromptSchema = z + .object({ + text: z.string().default(""), + mentions: z.array(promptTextMentionSchema).default([]), + attachments: z.array(uploadedPromptAttachmentSchema).default([]), + }) + .strict(); +export type DraftPrompt = z.infer; + +export const draftEnvironmentSchema = z.discriminatedUnion("type", [ + reuseEnvironmentSchema, + hostEnvironmentSchema, + projectDefaultEnvironmentSchema, + z.object({ + type: z.literal("provider"), + environmentProviderId: z.string().min(1), + machine: environmentMachineSelectionSchema.nullable().default(null), + inputs: jsonValueSchema.nullable(), + }), +]); + +export const draftOptionsSchema = z + .object({ + providerId: z.string().min(1).nullable().default(null), + model: z.string().min(1).nullable().default(null), + reasoningLevel: reasoningLevelSchema.nullable().default(null), + serviceTier: serviceTierSchema.nullable().default(null), + permissionMode: permissionModeInputSchema.nullable().default(null), + environment: draftEnvironmentSchema.nullable().default(null), + title: z.string().min(1).nullable().default(null), + parentThreadId: z.string().min(1).nullable().default(null), + sourceThreadId: z.string().min(1).nullable().default(null), + sourceSeqEnd: z.number().int().nonnegative().nullable().default(null), + originKind: threadOriginKindSchema.nullable().default(null), + sendAt: z.number().int().nonnegative().nullable().default(null), + }) + .strict(); +export type DraftOptions = z.infer; + +export const draftContentSchema = z + .object({ + projectId: z.string().min(1).nullable().default(null), + sectionId: z.string().min(1).nullable().default(null), + prompt: draftPromptSchema.prefault({}), + options: draftOptionsSchema.prefault({}), + }) + .strict(); +export type DraftContent = z.infer; +export type DraftContentInput = z.input; + +export const draftSchema = z.object({ + id: draftIdSchema, + revision: z.number().int().positive(), + createdAt: z.number().int().nonnegative(), + updatedAt: z.number().int().nonnegative(), + content: draftContentSchema, +}); +export type Draft = z.infer; + +export const draftCreateRequestSchema = z + .object({ + id: draftIdSchema.optional(), + content: draftContentSchema.prefault({}), + }) + .strict(); +export type DraftCreateRequest = z.input; + +export const draftCreateResponseSchema = z.object({ + id: draftIdSchema, + draft: draftSchema.nullable(), +}); +export type DraftCreateResponse = z.infer; + +export const draftListQuerySchema = z.object({ + projectId: z.string().min(1).optional(), + query: z.string().max(500).optional(), + includeEmpty: z.enum(["true", "false"]).optional(), + limit: z.string().regex(/^\d+$/).optional(), + offset: z.string().regex(/^\d+$/).optional(), +}); +export type DraftListQuery = z.infer; + +export const draftListResponseSchema = z.object({ + drafts: z.array(draftSchema), + nextOffset: z.number().int().nonnegative().nullable(), +}); +export type DraftListResponse = z.infer; + +export const draftUpdateRequestSchema = z + .object({ + expectedRevision: z.number().int().positive(), + content: draftContentSchema, + }) + .strict(); +export type DraftUpdateRequest = z.input; + +export const draftDeleteRequestSchema = z + .object({ + expectedRevision: z.number().int().positive(), + }) + .strict(); +export type DraftDeleteRequest = z.infer; + +export const draftDeleteResponseSchema = z.object({ + id: draftIdSchema, + revision: z.number().int().positive(), +}); +export type DraftDeleteResponse = z.infer; + +export const draftSubmitRequestSchema = z + .object({ + expectedRevision: z.number().int().positive(), + origin: threadCreateOriginSchema.default("sdk"), + originPluginId: z.string().min(1).optional(), + }) + .strict(); +export type DraftSubmitRequest = z.input; + +export const draftSubmitResponseSchema = z.object({ + thread: threadWithRuntimeSchema, + draft: draftSchema.nullable(), +}); +export type DraftSubmitResponse = z.infer; diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index b13596d173..785bd7c0e4 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -10,6 +10,23 @@ import { machineEnvironmentReplaceSchema, type MachineEnvironmentReplace, } from "./api/system.js"; +import { + draftCreateRequestSchema, + draftDeleteRequestSchema, + draftListQuerySchema, + draftSubmitRequestSchema, + draftUpdateRequestSchema, + type Draft, + type DraftCreateRequest, + type DraftCreateResponse, + type DraftDeleteRequest, + type DraftDeleteResponse, + type DraftListQuery, + type DraftListResponse, + type DraftSubmitRequest, + type DraftSubmitResponse, + type DraftUpdateRequest, +} from "./api/drafts.js"; import { desktopBrowserHostRequestSchema, desktopBrowserScopeSchema, @@ -1169,6 +1186,61 @@ export const publicApiRoutes = { }), }, + drafts: { + create: defineRoute({ + path: "/drafts", + method: "post", + request: jsonRequest< + EmptyInput, + DraftCreateRequest, + typeof draftCreateRequestSchema + >(draftCreateRequestSchema), + response: jsonResponse({ status: 201 }), + }), + list: defineRoute({ + path: "/drafts", + method: "get", + request: optionalQueryRequest( + draftListQuerySchema, + ), + response: jsonResponse(), + }), + get: defineRoute({ + path: "/drafts/:id", + method: "get", + request: noRequest(), + response: jsonResponse(), + }), + update: defineRoute({ + path: "/drafts/:id", + method: "patch", + request: jsonRequest< + PathId, + DraftUpdateRequest, + typeof draftUpdateRequestSchema + >(draftUpdateRequestSchema), + response: jsonResponse(), + }), + delete: defineRoute({ + path: "/drafts/:id", + method: "delete", + request: jsonRequest( + draftDeleteRequestSchema, + ), + response: jsonResponse(), + }), + submit: defineRoute({ + path: "/drafts/:id/submit", + method: "post", + request: jsonRequest< + PathId, + DraftSubmitRequest, + typeof draftSubmitRequestSchema + >(draftSubmitRequestSchema), + response: jsonResponse(), + }), + }, + threadSections: { create: defineRoute({ path: "/thread-sections", diff --git a/packages/templates/src/templates/bb-guide-overview.md b/packages/templates/src/templates/bb-guide-overview.md index 4ea8b6edd0..f6dd14ccc3 100644 --- a/packages/templates/src/templates/bb-guide-overview.md +++ b/packages/templates/src/templates/bb-guide-overview.md @@ -11,6 +11,7 @@ Core concepts: - Project — maps to a repository. All threads belong to a project. - Thread — a single agent conversation. The fundamental unit of work. +- Draft — saved prompt contents, destination and composer choices before a thread starts. Drafts are server resources available through SDK and CLI with the app closed. - Environment — where a thread runs. Kinds: project checkout or isolated worktree. Multiple threads can share an environment. - Machine — an execution host where project sources and thread environments live. - Terminal — a persistent PTY session scoped to a thread, environment, or machine path. Use terminals for long-running commands such as dev servers. @@ -38,7 +39,7 @@ behavior. Run `bb guide ` for command details: - threads Spawning, inspecting, messaging, and managing threads + threads Saved drafts; spawning, inspecting, and managing threads environments Environment lifecycle hooks, operations, commits, and merges agent-configuration AGENTS.md and skills files that shape agents providers Discovering providers and models diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 4e9483fc6e..d8a63355ea 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -1,7 +1,7 @@ --- kind: instruction title: bb Guide — Threads -summary: Command reference for thread spawning, inspecting, messaging, and lifecycle. +summary: Command reference for saved drafts, thread spawning, inspecting, messaging, and lifecycle. intent: Provide complete thread command documentation for agents. editingNotes: Keep flags accurate against the CLI implementation. Run the json-flag-enforcement and command-output tests after changes. --- @@ -9,6 +9,70 @@ Thread commands Every command supports --json for machine-readable output. +Saved drafts: + + bb thread draft create [--text ] [--project ] [--section ] + [--content-file ] [--id ] + bb thread draft list [--project ] [--query ] [--include-empty] + [--limit ] [--offset ] + bb thread draft show (alias: bb thread draft get) + bb thread draft update --expected-revision + [--text ] [--project ] [--section ] + [--content-file ] + bb thread draft delete --expected-revision [--yes] + bb thread draft submit --expected-revision + + Drafts persist prompt text, mentions, uploaded attachment references, project, + section and composer choices on the server. All these operations work with + no app connected. A draft is separate from an active thread's unsent reply. + No command infers a destination from BB_PROJECT_ID. An incomplete draft may + be saved; submit validates its project and execution choices before starting + an ordinary thread. + + List excludes blank and option-only drafts unless --include-empty is passed. + --query matches prompt text and attachment names. Pagination defaults to 50, + accepts --limit 1–200, and returns nextOffset in JSON (null at the end). + Without --project, list spans projects. + + Inspect the revision with show --json before editing, deleting or submitting. + A stale --expected-revision fails without overwriting newer content. Updates + using --text, --project or --section retain unsupplied fields. A text change + to a draft containing mentions requires --content-file so mention positions + are explicit. --content-file replaces the whole content object; omitted + fields reset to schema defaults. Text/project/section flags override the file. + + To edit every field, extract the content object (not the complete draft): + + bb thread draft show --json > draft.json + jq '.content' draft.json > content.json + bb thread draft update --expected-revision --content-file content.json + + content.json has projectId, sectionId, prompt {text, mentions, attachments}, + and options {providerId, model, reasoningLevel, serviceTier, permissionMode, + environment, title, parentThreadId, sourceThreadId, sourceSeqEnd, originKind, + sendAt}. Nullable selections stay unset until submit resolves defaults. + Use null in a content file to clear a destination or optional choice. + Upload attachments through bb project attachment upload, then retain the + returned attachment references in prompt.attachments. + + Create returns {id, draft}. For a retried create/import, reuse --id and the + exact original content: a consumed/deleted identity returns draft: null and + never resurrects. Different creation content for that ID is a conflict. + IDs use drf_ followed by 8–128 letters, digits, underscores or hyphens. + + Submit returns {thread, draft}; draft is null when the accepted revision was + consumed, or the newer remaining revision when edits arrived during submit. + Retrying the same draft revision never creates a second thread. If submission + fails after reserving a thread, the error requires saving a new revision + before an intentional new attempt. Keep local unsaved content after errors. + + SDK parity: sdk.drafts.create/list/get/update/delete/submit. Use draftId for + ID operations, expectedRevision for mutations, and content for create/update. + list accepts projectId, query, includeEmpty (boolean), limit and offset + (numbers); list/get accept signal. SDK submit defaults origin to "sdk"; + bb thread draft submit records origin "cli". The SDK exports Draft, DraftContent, + DraftContentInput, DraftPrompt, DraftOptions and each operation's args/results. + Spawning: bb thread spawn --project --prompt "..." [options] diff --git a/plugins/bb-guide/skills/bb-cli/SKILL.md b/plugins/bb-guide/skills/bb-cli/SKILL.md index 91dff83eb9..1bc098c12c 100644 --- a/plugins/bb-guide/skills/bb-cli/SKILL.md +++ b/plugins/bb-guide/skills/bb-cli/SKILL.md @@ -31,7 +31,8 @@ BB_HOST_DAEMON_PORT only for an intentional non-default target. - Read references/configuration.md for settings, agent instructions, skills, remote clients, and environment setup scripts. - Read references/thread-creation.md before you spawn or fork threads, create - projects, select machines, move the server, or create environments. + or submit saved drafts, create projects, select machines, move the server, or + create environments. - Read references/thread-operation.md for messages, queues, interactions, panes, terminals, inspection, and long-running commands. - Read references/failure-recovery.md when a thread fails, stops, or needs plan @@ -46,6 +47,9 @@ BB_HOST_DAEMON_PORT only for an intentional non-default target. - Resolve names and IDs with a list or show command before mutation. - Pass an explicit project when a command can act across projects. +- Manage saved prompts through `bb thread draft` even with the app closed. Read the + current revision before update/delete/submit, and pass --expected-revision. + Use --content-file for full composer content and mention-safe text edits. - Pass an environment or machine selector when the default host is uncertain. - Spawn onto a plugin-provisioned environment with `bb thread spawn --environment-provider ` (list them with diff --git a/plugins/bb-guide/skills/bb-cli/references/command-index.md b/plugins/bb-guide/skills/bb-cli/references/command-index.md index 194fab1b37..204bf8dfcd 100644 --- a/plugins/bb-guide/skills/bb-cli/references/command-index.md +++ b/plugins/bb-guide/skills/bb-cli/references/command-index.md @@ -28,6 +28,17 @@ This index lists every command path that the core CLI registers. Read the task-s - `bb settings version` - `bb settings reload` +## thread draft + +- `bb thread draft` +- `bb thread draft create` +- `bb thread draft list` +- `bb thread draft show` +- `bb thread draft get` +- `bb thread draft update` +- `bb thread draft delete` +- `bb thread draft submit` + ## project - `bb project` diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md index ae9436a37a..579349f999 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md @@ -1,5 +1,46 @@ # Thread, project, environment, and machine creation +## Saved drafts + +`bb thread draft` manages server-backed prompt drafts with the app closed. It does not +start a thread until `submit` succeeds. Drafts have independent `drf_` IDs and +are separate from an active thread's unsent follow-up. + +- Create with `bb thread draft create --text "..." --project `, or use + `--content-file ` for a JSON content object. Drafts may be incomplete; + no project is inferred from the current thread. `--section` sets a section. +- `bb thread draft list --project --query --json` searches prompt text and + attachment names. Omit --project for all projects. Blank/option-only drafts + require --include-empty. Use --limit (1–200, default 50) and --offset for + pagination; JSON returns drafts and nextOffset. +- Read `bb thread draft show --json` (alias `get`) to obtain the revision and full + content. `bb thread draft update --expected-revision --text "..."` + preserves attachments, options and destination. --project and --section edit + those destinations. A stale revision fails instead of replacing unseen work. +- For full replacement, extract `.content` from show's JSON into a local file, + edit it, then pass `--content-file ` and the revision you inspected. + Content fields are projectId, sectionId, prompt {text, mentions, attachments}, + and options {providerId, model, reasoningLevel, serviceTier, permissionMode, + environment, title, parentThreadId, sourceThreadId, sourceSeqEnd, originKind, + sendAt}. Omitted fields reset to defaults; null clears nullable fields. + Text/project/section flags override a content file. Changing text with + mentions requires a content file so its mention positions remain explicit. + Upload files with `bb project attachment upload` and use the returned + attachment references; content files are read on the CLI machine. +- `bb thread draft delete --expected-revision --yes` deletes that + revision. All draft commands support --json. +- `bb thread draft submit --expected-revision --json` returns the + ordinary thread and a remaining draft (null if consumed). Newer edits survive. + Retrying the same revision never creates a second thread. A failure after + thread reservation requires saving a new revision before an intentional retry. +- Retried creation/import must reuse --id and the exact original content. + Create returns `{id, draft}`; draft: null acknowledges an already consumed or + deleted identity. Reusing an ID with different creation content is a conflict. + +The SDK exposes the same operations through sdk.drafts. See `bb guide threads` +for request fields and retry behavior. Keep unsaved local contents when an +operation fails; inspect the current revision before reconciling a conflict. + ## Spawning Threads - Use `bb thread spawn --project --prompt "..."` to create another diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0adf908f8c..2066f679d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -939,6 +939,9 @@ importers: apps/server: dependencies: + '@bb/client-core': + specifier: workspace:* + version: link:../../packages/client-core '@bb/config': specifier: workspace:* version: link:../../packages/config @@ -1051,9 +1054,6 @@ importers: '@bb/agent-runtime': specifier: workspace:* version: link:../../packages/agent-runtime - '@bb/client-core': - specifier: workspace:* - version: link:../../packages/client-core '@bb/scripts': specifier: workspace:* version: link:../../packages/scripts From a053ab7be2fb2238780825dc43b104add6253ca1 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 04:13:52 -0400 Subject: [PATCH 2/4] Preserve current thread origin and plugin submission contracts --- apps/server/src/services/drafts/draft-submit.ts | 3 ++- apps/server/test/public/public-drafts.test.ts | 17 ++++++++++++----- packages/server-contract/src/api/drafts.ts | 6 +++++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/server/src/services/drafts/draft-submit.ts b/apps/server/src/services/drafts/draft-submit.ts index 7ee6afb67c..fce9c6739b 100644 --- a/apps/server/src/services/drafts/draft-submit.ts +++ b/apps/server/src/services/drafts/draft-submit.ts @@ -15,8 +15,8 @@ import { createThreadRequestSchema, type Draft, type DraftSubmitResponse, - type ThreadCreateOrigin, } from "@bb/server-contract"; +import type { ThreadCreateOrigin } from "@bb/domain"; import { ApiError } from "../../errors.js"; import type { AppDeps } from "../../types.js"; import { requestThreadStorageDeletion } from "../threads/thread-lifecycle.js"; @@ -127,6 +127,7 @@ async function performSubmission( sourceSeqEnd: options.sourceSeqEnd ?? undefined, originKind: options.originKind, sendAt: options.sendAt ?? undefined, + pluginSubmission: options.pluginSubmission ?? undefined, }); if (!request.success) { throw new ApiError( diff --git a/apps/server/test/public/public-drafts.test.ts b/apps/server/test/public/public-drafts.test.ts index da598eb08c..3c9f07b1ba 100644 --- a/apps/server/test/public/public-drafts.test.ts +++ b/apps/server/test/public/public-drafts.test.ts @@ -498,16 +498,23 @@ describe("durable draft resources", () => { const content = readyContent(harness); const draft = await create(harness, { ...content, - options: { ...content.options, sendAt: null }, + options: { + ...content.options, + sendAt: null, + pluginSubmission: { pluginId: "draft-test", data: { saved: true } }, + }, }); const registry: { [K in PluginHookName]: PluginHookRegistration[] } = { "message.dispatch": [ { pluginId: "draft-test", - handler: () => ({ - action: "reject", - message: "Temporarily blocked", - }), + handler: (context) => { + expect(context.experimental_submission).toEqual({ + pluginId: "draft-test", + data: { saved: true }, + }); + return { action: "reject", message: "Temporarily blocked" }; + }, }, ], }; diff --git a/packages/server-contract/src/api/drafts.ts b/packages/server-contract/src/api/drafts.ts index f7156b0cc2..f9559a82f3 100644 --- a/packages/server-contract/src/api/drafts.ts +++ b/packages/server-contract/src/api/drafts.ts @@ -1,4 +1,5 @@ import { + threadCreateOriginSchema, environmentMachineSelectionSchema, jsonValueSchema, permissionModeInputSchema, @@ -15,7 +16,7 @@ import { projectDefaultEnvironmentSchema, reuseEnvironmentSchema, } from "./shared.js"; -import { threadCreateOriginSchema } from "./threads.js"; +import { createThreadRequestSchema } from "./threads.js"; export const draftIdSchema = z.string().regex(/^drf_[A-Za-z0-9_-]{8,128}$/); @@ -54,6 +55,9 @@ export const draftOptionsSchema = z sourceSeqEnd: z.number().int().nonnegative().nullable().default(null), originKind: threadOriginKindSchema.nullable().default(null), sendAt: z.number().int().nonnegative().nullable().default(null), + pluginSubmission: createThreadRequestSchema.shape.pluginSubmission + .nullable() + .default(null), }) .strict(); export type DraftOptions = z.infer; From da92b0283e65df21da2256a36d56f8fa4bb28407 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 04:12:50 -0400 Subject: [PATCH 3/4] Update draft SDK fixture for thread lifecycle ownership --- packages/sdk/test/drafts.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/test/drafts.test.ts b/packages/sdk/test/drafts.test.ts index 18f6f4c999..91c151df9f 100644 --- a/packages/sdk/test/drafts.test.ts +++ b/packages/sdk/test/drafts.test.ts @@ -71,6 +71,7 @@ const thread: ThreadWithRuntime = { sectionId: "section_test", status: "pending", parentThreadId: "thr_parent", + lifecycleOwnerThreadId: null, sourceThreadId: null, originKind: null, originPluginId: null, From 720af27ce81921013712f1b1a3ad870eeaab220d Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 04:12:50 -0400 Subject: [PATCH 4/4] Align draft submission regressions with current thread storage --- apps/server/test/public/public-drafts.test.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/server/test/public/public-drafts.test.ts b/apps/server/test/public/public-drafts.test.ts index 3c9f07b1ba..fcaacb7175 100644 --- a/apps/server/test/public/public-drafts.test.ts +++ b/apps/server/test/public/public-drafts.test.ts @@ -3,7 +3,7 @@ import { getDraftSubmissionReceipt, getStoredDraft, getThread, - listThreads, + listThreadsWithPendingInteractionState, } from "@bb/db"; import { draftCreateResponseSchema, @@ -331,7 +331,9 @@ describe("durable draft resources", () => { expect(replay.thread.id).toBe(results[0]?.thread.id); expect(replay.draft).toBeNull(); expect( - listThreads(harness.db, { projectId: draft.content.projectId! }), + listThreadsWithPendingInteractionState(harness.db, { + projectId: draft.content.projectId!, + }), ).toHaveLength(1); expect( (await harness.app.request(`/api/v1/drafts/${draft.id}`)).status, @@ -386,7 +388,9 @@ describe("durable draft resources", () => { } expect((await submitting).status).toBe(409); expect( - listThreads(harness.db, { projectId: draft.content.projectId! }), + listThreadsWithPendingInteractionState(harness.db, { + projectId: draft.content.projectId!, + }), ).toHaveLength(0); }); }); @@ -476,9 +480,7 @@ describe("durable draft resources", () => { expectedRevision: 1, }); expect(response.status).toBe(409); - expect(getThread(harness.db, thread.id)?.deletedAt).toEqual( - expect.any(Number), - ); + expect(getThread(harness.db, thread.id)).toBeNull(); expect(getStoredDraft(harness.db, draft.id)?.payloadJson).not.toBeNull(); expect( ( @@ -488,7 +490,9 @@ describe("durable draft resources", () => { ).status, ).toBe(409); expect( - listThreads(harness.db, { projectId: draft.content.projectId! }), + listThreadsWithPendingInteractionState(harness.db, { + projectId: draft.content.projectId!, + }), ).toHaveLength(0); }); }); @@ -541,7 +545,9 @@ describe("durable draft resources", () => { setPluginEnvironmentProviderBridge(undefined); expect((await submit()).status).toBe(409); expect( - listThreads(harness.db, { projectId: draft.content.projectId! }), + listThreadsWithPendingInteractionState(harness.db, { + projectId: draft.content.projectId!, + }), ).toHaveLength(0); expect( ( @@ -559,7 +565,9 @@ describe("durable draft resources", () => { ).status, ).toBe(200); expect( - listThreads(harness.db, { projectId: draft.content.projectId! }), + listThreadsWithPendingInteractionState(harness.db, { + projectId: draft.content.projectId!, + }), ).toHaveLength(1); }); });