feat(ai): run agent sessions as background tasks

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-26 14:09:08 +08:00
1 parent b37a1f8538
commit eeeb4e5011
34 files changed
+3440 -184

No files matched your search

@@ -119,8 +119,10 @@ describe("Windows 7 fixed WebView2 runtime bundle", () => {
it("passes the configured fixed-runtime folder to WebView2 discovery and creation", () => {
expect(workspaceCargoToml).toContain('wry = { path = "vendor/wry" }');
expect(wryWebView2Source).toContain('std::env::var_os("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER")');
expect(wryWebView2Source).toContain(`CreateCoreWebView2EnvironmentWithOptions(
browser_executable_folder_ptr,`);
// The webview2_com crate indents this with 8 spaces; assert on the two
// adjacent lines loosely so a formatter's re-indent doesn't break the test.
expect(wryWebView2Source).toContain("CreateCoreWebView2EnvironmentWithOptions(");
expect(wryWebView2Source).toContain("browser_executable_folder_ptr,");
expect(wryWebView2Source).toContain("GetAvailableCoreWebView2BrowserVersionString(browser_executable_folder_ptr, &mut versioninfo)");
});
@@ -0,0 +1,206 @@
import { afterEach, describe, expect, it } from "vitest";
import {
acquireDesktopAiRunSlot,
activeDesktopAiRuns,
blockingDesktopAiRunsForQuit,
bumpDesktopAiRunSeq,
cancelQueuedDesktopAiRun,
desktopAiRun,
finishDesktopAiRun,
hasActiveDesktopAiRun,
isTerminalDesktopAiRunStatus,
registerDesktopAiRun,
releaseDesktopAiRunSlot,
resetDesktopAiRunRegistryForTests,
} from "@/lib/ai/desktopAiRunRegistry";
import { resolveAiRuntimeStrategy, supportsBackgroundAiRuns } from "@/lib/ai/aiRuntimeStrategy";
describe("AI runtime platform strategy", () => {
afterEach(() => resetDesktopAiRunRegistryForTests());
it("enables detached runs only for the Tauri desktop runtime", () => {
expect(resolveAiRuntimeStrategy(true)).toBe("desktop-background");
expect(supportsBackgroundAiRuns("desktop-background")).toBe(true);
expect(resolveAiRuntimeStrategy(false)).toBe("web-cancel-on-navigation");
expect(supportsBackgroundAiRuns("web-cancel-on-navigation")).toBe(false);
});
it("keeps a desktop run addressable by conversation until terminal", () => {
const run = registerDesktopAiRun({
runId: "run-1",
conversationId: "conversation-1",
sessionIds: ["session-1"],
currentSessionId: "session-1",
status: "running",
messages: [{ content: "partial" }],
assistantMessageIndex: 0,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
expect(desktopAiRun("conversation-1")).toBe(run);
expect(activeDesktopAiRuns()).toEqual([run]);
finishDesktopAiRun(run, "completed");
expect(desktopAiRun("conversation-1")?.status).toBe("completed");
expect(activeDesktopAiRuns()).toEqual([]);
});
it("admits at most three runs and lets a queued run be cancelled", async () => {
const makeRun = (index: number) =>
registerDesktopAiRun({
runId: `run-${index}`,
conversationId: `conversation-${index}`,
sessionIds: [],
currentSessionId: "",
status: "preparing" as const,
messages: [],
assistantMessageIndex: -1,
connectionId: `connection-${index}`,
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
const runs = [1, 2, 3, 4].map(makeRun);
await Promise.all(runs.slice(0, 3).map(acquireDesktopAiRunSlot));
const fourthAdmission = acquireDesktopAiRunSlot(runs[3]);
expect(runs[3].status).toBe("queued");
expect(cancelQueuedDesktopAiRun(runs[3])).toBe(true);
await expect(fourthAdmission).resolves.toBe(false);
runs.slice(0, 3).forEach((run) => releaseDesktopAiRunSlot(run.runId));
});
it("keeps a recovered pending-input run addressable without consuming a slot or blocking quit", async () => {
const run = registerDesktopAiRun({
runId: "run-recovered",
conversationId: "conversation-1",
sessionIds: [],
currentSessionId: "",
status: "pending_recoverable",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
pendingInput: "select * from orders",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
// Addressable (so the row can render "pending input"), but neither an
// active run nor a quit-blocking one.
expect(desktopAiRun("conversation-1")).toBe(run);
expect(activeDesktopAiRuns()).toEqual([]);
expect(blockingDesktopAiRunsForQuit()).toEqual([]);
// It also does not consume a global concurrency slot: a fresh preparing run
// is admitted immediately even though the recovered run is registered.
const fresh = registerDesktopAiRun({
runId: "run-fresh",
conversationId: "conversation-2",
sessionIds: [],
currentSessionId: "",
status: "preparing",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-2",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
const admission = acquireDesktopAiRunSlot(fresh);
expect(fresh.status).toBe("preparing");
await expect(admission).resolves.toBe(true);
releaseDesktopAiRunSlot(fresh.runId);
});
it("assigns strictly increasing event seqs that persist on the run", () => {
const run = registerDesktopAiRun({
runId: "run-seq",
conversationId: "conversation-1",
sessionIds: ["session-1", "session-2"],
currentSessionId: "session-2",
status: "running",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
// Parent PRD §8: seq starts at 1 and strictly increases across sessions.
expect(bumpDesktopAiRunSeq(run)).toBe(1);
expect(bumpDesktopAiRunSeq(run)).toBe(2);
expect(run.maxSeq).toBe(2);
// A recovered run with a persisted maxSeq continues from there.
run.maxSeq = 40;
expect(bumpDesktopAiRunSeq(run)).toBe(41);
expect(desktopAiRun("conversation-1")?.maxSeq).toBe(41);
});
it("treats every non-terminal status as active for queue-send, including pending_recoverable", () => {
const statuses = ["preparing", "queued", "running", "awaiting_write_confirmation", "pending_recoverable"] as const;
for (const status of statuses) {
registerDesktopAiRun({
runId: `run-${status}`,
conversationId: `conversation-${status}`,
sessionIds: [],
currentSessionId: "",
status,
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
expect(hasActiveDesktopAiRun(`conversation-${status}`)).toBe(true);
}
for (const status of ["completed", "failed", "cancelled", "interrupted"] as const) {
const run = registerDesktopAiRun({
runId: `run-${status}`,
conversationId: `conversation-${status}`,
sessionIds: [],
currentSessionId: "",
status,
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
expect(hasActiveDesktopAiRun(`conversation-${status}`)).toBe(false);
finishDesktopAiRun(run, "completed");
}
});
it("classifies terminal statuses for the stop-wait logic (isTerminalDesktopAiRunStatus)", () => {
for (const status of ["completed", "failed", "cancelled"] as const) {
expect(isTerminalDesktopAiRunStatus(status)).toBe(true);
}
// `interrupted` is a persisted-row-only pseudo status, never a runtime one.
for (const status of ["preparing", "queued", "running", "awaiting_write_confirmation", "pending_recoverable"] as const) {
expect(isTerminalDesktopAiRunStatus(status)).toBe(false);
}
});
});
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDesktopAiRunSnapshotScheduler } from "@/lib/ai/desktopAiRunSnapshotScheduler";
import type { DesktopAiRunRuntime } from "@/lib/ai/desktopAiRunRegistry";
function makeRun(overrides: Partial<DesktopAiRunRuntime> = {}): DesktopAiRunRuntime {
return {
runId: "run-1",
conversationId: "conversation-1",
sessionIds: [],
currentSessionId: "",
status: "running",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
...overrides,
};
}
// Finding 1 regression coverage: detached streaming deltas used to live only
// in memory (a crash/quit mid-response lost everything after the last
// pre-stream snapshot). The scheduler persists snapshots incrementally from
// the delta flush path - throttled to keep writes cheap, serialized per run so
// a slow write can never let an older snapshot overwrite a newer one.
describe("desktop AI run snapshot scheduler", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("throttles streaming flushes to one durable save per interval, capturing state at fire time", async () => {
const persistedContentLengths: number[] = [];
const run = makeRun();
const scheduler = createDesktopAiRunSnapshotScheduler({
persist: async () => {
persistedContentLengths.push(run.messages.length);
},
intervalMs: 2000,
});
run.messages.push({ content: "a" });
scheduler.schedule(run);
// Further flushes inside the window must not stack extra timers.
run.messages.push({ content: "b" });
scheduler.schedule(run);
run.messages.push({ content: "c" });
scheduler.schedule(run);
await vi.advanceTimersByTimeAsync(1999);
expect(persistedContentLengths).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
// The save reads the run's state when the timer fires (3 messages), not at
// schedule time - so a crash after the first flush still persists the tail.
expect(persistedContentLengths).toEqual([3]);
});
it("re-arms after each save so a long stream keeps snapshotting", async () => {
const persisted: number[] = [];
const run = makeRun();
const scheduler = createDesktopAiRunSnapshotScheduler({
persist: async () => {
persisted.push(run.messages.length);
},
intervalMs: 2000,
});
scheduler.schedule(run);
await vi.advanceTimersByTimeAsync(2000);
expect(persisted).toEqual([0]);
run.messages.push({ content: "late" });
scheduler.schedule(run);
await vi.advanceTimersByTimeAsync(2000);
expect(persisted).toEqual([0, 1]);
});
it("serializes saves so an older in-flight snapshot can never overwrite a newer one", async () => {
const callOrder: number[] = [];
let resolveFirst!: () => void;
const persist = vi
.fn()
.mockImplementationOnce(() => {
callOrder.push(1);
return new Promise<void>((resolve) => {
resolveFirst = resolve;
});
})
.mockImplementationOnce(() => {
callOrder.push(2);
return Promise.resolve();
});
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
const run = makeRun();
const first = scheduler.save(run);
run.status = "completed";
const second = scheduler.save(run);
// The newer save must not start (let alone finish) before the older one
// resolves - saves are INSERT OR REPLACEs keyed by the same run row.
await vi.advanceTimersByTimeAsync(10_000);
expect(persist).toHaveBeenCalledTimes(1);
resolveFirst();
await Promise.all([first, second]);
expect(callOrder).toEqual([1, 2]);
});
it("skips a queued save if the run is deleted while a prior write is still in flight", async () => {
// Reviewed finding (delete-resurrection race): a save() that was enqueued
// behind a slow in-flight write was NOT re-checked for discardOnFinish at
// execution time. The delete path commits its DELETE first, so the queued
// save firing afterwards recreated the just-deleted conversation via
// INSERT OR REPLACE. The discard marker must be checked at the actual
// execution point, not just at enqueue time.
const persist = vi.fn(async () => {});
let resolveFirst!: () => void;
persist.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveFirst = resolve;
}),
);
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
const run = makeRun();
// First save: flush the microtask so the write actually starts (and blocks
// on the pending mock) BEFORE the second is enqueued behind it.
const first = scheduler.save(run);
await Promise.resolve();
const second = scheduler.save(run);
// The user deletes the conversation while the first write is still running.
run.discardOnFinish = true;
resolveFirst();
await Promise.all([first, second]);
// Only the already-executing first write persisted; the queued one skipped.
expect(persist).toHaveBeenCalledTimes(1);
});
it("never persists a run the user deleted (discardOnFinish), even through the ordered path", async () => {
const persist = vi.fn(async () => {});
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
scheduler.schedule(makeRun({ discardOnFinish: true }));
await vi.advanceTimersByTimeAsync(5000);
expect(persist).not.toHaveBeenCalled();
// A run deleted after scheduling must be skipped when the timer fires.
const run = makeRun();
scheduler.schedule(run);
run.discardOnFinish = true;
await vi.advanceTimersByTimeAsync(5000);
expect(persist).not.toHaveBeenCalled();
// save() on a discarded run is also a no-op (delete-resurrection guard).
await scheduler.save(makeRun({ discardOnFinish: true }));
expect(persist).not.toHaveBeenCalled();
});
it("cancel() drops a pending throttled save so a late fire cannot write stale state", async () => {
const persist = vi.fn(async () => {});
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
const run = makeRun();
scheduler.schedule(run);
scheduler.cancel(run.runId);
await vi.advanceTimersByTimeAsync(5000);
expect(persist).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,15 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
export type AiRuntimeStrategy = "desktop-background" | "web-cancel-on-navigation";
/**
* Keep the platform boundary in one place. Desktop owns a process-wide Tauri
* event stream, while Web's POST/SSE response is still tied to the page request.
*/
export function resolveAiRuntimeStrategy(tauriRuntime = isTauriRuntime()): AiRuntimeStrategy {
return tauriRuntime ? "desktop-background" : "web-cancel-on-navigation";
}
export function supportsBackgroundAiRuns(strategy = resolveAiRuntimeStrategy()): boolean {
return strategy === "desktop-background";
}
@@ -0,0 +1,159 @@
import { shallowReactive } from "vue";
export type DesktopAiRunStatus = "preparing" | "queued" | "running" | "awaiting_write_confirmation" | "completed" | "failed" | "cancelled" | "pending_recoverable";
export type DesktopAiRunFifoCategory = "normal_send" | "write_confirmation_resume";
const DESKTOP_AI_CONCURRENCY_LIMIT = 3;
export interface DesktopAiRunRuntime<TMessage = unknown> {
runId: string;
conversationId: string;
sessionIds: string[];
currentSessionId: string;
status: DesktopAiRunStatus;
messages: TMessage[];
assistantMessageIndex: number;
connectionId: string;
connectionName: string;
database: string;
schema?: string;
pendingConfirmation?: unknown;
/** Queue category of a global-FIFO item, persisted so restart recovery can
* tell a normal send (→ editable pending input) from an accepted
* write-confirmation resume (→ back to awaiting confirmation). */
fifoCategory?: DesktopAiRunFifoCategory;
/** User input text carried by a normal-send FIFO item, recovered after
* restart as an editable, unsent pending draft. */
pendingInput?: string;
/** Highest event `seq` assigned to this run, across all its sessions (parent
* PRD §8). Strictly increasing from 1; persisted with the run. */
maxSeq?: number;
/** True when the user rejected the write-confirmation card for this run. A
* run cancelled on this path must NOT auto-send the conversation's queued
* input (parent PRD §5). In-memory only — never serialized. */
pendingConfirmationRejected?: boolean;
createdAt: string;
updatedAt: string;
cancelRequested: boolean;
discardOnFinish?: boolean;
flushPending?: () => void;
/** Resolved by the owning send() pipeline once it has fully settled (its
* finally block, or a pre-stream early return). A stop request waits on this
* signal rather than finalizing the run itself, so a cancellation-pending
* run stays visible - and keeps owning its concurrency slot - until the
* real terminal event (or a bounded force-abandon). Runtime-only - never
* serialized. */
settled?: Promise<void>;
}
// This registry deliberately lives outside AiAssistant.vue. Closing the panel
// unmounts its view in some layouts, but a Desktop run remains addressable until
// it reaches a terminal state. Web never registers runs here.
const runsByConversation = shallowReactive(new Map<string, DesktopAiRunRuntime>());
const admittedRunIds = new Set<string>();
const admissionQueue: Array<{ run: DesktopAiRunRuntime; resolve: (admitted: boolean) => void }> = [];
export function registerDesktopAiRun<TMessage>(run: DesktopAiRunRuntime<TMessage>): DesktopAiRunRuntime<TMessage> {
runsByConversation.set(run.conversationId, run as DesktopAiRunRuntime);
return run;
}
export function desktopAiRun<TMessage = unknown>(conversationId: string): DesktopAiRunRuntime<TMessage> | undefined {
return runsByConversation.get(conversationId) as DesktopAiRunRuntime<TMessage> | undefined;
}
export function updateDesktopAiRun(run: DesktopAiRunRuntime, patch: Partial<DesktopAiRunRuntime>): void {
Object.assign(run, patch, { updatedAt: new Date().toISOString() });
// Values are intentionally shallow (message arrays can be large). Re-setting
// the entry invalidates count/badge computations when status changes.
runsByConversation.set(run.conversationId, run);
}
export function finishDesktopAiRun(run: DesktopAiRunRuntime, status: Extract<DesktopAiRunStatus, "completed" | "failed" | "cancelled">): void {
updateDesktopAiRun(run, { status, currentSessionId: "", flushPending: undefined, pendingConfirmation: undefined });
}
export function acquireDesktopAiRunSlot(run: DesktopAiRunRuntime): Promise<boolean> {
if (admittedRunIds.has(run.runId)) return Promise.resolve(true);
if (admittedRunIds.size < DESKTOP_AI_CONCURRENCY_LIMIT) {
admittedRunIds.add(run.runId);
return Promise.resolve(true);
}
updateDesktopAiRun(run, { status: "queued" });
return new Promise((resolve) => admissionQueue.push({ run, resolve }));
}
export function releaseDesktopAiRunSlot(runId: string): void {
if (!admittedRunIds.delete(runId)) return;
while (admissionQueue.length > 0 && admittedRunIds.size < DESKTOP_AI_CONCURRENCY_LIMIT) {
const next = admissionQueue.shift()!;
if (next.run.cancelRequested || next.run.status === "cancelled") {
next.resolve(false);
continue;
}
admittedRunIds.add(next.run.runId);
updateDesktopAiRun(next.run, { status: "preparing" });
next.resolve(true);
}
}
export function cancelQueuedDesktopAiRun(run: DesktopAiRunRuntime): boolean {
const index = admissionQueue.findIndex((entry) => entry.run === run);
if (index < 0) return false;
const [entry] = admissionQueue.splice(index, 1);
run.cancelRequested = true;
finishDesktopAiRun(run, "cancelled");
entry.resolve(false);
return true;
}
export function activeDesktopAiRuns(): DesktopAiRunRuntime[] {
return [...runsByConversation.values()].filter((run) => run.status === "preparing" || run.status === "queued" || run.status === "running" || run.status === "awaiting_write_confirmation");
}
/** Terminal statuses: the run has fully settled and no longer blocks the
* conversation, the quit prompt, or a concurrency slot. `interrupted` is a
* persisted-row-only pseudo status (never a runtime status) and is excluded
* here for that reason. */
export function isTerminalDesktopAiRunStatus(status: DesktopAiRunStatus): boolean {
return status === "completed" || status === "failed" || status === "cancelled";
}
/** Statuses that block queue-send/auto-send: the conversation already owns a
* run the queued input must wait behind (parent PRD §5). */
export function hasActiveDesktopAiRun(conversationId: string): boolean {
const run = runsByConversation.get(conversationId);
if (!run) return false;
return run.status === "preparing" || run.status === "queued" || run.status === "running" || run.status === "awaiting_write_confirmation" || run.status === "pending_recoverable";
}
export function blockingDesktopAiRunsForQuit(): DesktopAiRunRuntime[] {
return [...runsByConversation.values()].filter((run) => run.status === "preparing" || run.status === "queued" || run.status === "running");
}
/**
* Advances a run's event `seq` (parent PRD §8). Every agent event for the run —
* across all its sessions — takes the next integer, strictly increasing from 1.
* Returns the new seq so the caller can record it as the last-processed anchor.
*/
export function bumpDesktopAiRunSeq(run: DesktopAiRunRuntime): number {
const next = (run.maxSeq ?? 0) + 1;
run.maxSeq = next;
runsByConversation.set(run.conversationId, run);
return next;
}
export function removeDesktopAiRun(conversationId: string): void {
runsByConversation.delete(conversationId);
}
export function retireDesktopAiRun(run: DesktopAiRunRuntime): void {
if (runsByConversation.get(run.conversationId) === run) runsByConversation.delete(run.conversationId);
}
export function resetDesktopAiRunRegistryForTests(): void {
runsByConversation.clear();
admittedRunIds.clear();
while (admissionQueue.length) admissionQueue.shift()!.resolve(false);
}
@@ -0,0 +1,83 @@
import type { DesktopAiRunRuntime } from "@/lib/ai/desktopAiRunRegistry";
export interface DesktopAiRunSnapshotSchedulerOptions<TMessage> {
/** Durable save for one run. Runs while the previous save for the same run
* is still in flight are deferred, so call order equals write order.
* Rejections are swallowed by the scheduler - a failed save must never
* break the chain for the saves behind it. */
persist: (run: DesktopAiRunRuntime<TMessage>) => Promise<void>;
/** Minimum spacing between throttled saves for one run. Bounds how much
* streamed output a crash/quit can lose relative to the last durable
* snapshot. */
intervalMs: number;
}
export interface DesktopAiRunSnapshotScheduler<TMessage = unknown> {
/** Schedules a throttled save for a streaming run: at most one save per
* intervalMs, capturing whatever the run's state is when the timer fires
* (not when the flush that scheduled it happened). */
schedule(run: DesktopAiRunRuntime<TMessage>): void;
/** Saves immediately, strictly ordered after any save already queued or in
* flight for the run, so an older snapshot can never overwrite a newer
* one (saves are INSERT OR REPLACEs keyed by the same row). */
save(run: DesktopAiRunRuntime<TMessage>): Promise<void>;
/** Drops a pending throttled save - call when the run settles or is deleted,
* so a late fire cannot write stale state over the authoritative final
* save (or resurrect a deleted conversation). */
cancel(runId: string): void;
}
// Streaming deltas used to live only in memory: a crash or quit mid-response
// lost everything after the last pre-stream snapshot. The scheduler persists
// snapshots incrementally from the delta flush path while keeping the writes
// cheap (throttled) and ordered (serialized per run).
export function createDesktopAiRunSnapshotScheduler<TMessage>(options: DesktopAiRunSnapshotSchedulerOptions<TMessage>): DesktopAiRunSnapshotScheduler<TMessage> {
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
const chains = new Map<string, Promise<void>>();
const enqueue = (run: DesktopAiRunRuntime<TMessage>): Promise<void> => {
const chain = (chains.get(run.runId) ?? Promise.resolve())
.then(() => {
// Re-check discard at the actual execution point, not just at enqueue
// time: the run may have been deleted while an earlier write in this
// chain was still in flight. Without this re-check the queued save
// would persist anyway and resurrect the deleted conversation via
// INSERT OR REPLACE.
if (run.discardOnFinish) return;
return options.persist(run);
})
.catch(() => {})
.finally(() => {
if (chains.get(run.runId) === chain) chains.delete(run.runId);
});
chains.set(run.runId, chain);
return chain;
};
return {
schedule(run) {
// A run the user deleted must never be written back - INSERT OR REPLACE
// would resurrect the just-deleted conversation.
if (run.discardOnFinish) return;
if (pendingTimers.has(run.runId)) return;
const timer = setTimeout(() => {
pendingTimers.delete(run.runId);
if (run.discardOnFinish) return;
void enqueue(run);
}, options.intervalMs);
pendingTimers.set(run.runId, timer);
},
save(run) {
// Same delete-resurrection guard as schedule(): no caller may persist a
// discarded run, not even through the ordered path.
if (run.discardOnFinish) return Promise.resolve();
return enqueue(run);
},
cancel(runId) {
const timer = pendingTimers.get(runId);
if (timer === undefined) return;
clearTimeout(timer);
pendingTimers.delete(runId);
},
};
}
+5
View File
@@ -345,6 +345,9 @@ export const snippetSyncDownload = forward("snippetSyncDownload");
export const saveAiConversation = forward("saveAiConversation");
export const loadAiConversations = forward("loadAiConversations");
export const deleteAiConversation = forward("deleteAiConversation");
export const saveAiRun = forward("saveAiRun");
export const saveAiRunState = forward("saveAiRunState");
export const loadAiRuns = forward("loadAiRuns");
// Prompt Templates
export const loadPromptTemplates = forward("loadPromptTemplates");
@@ -821,6 +824,8 @@ export type {
AiModelInfo,
AiChatMessage,
AiConversation,
AiRun,
AiRunStatus,
PromptTemplate,
AgentDriverInfo,
AgentOfflineArtifactKind,
+36 -12
View File
@@ -60,6 +60,7 @@ import type {
AiCompletionRequest,
AiStreamChunk,
AiConversation,
AiRun,
AiModelInfo,
DriverStoreUsage,
DriverRuntimeSummary,
@@ -1590,13 +1591,16 @@ export async function aiStream(sessionId: string, request: AiCompletionRequest,
if (line.startsWith("data:")) {
const data = line.slice(5).trim();
if (data && data !== "[DONE]") {
let chunk: AiStreamChunk;
try {
const chunk: AiStreamChunk = JSON.parse(data);
onChunk(chunk);
if (chunk.done) return;
chunk = JSON.parse(data);
} catch {
// skip malformed JSON
continue;
}
if (chunk.error) throw new Error(chunk.error);
onChunk(chunk);
if (chunk.done) return;
}
}
}
@@ -1687,18 +1691,23 @@ export async function aiAgentStream(
if (line.startsWith("data:")) {
const data = line.slice(5).trim();
if (data && data !== "[DONE]") {
let parsed: unknown;
try {
const parsed = JSON.parse(data);
if (!isAgentEvent(parsed)) {
console.warn("[aiAgentStream] Skipping invalid agent event:", data);
continue;
}
onEvent(parsed);
if (parsed.type === "agent_end" || parsed.type === "error") {
result = data;
}
parsed = JSON.parse(data);
} catch {
// skip malformed JSON
continue;
}
if (!isAgentEvent(parsed)) {
console.warn("[aiAgentStream] Skipping invalid agent event:", data);
continue;
}
onEvent(parsed);
if (parsed.type === "error") {
throw new Error(parsed.message);
}
if (parsed.type === "agent_end") {
result = data;
}
}
}
@@ -2067,6 +2076,21 @@ export async function deleteAiConversation(id: string): Promise<void> {
return del(`/api/ai/conversation/${id}`);
}
// Background AI runs are a Desktop-only capability in this release. Keep the
// symbols for backend-module parity without changing Web's request-bound SSE
// lifecycle or adding a partially functional persistence API.
export async function saveAiRun(_run: AiRun): Promise<void> {
throw new Error("Background AI runs are only available in DBX Desktop");
}
export async function saveAiRunState(_conversation: AiConversation, _run: AiRun): Promise<void> {
throw new Error("Background AI runs are only available in DBX Desktop");
}
export async function loadAiRuns(): Promise<AiRun[]> {
return [];
}
// ---------------------------------------------------------------------------
// Prompt Templates
// ---------------------------------------------------------------------------
+40
View File
@@ -447,6 +447,8 @@ export interface AiStreamChunk {
delta: string;
reasoning_delta?: string;
done: boolean;
/** Web-only explicit terminal error; Tauri reports invoke failures directly. */
error?: string;
}
export async function aiStream(sessionId: string, request: AiCompletionRequest, onChunk: (chunk: AiStreamChunk) => void): Promise<void> {
@@ -904,6 +906,32 @@ export interface AiConversation {
connectionName: string;
database: string;
messages: AiChatMessage[];
/** One editable "send later" input saved while an active run occupies the
* conversation (parent PRD §5). Persisted with the conversation. */
queuedInput?: string;
createdAt: string;
updatedAt: string;
}
export type AiRunStatus = "preparing" | "queued" | "running" | "awaiting_write_confirmation" | "completed" | "failed" | "cancelled" | "interrupted" | "pending_recoverable";
export type AiRunFifoCategory = "normal_send" | "write_confirmation_resume";
export interface AiRun {
runId: string;
conversationId: string;
sessionIds: string[];
status: AiRunStatus;
connectionId: string;
database: string;
schema?: string;
pendingConfirmation?: unknown;
fifoCategory?: AiRunFifoCategory;
pendingInput?: string;
/** Highest event seq assigned to this run across all its sessions (parent
* PRD §8). Drives the unread baseline and the "updates while you were away"
* separator anchor. */
maxSeq?: number;
createdAt: string;
updatedAt: string;
}
@@ -920,6 +948,18 @@ export async function deleteAiConversation(id: string): Promise<void> {
return invoke("delete_ai_conversation", { id });
}
export async function saveAiRun(run: AiRun): Promise<void> {
return invoke("save_ai_run", { run });
}
export async function saveAiRunState(conversation: AiConversation, run: AiRun): Promise<void> {
return invoke("save_ai_run_state", { conversation, run });
}
export async function loadAiRuns(): Promise<AiRun[]> {
return invoke("load_ai_runs");
}
// --- Prompt Templates ---
export interface PromptTemplate {