mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 00:03:19 +00:00
feat: persist a per-session AI task list in IndexedDB
This commit is contained in:
@@ -133,6 +133,7 @@ import { scopedKey, onUserChange, migrateLegacyLocalStorage } from '$lib/userSco
|
||||
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { AttachedFilesStore } from './files/attachedFiles.svelte'
|
||||
import { SessionArtifactsStore } from './artifacts/artifactsState.svelte'
|
||||
import { SessionTasksStore } from './tasks/tasksState.svelte'
|
||||
import { appendAttachedFilesRoster } from './files/fileTools'
|
||||
|
||||
// SSR and users who prefer reduced motion get no typewriter pacing.
|
||||
@@ -348,6 +349,8 @@ export class AIChatManager {
|
||||
attachedFiles = new AttachedFilesStore()
|
||||
/** Markdown artifacts the copilot created for the current session. */
|
||||
artifacts = new SessionArtifactsStore()
|
||||
/** The task list the copilot is working through for the current session. */
|
||||
tasks = new SessionTasksStore()
|
||||
abortController: AbortController | undefined = undefined
|
||||
inlineAbortController: AbortController | undefined = undefined
|
||||
// Flag to skip Responses API if it's not available (e.g., Azure region doesn't support it)
|
||||
@@ -1890,7 +1893,7 @@ export class AIChatManager {
|
||||
this.helpers = baseHelpers
|
||||
}
|
||||
this.systemMessage = systemMessage
|
||||
this.syncArtifactsSession()
|
||||
this.syncSessionStores()
|
||||
}
|
||||
|
||||
refreshGlobalSkills = async (workspace = this.operatingWorkspace ?? '') => {
|
||||
@@ -3526,7 +3529,7 @@ export class AIChatManager {
|
||||
if (!this.isSessionChat) this.attachedFiles.clear()
|
||||
// Message-attached rows belong to the conversation just left in every case.
|
||||
this.#syncMessageFiles()
|
||||
this.syncArtifactsSession()
|
||||
this.syncSessionStores()
|
||||
this.onChatRotated?.(this.historyManager.getCurrentChatId())
|
||||
}
|
||||
|
||||
@@ -3568,13 +3571,15 @@ export class AIChatManager {
|
||||
// readable (and the previous chat's are pruned).
|
||||
this.#syncMessageFiles()
|
||||
this.#automaticScroll = true
|
||||
this.syncArtifactsSession()
|
||||
this.syncSessionStores()
|
||||
this.onChatRotated?.(id)
|
||||
}
|
||||
}
|
||||
|
||||
private syncArtifactsSession = () => {
|
||||
void this.artifacts.setSession(this.isSessionChat ? this.sessionId : undefined)
|
||||
private syncSessionStores = () => {
|
||||
const sessionId = this.isSessionChat ? this.sessionId : undefined
|
||||
void this.artifacts.setSession(sessionId)
|
||||
void this.tasks.setSession(sessionId)
|
||||
}
|
||||
|
||||
get automaticScroll() {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Scoped by sessionId (fixed for the session's life), not chatId: a session follows its
|
||||
// active chat's rotation, so chatId-keying would drop the plan mid-run.
|
||||
import { type DBSchema as IDBSchema } from 'idb'
|
||||
import { userScopedDb } from '$lib/userScopedDb'
|
||||
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed'
|
||||
|
||||
export interface PersistedTask {
|
||||
sessionId: string
|
||||
// 1-based per session. This is also the id the model handles: it echoes an id on
|
||||
// every update_task call, so an integer costs a character where a UUID costs 36.
|
||||
seq: number
|
||||
subject: string
|
||||
description: string
|
||||
/** Present continuous ("Fixing auth redirect"), shown while the task is in_progress. */
|
||||
activeForm?: string
|
||||
status: TaskStatus
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface TasksSchema extends IDBSchema {
|
||||
items: {
|
||||
// Compound so the store key stays globally unique while the model-facing id
|
||||
// remains the bare `seq`.
|
||||
key: [string, number]
|
||||
value: PersistedTask
|
||||
indexes: { 'by-session': string }
|
||||
}
|
||||
}
|
||||
|
||||
// User-scoped like the chat-history store these are keyed against: no cross-user
|
||||
// co-residency on a shared browser.
|
||||
const dbh = userScopedDb<TasksSchema>('copilot-tasks', {
|
||||
version: 1,
|
||||
upgrade(db) {
|
||||
const store = db.createObjectStore('items', { keyPath: ['sessionId', 'seq'] })
|
||||
store.createIndex('by-session', 'sessionId')
|
||||
}
|
||||
})
|
||||
|
||||
function getDB() {
|
||||
return dbh.whenReady()
|
||||
}
|
||||
|
||||
/** A task before it has been assigned its session-scoped sequence number. */
|
||||
export type NewTask = Omit<PersistedTask, 'sessionId' | 'seq'>
|
||||
|
||||
/**
|
||||
* Append tasks, numbering them from the highest `seq` present when the write runs.
|
||||
*
|
||||
* The allocation happens INSIDE the readwrite transaction on purpose. Deriving the
|
||||
* next seq from an in-memory snapshot instead lets two tabs on the same session pick
|
||||
* the same number, and since `seq` is half the primary key, the second `put` silently
|
||||
* replaces the first tab's task rather than failing. IndexedDB serialises overlapping
|
||||
* readwrite transactions on a store, so reading the max and writing in one transaction
|
||||
* closes that window.
|
||||
*
|
||||
* Returns undefined when IndexedDB is unavailable or the write failed — the caller
|
||||
* then numbers in memory, where there is no other tab to contend with.
|
||||
*/
|
||||
export async function appendTasks(
|
||||
sessionId: string,
|
||||
drafts: NewTask[]
|
||||
): Promise<PersistedTask[] | undefined> {
|
||||
const db = await getDB()
|
||||
if (!db) return undefined
|
||||
try {
|
||||
const tx = db.transaction('items', 'readwrite')
|
||||
let next = 0
|
||||
let cursor = await tx.store.index('by-session').openCursor(IDBKeyRange.only(sessionId))
|
||||
while (cursor) {
|
||||
next = Math.max(next, cursor.value.seq)
|
||||
cursor = await cursor.continue()
|
||||
}
|
||||
const created = drafts.map((draft) => ({ ...draft, sessionId, seq: ++next }))
|
||||
for (const task of created) await tx.store.put(task)
|
||||
await tx.done
|
||||
return created
|
||||
} catch (err) {
|
||||
console.error('Could not persist tasks', err)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function putTask(task: PersistedTask): Promise<void> {
|
||||
const db = await getDB()
|
||||
if (!db) return
|
||||
try {
|
||||
// A rejected write (most likely QuotaExceededError) leaves the task usable for the
|
||||
// session but unpersisted — degrade like the reads rather than throwing at the caller.
|
||||
await db.put('items', task)
|
||||
} catch (err) {
|
||||
console.error('Could not persist task', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTasksForSession(sessionId: string): Promise<PersistedTask[]> {
|
||||
const db = await getDB()
|
||||
if (!db) return []
|
||||
try {
|
||||
return await db.getAllFromIndex('items', 'by-session', sessionId)
|
||||
} catch (err) {
|
||||
console.error('Could not read tasks', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTask(sessionId: string, seq: number): Promise<void> {
|
||||
const db = await getDB()
|
||||
if (!db) return
|
||||
try {
|
||||
await db.delete('items', [sessionId, seq])
|
||||
} catch (err) {
|
||||
console.error('Could not delete task', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTasksForSession(sessionId: string): Promise<void> {
|
||||
const db = await getDB()
|
||||
if (!db) return
|
||||
try {
|
||||
const tx = db.transaction('items', 'readwrite')
|
||||
const index = tx.store.index('by-session')
|
||||
let cursor = await index.openCursor(sessionId)
|
||||
while (cursor) {
|
||||
await cursor.delete()
|
||||
cursor = await cursor.continue()
|
||||
}
|
||||
await tx.done
|
||||
} catch (err) {
|
||||
console.error('Could not delete tasks for session', err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
appendTasks,
|
||||
deleteTask,
|
||||
listTasksForSession,
|
||||
putTask,
|
||||
type NewTask,
|
||||
type PersistedTask,
|
||||
type TaskStatus
|
||||
} from './tasksDB'
|
||||
|
||||
export interface CreateTaskInput {
|
||||
subject: string
|
||||
description: string
|
||||
activeForm?: string
|
||||
}
|
||||
|
||||
export interface UpdateTaskInput {
|
||||
subject?: string
|
||||
description?: string
|
||||
activeForm?: string
|
||||
status?: TaskStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive view of the active session's task list, owned by AIChatManager (like
|
||||
* SessionArtifactsStore). The consumer drives which session is loaded via setSession();
|
||||
* the tools mutate through createMany/update/remove, which persist and update the
|
||||
* in-memory list in one step.
|
||||
*
|
||||
* Ordered by `seq` — creation order is plan order, and the sequential tool loop can only
|
||||
* honor precedence expressed as ordering.
|
||||
*/
|
||||
export class SessionTasksStore {
|
||||
tasks = $state<PersistedTask[]>([])
|
||||
|
||||
#sessionId: string | undefined
|
||||
// A later load always wins, even if an earlier DB read resolves after it.
|
||||
#loadToken = 0
|
||||
|
||||
/** Load the given session's tasks into the reactive list, if it changed. */
|
||||
async setSession(sessionId: string | undefined): Promise<void> {
|
||||
// Skip same-id resyncs: in-memory owns the loaded session, so a DB reload would
|
||||
// drop tasks whose best-effort persist failed.
|
||||
if (sessionId === this.#sessionId) return
|
||||
this.#sessionId = sessionId
|
||||
await this.#load()
|
||||
}
|
||||
|
||||
async #load(): Promise<void> {
|
||||
const token = ++this.#loadToken
|
||||
const id = this.#sessionId
|
||||
if (!id) {
|
||||
this.tasks = []
|
||||
return
|
||||
}
|
||||
const items = await listTasksForSession(id)
|
||||
if (token !== this.#loadToken) return
|
||||
this.tasks = sortBySeq(items)
|
||||
}
|
||||
|
||||
// Bump the token so an in-flight #load, whose snapshot predates this write, cannot
|
||||
// clobber it.
|
||||
#applyWrite(next: PersistedTask[]): void {
|
||||
this.#loadToken++
|
||||
this.tasks = next
|
||||
}
|
||||
|
||||
async listForSession(sessionId: string): Promise<PersistedTask[]> {
|
||||
if (sessionId === this.#sessionId) return [...this.tasks]
|
||||
return sortBySeq(await listTasksForSession(sessionId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every task currently being worked on. Plural on purpose: a test run detaches into
|
||||
* the background after DETACH_AFTER_MS (or immediately, when the model asks), which
|
||||
* frees the chat loop to work on the next task while the job is still running — so
|
||||
* more than one task is genuinely in flight.
|
||||
*/
|
||||
get activeTasks(): PersistedTask[] {
|
||||
return this.tasks.filter((t) => t.status === 'in_progress')
|
||||
}
|
||||
|
||||
/**
|
||||
* Append tasks to `sessionId`'s plan. Batch because a plan created in one call is
|
||||
* one transcript card rather than N. Sequence numbers are assigned by the store
|
||||
* layer inside the write transaction (see appendTasks) so a second tab on the same
|
||||
* session cannot allocate the same number and overwrite this write.
|
||||
*/
|
||||
async createMany(sessionId: string, inputs: CreateTaskInput[]): Promise<PersistedTask[]> {
|
||||
const now = Date.now()
|
||||
const drafts: NewTask[] = inputs.map((input) => ({
|
||||
subject: input.subject,
|
||||
description: input.description,
|
||||
activeForm: input.activeForm,
|
||||
status: 'pending' as TaskStatus,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}))
|
||||
// Without IndexedDB the tasks live only in this store, so numbering from its own
|
||||
// list is both sufficient and the only option.
|
||||
let created = await appendTasks(sessionId, drafts)
|
||||
if (!created) {
|
||||
const existing = sessionId === this.#sessionId ? this.tasks : []
|
||||
let next = existing.reduce((max, t) => Math.max(max, t.seq), 0)
|
||||
created = drafts.map((draft) => ({ ...draft, sessionId, seq: ++next }))
|
||||
}
|
||||
if (sessionId === this.#sessionId) {
|
||||
this.#applyWrite(sortBySeq([...this.tasks, ...created]))
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
/** Merge changes into one task. Returns undefined if the session has no such seq. */
|
||||
async update(
|
||||
sessionId: string,
|
||||
seq: number,
|
||||
input: UpdateTaskInput
|
||||
): Promise<PersistedTask | undefined> {
|
||||
const source = sessionId === this.#sessionId ? this.tasks : await listTasksForSession(sessionId)
|
||||
const existing = source.find((t) => t.seq === seq)
|
||||
if (!existing) return undefined
|
||||
const updated: PersistedTask = {
|
||||
...existing,
|
||||
subject: input.subject ?? existing.subject,
|
||||
description: input.description ?? existing.description,
|
||||
activeForm: input.activeForm ?? existing.activeForm,
|
||||
status: input.status ?? existing.status,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
await putTask(updated)
|
||||
if (sessionId === this.#sessionId) {
|
||||
this.#applyWrite(this.tasks.map((t) => (t.seq === seq ? updated : t)))
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
/** Drop one task. Returns false if the session has no such seq. */
|
||||
async remove(sessionId: string, seq: number): Promise<boolean> {
|
||||
const source = sessionId === this.#sessionId ? this.tasks : await listTasksForSession(sessionId)
|
||||
if (!source.some((t) => t.seq === seq)) return false
|
||||
await deleteTask(sessionId, seq)
|
||||
if (sessionId === this.#sessionId) {
|
||||
this.#applyWrite(this.tasks.filter((t) => t.seq !== seq))
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function sortBySeq(items: PersistedTask[]): PersistedTask[] {
|
||||
return [...items].sort((a, b) => a.seq - b.seq)
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line state of the plan, returned on every write so the model tracks progress
|
||||
* without the tool echoing back the list it just authored.
|
||||
*/
|
||||
export function summarizeTasks(tasks: PersistedTask[]): string {
|
||||
if (tasks.length === 0) return 'No tasks.'
|
||||
const done = tasks.filter((t) => t.status === 'completed').length
|
||||
// Names every running task, not just the first — with backgrounded jobs several
|
||||
// can be in flight, and reporting one would tell the model its own plan is
|
||||
// narrower than it is.
|
||||
const active = tasks.filter((t) => t.status === 'in_progress').map((t) => t.subject)
|
||||
const now =
|
||||
active.length > 3 ? `${active.slice(0, 3).join(', ')} +${active.length - 3}` : active.join(', ')
|
||||
return `${done}/${tasks.length} done` + (now ? `, now: ${now}` : '')
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IDBFactory } from 'fake-indexeddb'
|
||||
import { SessionTasksStore, summarizeTasks } from './tasksState.svelte'
|
||||
|
||||
// The user-scoping subscription is BROWSER-gated; the node test env reports false.
|
||||
vi.mock('esm-env', async (orig) => ({
|
||||
...(await orig<typeof import('esm-env')>()),
|
||||
BROWSER: true
|
||||
}))
|
||||
|
||||
// Stub $lib/stores + $lib/utils (userScopedStorage's only deps here) to keep their heavy
|
||||
// svelte/app-store graphs out of the per-test cold transform.
|
||||
vi.mock('$lib/stores', async () => {
|
||||
const { writable } = await import('svelte/store')
|
||||
return { userStore: writable(undefined) }
|
||||
})
|
||||
vi.mock('$lib/utils', () => ({ getLocalSetting: () => undefined, storeLocalSetting: () => {} }))
|
||||
|
||||
// The DB module memoises its handle at module scope. A fresh IDBFactory per test only
|
||||
// isolates data once the handle is reset, so reset modules and re-import both together.
|
||||
// The DB is namespaced by email, so seed a user.
|
||||
async function fresh() {
|
||||
vi.resetModules()
|
||||
;(globalThis as any).indexedDB = new IDBFactory()
|
||||
;(await import('$lib/stores')).userStore.set({ email: 'a@x.com' } as never)
|
||||
const { SessionTasksStore: Store } = await import('./tasksState.svelte')
|
||||
const db = await import('./tasksDB')
|
||||
return { store: new Store(), makeStore: () => new Store(), db }
|
||||
}
|
||||
|
||||
let store: SessionTasksStore
|
||||
let makeStore: () => SessionTasksStore
|
||||
let db: typeof import('./tasksDB')
|
||||
beforeEach(async () => {
|
||||
;({ store, makeStore, db } = await fresh())
|
||||
})
|
||||
|
||||
const mk = (subject: string) => ({ subject, description: `do ${subject}` })
|
||||
|
||||
describe('SessionTasksStore', () => {
|
||||
it('numbers tasks per session, continuing past the highest existing seq', async () => {
|
||||
await store.setSession('s1')
|
||||
expect((await store.createMany('s1', [mk('a'), mk('b')])).map((t) => t.seq)).toEqual([1, 2])
|
||||
expect((await store.createMany('s1', [mk('c')])).map((t) => t.seq)).toEqual([3])
|
||||
|
||||
// A second session numbers from 1 again — seq is only unique within a session,
|
||||
// which is what makes the compound [sessionId, seq] key necessary.
|
||||
await store.setSession('s2')
|
||||
expect((await store.createMany('s2', [mk('x')])).map((t) => t.seq)).toEqual([1])
|
||||
expect(store.tasks.map((t) => t.subject)).toEqual(['x'])
|
||||
})
|
||||
|
||||
it('keeps seq order regardless of update order, and scopes updates by session', async () => {
|
||||
await store.setSession('s1')
|
||||
await store.createMany('s1', [mk('a'), mk('b'), mk('c')])
|
||||
await store.createMany('s2', [mk('other')])
|
||||
|
||||
await store.update('s1', 1, { status: 'completed' })
|
||||
expect(store.tasks.map((t) => t.seq)).toEqual([1, 2, 3])
|
||||
expect(store.activeTasks).toEqual([])
|
||||
|
||||
// Several at once: a backgrounded job leaves its task running while the agent
|
||||
// starts the next one, so this must not collapse to the first.
|
||||
await store.update('s1', 2, { status: 'in_progress' })
|
||||
await store.update('s1', 3, { status: 'in_progress' })
|
||||
expect(store.activeTasks.map((t) => t.subject)).toEqual(['b', 'c'])
|
||||
|
||||
// s2's seq 1 is a different task; updating it must not touch s1's. Assert on the
|
||||
// subject: s1's seq 1 is already `completed`, so a status assertion would pass
|
||||
// even if s2's task had replaced it wholesale.
|
||||
expect(await store.update('s2', 1, { subject: 'renamed in s2' })).toBeDefined()
|
||||
expect(store.tasks.find((t) => t.seq === 1)?.subject).toBe('a')
|
||||
expect(await store.update('s1', 99, { status: 'completed' })).toBeUndefined()
|
||||
})
|
||||
|
||||
// Two stores over one session = the same session open in two tabs. Numbering from
|
||||
// each store's own snapshot made both pick seq 1, and since seq is half the primary
|
||||
// key the second put silently destroyed the first tab's task.
|
||||
it('does not lose a task when two stores append to one session', async () => {
|
||||
const tabA = store
|
||||
const tabB = makeStore()
|
||||
await tabA.setSession('s1')
|
||||
await tabB.setSession('s1')
|
||||
|
||||
await tabA.createMany('s1', [mk('from tab A')])
|
||||
// tabB's in-memory list predates tabA's write.
|
||||
await tabB.createMany('s1', [mk('from tab B')])
|
||||
|
||||
const persisted = await db.listTasksForSession('s1')
|
||||
expect(persisted.map((t) => t.subject).sort()).toEqual(['from tab A', 'from tab B'])
|
||||
expect(new Set(persisted.map((t) => t.seq)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('survives a reload of the same session', async () => {
|
||||
await store.setSession('s1')
|
||||
await store.createMany('s1', [mk('a'), mk('b')])
|
||||
await store.update('s1', 2, { status: 'in_progress' })
|
||||
|
||||
await store.setSession(undefined)
|
||||
await store.setSession('s1')
|
||||
expect(store.tasks.map((t) => [t.seq, t.status])).toEqual([
|
||||
[1, 'pending'],
|
||||
[2, 'in_progress']
|
||||
])
|
||||
})
|
||||
|
||||
it('removes a task without renumbering the rest', async () => {
|
||||
await store.setSession('s1')
|
||||
await store.createMany('s1', [mk('a'), mk('b'), mk('c')])
|
||||
await store.remove('s1', 2)
|
||||
expect(store.tasks.map((t) => t.seq)).toEqual([1, 3])
|
||||
|
||||
// The next created task must not collide with the surviving seq 3.
|
||||
expect((await store.createMany('s1', [mk('d')])).map((t) => t.seq)).toEqual([4])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeTasks', () => {
|
||||
const task = (seq: number, subject: string, status: any) => ({
|
||||
sessionId: 's',
|
||||
seq,
|
||||
subject,
|
||||
description: '',
|
||||
status,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
})
|
||||
|
||||
it('reports progress and every running task', () => {
|
||||
expect(summarizeTasks([])).toBe('No tasks.')
|
||||
expect(
|
||||
summarizeTasks([
|
||||
task(1, 'a', 'completed'),
|
||||
task(2, 'b', 'in_progress'),
|
||||
task(3, 'c', 'pending')
|
||||
])
|
||||
).toBe('1/3 done, now: b')
|
||||
expect(summarizeTasks([task(1, 'a', 'completed')])).toBe('1/1 done')
|
||||
// Concurrent work (a detached job plus the next task) must all be named —
|
||||
// reporting only the first would understate what is in flight.
|
||||
expect(
|
||||
summarizeTasks([
|
||||
task(1, 'a', 'in_progress'),
|
||||
task(2, 'b', 'in_progress'),
|
||||
task(3, 'c', 'pending')
|
||||
])
|
||||
).toBe('0/3 done, now: a, b')
|
||||
})
|
||||
})
|
||||
@@ -485,8 +485,9 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
previewTabs.open({ type: 'artifact', id, name })
|
||||
}
|
||||
manager.closeArtifact = (id) => previewTabs.closeArtifact(id)
|
||||
// Key the store before any configureGlobalMode runs, so a new session's first create shows at once.
|
||||
// Key the stores before any configureGlobalMode runs, so a new session's first create shows at once.
|
||||
void manager.artifacts.setSession(session.id)
|
||||
void manager.tasks.setSession(session.id)
|
||||
|
||||
// Pipeline target state lives on the runtime (not the PipelineEditorView
|
||||
// component) so the in-session drafts survive hide/show of the editor pane —
|
||||
|
||||
@@ -25,6 +25,7 @@ import { type DBSchema, type IDBPDatabase } from 'idb'
|
||||
import { userScopedDb } from '$lib/userScopedDb'
|
||||
import { deleteItemsForSession } from '../copilot/chat/files/attachedFilesDB'
|
||||
import { deleteArtifactsForSession } from '../copilot/chat/artifacts/artifactsDB'
|
||||
import { deleteTasksForSession } from '../copilot/chat/tasks/tasksDB'
|
||||
|
||||
// Switch the global workspace iff the target differs from the active one
|
||||
// and is non-empty. Centralises the "session needs its workspace in focus"
|
||||
@@ -540,6 +541,7 @@ export async function reconcileSessionsLifecycle(): Promise<void> {
|
||||
// here would orphan the session's attached-file blobs/handles.
|
||||
void deleteItemsForSession(s.id)
|
||||
void deleteArtifactsForSession(s.id)
|
||||
void deleteTasksForSession(s.id)
|
||||
deletedIds.add(s.id)
|
||||
continue
|
||||
}
|
||||
@@ -632,6 +634,7 @@ export async function deleteSessionsForWorkspace(workspaceId: string): Promise<v
|
||||
// doesn't leave the sessions' attached-file blobs/handles orphaned.
|
||||
void deleteItemsForSession(id)
|
||||
void deleteArtifactsForSession(id)
|
||||
void deleteTasksForSession(id)
|
||||
}
|
||||
sessionState.sessions = sessionState.sessions.filter((s) => !ids.has(s.id))
|
||||
if (sessionState.currentSessionId && ids.has(sessionState.currentSessionId)) {
|
||||
@@ -1031,6 +1034,7 @@ export function deleteSession(id: string) {
|
||||
// GC any linked files and artifacts persisted for this session.
|
||||
void deleteItemsForSession(id)
|
||||
void deleteArtifactsForSession(id)
|
||||
void deleteTasksForSession(id)
|
||||
logFeatureUsage('ai_session', 'deleted', { entityId: id, workspace: s.workspace_id })
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ vi.mock('../copilot/chat/artifacts/artifactsDB', async (orig) => ({
|
||||
deleteArtifactsForSession: deleteArtifactsForSessionMock
|
||||
}))
|
||||
|
||||
const { deleteTasksForSessionMock } = vi.hoisted(() => ({
|
||||
deleteTasksForSessionMock: vi.fn()
|
||||
}))
|
||||
// Bare (no `orig()` spread): sessionState imports only this one symbol, and pulling the
|
||||
// real module in would drag $lib/userScopedDb's store graph into this suite.
|
||||
vi.mock('../copilot/chat/tasks/tasksDB', () => ({
|
||||
deleteTasksForSession: deleteTasksForSessionMock
|
||||
}))
|
||||
|
||||
// sessionState imports WorkspaceService; these tests don't touch the network.
|
||||
vi.mock('$lib/gen', async (orig) => {
|
||||
const actual = await orig<typeof import('$lib/gen')>()
|
||||
@@ -444,13 +453,17 @@ describe('sessionState IndexedDB persistence', () => {
|
||||
|
||||
deleteItemsForSessionMock.mockClear()
|
||||
deleteArtifactsForSessionMock.mockClear()
|
||||
deleteTasksForSessionMock.mockClear()
|
||||
await deleteSessionsForWorkspace('wsX')
|
||||
|
||||
// Both deleted sessions' linked files and artifacts must be GC'd, not just their records.
|
||||
// Both deleted sessions' linked files, artifacts and task plans must be GC'd,
|
||||
// not just their records.
|
||||
const cleaned = deleteItemsForSessionMock.mock.calls.map((c) => c[0]).sort()
|
||||
expect(cleaned).toEqual(['f1', 'f2'])
|
||||
const cleanedArtifacts = deleteArtifactsForSessionMock.mock.calls.map((c) => c[0]).sort()
|
||||
expect(cleanedArtifacts).toEqual(['f1', 'f2'])
|
||||
const cleanedTasks = deleteTasksForSessionMock.mock.calls.map((c) => c[0]).sort()
|
||||
expect(cleanedTasks).toEqual(['f1', 'f2'])
|
||||
})
|
||||
|
||||
it('does not persist a per-session unarchive when the workspace is gone (resurrection guard)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user