From 64e9963fa8d28267b6ddb00b379705bfe3063b29 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 22:52:49 -0400 Subject: [PATCH 01/11] feat: discover saved drafts through thread lifecycle filters --- .../commands/CommandPalette.test.tsx | 1 + .../app/src/hooks/cache-owners/query-cache.ts | 5 + .../thread-runtime-cache-owner.test.ts | 35 ++- .../thread-runtime-cache-owner.ts | 14 +- .../palette-thread-search.test.ts | 1 + .../command-output/thread-list.test.ts | 25 ++ .../thread-organization.test.ts | 27 ++ .../command-output/thread-spawn.test.ts | 29 ++ .../command-output/thread-tell.test.ts | 54 ++++ apps/cli/src/commands/thread/actions.ts | 21 +- apps/cli/src/commands/thread/helpers.ts | 21 ++ apps/cli/src/commands/thread/list.ts | 8 + apps/cli/src/commands/thread/organization.ts | 13 +- apps/cli/src/commands/thread/spawn.ts | 19 +- apps/cli/src/json-shapes.ts | 5 +- apps/server/src/routes/threads/base.ts | 21 ++ .../services/plugins/plugin-hook-registry.ts | 5 +- .../src/services/plugins/plugin-service.ts | 9 + .../src/services/threads/dispatch-attempt.ts | 8 +- .../src/services/threads/dispatch-hooks.ts | 58 +++- .../src/services/threads/thread-create.ts | 2 + .../threads/thread-runtime-display.ts | 27 +- .../services/threads/thread-send-request.ts | 10 +- .../test/public/public-thread-search.test.ts | 104 ++++++- .../threads/thread-runtime-display.test.ts | 62 ++++ .../test/threads/dispatch-hooks.test.ts | 286 +++++++++++++++++- .../db/src/data/queued-thread-messages.ts | 2 + packages/db/src/data/threads.ts | 67 +++- .../data/thread-discovery-lifecycle.test.ts | 190 ++++++++++++ packages/domain/src/thread.ts | 5 + packages/sdk/src/areas/threads.ts | 14 +- packages/sdk/test/sdk.test.ts | 34 +++ packages/server-contract/src/api/threads.ts | 14 + .../server-contract/test/contract.test.ts | 1 + .../src/templates/bb-guide-threads.md | 22 ++ packages/test-helpers/src/domain-fixtures.ts | 1 + .../bb-cli/references/thread-creation.md | 4 + .../bb-cli/references/thread-operation.md | 12 + 38 files changed, 1199 insertions(+), 37 deletions(-) create mode 100644 packages/db/test/data/thread-discovery-lifecycle.test.ts diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index acf02e3764d..31ff5a720f0 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -308,6 +308,7 @@ function makeThread( environmentWorkspaceDisplayKind: "other", runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, queuedWork: "none", + lifecycle: overrides.archivedAt != null ? "archived" : "active", ...overrides, }; } diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts index 7e3a40b8460..7b3cb508289 100644 --- a/apps/app/src/hooks/cache-owners/query-cache.ts +++ b/apps/app/src/hooks/cache-owners/query-cache.ts @@ -2,6 +2,7 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { Thread, ThreadListEntry, + ThreadLifecycle, ThreadStatusChangeMetadata, } from "@bb/domain"; import { @@ -572,10 +573,14 @@ function threadMatchesListFilters( export function optimisticallyInsertThread( queryClient: QueryClient, thread: ThreadResponse, + lifecycle: ThreadLifecycle = thread.archivedAt !== null + ? "archived" + : "active", ): void { const queuedWork = thread.queuedMessageCount > 0 ? "waiting" : "none"; const insertedThread: ThreadListEntry = { ...thread, + lifecycle, activity: { activeWorkflowCount: 0, activeBackgroundAgentCount: 0, diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts index 61a1584aa9b..77edad530f8 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts @@ -15,7 +15,10 @@ import { makeProjectWithThreadsResponse, makeSidebarBootstrapResponse, } from "@/test/fixtures/projects"; -import { makeThreadTimelineResponse as makeTimelineResponse } from "@/test/fixtures/thread-responses"; +import { + makeThreadResponse, + makeThreadTimelineResponse as makeTimelineResponse, +} from "@/test/fixtures/thread-responses"; import { sidebarNavigationQueryKey, threadListQueryKey, @@ -27,6 +30,7 @@ import { } from "../queries/query-keys"; import { threadDefaultExecutionOptionsQueryKey } from "../queries/thread-default-execution-options-query"; import { + applyCreateThreadResult, applyQueuedMessageCreateResult, applyQueuedMessageSendResult, applyQueuedMessageUpdateResult, @@ -112,6 +116,35 @@ function makeQueuedMessage( } describe("thread runtime cache owner", () => { + it("keeps a newly saved draft classified while the list refreshes", () => { + const queryClient = createAppQueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + showMutationErrorToasts: false, + }); + const key = threadListQueryKey({ archived: false, projectId: "project-1" }); + queryClient.setQueryData(key, []); + + applyCreateThreadResult({ + queryClient, + request: { + projectId: "project-1", + input: [{ type: "text", text: "Save for later", mentions: [] }], + environment: { type: "project-default" }, + pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } }, + }, + thread: makeThreadResponse({ + id: "thread-draft", + projectId: "project-1", + status: "pending", + queuedMessageCount: 1, + }), + }); + + expect(queryClient.getQueryData(key)).toEqual([ + expect.objectContaining({ id: "thread-draft", lifecycle: "draft" }), + ]); + }); + it.each([ ["Plan", applyThreadPlanCancellationResult, "activePlanModeCount"], ["Goal", applyThreadGoalClearResult, "activeGoalCount"], diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts index 695a69c1c41..9fe2ebb3cb3 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts @@ -890,7 +890,19 @@ export function applyCreateThreadResult({ thread, }: CreateThreadSuccessArgs): void { queryClient.setQueryData(threadQueryKey(thread.id), thread); - optimisticallyInsertThread(queryClient, thread); + const submission = request.pluginSubmission; + const savedDraft = + thread.status === "pending" && + submission?.pluginId === "drafts" && + submission.data !== null && + typeof submission.data === "object" && + !Array.isArray(submission.data) && + submission.data.kind === "draft"; + optimisticallyInsertThread( + queryClient, + thread, + savedDraft ? "draft" : "active", + ); prependProjectPromptHistory( queryClient, request.projectId, diff --git a/apps/app/src/lib/command-palette/palette-thread-search.test.ts b/apps/app/src/lib/command-palette/palette-thread-search.test.ts index 8ce19f522ec..8ddaf6be9ae 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.test.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -49,6 +49,7 @@ function makeThread( environmentWorkspaceDisplayKind: "other", runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, queuedWork: "none", + lifecycle: overrides.archivedAt != null ? "archived" : "active", ...overrides, }; } diff --git a/apps/cli/src/__tests__/command-output/thread-list.test.ts b/apps/cli/src/__tests__/command-output/thread-list.test.ts index 23557696b35..418b466756f 100644 --- a/apps/cli/src/__tests__/command-output/thread-list.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-list.test.ts @@ -16,6 +16,31 @@ describe("bb thread list command output", () => { const register: CommandRegistrar = (program) => registerThreadCommands(program, () => "http://server"); + it("passes explicit lifecycle filters without imposing the legacy archive filter", async () => { + const list = vi.fn(async () => []); + stubServerApi({ "v1.threads.$get": list }); + + await runCommand( + ["thread", "list", "--lifecycle", "draft,archived"], + register, + ); + + expect(list).toHaveBeenCalledWith({ + query: { lifecycles: "draft,archived" }, + }); + }); + + it("rejects an empty lifecycle instead of broadening the list", async () => { + const list = vi.fn(async () => []); + stubServerApi({ "v1.threads.$get": list }); + + await expect( + runCommand(["thread", "list", "--lifecycle", ""], register), + ).rejects.toThrow("process.exit:1"); + + expect(list).not.toHaveBeenCalled(); + }); + it("bb thread list supports parent-thread filtering", async () => { const list = vi.fn(async () => []); stubServerApi({ "v1.threads.$get": list }); diff --git a/apps/cli/src/__tests__/command-output/thread-organization.test.ts b/apps/cli/src/__tests__/command-output/thread-organization.test.ts index 5c2edcab641..165447871d8 100644 --- a/apps/cli/src/__tests__/command-output/thread-organization.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-organization.test.ts @@ -41,6 +41,33 @@ describe("bb thread organization commands", () => { const register: CommandRegistrar = (program) => registerThreadCommands(program, () => "http://server"); + it("forwards lifecycle filtering and the per-group limit", async () => { + const search = vi.fn(async () => ({ + active: { total: 0, results: [] }, + archived: { total: 0, results: [] }, + draft: { total: 0, results: [] }, + })); + stubServerApi({ "v1.threads.search.$get": search }); + + await runCommand( + [ + "thread", + "search", + "release", + "--lifecycle", + "draft", + "--limit", + "3", + "--json", + ], + register, + ); + + expect(search).toHaveBeenCalledWith({ + query: { query: "release", lifecycles: "draft", limitPerGroup: 3 }, + }); + }); + it("creates a named thread section", async () => { const create = vi.fn(async () => ({ id: "section-review", diff --git a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts index a4d02129287..813bce58307 100644 --- a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts @@ -27,6 +27,35 @@ describe("bb thread spawn command output", () => { return vi.spyOn(process.stderr, "write").mockImplementation(() => true); } + it("saves the first message with the shipped Drafts submission", async () => { + const post = vi.fn(async ({ json }: { json: unknown }) => { + createThreadRequestSchema.parse(json); + return fixtures.makeThread({ id: "thread-draft", status: "pending" }); + }); + stubServerApi({ "v1.threads.$post": post }); + + await runCommand( + [ + "thread", + "spawn", + "--project", + "proj-1", + "--prompt", + "Save this", + "--draft", + ], + register, + ); + + expect(post).toHaveBeenCalledWith({ + json: expect.objectContaining({ + pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } }, + input: [{ type: "text", text: "Save this", mentions: [] }], + }), + }); + expect(collectLogLines()[0]).toBe("Draft saved: thread-draft"); + }); + it("rejects explicitly empty lifecycle ownership instead of creating an independent thread", async () => { const post = vi.fn(async ({ json }: { json: unknown }) => { createThreadRequestSchema.parse(json); diff --git a/apps/cli/src/__tests__/command-output/thread-tell.test.ts b/apps/cli/src/__tests__/command-output/thread-tell.test.ts index b94badaed04..a0e7eb34b30 100644 --- a/apps/cli/src/__tests__/command-output/thread-tell.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-tell.test.ts @@ -18,6 +18,60 @@ describe("bb thread tell command output", () => { const register: CommandRegistrar = (program) => registerThreadCommands(program, () => "http://server"); + it("saves a follow-up draft without steering the active turn", async () => { + const post = vi.fn(async () => ({ + ok: true, + delivery: "queued", + queuedMessage: { + id: "qm-draft", + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, + }, + })); + stubServerApi({ "v1.threads.:id.send.$post": post }); + + await runCommand( + ["thread", "tell", "thread-draft", "Save this", "--draft"], + register, + ); + + expect(post).toHaveBeenCalledWith({ + param: { id: "thread-draft" }, + json: expect.objectContaining({ + mode: "queue-if-active", + pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } }, + }), + }); + expect(vi.mocked(console.log).mock.calls[0]?.[0]).toBe( + "Thread thread-draft draft saved; send it with bb thread queue send", + ); + }); + + it("rejects conflicting draft and steer requests before sending", async () => { + const post = vi.fn(); + stubServerApi({ "v1.threads.:id.send.$post": post }); + + await expect( + runCommand( + [ + "thread", + "tell", + "thread-draft", + "Save this", + "--draft", + "--mode", + "steer", + ], + register, + ), + ).rejects.toThrow("process.exit:1"); + + expect(post).not.toHaveBeenCalled(); + expect(vi.mocked(console.error).mock.calls[0]?.[0]).toBe( + "Error: --draft cannot be combined with --mode steer or auto.", + ); + }); + it("bb thread tell --json prints the raw response plus thread id", async () => { const post = vi.fn(async () => ({ ok: true, delivery: "sent" })); stubServerApi({ "v1.threads.:id.send.$post": post }); diff --git a/apps/cli/src/commands/thread/actions.ts b/apps/cli/src/commands/thread/actions.ts index f7a4aff90b0..e9d17133b3a 100644 --- a/apps/cli/src/commands/thread/actions.ts +++ b/apps/cli/src/commands/thread/actions.ts @@ -69,6 +69,7 @@ interface ThreadDeleteCommandOptions { } interface ThreadTellCommandOptions { + draft?: boolean; json?: boolean; messageFile?: string; model?: string; @@ -106,6 +107,7 @@ interface ThreadEditMessageCommandOptions { type ThreadTellDeliveryMode = "auto" | "queue" | "steer"; interface PostThreadMessageArgs { + draft?: boolean; getUrl: () => string; threadId: string; message: string; @@ -440,6 +442,7 @@ export function registerActionsCommands( .command("tell [message]") .aliases(["message", "send"]) .description("Send a follow-up message to a thread") + .option("--draft", "Save the message as a draft until you send it manually") .option( "--message-file ", `Read the message from a file instead of [message]; ${TEXT_FILE_HELP_SUFFIX}`, @@ -483,11 +486,18 @@ export function registerActionsCommands( inline: inlineMessage, inlineLabel: "", }); + const mode = resolveThreadMessageMode(opts.mode); + if (opts.draft && opts.mode !== undefined && mode !== "queue") { + throw new Error( + "--draft cannot be combined with --mode steer or auto.", + ); + } const response = await postThreadMessage({ getUrl, threadId: id, message, - mode: resolveThreadMessageMode(opts.mode), + mode: opts.draft ? "queue" : mode, + draft: opts.draft, model: opts.model, permissionMode: parsePermissionMode(opts.permissionMode), reasoningLevel: parseReasoningLevel(opts.reasoningLevel), @@ -618,6 +628,9 @@ async function postThreadMessage( ...(args.serviceTier ? { serviceTier: args.serviceTier } : {}), ...(args.senderThreadId ? { senderThreadId: args.senderThreadId } : {}), ...(args.sendAt === undefined ? {} : { sendAt: args.sendAt }), + ...(args.draft + ? { pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } } } + : {}), }); return { ...response, mode: args.mode }; } @@ -627,6 +640,12 @@ function describeThreadTellOutcome( response: PostThreadMessageResult, ): string { if (response.delivery === "queued") { + if ( + response.queuedMessage.waitingOn?.kind === "plugin" && + response.queuedMessage.waitingOn.pluginId === "drafts" + ) { + return `Thread ${threadId} draft saved; send it with bb thread queue send`; + } // The server says WHY it is waiting, so the CLI does not have to guess // from the flags it happened to send. `bb thread queue list` shows the // same reason for the row afterwards. diff --git a/apps/cli/src/commands/thread/helpers.ts b/apps/cli/src/commands/thread/helpers.ts index d7bbee371cc..9179b0656fd 100644 --- a/apps/cli/src/commands/thread/helpers.ts +++ b/apps/cli/src/commands/thread/helpers.ts @@ -8,6 +8,8 @@ import { type PromptInput, serviceTierSchema, type ServiceTier, + threadLifecycleSchema, + type ThreadLifecycle, } from "@bb/domain"; import { DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS, @@ -29,6 +31,25 @@ export const PERMISSION_MODE_HELP = export const PLAN_HELP = "Send the message as the provider's /plan action so the agent proposes a plan for approval before executing"; +export function parseThreadLifecycles( + value: string | undefined, +): ThreadLifecycle[] | undefined { + if (value === undefined) return undefined; + return [ + ...new Set( + value.split(",").map((entry) => { + const result = threadLifecycleSchema.safeParse(entry.trim()); + if (!result.success) { + throw new Error( + "--lifecycle must contain active, draft, or archived, separated by commas.", + ); + } + return result.data; + }), + ), + ]; +} + export function buildPromptInputs(args: { message: string; files?: readonly string[]; diff --git a/apps/cli/src/commands/thread/list.ts b/apps/cli/src/commands/thread/list.ts index eb4111bd248..ec71258b411 100644 --- a/apps/cli/src/commands/thread/list.ts +++ b/apps/cli/src/commands/thread/list.ts @@ -9,12 +9,14 @@ import { truncateCell, } from "../../table.js"; import { outputJson } from "../helpers.js"; +import { parseThreadLifecycles } from "./helpers.js"; interface ThreadListCommandOptions { environment?: string; project?: string; parentThread?: string; archived?: boolean; + lifecycle?: string; section?: string; unsectioned?: boolean; json?: boolean; @@ -34,6 +36,10 @@ export function registerListCommand( .option("--section ", "Filter by thread section ID") .option("--unsectioned", "Show only threads outside sections") .option("--archived", "Show only archived threads") + .option( + "--lifecycle ", + "Filter by active, draft, or archived (comma-separated)", + ) .option("--include-hidden", "Include hidden threads") .option("--json", "Print machine-readable JSON output") .action( @@ -58,7 +64,9 @@ export function registerListCommand( flagName: "--section", value: opts.section, }); + const lifecycles = parseThreadLifecycles(opts.lifecycle); const threads = await sdk.threads.list({ + ...(lifecycles === undefined ? {} : { lifecycles }), ...(projectId ? { projectId } : {}), ...(environmentId ? { environmentId } : {}), ...(parentThreadId ? { parentThreadId } : {}), diff --git a/apps/cli/src/commands/thread/organization.ts b/apps/cli/src/commands/thread/organization.ts index f5e53141b51..a1f7955a721 100644 --- a/apps/cli/src/commands/thread/organization.ts +++ b/apps/cli/src/commands/thread/organization.ts @@ -22,7 +22,11 @@ import { outputJson, requireThreadIdOrSelf, } from "../helpers.js"; -import { buildPromptInputs, uploadClientAttachmentInputs } from "./helpers.js"; +import { + buildPromptInputs, + parseThreadLifecycles, + uploadClientAttachmentInputs, +} from "./helpers.js"; interface JsonOptions { json?: boolean; @@ -38,6 +42,7 @@ interface SectionDeleteOptions extends JsonOptions { interface SearchOptions extends JsonOptions { limit?: string; + lifecycle?: string; } interface HistoryOptions extends JsonOptions { @@ -230,6 +235,10 @@ export function registerOrganizationCommands( parent .command("search ") .description("Search threads and messages") + .option( + "--lifecycle ", + "Filter by active, draft, or archived (comma-separated)", + ) .option( "--limit ", `Maximum results per group (1-${THREAD_SEARCH_LIMIT_PER_GROUP_MAX})`, @@ -237,8 +246,10 @@ export function registerOrganizationCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (query: string, opts: SearchOptions) => { + const lifecycles = parseThreadLifecycles(opts.lifecycle); const result = await createCliBbSdk(getUrl()).threads.search({ query, + ...(lifecycles === undefined ? {} : { lifecycles }), limitPerGroup: parsePositiveInteger( opts.limit, "--limit", diff --git a/apps/cli/src/commands/thread/spawn.ts b/apps/cli/src/commands/thread/spawn.ts index afb92217144..786dfadf2ca 100644 --- a/apps/cli/src/commands/thread/spawn.ts +++ b/apps/cli/src/commands/thread/spawn.ts @@ -73,6 +73,7 @@ interface ThreadSpawnCommandOptions { sourceSeqEnd?: string; visibility?: string; sendAt?: string; + draft?: boolean; } export function looksLikePath(value: string): boolean { @@ -392,6 +393,10 @@ export function registerSpawnCommand( "JSON value for an --environment-provider that declares inputs (`bb environment providers --json` shows the schema)", ) .option("--send-at ", SEND_AT_HELP) + .option( + "--draft", + "Save the first message as a draft until you send it manually", + ) .option("--origin-kind ", "Thread origin: fork") .option("--source-thread ", "Source thread for a fork") .option( @@ -596,14 +601,24 @@ export function registerSpawnCommand( ...(opts.sourceThread ? { sourceThreadId: opts.sourceThread } : {}), ...(sourceSeqEnd !== undefined ? { sourceSeqEnd } : {}), ...(sendAt !== undefined ? { sendAt } : {}), + ...(opts.draft + ? { + pluginSubmission: { + pluginId: "drafts", + data: { kind: "draft" }, + }, + } + : {}), }); } catch (err: unknown) { throw prependErrorContext("Failed to create thread", err); } if (outputJson(opts, thread)) return; - console.log(`Thread spawned: ${thread.id}`); - if (sendAt !== undefined) { + console.log( + `${opts.draft ? "Draft saved" : "Thread spawned"}: ${thread.id}`, + ); + if (sendAt !== undefined && !opts.draft) { console.log( `First message scheduled for ${new Date(sendAt).toLocaleString()}; the thread stays pending until then.`, ); diff --git a/apps/cli/src/json-shapes.ts b/apps/cli/src/json-shapes.ts index 2cfce1d7beb..59db40e053a 100644 --- a/apps/cli/src/json-shapes.ts +++ b/apps/cli/src/json-shapes.ts @@ -2,7 +2,7 @@ export const JSON_SHAPE_BY_COMMAND_PATH: Readonly> = { status: "{project: {id, name} | null, thread: {id, status, title, parentThreadId, environment: {hostId, display} | null} | null, childThreads: [{id, status, title}] | null, pendingTodos, pluginsNeedingAttention: [{id, status}], dataDir}", "thread list": - "[{id, projectId, environmentId, providerId, title, status, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null)", + "[{id, projectId, environmentId, providerId, title, status, lifecycle, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null)", "thread show": "{thread: {id, status, title, projectId, environmentId, parentThreadId, ...}, environment: {id, hostId, path, branchName, ...} | null, pendingTodos} (thread fields are under .thread)", "thread log": @@ -11,7 +11,8 @@ export const JSON_SHAPE_BY_COMMAND_PATH: Readonly> = { "thread spawn": "the created thread: {id, status, title, projectId, environmentId, ...}", "thread wait": "{threadId, matched: true, target}", - "thread search": "{active: {total, results}, archived: {total, results}}", + "thread search": + "{active: {total, results}, archived: {total, results}, draft?: {total, results}} (draft group present with --lifecycle)", "project list": "[{id, kind, name, gitRemoteUrl, sources: [{id, hostId, path, isDefault}]}] (bare array)", "machine list": diff --git a/apps/server/src/routes/threads/base.ts b/apps/server/src/routes/threads/base.ts index e26770630a7..999e7f404b0 100644 --- a/apps/server/src/routes/threads/base.ts +++ b/apps/server/src/routes/threads/base.ts @@ -17,6 +17,7 @@ import { type UpdateThreadInput, } from "@bb/db"; import type { Environment, Thread, ThreadListEntry } from "@bb/domain"; +import { threadLifecycleSchema } from "@bb/domain"; import { toEnvironmentResponse } from "../../services/environments/environment-response.js"; import { threadIncludeOptionSchema, @@ -85,6 +86,7 @@ interface BuildThreadSearchGroupResponseArgs { interface BuildThreadSearchResponseArgs { active: DbThreadSearchResultGroup; + draft?: DbThreadSearchResultGroup; archived: DbThreadSearchResultGroup; } @@ -204,6 +206,11 @@ function buildThreadSearchResponse( ): ThreadSearchResponse { return { active: buildThreadSearchGroupResponse(deps, { group: args.active }), + ...(args.draft === undefined + ? {} + : { + draft: buildThreadSearchGroupResponse(deps, { group: args.draft }), + }), archived: buildThreadSearchGroupResponse(deps, { group: args.archived }), }; } @@ -272,6 +279,13 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { requireThreadSection(deps, query.sectionId); } const threads = listThreadsWithPendingInteractionState(deps.db, { + ...(query.lifecycles === undefined + ? {} + : { + lifecycles: query.lifecycles + .split(",") + .map((value) => threadLifecycleSchema.parse(value)), + }), ...(query.projectId ? { projectId: query.projectId } : {}), ...(query.environmentId ? { environmentId: query.environmentId } : {}), ...(query.parentThreadId ? { parentThreadId: query.parentThreadId } : {}), @@ -308,6 +322,13 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { ...searchThreadsWithPendingInteractionState(deps.db, { query: searchQuery, limitPerGroup, + ...(query.lifecycles === undefined + ? {} + : { + lifecycles: query.lifecycles + .split(",") + .map((value) => threadLifecycleSchema.parse(value)), + }), }), }) satisfies ThreadSearchResponse, ); diff --git a/apps/server/src/services/plugins/plugin-hook-registry.ts b/apps/server/src/services/plugins/plugin-hook-registry.ts index 12e62a32718..7831745f53c 100644 --- a/apps/server/src/services/plugins/plugin-hook-registry.ts +++ b/apps/server/src/services/plugins/plugin-hook-registry.ts @@ -52,6 +52,7 @@ export async function invokeBridgedProvider( * seams — and because it is the seam a test substitutes fake handlers through. */ export interface PluginHookProvider { + isPluginRunning?(pluginId: string): boolean; /** Registered handlers for a hook, in plugin install order. */ listHooks(hook: K): PluginHookRegistration[]; /** @@ -77,7 +78,9 @@ export interface PluginHookProvider { */ let provider: PluginHookProvider | undefined; -export function setPluginHookProvider(next: PluginHookProvider | undefined): void { +export function setPluginHookProvider( + next: PluginHookProvider | undefined, +): void { provider = next; } diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index f4f905949de..0b65a696c4a 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -1266,6 +1266,15 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { }, hooks: { + isPluginRunning(pluginId) { + const row = getInstalledPlugin(deps.db, pluginId); + return ( + row !== undefined && + row.enabled && + loaded.has(pluginId) && + getStatus(row).status === "running" + ); + }, listHooks: listPluginHooks, invokeHook: invokeIsolated, decisionTimeoutMs: DEFAULT_PLUGIN_HOOK_TIMEOUT_MS, diff --git a/apps/server/src/services/threads/dispatch-attempt.ts b/apps/server/src/services/threads/dispatch-attempt.ts index 6b62d8380b1..27ffeb04dd2 100644 --- a/apps/server/src/services/threads/dispatch-attempt.ts +++ b/apps/server/src/services/threads/dispatch-attempt.ts @@ -43,6 +43,8 @@ import { dispatchExecutionSources, dispatchWaitReasonForPass, hasMessageDispatchHooks, + isDraftSubmission, + requireDraftSubmissionAvailable, noteDispatchRequeued, runMessageDispatchHookPass, type DispatchAttemptKind, @@ -293,6 +295,7 @@ async function runDispatchAttempt( reattempted: boolean, ): Promise { const { payload, thread } = args; + requireDraftSubmissionAvailable(args.pluginSubmission); // A stopping thread is writable HERE and nowhere upstream: the checkpoint // below turns it into a core wait, which is a truthful "not yet" the row can // recover from, rather than the 409 that used to make a stop a dead end for @@ -505,7 +508,10 @@ async function runDispatchAttempt( } }; - if (!sendNow && hasMessageDispatchHooks()) { + if ( + !sendNow && + (isDraftSubmission(args.pluginSubmission) || hasMessageDispatchHooks()) + ) { const outcome = await runMessageDispatchHookPass(deps, { thread, threadResponse: toThreadResponseFromThread(deps, { thread }), diff --git a/apps/server/src/services/threads/dispatch-hooks.ts b/apps/server/src/services/threads/dispatch-hooks.ts index 554d9fe1999..e1a7fe457dd 100644 --- a/apps/server/src/services/threads/dispatch-hooks.ts +++ b/apps/server/src/services/threads/dispatch-hooks.ts @@ -166,6 +166,38 @@ export function hasMessageDispatchHooks(): boolean { ); } +export function isDraftSubmission( + submission: MessageDispatchHookContext["experimental_submission"] | undefined, +): boolean { + return ( + submission?.pluginId === "drafts" && + submission.data !== null && + typeof submission.data === "object" && + !Array.isArray(submission.data) && + submission.data.kind === "draft" + ); +} + +export function requireDraftSubmissionAvailable( + submission: MessageDispatchHookContext["experimental_submission"] | undefined, +): void { + if (!isDraftSubmission(submission)) return; + const provider = pluginHookProvider(); + if ( + provider?.isPluginRunning?.("drafts") !== true || + !provider + .listHooks("message.dispatch") + .some((hook) => hook.pluginId === "drafts") + ) { + throw new ApiError( + 409, + "drafts_unavailable", + "Drafts must be installed, enabled, and running to save a draft.", + { details: { pluginId: "drafts" } }, + ); + } +} + /** * Server-wide evaluation lock. * @@ -393,6 +425,7 @@ export async function runMessageDispatchHookPass( deps: DispatchHookDeps, request: MessageDispatchHookPassRequest, ): Promise { + requireDraftSubmissionAvailable(request.pluginSubmission); const provider = pluginHookProvider(); if (provider === undefined) { return { kind: "proceed" }; @@ -403,6 +436,7 @@ export async function runMessageDispatchHookPass( } return withEvaluationLock(async () => { + requireDraftSubmissionAvailable(request.pluginSubmission); const context = buildHookContext(deps, request); const waits: MessageDispatchWaitDecision[] = []; @@ -447,12 +481,32 @@ export async function runMessageDispatchHookPass( } } - const waiter = waits[0]; + const draftWait = waits.find((wait) => wait.pluginId === "drafts"); + if ( + isDraftSubmission(request.pluginSubmission) && + (draftWait === undefined || draftWait.sendAt !== null) + ) { + throw messageDispatchHookFailure( + "drafts", + "did not hold the draft for manual dispatch", + ); + } + const firstQueuedWait = request.queuedMessages[0]?.waitingOn; + const preserveDraftHold = + isDraftSubmission(request.pluginSubmission) || + (firstQueuedWait?.kind === "plugin" && + firstQueuedWait.pluginId === "drafts"); + const waiter = + preserveDraftHold && draftWait !== undefined ? draftWait : waits[0]; if (waiter === undefined) { await request.continueAfterHooks?.(); return { kind: "proceed" }; } - return { kind: "wait", waiter, additionalWaiters: waits.slice(1) }; + return { + kind: "wait", + waiter, + additionalWaiters: waits.filter((wait) => wait !== waiter), + }; }); } diff --git a/apps/server/src/services/threads/thread-create.ts b/apps/server/src/services/threads/thread-create.ts index 0914d5b04a3..0e3052714e4 100644 --- a/apps/server/src/services/threads/thread-create.ts +++ b/apps/server/src/services/threads/thread-create.ts @@ -18,6 +18,7 @@ import type { LoggedPendingInteractionWorkSessionDeps, } from "../../types.js"; import { ApiError } from "../../errors.js"; +import { requireDraftSubmissionAvailable } from "./dispatch-hooks.js"; import { ensureHostSessionReadyForWork } from "../hosts/host-lifecycle.js"; import { buildExecutionOptions } from "./thread-commands.js"; import { @@ -525,6 +526,7 @@ export async function createThreadFromRequest( forkSourceEnvironmentId?: string; } = {}, ) { + requireDraftSubmissionAvailable(rawRequestInput.pluginSubmission); const project = requirePublicProjectForThreadCreate( deps, rawRequestInput.projectId, diff --git a/apps/server/src/services/threads/thread-runtime-display.ts b/apps/server/src/services/threads/thread-runtime-display.ts index 4dc73f35993..b51372589f1 100644 --- a/apps/server/src/services/threads/thread-runtime-display.ts +++ b/apps/server/src/services/threads/thread-runtime-display.ts @@ -19,6 +19,7 @@ import type { ThreadActivityState, ThreadChangeMetadata, ThreadListEntry, + ThreadLifecycle, ThreadQueuedWork, ThreadRuntimeState, ThreadStatus, @@ -87,6 +88,7 @@ interface ToThreadListEntryResponseFromLatestSessionArgs { latestSession: HostDaemonSessionRow | null; now?: number; queuedWork: ThreadQueuedWork; + lifecycle: ThreadLifecycle; thread: ThreadWithPendingInteractionState; } @@ -522,16 +524,19 @@ function buildThreadActivityStateByThreadId( function buildThreadQueuedWorkByThreadId( deps: ThreadRuntimeDisplayDeps, threads: readonly Thread[], -): Map { - const result = new Map(); +): Map { + const result = new Map< + string, + { queuedWork: ThreadQueuedWork; hasDraft: boolean } + >(); for (const counts of listQueuedThreadMessageCountsByThreadIds(deps.db, { threadIds: threads.map((thread) => thread.id), })) { if (counts.queuedMessageCount === 0) continue; - result.set( - counts.threadId, - counts.failedQueuedMessageCount > 0 ? "failed" : "waiting", - ); + result.set(counts.threadId, { + queuedWork: counts.failedQueuedMessageCount > 0 ? "failed" : "waiting", + hasDraft: counts.draftQueuedMessageCount > 0, + }); } return result; } @@ -568,9 +573,16 @@ export function toThreadListEntryResponses( args.threads, ); return args.threads.map((thread) => { + const queue = queuedWorkByThreadId.get(thread.id); return toThreadListEntryResponseFromLatestSession({ activity: activityByThreadId.get(thread.id) ?? EMPTY_THREAD_ACTIVITY, - queuedWork: queuedWorkByThreadId.get(thread.id) ?? "none", + queuedWork: queue?.queuedWork ?? "none", + lifecycle: + thread.archivedAt !== null + ? "archived" + : thread.status === "pending" && queue?.hasDraft === true + ? "draft" + : "active", hostConnected: thread.environmentHostId !== null && connectedActiveHostIds.has(thread.environmentHostId), @@ -592,6 +604,7 @@ function toThreadListEntryResponseFromLatestSession( ...thread, activity: args.activity, queuedWork: args.queuedWork, + lifecycle: args.lifecycle, pinSortKey: args.thread.pinSortKey, environmentBranchName: args.thread.environmentBranchName, environmentHostId: args.thread.environmentHostId, diff --git a/apps/server/src/services/threads/thread-send-request.ts b/apps/server/src/services/threads/thread-send-request.ts index 132257c3c4f..332a73f980d 100644 --- a/apps/server/src/services/threads/thread-send-request.ts +++ b/apps/server/src/services/threads/thread-send-request.ts @@ -5,6 +5,10 @@ import type { } from "@bb/server-contract"; import type { LoggedPendingInteractionWorkSessionDeps } from "../../types.js"; import { attemptDispatch } from "./dispatch-attempt.js"; +import { + isDraftSubmission, + requireDraftSubmissionAvailable, +} from "./dispatch-hooks.js"; import { requireThreadCommandEnvironment } from "./thread-command-environment.js"; import { sendThreadMessage } from "./thread-send.js"; @@ -17,7 +21,11 @@ export async function acceptThreadSendRequest( deps: LoggedPendingInteractionWorkSessionDeps, args: AcceptThreadSendRequestArgs, ): Promise { - if (isStandaloneBuiltinClearCommand(args.payload.input)) { + requireDraftSubmissionAvailable(args.payload.pluginSubmission); + if ( + !isDraftSubmission(args.payload.pluginSubmission) && + isStandaloneBuiltinClearCommand(args.payload.input) + ) { const environment = await requireThreadCommandEnvironment(deps, { thread: args.thread, }); diff --git a/apps/server/test/public/public-thread-search.test.ts b/apps/server/test/public/public-thread-search.test.ts index 63f425d13cb..09f9a508dd3 100644 --- a/apps/server/test/public/public-thread-search.test.ts +++ b/apps/server/test/public/public-thread-search.test.ts @@ -1,5 +1,8 @@ -import { archiveThread } from "@bb/db"; -import { threadSearchResponseSchema } from "@bb/server-contract"; +import { archiveThread, createQueuedThreadMessage } from "@bb/db"; +import { + threadListResponseSchema, + threadSearchResponseSchema, +} from "@bb/server-contract"; import { describe, expect, it } from "vitest"; import { readJson } from "../helpers/json.js"; import { @@ -84,4 +87,101 @@ describe("public thread search route", () => { expect(badLimitResponse.status).toBe(400); }); }); + + it("opts into lifecycle list filters and a separate draft search group", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const draft = seedThread(harness.deps, { + projectId: project.id, + status: "pending", + title: "lifecycleroute draft", + }); + const active = seedThread(harness.deps, { + projectId: project.id, + title: "lifecycleroute active", + }); + const archived = seedThread(harness.deps, { + projectId: project.id, + title: "lifecycleroute archived", + }); + const hidden = seedThread(harness.deps, { + projectId: project.id, + status: "pending", + title: "lifecycleroute hidden", + visibility: "hidden", + }); + for (const thread of [draft, hidden]) { + createQueuedThreadMessage(harness.db, harness.deps.hub, { + threadId: thread.id, + content: [{ type: "text", text: "Saved draft", mentions: [] }], + model: "gpt-5", + reasoningLevel: "medium", + permissionMode: "full", + serviceTier: "default", + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, + payload: { kind: "inline" }, + systemNotice: null, + }); + } + archiveThread(harness.db, harness.deps.hub, archived.id); + const legacyResponse = await harness.app.request( + "/api/v1/threads/search?query=lifecycleroute", + ); + const legacy = threadSearchResponseSchema.parse( + await readJson(legacyResponse), + ); + expect(Object.keys(legacy)).toEqual(["active", "archived"]); + expect( + new Set(legacy.active.results.map((result) => result.thread.id)), + ).toEqual(new Set([draft.id, active.id])); + const response = await harness.app.request( + "/api/v1/threads/search?query=lifecycleroute&lifecycles=draft,archived", + ); + const body = threadSearchResponseSchema.parse(await readJson(response)); + expect(response.status).toBe(200); + expect(body.active).toEqual({ total: 0, results: [] }); + expect( + body.draft?.results.map((result) => [ + result.thread.id, + result.thread.lifecycle, + ]), + ).toEqual([[draft.id, "draft"]]); + expect(body.archived.results.map((result) => result.thread.id)).toEqual([ + archived.id, + ]); + const listResponse = await harness.app.request( + `/api/v1/threads?projectId=${project.id}&lifecycles=draft&limit=1`, + ); + expect(listResponse.status).toBe(200); + expect( + threadListResponseSchema + .parse(await readJson(listResponse)) + .map((thread) => thread.id), + ).toEqual([draft.id]); + const intersection = await harness.app.request( + `/api/v1/threads?projectId=${project.id}&lifecycles=draft&archived=true`, + ); + expect(await readJson(intersection)).toEqual([]); + for (const lifecycles of ["", "unknown", "draft,", "draft,unknown"]) { + expect( + ( + await harness.app.request( + `/api/v1/threads?lifecycles=${lifecycles}`, + ) + ).status, + ).toBe(400); + expect( + ( + await harness.app.request( + `/api/v1/threads/search?query=lifecycleroute&lifecycles=${lifecycles}`, + ) + ).status, + ).toBe(400); + } + }); + }); }); diff --git a/apps/server/test/services/threads/thread-runtime-display.test.ts b/apps/server/test/services/threads/thread-runtime-display.test.ts index 2deaaf650c0..5f2137aeed6 100644 --- a/apps/server/test/services/threads/thread-runtime-display.test.ts +++ b/apps/server/test/services/threads/thread-runtime-display.test.ts @@ -3,6 +3,8 @@ import { eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { appendStoredThreadEvent, + archiveThread, + claimQueuedThreadMessage, closeSession, createConnection, createEnvironment, @@ -519,6 +521,66 @@ describe("thread runtime display", () => { ).toHaveLength(32_767); }); + it("projects only unarchived pending threads with live Drafts holds as drafts", () => { + const { db, hostId, hub } = setup(); + const saved = createThreadWithEnvironment({ + db, + hostId, + status: "pending", + }); + const followup = createThreadWithEnvironment({ + db, + hostId, + status: "idle", + }); + const archived = createThreadWithEnvironment({ + db, + hostId, + status: "pending", + }); + const claimed = createThreadWithEnvironment({ + db, + hostId, + status: "pending", + }); + const pending = createThreadWithEnvironment({ + db, + hostId, + status: "pending", + }); + for (const fixture of [saved, followup, archived, claimed]) { + const queued = createQueuedThreadMessage(db, noopNotifier, { + threadId: fixture.thread.id, + content: [{ type: "text", text: "Saved draft", mentions: [] }], + model: "gpt-5", + reasoningLevel: "medium", + permissionMode: "auto", + serviceTier: "default", + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, + payload: { kind: "inline" }, + systemNotice: null, + }); + if (fixture === claimed) + claimQueuedThreadMessage(db, noopNotifier, queued.id); + } + archiveThread(db, noopNotifier, archived.thread.id); + const entries = toThreadListEntryResponses( + { db, hub, providerRegistry }, + { + threads: listThreadsWithPendingInteractionState(db, {}), + }, + ); + const byId = new Map(entries.map((entry) => [entry.id, entry])); + expect( + [saved, followup, archived, claimed, pending].map( + (fixture) => byId.get(fixture.thread.id)?.lifecycle, + ), + ).toEqual(["draft", "active", "archived", "active", "active"]); + expect(byId.get(saved.thread.id)?.queuedWork).toBe("waiting"); + expect(byId.get(claimed.thread.id)?.queuedWork).toBe("none"); + }); + it("marks list entries active when the prompt banner would show plan or goal state", () => { const { db, hostId, hub } = setup(); const activePlan = createThreadWithEnvironment({ db, hostId }); diff --git a/apps/server/test/threads/dispatch-hooks.test.ts b/apps/server/test/threads/dispatch-hooks.test.ts index 9953b894e97..c15e1f51991 100644 --- a/apps/server/test/threads/dispatch-hooks.test.ts +++ b/apps/server/test/threads/dispatch-hooks.test.ts @@ -1,10 +1,13 @@ import { createQueuedThreadMessage, + environments, + threads, getThread, listEvents, listQueuedThreadMessages, listQueuedThreadMessagesForApi, listRunningThreads, + listThreadsWithPendingInteractionState, setQueuedThreadMessageGroupBoundary, } from "@bb/db"; import type { ThreadQueuedMessage } from "@bb/domain"; @@ -72,9 +75,12 @@ function emptyRegistry(): HookRegistry { */ function installHooks( registry: HookRegistry, - options: { decisionTimeoutMs?: number } = {}, + options: { decisionTimeoutMs?: number; running?: boolean } = {}, ): void { setPluginHookProvider({ + isPluginRunning: (pluginId) => + options.running ?? + registry["message.dispatch"].some((hook) => hook.pluginId === pluginId), listHooks: (hook) => registry[hook], // Mirrors the plugin service's failure isolation: a throw is reported, not // propagated, and the runner is what turns it into a failed dispatch. @@ -197,6 +203,256 @@ async function expectApiError(run: () => Promise): Promise { throw new Error("expected the operation to fail"); } +describe("built-in Drafts save-only admission", () => { + const pluginSubmission = { pluginId: "drafts", data: { kind: "draft" } }; + + it.each(["missing", "disabled", "failed", "missing-handler"] as const)( + "rejects %s Drafts before creating a thread, environment, or turn", + async (state) => { + await withTestHarness(async (harness) => { + const hostId = `host-drafts-${state}`; + const { project, thread } = seedRunnableThread(harness, { + hostId, + status: "active", + }); + if (state === "missing") { + setPluginHookProvider(undefined); + } else { + installHooks( + { + "message.dispatch": + state === "missing-handler" + ? [] + : [ + { + pluginId: "drafts", + handler: () => ({ action: "wait", reason: "Draft" }), + }, + ], + }, + { running: state === "missing-handler" }, + ); + } + const beforeThreads = harness.db.select().from(threads).all().length; + const beforeEnvironments = harness.db + .select() + .from(environments) + .all().length; + const beforeTurns = turnRequests(harness, thread.id).length; + const createError = await expectApiError(() => + createThreadFromRequest(harness.deps, { + projectId: project.id, + providerId: "codex", + environment: { + type: "host", + hostId, + workspace: { + type: "unmanaged", + path: "/tmp/drafts-new-environment", + }, + }, + origin: "sdk", + startedOnBehalfOf: null, + input: textInput("save only"), + pluginSubmission, + }), + ); + expect(createError.body.code).toBe("drafts_unavailable"); + const sendError = await expectApiError(() => + acceptThreadSendRequest(harness.deps, { + thread, + payload: { + input: textInput("/clear"), + mode: "steer-if-active", + pluginSubmission, + }, + }), + ); + expect(sendError.body.code).toBe("drafts_unavailable"); + expect(harness.db.select().from(threads).all()).toHaveLength( + beforeThreads, + ); + expect(harness.db.select().from(environments).all()).toHaveLength( + beforeEnvironments, + ); + expect(turnRequests(harness, thread.id)).toHaveLength(beforeTurns); + expect(queuedRows(harness, thread.id)).toEqual([]); + }); + }, + ); + + it("keeps active steering, a future schedule, and /clear behind the Drafts hold", async () => { + await withTestHarness(async (harness) => { + const { thread } = seedRunnableThread(harness, { + hostId: "host-save-only-steer", + status: "active", + }); + installHooks({ + "message.dispatch": [ + { + pluginId: "drafts", + handler: () => ({ action: "wait", reason: "Draft" }), + }, + ], + }); + const beforeTurns = turnRequests(harness, thread.id).length; + const response = await acceptThreadSendRequest(harness.deps, { + thread, + payload: { + input: textInput("/clear"), + mode: "steer-if-active", + sendAt: Date.now() + 60_000, + pluginSubmission, + }, + }); + expect(response.delivery).toBe("queued"); + expect(onlyQueuedRow(harness, thread.id)).toMatchObject({ + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, + }); + expect(turnRequests(harness, thread.id)).toHaveLength(beforeTurns); + expect(getThread(harness.db, thread.id)?.status).toBe("active"); + }); + }); + + it.each(["throws", "proceeds", "schedules"] as const)( + "fails closed when Drafts %s", + async (behavior) => { + await withTestHarness(async (harness) => { + const { host, project } = seedDispatchFixture( + harness, + `host-draft-${behavior}`, + ); + installHooks({ + "message.dispatch": [ + { + pluginId: "drafts", + handler: () => { + if (behavior === "throws") throw new Error("Drafts failed"); + return behavior === "proceeds" + ? { action: "proceed" } + : { + action: "wait", + reason: "Draft", + sendAt: Date.now() + 60_000, + }; + }, + }, + ], + }); + const beforeEnvironments = harness.db + .select() + .from(environments) + .all().length; + const error = await expectApiError(() => + createThreadFromRequest(harness.deps, { + projectId: project.id, + providerId: "codex", + environment: { + type: "host", + hostId: host.id, + workspace: { + type: "unmanaged", + path: "/tmp/drafts-failure-environment", + }, + }, + origin: "sdk", + startedOnBehalfOf: null, + input: textInput("save only"), + pluginSubmission, + }), + ); + expect(error.body.code).toBe("dispatch_hook_failed"); + expect(harness.db.select().from(environments).all()).toHaveLength( + beforeEnvironments, + ); + for (const row of harness.db.select().from(threads).all()) { + expect(turnRequests(harness, row.id)).toEqual([]); + } + }); + }, + ); + + it("keeps the durable Drafts owner when another plugin also waits", async () => { + await withTestHarness(async (harness) => { + const { thread } = seedRunnableThread(harness, { + hostId: "host-drafts-owner", + status: "idle", + }); + installHooks({ + "message.dispatch": [ + { + pluginId: "capacity", + handler: () => ({ action: "wait", reason: "Busy" }), + }, + { + pluginId: "drafts", + handler: (context) => { + const wait = context.queuedMessages[0]?.waitingOn; + return context.experimental_submission?.pluginId === "drafts" || + (wait?.kind === "plugin" && wait.pluginId === "drafts") + ? { action: "wait", reason: "Draft" } + : { action: "proceed" }; + }, + }, + ], + }); + const beforeTurns = turnRequests(harness, thread.id).length; + await acceptThreadSendRequest(harness.deps, { + thread, + payload: { + input: textInput("save only"), + mode: "auto", + pluginSubmission, + }, + }); + const saved = onlyQueuedRow(harness, thread.id); + expect(saved.waitingOn).toEqual({ + kind: "plugin", + pluginId: "drafts", + reason: "Draft (also waiting on capacity: Busy)", + }); + await runQueuedMessageDispatch(harness.deps, { kind: "plugin-recheck" }); + expect(onlyQueuedRow(harness, thread.id)).toMatchObject({ + id: saved.id, + waitingOn: { kind: "plugin", pluginId: "drafts" }, + }); + expect(turnRequests(harness, thread.id)).toHaveLength(beforeTurns); + }); + }); + + it.each([ + { pluginId: "other", data: { kind: "draft" } }, + { pluginId: "drafts", data: { kind: "other" } }, + { pluginId: "drafts", data: null }, + { pluginId: "drafts", data: [{ kind: "draft" }] }, + ])( + "preserves opaque submission behavior for $pluginId / $data", + async (opaqueSubmission) => { + await withTestHarness(async (harness) => { + const { thread } = seedRunnableThread(harness, { + hostId: "host-opaque-draft", + status: "idle", + }); + setPluginHookProvider(undefined); + const response = await acceptThreadSendRequest(harness.deps, { + thread, + payload: { + input: textInput("scheduled opaque request"), + mode: "auto", + sendAt: Date.now() + 60_000, + pluginSubmission: opaqueSubmission, + }, + }); + expect(response.delivery).toBe("queued"); + expect(onlyQueuedRow(harness, thread.id).waitingOn).toEqual({ + kind: "time", + }); + }); + }, + ); +}); + describe("message.dispatch hook context", () => { it("passes plugin submission data through a new thread's first dispatch", async () => { await withTestHarness(async (harness) => { @@ -207,7 +463,7 @@ describe("message.dispatch hook context", () => { pluginId: "drafts", handler: (context) => { seen.push(context.experimental_submission); - return { action: "proceed" }; + return { action: "wait", reason: "Draft" }; }, }, ], @@ -221,7 +477,7 @@ describe("message.dispatch hook context", () => { data: { kind: "draft" }, }; - await createThreadFromRequest(harness.deps, { + const created = await createThreadFromRequest(harness.deps, { environment: { type: "host", hostId: host.id, @@ -236,6 +492,30 @@ describe("message.dispatch hook context", () => { }); expect(seen).toEqual([pluginSubmission]); + expect( + listThreadsWithPendingInteractionState(harness.db, { + lifecycles: ["draft"], + }).map((thread) => thread.id), + ).toEqual([created.id]); + const saved = onlyQueuedRow(harness, created.id); + await sendQueuedMessage(harness.deps, { + threadId: created.id, + queuedMessageId: saved.id, + mode: "auto", + claimPolicy: { kind: "explicit-send" }, + }); + expect(seen).toEqual([pluginSubmission]); + expect( + listThreadsWithPendingInteractionState(harness.db, { + lifecycles: ["draft"], + }), + ).toEqual([]); + expect( + listThreadsWithPendingInteractionState(harness.db, { + lifecycles: ["active"], + }).map((thread) => thread.id), + ).toContain(created.id); + expect(queuedRows(harness, created.id)).toEqual([]); }); }); diff --git a/packages/db/src/data/queued-thread-messages.ts b/packages/db/src/data/queued-thread-messages.ts index 79a029c6a60..c76e004e1d1 100644 --- a/packages/db/src/data/queued-thread-messages.ts +++ b/packages/db/src/data/queued-thread-messages.ts @@ -1572,6 +1572,7 @@ export function listQueuedThreadMessagesForApi( export interface QueuedThreadMessageCounts { threadId: string; queuedMessageCount: number; + draftQueuedMessageCount: number; /** * How many of those rows last failed to dispatch. Counted in the same pass * as the total because both answers come from the same rows, and the thread @@ -1605,6 +1606,7 @@ export function listQueuedThreadMessageCountsByThreadIds( threadId: queuedThreadMessages.threadId, queuedMessageCount: count(queuedThreadMessages.id), failedQueuedMessageCount: count(queuedThreadMessages.failureReason), + draftQueuedMessageCount: sql`count(CASE WHEN ${queuedThreadMessages.waitHolder} = 'plugin:drafts' THEN 1 END)`.mapWith(Number), }) .from(queuedThreadMessages) .where( diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index 9375627e1bb..7aedd70b59a 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -14,12 +14,14 @@ import { or, sql, type SQL, + type SQLWrapper, } from "drizzle-orm"; import type { JsonObject, ReasoningLevel, ThreadChangeKind, ThreadLifecycleEvent, + ThreadLifecycle, ThreadLifecycleNoopReason, ThreadOriginKind, ThreadSearchSourceKind, @@ -106,12 +108,14 @@ export interface ThreadSearchResultGroup { export interface ThreadSearchResults { active: ThreadSearchResultGroup; + draft?: ThreadSearchResultGroup; archived: ThreadSearchResultGroup; } export interface SearchThreadsWithPendingInteractionStateArgs { query: string; limitPerGroup: number; + lifecycles?: readonly ThreadLifecycle[]; } export interface UpsertThreadTitleSearchSegmentsArgs { @@ -143,10 +147,11 @@ interface ListThreadSearchMatchRowsArgs { anyTokenMatchQuery: string; limitPerGroup: number; tokenMatchQueries: readonly string[]; + lifecycles?: readonly ThreadLifecycle[]; } interface ThreadSearchMatchRow { - archived: number; + lifecycle: ThreadLifecycle; segmentOrder: number; sourceKind: string; sourceSeq: number | null; @@ -413,6 +418,7 @@ export interface ListThreadsOptions { projectId?: string; environmentId?: string; archived?: boolean; + lifecycles?: readonly ThreadLifecycle[]; sectionId?: string; unsectioned?: boolean; parentThreadId?: string; @@ -676,8 +682,27 @@ function statusTransitionNeedsAttention(args: StatusTransition): boolean { return args.currentStatus === "active" || args.currentStatus === "starting"; } +function threadLifecycleSql(thread: { + id: SQLWrapper; + archivedAt: SQLWrapper; + status: SQLWrapper; +} = threads): SQL { + return sql`CASE + WHEN ${thread.archivedAt} IS NOT NULL THEN 'archived' + WHEN ${thread.status} = 'pending' AND ${thread.id} IN ( + SELECT thread_id FROM queued_thread_messages + WHERE wait_holder = 'plugin:drafts' + AND claimed_at IS NULL AND claim_token IS NULL + ) THEN 'draft' + ELSE 'active' + END`; +} + function buildListThreadsFilters(options: ListThreadsOptions) { return [ + options.lifecycles === undefined + ? undefined + : inArray(threadLifecycleSql(), [...options.lifecycles]), options.projectId ? eq(threads.projectId, options.projectId) : undefined, options.environmentId ? eq(threads.environmentId, options.environmentId) @@ -993,6 +1018,17 @@ function listThreadSearchMatchRows( ); const isTitleSegment = sql`thread_search_segments.source_kind IN ('title', 'title_fallback')`; + const lifecycle = args.lifecycles === undefined + ? sql`CASE WHEN t.archived_at IS NOT NULL THEN 'archived' ELSE 'active' END` + : threadLifecycleSql({ + id: sql`t.id`, + archivedAt: sql`t.archived_at`, + status: sql`t.status`, + }); + const lifecycleFilter = args.lifecycles === undefined + ? sql`1 = 1` + : inArray(sql`lifecycle`, [...args.lifecycles]); + return db.all(sql` WITH token_matches AS ( ${sql.join(tokenMatchSelects, sql` UNION ALL `)} @@ -1002,7 +1038,7 @@ function listThreadSearchMatchRows( token_matches.threadId AS threadId, MIN(token_matches.tokenRank) AS bestRank, MAX(t.updated_at) AS threadUpdatedAt, - MAX(t.archived_at IS NOT NULL) AS archived + ${lifecycle} AS lifecycle FROM token_matches JOIN threads AS t ON t.id = token_matches.threadId WHERE t.deleted_at IS NULL @@ -1013,22 +1049,23 @@ function listThreadSearchMatchRows( ordered_threads AS ( SELECT threadId, - archived, + lifecycle, ROW_NUMBER() OVER ( - PARTITION BY archived + PARTITION BY lifecycle ORDER BY bestRank ASC, threadUpdatedAt DESC, threadId DESC ) AS threadOrder, - COUNT(*) OVER (PARTITION BY archived) AS total + COUNT(*) OVER (PARTITION BY lifecycle) AS total FROM ranked_threads + WHERE ${lifecycleFilter} ), limited_threads AS ( - SELECT threadId, archived, threadOrder, total + SELECT threadId, lifecycle, threadOrder, total FROM ordered_threads WHERE threadOrder <= ${args.limitPerGroup} ), ranked_segments AS ( SELECT - limited_threads.archived AS archived, + limited_threads.lifecycle AS lifecycle, limited_threads.threadOrder AS threadOrder, limited_threads.total AS total, ROW_NUMBER() OVER ( @@ -1051,7 +1088,7 @@ function listThreadSearchMatchRows( WHERE thread_search_segments_fts MATCH ${args.anyTokenMatchQuery} ) SELECT - archived, + lifecycle, threadOrder, total, segmentOrder, @@ -1063,7 +1100,7 @@ function listThreadSearchMatchRows( FROM ranked_segments WHERE isTitle = 1 OR segmentOrder <= ${THREAD_SEARCH_MESSAGE_MATCHES_PER_THREAD} - ORDER BY archived ASC, threadOrder ASC, isTitle DESC, segmentOrder ASC + ORDER BY lifecycle ASC, threadOrder ASC, isTitle DESC, segmentOrder ASC `); } @@ -1136,6 +1173,7 @@ export function searchThreadsWithPendingInteractionState( if (anyTokenMatchQuery === null) { return { active: { total: 0, results: [] }, + ...(args.lifecycles === undefined ? {} : { draft: { total: 0, results: [] } }), archived: { total: 0, results: [] }, }; } @@ -1148,16 +1186,23 @@ export function searchThreadsWithPendingInteractionState( anyTokenMatchQuery, limitPerGroup, tokenMatchQueries, + ...(args.lifecycles === undefined ? {} : { lifecycles: args.lifecycles }), }); return { active: hydrateThreadSearchGroup(db, { tokens, - rows: rows.filter((row) => row.archived === 0), + rows: rows.filter((row) => row.lifecycle === "active"), + }), + ...(args.lifecycles === undefined ? {} : { + draft: hydrateThreadSearchGroup(db, { + tokens, + rows: rows.filter((row) => row.lifecycle === "draft"), + }), }), archived: hydrateThreadSearchGroup(db, { tokens, - rows: rows.filter((row) => row.archived === 1), + rows: rows.filter((row) => row.lifecycle === "archived"), }), }; } diff --git a/packages/db/test/data/thread-discovery-lifecycle.test.ts b/packages/db/test/data/thread-discovery-lifecycle.test.ts new file mode 100644 index 00000000000..9b850052972 --- /dev/null +++ b/packages/db/test/data/thread-discovery-lifecycle.test.ts @@ -0,0 +1,190 @@ +import { eq } from "drizzle-orm"; +import { describe, expect, it, vi } from "vitest"; +import { threadScope, type ThreadLifecycle } from "@bb/domain"; +import { createConnection } from "../../src/connection.js"; +import { migrate } from "../../src/migrate.js"; +import { noopNotifier } from "../../src/notifier.js"; +import { threads } from "../../src/schema.js"; +import { insertEvents, listEvents } from "../../src/data/events.js"; +import { upsertHost } from "../../src/data/hosts.js"; +import { createProject } from "../../src/data/projects.js"; +import { + claimQueuedThreadMessage, + createQueuedThreadMessage, + deleteQueuedThreadMessage, + getQueuedThreadMessage, + listQueuedThreadMessageCountsByThreadIds, + setQueuedThreadMessageFailureReason, +} from "../../src/data/queued-thread-messages.js"; +import { + archiveThread, + createThread, + getThread, + listThreadsWithPendingInteractionState, + searchThreadsWithPendingInteractionState, + unarchiveThread, +} from "../../src/data/threads.js"; + +function setup() { + const db = createConnection(":memory:"); + migrate(db); + const host = upsertHost(db, noopNotifier, { name: "lifecycle-host" }); + const { project } = createProject(db, noopNotifier, { + name: "lifecycle-project", + source: { type: "local_path", hostId: host.id, path: "/tmp/lifecycle" }, + }); + function thread(status: "pending" | "idle" = "pending") { + return createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + status, + title: "discovery lifecycle", + }); + } + function draft(threadId: string, pluginId = "drafts") { + return createQueuedThreadMessage(db, noopNotifier, { + threadId, + content: [{ type: "text", text: "saved message", mentions: [] }], + model: "gpt-5", + reasoningLevel: "medium", + permissionMode: "full", + serviceTier: "default", + waitingOn: { kind: "plugin", pluginId, reason: "Draft" }, + sendAt: null, + payload: { kind: "inline" }, + systemNotice: null, + }); + } + function list(lifecycles: readonly ThreadLifecycle[]) { + return listThreadsWithPendingInteractionState(db, { lifecycles }).map((row) => row.id); + } + return { db, project, thread, draft, list }; +} + +describe("derived thread lifecycle discovery", () => { + it("recognizes live Drafts waits, preserves archive precedence and counts in one grouped pass", () => { + const { db, thread, draft, list } = setup(); + try { + const saved = thread(); + const savedRow = draft(saved.id); + const followup = thread("idle"); + draft(followup.id); + const other = thread(); + draft(other.id, "scheduler"); + const claimed = thread(); + const claimedRow = draft(claimed.id); + claimQueuedThreadMessage(db, noopNotifier, claimedRow.id); + setQueuedThreadMessageFailureReason(db, noopNotifier, { + threadId: saved.id, + id: savedRow.id, + failureReason: "Host unavailable", + }); + const prepare = vi.spyOn(db.$client, "prepare"); + try { + expect(listQueuedThreadMessageCountsByThreadIds(db, { + threadIds: [saved.id, followup.id, other.id, claimed.id], + })).toEqual(expect.arrayContaining([ + { threadId: saved.id, queuedMessageCount: 1, failedQueuedMessageCount: 1, draftQueuedMessageCount: 1 }, + { threadId: followup.id, queuedMessageCount: 1, failedQueuedMessageCount: 0, draftQueuedMessageCount: 1 }, + { threadId: other.id, queuedMessageCount: 1, failedQueuedMessageCount: 0, draftQueuedMessageCount: 0 }, + ])); + expect(prepare).toHaveBeenCalledTimes(1); + } finally { + prepare.mockRestore(); + } + expect(list(["draft"])).toEqual([saved.id]); + expect(new Set(list(["active"]))).toEqual(new Set([followup.id, other.id, claimed.id])); + archiveThread(db, noopNotifier, saved.id); + expect(list(["draft"])).toEqual([]); + expect(list(["archived"])).toEqual([saved.id]); + unarchiveThread(db, noopNotifier, saved.id); + expect(list(["draft"])).toEqual([saved.id]); + expect(getQueuedThreadMessage(db, savedRow.id)?.waitingOn).toContain('"drafts"'); + } finally { + db.$client.close(); + } + }); + + it("filters before list offsets and search limits while legacy search keeps drafts active", () => { + const { db, thread, draft } = setup(); + try { + const first = thread(); + const second = thread(); + draft(first.id); + draft(second.id); + db.update(threads).set({ createdAt: 1, updatedAt: 1 }).where(eq(threads.id, first.id)).run(); + db.update(threads).set({ createdAt: 2, updatedAt: 2 }).where(eq(threads.id, second.id)).run(); + for (let index = 0; index < 24; index += 1) thread("idle"); + const archived = thread(); + draft(archived.id); + archiveThread(db, noopNotifier, archived.id); + const hidden = thread(); + draft(hidden.id); + db.update(threads).set({ visibility: "hidden" }).where(eq(threads.id, hidden.id)).run(); + const deleted = thread(); + draft(deleted.id); + db.update(threads).set({ deletedAt: Date.now() }).where(eq(threads.id, deleted.id)).run(); + + expect(listThreadsWithPendingInteractionState(db, { + lifecycles: ["draft"], limit: 1, offset: 1, + }).map((row) => row.id)).toEqual([first.id]); + expect(listThreadsWithPendingInteractionState(db, { + lifecycles: ["draft"], archived: true, + })).toEqual([]); + const legacy = searchThreadsWithPendingInteractionState(db, { + query: "discovery", limitPerGroup: 50, + }); + expect(Object.keys(legacy)).toEqual(["active", "archived"]); + expect(legacy.active.total).toBe(26); + expect(legacy.active.results.map((result) => result.thread.id)).toContain(first.id); + const filtered = searchThreadsWithPendingInteractionState(db, { + query: "discovery", limitPerGroup: 1, lifecycles: ["draft"], + }); + expect(filtered.active).toEqual({ total: 0, results: [] }); + expect(filtered.archived).toEqual({ total: 0, results: [] }); + expect(filtered.draft?.total).toBe(2); + expect(filtered.draft?.results.map((result) => result.thread.id)).toEqual([second.id]); + const all = searchThreadsWithPendingInteractionState(db, { + query: "discovery", limitPerGroup: 50, lifecycles: ["active", "draft", "archived"], + }); + expect([all.active.total, all.draft?.total, all.archived.total]).toEqual([24, 2, 1]); + expect(all.archived.results.map((result) => result.thread.id)).toEqual([archived.id]); + } finally { + db.$client.close(); + } + }); + + it("deletes only the held row and preserves its owning fork and inherited history", () => { + const { db, project, thread, draft, list } = setup(); + try { + const source = thread("idle"); + const fork = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + status: "pending", + sourceThreadId: source.id, + originKind: "fork", + }); + insertEvents(db, noopNotifier, [{ + threadId: fork.id, + sequence: 1, + type: "item/completed", + scope: threadScope(), + itemId: "inherited-message", + itemKind: "agentMessage", + parentToolCallId: null, + data: JSON.stringify({ item: { id: "inherited-message", type: "agentMessage", text: "Inherited response" } }), + }]); + const saved = draft(fork.id); + const before = listEvents(db, { threadId: fork.id }); + expect(list(["draft"])).toEqual([fork.id]); + expect(deleteQueuedThreadMessage(db, noopNotifier, saved.id)).toBe(true); + expect(getThread(db, fork.id)).toMatchObject({ sourceThreadId: source.id, deletedAt: null, status: "pending" }); + expect(listEvents(db, { threadId: fork.id })).toEqual(before); + expect(list(["draft"])).toEqual([]); + expect(list(["active"])).toContain(fork.id); + } finally { + db.$client.close(); + } + }); +}); diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts index 405edfee51a..e13e8ff1a20 100644 --- a/packages/domain/src/thread.ts +++ b/packages/domain/src/thread.ts @@ -437,7 +437,12 @@ export const threadQueuedWorkValues = ["none", "waiting", "failed"] as const; export const threadQueuedWorkSchema = z.enum(threadQueuedWorkValues); export type ThreadQueuedWork = z.infer; +export const threadLifecycleValues = ["active", "draft", "archived"] as const; +export const threadLifecycleSchema = z.enum(threadLifecycleValues); +export type ThreadLifecycle = z.infer; + export const threadListEntrySchema = threadWithRuntimeSchema.extend({ + lifecycle: threadLifecycleSchema, activity: threadActivityStateSchema, queuedWork: threadQueuedWorkSchema, pinSortKey: z.string().nullable(), diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 292420f4382..33dbfc683a6 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -11,6 +11,7 @@ import { type QueuedMessageWaitHolder, type ThreadQueuedMessage, type ThreadStatus, + type ThreadLifecycle, validatePluginMetadata, } from "@bb/domain"; import { @@ -84,6 +85,7 @@ export const DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS = 250; export interface ThreadListArgs { archived?: boolean; + lifecycles?: readonly ThreadLifecycle[]; environmentId?: string; sectionId?: string; hasParent?: boolean; @@ -99,7 +101,11 @@ export interface ThreadListArgs { unsectioned?: boolean; } -export interface ThreadSearchArgs extends ThreadSearchQuery { +export interface ThreadSearchArgs extends Omit< + ThreadSearchQuery, + "lifecycles" +> { + lifecycles?: readonly ThreadLifecycle[]; signal?: AbortSignal; } @@ -633,6 +639,9 @@ function listQuery(args: ThreadListArgs | undefined): ThreadListQuery { ...(args?.archived === undefined ? {} : { archived: args.archived ? "true" : "false" }), + ...(args?.lifecycles === undefined + ? {} + : { lifecycles: args.lifecycles.join(",") }), ...(args?.unsectioned === undefined ? {} : { unsectioned: args.unsectioned ? "true" : "false" }), @@ -769,6 +778,9 @@ function searchQuery(args: ThreadSearchArgs): ThreadSearchQuery { return { limitPerGroup: args.limitPerGroup, query: args.query, + ...(args.lifecycles === undefined + ? {} + : { lifecycles: args.lifecycles.join(",") }), }; } diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index e5fe470a80d..a613f52afc9 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -1312,6 +1312,40 @@ describe("@bb/sdk", () => { }); }); + it("opts into lifecycle filtering without changing legacy list and search requests", async () => { + const queue = createFetchQueue( + Array.from({ length: 4 }, () => ({ body: [] })), + ); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: queue.fetch, + runtime: "node", + }), + }); + + await sdk.threads.list(); + await sdk.threads.search({ query: "release" }); + await sdk.threads.list({ lifecycles: ["draft", "archived"], limit: 5 }); + await sdk.threads.search({ + query: "release", + lifecycles: ["draft"], + limitPerGroup: 3, + }); + + expect( + queue.requests.map(({ url }) => { + const parsed = new URL(url); + return Object.fromEntries(parsed.searchParams); + }), + ).toEqual([ + {}, + { query: "release" }, + { lifecycles: "draft,archived", limit: "5" }, + { query: "release", lifecycles: "draft", limitPerGroup: "3" }, + ]); + }); + it("forwards every public permission mode through thread surfaces", async () => { const queue = createFetchQueue([ { body: { id: "thr_auto" }, status: 201 }, diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 6e3b33f2dd4..ed9725d44ae 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -23,6 +23,7 @@ import { threadCreateOriginSchema, threadOriginKindSchema, threadListEntrySchema, + threadLifecycleSchema, threadQueuedMessageSchema, threadSearchSourceKindSchema, threadStatusSchema, @@ -476,6 +477,7 @@ export const threadSearchResultGroupSchema = z export const threadSearchResponseSchema = z .object({ active: threadSearchResultGroupSchema, + draft: threadSearchResultGroupSchema.optional(), archived: threadSearchResultGroupSchema, }) .strict(); @@ -751,7 +753,18 @@ export type ThreadArchiveAllResponse = z.infer< typeof threadArchiveAllResponseSchema >; +const threadLifecyclesQuerySchema = z + .string() + .refine( + (value) => + value + .split(",") + .every((entry) => threadLifecycleSchema.safeParse(entry).success), + { message: "Invalid lifecycles" }, + ); + export const threadListQuerySchema = z.object({ + lifecycles: threadLifecyclesQuerySchema.optional(), projectId: z.string().min(1).optional(), environmentId: z.string().min(1).optional(), parentThreadId: z.string().min(1).optional(), @@ -853,6 +866,7 @@ export const threadRunningResponseSchema = z.array(threadRunningEntrySchema); export type ThreadRunningResponse = z.infer; export const threadSearchQuerySchema = z.object({ + lifecycles: threadLifecyclesQuerySchema.optional(), query: z.string().trim().min(2), limitPerGroup: z.string().regex(/^\d+$/).optional(), }); diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 602d1dae150..0ed17717701 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -1224,6 +1224,7 @@ describe("server-contract canonical schemas", () => { environmentIsWorktree: true, environmentWorkspaceDisplayKind: "managed-worktree", queuedWork: "none", + lifecycle: "active", }, ]), ).toMatchObject([ diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 0329c745ac1..42884a18692 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -51,6 +51,7 @@ Spawning: --section Create the thread in a section --visibility visible or hidden; a child inherits its parent by default --send-at Dispatch the first message at an ISO 8601 timestamp or a duration from now (30s, 10m, 2h, 7d) + --draft Save the first message until you send it manually --file CLI-local absolute path, file: URL, or uploaded file path --image CLI-local absolute path, file: URL, or uploaded image path --origin-kind Create a fork thread @@ -82,6 +83,11 @@ Spawning: machine resolution is unchanged. Omit --base-branch for bb's default. Explicit values are exact; use origin/ for a remote ref. + --draft uses the built-in Drafts plugin to hold the first queued message. + The thread stays pending without starting a turn or provisioning its workspace. + Missing, disabled, or unavailable Drafts rejects the save instead of starting work. + Inspect or edit it with bb thread queue list/update; send it with queue send. + Deleting the queued message preserves its thread and any fork history. Before selecting a provider, run `bb environment providers --project --machine ` to see whether it is available, needs setup, or is unavailable and why. The first-party providers are Project checkout, @@ -154,6 +160,7 @@ Listing: --environment Filter by environment --parent-thread Filter by parent thread --archived Show only archived threads + --lifecycle Filter by active, draft, or archived (comma-separated) --section Filter by section --unsectioned Show only threads outside sections --include-hidden Include hidden threads @@ -165,6 +172,13 @@ Listing: bb thread search [--limit <1-50>] Search threads and messages + --lifecycle Filter by active, draft, or archived (comma-separated) + + Lifecycle draft means an unarchived pending thread held by Drafts. Saved + follow-ups on an established thread do not change its lifecycle. Archived + takes precedence. Omit --lifecycle to retain the existing list/search groups; + with it, search also returns a draft group. --archived intersects the lifecycle + filter when both are given. SDK list/search accept lifecycles as an array. bb thread history List prompt history bb thread count Count threads without listing them @@ -260,6 +274,7 @@ Messaging: --reasoning-level Reasoning level override --plan Send the message as the provider's /plan action --send-at Dispatch at an ISO 8601 timestamp or a duration from now (30s, 10m, 2h, 7d) + --draft Save a follow-up until you send it manually; implies queue mode --file CLI-local absolute path, file: URL, or uploaded file path --image CLI-local absolute path, file: URL, or uploaded image path @@ -276,6 +291,13 @@ Messaging: its `id`, `waitingOn`, and `sendAt`. A deferred message waits for a thread that failed while it was deferred, and delivers when the thread is retried. + --draft saves a held queue row even while a turn is running. It cannot be + combined with --mode steer or auto. SDK callers pass + pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } } to + threads.spawn or threads.send; follow-up saves use mode: "queue-if-active". + Save-only requests require the Drafts plugin to be available. A saved + follow-up keeps its established thread active; it does not become a draft thread. + --plan sends the same structured /plan command the composer's plan action sends, so the agent proposes a plan for approval before executing (Claude Code and Codex threads). Plain "/plan ..." text is not recognized; it reaches diff --git a/packages/test-helpers/src/domain-fixtures.ts b/packages/test-helpers/src/domain-fixtures.ts index 2e17d4e52ab..44a55bc1737 100644 --- a/packages/test-helpers/src/domain-fixtures.ts +++ b/packages/test-helpers/src/domain-fixtures.ts @@ -186,6 +186,7 @@ export function makeThreadListEntry( environmentIsWorktree: null, environmentWorkspaceDisplayKind: "other", queuedWork: "none", + lifecycle: overrides.archivedAt != null ? "archived" : "active", }; return { ...entry, 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 bbe137475bd..c35436392ba 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md @@ -10,6 +10,10 @@ current thread's project ID to add. Omitted execution flags use remembered project defaults; without a remembered model, bb resolves the selected provider and its reported default model on the target machine. +- Add `--draft` to save the first message without starting a turn or provisioning + its environment. Drafts must be available; a failed save never falls back to + starting work. Use `bb thread queue list/update/send` to inspect, edit, or send + it. Queue deletion removes only the message, preserving the owning thread. - Select a target with `--environment`, `--new-environment`, `--base-branch`, or `--machine`. Select execution with `--provider`, `--model`, `--reasoning-level`, `--service-tier`, and `--permission-mode`. diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md index 88984cd9adf..2f0dd120ee7 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md @@ -42,6 +42,12 @@ scheduled tell neither sends nor runs. Both report `delivery: "queued"` and dispatch on the sweep after the requested time. The SDK equivalent is `sendAt` (epoch ms) on `threads.spawn` / `threads.send`. +- Add `--draft` to `bb thread spawn` or `bb thread tell` to save a message until + manual Send now. Follow-up saves imply queue mode and reject explicit steer + or auto mode. SDK callers use `pluginSubmission: { pluginId: "drafts", data: + { kind: "draft" } }` on `threads.spawn` or `threads.send`; use + `mode: "queue-if-active"` for a follow-up. Drafts must be installed, enabled, + and available or the server rejects the save before starting any work. - `bb thread queue list` shows a Sender for agent threads and system notices. SDK queue rows and `--json` include `initiator` and nullable `senderThreadId`. - A send that cannot run right now does not fail: it joins the thread's queue @@ -96,6 +102,12 @@ hostId, providerId, projectId, parentThreadId, groupBy })`. ## Inspecting Results +- Add `--lifecycle active,draft,archived` to thread list/search to select any + nonempty subset. Draft means pending with a Drafts-held first message; + established threads with saved follow-ups stay active. Archived takes + precedence. Omission retains legacy groups; opt-in search adds a draft group. + List `--archived` intersects this filter. SDK list/search accept `lifecycles` + as an array. Filtering precedes result limits and counts. - Use `bb thread search [--limit <1-50>]` for sidebar search. Use `history`, `read|unread`, and `section` for organization and recall. The `bb thread queue` group contains the queued-message operations. Queue updates From 597c10e7e03035fb88aea70563d30ec08b1ac1d7 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 22:56:25 -0400 Subject: [PATCH 02/11] test: align lifecycle projection fixtures and contract allowlist --- apps/demo-server/src/fixtures/world.ts | 1 + packages/server-contract/test/contract.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/demo-server/src/fixtures/world.ts b/apps/demo-server/src/fixtures/world.ts index b1060de990d..5014f6b5fdc 100644 --- a/apps/demo-server/src/fixtures/world.ts +++ b/apps/demo-server/src/fixtures/world.ts @@ -84,6 +84,7 @@ export function threadListEntry( environmentIsWorktree: null, environmentWorkspaceDisplayKind: "other", queuedWork: "none", + lifecycle: "active", }; } diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 0ed17717701..61faa28157a 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -506,6 +506,7 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ "threadListQuerySchema.limit", "threadListQuerySchema.hasParent", "threadListQuerySchema.includeHidden", + "threadListQuerySchema.lifecycles", "threadListQuerySchema.offset", "threadListQuerySchema.originKind", "threadListQuerySchema.originPluginId", From 75e350710b26d0bbc6bdb5a4d43c930e25be5f88 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 23:00:03 -0400 Subject: [PATCH 03/11] test: align CLI assertions with existing search and logging contracts --- .../src/__tests__/command-output/thread-organization.test.ts | 2 +- apps/cli/src/__tests__/command-output/thread-spawn.test.ts | 4 +++- packages/sdk/test/sdk.test.ts | 2 +- packages/templates/src/templates/bb-guide-json.md | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/__tests__/command-output/thread-organization.test.ts b/apps/cli/src/__tests__/command-output/thread-organization.test.ts index 165447871d8..76db1d88c92 100644 --- a/apps/cli/src/__tests__/command-output/thread-organization.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-organization.test.ts @@ -64,7 +64,7 @@ describe("bb thread organization commands", () => { ); expect(search).toHaveBeenCalledWith({ - query: { query: "release", lifecycles: "draft", limitPerGroup: 3 }, + query: { query: "release", lifecycles: "draft", limitPerGroup: "3" }, }); }); diff --git a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts index 813bce58307..faa8279361e 100644 --- a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts @@ -53,7 +53,9 @@ describe("bb thread spawn command output", () => { input: [{ type: "text", text: "Save this", mentions: [] }], }), }); - expect(collectLogLines()[0]).toBe("Draft saved: thread-draft"); + expect(collectLogLines(vi.mocked(console.log))[0]).toBe( + "Draft saved: thread-draft", + ); }); it("rejects explicitly empty lifecycle ownership instead of creating an independent thread", async () => { diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index a613f52afc9..36b689d23f6 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -1330,7 +1330,7 @@ describe("@bb/sdk", () => { await sdk.threads.search({ query: "release", lifecycles: ["draft"], - limitPerGroup: 3, + limitPerGroup: "3", }); expect( diff --git a/packages/templates/src/templates/bb-guide-json.md b/packages/templates/src/templates/bb-guide-json.md index 02162dd6e2e..7dedda70403 100644 --- a/packages/templates/src/templates/bb-guide-json.md +++ b/packages/templates/src/templates/bb-guide-json.md @@ -38,7 +38,7 @@ Fields beyond those shown exist; these are the ones scripts use. {project: {id, name} | null, thread: {id, status, title, parentThreadId, environment: {hostId, display} | null} | null, childThreads: [{id, status, title}] | null, pendingTodos, pluginsNeedingAttention: [{id, status}], dataDir} bb thread list --json - [{id, projectId, environmentId, providerId, title, status, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null) + [{id, projectId, environmentId, providerId, title, status, lifecycle, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null) bb thread show --json {thread: {id, status, title, projectId, environmentId, parentThreadId, ...}, environment: {id, hostId, path, branchName, ...} | null, pendingTodos} (thread fields are under .thread) @@ -62,7 +62,7 @@ Fields beyond those shown exist; these are the ones scripts use. {total} bb thread search --json - {active: {total, results}, archived: {total, results}} + {active: {total, results}, archived: {total, results}, draft?: {total, results}} (draft group present with --lifecycle) bb thread section list --json [{id, name, createdAt, updatedAt}] From 9cdd061f528718aba9eb11fbd432b4586b327d05 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 23:06:17 -0400 Subject: [PATCH 04/11] test: supply required identity in draft CLI fixture --- apps/cli/src/__tests__/command-output/thread-spawn.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts index faa8279361e..a96f1cddd62 100644 --- a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts @@ -30,7 +30,12 @@ describe("bb thread spawn command output", () => { it("saves the first message with the shipped Drafts submission", async () => { const post = vi.fn(async ({ json }: { json: unknown }) => { createThreadRequestSchema.parse(json); - return fixtures.makeThread({ id: "thread-draft", status: "pending" }); + return fixtures.makeThread({ + id: "thread-draft", + projectId: "proj-1", + providerId: "codex", + status: "pending", + }); }); stubServerApi({ "v1.threads.$post": post }); From 38819e21f3dc923c773be8a64cf97cbbd57aba33 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 23:34:28 -0400 Subject: [PATCH 05/11] Order lifecycle-filtered lists by global recency --- packages/db/src/data/threads.ts | 3 ++ .../data/thread-discovery-lifecycle.test.ts | 47 ++++++++++++++++++- .../src/templates/bb-guide-threads.md | 4 +- .../bb-cli/references/thread-operation.md | 4 +- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index 7aedd70b59a..ea3390124be 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -769,6 +769,9 @@ function buildPinnedThreadOrderBy() { } function buildListThreadsOrderBy(options: ListThreadsOptions) { + if (options.lifecycles !== undefined) { + return [desc(threads.updatedAt), desc(threads.id)]; + } if (options.archived === true) { return [desc(threads.archivedAt), desc(threads.id)]; } diff --git a/packages/db/test/data/thread-discovery-lifecycle.test.ts b/packages/db/test/data/thread-discovery-lifecycle.test.ts index 9b850052972..903728bbfca 100644 --- a/packages/db/test/data/thread-discovery-lifecycle.test.ts +++ b/packages/db/test/data/thread-discovery-lifecycle.test.ts @@ -58,7 +58,7 @@ function setup() { function list(lifecycles: readonly ThreadLifecycle[]) { return listThreadsWithPendingInteractionState(db, { lifecycles }).map((row) => row.id); } - return { db, project, thread, draft, list }; + return { db, host, project, thread, draft, list }; } describe("derived thread lifecycle discovery", () => { @@ -154,6 +154,51 @@ describe("derived thread lifecycle discovery", () => { } }); + it("bounds lifecycle lists by global recency without changing legacy project or archive ordering", () => { + const { db, host, project, draft } = setup(); + try { + const { project: otherProject } = createProject(db, noopNotifier, { + name: "other-lifecycle-project", + source: { type: "local_path", hostId: host.id, path: "/tmp/other-lifecycle" }, + }); + const older = createThread(db, noopNotifier, { + projectId: project.id < otherProject.id ? project.id : otherProject.id, + providerId: "codex", + status: "pending", + }); + const recent = createThread(db, noopNotifier, { + projectId: project.id < otherProject.id ? otherProject.id : project.id, + providerId: "codex", + status: "pending", + }); + draft(older.id); + draft(recent.id); + db.update(threads).set({ createdAt: 20, updatedAt: 30, pinnedAt: 10 }).where(eq(threads.id, older.id)).run(); + db.update(threads).set({ createdAt: 10, updatedAt: 40 }).where(eq(threads.id, recent.id)).run(); + + expect(listThreadsWithPendingInteractionState(db, { + lifecycles: ["draft"], limit: 1, + }).map((row) => row.id)).toEqual([recent.id]); + expect(listThreadsWithPendingInteractionState(db, { + lifecycles: ["draft"], limit: 1, offset: 1, + }).map((row) => row.id)).toEqual([older.id]); + expect(listThreadsWithPendingInteractionState(db, { + archived: false, limit: 1, + }).map((row) => row.id)).toEqual([older.id]); + + db.update(threads).set({ archivedAt: 60 }).where(eq(threads.id, older.id)).run(); + db.update(threads).set({ archivedAt: 50 }).where(eq(threads.id, recent.id)).run(); + expect(listThreadsWithPendingInteractionState(db, { + lifecycles: ["archived"], archived: true, limit: 1, + }).map((row) => row.id)).toEqual([recent.id]); + expect(listThreadsWithPendingInteractionState(db, { + archived: true, limit: 1, + }).map((row) => row.id)).toEqual([older.id]); + } finally { + db.$client.close(); + } + }); + it("deletes only the held row and preserves its owning fork and inherited history", () => { const { db, project, thread, draft, list } = setup(); try { diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 42884a18692..f344fbb7e64 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -178,7 +178,9 @@ Listing: follow-ups on an established thread do not change its lifecycle. Archived takes precedence. Omit --lifecycle to retain the existing list/search groups; with it, search also returns a draft group. --archived intersects the lifecycle - filter when both are given. SDK list/search accept lifecycles as an array. + filter when both are given. Lifecycle-filtered lists sort by last updated, + newest first, before pagination; omitted filters keep existing list ordering. + SDK list/search accept lifecycles as an array. bb thread history List prompt history bb thread count Count threads without listing them diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md index 2f0dd120ee7..1f19264b8b5 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md @@ -107,7 +107,9 @@ hostId, providerId, projectId, parentThreadId, groupBy })`. established threads with saved follow-ups stay active. Archived takes precedence. Omission retains legacy groups; opt-in search adds a draft group. List `--archived` intersects this filter. SDK list/search accept `lifecycles` - as an array. Filtering precedes result limits and counts. + as an array. Filtering precedes result limits and counts. Lifecycle-filtered + lists sort by last updated, newest first, before pagination; omission keeps + existing list ordering. - Use `bb thread search [--limit <1-50>]` for sidebar search. Use `history`, `read|unread`, and `section` for organization and recall. The `bb thread queue` group contains the queued-message operations. Queue updates From 12262cd53a185097577f93e545290f92aa048898 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 23:37:10 -0400 Subject: [PATCH 06/11] Remove duplicate unavailable Drafts fixture --- apps/server/test/threads/dispatch-hooks.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/test/threads/dispatch-hooks.test.ts b/apps/server/test/threads/dispatch-hooks.test.ts index c15e1f51991..aa984fcfc75 100644 --- a/apps/server/test/threads/dispatch-hooks.test.ts +++ b/apps/server/test/threads/dispatch-hooks.test.ts @@ -206,7 +206,7 @@ async function expectApiError(run: () => Promise): Promise { describe("built-in Drafts save-only admission", () => { const pluginSubmission = { pluginId: "drafts", data: { kind: "draft" } }; - it.each(["missing", "disabled", "failed", "missing-handler"] as const)( + it.each(["missing", "disabled", "missing-handler"] as const)( "rejects %s Drafts before creating a thread, environment, or turn", async (state) => { await withTestHarness(async (harness) => { From 3205aa38259bef4a68d3ea7e9fb98d12003ed5c1 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:09:01 -0700 Subject: [PATCH 07/11] Index saved queued messages in thread search --- .../db/drizzle/0127_queued_message_search.sql | 86 + packages/db/drizzle/meta/0127_snapshot.json | 5001 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../test/data/queued-message-search.test.ts | 123 + 4 files changed, 5217 insertions(+) create mode 100644 packages/db/drizzle/0127_queued_message_search.sql create mode 100644 packages/db/drizzle/meta/0127_snapshot.json create mode 100644 packages/db/test/data/queued-message-search.test.ts diff --git a/packages/db/drizzle/0127_queued_message_search.sql b/packages/db/drizzle/0127_queued_message_search.sql new file mode 100644 index 00000000000..d4d889867e9 --- /dev/null +++ b/packages/db/drizzle/0127_queued_message_search.sql @@ -0,0 +1,86 @@ +INSERT INTO thread_search_segments (`id`, `thread_id`, `source_kind`, `source_key`, `source_seq`, `text`, `created_at`, `updated_at`) +SELECT + q.thread_id || ':user_message:queued:' || q.id, + q.thread_id, + 'user_message', + 'queued:' || q.id, + NULL, + trim(COALESCE(( + SELECT group_concat(json_extract(part.value, '$.text'), char(10)) + FROM json_each(q.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + ), '')), + q.created_at, + q.updated_at +FROM queued_thread_messages AS q +WHERE q.system_notice IS NULL + AND EXISTS ( + SELECT 1 FROM json_each(q.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + AND trim(json_extract(part.value, '$.text')) <> '' + ); +--> statement-breakpoint +CREATE TRIGGER queued_thread_messages_search_insert +AFTER INSERT ON queued_thread_messages +WHEN NEW.system_notice IS NULL +BEGIN + INSERT INTO thread_search_segments (`id`, `thread_id`, `source_kind`, `source_key`, `source_seq`, `text`, `created_at`, `updated_at`) + SELECT + NEW.thread_id || ':user_message:queued:' || NEW.id, + NEW.thread_id, + 'user_message', + 'queued:' || NEW.id, + NULL, + trim(COALESCE(( + SELECT group_concat(json_extract(part.value, '$.text'), char(10)) + FROM json_each(NEW.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + ), '')), + NEW.created_at, + NEW.updated_at + WHERE EXISTS ( + SELECT 1 FROM json_each(NEW.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + AND trim(json_extract(part.value, '$.text')) <> '' + ); +END; +--> statement-breakpoint +CREATE TRIGGER queued_thread_messages_search_update +AFTER UPDATE OF content, system_notice ON queued_thread_messages +BEGIN + DELETE FROM thread_search_segments + WHERE id = OLD.thread_id || ':user_message:queued:' || OLD.id; + INSERT INTO thread_search_segments (`id`, `thread_id`, `source_kind`, `source_key`, `source_seq`, `text`, `created_at`, `updated_at`) + SELECT + NEW.thread_id || ':user_message:queued:' || NEW.id, + NEW.thread_id, + 'user_message', + 'queued:' || NEW.id, + NULL, + trim(COALESCE(( + SELECT group_concat(json_extract(part.value, '$.text'), char(10)) + FROM json_each(NEW.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + ), '')), + NEW.created_at, + NEW.updated_at + WHERE NEW.system_notice IS NULL + AND EXISTS ( + SELECT 1 FROM json_each(NEW.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + AND trim(json_extract(part.value, '$.text')) <> '' + ); +END; +--> statement-breakpoint +CREATE TRIGGER queued_thread_messages_search_delete +AFTER DELETE ON queued_thread_messages +BEGIN + DELETE FROM thread_search_segments + WHERE id = OLD.thread_id || ':user_message:queued:' || OLD.id; +END; diff --git a/packages/db/drizzle/meta/0127_snapshot.json b/packages/db/drizzle/meta/0127_snapshot.json new file mode 100644 index 00000000000..a337674ca42 --- /dev/null +++ b/packages/db/drizzle/meta/0127_snapshot.json @@ -0,0 +1,5001 @@ +{ + "id": "b82c0f1e-74b0-4992-ba56-dce2ea5f74d6", + "prevId": "48979c46-bc2b-411d-a6cc-18dfcb7cd457", + "version": "6", + "dialect": "sqlite", + "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", + "columnsFrom": [ + "referenceId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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": {} + }, + "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" + ], + "where": "\"environment_variables\".\"project_id\" IS NULL", + "isUnique": true + }, + "environment_variables_project_name": { + "name": "environment_variables_project_name", + "columns": [ + "project_id", + "name" + ], + "where": "\"environment_variables\".\"project_id\" IS NOT NULL", + "isUnique": true + } + }, + "foreignKeys": { + "environment_variables_project_id_projects_id_fk": { + "name": "environment_variables_project_id_projects_id_fk", + "tableFrom": "environment_variables", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "columnsFrom": [ + "host_id" + ], + "tableTo": "hosts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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" + ], + "where": "\"events\".\"item_kind\" IN ('toolCall', 'delegation')", + "isUnique": false + }, + "events_plan_steps_thread_sequence_idx": { + "name": "events_plan_steps_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "where": "(\"events\".\"item_kind\" = 'planSteps' AND \"events\".\"type\" = 'item/completed') OR \"events\".\"type\" = 'turn/plan/updated'", + "isUnique": false + }, + "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" + ], + "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL", + "isUnique": false + }, + "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" + ], + "where": "\"events\".\"item_kind\" = 'backgroundTask'", + "isUnique": false + }, + "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" + ], + "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')", + "isUnique": false + }, + "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" + ], + "where": "\"events\".\"type\" = 'thread/identity'", + "isUnique": false + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "where": "\"events\".\"type\" = 'item/completed'", + "isUnique": false + }, + "events_thread_state_thread_sequence_idx": { + "name": "events_thread_state_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared', 'thread/extensionState/updated')", + "isUnique": false + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "columnsFrom": [ + "environment_id" + ], + "tableTo": "environments", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "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", + "columnsFrom": [ + "host_id" + ], + "tableTo": "hosts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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" + ], + "where": "\"hosts\".\"destroyed_at\" is null", + "isUnique": true + } + }, + "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", + "columnsFrom": [ + "active_artifact_id" + ], + "tableTo": "plugin_artifacts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "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", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "attachment_id" + ], + "tableTo": "project_attachments", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "project_attachment_threads_thread_id_threads_id_fk": { + "name": "project_attachment_threads_thread_id_threads_id_fk", + "tableFrom": "project_attachment_threads", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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" + ], + "where": "\"project_attachments\".\"deletion_claimed_at\" IS NOT NULL", + "isUnique": false + } + }, + "foreignKeys": { + "project_attachments_project_id_projects_id_fk": { + "name": "project_attachments_project_id_projects_id_fk", + "tableFrom": "project_attachments", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "columnsFrom": [ + "host_id" + ], + "tableTo": "hosts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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" + ], + "where": "\"projects\".\"kind\" = 'personal'", + "isUnique": true + } + }, + "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", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "host_id" + ], + "tableTo": "hosts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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" + ], + "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", + "isUnique": false + }, + "queued_thread_messages_wait_holder_idx": { + "name": "queued_thread_messages_wait_holder_idx", + "columns": [ + "wait_holder", + "id" + ], + "where": "\"queued_thread_messages\".\"wait_holder\" IS NOT NULL", + "isUnique": false + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "event_id" + ], + "tableTo": "events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "columnsFrom": [ + "environment_id" + ], + "tableTo": "environments", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "columnsFrom": [ + "host_id" + ], + "tableTo": "hosts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "tableTo": "host_daemon_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "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", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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" + ], + "where": "\"threads\".\"pinned_at\" IS NOT NULL", + "isUnique": false + }, + "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" + ], + "where": "\"threads\".\"deleted_at\" IS NULL", + "isUnique": false + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "environment_id" + ], + "tableTo": "environments", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "threads_section_id_thread_sections_id_fk": { + "name": "threads_section_id_thread_sections_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "section_id" + ], + "tableTo": "thread_sections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "threads_lifecycle_owner_thread_id_threads_id_fk": { + "name": "threads_lifecycle_owner_thread_id_threads_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "lifecycle_owner_thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "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": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "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 6a30ce673b3..4e47f078fa6 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": 1789952709078, + "tag": "0127_queued_message_search", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/test/data/queued-message-search.test.ts b/packages/db/test/data/queued-message-search.test.ts new file mode 100644 index 00000000000..eb79fde3369 --- /dev/null +++ b/packages/db/test/data/queued-message-search.test.ts @@ -0,0 +1,123 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { type PromptInput } from "@bb/domain"; +import { noopNotifier } from "../../src/notifier.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { upsertHost } from "../../src/data/hosts.js"; +import { createProject } from "../../src/data/projects.js"; +import { + archiveThread, + createThread, + deleteThread, + searchThreadsWithPendingInteractionState, +} from "../../src/data/threads.js"; +import { + createQueuedThreadMessage, + deleteQueuedThreadMessage, + updateQueuedThreadMessage, +} from "../../src/data/queued-thread-messages.js"; + +function text(text: string, visibility?: "agent-only"): PromptInput { + return { type: "text", text, mentions: [], ...(visibility ? { visibility } : {}) }; +} + +function setup() { + const db = createMigratedConnection(); + const host = upsertHost(db, noopNotifier, { name: "search-host" }); + const { project } = createProject(db, noopNotifier, { + name: "search-project", + source: { type: "local_path", hostId: host.id, path: "/tmp/search" }, + }); + const thread = (status: "pending" | "idle" = "pending", visibility: "visible" | "hidden" = "visible") => + createThread(db, noopNotifier, { projectId: project.id, providerId: "codex", title: "Conversation", status, visibility }); + const save = (threadId: string, content: PromptInput[]) => + createQueuedThreadMessage(db, noopNotifier, { + threadId, content, model: "gpt-5", reasoningLevel: "medium", + permissionMode: "full", serviceTier: "default", + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, payload: { kind: "inline" }, systemNotice: null, + }); + const search = (query: string, limitPerGroup = 20) => + searchThreadsWithPendingInteractionState(db, { query, limitPerGroup }); + return { db, thread, save, search }; +} + +describe("saved message thread search", () => { + it("finds both first messages and follow-ups once per thread with normal snippets and no event anchor", () => { + const { db, thread, save, search } = setup(); + try { + const pending = thread(); + const existing = thread("idle"); + save(pending.id, [text("violet launch plan"), text("privatecode", "agent-only")]); + save(existing.id, [text("violet launch follow-up")]); + save(existing.id, [text("another violet launch note")]); + const result = search("violet launch"); + expect(result.active.total).toBe(2); + expect(new Set(result.active.results.map((row) => row.thread.id))).toEqual(new Set([pending.id, existing.id])); + for (const row of result.active.results) { + expect(row.matches[0]).toMatchObject({ sourceKind: "user_message", sourceSeq: null }); + expect(row.matches[0]?.highlightRanges.length).toBeGreaterThan(0); + } + expect(search("privatecode").active.total).toBe(0); + expect(search("violet", 1).active.results).toHaveLength(1); + expect(search("violet", 1).active.total).toBe(2); + archiveThread(db, noopNotifier, pending.id); + expect(search("violet").archived.results[0]?.thread.id).toBe(pending.id); + const hidden = thread("idle", "hidden"); + save(hidden.id, [text("violet launch hidden")]); + deleteThread(db, noopNotifier, existing.id); + expect(search("violet").active.total).toBe(0); + } finally { + db.$client.close(); + } + }); + + it("replaces edited content and removes deleted or emptied messages from the index", () => { + const { db, thread, save, search } = setup(); + try { + const owner = thread(); + const message = save(owner.id, [text("oldword")]); + const edited = updateQueuedThreadMessage(db, noopNotifier, { + id: message.id, threadId: owner.id, expectedUpdatedAt: message.updatedAt, + content: [text("newword")], + }); + expect(edited.kind).toBe("updated"); + expect(search("oldword").active.total).toBe(0); + expect(search("newword").active.total).toBe(1); + if (edited.kind !== "updated") throw new Error("Expected edited message"); + updateQueuedThreadMessage(db, noopNotifier, { + id: message.id, threadId: owner.id, expectedUpdatedAt: edited.queuedMessage.updatedAt, + content: [text("privateword", "agent-only")], + }); + expect(search("newword").active.total).toBe(0); + expect(search("privateword").active.total).toBe(0); + const removed = save(owner.id, [text("removedword")]); + deleteQueuedThreadMessage(db, noopNotifier, removed.id); + expect(search("removedword").active.total).toBe(0); + } finally { + db.$client.close(); + } + }); + + it("backfills previously saved messages without changing their queue rows", () => { + const { db, thread, save, search } = setup(); + try { + db.$client.exec(` + DROP TRIGGER queued_thread_messages_search_insert; + DROP TRIGGER queued_thread_messages_search_update; + DROP TRIGGER queued_thread_messages_search_delete; + `); + const owner = thread(); + const saved = save(owner.id, [text("preexistingmessage")]); + expect(search("preexistingmessage").active.total).toBe(0); + const migration = readFileSync(resolve(__dirname, "../../drizzle/0127_queued_message_search.sql"), "utf8"); + db.$client.exec(migration); + expect(search("preexistingmessage").active.results[0]?.thread.id).toBe(owner.id); + const persisted = db.$client.prepare("SELECT content, waiting_on FROM queued_thread_messages WHERE id = ?").get(saved.id); + expect(persisted).toEqual({ content: saved.content, waiting_on: saved.waitingOn }); + } finally { + db.$client.close(); + } + }); +}); From 421870d88239d5f2aa1ad1741794d9e59ad4c43c Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:14:46 -0700 Subject: [PATCH 08/11] Preserve queue corruption diagnostics and migration fixtures --- packages/db/drizzle/0127_queued_message_search.sql | 12 ++++++------ packages/db/test/migrate.test.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/db/drizzle/0127_queued_message_search.sql b/packages/db/drizzle/0127_queued_message_search.sql index d4d889867e9..1045cdee965 100644 --- a/packages/db/drizzle/0127_queued_message_search.sql +++ b/packages/db/drizzle/0127_queued_message_search.sql @@ -7,7 +7,7 @@ SELECT NULL, trim(COALESCE(( SELECT group_concat(json_extract(part.value, '$.text'), char(10)) - FROM json_each(q.content) AS part + FROM json_each(CASE WHEN json_valid(q.content) THEN q.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' ), '')), @@ -16,7 +16,7 @@ SELECT FROM queued_thread_messages AS q WHERE q.system_notice IS NULL AND EXISTS ( - SELECT 1 FROM json_each(q.content) AS part + SELECT 1 FROM json_each(CASE WHEN json_valid(q.content) THEN q.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' AND trim(json_extract(part.value, '$.text')) <> '' @@ -35,14 +35,14 @@ BEGIN NULL, trim(COALESCE(( SELECT group_concat(json_extract(part.value, '$.text'), char(10)) - FROM json_each(NEW.content) AS part + FROM json_each(CASE WHEN json_valid(NEW.content) THEN NEW.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' ), '')), NEW.created_at, NEW.updated_at WHERE EXISTS ( - SELECT 1 FROM json_each(NEW.content) AS part + SELECT 1 FROM json_each(CASE WHEN json_valid(NEW.content) THEN NEW.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' AND trim(json_extract(part.value, '$.text')) <> '' @@ -63,7 +63,7 @@ BEGIN NULL, trim(COALESCE(( SELECT group_concat(json_extract(part.value, '$.text'), char(10)) - FROM json_each(NEW.content) AS part + FROM json_each(CASE WHEN json_valid(NEW.content) THEN NEW.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' ), '')), @@ -71,7 +71,7 @@ BEGIN NEW.updated_at WHERE NEW.system_notice IS NULL AND EXISTS ( - SELECT 1 FROM json_each(NEW.content) AS part + SELECT 1 FROM json_each(CASE WHEN json_valid(NEW.content) THEN NEW.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' AND trim(json_extract(part.value, '$.text')) <> '' diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 4da166327e3..ed2ab8f8530 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -736,7 +736,16 @@ function dropMarketplaceStatsColumn(db: DbConnection): void { * nothing here. Every rewind that clears 0110's journal row also clears * 0108's, so the replay recreates the table before 0110 drops it again. */ +function dropQueuedMessageSearchTriggers(db: DbConnection): void { + db.$client.exec(` + DROP TRIGGER IF EXISTS queued_thread_messages_search_insert; + DROP TRIGGER IF EXISTS queued_thread_messages_search_update; + DROP TRIGGER IF EXISTS queued_thread_messages_search_delete; + `); +} + function rewindEnvironmentProvisioningMigration(db: DbConnection): void { + dropQueuedMessageSearchTriggers(db); db.$client.exec("DROP TRIGGER IF EXISTS threads_lifecycle_owner_insert"); db.$client.exec("DROP TRIGGER IF EXISTS threads_lifecycle_owner_immutable"); db.$client.exec("DROP INDEX IF EXISTS threads_lifecycle_owner_idx"); @@ -858,6 +867,7 @@ function rewindEnvironmentRowFactsMigration(db: DbConnection): void { } function rewindMachineProvidersMigration(db: DbConnection): void { + dropQueuedMessageSearchTriggers(db); const queuedDispatchOrigin = db.$client .prepare<[], TableInfoRow>("PRAGMA table_info(queued_thread_messages)") .all(); From 1e9c39bbe4a653f74e290e320455fa13c74aa955 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:17:10 -0700 Subject: [PATCH 09/11] Rewind queue search rows with the migration fixture --- packages/db/test/migrate.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index ed2ab8f8530..b91110698a5 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -741,6 +741,8 @@ function dropQueuedMessageSearchTriggers(db: DbConnection): void { DROP TRIGGER IF EXISTS queued_thread_messages_search_insert; DROP TRIGGER IF EXISTS queued_thread_messages_search_update; DROP TRIGGER IF EXISTS queued_thread_messages_search_delete; + DELETE FROM thread_search_segments + WHERE source_kind = 'user_message' AND source_key LIKE 'queued:%'; `); } From 0d4081763cb9e105d2127a669385e6bfe07445c6 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:24:25 -0700 Subject: [PATCH 10/11] Handle pre-search schemas when rewinding migration fixtures --- packages/db/test/migrate.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index b91110698a5..bccf553e041 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -741,9 +741,13 @@ function dropQueuedMessageSearchTriggers(db: DbConnection): void { DROP TRIGGER IF EXISTS queued_thread_messages_search_insert; DROP TRIGGER IF EXISTS queued_thread_messages_search_update; DROP TRIGGER IF EXISTS queued_thread_messages_search_delete; - DELETE FROM thread_search_segments - WHERE source_kind = 'user_message' AND source_key LIKE 'queued:%'; `); + if (readTableNames(db).includes("thread_search_segments")) { + db.$client.exec(` + DELETE FROM thread_search_segments + WHERE source_kind = 'user_message' AND source_key LIKE 'queued:%'; + `); + } } function rewindEnvironmentProvisioningMigration(db: DbConnection): void { From 55c762560a4b3588c566477df397493b12abc65f Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 20:26:57 -0700 Subject: [PATCH 11/11] Remove draft thread lifecycle API and classification --- .../commands/CommandPalette.test.tsx | 1 - .../app/src/hooks/cache-owners/query-cache.ts | 5 - .../thread-runtime-cache-owner.test.ts | 35 +-- .../thread-runtime-cache-owner.ts | 14 +- .../palette-thread-search.test.ts | 1 - .../command-output/thread-list.test.ts | 25 -- .../thread-organization.test.ts | 27 -- apps/cli/src/commands/thread/helpers.ts | 21 -- apps/cli/src/commands/thread/list.ts | 8 - apps/cli/src/commands/thread/organization.ts | 13 +- apps/cli/src/json-shapes.ts | 5 +- apps/demo-server/src/fixtures/world.ts | 1 - apps/server/src/routes/threads/base.ts | 21 -- .../threads/thread-runtime-display.ts | 27 +- .../test/public/public-thread-search.test.ts | 140 +++-------- .../threads/thread-runtime-display.test.ts | 62 ----- .../test/threads/dispatch-hooks.test.ts | 18 +- .../db/src/data/queued-thread-messages.ts | 2 - packages/db/src/data/threads.ts | 70 +----- .../data/thread-discovery-lifecycle.test.ts | 235 ------------------ packages/domain/src/thread.ts | 5 - packages/sdk/src/areas/threads.ts | 14 +- packages/sdk/test/sdk.test.ts | 34 --- packages/server-contract/src/api/threads.ts | 14 -- .../server-contract/test/contract.test.ts | 2 - .../templates/src/templates/bb-guide-json.md | 4 +- .../src/templates/bb-guide-threads.md | 14 +- packages/test-helpers/src/domain-fixtures.ts | 1 - .../bb-cli/references/thread-operation.md | 8 - 29 files changed, 69 insertions(+), 758 deletions(-) delete mode 100644 packages/db/test/data/thread-discovery-lifecycle.test.ts diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 31ff5a720f0..acf02e3764d 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -308,7 +308,6 @@ function makeThread( environmentWorkspaceDisplayKind: "other", runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, queuedWork: "none", - lifecycle: overrides.archivedAt != null ? "archived" : "active", ...overrides, }; } diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts index 7b3cb508289..7e3a40b8460 100644 --- a/apps/app/src/hooks/cache-owners/query-cache.ts +++ b/apps/app/src/hooks/cache-owners/query-cache.ts @@ -2,7 +2,6 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { Thread, ThreadListEntry, - ThreadLifecycle, ThreadStatusChangeMetadata, } from "@bb/domain"; import { @@ -573,14 +572,10 @@ function threadMatchesListFilters( export function optimisticallyInsertThread( queryClient: QueryClient, thread: ThreadResponse, - lifecycle: ThreadLifecycle = thread.archivedAt !== null - ? "archived" - : "active", ): void { const queuedWork = thread.queuedMessageCount > 0 ? "waiting" : "none"; const insertedThread: ThreadListEntry = { ...thread, - lifecycle, activity: { activeWorkflowCount: 0, activeBackgroundAgentCount: 0, diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts index 77edad530f8..61a1584aa9b 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts @@ -15,10 +15,7 @@ import { makeProjectWithThreadsResponse, makeSidebarBootstrapResponse, } from "@/test/fixtures/projects"; -import { - makeThreadResponse, - makeThreadTimelineResponse as makeTimelineResponse, -} from "@/test/fixtures/thread-responses"; +import { makeThreadTimelineResponse as makeTimelineResponse } from "@/test/fixtures/thread-responses"; import { sidebarNavigationQueryKey, threadListQueryKey, @@ -30,7 +27,6 @@ import { } from "../queries/query-keys"; import { threadDefaultExecutionOptionsQueryKey } from "../queries/thread-default-execution-options-query"; import { - applyCreateThreadResult, applyQueuedMessageCreateResult, applyQueuedMessageSendResult, applyQueuedMessageUpdateResult, @@ -116,35 +112,6 @@ function makeQueuedMessage( } describe("thread runtime cache owner", () => { - it("keeps a newly saved draft classified while the list refreshes", () => { - const queryClient = createAppQueryClient({ - defaultOptions: { queries: { gcTime: Infinity, retry: false } }, - showMutationErrorToasts: false, - }); - const key = threadListQueryKey({ archived: false, projectId: "project-1" }); - queryClient.setQueryData(key, []); - - applyCreateThreadResult({ - queryClient, - request: { - projectId: "project-1", - input: [{ type: "text", text: "Save for later", mentions: [] }], - environment: { type: "project-default" }, - pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } }, - }, - thread: makeThreadResponse({ - id: "thread-draft", - projectId: "project-1", - status: "pending", - queuedMessageCount: 1, - }), - }); - - expect(queryClient.getQueryData(key)).toEqual([ - expect.objectContaining({ id: "thread-draft", lifecycle: "draft" }), - ]); - }); - it.each([ ["Plan", applyThreadPlanCancellationResult, "activePlanModeCount"], ["Goal", applyThreadGoalClearResult, "activeGoalCount"], diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts index 9fe2ebb3cb3..695a69c1c41 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts @@ -890,19 +890,7 @@ export function applyCreateThreadResult({ thread, }: CreateThreadSuccessArgs): void { queryClient.setQueryData(threadQueryKey(thread.id), thread); - const submission = request.pluginSubmission; - const savedDraft = - thread.status === "pending" && - submission?.pluginId === "drafts" && - submission.data !== null && - typeof submission.data === "object" && - !Array.isArray(submission.data) && - submission.data.kind === "draft"; - optimisticallyInsertThread( - queryClient, - thread, - savedDraft ? "draft" : "active", - ); + optimisticallyInsertThread(queryClient, thread); prependProjectPromptHistory( queryClient, request.projectId, diff --git a/apps/app/src/lib/command-palette/palette-thread-search.test.ts b/apps/app/src/lib/command-palette/palette-thread-search.test.ts index 8ddaf6be9ae..8ce19f522ec 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.test.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -49,7 +49,6 @@ function makeThread( environmentWorkspaceDisplayKind: "other", runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, queuedWork: "none", - lifecycle: overrides.archivedAt != null ? "archived" : "active", ...overrides, }; } diff --git a/apps/cli/src/__tests__/command-output/thread-list.test.ts b/apps/cli/src/__tests__/command-output/thread-list.test.ts index 418b466756f..23557696b35 100644 --- a/apps/cli/src/__tests__/command-output/thread-list.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-list.test.ts @@ -16,31 +16,6 @@ describe("bb thread list command output", () => { const register: CommandRegistrar = (program) => registerThreadCommands(program, () => "http://server"); - it("passes explicit lifecycle filters without imposing the legacy archive filter", async () => { - const list = vi.fn(async () => []); - stubServerApi({ "v1.threads.$get": list }); - - await runCommand( - ["thread", "list", "--lifecycle", "draft,archived"], - register, - ); - - expect(list).toHaveBeenCalledWith({ - query: { lifecycles: "draft,archived" }, - }); - }); - - it("rejects an empty lifecycle instead of broadening the list", async () => { - const list = vi.fn(async () => []); - stubServerApi({ "v1.threads.$get": list }); - - await expect( - runCommand(["thread", "list", "--lifecycle", ""], register), - ).rejects.toThrow("process.exit:1"); - - expect(list).not.toHaveBeenCalled(); - }); - it("bb thread list supports parent-thread filtering", async () => { const list = vi.fn(async () => []); stubServerApi({ "v1.threads.$get": list }); diff --git a/apps/cli/src/__tests__/command-output/thread-organization.test.ts b/apps/cli/src/__tests__/command-output/thread-organization.test.ts index 76db1d88c92..5c2edcab641 100644 --- a/apps/cli/src/__tests__/command-output/thread-organization.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-organization.test.ts @@ -41,33 +41,6 @@ describe("bb thread organization commands", () => { const register: CommandRegistrar = (program) => registerThreadCommands(program, () => "http://server"); - it("forwards lifecycle filtering and the per-group limit", async () => { - const search = vi.fn(async () => ({ - active: { total: 0, results: [] }, - archived: { total: 0, results: [] }, - draft: { total: 0, results: [] }, - })); - stubServerApi({ "v1.threads.search.$get": search }); - - await runCommand( - [ - "thread", - "search", - "release", - "--lifecycle", - "draft", - "--limit", - "3", - "--json", - ], - register, - ); - - expect(search).toHaveBeenCalledWith({ - query: { query: "release", lifecycles: "draft", limitPerGroup: "3" }, - }); - }); - it("creates a named thread section", async () => { const create = vi.fn(async () => ({ id: "section-review", diff --git a/apps/cli/src/commands/thread/helpers.ts b/apps/cli/src/commands/thread/helpers.ts index 9179b0656fd..d7bbee371cc 100644 --- a/apps/cli/src/commands/thread/helpers.ts +++ b/apps/cli/src/commands/thread/helpers.ts @@ -8,8 +8,6 @@ import { type PromptInput, serviceTierSchema, type ServiceTier, - threadLifecycleSchema, - type ThreadLifecycle, } from "@bb/domain"; import { DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS, @@ -31,25 +29,6 @@ export const PERMISSION_MODE_HELP = export const PLAN_HELP = "Send the message as the provider's /plan action so the agent proposes a plan for approval before executing"; -export function parseThreadLifecycles( - value: string | undefined, -): ThreadLifecycle[] | undefined { - if (value === undefined) return undefined; - return [ - ...new Set( - value.split(",").map((entry) => { - const result = threadLifecycleSchema.safeParse(entry.trim()); - if (!result.success) { - throw new Error( - "--lifecycle must contain active, draft, or archived, separated by commas.", - ); - } - return result.data; - }), - ), - ]; -} - export function buildPromptInputs(args: { message: string; files?: readonly string[]; diff --git a/apps/cli/src/commands/thread/list.ts b/apps/cli/src/commands/thread/list.ts index ec71258b411..eb4111bd248 100644 --- a/apps/cli/src/commands/thread/list.ts +++ b/apps/cli/src/commands/thread/list.ts @@ -9,14 +9,12 @@ import { truncateCell, } from "../../table.js"; import { outputJson } from "../helpers.js"; -import { parseThreadLifecycles } from "./helpers.js"; interface ThreadListCommandOptions { environment?: string; project?: string; parentThread?: string; archived?: boolean; - lifecycle?: string; section?: string; unsectioned?: boolean; json?: boolean; @@ -36,10 +34,6 @@ export function registerListCommand( .option("--section ", "Filter by thread section ID") .option("--unsectioned", "Show only threads outside sections") .option("--archived", "Show only archived threads") - .option( - "--lifecycle ", - "Filter by active, draft, or archived (comma-separated)", - ) .option("--include-hidden", "Include hidden threads") .option("--json", "Print machine-readable JSON output") .action( @@ -64,9 +58,7 @@ export function registerListCommand( flagName: "--section", value: opts.section, }); - const lifecycles = parseThreadLifecycles(opts.lifecycle); const threads = await sdk.threads.list({ - ...(lifecycles === undefined ? {} : { lifecycles }), ...(projectId ? { projectId } : {}), ...(environmentId ? { environmentId } : {}), ...(parentThreadId ? { parentThreadId } : {}), diff --git a/apps/cli/src/commands/thread/organization.ts b/apps/cli/src/commands/thread/organization.ts index a1f7955a721..f5e53141b51 100644 --- a/apps/cli/src/commands/thread/organization.ts +++ b/apps/cli/src/commands/thread/organization.ts @@ -22,11 +22,7 @@ import { outputJson, requireThreadIdOrSelf, } from "../helpers.js"; -import { - buildPromptInputs, - parseThreadLifecycles, - uploadClientAttachmentInputs, -} from "./helpers.js"; +import { buildPromptInputs, uploadClientAttachmentInputs } from "./helpers.js"; interface JsonOptions { json?: boolean; @@ -42,7 +38,6 @@ interface SectionDeleteOptions extends JsonOptions { interface SearchOptions extends JsonOptions { limit?: string; - lifecycle?: string; } interface HistoryOptions extends JsonOptions { @@ -235,10 +230,6 @@ export function registerOrganizationCommands( parent .command("search ") .description("Search threads and messages") - .option( - "--lifecycle ", - "Filter by active, draft, or archived (comma-separated)", - ) .option( "--limit ", `Maximum results per group (1-${THREAD_SEARCH_LIMIT_PER_GROUP_MAX})`, @@ -246,10 +237,8 @@ export function registerOrganizationCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (query: string, opts: SearchOptions) => { - const lifecycles = parseThreadLifecycles(opts.lifecycle); const result = await createCliBbSdk(getUrl()).threads.search({ query, - ...(lifecycles === undefined ? {} : { lifecycles }), limitPerGroup: parsePositiveInteger( opts.limit, "--limit", diff --git a/apps/cli/src/json-shapes.ts b/apps/cli/src/json-shapes.ts index 59db40e053a..2cfce1d7beb 100644 --- a/apps/cli/src/json-shapes.ts +++ b/apps/cli/src/json-shapes.ts @@ -2,7 +2,7 @@ export const JSON_SHAPE_BY_COMMAND_PATH: Readonly> = { status: "{project: {id, name} | null, thread: {id, status, title, parentThreadId, environment: {hostId, display} | null} | null, childThreads: [{id, status, title}] | null, pendingTodos, pluginsNeedingAttention: [{id, status}], dataDir}", "thread list": - "[{id, projectId, environmentId, providerId, title, status, lifecycle, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null)", + "[{id, projectId, environmentId, providerId, title, status, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null)", "thread show": "{thread: {id, status, title, projectId, environmentId, parentThreadId, ...}, environment: {id, hostId, path, branchName, ...} | null, pendingTodos} (thread fields are under .thread)", "thread log": @@ -11,8 +11,7 @@ export const JSON_SHAPE_BY_COMMAND_PATH: Readonly> = { "thread spawn": "the created thread: {id, status, title, projectId, environmentId, ...}", "thread wait": "{threadId, matched: true, target}", - "thread search": - "{active: {total, results}, archived: {total, results}, draft?: {total, results}} (draft group present with --lifecycle)", + "thread search": "{active: {total, results}, archived: {total, results}}", "project list": "[{id, kind, name, gitRemoteUrl, sources: [{id, hostId, path, isDefault}]}] (bare array)", "machine list": diff --git a/apps/demo-server/src/fixtures/world.ts b/apps/demo-server/src/fixtures/world.ts index 5014f6b5fdc..b1060de990d 100644 --- a/apps/demo-server/src/fixtures/world.ts +++ b/apps/demo-server/src/fixtures/world.ts @@ -84,7 +84,6 @@ export function threadListEntry( environmentIsWorktree: null, environmentWorkspaceDisplayKind: "other", queuedWork: "none", - lifecycle: "active", }; } diff --git a/apps/server/src/routes/threads/base.ts b/apps/server/src/routes/threads/base.ts index 999e7f404b0..e26770630a7 100644 --- a/apps/server/src/routes/threads/base.ts +++ b/apps/server/src/routes/threads/base.ts @@ -17,7 +17,6 @@ import { type UpdateThreadInput, } from "@bb/db"; import type { Environment, Thread, ThreadListEntry } from "@bb/domain"; -import { threadLifecycleSchema } from "@bb/domain"; import { toEnvironmentResponse } from "../../services/environments/environment-response.js"; import { threadIncludeOptionSchema, @@ -86,7 +85,6 @@ interface BuildThreadSearchGroupResponseArgs { interface BuildThreadSearchResponseArgs { active: DbThreadSearchResultGroup; - draft?: DbThreadSearchResultGroup; archived: DbThreadSearchResultGroup; } @@ -206,11 +204,6 @@ function buildThreadSearchResponse( ): ThreadSearchResponse { return { active: buildThreadSearchGroupResponse(deps, { group: args.active }), - ...(args.draft === undefined - ? {} - : { - draft: buildThreadSearchGroupResponse(deps, { group: args.draft }), - }), archived: buildThreadSearchGroupResponse(deps, { group: args.archived }), }; } @@ -279,13 +272,6 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { requireThreadSection(deps, query.sectionId); } const threads = listThreadsWithPendingInteractionState(deps.db, { - ...(query.lifecycles === undefined - ? {} - : { - lifecycles: query.lifecycles - .split(",") - .map((value) => threadLifecycleSchema.parse(value)), - }), ...(query.projectId ? { projectId: query.projectId } : {}), ...(query.environmentId ? { environmentId: query.environmentId } : {}), ...(query.parentThreadId ? { parentThreadId: query.parentThreadId } : {}), @@ -322,13 +308,6 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { ...searchThreadsWithPendingInteractionState(deps.db, { query: searchQuery, limitPerGroup, - ...(query.lifecycles === undefined - ? {} - : { - lifecycles: query.lifecycles - .split(",") - .map((value) => threadLifecycleSchema.parse(value)), - }), }), }) satisfies ThreadSearchResponse, ); diff --git a/apps/server/src/services/threads/thread-runtime-display.ts b/apps/server/src/services/threads/thread-runtime-display.ts index b51372589f1..4dc73f35993 100644 --- a/apps/server/src/services/threads/thread-runtime-display.ts +++ b/apps/server/src/services/threads/thread-runtime-display.ts @@ -19,7 +19,6 @@ import type { ThreadActivityState, ThreadChangeMetadata, ThreadListEntry, - ThreadLifecycle, ThreadQueuedWork, ThreadRuntimeState, ThreadStatus, @@ -88,7 +87,6 @@ interface ToThreadListEntryResponseFromLatestSessionArgs { latestSession: HostDaemonSessionRow | null; now?: number; queuedWork: ThreadQueuedWork; - lifecycle: ThreadLifecycle; thread: ThreadWithPendingInteractionState; } @@ -524,19 +522,16 @@ function buildThreadActivityStateByThreadId( function buildThreadQueuedWorkByThreadId( deps: ThreadRuntimeDisplayDeps, threads: readonly Thread[], -): Map { - const result = new Map< - string, - { queuedWork: ThreadQueuedWork; hasDraft: boolean } - >(); +): Map { + const result = new Map(); for (const counts of listQueuedThreadMessageCountsByThreadIds(deps.db, { threadIds: threads.map((thread) => thread.id), })) { if (counts.queuedMessageCount === 0) continue; - result.set(counts.threadId, { - queuedWork: counts.failedQueuedMessageCount > 0 ? "failed" : "waiting", - hasDraft: counts.draftQueuedMessageCount > 0, - }); + result.set( + counts.threadId, + counts.failedQueuedMessageCount > 0 ? "failed" : "waiting", + ); } return result; } @@ -573,16 +568,9 @@ export function toThreadListEntryResponses( args.threads, ); return args.threads.map((thread) => { - const queue = queuedWorkByThreadId.get(thread.id); return toThreadListEntryResponseFromLatestSession({ activity: activityByThreadId.get(thread.id) ?? EMPTY_THREAD_ACTIVITY, - queuedWork: queue?.queuedWork ?? "none", - lifecycle: - thread.archivedAt !== null - ? "archived" - : thread.status === "pending" && queue?.hasDraft === true - ? "draft" - : "active", + queuedWork: queuedWorkByThreadId.get(thread.id) ?? "none", hostConnected: thread.environmentHostId !== null && connectedActiveHostIds.has(thread.environmentHostId), @@ -604,7 +592,6 @@ function toThreadListEntryResponseFromLatestSession( ...thread, activity: args.activity, queuedWork: args.queuedWork, - lifecycle: args.lifecycle, pinSortKey: args.thread.pinSortKey, environmentBranchName: args.thread.environmentBranchName, environmentHostId: args.thread.environmentHostId, diff --git a/apps/server/test/public/public-thread-search.test.ts b/apps/server/test/public/public-thread-search.test.ts index 09f9a508dd3..e20f02e608d 100644 --- a/apps/server/test/public/public-thread-search.test.ts +++ b/apps/server/test/public/public-thread-search.test.ts @@ -1,8 +1,5 @@ import { archiveThread, createQueuedThreadMessage } from "@bb/db"; -import { - threadListResponseSchema, - threadSearchResponseSchema, -} from "@bb/server-contract"; +import { threadSearchResponseSchema } from "@bb/server-contract"; import { describe, expect, it } from "vitest"; import { readJson } from "../helpers/json.js"; import { @@ -13,6 +10,44 @@ import { import { withTestHarness } from "../helpers/test-app.js"; describe("public thread search route", () => { + it("finds saved first messages and follow-ups in the existing thread groups", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { hostId: host.id }); + const first = seedThread(harness.deps, { projectId: project.id, status: "pending" }); + const followup = seedThread(harness.deps, { projectId: project.id }); + const archived = seedThread(harness.deps, { projectId: project.id }); + const hidden = seedThread(harness.deps, { projectId: project.id, visibility: "hidden" }); + for (const thread of [first, followup, archived, hidden]) { + createQueuedThreadMessage(harness.db, harness.deps.hub, { + threadId: thread.id, + content: [{ type: "text", text: "Juniper saved message", mentions: [] }], + model: "gpt-5", + reasoningLevel: "medium", + permissionMode: "full", + serviceTier: "default", + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, + payload: { kind: "inline" }, + systemNotice: null, + }); + } + archiveThread(harness.db, harness.deps.hub, archived.id); + const response = await harness.app.request("/api/v1/threads/search?query=Juniper"); + expect(response.status).toBe(200); + const body = threadSearchResponseSchema.parse(await readJson(response)); + expect(Object.keys(body)).toEqual(["active", "archived"]); + expect(new Set(body.active.results.map((result) => result.thread.id))).toEqual(new Set([first.id, followup.id])); + expect(body.archived.results.map((result) => result.thread.id)).toEqual([archived.id]); + for (const result of [...body.active.results, ...body.archived.results]) { + expect(result.thread).not.toHaveProperty("lifecycle"); + expect(result.matches).toEqual(expect.arrayContaining([ + expect.objectContaining({ text: "Juniper saved message", sourceSeq: null }), + ])); + } + }); + }); + it("returns active and archived search result groups", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps); @@ -87,101 +122,4 @@ describe("public thread search route", () => { expect(badLimitResponse.status).toBe(400); }); }); - - it("opts into lifecycle list filters and a separate draft search group", async () => { - await withTestHarness(async (harness) => { - const { host } = seedHostSession(harness.deps); - const { project } = seedProjectWithSource(harness.deps, { - hostId: host.id, - }); - const draft = seedThread(harness.deps, { - projectId: project.id, - status: "pending", - title: "lifecycleroute draft", - }); - const active = seedThread(harness.deps, { - projectId: project.id, - title: "lifecycleroute active", - }); - const archived = seedThread(harness.deps, { - projectId: project.id, - title: "lifecycleroute archived", - }); - const hidden = seedThread(harness.deps, { - projectId: project.id, - status: "pending", - title: "lifecycleroute hidden", - visibility: "hidden", - }); - for (const thread of [draft, hidden]) { - createQueuedThreadMessage(harness.db, harness.deps.hub, { - threadId: thread.id, - content: [{ type: "text", text: "Saved draft", mentions: [] }], - model: "gpt-5", - reasoningLevel: "medium", - permissionMode: "full", - serviceTier: "default", - waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, - sendAt: null, - payload: { kind: "inline" }, - systemNotice: null, - }); - } - archiveThread(harness.db, harness.deps.hub, archived.id); - const legacyResponse = await harness.app.request( - "/api/v1/threads/search?query=lifecycleroute", - ); - const legacy = threadSearchResponseSchema.parse( - await readJson(legacyResponse), - ); - expect(Object.keys(legacy)).toEqual(["active", "archived"]); - expect( - new Set(legacy.active.results.map((result) => result.thread.id)), - ).toEqual(new Set([draft.id, active.id])); - const response = await harness.app.request( - "/api/v1/threads/search?query=lifecycleroute&lifecycles=draft,archived", - ); - const body = threadSearchResponseSchema.parse(await readJson(response)); - expect(response.status).toBe(200); - expect(body.active).toEqual({ total: 0, results: [] }); - expect( - body.draft?.results.map((result) => [ - result.thread.id, - result.thread.lifecycle, - ]), - ).toEqual([[draft.id, "draft"]]); - expect(body.archived.results.map((result) => result.thread.id)).toEqual([ - archived.id, - ]); - const listResponse = await harness.app.request( - `/api/v1/threads?projectId=${project.id}&lifecycles=draft&limit=1`, - ); - expect(listResponse.status).toBe(200); - expect( - threadListResponseSchema - .parse(await readJson(listResponse)) - .map((thread) => thread.id), - ).toEqual([draft.id]); - const intersection = await harness.app.request( - `/api/v1/threads?projectId=${project.id}&lifecycles=draft&archived=true`, - ); - expect(await readJson(intersection)).toEqual([]); - for (const lifecycles of ["", "unknown", "draft,", "draft,unknown"]) { - expect( - ( - await harness.app.request( - `/api/v1/threads?lifecycles=${lifecycles}`, - ) - ).status, - ).toBe(400); - expect( - ( - await harness.app.request( - `/api/v1/threads/search?query=lifecycleroute&lifecycles=${lifecycles}`, - ) - ).status, - ).toBe(400); - } - }); - }); }); diff --git a/apps/server/test/services/threads/thread-runtime-display.test.ts b/apps/server/test/services/threads/thread-runtime-display.test.ts index 5f2137aeed6..2deaaf650c0 100644 --- a/apps/server/test/services/threads/thread-runtime-display.test.ts +++ b/apps/server/test/services/threads/thread-runtime-display.test.ts @@ -3,8 +3,6 @@ import { eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { appendStoredThreadEvent, - archiveThread, - claimQueuedThreadMessage, closeSession, createConnection, createEnvironment, @@ -521,66 +519,6 @@ describe("thread runtime display", () => { ).toHaveLength(32_767); }); - it("projects only unarchived pending threads with live Drafts holds as drafts", () => { - const { db, hostId, hub } = setup(); - const saved = createThreadWithEnvironment({ - db, - hostId, - status: "pending", - }); - const followup = createThreadWithEnvironment({ - db, - hostId, - status: "idle", - }); - const archived = createThreadWithEnvironment({ - db, - hostId, - status: "pending", - }); - const claimed = createThreadWithEnvironment({ - db, - hostId, - status: "pending", - }); - const pending = createThreadWithEnvironment({ - db, - hostId, - status: "pending", - }); - for (const fixture of [saved, followup, archived, claimed]) { - const queued = createQueuedThreadMessage(db, noopNotifier, { - threadId: fixture.thread.id, - content: [{ type: "text", text: "Saved draft", mentions: [] }], - model: "gpt-5", - reasoningLevel: "medium", - permissionMode: "auto", - serviceTier: "default", - waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, - sendAt: null, - payload: { kind: "inline" }, - systemNotice: null, - }); - if (fixture === claimed) - claimQueuedThreadMessage(db, noopNotifier, queued.id); - } - archiveThread(db, noopNotifier, archived.thread.id); - const entries = toThreadListEntryResponses( - { db, hub, providerRegistry }, - { - threads: listThreadsWithPendingInteractionState(db, {}), - }, - ); - const byId = new Map(entries.map((entry) => [entry.id, entry])); - expect( - [saved, followup, archived, claimed, pending].map( - (fixture) => byId.get(fixture.thread.id)?.lifecycle, - ), - ).toEqual(["draft", "active", "archived", "active", "active"]); - expect(byId.get(saved.thread.id)?.queuedWork).toBe("waiting"); - expect(byId.get(claimed.thread.id)?.queuedWork).toBe("none"); - }); - it("marks list entries active when the prompt banner would show plan or goal state", () => { const { db, hostId, hub } = setup(); const activePlan = createThreadWithEnvironment({ db, hostId }); diff --git a/apps/server/test/threads/dispatch-hooks.test.ts b/apps/server/test/threads/dispatch-hooks.test.ts index aa984fcfc75..89ddc629324 100644 --- a/apps/server/test/threads/dispatch-hooks.test.ts +++ b/apps/server/test/threads/dispatch-hooks.test.ts @@ -7,7 +7,6 @@ import { listQueuedThreadMessages, listQueuedThreadMessagesForApi, listRunningThreads, - listThreadsWithPendingInteractionState, setQueuedThreadMessageGroupBoundary, } from "@bb/db"; import type { ThreadQueuedMessage } from "@bb/domain"; @@ -492,11 +491,7 @@ describe("message.dispatch hook context", () => { }); expect(seen).toEqual([pluginSubmission]); - expect( - listThreadsWithPendingInteractionState(harness.db, { - lifecycles: ["draft"], - }).map((thread) => thread.id), - ).toEqual([created.id]); + expect(getThread(harness.db, created.id)?.status).toBe("pending"); const saved = onlyQueuedRow(harness, created.id); await sendQueuedMessage(harness.deps, { threadId: created.id, @@ -505,16 +500,7 @@ describe("message.dispatch hook context", () => { claimPolicy: { kind: "explicit-send" }, }); expect(seen).toEqual([pluginSubmission]); - expect( - listThreadsWithPendingInteractionState(harness.db, { - lifecycles: ["draft"], - }), - ).toEqual([]); - expect( - listThreadsWithPendingInteractionState(harness.db, { - lifecycles: ["active"], - }).map((thread) => thread.id), - ).toContain(created.id); + expect(getThread(harness.db, created.id)?.status).not.toBe("pending"); expect(queuedRows(harness, created.id)).toEqual([]); }); }); diff --git a/packages/db/src/data/queued-thread-messages.ts b/packages/db/src/data/queued-thread-messages.ts index c76e004e1d1..79a029c6a60 100644 --- a/packages/db/src/data/queued-thread-messages.ts +++ b/packages/db/src/data/queued-thread-messages.ts @@ -1572,7 +1572,6 @@ export function listQueuedThreadMessagesForApi( export interface QueuedThreadMessageCounts { threadId: string; queuedMessageCount: number; - draftQueuedMessageCount: number; /** * How many of those rows last failed to dispatch. Counted in the same pass * as the total because both answers come from the same rows, and the thread @@ -1606,7 +1605,6 @@ export function listQueuedThreadMessageCountsByThreadIds( threadId: queuedThreadMessages.threadId, queuedMessageCount: count(queuedThreadMessages.id), failedQueuedMessageCount: count(queuedThreadMessages.failureReason), - draftQueuedMessageCount: sql`count(CASE WHEN ${queuedThreadMessages.waitHolder} = 'plugin:drafts' THEN 1 END)`.mapWith(Number), }) .from(queuedThreadMessages) .where( diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index ea3390124be..9375627e1bb 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -14,14 +14,12 @@ import { or, sql, type SQL, - type SQLWrapper, } from "drizzle-orm"; import type { JsonObject, ReasoningLevel, ThreadChangeKind, ThreadLifecycleEvent, - ThreadLifecycle, ThreadLifecycleNoopReason, ThreadOriginKind, ThreadSearchSourceKind, @@ -108,14 +106,12 @@ export interface ThreadSearchResultGroup { export interface ThreadSearchResults { active: ThreadSearchResultGroup; - draft?: ThreadSearchResultGroup; archived: ThreadSearchResultGroup; } export interface SearchThreadsWithPendingInteractionStateArgs { query: string; limitPerGroup: number; - lifecycles?: readonly ThreadLifecycle[]; } export interface UpsertThreadTitleSearchSegmentsArgs { @@ -147,11 +143,10 @@ interface ListThreadSearchMatchRowsArgs { anyTokenMatchQuery: string; limitPerGroup: number; tokenMatchQueries: readonly string[]; - lifecycles?: readonly ThreadLifecycle[]; } interface ThreadSearchMatchRow { - lifecycle: ThreadLifecycle; + archived: number; segmentOrder: number; sourceKind: string; sourceSeq: number | null; @@ -418,7 +413,6 @@ export interface ListThreadsOptions { projectId?: string; environmentId?: string; archived?: boolean; - lifecycles?: readonly ThreadLifecycle[]; sectionId?: string; unsectioned?: boolean; parentThreadId?: string; @@ -682,27 +676,8 @@ function statusTransitionNeedsAttention(args: StatusTransition): boolean { return args.currentStatus === "active" || args.currentStatus === "starting"; } -function threadLifecycleSql(thread: { - id: SQLWrapper; - archivedAt: SQLWrapper; - status: SQLWrapper; -} = threads): SQL { - return sql`CASE - WHEN ${thread.archivedAt} IS NOT NULL THEN 'archived' - WHEN ${thread.status} = 'pending' AND ${thread.id} IN ( - SELECT thread_id FROM queued_thread_messages - WHERE wait_holder = 'plugin:drafts' - AND claimed_at IS NULL AND claim_token IS NULL - ) THEN 'draft' - ELSE 'active' - END`; -} - function buildListThreadsFilters(options: ListThreadsOptions) { return [ - options.lifecycles === undefined - ? undefined - : inArray(threadLifecycleSql(), [...options.lifecycles]), options.projectId ? eq(threads.projectId, options.projectId) : undefined, options.environmentId ? eq(threads.environmentId, options.environmentId) @@ -769,9 +744,6 @@ function buildPinnedThreadOrderBy() { } function buildListThreadsOrderBy(options: ListThreadsOptions) { - if (options.lifecycles !== undefined) { - return [desc(threads.updatedAt), desc(threads.id)]; - } if (options.archived === true) { return [desc(threads.archivedAt), desc(threads.id)]; } @@ -1021,17 +993,6 @@ function listThreadSearchMatchRows( ); const isTitleSegment = sql`thread_search_segments.source_kind IN ('title', 'title_fallback')`; - const lifecycle = args.lifecycles === undefined - ? sql`CASE WHEN t.archived_at IS NOT NULL THEN 'archived' ELSE 'active' END` - : threadLifecycleSql({ - id: sql`t.id`, - archivedAt: sql`t.archived_at`, - status: sql`t.status`, - }); - const lifecycleFilter = args.lifecycles === undefined - ? sql`1 = 1` - : inArray(sql`lifecycle`, [...args.lifecycles]); - return db.all(sql` WITH token_matches AS ( ${sql.join(tokenMatchSelects, sql` UNION ALL `)} @@ -1041,7 +1002,7 @@ function listThreadSearchMatchRows( token_matches.threadId AS threadId, MIN(token_matches.tokenRank) AS bestRank, MAX(t.updated_at) AS threadUpdatedAt, - ${lifecycle} AS lifecycle + MAX(t.archived_at IS NOT NULL) AS archived FROM token_matches JOIN threads AS t ON t.id = token_matches.threadId WHERE t.deleted_at IS NULL @@ -1052,23 +1013,22 @@ function listThreadSearchMatchRows( ordered_threads AS ( SELECT threadId, - lifecycle, + archived, ROW_NUMBER() OVER ( - PARTITION BY lifecycle + PARTITION BY archived ORDER BY bestRank ASC, threadUpdatedAt DESC, threadId DESC ) AS threadOrder, - COUNT(*) OVER (PARTITION BY lifecycle) AS total + COUNT(*) OVER (PARTITION BY archived) AS total FROM ranked_threads - WHERE ${lifecycleFilter} ), limited_threads AS ( - SELECT threadId, lifecycle, threadOrder, total + SELECT threadId, archived, threadOrder, total FROM ordered_threads WHERE threadOrder <= ${args.limitPerGroup} ), ranked_segments AS ( SELECT - limited_threads.lifecycle AS lifecycle, + limited_threads.archived AS archived, limited_threads.threadOrder AS threadOrder, limited_threads.total AS total, ROW_NUMBER() OVER ( @@ -1091,7 +1051,7 @@ function listThreadSearchMatchRows( WHERE thread_search_segments_fts MATCH ${args.anyTokenMatchQuery} ) SELECT - lifecycle, + archived, threadOrder, total, segmentOrder, @@ -1103,7 +1063,7 @@ function listThreadSearchMatchRows( FROM ranked_segments WHERE isTitle = 1 OR segmentOrder <= ${THREAD_SEARCH_MESSAGE_MATCHES_PER_THREAD} - ORDER BY lifecycle ASC, threadOrder ASC, isTitle DESC, segmentOrder ASC + ORDER BY archived ASC, threadOrder ASC, isTitle DESC, segmentOrder ASC `); } @@ -1176,7 +1136,6 @@ export function searchThreadsWithPendingInteractionState( if (anyTokenMatchQuery === null) { return { active: { total: 0, results: [] }, - ...(args.lifecycles === undefined ? {} : { draft: { total: 0, results: [] } }), archived: { total: 0, results: [] }, }; } @@ -1189,23 +1148,16 @@ export function searchThreadsWithPendingInteractionState( anyTokenMatchQuery, limitPerGroup, tokenMatchQueries, - ...(args.lifecycles === undefined ? {} : { lifecycles: args.lifecycles }), }); return { active: hydrateThreadSearchGroup(db, { tokens, - rows: rows.filter((row) => row.lifecycle === "active"), - }), - ...(args.lifecycles === undefined ? {} : { - draft: hydrateThreadSearchGroup(db, { - tokens, - rows: rows.filter((row) => row.lifecycle === "draft"), - }), + rows: rows.filter((row) => row.archived === 0), }), archived: hydrateThreadSearchGroup(db, { tokens, - rows: rows.filter((row) => row.lifecycle === "archived"), + rows: rows.filter((row) => row.archived === 1), }), }; } diff --git a/packages/db/test/data/thread-discovery-lifecycle.test.ts b/packages/db/test/data/thread-discovery-lifecycle.test.ts deleted file mode 100644 index 903728bbfca..00000000000 --- a/packages/db/test/data/thread-discovery-lifecycle.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { eq } from "drizzle-orm"; -import { describe, expect, it, vi } from "vitest"; -import { threadScope, type ThreadLifecycle } from "@bb/domain"; -import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; -import { noopNotifier } from "../../src/notifier.js"; -import { threads } from "../../src/schema.js"; -import { insertEvents, listEvents } from "../../src/data/events.js"; -import { upsertHost } from "../../src/data/hosts.js"; -import { createProject } from "../../src/data/projects.js"; -import { - claimQueuedThreadMessage, - createQueuedThreadMessage, - deleteQueuedThreadMessage, - getQueuedThreadMessage, - listQueuedThreadMessageCountsByThreadIds, - setQueuedThreadMessageFailureReason, -} from "../../src/data/queued-thread-messages.js"; -import { - archiveThread, - createThread, - getThread, - listThreadsWithPendingInteractionState, - searchThreadsWithPendingInteractionState, - unarchiveThread, -} from "../../src/data/threads.js"; - -function setup() { - const db = createConnection(":memory:"); - migrate(db); - const host = upsertHost(db, noopNotifier, { name: "lifecycle-host" }); - const { project } = createProject(db, noopNotifier, { - name: "lifecycle-project", - source: { type: "local_path", hostId: host.id, path: "/tmp/lifecycle" }, - }); - function thread(status: "pending" | "idle" = "pending") { - return createThread(db, noopNotifier, { - projectId: project.id, - providerId: "codex", - status, - title: "discovery lifecycle", - }); - } - function draft(threadId: string, pluginId = "drafts") { - return createQueuedThreadMessage(db, noopNotifier, { - threadId, - content: [{ type: "text", text: "saved message", mentions: [] }], - model: "gpt-5", - reasoningLevel: "medium", - permissionMode: "full", - serviceTier: "default", - waitingOn: { kind: "plugin", pluginId, reason: "Draft" }, - sendAt: null, - payload: { kind: "inline" }, - systemNotice: null, - }); - } - function list(lifecycles: readonly ThreadLifecycle[]) { - return listThreadsWithPendingInteractionState(db, { lifecycles }).map((row) => row.id); - } - return { db, host, project, thread, draft, list }; -} - -describe("derived thread lifecycle discovery", () => { - it("recognizes live Drafts waits, preserves archive precedence and counts in one grouped pass", () => { - const { db, thread, draft, list } = setup(); - try { - const saved = thread(); - const savedRow = draft(saved.id); - const followup = thread("idle"); - draft(followup.id); - const other = thread(); - draft(other.id, "scheduler"); - const claimed = thread(); - const claimedRow = draft(claimed.id); - claimQueuedThreadMessage(db, noopNotifier, claimedRow.id); - setQueuedThreadMessageFailureReason(db, noopNotifier, { - threadId: saved.id, - id: savedRow.id, - failureReason: "Host unavailable", - }); - const prepare = vi.spyOn(db.$client, "prepare"); - try { - expect(listQueuedThreadMessageCountsByThreadIds(db, { - threadIds: [saved.id, followup.id, other.id, claimed.id], - })).toEqual(expect.arrayContaining([ - { threadId: saved.id, queuedMessageCount: 1, failedQueuedMessageCount: 1, draftQueuedMessageCount: 1 }, - { threadId: followup.id, queuedMessageCount: 1, failedQueuedMessageCount: 0, draftQueuedMessageCount: 1 }, - { threadId: other.id, queuedMessageCount: 1, failedQueuedMessageCount: 0, draftQueuedMessageCount: 0 }, - ])); - expect(prepare).toHaveBeenCalledTimes(1); - } finally { - prepare.mockRestore(); - } - expect(list(["draft"])).toEqual([saved.id]); - expect(new Set(list(["active"]))).toEqual(new Set([followup.id, other.id, claimed.id])); - archiveThread(db, noopNotifier, saved.id); - expect(list(["draft"])).toEqual([]); - expect(list(["archived"])).toEqual([saved.id]); - unarchiveThread(db, noopNotifier, saved.id); - expect(list(["draft"])).toEqual([saved.id]); - expect(getQueuedThreadMessage(db, savedRow.id)?.waitingOn).toContain('"drafts"'); - } finally { - db.$client.close(); - } - }); - - it("filters before list offsets and search limits while legacy search keeps drafts active", () => { - const { db, thread, draft } = setup(); - try { - const first = thread(); - const second = thread(); - draft(first.id); - draft(second.id); - db.update(threads).set({ createdAt: 1, updatedAt: 1 }).where(eq(threads.id, first.id)).run(); - db.update(threads).set({ createdAt: 2, updatedAt: 2 }).where(eq(threads.id, second.id)).run(); - for (let index = 0; index < 24; index += 1) thread("idle"); - const archived = thread(); - draft(archived.id); - archiveThread(db, noopNotifier, archived.id); - const hidden = thread(); - draft(hidden.id); - db.update(threads).set({ visibility: "hidden" }).where(eq(threads.id, hidden.id)).run(); - const deleted = thread(); - draft(deleted.id); - db.update(threads).set({ deletedAt: Date.now() }).where(eq(threads.id, deleted.id)).run(); - - expect(listThreadsWithPendingInteractionState(db, { - lifecycles: ["draft"], limit: 1, offset: 1, - }).map((row) => row.id)).toEqual([first.id]); - expect(listThreadsWithPendingInteractionState(db, { - lifecycles: ["draft"], archived: true, - })).toEqual([]); - const legacy = searchThreadsWithPendingInteractionState(db, { - query: "discovery", limitPerGroup: 50, - }); - expect(Object.keys(legacy)).toEqual(["active", "archived"]); - expect(legacy.active.total).toBe(26); - expect(legacy.active.results.map((result) => result.thread.id)).toContain(first.id); - const filtered = searchThreadsWithPendingInteractionState(db, { - query: "discovery", limitPerGroup: 1, lifecycles: ["draft"], - }); - expect(filtered.active).toEqual({ total: 0, results: [] }); - expect(filtered.archived).toEqual({ total: 0, results: [] }); - expect(filtered.draft?.total).toBe(2); - expect(filtered.draft?.results.map((result) => result.thread.id)).toEqual([second.id]); - const all = searchThreadsWithPendingInteractionState(db, { - query: "discovery", limitPerGroup: 50, lifecycles: ["active", "draft", "archived"], - }); - expect([all.active.total, all.draft?.total, all.archived.total]).toEqual([24, 2, 1]); - expect(all.archived.results.map((result) => result.thread.id)).toEqual([archived.id]); - } finally { - db.$client.close(); - } - }); - - it("bounds lifecycle lists by global recency without changing legacy project or archive ordering", () => { - const { db, host, project, draft } = setup(); - try { - const { project: otherProject } = createProject(db, noopNotifier, { - name: "other-lifecycle-project", - source: { type: "local_path", hostId: host.id, path: "/tmp/other-lifecycle" }, - }); - const older = createThread(db, noopNotifier, { - projectId: project.id < otherProject.id ? project.id : otherProject.id, - providerId: "codex", - status: "pending", - }); - const recent = createThread(db, noopNotifier, { - projectId: project.id < otherProject.id ? otherProject.id : project.id, - providerId: "codex", - status: "pending", - }); - draft(older.id); - draft(recent.id); - db.update(threads).set({ createdAt: 20, updatedAt: 30, pinnedAt: 10 }).where(eq(threads.id, older.id)).run(); - db.update(threads).set({ createdAt: 10, updatedAt: 40 }).where(eq(threads.id, recent.id)).run(); - - expect(listThreadsWithPendingInteractionState(db, { - lifecycles: ["draft"], limit: 1, - }).map((row) => row.id)).toEqual([recent.id]); - expect(listThreadsWithPendingInteractionState(db, { - lifecycles: ["draft"], limit: 1, offset: 1, - }).map((row) => row.id)).toEqual([older.id]); - expect(listThreadsWithPendingInteractionState(db, { - archived: false, limit: 1, - }).map((row) => row.id)).toEqual([older.id]); - - db.update(threads).set({ archivedAt: 60 }).where(eq(threads.id, older.id)).run(); - db.update(threads).set({ archivedAt: 50 }).where(eq(threads.id, recent.id)).run(); - expect(listThreadsWithPendingInteractionState(db, { - lifecycles: ["archived"], archived: true, limit: 1, - }).map((row) => row.id)).toEqual([recent.id]); - expect(listThreadsWithPendingInteractionState(db, { - archived: true, limit: 1, - }).map((row) => row.id)).toEqual([older.id]); - } finally { - db.$client.close(); - } - }); - - it("deletes only the held row and preserves its owning fork and inherited history", () => { - const { db, project, thread, draft, list } = setup(); - try { - const source = thread("idle"); - const fork = createThread(db, noopNotifier, { - projectId: project.id, - providerId: "codex", - status: "pending", - sourceThreadId: source.id, - originKind: "fork", - }); - insertEvents(db, noopNotifier, [{ - threadId: fork.id, - sequence: 1, - type: "item/completed", - scope: threadScope(), - itemId: "inherited-message", - itemKind: "agentMessage", - parentToolCallId: null, - data: JSON.stringify({ item: { id: "inherited-message", type: "agentMessage", text: "Inherited response" } }), - }]); - const saved = draft(fork.id); - const before = listEvents(db, { threadId: fork.id }); - expect(list(["draft"])).toEqual([fork.id]); - expect(deleteQueuedThreadMessage(db, noopNotifier, saved.id)).toBe(true); - expect(getThread(db, fork.id)).toMatchObject({ sourceThreadId: source.id, deletedAt: null, status: "pending" }); - expect(listEvents(db, { threadId: fork.id })).toEqual(before); - expect(list(["draft"])).toEqual([]); - expect(list(["active"])).toContain(fork.id); - } finally { - db.$client.close(); - } - }); -}); diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts index e13e8ff1a20..405edfee51a 100644 --- a/packages/domain/src/thread.ts +++ b/packages/domain/src/thread.ts @@ -437,12 +437,7 @@ export const threadQueuedWorkValues = ["none", "waiting", "failed"] as const; export const threadQueuedWorkSchema = z.enum(threadQueuedWorkValues); export type ThreadQueuedWork = z.infer; -export const threadLifecycleValues = ["active", "draft", "archived"] as const; -export const threadLifecycleSchema = z.enum(threadLifecycleValues); -export type ThreadLifecycle = z.infer; - export const threadListEntrySchema = threadWithRuntimeSchema.extend({ - lifecycle: threadLifecycleSchema, activity: threadActivityStateSchema, queuedWork: threadQueuedWorkSchema, pinSortKey: z.string().nullable(), diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 04d105225c9..8dd6bd7b42f 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -11,7 +11,6 @@ import { type QueuedMessageWaitHolder, type ThreadQueuedMessage, type ThreadStatus, - type ThreadLifecycle, validatePluginMetadata, } from "@bb/domain"; import { @@ -86,7 +85,6 @@ export const DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS = 250; export interface ThreadListArgs { archived?: boolean; - lifecycles?: readonly ThreadLifecycle[]; environmentId?: string; sectionId?: string; hasParent?: boolean; @@ -102,11 +100,7 @@ export interface ThreadListArgs { unsectioned?: boolean; } -export interface ThreadSearchArgs extends Omit< - ThreadSearchQuery, - "lifecycles" -> { - lifecycles?: readonly ThreadLifecycle[]; +export interface ThreadSearchArgs extends ThreadSearchQuery { signal?: AbortSignal; } @@ -643,9 +637,6 @@ function listQuery(args: ThreadListArgs | undefined): ThreadListQuery { ...(args?.archived === undefined ? {} : { archived: args.archived ? "true" : "false" }), - ...(args?.lifecycles === undefined - ? {} - : { lifecycles: args.lifecycles.join(",") }), ...(args?.unsectioned === undefined ? {} : { unsectioned: args.unsectioned ? "true" : "false" }), @@ -782,9 +773,6 @@ function searchQuery(args: ThreadSearchArgs): ThreadSearchQuery { return { limitPerGroup: args.limitPerGroup, query: args.query, - ...(args.lifecycles === undefined - ? {} - : { lifecycles: args.lifecycles.join(",") }), }; } diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index 36b689d23f6..e5fe470a80d 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -1312,40 +1312,6 @@ describe("@bb/sdk", () => { }); }); - it("opts into lifecycle filtering without changing legacy list and search requests", async () => { - const queue = createFetchQueue( - Array.from({ length: 4 }, () => ({ body: [] })), - ); - const sdk = createBbSdk({ - transport: createHttpTransport({ - baseUrl: "http://bb.test", - fetch: queue.fetch, - runtime: "node", - }), - }); - - await sdk.threads.list(); - await sdk.threads.search({ query: "release" }); - await sdk.threads.list({ lifecycles: ["draft", "archived"], limit: 5 }); - await sdk.threads.search({ - query: "release", - lifecycles: ["draft"], - limitPerGroup: "3", - }); - - expect( - queue.requests.map(({ url }) => { - const parsed = new URL(url); - return Object.fromEntries(parsed.searchParams); - }), - ).toEqual([ - {}, - { query: "release" }, - { lifecycles: "draft,archived", limit: "5" }, - { query: "release", lifecycles: "draft", limitPerGroup: "3" }, - ]); - }); - it("forwards every public permission mode through thread surfaces", async () => { const queue = createFetchQueue([ { body: { id: "thr_auto" }, status: 201 }, diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index dc8078de732..6dedb4a04dc 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -23,7 +23,6 @@ import { threadCreateOriginSchema, threadOriginKindSchema, threadListEntrySchema, - threadLifecycleSchema, threadQueuedMessageSchema, threadSearchSourceKindSchema, threadStatusSchema, @@ -477,7 +476,6 @@ export const threadSearchResultGroupSchema = z export const threadSearchResponseSchema = z .object({ active: threadSearchResultGroupSchema, - draft: threadSearchResultGroupSchema.optional(), archived: threadSearchResultGroupSchema, }) .strict(); @@ -753,18 +751,7 @@ export type ThreadArchiveAllResponse = z.infer< typeof threadArchiveAllResponseSchema >; -const threadLifecyclesQuerySchema = z - .string() - .refine( - (value) => - value - .split(",") - .every((entry) => threadLifecycleSchema.safeParse(entry).success), - { message: "Invalid lifecycles" }, - ); - export const threadListQuerySchema = z.object({ - lifecycles: threadLifecyclesQuerySchema.optional(), projectId: z.string().min(1).optional(), environmentId: z.string().min(1).optional(), parentThreadId: z.string().min(1).optional(), @@ -866,7 +853,6 @@ export const threadRunningResponseSchema = z.array(threadRunningEntrySchema); export type ThreadRunningResponse = z.infer; export const threadSearchQuerySchema = z.object({ - lifecycles: threadLifecyclesQuerySchema.optional(), query: z.string().trim().min(2), limitPerGroup: z.string().regex(/^\d+$/).optional(), }); diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 95f98331a71..9f42226af4d 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -507,7 +507,6 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ "threadListQuerySchema.limit", "threadListQuerySchema.hasParent", "threadListQuerySchema.includeHidden", - "threadListQuerySchema.lifecycles", "threadListQuerySchema.offset", "threadListQuerySchema.originKind", "threadListQuerySchema.originPluginId", @@ -1226,7 +1225,6 @@ describe("server-contract canonical schemas", () => { environmentIsWorktree: true, environmentWorkspaceDisplayKind: "managed-worktree", queuedWork: "none", - lifecycle: "active", }, ]), ).toMatchObject([ diff --git a/packages/templates/src/templates/bb-guide-json.md b/packages/templates/src/templates/bb-guide-json.md index 7dedda70403..02162dd6e2e 100644 --- a/packages/templates/src/templates/bb-guide-json.md +++ b/packages/templates/src/templates/bb-guide-json.md @@ -38,7 +38,7 @@ Fields beyond those shown exist; these are the ones scripts use. {project: {id, name} | null, thread: {id, status, title, parentThreadId, environment: {hostId, display} | null} | null, childThreads: [{id, status, title}] | null, pendingTodos, pluginsNeedingAttention: [{id, status}], dataDir} bb thread list --json - [{id, projectId, environmentId, providerId, title, status, lifecycle, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null) + [{id, projectId, environmentId, providerId, title, status, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null) bb thread show --json {thread: {id, status, title, projectId, environmentId, parentThreadId, ...}, environment: {id, hostId, path, branchName, ...} | null, pendingTodos} (thread fields are under .thread) @@ -62,7 +62,7 @@ Fields beyond those shown exist; these are the ones scripts use. {total} bb thread search --json - {active: {total, results}, archived: {total, results}, draft?: {total, results}} (draft group present with --lifecycle) + {active: {total, results}, archived: {total, results}} bb thread section list --json [{id, name, createdAt, updatedAt}] diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 152b027e233..d079cdf2cfc 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -160,7 +160,6 @@ Listing: --environment Filter by environment --parent-thread Filter by parent thread --archived Show only archived threads - --lifecycle Filter by active, draft, or archived (comma-separated) --section Filter by section --unsectioned Show only threads outside sections --include-hidden Include hidden threads @@ -172,15 +171,6 @@ Listing: bb thread search [--limit <1-50>] Search threads and messages - --lifecycle Filter by active, draft, or archived (comma-separated) - - Lifecycle draft means an unarchived pending thread held by Drafts. Saved - follow-ups on an established thread do not change its lifecycle. Archived - takes precedence. Omit --lifecycle to retain the existing list/search groups; - with it, search also returns a draft group. --archived intersects the lifecycle - filter when both are given. Lifecycle-filtered lists sort by last updated, - newest first, before pagination; omitted filters keep existing list ordering. - SDK list/search accept lifecycles as an array. bb thread history List prompt history bb thread count Count threads without listing them @@ -298,8 +288,8 @@ Messaging: combined with --mode steer or auto. SDK callers pass pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } } to threads.spawn or threads.send; follow-up saves use mode: "queue-if-active". - Save-only requests require the Drafts plugin to be available. A saved - follow-up keeps its established thread active; it does not become a draft thread. + Save-only requests require the Drafts plugin to be available. Saved messages + are searchable through their owning threads with bb thread search. --plan sends the same structured /plan command the composer's plan action sends, so the agent proposes a plan for approval before executing (Claude diff --git a/packages/test-helpers/src/domain-fixtures.ts b/packages/test-helpers/src/domain-fixtures.ts index 44a55bc1737..2e17d4e52ab 100644 --- a/packages/test-helpers/src/domain-fixtures.ts +++ b/packages/test-helpers/src/domain-fixtures.ts @@ -186,7 +186,6 @@ export function makeThreadListEntry( environmentIsWorktree: null, environmentWorkspaceDisplayKind: "other", queuedWork: "none", - lifecycle: overrides.archivedAt != null ? "archived" : "active", }; return { ...entry, diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md index 1f19264b8b5..af7b852c319 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md @@ -102,14 +102,6 @@ hostId, providerId, projectId, parentThreadId, groupBy })`. ## Inspecting Results -- Add `--lifecycle active,draft,archived` to thread list/search to select any - nonempty subset. Draft means pending with a Drafts-held first message; - established threads with saved follow-ups stay active. Archived takes - precedence. Omission retains legacy groups; opt-in search adds a draft group. - List `--archived` intersects this filter. SDK list/search accept `lifecycles` - as an array. Filtering precedes result limits and counts. Lifecycle-filtered - lists sort by last updated, newest first, before pagination; omission keeps - existing list ordering. - Use `bb thread search [--limit <1-50>]` for sidebar search. Use `history`, `read|unread`, and `section` for organization and recall. The `bb thread queue` group contains the queued-message operations. Queue updates