mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 16:02:47 +00:00
* feat: guidance for building standalone ai agents in ai sessions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: agent guidance for resource-only sessions, no flow tools Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: tell the user to deploy a drafted tool script with its agent Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: scope plain agent values, document tool_inputs, list agents in get_instructions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: test and run ai agents from ai sessions, open agents in their editor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: full-bleed agent editor tabs, back to the run form for agent tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: describe read-and-run as running agents too, pin agent run links Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: host the agent editor natively in session tabs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: switch the agent editor to tabs below 900px Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: name an agent's session tab after its summary Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show deployed agents in session tabs, open and run them from the chat Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: deploy agents from the chat, report agent tabs as the active editor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: pick agents from the tab picker, name their tabs, scope the agent page to its workspace Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: leave open editors alone on agent writes, read the agent page's workspace user Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refresh an agent's deployed page when the chat deploys or deletes it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: name agent tabs before they load, mark agents in the changes drawer, stay on a deleted agent's tab Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep a cleared agent summary out of its tab name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the resource drawer's json option that the resources list still passes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: open an agent in an AI session from its pages, fix review findings on agent tabs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: name the reserved agent tool names in the agent guidance Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: reload an agent's editor on a discard, open a new agent in a session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: show agents as agents on compare and deploy, name a never-deployed one by its path Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep a deployed agent's type in its draft diff Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: link a fork row to the agent editor only on the side that holds the agent Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: guard agent page paths, mark drafted agents in the picker, side-aware agent rows on fork compare Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1661 lines
70 KiB
TypeScript
1661 lines
70 KiB
TypeScript
import { BROWSER } from 'esm-env'
|
|
import { get } from 'svelte/store'
|
|
import { createLongHash } from '$lib/editorLangUtils'
|
|
import { random_adj } from '$lib/components/random_positive_adjetive'
|
|
import {
|
|
enterpriseLicense,
|
|
superadmin,
|
|
userWorkspaces,
|
|
usersWorkspaceStore,
|
|
workspaceStore,
|
|
type UserWorkspace
|
|
} from '$lib/stores'
|
|
import { switchWorkspace } from '$lib/storeUtils'
|
|
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
|
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
|
import { isReadOnlySessionWorkspace } from '../copilot/chat/global/sessionAccess'
|
|
import { AIWorkMode } from '../copilot/chat/workMode'
|
|
import { workspaceRootId } from './sessionScope.svelte'
|
|
import { clearSessionRecovered } from './sessionRecoveryNotice.svelte'
|
|
import { type DBSchema, type IDBPDatabase } from 'idb'
|
|
import { userScopedDb } from '$lib/userScopedDb'
|
|
import { emailOfScopedKey, scopedKeyFor } from '$lib/userScopedStorage'
|
|
import { deleteItemsForSession } from '../copilot/chat/files/attachedFilesDB'
|
|
import { deleteArtifactsForSession } from '../copilot/chat/artifacts/artifactsDB'
|
|
import { deleteSessionChats } from '../copilot/chat/HistoryManager.svelte'
|
|
import type { DraftAttachments } from '../copilot/chat/messageDraft.svelte'
|
|
import type { AppDomSelectorElement } from '../copilot/chat/context'
|
|
import {
|
|
markSessionDirty,
|
|
markSessionRemoved,
|
|
sessionSwept,
|
|
sessionsLockName
|
|
} from './sessionMirrorSignal'
|
|
|
|
// Switch the global workspace iff the target differs from the active one
|
|
// and is non-empty. Centralises the "session needs its workspace in focus"
|
|
// rule so picker, deep-link, and workspace-bar paths agree on the same
|
|
// semantic. No-op for `undefined` / empty.
|
|
export function syncWorkspaceTo(workspaceId: string | undefined): void {
|
|
if (!workspaceId) return
|
|
if (workspaceId === get(workspaceStore)) return
|
|
switchWorkspace(workspaceId)
|
|
}
|
|
import { WorkspaceService } from '$lib/gen'
|
|
import { createWorkspaceForkAndWait, waitForForkCreation } from '$lib/utils/forkCreation'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { onUserChange } from '$lib/userScopedStorage'
|
|
|
|
// A destination the session preview can open as an editor: a workspace item
|
|
// (`path`) for flow/script/raw_app, or — for 'pipeline' — a folder name (not an
|
|
// item path), which resolves to the data-pipeline graph editor for that folder.
|
|
// Legacy drag-and-drop apps aren't previewable; only code-based 'raw_app' apps.
|
|
export type SessionTarget = { kind: 'flow' | 'script' | 'raw_app' | 'pipeline'; path: string }
|
|
|
|
// Whether the session points at a workspace that is itself a fork (i.e.
|
|
// has a parent). Used by the sidebar to pick between a root (Building)
|
|
// icon and a fork icon.
|
|
//
|
|
// Sessions whose committed workspace is no longer in the user's list are
|
|
// still treated as forks (the "unavailable" terminal state) so we don't
|
|
// flip them back to Building once access is lost.
|
|
export function isForkSession(session: Session, allWorkspaces: UserWorkspace[]): boolean {
|
|
const wsId = session.workspace_id ?? session.pending_workspace_id
|
|
if (!wsId) return false
|
|
const ws = allWorkspaces.find((w) => w.id === wsId)
|
|
if (!ws) return !!session.workspace_id
|
|
return !!ws.parent_workspace_id
|
|
}
|
|
|
|
export type PendingFork = {
|
|
// Existing workspace to fork from (drives routing/scope pre-send).
|
|
parent_workspace_id: string
|
|
// Slug the new fork will use, e.g. `wm-fork-foo`.
|
|
id: string
|
|
// Display name shown in the workspace bar.
|
|
name: string
|
|
// The background creation this fork started, so a reload mid-copy waits for it rather than
|
|
// requesting the fork again, which the server refuses while that creation runs.
|
|
creation_id?: string
|
|
}
|
|
|
|
export type SessionSummarySource = 'placeholder' | 'generated' | 'manual'
|
|
|
|
export type Session = {
|
|
id: string
|
|
// Per-browser `session-N` label that older sessions-page URLs carried; only read
|
|
// to resolve those links.
|
|
name?: string
|
|
// Committed strictly at first user-message send. Undefined for drafts
|
|
// that have never been sent — those scope by `pending_workspace_id`
|
|
// instead and don't show the fork bar.
|
|
workspace_id?: string
|
|
// Pre-send draft workspace, picked via SessionWorkspaceBar. Drives
|
|
// scope/editor/display while workspace_id is undefined; gets copied
|
|
// into workspace_id at first send and then becomes irrelevant.
|
|
pending_workspace_id?: string
|
|
// Pre-send intent to create a new fork. The actual API call is
|
|
// deferred to first send (via commitSessionWorkspace) so cancelling
|
|
// the draft doesn't leave an orphan fork behind.
|
|
pending_fork?: PendingFork
|
|
// Home's draft: it acts on whatever workspace Home shows, so a switch made on another page
|
|
// moves it on Home's next mount. Cleared by a pick made on the session page, whose picker
|
|
// leaves the navigation workspace where it is.
|
|
followsHome?: boolean
|
|
// Stable root workspace id used only for sidebar grouping. Lifecycle follows
|
|
// workspace_id, not this field. Root sessions store the same id in both fields.
|
|
workspace_root_id?: string
|
|
chatId?: string
|
|
summary?: string
|
|
summarySource?: SessionSummarySource
|
|
createdAt: number
|
|
// User-archived sessions are hidden from the sidebar by default
|
|
// (toggleable via the picker filter). Archive is reversible — distinct
|
|
// from delete, which removes the session entirely.
|
|
archived?: boolean
|
|
// Set when `archived` was applied because the session's workspace was
|
|
// archived (not by the user). Lets reconciliation auto-unarchive the session
|
|
// when the workspace is unarchived, while leaving user-archived sessions be.
|
|
archivedByWorkspace?: boolean
|
|
// How many times the session moved to another workspace. The backup keeps it
|
|
// with the session's marker, so a restore that finds a copy in two workspaces
|
|
// (moved, the old copy not yet removed) takes the later one without a clock.
|
|
moves?: number
|
|
// In-memory-only flag: the session exists but hasn't been written to
|
|
// IndexedDB yet. Set at creation, cleared on the first genuine user touch
|
|
// (typed prompt, workspace/fork pick, preview tab, rename) which persists
|
|
// the record. Decoupled from "unsent" — a pending session is unsent while
|
|
// `workspace_id` is undefined, whether or not it has been persisted. An
|
|
// untouched draft never persists, so idle `+` clicks vanish on reload
|
|
// instead of littering the sidebar.
|
|
transient?: boolean
|
|
// Epoch ms of the last write to this record — typing, renaming, archiving,
|
|
// changing preview tabs, or catching up on new messages. Stamped by the two
|
|
// write funnels (persistTouched, markSessionSeen), so it tracks use rather
|
|
// than creation; merely opening a session without reading anything new is not
|
|
// a write and does not bump it.
|
|
// Absent on records last written before the field existed; readers fall back
|
|
// to createdAt via sessionLastActivityAt.
|
|
lastActivityAt?: number
|
|
// When this browser restored the session from its backup, by this browser's clock.
|
|
// The restore sets `lastActivityAt` to the backup's time, the storage's clock; the
|
|
// retention counts from whichever is later, so a browser clock ahead of the storage's
|
|
// never deletes a session it just brought back. Not backed up.
|
|
restoredAt?: number
|
|
// Per-session unread watermark: the displayMessages count the last time
|
|
// the user was on this session's page. Compared against the runtime's
|
|
// current message count to derive the unread badge (see sessionUnread).
|
|
// Travels on the record so it is scoped, migrated, and deleted with it.
|
|
lastSeenCount?: number
|
|
// Preview tabs the user opened in the sessions page, restored when the
|
|
// session is reopened.
|
|
previewTabs?: SessionPreviewTab[]
|
|
// Which preview tab was active. Falls back to the first tab on restore.
|
|
activePreviewTabId?: string
|
|
// Whether the user collapsed the preview panel for this session (to give the
|
|
// chat full width). Per-session so each session restores its own layout.
|
|
previewCollapsed?: boolean
|
|
// Preview split size (preview pane %, 0-100) the user dragged for this session.
|
|
// Per-session so each session restores its own layout.
|
|
previewSize?: number
|
|
// Use or Build, as the chat's mode picker last left it. Absent reads as Build.
|
|
workMode?: AIWorkMode.USE | AIWorkMode.BUILD
|
|
// Unsent composer text for a pending (uncommitted) session, persisted with
|
|
// the record so each parallel draft restores its own typed-but-unsent prompt.
|
|
// Only tracked while unsent; cleared once the workspace commits at first send.
|
|
draftPrompt?: string
|
|
// When `draftPrompt` should be sent on arrival rather than parked in the
|
|
// composer. Set by hand-offs whose click already stated the intent ("AI Fix",
|
|
// a typed step or app description), so they land mid-answer rather than
|
|
// waiting for a second Enter. Holds the arming time, not a flag: `goto`
|
|
// resolves even when a `beforeNavigate` cancels it, so an abandoned hand-off
|
|
// would otherwise leave a session armed forever and fire on some later visit.
|
|
// Consumed exactly once, by takeSessionAutoSend.
|
|
autoSendDraftAt?: number
|
|
// The element a preview pick hands over with the auto-sent prompt, sent as its
|
|
// chip. Lives and dies with `autoSendDraftAt`: a prompt like "make this bigger"
|
|
// must never go out without the element it names.
|
|
autoSendDomElement?: AppDomSelectorElement
|
|
}
|
|
|
|
// One preview tab: `url` is the URL we command the iframe to load, `loc` the
|
|
// last observed location (see the sessions page for the url/loc split).
|
|
// `friendlyLabel` / `friendlyPath` are transient overrides the live editor
|
|
// stamps; not persisted (hydrate rebuilds tabs field-by-field), recomputed on
|
|
// next mount. `friendlyLabel` is the item's display name — its summary, or the
|
|
// typed/auto name of a never-deployed item parked at
|
|
// `…/draft_<uuid>`. `friendlyPath` is the item's full
|
|
// staged path whenever it differs from the tab's route path — draft-parked OR
|
|
// a deployed item with an undeployed rename — and scopes the breadcrumb
|
|
// picker into the folder the picker tree displays the item under.
|
|
export type SessionPreviewTab = {
|
|
id: string
|
|
url: string
|
|
loc: string
|
|
friendlyLabel?: string
|
|
friendlyPath?: string
|
|
// True once this tab's live editor has loaded its item and reported a name —
|
|
// or reported that it has none. Until then the sessions page names the tab
|
|
// from the workspace listing; after, the editor is the only source, else
|
|
// clearing a summary would fall back to the listing's stale copy of it.
|
|
editorNamed?: boolean
|
|
// Stamped by an agent's editor in a resource tab, which is what makes that tab the chat's
|
|
// ACTIVE EDITOR rather than a page it previews.
|
|
agentEditor?: boolean
|
|
}
|
|
|
|
// Sessions live in one per-user IndexedDB, one record per session in the
|
|
// `sessions` store keyed by `id`. IndexedDB is the sole store — no localStorage
|
|
// fallback. The bare localStorage keys below are the oldest (pre-namespacing)
|
|
// source, claimed once during the legacy migration.
|
|
const SESSIONS_DB = 'windmill-sessions'
|
|
const LEGACY_SESSIONS_KEY = 'windmill_sessions'
|
|
const LEGACY_LAST_SEEN_KEY = 'windmill_sessions_last_seen_counts'
|
|
|
|
interface SessionSchema extends DBSchema {
|
|
sessions: { key: string; value: Session }
|
|
}
|
|
|
|
// Normalise legacy localStorage records in place: drop empty-string
|
|
// workspace_id (older drafts used '' as a missing marker), drop the retired
|
|
// `target` field (the preview is tab-driven now), and coerce unknown
|
|
// summarySource values. Operates on raw parsed JSON, so the record is loosely typed.
|
|
function normalizeLegacySession(s: Record<string, any>): void {
|
|
if (s.workspace_id === '') delete s.workspace_id
|
|
delete s.target
|
|
if (
|
|
s.summarySource !== undefined &&
|
|
s.summarySource !== 'placeholder' &&
|
|
s.summarySource !== 'generated' &&
|
|
s.summarySource !== 'manual'
|
|
) {
|
|
s.summarySource = 'manual'
|
|
}
|
|
}
|
|
|
|
// One-shot claim of the pre-namespacing localStorage data into the per-user DB.
|
|
// Runs once per scoped DB (via userScopedDb's migrate gate) and only when the
|
|
// DB is still empty — the first user to connect on a previously single-user
|
|
// browser inherits the sessions; later users start clean. The bare watermark
|
|
// map is folded onto each record as lastSeenCount. Both bare keys are deleted
|
|
// so nothing is left to re-claim. Intermediate `::email` keys (this branch's
|
|
// throwaway dev data) are intentionally not migrated.
|
|
async function migrateSessionsFromLocalStorage(db: IDBPDatabase<SessionSchema>): Promise<void> {
|
|
// Only claim into a still-empty DB, but ALWAYS fall through to delete the bare
|
|
// keys below — even when the DB is already populated. Otherwise a partially
|
|
// failed prior migration (puts committed, deletes didn't) would leave the bare
|
|
// keys for a different empty-DB user to later claim: the exact leak this closes.
|
|
const alreadyPopulated = (await db.count('sessions')) > 0
|
|
const raw = getLocalSetting(LEGACY_SESSIONS_KEY)
|
|
if (!alreadyPopulated && raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw)
|
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
let watermarks: Record<string, number> = {}
|
|
const wmRaw = getLocalSetting(LEGACY_LAST_SEEN_KEY)
|
|
if (wmRaw) {
|
|
try {
|
|
watermarks = JSON.parse(wmRaw)
|
|
} catch {
|
|
/* ignore malformed watermark map */
|
|
}
|
|
}
|
|
const tx = db.transaction('sessions', 'readwrite')
|
|
for (const s of parsed) {
|
|
if (!s || typeof s.id !== 'string' || s.transient) continue
|
|
normalizeLegacySession(s)
|
|
const count = watermarks[s.id]
|
|
if (typeof count === 'number') s.lastSeenCount = count
|
|
tx.store.put(s as Session)
|
|
}
|
|
await tx.done
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to migrate legacy sessions into IndexedDB', e)
|
|
}
|
|
}
|
|
// Drop the bare keys unconditionally (claimed, empty, malformed, or stale
|
|
// leftovers from a partially-failed prior migration) so a later different user
|
|
// on this browser never re-inherits them.
|
|
storeLocalSetting(LEGACY_SESSIONS_KEY, undefined)
|
|
storeLocalSetting(LEGACY_LAST_SEEN_KEY, undefined)
|
|
}
|
|
|
|
function sessionRootId(s: Session): string | undefined {
|
|
return (
|
|
s.workspace_root_id ??
|
|
workspaceRootId(s.workspace_id ?? s.pending_workspace_id, get(userWorkspaces)) ??
|
|
s.workspace_id ??
|
|
s.pending_workspace_id
|
|
)
|
|
}
|
|
|
|
// Whether a session belongs to the active workspace's family. Only in-memory
|
|
// drafts (transient) may lack a root and still count as in-family — they follow
|
|
// the user until a workspace is picked. A persisted session with no workspace
|
|
// binding must fail closed: counting it as in-family would surface it in every
|
|
// family, including freshly created workspaces.
|
|
export function sessionInCurrentFamily(s: Session): boolean {
|
|
const currentRoot = workspaceRootId(get(workspaceStore) ?? undefined, get(userWorkspaces))
|
|
if (!currentRoot) return true
|
|
const root = sessionRootId(s)
|
|
if (root === undefined) return !!s.transient
|
|
return root === currentRoot
|
|
}
|
|
|
|
function ensureSessionRootId(s: Session): boolean {
|
|
if (s.workspace_root_id || s.transient) return false
|
|
const workspaceId = s.workspace_id ?? s.pending_workspace_id
|
|
if (workspaceId && get(userWorkspaces).length === 0) return false
|
|
const root = sessionRootId(s)
|
|
if (!root) return false
|
|
s.workspace_root_id = root
|
|
return true
|
|
}
|
|
|
|
// Recompute workspace_root_id from the live parent chain, replacing a stale
|
|
// stored value. A fork family is re-rooted when an ancestor is deleted: the
|
|
// FK's ON DELETE SET NULL nulls the child's parent_workspace_id, so the topmost
|
|
// member shifts down. Without this, a sub-fork session keeps a root pointing at
|
|
// the deleted ancestor and drops out of the sidebar (grouped under a dead root)
|
|
// even though its own workspace is still alive. Only re-roots when the
|
|
// workspace resolves in the live list — otherwise workspaceRootId falls back to
|
|
// the id itself and would clobber a valid root for a merely-unavailable workspace.
|
|
function refreshSessionRootId(s: Session): boolean {
|
|
if (s.transient) return false
|
|
const workspaceId = s.workspace_id ?? s.pending_workspace_id
|
|
if (!workspaceId) return false
|
|
const all = get(userWorkspaces)
|
|
if (!all.some((w) => w.id === workspaceId)) return false
|
|
const root = workspaceRootId(workspaceId, all)
|
|
if (!root || root === s.workspace_root_id) return false
|
|
s.workspace_root_id = root
|
|
return true
|
|
}
|
|
|
|
const sessionsDb = userScopedDb<SessionSchema>(SESSIONS_DB, {
|
|
version: 1,
|
|
upgrade(db) {
|
|
if (!db.objectStoreNames.contains('sessions')) {
|
|
db.createObjectStore('sessions', { keyPath: 'id' })
|
|
}
|
|
},
|
|
async migrate(db) {
|
|
await migrateSessionsFromLocalStorage(db)
|
|
}
|
|
})
|
|
|
|
// Starts empty: the list is hydrated from the user's IndexedDB by the
|
|
// onUserChange handler below once the logged-in email resolves (async, after
|
|
// layout load). Opening eagerly here would touch a DB before we know who is
|
|
// logged in.
|
|
export const sessionState = $state<{
|
|
sessions: Session[]
|
|
currentSessionId: string | undefined
|
|
// False until the first IndexedDB hydration completes. Consumers gate their
|
|
// "not found" / empty states on it — before that, an empty list only means
|
|
// "still loading", not "the user has no sessions".
|
|
hydrated: boolean
|
|
}>({
|
|
sessions: [],
|
|
currentSessionId: undefined,
|
|
hydrated: false
|
|
})
|
|
|
|
// Debounced write-behind of the composer text for a pending (uncommitted)
|
|
// session, so a typed-but-unsent prompt survives a reload as part of the record.
|
|
// Keyed per session: a single shared timer would let a keystroke in one draft
|
|
// cancel a sibling draft's pending flush, dropping that draft's first-touch write.
|
|
const draftPromptFlushHandles = new Map<string, ReturnType<typeof setTimeout>>()
|
|
export function setSessionDraftPrompt(sessionId: string, text: string): void {
|
|
const s = sessionState.sessions.find((x) => x.id === sessionId)
|
|
if (!s || s.workspace_id) return
|
|
// No-op on an unchanged prompt, so opening an untouched draft never persists
|
|
// it. Writes of '' are real edits (a draft typed then erased is still a
|
|
// session), which is why the composer reports edits only — see AIChatInput.
|
|
if ((s.draftPrompt ?? '') === text) return
|
|
// Keep `transient` (means "in-memory only") set until the flush persists the
|
|
// draft, so hydrateSessions preserves it across a reconcile inside this window;
|
|
// isDiscardableDraft, not `transient`, is what stops createSession reusing a
|
|
// typed draft. Only the IndexedDB write is debounced.
|
|
s.draftPrompt = text
|
|
clearTimeout(draftPromptFlushHandles.get(sessionId))
|
|
draftPromptFlushHandles.set(
|
|
sessionId,
|
|
setTimeout(() => {
|
|
draftPromptFlushHandles.delete(sessionId)
|
|
persistTouched(s)
|
|
}, 400)
|
|
)
|
|
}
|
|
|
|
// Read back the persisted composer text when a pending session's chat mounts.
|
|
// Returns nothing once the session is committed (its draft prompt was consumed).
|
|
export function getSessionDraftPrompt(sessionId: string): string | undefined {
|
|
const s = sessionState.sessions.find((x) => x.id === sessionId)
|
|
if (!s || s.workspace_id) return undefined
|
|
return s.draftPrompt
|
|
}
|
|
|
|
// The rest of an unsent draft (collapsed pastes, images, files), so a composer mounted again
|
|
// for the same session, on Home or the session page, gets the draft whole: a collapsed paste
|
|
// is a token in `draftPrompt` whose content lives here. In memory only, like the attachments
|
|
// themselves before a send, so a reload drops them as it always has.
|
|
const draftAttachments = new Map<string, DraftAttachments>()
|
|
|
|
export function setSessionDraftAttachments(sessionId: string, attachments: DraftAttachments): void {
|
|
const empty = Object.values(attachments).every((lane) => lane.length === 0)
|
|
if (empty) {
|
|
draftAttachments.delete(sessionId)
|
|
return
|
|
}
|
|
const s = sessionState.sessions.find((x) => x.id === sessionId)
|
|
if (!s || s.workspace_id) return
|
|
draftAttachments.set(sessionId, attachments)
|
|
// Attaching is a touch, as typing is: a draft holding only an image would otherwise still
|
|
// read as a blank, be reused by the next hand-off with the image in it, or be dropped.
|
|
if (s.transient) persistTouched(s)
|
|
}
|
|
|
|
export function getSessionDraftAttachments(sessionId: string): DraftAttachments | undefined {
|
|
const s = sessionState.sessions.find((x) => x.id === sessionId)
|
|
if (!s || s.workspace_id) return undefined
|
|
return draftAttachments.get(sessionId)
|
|
}
|
|
|
|
// A hand-off arrives within a navigation; anything older than this is the debris
|
|
// of one that never landed, and must not fire at whatever the user does next.
|
|
const AUTO_SEND_TTL_MS = 5 * 60_000
|
|
|
|
// Mark this session's draft prompt for sending on arrival. Written straight to
|
|
// the record (not via setSessionDraftPrompt's debounce) because the navigation
|
|
// that follows must not outrun it.
|
|
export function setSessionAutoSend(sessionId: string, domElement?: AppDomSelectorElement): void {
|
|
const s = sessionState.sessions.find((x) => x.id === sessionId)
|
|
if (!s) return
|
|
s.autoSendDraftAt = Date.now()
|
|
s.autoSendDomElement = domElement
|
|
persistTouched(s)
|
|
}
|
|
|
|
// The element armed with the auto-send intent; read before takeSessionAutoSend clears both.
|
|
export function getSessionAutoSendDomElement(sessionId: string): AppDomSelectorElement | undefined {
|
|
return sessionState.sessions.find((x) => x.id === sessionId)?.autoSendDomElement
|
|
}
|
|
|
|
function autoSendIsFresh(s: Session | undefined): boolean {
|
|
return !!s?.autoSendDraftAt && Date.now() - s.autoSendDraftAt < AUTO_SEND_TTL_MS
|
|
}
|
|
|
|
// Whether a claim would be honoured, without consuming it. The composer asks
|
|
// before deciding to stay empty: suppressing the text for an intent that then
|
|
// goes stale would leave the prompt neither sent nor shown, and the composer's
|
|
// own empty-draft write would erase it from the record.
|
|
export function peekSessionAutoSend(sessionId: string): boolean {
|
|
return autoSendIsFresh(sessionState.sessions.find((x) => x.id === sessionId))
|
|
}
|
|
|
|
// Claim the auto-send intent, clearing it so a remount (or a second wrapper for
|
|
// the same session) cannot fire the same prompt twice. A stale claim is dropped
|
|
// rather than honoured, but still cleared — it has no other consumer.
|
|
export function takeSessionAutoSend(sessionId: string): boolean {
|
|
const s = sessionState.sessions.find((x) => x.id === sessionId)
|
|
if (!s?.autoSendDraftAt) return false
|
|
const fresh = autoSendIsFresh(s)
|
|
delete s.autoSendDraftAt
|
|
delete s.autoSendDomElement
|
|
persistTouched(s)
|
|
return fresh
|
|
}
|
|
|
|
// Persist a session on a genuine user edit, promoting an in-memory-only
|
|
// (transient) pending session to a durable IndexedDB record on first touch.
|
|
// Non-touch writers (runtime chatId writes via patchStoredSessionChatId, the
|
|
// unread watermark via putSession) persist directly, so an untouched draft
|
|
// stays in memory and vanishes on reload.
|
|
function persistTouched(s: Session): void {
|
|
if (s.transient) delete s.transient
|
|
s.lastActivityAt = Date.now()
|
|
void putSession(s)
|
|
}
|
|
|
|
// When the session was last used. Pre-dates-the-field records report their
|
|
// creation time, which is the earliest activity they could have had.
|
|
export function sessionLastActivityAt(s: Session): number {
|
|
return s.lastActivityAt ?? s.createdAt
|
|
}
|
|
|
|
// Sessions whose record has been removed from IndexedDB. Ids come from createLongHash
|
|
// and are never reused, so an entry can only ever match the record it was recorded for.
|
|
// In-memory by design: a reload re-runs reconciliation, which re-derives the set.
|
|
const deletedSessionIds = new Set<string>()
|
|
|
|
// Test-only: module state outlives a suite's per-test reset of the stores.
|
|
export function __resetDeletedSessionIdsForTesting(): void {
|
|
deletedSessionIds.clear()
|
|
}
|
|
|
|
// The one way to remove a session's record. Tombstones BEFORE awaiting the delete so a
|
|
// putSession racing this transaction cannot commit its write behind it — a direct
|
|
// db.delete elsewhere would silently reopen that window.
|
|
async function deleteSessionRow(
|
|
db: IDBPDatabase<SessionSchema>,
|
|
id: string,
|
|
// The retention sweep passes false. It holds the in-use lock exclusively, so no write can
|
|
// race its delete, and the backup may still hold the session: a tombstone would refuse the
|
|
// restore that is meant to bring it back.
|
|
tombstone = true
|
|
): Promise<void> {
|
|
if (tombstone) deletedSessionIds.add(id)
|
|
await db.delete('sessions', id)
|
|
}
|
|
|
|
// The way a session record is written (patchStoredSessionChatId is the one
|
|
// exception: it re-checks the tombstone inline to stay inside its own
|
|
// transaction). Every caller reaches its write across an await, so the
|
|
// tombstone has to be consulted here, not only at the entry points.
|
|
async function putSessionRow(db: IDBPDatabase<SessionSchema>, s: Session): Promise<void> {
|
|
if (deletedSessionIds.has(s.id)) return
|
|
await db.put('sessions', s)
|
|
markSessionDirty(s.id, undefined, emailOfScopedKey(SESSIONS_DB, db.name))
|
|
}
|
|
|
|
// Write-behind a single session record. Transient sessions are in-memory only
|
|
// (not yet touched) and are not written to IndexedDB; materializeTransient() /
|
|
// persistTouched() clear the flag first. Awaits DB-open so a write racing
|
|
// hydration still lands; no-ops (degrades to in-memory) when the DB can't be
|
|
// opened. In-memory $state is the read surface, so callers fire-and-forget.
|
|
export async function putSession(s: Session): Promise<void> {
|
|
if (!BROWSER) return
|
|
if (s.transient) return
|
|
// A record removed because its workspace is gone had its files and artifacts GC'd with
|
|
// it, so writing it back resurrects an empty husk. Callers reach here holding a
|
|
// reference captured before the delete: find-then-write callers race the
|
|
// re-hydrate, and any writer that awaits between the find and the write.
|
|
if (deletedSessionIds.has(s.id)) return
|
|
// Separately, keep a UI action (e.g. unarchive) from persisting into a workspace that
|
|
// has gone unavailable but not yet reconciled. `userWorkspaces` answers that only for
|
|
// a confirmed non-superadmin — hence `=== false`, the store being `undefined` until
|
|
// the role resolves — since a superadmin reaches workspaces they have no `usr` row in.
|
|
const boundWs = s.workspace_id ?? s.pending_workspace_id
|
|
if (boundWs && get(superadmin) === false) {
|
|
const all = get(userWorkspaces)
|
|
if (all.length > 0 && !all.some((w) => w.id === boundWs)) return
|
|
}
|
|
ensureSessionRootId(s)
|
|
const db = await sessionsDb.whenReady()
|
|
if (!db) return
|
|
try {
|
|
await putSessionRow(db, $state.snapshot(s))
|
|
} catch (e) {
|
|
console.error('Failed to persist session', e)
|
|
}
|
|
}
|
|
|
|
export async function deleteSessionRecord(id: string): Promise<void> {
|
|
if (!BROWSER) return
|
|
const db = await sessionsDb.whenReady()
|
|
if (!db) return
|
|
try {
|
|
await deleteSessionRow(db, id)
|
|
} catch (e) {
|
|
console.error('Failed to delete session record', e)
|
|
}
|
|
}
|
|
|
|
// Load the in-memory list from the user's DB. getAll() returns records in key
|
|
// (id) order; sort by createdAt descending to reproduce the newest-first order
|
|
// createSession() maintains (it prepends). whenReady() reopens for the current
|
|
// user automatically, so this also handles user switch; an absent DB (logged
|
|
// out / open failed) yields an empty list.
|
|
async function hydrateSessions({ dropTransients = false } = {}): Promise<void> {
|
|
// Transient (unsent) drafts live only in memory and belong to the current
|
|
// user; preserve them across an intra-user reconcile, but drop them on a user
|
|
// change so one user's draft (and its pending fork/workspace state) never
|
|
// bleeds into the next user's list.
|
|
const transients = dropTransients ? [] : sessionState.sessions.filter((s) => s.transient)
|
|
const db = await sessionsDb.whenReady()
|
|
if (!db) {
|
|
sessionState.sessions = transients
|
|
return
|
|
}
|
|
try {
|
|
const all = await db.getAll('sessions')
|
|
const changed = all.filter((s) => ensureSessionRootId(s))
|
|
for (const s of changed) await putSessionRow(db, s)
|
|
all.sort((a, b) => b.createdAt - a.createdAt)
|
|
// In-memory (untouched) drafts are prepended, newest-first as createSession
|
|
// maintains; persisted sessions follow, sorted by createdAt.
|
|
sessionState.sessions = [...transients, ...all]
|
|
} catch (e) {
|
|
console.error('Failed to load sessions from IndexedDB', e)
|
|
sessionState.sessions = transients
|
|
}
|
|
}
|
|
|
|
export type WorkspaceLifecycleStatus = 'active' | 'archived' | 'deleted'
|
|
|
|
// The never-orphaned rule as a pure function of (session, its workspace's
|
|
// status) — no IO, so the whole truth table is unit-testable. An `undefined`
|
|
// status (workspace absent from the queried set) is a no-op, never a delete.
|
|
// deleted → delete the session
|
|
// archived → archive it, tagged archivedByWorkspace (idempotent)
|
|
// active → auto-unarchive iff WE archived it (has archivedByWorkspace)
|
|
export function decideSessionLifecycle(
|
|
session: Session,
|
|
status: WorkspaceLifecycleStatus | undefined
|
|
): { action: 'delete' | 'archive' | 'unarchive' | 'noop'; patch?: Partial<Session> } {
|
|
if (status === 'deleted') return { action: 'delete' }
|
|
if (status === 'archived') {
|
|
return session.archived
|
|
? { action: 'noop' }
|
|
: { action: 'archive', patch: { archived: true, archivedByWorkspace: true } }
|
|
}
|
|
if (status === 'active' && session.archivedByWorkspace) {
|
|
return { action: 'unarchive', patch: { archived: undefined, archivedByWorkspace: undefined } }
|
|
}
|
|
return { action: 'noop' }
|
|
}
|
|
|
|
// Apply a decision patch in place: `undefined` removes the key (the unarchive
|
|
// path needs the flags gone, not set to undefined), any other value assigns.
|
|
function applyLifecyclePatch(session: Session, patch: Partial<Session>): void {
|
|
for (const [k, v] of Object.entries(patch)) {
|
|
if (v === undefined) delete (session as Record<string, unknown>)[k]
|
|
else (session as Record<string, unknown>)[k] = v
|
|
}
|
|
}
|
|
|
|
// Workspace switches reconcile too (to catch a workspace deleted/archived on
|
|
// another device), but throttled so rapid switching doesn't spam the status
|
|
// endpoint. Mutation-driven reconciles are unthrottled.
|
|
let lastReconcileAt = 0
|
|
const RECONCILE_THROTTLE_MS = 30_000
|
|
|
|
// Reconcile every stored session against its workspace's lifecycle. Sessions are
|
|
// client-only, so the backend can't delete/archive them directly — instead the
|
|
// client asks the backend for the status of every workspace its sessions
|
|
// reference and applies the rule (see decideSessionLifecycle) the user can't see
|
|
// happen otherwise. Reached via reconcileAfterWorkspaceChange (mutations),
|
|
// load, and a throttled workspace switch.
|
|
export async function reconcileSessionsLifecycle(): Promise<void> {
|
|
if (!BROWSER) return
|
|
lastReconcileAt = Date.now()
|
|
const db = await sessionsDb.whenReady()
|
|
if (!db) return
|
|
const wsIds = new Set<string>()
|
|
const sessions = await db.getAll('sessions')
|
|
// Committed sessions reconcile on workspace_id; persisted pending drafts on
|
|
// their pre-send pending_workspace_id, so a workspace deleted/archived under
|
|
// an unsent draft applies the same never-orphaned rule to the draft.
|
|
for (const s of sessions) {
|
|
const ws = s.workspace_id ?? s.pending_workspace_id
|
|
if (ws) wsIds.add(ws)
|
|
}
|
|
if (wsIds.size === 0) return
|
|
|
|
let status: Record<string, 'active' | 'archived' | 'deleted'>
|
|
try {
|
|
status = await WorkspaceService.getSessionWorkspaceStatus({
|
|
requestBody: { workspace_ids: [...wsIds] }
|
|
})
|
|
} catch (e) {
|
|
console.error('Failed to reconcile session lifecycle', e)
|
|
return
|
|
}
|
|
|
|
const deletedIds = new Set<string>()
|
|
try {
|
|
for (const s of sessions) {
|
|
const ws = s.workspace_id ?? s.pending_workspace_id
|
|
if (!ws) continue
|
|
const { action, patch } = decideSessionLifecycle(s, status[ws])
|
|
if (action === 'delete') {
|
|
await deleteSessionRow(db, s.id)
|
|
// GC linked files too, matching deleteSession — a record-only delete
|
|
// here would orphan the session's attached-file blobs/handles.
|
|
void deleteItemsForSession(s.id)
|
|
void deleteArtifactsForSession(s.id)
|
|
deletedIds.add(s.id)
|
|
continue
|
|
}
|
|
let changed = action === 'archive' || action === 'unarchive'
|
|
if (changed && patch) applyLifecyclePatch(s, patch)
|
|
// Re-root surviving sessions whose family topmost member shifted (an
|
|
// ancestor was deleted); fall back to backfilling a missing root.
|
|
if (refreshSessionRootId(s) || ensureSessionRootId(s)) changed = true
|
|
if (changed) await putSessionRow(db, s)
|
|
}
|
|
} catch (e) {
|
|
// The connection can go stale mid-loop (user switch reopens the per-user
|
|
// DB); this pass is best-effort — the next reconcile trigger retries.
|
|
console.error('Failed to reconcile session lifecycle', e)
|
|
return
|
|
}
|
|
await hydrateSessions()
|
|
// If the session the user was on lived in a now-deleted workspace, it was just
|
|
// removed — drop the dangling pointer so the page falls back to "no session
|
|
// selected" instead of a ghost.
|
|
if (sessionState.currentSessionId && deletedIds.has(sessionState.currentSessionId)) {
|
|
sessionState.currentSessionId = undefined
|
|
}
|
|
}
|
|
|
|
// --- Retention ---
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
|
|
// Past the retention by this browser's clock, counted from the later of the session's last
|
|
// activity and its restore here: a restored session carries the backup's time, the storage's
|
|
// clock, so without `restoredAt` a browser running ahead would delete what it just brought
|
|
// back. Archived sessions count like any other.
|
|
function isSessionExpired(
|
|
session: Session,
|
|
retentionDays: number | undefined,
|
|
now: number
|
|
): boolean {
|
|
if (retentionDays === undefined || !(retentionDays >= 1)) return false
|
|
const since = Math.max(sessionLastActivityAt(session), session.restoredAt ?? 0)
|
|
return since < now - retentionDays * DAY_MS
|
|
}
|
|
|
|
// What the server last told this browser, and when. It decides whether the sweep asks again,
|
|
// and nothing else: a retention raised or cleared since must not delete a session, and a
|
|
// persisted unsent draft has no backup to come back from.
|
|
const RETENTION_DAYS = 'windmill_sessions_retention_days'
|
|
|
|
// Nothing remembered for longer than this is trusted even to say there is nothing to ask
|
|
// about, so a retention lowered while this browser saw nothing expiring still takes effect.
|
|
const RETENTION_STALE_MS = 24 * 60 * 60 * 1000
|
|
|
|
interface RememberedRetention {
|
|
at: number
|
|
days: Record<string, number>
|
|
}
|
|
|
|
function rememberRetention(email: string, days: Record<string, number>): void {
|
|
try {
|
|
const remembered: RememberedRetention = { at: Date.now(), days }
|
|
localStorage.setItem(scopedKeyFor(RETENTION_DAYS, email), JSON.stringify(remembered))
|
|
} catch {}
|
|
}
|
|
|
|
function rememberedRetention(email: string): RememberedRetention | undefined {
|
|
try {
|
|
const stored = localStorage.getItem(scopedKeyFor(RETENTION_DAYS, email))
|
|
const remembered = stored ? JSON.parse(stored) : undefined
|
|
if (remembered?.days && typeof remembered.days === 'object') {
|
|
return remembered as RememberedRetention
|
|
}
|
|
} catch {}
|
|
return undefined
|
|
}
|
|
|
|
// How long the sweep waits for the retention of the workspaces it is about to sweep in. The
|
|
// tab reads its sessions after the sweep, so a request nothing answers costs the list this
|
|
// much and no more, and only in a tab that had something to delete.
|
|
const RETENTION_ASK_MS = 5000
|
|
|
|
// The retention the server gives now, or undefined when this browser could not be told: a
|
|
// session is deleted only on an answer of the moment.
|
|
async function askRetention(workspaceIds: string[]): Promise<Record<string, number> | undefined> {
|
|
try {
|
|
return await Promise.race([
|
|
WorkspaceService.getSessionWorkspaceRetention({
|
|
requestBody: { workspace_ids: workspaceIds }
|
|
}),
|
|
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), RETENTION_ASK_MS))
|
|
])
|
|
} catch (e) {
|
|
console.error('Failed to read the AI session retention of the workspaces', e)
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// One key per session this browser swept whose pieces are not all deleted yet.
|
|
const RETENTION_PENDING = 'windmill_sessions_retention_pending'
|
|
|
|
function retentionPendingPrefix(email: string): string {
|
|
return `${scopedKeyFor(RETENTION_PENDING, email)}::`
|
|
}
|
|
|
|
function forgetRetentionPending(email: string, id: string): void {
|
|
try {
|
|
localStorage.removeItem(retentionPendingPrefix(email) + id)
|
|
} catch {}
|
|
}
|
|
|
|
function retentionPending(email: string): string[] {
|
|
const prefix = retentionPendingPrefix(email)
|
|
const ids: string[] = []
|
|
try {
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const key = localStorage.key(i)
|
|
if (key?.startsWith(prefix)) ids.push(key.slice(prefix.length))
|
|
}
|
|
} catch {}
|
|
return ids
|
|
}
|
|
|
|
function webLocks(): LockManager | undefined {
|
|
return typeof navigator === 'undefined' ? undefined : (navigator as { locks?: LockManager }).locks
|
|
}
|
|
|
|
// Held, shared, by every tab from before it reads the user's sessions until it stops using
|
|
// them: the stores are shared, and each tab keeps copies of the sessions in memory, so the
|
|
// sweep deletes only while holding this exclusively.
|
|
function sessionsInUseLockName(email: string): string {
|
|
return `${sessionsLockName(email)}::in-use`
|
|
}
|
|
|
|
interface InUseHold {
|
|
email: string
|
|
released: boolean
|
|
release?: () => void
|
|
done?: Promise<unknown>
|
|
}
|
|
|
|
let inUse: InUseHold | undefined
|
|
|
|
// Resolves once the hold is granted, which waits for the sweep another tab is running. A
|
|
// request the browser refuses (a document that is not fully active) resolves it too, without
|
|
// a hold: the tab reads its sessions unguarded, as it does where Web Locks do not exist, and
|
|
// never sits waiting for a grant that is not coming.
|
|
async function holdSessionsInUse(email: string): Promise<void> {
|
|
const locks = webLocks()
|
|
if (!locks || inUse?.email === email) return
|
|
await releaseSessionsInUse()
|
|
const hold: InUseHold = { email, released: false }
|
|
inUse = hold
|
|
await new Promise<void>((granted) => {
|
|
hold.done = locks
|
|
.request(sessionsInUseLockName(email), { mode: 'shared' }, () => {
|
|
granted()
|
|
return hold.released ? undefined : new Promise<void>((resolve) => (hold.release = resolve))
|
|
})
|
|
.catch((e) => {
|
|
console.error('Could not hold the AI sessions this tab is reading', e)
|
|
if (inUse === hold) inUse = undefined
|
|
granted()
|
|
})
|
|
})
|
|
}
|
|
|
|
// Resolves once the hold is let go of, so an exclusive request made next can be granted.
|
|
async function releaseSessionsInUse(): Promise<void> {
|
|
const hold = inUse
|
|
if (!hold) return
|
|
inUse = undefined
|
|
hold.released = true
|
|
hold.release?.()
|
|
await hold.done?.catch(() => {})
|
|
}
|
|
|
|
// Deletes one expired session: its record first, so nothing plans a push for it afterwards,
|
|
// then its pieces. The pending key, written before the record and removed once every piece
|
|
// is gone, is what a later sweep finishes a failed deletion from.
|
|
async function sweepSession(
|
|
db: IDBPDatabase<SessionSchema>,
|
|
id: string,
|
|
email: string
|
|
): Promise<void> {
|
|
try {
|
|
localStorage.setItem(retentionPendingPrefix(email) + id, '1')
|
|
} catch {
|
|
return
|
|
}
|
|
await deleteSessionRow(db, id, false)
|
|
await sessionSwept(id, email)
|
|
if (await deleteSessionPieces(id, email)) forgetRetentionPending(email, id)
|
|
}
|
|
|
|
// Chats with their images, artifacts and attached files. False when any of them could not
|
|
// be deleted.
|
|
async function deleteSessionPieces(id: string, email: string): Promise<boolean> {
|
|
const chats = await deleteSessionChats(id, email)
|
|
const artifacts = await deleteArtifactsForSession(id, email)
|
|
const files = await deleteItemsForSession(id)
|
|
return chats && artifacts && files
|
|
}
|
|
|
|
// The workspace a session's retention comes from: persisted unsent drafts count by the one
|
|
// they are waiting on.
|
|
function retentionWorkspaceOf(session: Session): string | undefined {
|
|
return session.workspace_id ?? session.pending_workspace_id
|
|
}
|
|
|
|
// Deletes this browser's copies of the sessions past their workspace's retention, and the
|
|
// pieces of the ones an earlier sweep could not finish (docs/ai-session-backups.md). Deleting
|
|
// one record at a time, without re-reading it, is safe only under the in-use lock held
|
|
// exclusively, granted exactly when no tab has the sessions loaded — hence the call site.
|
|
async function sweepExpiredSessions(email: string): Promise<void> {
|
|
const locks = webLocks()
|
|
if (!locks || inUse) return
|
|
try {
|
|
await locks.request(sessionsInUseLockName(email), { ifAvailable: true }, async (idle) => {
|
|
if (!idle) return
|
|
// The flush and the restore run under this one: neither must see a session half
|
|
// deleted, or plan a push from it.
|
|
await locks.request(sessionsLockName(email), { ifAvailable: true }, async (mirror) => {
|
|
if (!mirror) return
|
|
const db = await sessionsDb.whenReady()
|
|
if (!db || db.name !== scopedKeyFor(SESSIONS_DB, email)) return
|
|
for (const id of retentionPending(email)) {
|
|
// A restore brought the session back: its pieces are that copy's now.
|
|
const back = (await db.getKey('sessions', id)) !== undefined
|
|
if (back || (await deleteSessionPieces(id, email))) forgetRetentionPending(email, id)
|
|
}
|
|
const stored = await db.getAll('sessions')
|
|
const remembered = rememberedRetention(email)
|
|
const now = Date.now()
|
|
const workspaces = new Set<string>()
|
|
let expired = false
|
|
for (const s of stored) {
|
|
const ws = retentionWorkspaceOf(s)
|
|
if (ws === undefined) continue
|
|
workspaces.add(ws)
|
|
expired ||= isSessionExpired(s, remembered?.days[ws], now)
|
|
}
|
|
// Nothing to sweep in, or nothing old enough by an answer recent enough to be
|
|
// believed about that: this load costs no request.
|
|
const fresh = remembered !== undefined && now - remembered.at < RETENTION_STALE_MS
|
|
if (workspaces.size === 0 || (!expired && fresh)) return
|
|
const retention = await askRetention([...workspaces])
|
|
// Asked and not told: the sessions wait for the next load rather than go on an
|
|
// answer this browser does not have.
|
|
if (!retention) return
|
|
rememberRetention(email, retention)
|
|
for (const s of stored) {
|
|
const ws = retentionWorkspaceOf(s)
|
|
if (ws === undefined || !isSessionExpired(s, retention[ws], Date.now())) continue
|
|
await sweepSession(db, s.id, email)
|
|
}
|
|
})
|
|
})
|
|
} catch (e) {
|
|
console.error('Failed to sweep the sessions past their retention', e)
|
|
}
|
|
}
|
|
|
|
// The single seam for "a workspace just changed — bring sessions back in sync."
|
|
// Refresh the workspace list FIRST — both reconcile and the putSession guard
|
|
// read it, so it must reflect the change before reconcile runs — then reconcile.
|
|
// Call this from every workspace create/delete/archive/unarchive site.
|
|
export async function reconcileAfterWorkspaceChange(): Promise<void> {
|
|
if (!BROWSER) return
|
|
try {
|
|
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
|
} catch (e) {
|
|
console.error('Failed to refresh workspace list before reconcile', e)
|
|
}
|
|
await reconcileSessionsLifecycle()
|
|
}
|
|
|
|
// Matches on `workspace_id ?? pending_workspace_id` so persisted unsent drafts
|
|
// count too; reconcileSessionsLifecycle tears them down with committed sessions on
|
|
// archive/delete, so this pre-teardown confirmation count must match.
|
|
export async function countSessionsForWorkspace(workspaceId: string): Promise<number> {
|
|
if (!BROWSER) return 0
|
|
const db = await sessionsDb.whenReady()
|
|
if (!db) return 0
|
|
try {
|
|
const all = await db.getAll('sessions')
|
|
return all.filter(
|
|
(s) => (s.workspace_id ?? s.pending_workspace_id) === workspaceId && !s.transient
|
|
).length
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
export async function archiveSessionsForWorkspace(workspaceId: string): Promise<void> {
|
|
if (!BROWSER || !workspaceId) return
|
|
const db = await sessionsDb.whenReady()
|
|
if (!db) return
|
|
const all = await db.getAll('sessions')
|
|
for (const s of all) {
|
|
// Skip sessions the user already archived: tagging them archivedByWorkspace
|
|
// would make a later workspace-unarchive auto-restore them, discarding the
|
|
// user's manual archive. Mirrors decideSessionLifecycle's already-archived noop.
|
|
if (s.transient || s.workspace_id !== workspaceId || s.archived) continue
|
|
s.archived = true
|
|
s.archivedByWorkspace = true
|
|
ensureSessionRootId(s)
|
|
await putSessionRow(db, s)
|
|
}
|
|
for (const s of sessionState.sessions) {
|
|
if (s.transient || s.workspace_id !== workspaceId || s.archived) continue
|
|
s.archived = true
|
|
s.archivedByWorkspace = true
|
|
}
|
|
}
|
|
|
|
export async function deleteSessionsForWorkspace(workspaceId: string): Promise<void> {
|
|
if (!BROWSER || !workspaceId) return
|
|
const db = await sessionsDb.whenReady()
|
|
if (!db) return
|
|
const all = await db.getAll('sessions')
|
|
const ids = new Set(
|
|
all.filter((s) => s.workspace_id === workspaceId && !s.transient).map((s) => s.id)
|
|
)
|
|
for (const id of ids) {
|
|
await deleteSessionRow(db, id)
|
|
// GC linked files too (matches deleteSession) so a workspace teardown
|
|
// doesn't leave the sessions' attached-file blobs/handles orphaned.
|
|
void deleteItemsForSession(id)
|
|
void deleteArtifactsForSession(id)
|
|
}
|
|
sessionState.sessions = sessionState.sessions.filter((s) => !ids.has(s.id))
|
|
if (sessionState.currentSessionId && ids.has(sessionState.currentSessionId)) {
|
|
sessionState.currentSessionId = undefined
|
|
}
|
|
}
|
|
|
|
// Re-hydrate on user (email) change. The new user's persisted sessions are
|
|
// re-read from their own scoped DB (different email → different DB name); the
|
|
// in-memory list is rebuilt from scratch — including dropping the previous
|
|
// user's transient drafts — and the active-session pointer is cleared, so one
|
|
// user's sessions never bleed into another.
|
|
onUserChange(async (email, prevEmail) => {
|
|
if (!BROWSER) return
|
|
// The retention sweep runs here and nowhere else: this tab holds none of the new user's
|
|
// sessions yet, and letting go of the hold it had leaves it holding none of anyone's. The
|
|
// new hold is taken before the sessions are read, so another tab's sweep never deletes
|
|
// what this tab is about to load, and one already running is waited for.
|
|
await releaseSessionsInUse()
|
|
if (email) {
|
|
await sweepExpiredSessions(email)
|
|
await holdSessionsInUse(email)
|
|
}
|
|
await hydrateSessions({ dropTransients: prevEmail !== email })
|
|
// onUserChange also fires at registration time, before the email resolves —
|
|
// that hydration is an empty no-op and must not clear the loading state.
|
|
if (email !== undefined) sessionState.hydrated = true
|
|
if (prevEmail !== undefined && prevEmail !== email) {
|
|
sessionState.currentSessionId = undefined
|
|
}
|
|
// Load-time reconcile: catch workspaces archived/deleted while away.
|
|
void reconcileSessionsLifecycle()
|
|
})
|
|
|
|
// Workspace switches do not reload or clear the active session. The sidebar
|
|
// filters the single loaded session list by workspace_root_id, while the current
|
|
// chat survives workspace switches.
|
|
if (BROWSER) {
|
|
let lastWorkspace: string | undefined
|
|
let initialized = false
|
|
workspaceStore.subscribe((ws) => {
|
|
const isSwitch = initialized && ws !== lastWorkspace
|
|
initialized = true
|
|
lastWorkspace = ws
|
|
if (isSwitch && Date.now() - lastReconcileAt > RECONCILE_THROTTLE_MS) {
|
|
void reconcileSessionsLifecycle()
|
|
}
|
|
})
|
|
}
|
|
|
|
export function sessionPageHref(id: string): string {
|
|
return `/sessions?session=${encodeURIComponent(id)}`
|
|
}
|
|
|
|
// Bumped to ask the active session's composer to re-focus even when
|
|
// `currentSessionId` doesn't change — the `+` reuse path lands you back on the
|
|
// untouched draft you're already viewing, so nothing navigates, but the click
|
|
// should still drop the cursor in the composer. SessionWrapper's focus effect
|
|
// depends on `nonce`.
|
|
export const composerFocusRequest = $state<{ nonce: number }>({ nonce: 0 })
|
|
export function requestComposerFocus(): void {
|
|
composerFocusRequest.nonce++
|
|
}
|
|
|
|
// An untouched in-memory blank that `+` may reuse and createSession may silently
|
|
// drop. `draftPrompt === undefined` (never edited), not falsiness: a draft typed
|
|
// then erased to '' still has a pending flush and is a real session. Every other
|
|
// touch clears `transient` synchronously, so only the draft prompt needs checking.
|
|
function isDiscardableDraft(s: Session): boolean {
|
|
return !!s.transient && s.draftPrompt === undefined
|
|
}
|
|
|
|
// Somewhere empty to put the user, for a URL naming a session this browser
|
|
// doesn't hold. Only `transient` makes "empty" trustworthy: a persisted session
|
|
// can hold a conversation while every field below stays untouched — its chat and
|
|
// attached files key off `!transient`, not off these.
|
|
export function findEmptyLandingSession(): Session | undefined {
|
|
return sessionState.sessions.find(
|
|
(s) =>
|
|
!!s.transient &&
|
|
!s.archived &&
|
|
!s.workspace_id &&
|
|
// Falsiness, not `=== undefined`: we only navigate into the session, so a
|
|
// draft erased back to '' is still an empty composer to land on.
|
|
!s.draftPrompt?.trim() &&
|
|
!s.pending_fork &&
|
|
sessionInCurrentFamily(s)
|
|
)
|
|
}
|
|
|
|
export function createSession(): Session {
|
|
// Reuse an existing untouched draft from the active family rather than pile a
|
|
// blank entry on every `+`, so several pending sessions can still be built up
|
|
// in parallel, one touch at a time. A cross-family leftover blank is dropped
|
|
// instead of reused (reusing it would act on that family).
|
|
const reusable = sessionState.sessions.find(
|
|
(s) => isDiscardableDraft(s) && sessionInCurrentFamily(s)
|
|
)
|
|
if (reusable) {
|
|
// It may be Home's draft; asked for here, it is the session page's now, and callers
|
|
// park it on a workspace of their own that Home must not move it off.
|
|
delete reusable.followsHome
|
|
sessionState.currentSessionId = reusable.id
|
|
// The blank recovery just landed on is exactly what this reuses, so `+`
|
|
// would otherwise hand back a session still carrying the recovery notice:
|
|
// asking for a new session must not be answered with "we couldn't find it".
|
|
clearSessionRecovered(reusable.id)
|
|
// Reusing an already-active draft doesn't change currentSessionId, so ask
|
|
// the composer to focus explicitly — the caller still navigates/redirects.
|
|
requestComposerFocus()
|
|
return reusable
|
|
}
|
|
const session = newTransientSession()
|
|
sessionState.currentSessionId = session.id
|
|
return session
|
|
}
|
|
|
|
// Told which never-touched drafts were dropped, so their runtimes go with them. Set by
|
|
// sessionRuntime, which imports this module and so cannot be imported back. Home builds a
|
|
// runtime for its draft on every mount, so a dropped draft would otherwise leave its
|
|
// manager and its history database handle open.
|
|
let onDraftsDiscarded: ((ids: string[]) => void) | undefined
|
|
export function setDraftsDiscardedHandler(handler: (ids: string[]) => void): void {
|
|
onDraftsDiscarded = handler
|
|
}
|
|
|
|
function newTransientSession(): Session {
|
|
const discarded = sessionState.sessions.filter(isDiscardableDraft).map((s) => s.id)
|
|
sessionState.sessions = sessionState.sessions.filter((s) => !isDiscardableDraft(s))
|
|
if (discarded.length > 0) onDraftsDiscarded?.(discarded)
|
|
// Start in the workspace you're in, a locked production root included: the session is then
|
|
// read-only, and points the user to a new session in the dev workspace for changes.
|
|
const pending = get(workspaceStore)
|
|
// Friendly default summary so the header reads like "Zippy session"
|
|
// rather than "Untitled session" — assigned at create time, the user
|
|
// can still rename it (or it gets overwritten by an editor target).
|
|
const adj = random_adj()
|
|
const summary = `${adj.charAt(0).toUpperCase() + adj.slice(1)} session`
|
|
const session: Session = {
|
|
id: createLongHash(),
|
|
summary,
|
|
summarySource: 'placeholder',
|
|
pending_workspace_id: pending && pending.length > 0 ? pending : undefined,
|
|
createdAt: Date.now(),
|
|
transient: true
|
|
}
|
|
sessionState.sessions = [session, ...sessionState.sessions]
|
|
// Transient until first touch: no DB record yet. Persisting is deferred to the
|
|
// first user edit (the mutation helpers below route through persistTouched).
|
|
return session
|
|
}
|
|
|
|
/** The unsent session Home's composer writes into, so a prompt half-typed on Home is the
|
|
* one the empty session page shows, and the reverse. Never selects it: merely opening Home
|
|
* must not change which session the AI Sessions switch resumes. A session opened on an
|
|
* item (it carries preview tabs) is about that item, and one the session page picked onto
|
|
* another workspace of the family is about that workspace: Home acts on the workspace it
|
|
* shows, so it starts its own instead of moving either. */
|
|
export function homeStarterSession(): Session {
|
|
const homeWs = get(workspaceStore)
|
|
const homes = (s: Session) =>
|
|
(s.followsHome || s.pending_workspace_id === homeWs) && sessionInCurrentFamily(s)
|
|
const unsent = (s: Session) =>
|
|
!s.archived && !s.workspace_id && !s.previewTabs?.length && homes(s)
|
|
const current = sessionState.sessions.find((s) => s.id === sessionState.currentSessionId)
|
|
// Stored but never sent: a draft the user touched, by typing or by picking where it acts.
|
|
// Either is theirs to come back to, and leaving it would strand it in the sidebar.
|
|
const touched = sessionState.sessions
|
|
.filter((s) => unsent(s) && !s.transient)
|
|
.sort((a, b) => sessionLastActivityAt(b) - sessionLastActivityAt(a))[0]
|
|
const draft =
|
|
(current && unsent(current) ? current : undefined) ??
|
|
touched ??
|
|
sessionState.sessions.find((s) => isDiscardableDraft(s) && homes(s)) ??
|
|
newTransientSession()
|
|
if (!draft.followsHome) {
|
|
draft.followsHome = true
|
|
if (!draft.transient) void putSession(draft)
|
|
}
|
|
return draft
|
|
}
|
|
|
|
// Promote an in-memory transient session to a persisted IndexedDB record.
|
|
// No-op when the session isn't transient (already persisted by a prior touch).
|
|
// Called on the first genuine user touch (via persistTouched) and, idempotently,
|
|
// from the chat manager's beforeSend so a send always hits a persisted record.
|
|
export function materializeTransient(id: string): void {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s || !s.transient) return
|
|
delete s.transient
|
|
void putSession(s)
|
|
}
|
|
|
|
export function setSessionPendingWorkspace(id: string, workspace_id: string) {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s) return
|
|
const changed = s.pending_workspace_id !== workspace_id || s.pending_fork !== undefined
|
|
s.pending_workspace_id = workspace_id
|
|
// Picking an existing workspace cancels any pending fork intent.
|
|
s.pending_fork = undefined
|
|
if (changed) persistTouched(s)
|
|
}
|
|
|
|
export function stopFollowingHome(id: string) {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s?.followsHome) return
|
|
delete s.followsHome
|
|
if (!s.transient) void putSession(s)
|
|
}
|
|
|
|
// Park a just-created session on a workspace. Deliberately does not persist a
|
|
// still-transient draft: picking where it will live is part of creating it, not a
|
|
// user touch, so an unused draft still vanishes on reload (see `transient`).
|
|
export function setNewSessionWorkspace(id: string, workspace_id: string) {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s) return
|
|
s.pending_workspace_id = workspace_id
|
|
s.pending_fork = undefined
|
|
if (!s.transient) persistTouched(s)
|
|
}
|
|
|
|
// Records the user's intent to create a new fork without firing the API
|
|
// call yet. Routing/scope stay on the parent workspace until commit.
|
|
export function setSessionPendingFork(id: string, fork: PendingFork) {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s) return
|
|
s.pending_fork = { ...fork }
|
|
s.pending_workspace_id = fork.parent_workspace_id
|
|
persistTouched(s)
|
|
}
|
|
|
|
// One-shot commit: locks in workspace_id at first user-message send.
|
|
// If a pending fork is set, materialises it via the API first, then
|
|
// switches the global workspace to the freshly created fork. Falls back
|
|
// to the pending pick, then the active workspace. Clears pending so it
|
|
// doesn't shadow later reads.
|
|
export async function commitSessionWorkspace(
|
|
id: string,
|
|
fallback?: string
|
|
): Promise<string | undefined> {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s) return undefined
|
|
if (s.workspace_id) return s.workspace_id
|
|
// A commit is a send: the record must be durable regardless of prior touches
|
|
// (a draft sent without ever being touched is still transient here).
|
|
if (s.transient) delete s.transient
|
|
|
|
if (s.pending_fork) {
|
|
const fork = s.pending_fork
|
|
// Defense-in-depth against a stale pending_fork (e.g. staged before
|
|
// another workspace was created, or any future entry point): a fork is a
|
|
// new workspace, and community edition caps the number of non-'admins'
|
|
// workspaces (backend _check_nb_of_workspaces). An enterprise license
|
|
// lifts the cap. Block the commit with an explicit error rather than
|
|
// letting materializeFork hit a backend rejection. Keep the pending fork
|
|
// set so the block persists until the user picks a non-fork workspace
|
|
// (setSessionPendingWorkspace clears it).
|
|
const CE_MAX_NON_ADMIN_WORKSPACES = 2
|
|
const nonAdminWorkspaceCount = get(userWorkspaces).filter((w) => w.id !== 'admins').length
|
|
if (!get(enterpriseLicense) && nonAdminWorkspaceCount >= CE_MAX_NON_ADMIN_WORKSPACES) {
|
|
throw new Error(
|
|
`Community edition is limited to ${CE_MAX_NON_ADMIN_WORKSPACES + 1} workspaces — archive a workspace or pick one to run in`
|
|
)
|
|
}
|
|
const newId = await materializeFork(fork, (creation_id) => {
|
|
s.pending_fork = { ...fork, creation_id }
|
|
void putSession(s)
|
|
})
|
|
if (!newId) {
|
|
// Real failure (not a recovered duplicate). Drop the pending
|
|
// fork so the session falls through to the workspace-pick
|
|
// fallback on the next call and the unavailable-banner UX
|
|
// can take over instead of looping on the same broken intent.
|
|
s.pending_fork = undefined
|
|
void putSession(s)
|
|
return undefined
|
|
}
|
|
s.workspace_id = newId
|
|
s.pending_fork = undefined
|
|
s.pending_workspace_id = undefined
|
|
s.workspace_root_id = workspaceRootId(newId, get(userWorkspaces)) ?? newId
|
|
// The draft prompt has been consumed as the first message.
|
|
delete s.draftPrompt
|
|
await putSession(s)
|
|
logFeatureUsage('ai_session', 'created', { key: 'fork', entityId: s.id, workspace: newId })
|
|
// The global workspaceStore is intentionally left untouched: the session
|
|
// chat targets its own workspace via AIChatManager.operatingWorkspace, so
|
|
// committing must not yank the user's active (navigation-mode) workspace.
|
|
return newId
|
|
}
|
|
|
|
const ws = s.pending_workspace_id ?? fallback
|
|
if (!ws) return undefined
|
|
s.workspace_id = ws
|
|
s.pending_workspace_id = undefined
|
|
s.workspace_root_id = workspaceRootId(ws, get(userWorkspaces)) ?? ws
|
|
// The draft prompt has been consumed as the first message.
|
|
delete s.draftPrompt
|
|
await putSession(s)
|
|
// A picked workspace can itself be an existing fork — classify by root. A read-only root is
|
|
// counted apart, to see whether production sessions are used.
|
|
const sessionId = s.id
|
|
void isReadOnlySessionWorkspace(ws).then((readOnly) =>
|
|
logFeatureUsage('ai_session', 'created', {
|
|
key: readOnly ? 'read_only' : ws === s.workspace_root_id ? 'root' : 'fork',
|
|
entityId: sessionId,
|
|
workspace: ws
|
|
})
|
|
)
|
|
// The global workspaceStore is intentionally left untouched (see the fork
|
|
// branch above): the session chat reads its committed workspace through the
|
|
// manager's workspace resolver, not the active workspaceStore.
|
|
return ws
|
|
}
|
|
|
|
// Effective workspace for scope/routing — committed if set, otherwise the
|
|
// pre-send pending pick (which defaults to the workspace at create time).
|
|
// Pending forks route via their parent until creation lands.
|
|
export function getEffectiveWorkspaceId(session: Session): string | undefined {
|
|
return session.workspace_id ?? session.pending_workspace_id
|
|
}
|
|
|
|
// Persist the session's preview tabs (a touch — see persistTouched).
|
|
export function setSessionTabs(id: string, tabs: SessionPreviewTab[], activeTabId: string): void {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s) return
|
|
s.previewTabs = tabs.map((t) => ({ ...t }))
|
|
s.activePreviewTabId = activeTabId
|
|
persistTouched(s)
|
|
}
|
|
|
|
// Persist whether the preview panel is collapsed for this session (a touch).
|
|
export function setSessionPreviewCollapsed(id: string, collapsed: boolean): void {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s || !!s.previewCollapsed === collapsed) return
|
|
s.previewCollapsed = collapsed
|
|
persistTouched(s)
|
|
}
|
|
|
|
// Persist the preview split size the user dragged for this session (a touch).
|
|
export function setSessionPreviewSize(id: string, size: number): void {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s || s.previewSize === size) return
|
|
s.previewSize = size
|
|
persistTouched(s)
|
|
}
|
|
|
|
// Persist the session's Use or Build choice (a touch). Plan is left out: a reload ends the
|
|
// planning round, and the session reopens in the mode it was entered from.
|
|
export function setSessionWorkMode(id: string, mode: AIWorkMode): void {
|
|
if (mode === AIWorkMode.PLAN) return
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s || (s.workMode ?? AIWorkMode.BUILD) === mode) return
|
|
s.workMode = mode
|
|
persistTouched(s)
|
|
}
|
|
|
|
export function selectSession(id: string) {
|
|
sessionState.currentSessionId = id
|
|
}
|
|
|
|
export function renameSession(id: string, newSummary: string) {
|
|
const trimmed = newSummary.trim()
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s) return
|
|
s.summary = trimmed.length > 0 ? trimmed : undefined
|
|
s.summarySource = 'manual'
|
|
persistTouched(s)
|
|
}
|
|
|
|
export function setGeneratedSessionSummary(
|
|
id: string,
|
|
newSummary: string,
|
|
expectedChatId?: string
|
|
): boolean {
|
|
const trimmed = newSummary.trim()
|
|
if (!trimmed) return false
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s) return false
|
|
if (expectedChatId && s.chatId !== expectedChatId) return false
|
|
if (s.summarySource !== 'placeholder') return false
|
|
s.summary = trimmed
|
|
s.summarySource = 'generated'
|
|
void putSession(s)
|
|
return true
|
|
}
|
|
|
|
// A resumed attempt's polls fail for good once it is gone, so they are not waited out as long as a
|
|
// fresh creation's, whose failures a rolling deploy clears; a resume cut short by such a deploy
|
|
// falls back to requesting the fork, which then waits for it below.
|
|
const RESUMED_FORK_MAX_FAILING_MS = 10_000
|
|
// How long a fork another request is still creating is waited for.
|
|
const IN_FLIGHT_FORK_WAIT_MS = 3 * 60 * 1000
|
|
|
|
// Make sure the fork exists, refresh the user-workspaces store, and return
|
|
// the fork id. Used by both the first-send commit path
|
|
// (commitSessionWorkspace) and the move-session-to-a-new-fork path in the
|
|
// unavailable-session banner. Returns undefined on failure (a user-facing
|
|
// toast is already emitted).
|
|
//
|
|
// Self-heal: a fork already present in the user-workspaces store was
|
|
// created by an earlier request whose response was lost, and is adopted
|
|
// without requesting it again; so is one the API reports as existing. A
|
|
// fork whose `creation_id` is set waits for that creation (started before a
|
|
// reload), and one the API reports as still being created, by a creation
|
|
// whose id was lost, is waited for until it shows up among the user's
|
|
// workspaces. `onCreationStarted` receives the id of a new creation.
|
|
export async function materializeFork(
|
|
fork: PendingFork,
|
|
onCreationStarted?: (creationId: string) => void
|
|
): Promise<string | undefined> {
|
|
if (get(userWorkspaces).some((w) => w.id === fork.id)) return fork.id
|
|
try {
|
|
let resumed = false
|
|
if (fork.creation_id) {
|
|
// An attempt that failed or is gone is requested again below.
|
|
resumed = await waitForForkCreation(
|
|
fork.parent_workspace_id,
|
|
fork.creation_id,
|
|
undefined,
|
|
RESUMED_FORK_MAX_FAILING_MS
|
|
).then(
|
|
() => true,
|
|
() => false
|
|
)
|
|
}
|
|
if (!resumed) {
|
|
await createWorkspaceForkAndWait(
|
|
fork.parent_workspace_id,
|
|
{ id: fork.id, name: fork.name },
|
|
{ onCreationStarted }
|
|
)
|
|
}
|
|
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
|
sendUserToast(`Created fork ${fork.name}`)
|
|
return fork.id
|
|
} catch (e: any) {
|
|
const msg = String(e?.body ?? e?.message ?? e)
|
|
if (msg.includes('is already being created') && (await forkJoinedWorkspaces(fork.id))) {
|
|
sendUserToast(`Created fork ${fork.name}`)
|
|
return fork.id
|
|
}
|
|
if (/workspace_pkey|duplicate key|already exists/i.test(msg)) {
|
|
// Self-heal: the create likely already succeeded. Refresh + adopt the
|
|
// existing row. Guard this refresh — a second network failure here must
|
|
// NOT rethrow out of materializeFork (callers rely on the
|
|
// toast-and-return-undefined contract; an uncaught throw would bypass
|
|
// it and propagate up through commitSessionWorkspace/beforeSend).
|
|
try {
|
|
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
|
if (get(userWorkspaces).some((w) => w.id === fork.id)) return fork.id
|
|
} catch (refreshErr) {
|
|
console.error('Failed to refresh workspaces during fork self-heal', refreshErr)
|
|
}
|
|
}
|
|
sendUserToast(`Could not create fork: ${msg}`, true)
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// Whether the fork shows up among the user's workspaces within IN_FLIGHT_FORK_WAIT_MS.
|
|
async function forkJoinedWorkspaces(forkId: string): Promise<boolean> {
|
|
const deadline = Date.now() + IN_FLIGHT_FORK_WAIT_MS
|
|
while (Date.now() < deadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
const listed = await WorkspaceService.listUserWorkspaces().catch(() => undefined)
|
|
if (listed?.workspaces?.some((w) => w.id === forkId)) {
|
|
usersWorkspaceStore.set(listed)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Re-assign a committed session to a different workspace. Used to rescue
|
|
// sessions whose original workspace was deleted / archived / had access
|
|
// revoked — the chat history (stored in IndexedDB keyed by session id) is
|
|
// preserved; only the workspace pointer changes. Drops pending fields
|
|
// since the session is already past the draft stage by definition.
|
|
export async function moveSessionToWorkspace(id: string, newWorkspaceId: string): Promise<void> {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s) return
|
|
if (s.workspace_id === newWorkspaceId) return
|
|
if (s.workspace_id !== undefined) s.moves = (s.moves ?? 0) + 1
|
|
s.workspace_id = newWorkspaceId
|
|
delete s.pending_workspace_id
|
|
delete s.pending_fork
|
|
s.workspace_root_id = workspaceRootId(newWorkspaceId, get(userWorkspaces)) ?? newWorkspaceId
|
|
await putSession(s)
|
|
}
|
|
|
|
// Create a brand-new fork and re-assign a committed session to it. Used
|
|
// by the unavailable-session banner's "Create new fork" path in the
|
|
// move dropdown. On success the global workspace is switched to the
|
|
// freshly created fork.
|
|
export async function moveSessionToNewFork(
|
|
id: string,
|
|
fork: PendingFork
|
|
): Promise<string | undefined> {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s) return undefined
|
|
const newId = await materializeFork(fork)
|
|
if (!newId) return undefined
|
|
// Persist the session before switching so the target workspace can show it
|
|
// immediately in the root-filtered sidebar.
|
|
await moveSessionToWorkspace(id, newId)
|
|
if (get(workspaceStore) !== newId) switchWorkspace(newId)
|
|
return newId
|
|
}
|
|
|
|
export function setSessionArchived(id: string, archived: boolean) {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s) return
|
|
const next = archived ? true : undefined
|
|
if (s.archived === next && (archived || !s.archivedByWorkspace)) return
|
|
if (archived) {
|
|
s.archived = true
|
|
logFeatureUsage('ai_session', 'archived', { entityId: s.id, workspace: s.workspace_id })
|
|
} else {
|
|
delete s.archived
|
|
delete s.archivedByWorkspace
|
|
}
|
|
persistTouched(s)
|
|
}
|
|
|
|
// A counter rather than a flag: an inner teardown finishing must not reopen the
|
|
// gate while an outer one is still running. Released in a finally, so a delete
|
|
// that throws can't wedge it shut.
|
|
let openSessionTeardowns = $state(0)
|
|
|
|
export function isTearingDownOpenSession(): boolean {
|
|
return openSessionTeardowns > 0
|
|
}
|
|
|
|
export async function withOpenSessionTeardown<T>(run: () => Promise<T>): Promise<T> {
|
|
openSessionTeardowns++
|
|
try {
|
|
return await run()
|
|
} finally {
|
|
openSessionTeardowns--
|
|
}
|
|
}
|
|
|
|
export function deleteSession(id: string) {
|
|
const s = sessionState.sessions.find((x) => x.id === id)
|
|
if (!s) return
|
|
// Cancel any pending draft-prompt flush: left running, its persistTouched
|
|
// would write the record back to IndexedDB after we delete it, resurrecting
|
|
// a draft deleted inside the debounce window.
|
|
clearTimeout(draftPromptFlushHandles.get(id))
|
|
draftPromptFlushHandles.delete(id)
|
|
draftAttachments.delete(id)
|
|
sessionState.sessions = sessionState.sessions.filter((x) => x.id !== id)
|
|
if (sessionState.currentSessionId === id) {
|
|
sessionState.currentSessionId = sessionState.sessions[0]?.id
|
|
}
|
|
void deleteSessionRecord(id)
|
|
// GC any linked files and artifacts persisted for this session.
|
|
void deleteItemsForSession(id)
|
|
void deleteArtifactsForSession(id)
|
|
// Only a delete the user asked for takes the backup with it: the workspace-lifecycle
|
|
// removals above keep theirs, so a session dropped by a wrong reconcile can be restored.
|
|
markSessionRemoved(id, s.workspace_id)
|
|
logFeatureUsage('ai_session', 'deleted', { entityId: id, workspace: s.workspace_id })
|
|
}
|
|
|
|
// --- Session backup support (sessionMirror) ---
|
|
|
|
export function isSessionTombstoned(id: string): boolean {
|
|
return deletedSessionIds.has(id)
|
|
}
|
|
|
|
// Every stored record of the named user, or undefined when the store is unavailable or
|
|
// already serves someone else: the backup captures its user up front and must not follow
|
|
// an in-place account switch.
|
|
export async function readStoredSessions(email: string): Promise<Session[] | undefined> {
|
|
if (!BROWSER) return undefined
|
|
const db = await sessionsDb.whenReady()
|
|
if (!db || db.name !== scopedKeyFor(SESSIONS_DB, email)) return undefined
|
|
try {
|
|
return await db.getAll('sessions')
|
|
} catch (e) {
|
|
console.error('Failed to read sessions from IndexedDB', e)
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// Add restored records for sessions this browser does not have, and re-hydrate the list.
|
|
// A record that exists, or was deleted here, is left alone: the local copy is the newer
|
|
// one. Returns the ids written.
|
|
export async function importSessions(records: Session[], email: string): Promise<string[]> {
|
|
if (!BROWSER) return []
|
|
const db = await sessionsDb.whenReady()
|
|
if (!db || db.name !== scopedKeyFor(SESSIONS_DB, email)) return []
|
|
const imported: string[] = []
|
|
try {
|
|
const tx = db.transaction('sessions', 'readwrite')
|
|
const existing = new Set((await tx.store.getAllKeys()).map(String))
|
|
const restoredAt = Date.now()
|
|
for (const r of records) {
|
|
if (existing.has(r.id) || deletedSessionIds.has(r.id)) continue
|
|
const record: Session = { ...r, restoredAt }
|
|
delete record.transient
|
|
delete record.workspace_root_id
|
|
ensureSessionRootId(record)
|
|
await tx.store.put(record)
|
|
imported.push(record.id)
|
|
}
|
|
await tx.done
|
|
} catch (e) {
|
|
console.error('Failed to import sessions', e)
|
|
return []
|
|
}
|
|
if (imported.length > 0) await hydrateSessions()
|
|
return imported
|
|
}
|
|
|
|
export function setSessionChatId(sessionId: string, chatId: string) {
|
|
const s = sessionState.sessions.find((x) => x.id === sessionId)
|
|
if (s && s.chatId !== chatId) {
|
|
s.chatId = chatId
|
|
void patchStoredSessionChatId(s, chatId)
|
|
}
|
|
}
|
|
|
|
// Persists the pointer through the STORED row, not this tab's copy: another
|
|
// tab may have written newer fields (summary, tabs, archive state) since this
|
|
// tab last read the record, and a whole-object put would roll them back — a
|
|
// watcher adopting the driver's rotation reaches here with exactly that copy.
|
|
async function patchStoredSessionChatId(s: Session, chatId: string): Promise<void> {
|
|
if (!BROWSER || s.transient || deletedSessionIds.has(s.id)) return
|
|
const db = await sessionsDb.whenReady()
|
|
if (!db) return
|
|
try {
|
|
const tx = db.transaction('sessions', 'readwrite')
|
|
const stored = await tx.store.get(s.id)
|
|
// Inline tombstone re-check in place of putSessionRow's: routing through
|
|
// it would put outside this transaction and lose the read's atomicity.
|
|
if (stored && !deletedSessionIds.has(s.id)) {
|
|
stored.chatId = chatId
|
|
await tx.store.put(stored)
|
|
await tx.done
|
|
markSessionDirty(s.id, undefined, emailOfScopedKey(SESSIONS_DB, db.name))
|
|
return
|
|
}
|
|
await tx.done
|
|
// No stored row: either the record is not yet persisted — its own
|
|
// materialization writes it later with the chatId already set in memory —
|
|
// or another tab deleted it, and an upsert here would resurrect it. No
|
|
// write either way.
|
|
} catch (e) {
|
|
console.error('Failed to persist session chat id', e)
|
|
}
|
|
}
|