feat(ai): run agent sessions as background tasks
This commit is contained in:
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user