diff --git a/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.test.tsx b/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.test.tsx
new file mode 100644
index 0000000000..13fcafdd43
--- /dev/null
+++ b/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.test.tsx
@@ -0,0 +1,48 @@
+import { beforeEach, describe, expect, it, mock } from 'bun:test'
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+
+let dismissed = false
+mock.module('@/lib/cloud-sync/cloud-sync-storage', () => ({
+ cloudSyncNoticeDismissedStorage: {
+ getValue: async () => dismissed,
+ setValue: async (value: boolean) => {
+ dismissed = value
+ },
+ },
+}))
+
+const { CloudSyncRetiredNotice } = await import('./CloudSyncRetiredNotice')
+
+beforeEach(() => {
+ dismissed = false
+})
+
+describe('CloudSyncRetiredNotice', () => {
+ // Dismissal is read asynchronously from extension storage, so the first
+ // paint must not flash a banner the user already dismissed.
+ it('renders nothing before the dismissal state is known', () => {
+ const html = renderToStaticMarkup(createElement(CloudSyncRetiredNotice))
+ expect(html).toBe('')
+ })
+})
+
+describe('the copy', () => {
+ const source = require('node:fs').readFileSync(
+ new URL('./CloudSyncRetiredNotice.tsx', import.meta.url).pathname,
+ 'utf8',
+ )
+
+ // Sync stops in the same release this ships, so a future-tense warning
+ // would describe something that has already happened.
+ it('states what changed rather than warning about it', () => {
+ expect(source).toContain('has been turned off')
+ expect(source).not.toMatch(/will (stop|soon)/i)
+ })
+
+ // The question people actually have is whether they are losing anything.
+ it('says what keeps working and what does not', () => {
+ expect(source).toContain('keep working')
+ expect(source).toContain('history')
+ })
+})
diff --git a/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.tsx b/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.tsx
new file mode 100644
index 0000000000..7c9efea60b
--- /dev/null
+++ b/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.tsx
@@ -0,0 +1,63 @@
+import { HardDrive, X } from 'lucide-react'
+import { type FC, useEffect, useState } from 'react'
+import { cloudSyncNoticeDismissedStorage } from '@/lib/cloud-sync/cloud-sync-storage'
+
+/**
+ * Tells the user what changed, once, wherever their synced data used to live.
+ *
+ * Deliberately past tense. Sync stops in the same release this ships, so a
+ * warning about the future would be describing something that has already
+ * happened. It also answers the question people will actually have, which is
+ * not whether sync is going away but whether they are about to lose anything.
+ *
+ * Dismissal persists: this is a one-time announcement, not a standing banner,
+ * and it should not reappear on every visit to settings.
+ */
+export const CloudSyncRetiredNotice: FC = () => {
+ const [visible, setVisible] = useState(false)
+
+ // Reading persisted dismissal is an async read from extension storage, so
+ // the banner starts hidden and appears only once we know it was not dismissed.
+ useEffect(() => {
+ let cancelled = false
+ cloudSyncNoticeDismissedStorage.getValue().then((dismissed) => {
+ if (!cancelled) setVisible(!dismissed)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ if (!visible) return null
+
+ const dismiss = () => {
+ setVisible(false)
+ void cloudSyncNoticeDismissedStorage.setValue(true)
+ }
+
+ return (
+
+
+
+
+
+
+ Your data now stays on this device
+
+
+ Cloud sync has been turned off. Your providers, agents and schedules
+ are stored on this machine and keep working. Chats saved to the cloud
+ stay visible in history for now.
+
+
+
+
+ )
+}
diff --git a/packages/browseros-agent/apps/app/entrypoints/background/index.ts b/packages/browseros-agent/apps/app/entrypoints/background/index.ts
index 7e0fcdc16c..de64151ec2 100644
--- a/packages/browseros-agent/apps/app/entrypoints/background/index.ts
+++ b/packages/browseros-agent/apps/app/entrypoints/background/index.ts
@@ -1,5 +1,4 @@
import { storage } from '@wxt-dev/storage'
-import { sessionStorage } from '@/lib/auth/sessionStorage'
import { Capabilities } from '@/lib/browseros/capabilities'
import { createConversationPanelBroker } from '@/lib/browseros/conversationPanelBroker.browser'
import { getHealthCheckUrl, getMcpServerUrl } from '@/lib/browseros/helpers'
@@ -12,11 +11,7 @@ import {
toggleSidePanel,
} from '@/lib/browseros/toggleSidePanel'
import { checkAndShowChangelog } from '@/lib/changelog/changelog-notifier'
-import {
- setupLlmProvidersBackupToBrowserOS,
- setupLlmProvidersSyncToBackend,
- syncLlmProviders,
-} from '@/lib/llm-providers/storage'
+import { setupLlmProvidersBackupToBrowserOS } from '@/lib/llm-providers/storage'
import { fetchMcpTools } from '@/lib/mcp/client'
import {
onRuntimeMessage,
@@ -25,13 +20,10 @@ import {
import { onServerMessage } from '@/lib/messaging/server/serverMessages'
import { onOpenSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
import { authRedirectPathStorage } from '@/lib/onboarding/onboardingStorage'
-import {
- setupScheduledJobsSyncToBackend,
- syncScheduledJobs,
-} from '@/lib/schedules/syncSchedulesToBackend'
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
+import { startLocalFirstMigration } from '@/modules/local-first-migration/start-local-first-migration'
import { scheduledJobRuns } from './scheduledJobRuns'
const LEGACY_TOOL_APPROVAL_STORAGE_KEYS = [
@@ -59,8 +51,7 @@ export default defineBackground(() => {
Capabilities.initialize().catch(() => null)
setupLlmProvidersBackupToBrowserOS()
- setupLlmProvidersSyncToBackend()
- setupScheduledJobsSyncToBackend()
+ startLocalFirstMigration()
scheduledJobRuns()
@@ -151,17 +142,6 @@ export default defineBackground(() => {
})
})
- sessionStorage.watch(async (newSession) => {
- if (newSession?.user?.id) {
- try {
- await syncLlmProviders()
- } catch {}
- try {
- await syncScheduledJobs()
- } catch {}
- }
- })
-
onServerMessage('checkHealth', async () => {
try {
const url = await getHealthCheckUrl()
diff --git a/packages/browseros-agent/apps/app/entrypoints/background/scheduledJobRuns.ts b/packages/browseros-agent/apps/app/entrypoints/background/scheduledJobRuns.ts
index 7fc8cf45fe..297442055b 100644
--- a/packages/browseros-agent/apps/app/entrypoints/background/scheduledJobRuns.ts
+++ b/packages/browseros-agent/apps/app/entrypoints/background/scheduledJobRuns.ts
@@ -1,45 +1,51 @@
import { onScheduleMessage } from '@/lib/messaging/schedules/scheduleMessages'
import { createAlarmFromJob } from '@/lib/schedules/createAlarmFromJob'
import { getChatServerResponse } from '@/lib/schedules/getChatServerResponse'
-import {
- scheduledJobRunStorage,
- scheduledJobStorage,
-} from '@/lib/schedules/scheduleStorage'
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
+import {
+ listScheduledJobRunsOrNull,
+ listScheduledJobsOrNull,
+ putScheduledJob,
+ putScheduledJobRun,
+} from '@/modules/schedules/schedules.api'
+import { applyLastRunAt } from '@/modules/schedules/schedules.helpers'
-const MAX_RUNS_PER_JOB = 15
const STALE_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000
const runAbortControllers = new Map()
export const scheduledJobRuns = async () => {
+ // Every read below distinguishes an unreachable server from an empty list.
+ // Treating the two alike would look like "nothing is scheduled": alarms would
+ // not be rebuilt on startup and schedules would quietly stop firing, with no
+ // failed run to show for it. Skipping the pass instead leaves the next
+ // startup to retry.
const cleanupStaleJobRuns = async () => {
- const current = (await scheduledJobRunStorage.getValue()) ?? []
+ const current = await listScheduledJobRunsOrNull()
+ if (current === null) return
const now = Date.now()
- const updated = current.map((run) => {
- if (run.status !== 'running') return run
-
- const startedAt = new Date(run.startedAt).getTime()
- if (now - startedAt > STALE_TIMEOUT_MS) {
- return {
- ...run,
- status: 'failed' as const,
- completedAt: new Date().toISOString(),
- result: 'Job timed out!',
- }
- }
- return run
- })
+ const stale = current.filter(
+ (run) =>
+ run.status === 'running' &&
+ now - new Date(run.startedAt).getTime() > STALE_TIMEOUT_MS,
+ )
- await scheduledJobRunStorage.setValue(updated)
+ for (const run of stale) {
+ await putScheduledJobRun({
+ ...run,
+ status: 'failed',
+ completedAt: new Date().toISOString(),
+ result: 'Job timed out!',
+ })
+ }
}
const syncAlarmState = async () => {
- const jobs = (await scheduledJobStorage.getValue()).filter(
- (each) => each.enabled,
- )
+ const loaded = await listScheduledJobsOrNull()
+ if (loaded === null) return
+ const jobs = loaded.filter((each) => each.enabled)
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i]
@@ -56,6 +62,8 @@ export const scheduledJobRuns = async () => {
jobId: string,
status: ScheduledJobRun['status'],
): Promise => {
+ // Trimming to the per-job cap happens on the server now, so creating a run
+ // no longer has to rewrite the job's whole history to stay bounded.
const jobRun: ScheduledJobRun = {
id: crypto.randomUUID(),
jobId,
@@ -63,48 +71,37 @@ export const scheduledJobRuns = async () => {
status,
}
- const current = (await scheduledJobRunStorage.getValue()) ?? []
- const otherJobRuns = current.filter((r) => r.jobId !== jobId)
- const thisJobRuns = current
- .filter((r) => r.jobId === jobId)
- .sort(
- (a, b) =>
- new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
- )
- .slice(0, MAX_RUNS_PER_JOB - 1)
-
- await scheduledJobRunStorage.setValue([
- ...otherJobRuns,
- ...thisJobRuns,
- jobRun,
- ])
+ await putScheduledJobRun(jobRun)
return jobRun
}
+ // Takes the run rather than its id: the caller already holds it, and merging
+ // locally avoids re-reading a list to update one row.
const updateJobRun = async (
- runId: string,
+ run: ScheduledJobRun,
updates: Partial>,
) => {
- const current = (await scheduledJobRunStorage.getValue()) ?? []
- await scheduledJobRunStorage.setValue(
- current.map((r) => (r.id === runId ? { ...r, ...updates } : r)),
- )
+ await putScheduledJobRun({ ...run, ...updates })
}
+ // Takes an id, not the job: a snapshot captured before the run would be
+ // minutes stale by the time this writes, and putting it back would revert any
+ // edit made while the run was going.
const updateJobLastRunAt = async (jobId: string) => {
- const current = (await scheduledJobStorage.getValue()) ?? []
- await scheduledJobStorage.setValue(
- current.map((j) =>
- j.id === jobId ? { ...j, lastRunAt: new Date().toISOString() } : j,
- ),
- )
+ const jobs = await listScheduledJobsOrNull()
+ if (jobs === null) return
+
+ const updated = applyLastRunAt(jobs, jobId, new Date().toISOString())
+ if (updated) await putScheduledJob(updated)
}
const executeScheduledJob = async (jobId: string): Promise => {
- const job = (await scheduledJobStorage.getValue()).find(
- (each) => each.id === jobId,
- )
+ const jobs = await listScheduledJobsOrNull()
+ if (jobs === null) {
+ throw new Error('Cannot reach the BrowserOS server to load the job')
+ }
+ const job = jobs.find((each) => each.id === jobId)
if (!job) {
throw new Error(`Job not found: ${jobId}`)
}
@@ -120,7 +117,7 @@ export const scheduledJobRuns = async () => {
providerId: job.providerId,
})
- await updateJobRun(jobRun.id, {
+ await updateJobRun(jobRun, {
status: 'completed',
completedAt: new Date().toISOString(),
result: response.text,
@@ -135,7 +132,7 @@ export const scheduledJobRuns = async () => {
: e instanceof Error
? e.message
: String(e)
- await updateJobRun(jobRun.id, {
+ await updateJobRun(jobRun, {
status: 'failed',
completedAt: new Date().toISOString(),
result: errorMessage,
@@ -155,10 +152,11 @@ export const scheduledJobRuns = async () => {
runningMissedJobs = true
try {
- const jobs = (await scheduledJobStorage.getValue()).filter(
- (j) => j.enabled,
- )
- const runs = (await scheduledJobRunStorage.getValue()) ?? []
+ const loadedJobs = await listScheduledJobsOrNull()
+ const runs = await listScheduledJobRunsOrNull()
+ if (loadedJobs === null || runs === null) return
+
+ const jobs = loadedJobs.filter((j) => j.enabled)
const now = Date.now()
const cutoff = now - TWENTY_FOUR_HOURS_MS
diff --git a/packages/browseros-agent/apps/app/lib/cloud-sync/cloud-sync-storage.ts b/packages/browseros-agent/apps/app/lib/cloud-sync/cloud-sync-storage.ts
new file mode 100644
index 0000000000..fcc1474958
--- /dev/null
+++ b/packages/browseros-agent/apps/app/lib/cloud-sync/cloud-sync-storage.ts
@@ -0,0 +1,7 @@
+import { storage } from '#imports'
+
+/** One-time announcement, so dismissal has to outlive the session. */
+export const cloudSyncNoticeDismissedStorage = storage.defineItem(
+ 'local:cloudSyncNoticeDismissed',
+ { fallback: false },
+)
diff --git a/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.test.ts b/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.test.ts
deleted file mode 100644
index 02bfd50cbe..0000000000
--- a/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { describe, expect, it } from 'bun:test'
-import {
- type ActiveConversationBufferEntry,
- pruneFlushedEntries,
- runExclusiveBufferWrite,
- selectBufferEntriesForUser,
- upsertBufferEntry,
-} from './active-conversation-buffer.helpers'
-
-const entry = (
- id: string,
- userId: string,
- lastMessagedAt = 0,
-): ActiveConversationBufferEntry => ({
- id,
- userId,
- messages: [],
- lastMessagedAt,
-})
-
-describe('upsertBufferEntry', () => {
- it('appends a conversation that is not buffered yet', () => {
- const result = upsertBufferEntry([entry('a', 'A')], entry('b', 'A'))
- expect(result.map((e) => e.id)).toEqual(['a', 'b'])
- })
-
- it('replaces an existing conversation instead of duplicating it', () => {
- const updated = { ...entry('a', 'A'), lastMessagedAt: 5 }
- const result = upsertBufferEntry(
- [entry('a', 'A'), entry('b', 'A')],
- updated,
- )
- expect(result.map((e) => e.id)).toEqual(['b', 'a'])
- expect(result.find((e) => e.id === 'a')?.lastMessagedAt).toBe(5)
- })
-})
-
-describe('selectBufferEntriesForUser', () => {
- it('keeps only the given user and never another account', () => {
- const all = [entry('a', 'A'), entry('b', 'B'), entry('c', 'A')]
- expect(selectBufferEntriesForUser(all, 'A').map((e) => e.id)).toEqual([
- 'a',
- 'c',
- ])
- expect(selectBufferEntriesForUser(all, 'B').map((e) => e.id)).toEqual(['b'])
- })
-})
-
-describe('pruneFlushedEntries', () => {
- it('removes only the exact snapshots that were flushed', () => {
- const all = [entry('a', 'A', 1), entry('b', 'A', 1), entry('c', 'A', 1)]
- const flushed = [entry('a', 'A', 1), entry('c', 'A', 1)]
- expect(pruneFlushedEntries(all, flushed).map((e) => e.id)).toEqual(['b'])
- })
-
- it('keeps a newer snapshot written for the same id during the upload', () => {
- // 'a' was uploaded at t=1 but rewritten at t=2 while the upload ran.
- const latest = [entry('a', 'A', 2), entry('b', 'A', 1)]
- const flushed = [entry('a', 'A', 1)]
- expect(pruneFlushedEntries(latest, flushed).map((e) => e.id)).toEqual([
- 'a',
- 'b',
- ])
- })
-
- it('keeps entries whose upload was not confirmed', () => {
- const latest = [entry('a', 'A', 1), entry('b', 'A', 1)]
- expect(pruneFlushedEntries(latest, []).map((e) => e.id)).toEqual(['a', 'b'])
- })
-})
-
-describe('runExclusiveBufferWrite', () => {
- it('serializes overlapping mutations instead of interleaving them', async () => {
- const order: string[] = []
- const first = runExclusiveBufferWrite(async () => {
- order.push('a-start')
- await Promise.resolve()
- await Promise.resolve()
- order.push('a-end')
- })
- const second = runExclusiveBufferWrite(async () => {
- order.push('b-start')
- order.push('b-end')
- })
- await Promise.all([first, second])
- expect(order).toEqual(['a-start', 'a-end', 'b-start', 'b-end'])
- })
-
- it('keeps draining the queue after a mutation rejects', async () => {
- await expect(
- runExclusiveBufferWrite(async () => {
- throw new Error('boom')
- }),
- ).rejects.toThrow('boom')
- await expect(runExclusiveBufferWrite(async () => 'ok')).resolves.toBe('ok')
- })
-})
diff --git a/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.ts b/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.ts
deleted file mode 100644
index 79b30c3775..0000000000
--- a/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { storage } from '@wxt-dev/storage'
-import {
- type ActiveConversationBufferEntry,
- pruneFlushedEntries,
- runExclusiveBufferWrite,
- selectBufferEntriesForUser,
- upsertBufferEntry,
-} from './active-conversation-buffer.helpers'
-import type { Conversation } from './conversationStorage'
-
-export type { ActiveConversationBufferEntry } from './active-conversation-buffer.helpers'
-
-/**
- * Transient buffer of in-flight signed-in conversations. Never read by the
- * history panel; it only guarantees the cloud eventually receives the
- * conversation. Entries are removed once flushed, so it never grows into a
- * local mirror of history.
- */
-export const activeConversationBufferStorage = storage.defineItem<
- ActiveConversationBufferEntry[]
->('local:activeConversationBuffer', { fallback: [] })
-
-/** Persists (upserts) the in-flight conversation into the buffer. */
-export async function bufferActiveConversation(
- entry: ActiveConversationBufferEntry,
-): Promise {
- await runExclusiveBufferWrite(async () => {
- const current = (await activeConversationBufferStorage.getValue()) ?? []
- await activeConversationBufferStorage.setValue(
- upsertBufferEntry(current, entry),
- )
- })
-}
-
-/**
- * Uploads the current user's buffered conversations to the cloud via the given
- * uploader (which returns the ids it confirmed reached the cloud), then removes
- * only those exact snapshots. Entries whose upload failed, and any newer
- * snapshot written for the same conversation during the upload, are kept for a
- * later retry. Only the current user's entries are ever touched, so a previous
- * account's un-synced conversation is never pushed into this account's cloud.
- */
-export async function flushActiveConversationBuffer(
- userId: string,
- upload: (conversations: Conversation[]) => Promise,
-): Promise {
- const current = (await activeConversationBufferStorage.getValue()) ?? []
- const mine = selectBufferEntriesForUser(current, userId)
- if (mine.length === 0) return
-
- const uploadedIds = new Set(
- await upload(
- mine.map(({ id, messages, lastMessagedAt }) => ({
- id,
- messages,
- lastMessagedAt,
- })),
- ),
- )
- const flushed = mine.filter((e) => uploadedIds.has(e.id))
- if (flushed.length === 0) return
-
- // The upload above ran outside the lock so writes weren't blocked; take the
- // lock only to re-read the latest buffer and remove the flushed snapshots, so
- // a concurrent write is neither clobbered by nor clobbers this prune.
- await runExclusiveBufferWrite(async () => {
- const latest = (await activeConversationBufferStorage.getValue()) ?? []
- await activeConversationBufferStorage.setValue(
- pruneFlushedEntries(latest, flushed),
- )
- })
-}
diff --git a/packages/browseros-agent/apps/app/lib/conversations/conversationStorage.ts b/packages/browseros-agent/apps/app/lib/conversations/conversationStorage.ts
deleted file mode 100644
index 2bda17497c..0000000000
--- a/packages/browseros-agent/apps/app/lib/conversations/conversationStorage.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { storage } from '@wxt-dev/storage'
-import type { UIMessage } from 'ai'
-
-export interface Conversation {
- id: string
- messages: UIMessage[]
- lastMessagedAt: number
-}
-
-export const conversationStorage = storage.defineItem(
- 'local:conversations',
- {
- fallback: [],
- },
-)
diff --git a/packages/browseros-agent/apps/app/lib/conversations/uploadConversationsToGraphql.ts b/packages/browseros-agent/apps/app/lib/conversations/uploadConversationsToGraphql.ts
deleted file mode 100644
index 16b41611fd..0000000000
--- a/packages/browseros-agent/apps/app/lib/conversations/uploadConversationsToGraphql.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { execute } from '@/lib/graphql/execute'
-import { sessionStorage } from '../auth/sessionStorage'
-import { sentry } from '../sentry/sentry'
-import type { Conversation } from './conversationStorage'
-import {
- BulkCreateConversationMessagesDocument,
- ConversationExistsDocument,
- CreateConversationForUploadDocument,
- GetProfileIdByUserIdDocument,
- GetUploadedMessageCountDocument,
-} from './graphql/uploadConversationDocument'
-
-/**
- * Uploads each conversation to the cloud idempotently (creating it and
- * appending only the messages the cloud does not already have) and returns the
- * ids that are now fully in the cloud. A missing session/profile yields an empty
- * result and per-conversation errors are swallowed and omitted, so a caller can
- * safely retry whatever is not returned. Does not touch local storage.
- *
- * Pass `expectedUserId` to bind the upload to a specific account: if the live
- * session no longer belongs to that user (a session switch races the upload),
- * nothing is uploaded, so one account's buffered conversation is never created
- * under another account's profile.
- */
-export async function uploadConversations(
- conversations: Conversation[],
- expectedUserId?: string,
-): Promise {
- if (conversations.length === 0) return []
-
- const sessionInfo = await sessionStorage.getValue()
- const userId = sessionInfo?.user?.id
- if (!userId) return []
- if (expectedUserId && userId !== expectedUserId) return []
-
- const profileResult = await execute(GetProfileIdByUserIdDocument, { userId })
- const profileId = profileResult.profileByUserId?.rowId
- if (!profileId) return []
-
- const uploadedIds: string[] = []
-
- for (const conversation of conversations) {
- try {
- const existsResult = await execute(ConversationExistsDocument, {
- pConversationId: conversation.id,
- })
-
- let uploadedCount = 0
-
- if (existsResult.conversationExists) {
- const countResult = await execute(GetUploadedMessageCountDocument, {
- conversationId: conversation.id,
- })
- uploadedCount = countResult.conversationMessages?.totalCount ?? 0
-
- if (uploadedCount >= conversation.messages.length) {
- uploadedIds.push(conversation.id)
- continue
- }
- } else {
- await execute(CreateConversationForUploadDocument, {
- input: {
- conversation: {
- rowId: conversation.id,
- profileId,
- lastMessagedAt: new Date(
- conversation.lastMessagedAt,
- ).toISOString(),
- createdAt: new Date(conversation.lastMessagedAt).toISOString(),
- },
- },
- })
- }
-
- const remainingMessages = conversation.messages.slice(uploadedCount)
-
- if (remainingMessages.length > 0) {
- const BATCH_SIZE = 50
- for (let i = 0; i < remainingMessages.length; i += BATCH_SIZE) {
- const batch = remainingMessages.slice(i, i + BATCH_SIZE)
- await execute(BulkCreateConversationMessagesDocument, {
- input: {
- pConversationId: conversation.id,
- pMessages: batch.map((msg, batchIndex) => ({
- orderIndex: uploadedCount + i + batchIndex,
- message: msg,
- })),
- },
- })
- }
- }
-
- uploadedIds.push(conversation.id)
- } catch (error) {
- sentry.captureException(error, {
- extra: {
- conversationId: conversation.id,
- messageCount: conversation.messages.length,
- },
- })
- }
- }
-
- return uploadedIds
-}
diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/providerTemplates.ts b/packages/browseros-agent/apps/app/lib/llm-providers/providerTemplates.ts
index b8dc5fb4e5..52e79052c1 100644
--- a/packages/browseros-agent/apps/app/lib/llm-providers/providerTemplates.ts
+++ b/packages/browseros-agent/apps/app/lib/llm-providers/providerTemplates.ts
@@ -199,6 +199,19 @@ const DEFAULT_BASE_URLS: Record = {
* Get default base URL for a provider type
* @public
*/
+/**
+ * Whether a stored type string is one this build understands.
+ *
+ * Keyed off DEFAULT_BASE_URLS because it is a `Record`,
+ * so the compiler keeps it exhaustive as the union changes. Used to filter
+ * rows written by a newer build after a downgrade: icons, templates and base
+ * URLs are all keyed by this union, so an unknown type would read as
+ * undefined through every one of them.
+ */
+export function isProviderType(value: string): value is ProviderType {
+ return Object.hasOwn(DEFAULT_BASE_URLS, value)
+}
+
export const getDefaultBaseUrlForProviders = (type: ProviderType): string => {
return DEFAULT_BASE_URLS[type] || ''
}
diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/removed-provider-types.ts b/packages/browseros-agent/apps/app/lib/llm-providers/removed-provider-types.ts
new file mode 100644
index 0000000000..092cf10b67
--- /dev/null
+++ b/packages/browseros-agent/apps/app/lib/llm-providers/removed-provider-types.ts
@@ -0,0 +1,22 @@
+import type { LlmProviderConfig } from './types'
+
+/**
+ * Provider types that no longer exist. Storage migrations 4 and 5 strip these,
+ * but the `browseros.providers` pref backup has no migration path, so a stale
+ * backup can still be holding them.
+ */
+export const REMOVED_PROVIDER_TYPES = new Set([
+ 'remote-hermes',
+ 'claude-code',
+ 'codex',
+ 'acp-custom',
+])
+
+export function dropRemovedProviderConfigs(
+ providers: LlmProviderConfig[] | null,
+): LlmProviderConfig[] | null {
+ if (!providers) return providers
+ return providers.filter(
+ (provider) => !REMOVED_PROVIDER_TYPES.has(String(provider.type)),
+ )
+}
diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/storage.test.ts b/packages/browseros-agent/apps/app/lib/llm-providers/storage.test.ts
index 2213d687d2..3b262fb593 100644
--- a/packages/browseros-agent/apps/app/lib/llm-providers/storage.test.ts
+++ b/packages/browseros-agent/apps/app/lib/llm-providers/storage.test.ts
@@ -54,10 +54,6 @@ mock.module('@/lib/browseros/prefs', () => ({
BROWSEROS_PREFS: { PROVIDERS: 'browseros.providers' },
}))
-mock.module('./uploadLlmProvidersToGraphql', () => ({
- uploadLlmProvidersToGraphql: async () => {},
-}))
-
let loadProviders: typeof import('./storage').loadProviders
let providersStorage: typeof import('./storage').providersStorage
diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/storage.ts b/packages/browseros-agent/apps/app/lib/llm-providers/storage.ts
index 8ec881ace6..c79b068a61 100644
--- a/packages/browseros-agent/apps/app/lib/llm-providers/storage.ts
+++ b/packages/browseros-agent/apps/app/lib/llm-providers/storage.ts
@@ -1,5 +1,4 @@
import { storage } from '@wxt-dev/storage'
-import { sessionStorage } from '@/lib/auth/sessionStorage'
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
import {
@@ -10,27 +9,11 @@ import {
DEFAULT_PROVIDER_ID,
DEFAULT_PROVIDER_NAME,
} from './provider-selection'
+import { dropRemovedProviderConfigs } from './removed-provider-types'
import type { LlmProviderConfig, LlmProvidersBackup } from './types'
-import { uploadLlmProvidersToGraphql } from './uploadLlmProvidersToGraphql'
export { DEFAULT_PROVIDER_ID } from './provider-selection'
-const REMOVED_PROVIDER_TYPES = new Set([
- 'remote-hermes',
- 'claude-code',
- 'codex',
- 'acp-custom',
-])
-
-function dropRemovedProviderConfigs(
- providers: LlmProviderConfig[] | null,
-): LlmProviderConfig[] | null {
- if (!providers) return providers
- return providers.filter(
- (provider) => !REMOVED_PROVIDER_TYPES.has(String(provider.type)),
- )
-}
-
export const providersStorage = storage.defineItem(
'local:llm-providers',
{
@@ -78,28 +61,6 @@ export function setupLlmProvidersBackupToBrowserOS(): () => void {
return unsubscribe
}
-export async function syncLlmProviders(): Promise {
- const providers = await providersStorage.getValue()
- if (!providers || providers.length === 0) return
-
- const session = await sessionStorage.getValue()
- const userId = session?.user?.id
- if (!userId) return
-
- await uploadLlmProvidersToGraphql(providers, userId)
-}
-
-export function setupLlmProvidersSyncToBackend(): () => void {
- syncLlmProviders().catch(() => {})
-
- const unsubscribe = providersStorage.watch(async () => {
- try {
- await syncLlmProviders()
- } catch {}
- })
- return unsubscribe
-}
-
export async function loadProviders(): Promise {
const providers = (await providersStorage.getValue()) || []
const supportedProviders = dropRemovedProviderConfigs(providers) ?? []
diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/types.ts b/packages/browseros-agent/apps/app/lib/llm-providers/types.ts
index ba72264631..71088669d8 100644
--- a/packages/browseros-agent/apps/app/lib/llm-providers/types.ts
+++ b/packages/browseros-agent/apps/app/lib/llm-providers/types.ts
@@ -62,6 +62,15 @@ export interface LlmProviderConfig {
// so it is stored as a free string validated against the model at selection time.
reasoningEffort?: string
reasoningSummary?: 'auto' | 'concise' | 'detailed'
+
+ // Whether a credential is stored on the server, for providers read back from
+ // it. The values themselves never leave the server, so a form editing an
+ // existing provider sees these rather than the secret, and leaving a field
+ // blank keeps what is stored.
+ hasApiKey?: boolean
+ hasAccessKeyId?: boolean
+ hasSecretAccessKey?: boolean
+ hasSessionToken?: boolean
}
/**
diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/uploadLlmProvidersToGraphql.ts b/packages/browseros-agent/apps/app/lib/llm-providers/uploadLlmProvidersToGraphql.ts
deleted file mode 100644
index 6fa130c2e1..0000000000
--- a/packages/browseros-agent/apps/app/lib/llm-providers/uploadLlmProvidersToGraphql.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { isEqual, omit } from 'es-toolkit'
-import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
-import { execute } from '@/lib/graphql/execute'
-import { sentry } from '@/lib/sentry/sentry'
-import {
- CreateLlmProviderForUploadDocument,
- GetLlmProvidersByProfileIdDocument,
- UpdateLlmProviderForUploadDocument,
-} from './graphql/uploadLlmProviderDocument'
-import type { LlmProviderConfig } from './types'
-
-type RemoteProvider = {
- rowId: string
- type: string
- name: string
- baseUrl: string | null
- modelId: string
- supportsImages: boolean
- contextWindow: number | null
- temperature: number | null
- resourceName: string | null
- region: string | null
-}
-
-const IGNORED_FIELDS = [
- 'id',
- 'createdAt',
- 'updatedAt',
- 'apiKey',
- 'accessKeyId',
- 'secretAccessKey',
- 'sessionToken',
-] as const
-
-function toComparable(provider: LlmProviderConfig) {
- const data = omit(provider, IGNORED_FIELDS)
- return {
- ...data,
- baseUrl: data.baseUrl ?? null,
- resourceName: data.resourceName ?? null,
- region: data.region ?? null,
- }
-}
-
-export async function uploadLlmProvidersToGraphql(
- providers: LlmProviderConfig[],
- userId: string,
-) {
- if (providers.length === 0) return
-
- const profileResult = await execute(GetProfileIdByUserIdDocument, { userId })
- const profileId = profileResult.profileByUserId?.rowId
- if (!profileId) return
-
- const remoteResult = await execute(GetLlmProvidersByProfileIdDocument, {
- profileId,
- })
- const remoteProviders = new Map()
- for (const node of remoteResult.llmProviders?.nodes ?? []) {
- if (node) {
- remoteProviders.set(node.rowId, node as RemoteProvider)
- }
- }
-
- for (const provider of providers) {
- if (provider.type === 'browseros') continue
-
- try {
- const remote = remoteProviders.get(provider.id)
-
- if (remote) {
- if (isEqual(toComparable(provider), omit(remote, ['rowId']))) continue
-
- await execute(UpdateLlmProviderForUploadDocument, {
- input: {
- rowId: provider.id,
- patch: {
- type: provider.type,
- name: provider.name,
- baseUrl: provider.baseUrl ?? null,
- modelId: provider.modelId,
- supportsImages: provider.supportsImages,
- contextWindow: provider.contextWindow,
- temperature: provider.temperature,
- resourceName: provider.resourceName ?? null,
- region: provider.region ?? null,
- updatedAt: new Date(provider.updatedAt).toISOString(),
- },
- },
- })
- } else {
- await execute(CreateLlmProviderForUploadDocument, {
- input: {
- llmProvider: {
- rowId: provider.id,
- profileId,
- type: provider.type,
- name: provider.name,
- baseUrl: provider.baseUrl ?? null,
- modelId: provider.modelId,
- supportsImages: provider.supportsImages,
- contextWindow: provider.contextWindow,
- temperature: provider.temperature,
- resourceName: provider.resourceName ?? null,
- region: provider.region ?? null,
- createdAt: new Date(provider.createdAt).toISOString(),
- updatedAt: new Date(provider.updatedAt).toISOString(),
- },
- },
- })
- }
- } catch (error) {
- sentry.captureException(error, {
- extra: {
- providerId: provider.id,
- providerName: provider.name,
- },
- })
- }
- }
-}
diff --git a/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.test.ts b/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.test.ts
index 75a6db23c6..6d487a17a0 100644
--- a/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.test.ts
+++ b/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.test.ts
@@ -25,7 +25,46 @@ describe('buildChatRequestBody', () => {
type: 'browseros',
providerId: 'provider-1',
})
- expect(body.providerId).toBe('provider-1')
+ })
+
+ // The provider is named and nothing more. Its model, endpoint and
+ // credentials are resolved from the id server side, so an api key and an aws
+ // secret no longer cross the wire on every message.
+ it('sends no provider configuration or credentials', () => {
+ const body = buildChatRequestBody({
+ conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
+ provider: {
+ ...provider,
+ id: 'bedrock-1',
+ type: 'bedrock',
+ apiKey: 'sk-secret',
+ accessKeyId: 'AKIA',
+ secretAccessKey: 'aws-secret',
+ sessionToken: 'token',
+ baseUrl: 'https://example.com',
+ },
+ })
+
+ for (const field of [
+ 'apiKey',
+ 'accessKeyId',
+ 'secretAccessKey',
+ 'sessionToken',
+ 'baseUrl',
+ 'model',
+ 'provider',
+ 'providerId',
+ 'providerType',
+ 'providerName',
+ 'temperature',
+ 'contextWindowSize',
+ 'region',
+ 'resourceName',
+ ]) {
+ expect(field in body).toBe(false)
+ }
+ expect(JSON.stringify(body)).not.toContain('aws-secret')
+ expect(JSON.stringify(body)).not.toContain('sk-secret')
})
it('preserves browser context and chat metadata', () => {
diff --git a/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.ts b/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.ts
index d186ac731b..a0ff34c6a6 100644
--- a/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.ts
+++ b/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.ts
@@ -40,7 +40,16 @@ export interface ChatRequestBrowserContext {
export interface ChatRequestBodyParams {
conversationId: string
- provider: LlmProviderConfig
+ /**
+ * The provider config, when the caller already holds it. Only the id is sent;
+ * the rest is used to describe what the chosen model can do, which comes from
+ * a catalogue the extension bundles.
+ *
+ * Callers that hold nothing but an id, such as the scheduled runner, pass
+ * `providerId` instead and let the server resolve the rest.
+ */
+ provider?: LlmProviderConfig
+ providerId?: string
message?: string
mode?: ChatMode
browserContext?: ChatRequestBrowserContext
@@ -61,6 +70,7 @@ export interface ChatRequestBodyParams {
export const buildChatRequestBody = ({
conversationId,
provider,
+ providerId,
message = '',
mode,
browserContext,
@@ -74,31 +84,26 @@ export const buildChatRequestBody = ({
selectedTextSource,
isScheduledTask,
}: ChatRequestBodyParams) => ({
- target: { type: 'browseros' as const, providerId: provider.id },
+ // The provider is named, not described. The server holds the list and which
+ // one is selected, so it resolves the model, endpoint and credentials from
+ // the id. Those used to travel on every message, which meant the api key and
+ // the aws secret crossed the wire each time the user pressed send.
+ target: {
+ type: 'browseros' as const,
+ // Absent when the caller has neither, which tells the server to use the
+ // selected provider.
+ providerId: provider?.id ?? providerId,
+ },
message,
- provider: provider.type,
- providerId: provider.id,
- providerType: provider.type,
- providerName: provider.name,
- apiKey: provider.apiKey,
- baseUrl: provider.baseUrl,
conversationId,
- model: provider.modelId ?? 'default',
mode,
- contextWindowSize: provider.contextWindow,
- temperature: provider.temperature,
- resourceName: provider.resourceName,
- accessKeyId: provider.accessKeyId,
- secretAccessKey: provider.secretAccessKey,
- region: provider.region,
- sessionToken: provider.sessionToken,
- reasoningEffort: provider.reasoningEffort,
- reasoningSummary: provider.reasoningSummary,
browserContext,
userSystemPrompt,
userWorkingDir,
- supportsImages: supportsImages ?? provider.supportsImages,
- supportsReasoning: resolvesSupportsReasoning(provider),
+ // Sent because the caller can override what the provider says, and because
+ // the reasoning answer comes from a model catalogue the extension bundles.
+ supportsImages: supportsImages ?? provider?.supportsImages,
+ supportsReasoning: provider ? resolvesSupportsReasoning(provider) : undefined,
previousConversation,
historyMode,
declinedApps: declinedApps?.length ? declinedApps : undefined,
diff --git a/packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts b/packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts
index 489590dab9..fbad4e5314 100644
--- a/packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts
+++ b/packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts
@@ -1,19 +1,9 @@
import { chatErrorMessage } from '@browseros/shared/schemas/chat-error'
import { createParser, type EventSourceMessage } from 'eventsource-parser'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
-import {
- createDefaultBrowserOSProvider,
- defaultProviderIdStorage,
- providersStorage,
-} from '@/lib/llm-providers/storage'
-import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { mcpServerStorage } from '@/lib/mcp/mcpServerStorage'
import { buildChatRequestBody } from '@/lib/messaging/server/buildChatRequestBody'
import type { ChatMode } from '@/modules/chat/chat-types'
-import {
- findChatProviderById,
- resolveChatProvider,
-} from '../llm-providers/provider-runtime'
import { personalizationStorage } from '../personalization/personalizationStorage'
import { scheduleSystemPrompt } from './scheduleSystemPrompt'
import type { ToolCallExecution } from './scheduleTypes'
@@ -73,30 +63,15 @@ interface StreamParseState {
receivedFinish: boolean
}
-const getDefaultProvider = async (): Promise => {
- const providers = await providersStorage.getValue()
- if (!providers?.length) return null
-
- const defaultProviderId = await defaultProviderIdStorage.getValue()
- return resolveChatProvider(providers, defaultProviderId)
-}
-
-const resolveProvider = async (
- providerId?: string,
-): Promise => {
- if (providerId) {
- const providers = await providersStorage.getValue()
- const match = findChatProviderById(providers ?? [], providerId)
- if (match) return match
- }
- return (await getDefaultProvider()) ?? createDefaultBrowserOSProvider()
-}
-
export async function getChatServerResponse(
request: ChatServerRequest,
): Promise {
const agentServerUrl = await getAgentServerUrl()
- const provider = await resolveProvider(request.providerId)
+ // No provider lookup here any more. The server holds the list and the
+ // selection, so a job names an id or names nothing and the server resolves
+ // it. That also removes the guard this path needed when it did the lookup
+ // itself: an unreachable list could not be told apart from an empty one, so
+ // a job risked running on the built-in provider with the wrong credentials.
const conversationId = request.conversationId ?? crypto.randomUUID()
const personalization = await personalizationStorage.getValue()
@@ -119,9 +94,9 @@ export async function getChatServerResponse(
body: JSON.stringify({
messages: [{ role: 'user', content: request.message }],
...buildChatRequestBody({
+ providerId: request.providerId,
message: request.message,
conversationId,
- provider,
mode: request.mode ?? 'agent',
browserContext:
request.activeTab ||
@@ -138,7 +113,6 @@ export async function getChatServerResponse(
}
: undefined,
userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`,
- supportsImages: provider.supportsImages,
isScheduledTask: true,
}),
}),
diff --git a/packages/browseros-agent/apps/app/lib/schedules/provider-resolution.test.ts b/packages/browseros-agent/apps/app/lib/schedules/provider-resolution.test.ts
index 3a39c1b805..13fde55a48 100644
--- a/packages/browseros-agent/apps/app/lib/schedules/provider-resolution.test.ts
+++ b/packages/browseros-agent/apps/app/lib/schedules/provider-resolution.test.ts
@@ -46,6 +46,15 @@ mock.module('@/lib/llm-providers/storage', () => ({
},
}))
+// The provider list is a request now, not a storage read. Mocked here so the
+// fetch stub below still sees only the chat call it is asserting on.
+mock.module('@/modules/llm-providers/llm-providers.api', () => ({
+ listProvidersOrNull: async () =>
+ storageValues.has('unreachable')
+ ? null
+ : ((storageValues.get('providers') as LlmProviderConfig[]) ?? []),
+}))
+
mock.module('@/lib/browseros/helpers', () => ({
getAgentServerUrl: async () => 'http://127.0.0.1:9105',
getMcpServerUrl: async () => 'http://127.0.0.1:9106/mcp',
@@ -69,6 +78,22 @@ mock.module('../personalization/personalizationStorage', () => ({
},
}))
+// Only the refine path still resolves a provider on this side; the scheduled
+// path names an id and lets the server do it.
+const providers: LlmProviderConfig[] = [
+ {
+ id: 'anthropic-sonnet',
+ type: 'anthropic',
+ name: 'Anthropic Sonnet',
+ modelId: 'claude-sonnet-4-6',
+ supportsImages: true,
+ contextWindow: 200000,
+ temperature: 0.2,
+ createdAt: 0,
+ updatedAt: 0,
+ },
+]
+
beforeEach(() => {
storageValues.clear()
fetchBodies.length = 0
@@ -97,7 +122,12 @@ afterEach(() => {
})
describe('scheduled provider resolution', () => {
- it('uses an explicit scheduled provider', async () => {
+ // The runner names the provider and stops there. Its model, endpoint and
+ // credentials are resolved from the id on the server, which is what let the
+ // client-side lookup and its unreachable-versus-empty guard go: the guard
+ // existed because a failed lookup and an empty list looked the same, and a
+ // job risked running on the built-in provider with the wrong credentials.
+ it('names the provider and sends no configuration', async () => {
const { getChatServerResponse } = await import('./getChatServerResponse')
await getChatServerResponse({
@@ -106,10 +136,24 @@ describe('scheduled provider resolution', () => {
})
expect(fetchBodies[0]).toMatchObject({
- provider: 'anthropic',
- providerName: 'Anthropic Sonnet',
- model: 'claude-sonnet-4-6',
+ target: { type: 'browseros', providerId: 'anthropic-sonnet' },
+ isScheduledTask: true,
})
+ for (const field of ['apiKey', 'model', 'provider', 'baseUrl']) {
+ expect(field in fetchBodies[0]).toBe(false)
+ }
+ })
+
+ // A job created without picking a provider names none, and the server uses
+ // whichever is selected.
+ it('leaves the provider unnamed when the job has none', async () => {
+ const { getChatServerResponse } = await import('./getChatServerResponse')
+
+ await getChatServerResponse({ message: 'Run my schedule' })
+
+ expect(
+ (fetchBodies[0] as { target: { providerId?: string } }).target.providerId,
+ ).toBeUndefined()
})
it('uses an explicit refine provider', async () => {
@@ -132,31 +176,3 @@ describe('scheduled provider resolution', () => {
})
})
})
-
-const timestamp = 1000
-
-const providers: LlmProviderConfig[] = [
- {
- id: 'browseros',
- type: 'browseros',
- name: 'BrowserOS',
- modelId: 'browseros-auto',
- supportsImages: true,
- contextWindow: 200000,
- temperature: 0.2,
- createdAt: timestamp,
- updatedAt: timestamp,
- },
- {
- id: 'anthropic-sonnet',
- type: 'anthropic',
- name: 'Anthropic Sonnet',
- modelId: 'claude-sonnet-4-6',
- apiKey: 'sk-ant',
- supportsImages: true,
- contextWindow: 200000,
- temperature: 0.2,
- createdAt: timestamp,
- updatedAt: timestamp,
- },
-]
diff --git a/packages/browseros-agent/apps/app/lib/schedules/refine-prompt.ts b/packages/browseros-agent/apps/app/lib/schedules/refine-prompt.ts
index 7d2608ca18..72b6aa5d01 100644
--- a/packages/browseros-agent/apps/app/lib/schedules/refine-prompt.ts
+++ b/packages/browseros-agent/apps/app/lib/schedules/refine-prompt.ts
@@ -2,9 +2,9 @@ import { getAgentServerUrl } from '@/lib/browseros/helpers'
import {
createDefaultBrowserOSProvider,
defaultProviderIdStorage,
- providersStorage,
} from '@/lib/llm-providers/storage'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import { listProvidersOrNull } from '@/modules/llm-providers/llm-providers.api'
import {
findChatProviderById,
resolveChatProvider,
@@ -13,8 +13,18 @@ import {
const resolveProvider = async (
providerId?: string,
): Promise => {
- const providers = await providersStorage.getValue()
- if (providers?.length) {
+ const loaded = await listProvidersOrNull()
+ // Same rule as the scheduled run: the configured default is a choice too, and
+ // its model and credentials are in the list that failed to load. Callers here
+ // already catch and surface this.
+ if (loaded === null) {
+ throw new Error(
+ 'Cannot reach the BrowserOS server to load the selected provider',
+ )
+ }
+
+ const providers = loaded
+ if (providers.length) {
const explicitProvider = findChatProviderById(providers, providerId)
if (explicitProvider) return explicitProvider
diff --git a/packages/browseros-agent/apps/app/lib/schedules/scheduleStorage.ts b/packages/browseros-agent/apps/app/lib/schedules/scheduleStorage.ts
index 41aeaedcda..971186056e 100644
--- a/packages/browseros-agent/apps/app/lib/schedules/scheduleStorage.ts
+++ b/packages/browseros-agent/apps/app/lib/schedules/scheduleStorage.ts
@@ -1,10 +1,12 @@
import { storage } from '@wxt-dev/storage'
-import { useEffect, useState } from 'react'
-import { sendScheduleMessage } from '@/lib/messaging/schedules/scheduleMessages'
-import { createAlarmFromJob } from './createAlarmFromJob'
import type { ScheduledJob, ScheduledJobRun } from './scheduleTypes'
-const getAlarmName = (jobId: string) => `scheduled-job-${jobId}`
+/**
+ * Legacy extension storage for scheduled jobs and their runs.
+ *
+ * The server owns both now. These items remain only as the source the one-time
+ * import reads, and nothing writes them any more.
+ */
export const scheduledJobStorage = storage.defineItem(
'local:scheduledJobs',
@@ -19,142 +21,3 @@ export const scheduledJobRunStorage = storage.defineItem(
fallback: [],
},
)
-
-export const pendingDeletionStorage = storage.defineItem(
- 'local:scheduledJobsPendingDeletion',
- {
- fallback: [],
- },
-)
-
-export function useScheduledJobs() {
- const [jobs, setJobs] = useState([])
-
- useEffect(() => {
- scheduledJobStorage.getValue().then(setJobs)
- const unwatch = scheduledJobStorage.watch((newValue) => {
- setJobs(newValue ?? [])
- })
- return unwatch
- }, [])
-
- const addJob = async (
- job: Omit,
- ) => {
- const now = new Date().toISOString()
- const newJob: ScheduledJob = {
- id: crypto.randomUUID(),
- createdAt: now,
- updatedAt: now,
- ...job,
- }
- const current = (await scheduledJobStorage.getValue()) ?? []
- await scheduledJobStorage.setValue([...current, newJob])
-
- if (newJob.enabled) {
- await createAlarmFromJob(newJob)
- }
- }
-
- const removeJob = async (id: string) => {
- await chrome.alarms.clear(getAlarmName(id))
-
- const pending = (await pendingDeletionStorage.getValue()) ?? []
- if (!pending.includes(id)) {
- await pendingDeletionStorage.setValue([...pending, id])
- }
-
- const currentJobs = (await scheduledJobStorage.getValue()) ?? []
- await scheduledJobStorage.setValue(currentJobs.filter((j) => j.id !== id))
-
- const currentRuns = (await scheduledJobRunStorage.getValue()) ?? []
- await scheduledJobRunStorage.setValue(
- currentRuns.filter((r) => r.jobId !== id),
- )
- }
-
- const toggleJob = async (id: string, enabled: boolean) => {
- const current = (await scheduledJobStorage.getValue()) ?? []
- const job = current.find((j) => j.id === id)
- if (!job) return
-
- const updatedAt = new Date().toISOString()
- await scheduledJobStorage.setValue(
- current.map((j) => (j.id === id ? { ...j, enabled, updatedAt } : j)),
- )
-
- if (enabled) {
- await createAlarmFromJob({ ...job, enabled })
- } else {
- await chrome.alarms.clear(getAlarmName(id))
- }
- }
-
- const editJob = async (
- id: string,
- updates: Omit,
- ) => {
- const current = (await scheduledJobStorage.getValue()) ?? []
- const existingJob = current.find((j) => j.id === id)
- if (!existingJob) return
-
- const updatedJob: ScheduledJob = {
- id,
- createdAt: existingJob.createdAt,
- updatedAt: new Date().toISOString(),
- ...updates,
- }
- await scheduledJobStorage.setValue(
- current.map((j) => (j.id === id ? updatedJob : j)),
- )
-
- await chrome.alarms.clear(getAlarmName(id))
- if (updatedJob.enabled) {
- await createAlarmFromJob(updatedJob)
- }
- }
-
- const runJob = async (id: string) => {
- return sendScheduleMessage('runScheduledJob', { jobId: id })
- }
-
- return { jobs, addJob, removeJob, editJob, toggleJob, runJob }
-}
-
-export function useScheduledJobRuns() {
- const [jobRuns, setJobRuns] = useState([])
-
- useEffect(() => {
- scheduledJobRunStorage.getValue().then(setJobRuns)
- const unwatch = scheduledJobRunStorage.watch((newValue) => {
- setJobRuns(newValue ?? [])
- })
- return unwatch
- }, [])
-
- const addJobRun = async (jobRun: ScheduledJobRun) => {
- const current = (await scheduledJobRunStorage.getValue()) ?? []
- await scheduledJobRunStorage.setValue([...current, jobRun])
- }
-
- const removeJobRun = async (id: string) => {
- const current = (await scheduledJobRunStorage.getValue()) ?? []
- await scheduledJobRunStorage.setValue(current.filter((r) => r.id !== id))
- }
-
- const editJobRun = async (
- id: string,
- updates: Partial>,
- ) => {
- const current = (await scheduledJobRunStorage.getValue()) ?? []
- await scheduledJobRunStorage.setValue(
- current.map((r) => (r.id === id ? { ...r, ...updates } : r)),
- )
- }
-
- const cancelJobRun = async (runId: string) => {
- return sendScheduleMessage('cancelScheduledJobRun', { runId })
- }
-
- return { jobRuns, addJobRun, removeJobRun, editJobRun, cancelJobRun }
-}
diff --git a/packages/browseros-agent/apps/app/lib/schedules/syncSchedulesToBackend.ts b/packages/browseros-agent/apps/app/lib/schedules/syncSchedulesToBackend.ts
deleted file mode 100644
index fcc9633d1f..0000000000
--- a/packages/browseros-agent/apps/app/lib/schedules/syncSchedulesToBackend.ts
+++ /dev/null
@@ -1,262 +0,0 @@
-import { isEqual, omit } from 'es-toolkit'
-import { sessionStorage } from '@/lib/auth/sessionStorage'
-import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
-import { execute } from '@/lib/graphql/execute'
-import { sentry } from '@/lib/sentry/sentry'
-import { createAlarmFromJob } from './createAlarmFromJob'
-import {
- CreateScheduledJobDocument,
- DeleteScheduledJobDocument,
- GetScheduledJobsByProfileIdDocument,
- UpdateScheduledJobDocument,
-} from './graphql/syncSchedulesDocument'
-import { pendingDeletionStorage, scheduledJobStorage } from './scheduleStorage'
-import type { ScheduledJob } from './scheduleTypes'
-
-type RemoteScheduledJob = {
- rowId: string
- name: string
- query: string
- scheduleType: string
- scheduleTime: string | null
- scheduleInterval: number | null
- enabled: boolean
- llmProviderId: string | null
- createdAt: string
- updatedAt: string
- lastRunAt: string | null
-}
-
-const IGNORED_FIELDS = ['id', 'createdAt', 'lastRunAt'] as const
-
-function toComparable(job: ScheduledJob) {
- const data = omit(job, IGNORED_FIELDS)
- return {
- ...data,
- scheduleTime: data.scheduleTime ?? null,
- scheduleInterval: data.scheduleInterval ?? null,
- providerId: data.providerId ?? null,
- }
-}
-
-function remoteToComparable(job: RemoteScheduledJob) {
- return {
- name: job.name,
- query: job.query,
- scheduleType: job.scheduleType as ScheduledJob['scheduleType'],
- scheduleTime: job.scheduleTime,
- scheduleInterval: job.scheduleInterval,
- enabled: job.enabled,
- providerId: job.llmProviderId,
- }
-}
-
-function normalizeTimestamp(ts: string): string {
- return ts.endsWith('Z') ? ts : `${ts}Z`
-}
-
-function remoteToLocal(remote: RemoteScheduledJob): ScheduledJob {
- return {
- id: remote.rowId,
- name: remote.name,
- query: remote.query,
- scheduleType: remote.scheduleType as ScheduledJob['scheduleType'],
- scheduleTime: remote.scheduleTime ?? undefined,
- scheduleInterval: remote.scheduleInterval ?? undefined,
- enabled: remote.enabled,
- providerId: remote.llmProviderId ?? undefined,
- createdAt: normalizeTimestamp(remote.createdAt),
- updatedAt: normalizeTimestamp(remote.updatedAt),
- lastRunAt: remote.lastRunAt
- ? normalizeTimestamp(remote.lastRunAt)
- : undefined,
- }
-}
-
-function getLocalUpdatedAt(job: ScheduledJob): Date {
- return new Date(job.updatedAt || job.createdAt)
-}
-
-function getRemoteUpdatedAt(remote: RemoteScheduledJob): Date {
- return new Date(normalizeTimestamp(remote.updatedAt))
-}
-
-// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO(dani) refactor to reduce complexity
-async function syncSchedulesToBackend(
- localJobs: ScheduledJob[],
- userId: string,
-): Promise {
- const profileResult = await execute(GetProfileIdByUserIdDocument, { userId })
- const profileId = profileResult.profileByUserId?.rowId
- if (!profileId) return
-
- const remoteResult = await execute(GetScheduledJobsByProfileIdDocument, {
- profileId,
- })
-
- const remoteJobs = new Map()
- for (const node of remoteResult.scheduledJobs?.nodes ?? []) {
- if (node) {
- remoteJobs.set(node.rowId, node as RemoteScheduledJob)
- }
- }
-
- const pendingDeletions = new Set(
- (await pendingDeletionStorage.getValue()) ?? [],
- )
- const resolvedDeletions = new Set()
-
- for (const rowId of pendingDeletions) {
- if (remoteJobs.has(rowId)) {
- try {
- await execute(DeleteScheduledJobDocument, { rowId })
- remoteJobs.delete(rowId)
- resolvedDeletions.add(rowId)
- } catch (error) {
- sentry.captureException(error, {
- extra: { jobId: rowId, context: 'sync-pending-deletion' },
- })
- }
- } else {
- resolvedDeletions.add(rowId)
- }
- }
-
- const latestPending = (await pendingDeletionStorage.getValue()) ?? []
- await pendingDeletionStorage.setValue(
- latestPending.filter((id) => !resolvedDeletions.has(id)),
- )
-
- const localJobsMap = new Map(localJobs.map((j) => [j.id, j]))
- const jobsToAddLocally: ScheduledJob[] = []
- const jobsToUpdateLocally: ScheduledJob[] = []
-
- for (const [rowId, remote] of remoteJobs) {
- const localJob = localJobsMap.get(rowId)
- if (!localJob) {
- jobsToAddLocally.push(remoteToLocal(remote))
- } else {
- const localTime = getLocalUpdatedAt(localJob)
- const remoteTime = getRemoteUpdatedAt(remote)
-
- if (remoteTime > localTime) {
- jobsToUpdateLocally.push(remoteToLocal(remote))
- }
- }
- }
-
- if (jobsToAddLocally.length > 0 || jobsToUpdateLocally.length > 0) {
- const currentJobs = (await scheduledJobStorage.getValue()) ?? []
- const existingIds = new Set(currentJobs.map((j) => j.id))
-
- const newJobs = jobsToAddLocally.filter((j) => !existingIds.has(j.id))
-
- const mergedJobs = currentJobs.map((j) => {
- const updated = jobsToUpdateLocally.find((u) => u.id === j.id)
- return updated ?? j
- })
-
- if (newJobs.length > 0 || jobsToUpdateLocally.length > 0) {
- await scheduledJobStorage.setValue([...mergedJobs, ...newJobs])
-
- for (const job of [...newJobs, ...jobsToUpdateLocally]) {
- try {
- const alarmName = `scheduled-job-${job.id}`
- await chrome.alarms.clear(alarmName)
- if (job.enabled) {
- await createAlarmFromJob(job)
- }
- } catch {
- // Alarm operations may fail in non-background context
- }
- }
- }
- }
-
- for (const job of localJobs) {
- try {
- const remote = remoteJobs.get(job.id)
-
- if (remote) {
- const localTime = getLocalUpdatedAt(job)
- const remoteTime = getRemoteUpdatedAt(remote)
-
- if (remoteTime >= localTime) continue
-
- if (isEqual(toComparable(job), remoteToComparable(remote))) continue
-
- await execute(UpdateScheduledJobDocument, {
- input: {
- rowId: job.id,
- patch: {
- name: job.name,
- query: job.query,
- scheduleType: job.scheduleType,
- scheduleTime: job.scheduleTime ?? null,
- scheduleInterval: job.scheduleInterval ?? null,
- enabled: job.enabled,
- llmProviderId: job.providerId ?? null,
- lastRunAt: job.lastRunAt
- ? new Date(job.lastRunAt).toISOString()
- : null,
- updatedAt: job.updatedAt || new Date().toISOString(),
- },
- },
- })
- } else {
- await execute(CreateScheduledJobDocument, {
- input: {
- scheduledJob: {
- rowId: job.id,
- profileId,
- name: job.name,
- query: job.query,
- scheduleType: job.scheduleType,
- scheduleTime: job.scheduleTime ?? null,
- scheduleInterval: job.scheduleInterval ?? null,
- enabled: job.enabled,
- llmProviderId: job.providerId ?? null,
- createdAt: new Date(job.createdAt).toISOString(),
- updatedAt: job.updatedAt || new Date().toISOString(),
- lastRunAt: job.lastRunAt
- ? new Date(job.lastRunAt).toISOString()
- : null,
- },
- },
- })
- }
- } catch (error) {
- sentry.captureException(error, {
- extra: {
- jobId: job.id,
- jobName: job.name,
- },
- })
- }
- }
-}
-
-export async function syncScheduledJobs(): Promise {
- const jobs = await scheduledJobStorage.getValue()
- if (!jobs) return
-
- const session = await sessionStorage.getValue()
- const userId = session?.user?.id
- if (!userId) return
-
- await syncSchedulesToBackend(jobs, userId)
-}
-
-export function setupScheduledJobsSyncToBackend(): () => void {
- syncScheduledJobs().catch(() => {})
-
- const unsubscribe = scheduledJobStorage.watch(async () => {
- try {
- await syncScheduledJobs()
- } catch {
- // Sync failed silently - will retry on next storage change
- }
- })
-
- return unsubscribe
-}
diff --git a/packages/browseros-agent/apps/app/modules/chat/chat-session-request.test.ts b/packages/browseros-agent/apps/app/modules/chat/chat-session-request.test.ts
index 2708216641..4b6c88bfb7 100644
--- a/packages/browseros-agent/apps/app/modules/chat/chat-session-request.test.ts
+++ b/packages/browseros-agent/apps/app/modules/chat/chat-session-request.test.ts
@@ -21,9 +21,11 @@ describe('chat request preparation', () => {
expect(request.api).toBe('http://127.0.0.1:5151/chat')
expect(request.body).toMatchObject({
target: { type: 'browseros', providerId: 'browseros' },
- provider: 'browseros',
message: 'Summarize this page',
})
+ // The provider is named, not described: its configuration is resolved
+ // from the id on the server.
+ expect('provider' in request.body).toBe(false)
})
it('sends ACP agents to the same endpoint without provider fields', () => {
diff --git a/packages/browseros-agent/apps/app/modules/chat/chat-session.hooks.ts b/packages/browseros-agent/apps/app/modules/chat/chat-session.hooks.ts
index 32973236be..31ec26170f 100644
--- a/packages/browseros-agent/apps/app/modules/chat/chat-session.hooks.ts
+++ b/packages/browseros-agent/apps/app/modules/chat/chat-session.hooks.ts
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import useDeepCompareEffect from 'use-deep-compare-effect'
import type { Provider } from '@/components/chat/chatComponentTypes'
+import { useSessionInfo } from '@/lib/auth/sessionStorage'
import {
conversationForTab,
conversationPanelViewsStorage,
@@ -24,12 +25,7 @@ import {
MESSAGE_SENT_EVENT,
PROVIDER_SELECTED_EVENT,
} from '@/lib/constants/analyticsEvents'
-import {
- bufferActiveConversation,
- flushActiveConversationBuffer,
-} from '@/lib/conversations/active-conversation-buffer'
import { formatConversationHistory } from '@/lib/conversations/formatConversationHistory'
-import { uploadConversations } from '@/lib/conversations/uploadConversationsToGraphql'
import { declinedAppsStorage } from '@/lib/declined-apps/storage'
import { resolveChatProvider } from '@/lib/llm-providers/provider-runtime'
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
@@ -64,7 +60,6 @@ import {
fetchConversationRunState,
} from './conversation-run-client'
import { useExecutionHistoryTracker } from './execution-history-tracker.hooks'
-import { useRemoteConversationSave } from './remote-conversation-save.hooks'
import { toLlmProviderConfig } from './sidepanel-chat-targets'
import { stripImageToolOutputs } from './tool-output-strip'
@@ -218,23 +213,24 @@ export const useChatSession = (options?: ChatSessionOptions) => {
error: agentUrlError,
} = useAgentServerUrl()
- const {
- isLoggedIn,
- userId,
- saveConversation: saveRemoteConversation,
- resetConversation: resetRemoteConversation,
- markMessagesAsSaved,
- } = useRemoteConversationSave()
+ // Identity is still needed to read a cloud conversation back. Nothing on
+ // this screen writes to the cloud any more.
+ const { sessionInfo } = useSessionInfo()
+ const userId = sessionInfo.user?.id
+ const isLoggedIn = !!userId
const [searchParams, setSearchParams] = useSearchParams()
const conversationIdParam = searchParams.get('conversationId')
- // 'local': the local server owns history (persisted during /chat, loaded from
- // SQLite); 'cloud': the client owns it (logged-in cloud sync, or incognito).
+ // 'local': the local server owns history, persisting it to SQLite during
+ // /chat. Every signed-in user now takes this path too, where the client used
+ // to upload their turns to the cloud instead. 'cloud' survives only as the
+ // incognito case, where it means nothing is persisted at all, because the
+ // client no longer writes anywhere.
// Read via a ref because the transport closure below is created only once.
const historyModeRef = useRef<'local' | 'cloud'>('cloud')
useEffect(() => {
- historyModeRef.current = !isLoggedIn && persistHistory ? 'local' : 'cloud'
- }, [isLoggedIn, persistHistory])
+ historyModeRef.current = persistHistory ? 'local' : 'cloud'
+ }, [persistHistory])
const agentUrlRef = useRef(agentServerUrl)
@@ -657,7 +653,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
conversationIdParam as ReturnType,
)
setMessages(restoredMessages)
- markMessagesAsSaved(conversationIdParam, restoredMessages)
}
setRestoredConversationId(conversationIdParam)
setSearchParams({}, { replace: true })
@@ -766,66 +761,17 @@ export const useChatSession = (options?: ChatSessionOptions) => {
const messagesToSave = getPersistableMessages(messages)
if (messagesToSave.length === 0) return
- // Skip all history writes in incognito so the chat never becomes durable
- // (neither local nor cloud) and can't surface in a normal window (#1189).
- // Logged-out history is owned by the local server (persisted during /chat
- // in local mode), so only the cloud lane writes from the client now.
- if (persistHistory && isLoggedIn) {
- // Buffer the settled turn durably before the fire-and-forget cloud
- // write, so an interrupted navigation still lets the next mount sync it
- // (#559).
- if (userId) {
- void bufferActiveConversation({
- id: conversationIdRef.current,
- messages: messagesToSave,
- lastMessagedAt: Date.now(),
- userId,
- })
- }
- saveRemoteConversation(conversationIdRef.current, messagesToSave)
- }
+ // The local server persists every turn during /chat, so the client has no
+ // history write of its own left. Incognito still writes nowhere (#1189).
invalidateCredits()
}, [status])
// Save the in-flight conversation before it can be lost: on page hide (full
// navigation, tab switch, close) and on unmount, because an in-app SPA route
- // change to Settings unmounts the chat while the page stays visible, so
- // visibilitychange never fires. Reads the latest messages either way; the next
- // mount then syncs it to the cloud (#559). The settled turn is also buffered
- // at turn end above. This effect's deps are the auth pair, not messages, so
- // the unmount write runs once, not on every token.
- useEffect(() => {
- if (!persistHistory || !isLoggedIn || !userId) return
- const writeBuffer = () => {
- const latest = getPersistableMessages(messagesRef.current)
- if (latest.length === 0) return
- void bufferActiveConversation({
- id: conversationIdRef.current,
- messages: latest,
- lastMessagedAt: Date.now(),
- userId,
- })
- }
- const onHide = () => {
- if (document.visibilityState === 'hidden') writeBuffer()
- }
- document.addEventListener('visibilitychange', onHide)
- return () => {
- document.removeEventListener('visibilitychange', onHide)
- writeBuffer()
- }
- }, [persistHistory, isLoggedIn, userId])
-
- // On mount (and on sign-in), push any buffered in-flight conversations for the
- // current user to the cloud so an interrupted chat still lands in history. It
- // is never restored into the active conversation; recovery is via history.
- useEffect(() => {
- if (!persistHistory || !isLoggedIn || !userId) return
- void flushActiveConversationBuffer(userId, (conversations) =>
- uploadConversations(conversations, userId),
- )
- }, [persistHistory, isLoggedIn, userId])
+ // The durable turn buffer and its flush lived here to survive an
+ // interrupted cloud upload. The local server persists each turn during
+ // /chat, so there is nothing left to buffer.
useEffect(() => {
if (chatError) invalidateCredits()
@@ -969,7 +915,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
// (via the restore effect's cleanup), so a stale response can't revive the
// old conversation over this new blank session.
setSearchParams({}, { replace: true })
- resetRemoteConversation()
}
const handleSelectProvider = (provider: Provider) => {
diff --git a/packages/browseros-agent/apps/app/modules/chat/remote-conversation-save.hooks.ts b/packages/browseros-agent/apps/app/modules/chat/remote-conversation-save.hooks.ts
deleted file mode 100644
index 4a44c6c4d8..0000000000
--- a/packages/browseros-agent/apps/app/modules/chat/remote-conversation-save.hooks.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import type { UIMessage } from 'ai'
-import { useCallback, useRef } from 'react'
-import { useSessionInfo } from '@/lib/auth/sessionStorage'
-import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
-import { execute } from '@/lib/graphql/execute'
-import { sentry } from '@/lib/sentry/sentry'
-import {
- AppendConversationMessageDocument,
- CreateConversationWithMessageDocument,
- UpdateConversationLastMessagedAtDocument,
-} from './chat-session-document'
-
-export function useRemoteConversationSave() {
- const { sessionInfo } = useSessionInfo()
- const userId = sessionInfo.user?.id
-
- const profileIdRef = useRef(null)
- const createdConversationsRef = useRef>(new Set())
- const savedMessageIdsRef = useRef>(new Set())
-
- const getProfileId = async (): Promise => {
- if (profileIdRef.current) return profileIdRef.current
- if (!userId) return null
-
- const result = await execute(GetProfileIdByUserIdDocument, { userId })
- const profileId = result.profileByUserId?.rowId ?? null
- profileIdRef.current = profileId
- return profileId
- }
-
- const saveConversation = async (
- conversationId: string,
- messages: UIMessage[],
- ) => {
- if (!userId || messages.length === 0) return
-
- const profileId = await getProfileId()
- if (!profileId) return
-
- const isNewConversation =
- !createdConversationsRef.current.has(conversationId)
- const newMessages = messages.filter(
- (msg) => !savedMessageIdsRef.current.has(msg.id),
- )
-
- if (newMessages.length === 0) return
-
- try {
- if (isNewConversation && newMessages.length > 0) {
- const firstMessage = newMessages[0]
- await execute(CreateConversationWithMessageDocument, {
- conversationId,
- profileId,
- message: firstMessage,
- })
- createdConversationsRef.current.add(conversationId)
- savedMessageIdsRef.current.add(firstMessage.id)
-
- for (let i = 1; i < newMessages.length; i++) {
- const msg = newMessages[i]
- const orderIndex = messages.findIndex((m) => m.id === msg.id)
- await execute(AppendConversationMessageDocument, {
- messageId: msg.id,
- conversationId,
- orderIndex,
- message: msg,
- })
- savedMessageIdsRef.current.add(msg.id)
- }
- } else {
- for (const msg of newMessages) {
- const orderIndex = messages.findIndex((m) => m.id === msg.id)
- await execute(AppendConversationMessageDocument, {
- messageId: msg.id,
- conversationId,
- orderIndex,
- message: msg,
- })
- savedMessageIdsRef.current.add(msg.id)
- }
-
- await execute(UpdateConversationLastMessagedAtDocument, {
- conversationId,
- })
- }
- } catch (error) {
- sentry.captureException(error, {
- extra: {
- message: 'Failed to save conversation to remote',
- },
- })
- }
- }
-
- const resetConversation = () => {
- savedMessageIdsRef.current = new Set()
- }
-
- const markMessagesAsSaved = useCallback(
- (conversationId: string, messages: UIMessage[]) => {
- createdConversationsRef.current.add(conversationId)
- for (const msg of messages) {
- savedMessageIdsRef.current.add(msg.id)
- }
- },
- [],
- )
-
- return {
- isLoggedIn: !!userId,
- userId,
- saveConversation,
- resetConversation,
- markMessagesAsSaved,
- }
-}
diff --git a/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.test.ts b/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.test.ts
index 11000895ce..a1e3989c2e 100644
--- a/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.test.ts
+++ b/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.test.ts
@@ -204,7 +204,11 @@ describe('commitChatTargetSelection', () => {
expect(setDefaultProvider).toHaveBeenCalledWith(provider.id)
})
- it('persists an ACP selection without touching the default provider id', async () => {
+ // Selecting an agent used to leave the default pointing at whichever llm
+ // provider was chosen before it, because the two lived in separate tables and
+ // the default could only name the llm one. They are one table now, so there
+ // is one selection and it records whatever was picked.
+ it('records an ACP selection as the default too', async () => {
const store = createSelectionStore()
const setDefaultProvider = mock(async (_id: string) => {})
@@ -215,7 +219,7 @@ describe('commitChatTargetSelection', () => {
)
expect(await store.getValue()).toEqual({ kind: 'acp', id: agent.id })
- expect(setDefaultProvider).not.toHaveBeenCalled()
+ expect(setDefaultProvider).toHaveBeenCalledWith(agent.id)
})
it('clears the selection without touching the default provider id', async () => {
@@ -241,3 +245,51 @@ function createSelectionStore(
watch: () => () => {},
}
}
+
+// Each extension surface holds its own cache of a list that lives on the
+// server, so one can be a refetch behind another. Repairing against a list
+// that has not caught up destroys a choice the user just made, which is what
+// made selecting a new provider appear to revert to BrowserOS.
+describe('resolveRepairedSelection with an incomplete list', () => {
+ const browserosTarget = {
+ kind: 'llm' as const,
+ id: 'browseros',
+ name: 'BrowserOS',
+ type: 'browseros' as const,
+ provider: {} as never,
+ }
+
+ it('leaves a selection this surface has not seen yet alone', () => {
+ expect(
+ resolveRepairedSelection({
+ selection: { kind: 'llm', id: 'just-created' },
+ resolvedTarget: browserosTarget,
+ ready: true,
+ knownIds: new Set(['browseros']),
+ }).repair,
+ ).toBe(false)
+ })
+
+ // The case repair exists for: the provider is gone from a list that does
+ // know about it, so the selection genuinely dangles.
+ it('still repairs a selection the list can account for', () => {
+ expect(
+ resolveRepairedSelection({
+ selection: { kind: 'llm', id: 'deleted-but-known' },
+ resolvedTarget: browserosTarget,
+ ready: true,
+ knownIds: new Set(['browseros', 'deleted-but-known']),
+ }),
+ ).toEqual({ repair: true, selection: { kind: 'llm', id: 'browseros' } })
+ })
+
+ it('repairs as before when no list is given', () => {
+ expect(
+ resolveRepairedSelection({
+ selection: { kind: 'llm', id: 'gone' },
+ resolvedTarget: browserosTarget,
+ ready: true,
+ }).repair,
+ ).toBe(true)
+ })
+})
diff --git a/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.ts b/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.ts
index 7a54c9e5e1..5519e8b4cd 100644
--- a/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.ts
+++ b/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.ts
@@ -121,24 +121,39 @@ export type RepairSelectionDecision =
| { repair: true; selection: SidepanelChatTargetSelection | null }
/**
- * Decides whether a persisted sidebar selection needs repair. It never repairs
- * an ACP selection: the agents list is fetch-backed and can be stale (a
- * persisted react-query cache, or a different extension context that has not
- * refetched a newly-created agent), so repairing here would wipe a valid ACP
- * default and silently downgrade it to the LLM fallback. Stale ACP selections
- * are cleaned by `clearSidepanelChatTargetSelectionForAgent` on delete, and
- * `resolveSidepanelChatTarget` already falls back non-destructively at render.
- * Only LLM selections are repaired, since providers load reliably from local
- * storage, and only once loads are settled.
+ * Decides whether a persisted sidebar selection needs repair.
+ *
+ * Repair exists for one case: the selection names something that has been
+ * deleted, so the sidebar would otherwise point at nothing. Absence from the
+ * list in hand is not evidence of that. Each extension surface holds its own
+ * query cache of a list that lives on the server, so a provider added in
+ * another surface is missing here until this one refetches, and rewriting the
+ * selection then destroys a choice the user just made. That is what made
+ * picking a new provider appear to revert to BrowserOS while picking an agent
+ * worked, since ACP selections were already exempt.
+ *
+ * So a selection is only repaired when the list is known to be complete, and a
+ * selection this list cannot confirm is left alone for the next render, when
+ * the revision signal will have brought the list up to date.
+ * `resolveSidepanelChatTarget` already falls back non-destructively for
+ * display, so nothing is broken while that happens. Stale entries are still
+ * cleaned on delete by `clearSidepanelChatTargetSelectionForAgent`.
*/
export function resolveRepairedSelection({
selection,
resolvedTarget,
ready,
+ knownIds,
}: {
selection: SidepanelChatTargetSelection | null
resolvedTarget: SidepanelChatTarget | undefined
ready: boolean
+ /**
+ * Every id this surface currently knows about, of either kind. A selection
+ * naming something absent from it is treated as not yet loaded rather than
+ * as deleted.
+ */
+ knownIds?: ReadonlySet
}): RepairSelectionDecision {
if (!ready || !selection) return { repair: false }
if (selection.kind === 'acp') return { repair: false }
@@ -149,6 +164,8 @@ export function resolveRepairedSelection({
) {
return { repair: false }
}
+ // Inconclusive rather than deleted: this surface has not seen it yet.
+ if (knownIds && !knownIds.has(selection.id)) return { repair: false }
return {
repair: true,
selection: resolvedTarget
@@ -187,13 +204,22 @@ export async function saveSidepanelChatTargetSelection(
* target, also updates the default-provider id so both stores stay consistent.
* Keeping this in one place is what prevents surfaces from drifting apart.
*/
+/**
+ * Records the chosen chat target.
+ *
+ * One write, for either kind. While llm providers and acp agents were separate
+ * tables the default could only name an llm one, so this wrote the selection
+ * unconditionally and the default only when the kind happened to be llm.
+ * Choosing an agent left the default pointing at whichever provider was
+ * selected before it, a stale shadow of the real choice.
+ */
export async function commitChatTargetSelection(
selection: SidepanelChatTargetSelection | null,
deps: { setDefaultProvider: (providerId: string) => Promise },
store?: SidepanelChatTargetSelectionWriter,
): Promise {
await saveSidepanelChatTargetSelection(selection, store)
- if (selection?.kind === 'llm') await deps.setDefaultProvider(selection.id)
+ if (selection) await deps.setDefaultProvider(selection.id)
}
export async function clearSidepanelChatTargetSelectionForAgent(
diff --git a/packages/browseros-agent/apps/app/modules/chat/use-chat-target-selection.ts b/packages/browseros-agent/apps/app/modules/chat/use-chat-target-selection.ts
index 6904ba8384..c15556de76 100644
--- a/packages/browseros-agent/apps/app/modules/chat/use-chat-target-selection.ts
+++ b/packages/browseros-agent/apps/app/modules/chat/use-chat-target-selection.ts
@@ -97,11 +97,18 @@ export function useChatTargetSelection() {
selection: targetSelection,
resolvedTarget: selectedChatTarget,
ready,
+ knownIds: new Set(chatTargets.map((target) => target.id)),
})
if (!decision.repair) return
setTargetSelection(decision.selection)
void persistSidepanelChatTargetSelection(selectedChatTarget)
- }, [agentsSettled, isLoadingProviders, selectedChatTarget, targetSelection])
+ }, [
+ agentsSettled,
+ chatTargets,
+ isLoadingProviders,
+ selectedChatTarget,
+ targetSelection,
+ ])
const selectedLlmProviderRef = useRef(
selectedLlmProvider,
diff --git a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts
deleted file mode 100644
index d8ea52c558..0000000000
--- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import type { UIMessage } from 'ai'
-import type { Conversation } from '@/lib/conversations/conversationStorage'
-
-export interface MigrateLegacyConversationsOptions {
- conversations: Conversation[]
- isLoggedIn: boolean
- userId: string | undefined
- importToServer: (conversation: Conversation) => Promise
- uploadToCloud: (
- conversations: Conversation[],
- userId: string,
- ) => Promise
-}
-
-/**
- * One-shot migration of pre-upgrade `local:conversations`. A logged-in user
- * keeps the old promote-to-cloud behavior; a logged-out user's history moves to
- * the local server. Returns the ids that were handled so the caller can drain
- * them from storage; a conversation that fails to migrate is left for a retry.
- */
-export async function migrateLegacyConversations({
- conversations,
- isLoggedIn,
- userId,
- importToServer,
- uploadToCloud,
-}: MigrateLegacyConversationsOptions): Promise {
- if (conversations.length === 0) return []
-
- if (isLoggedIn) {
- return userId ? uploadToCloud(conversations, userId) : []
- }
-
- const migrated: string[] = []
- for (const conversation of conversations) {
- try {
- await importToServer(conversation)
- migrated.push(conversation.id)
- } catch {
- // Leave unmigrated conversations in place for the next attempt.
- }
- }
- return migrated
-}
-
-export interface CollectServerConversationsOptions {
- listSummaries: () => Promise>
- loadDetail: (
- id: string,
- ) => Promise<{ id: string; messages: UIMessage[] } | null>
-}
-
-/**
- * Reads every server conversation with its messages, shaped for a cloud upload.
- * Drops any conversation deleted between the list and its detail fetch.
- */
-export async function collectServerConversations({
- listSummaries,
- loadDetail,
-}: CollectServerConversationsOptions): Promise {
- const summaries = await listSummaries()
- const details = await Promise.all(
- summaries.map(async (summary) => {
- const detail = await loadDetail(summary.id)
- return detail
- ? {
- id: detail.id,
- messages: detail.messages,
- lastMessagedAt: summary.lastMessagedAt,
- }
- : null
- }),
- )
- return details.filter(
- (conversation): conversation is Conversation => conversation !== null,
- )
-}
-
-export interface PromoteServerConversationsOptions {
- userId: string
- collect: () => Promise
- upload: (conversations: Conversation[], userId: string) => Promise
- drain: (id: string) => Promise
-}
-
-export interface PromoteResult {
- uploadedIds: string[]
- allUploaded: boolean
-}
-
-/**
- * Promotes the local server's (logged-out) history to the cloud, then drains
- * only the conversations the cloud confirmed. Draining is what keeps a later
- * sign-in under a different account from re-uploading someone else's retained
- * history, and it leaves any failed conversation on the server for a retry.
- */
-export async function promoteServerConversations({
- userId,
- collect,
- upload,
- drain,
-}: PromoteServerConversationsOptions): Promise {
- const conversations = await collect()
- if (conversations.length === 0) return { uploadedIds: [], allUploaded: true }
-
- const uploadedIds = await upload(conversations, userId)
- await Promise.all(uploadedIds.map((id) => drain(id)))
-
- return {
- uploadedIds,
- allUploaded: uploadedIds.length === conversations.length,
- }
-}
-
-/**
- * Returns a runner that executes tasks one at a time. The promote must not
- * overlap across an account switch: a serialized second promotion waits for the
- * first to upload and drain, so it never re-uploads the same unowned server rows
- * into a different account.
- */
-export function createSerialRunner(): (
- task: () => Promise,
-) => Promise {
- let chain: Promise = Promise.resolve()
- return (task: () => Promise): Promise => {
- const result = chain.then(task, task)
- chain = result.then(
- () => undefined,
- () => undefined,
- )
- return result
- }
-}
diff --git a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.test.ts b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.test.ts
deleted file mode 100644
index 13a925ac70..0000000000
--- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.test.ts
+++ /dev/null
@@ -1,236 +0,0 @@
-import { describe, expect, it, mock } from 'bun:test'
-import type { UIMessage } from 'ai'
-import type { Conversation } from '@/lib/conversations/conversationStorage'
-import {
- collectServerConversations,
- createSerialRunner,
- migrateLegacyConversations,
- promoteServerConversations,
-} from './conversations-migration.helpers'
-
-function conversation(id: string): Conversation {
- const messages: UIMessage[] = [
- { id: `${id}-m`, role: 'user', parts: [{ type: 'text', text: id }] },
- ]
- return { id, messages, lastMessagedAt: 1 }
-}
-
-describe('migrateLegacyConversations', () => {
- it('does nothing when there are no conversations', async () => {
- const importToServer = mock(async () => {})
- const uploadToCloud = mock(async () => [])
-
- expect(
- await migrateLegacyConversations({
- conversations: [],
- isLoggedIn: false,
- userId: undefined,
- importToServer,
- uploadToCloud,
- }),
- ).toEqual([])
- expect(importToServer).not.toHaveBeenCalled()
- expect(uploadToCloud).not.toHaveBeenCalled()
- })
-
- it('uploads to the cloud when logged in', async () => {
- const importToServer = mock(async () => {})
- const uploadToCloud = mock(async () => ['a'])
-
- const handled = await migrateLegacyConversations({
- conversations: [conversation('a'), conversation('b')],
- isLoggedIn: true,
- userId: 'user-1',
- importToServer,
- uploadToCloud,
- })
-
- expect(handled).toEqual(['a'])
- expect(uploadToCloud).toHaveBeenCalledWith(
- [conversation('a'), conversation('b')],
- 'user-1',
- )
- expect(importToServer).not.toHaveBeenCalled()
- })
-
- it('imports to the server when logged out', async () => {
- const importToServer = mock(async () => {})
- const uploadToCloud = mock(async () => [])
-
- const handled = await migrateLegacyConversations({
- conversations: [conversation('a'), conversation('b')],
- isLoggedIn: false,
- userId: undefined,
- importToServer,
- uploadToCloud,
- })
-
- expect(handled).toEqual(['a', 'b'])
- expect(importToServer).toHaveBeenCalledTimes(2)
- expect(uploadToCloud).not.toHaveBeenCalled()
- })
-
- it('only reports the conversations that imported successfully', async () => {
- const importToServer = mock(async (conv: Conversation) => {
- if (conv.id === 'b') throw new Error('server down')
- })
-
- const handled = await migrateLegacyConversations({
- conversations: [conversation('a'), conversation('b'), conversation('c')],
- isLoggedIn: false,
- userId: undefined,
- importToServer,
- uploadToCloud: mock(async () => []),
- })
-
- expect(handled).toEqual(['a', 'c'])
- })
-
- it('does not touch the server for a logged-in state that lacks a user id', async () => {
- const importToServer = mock(async () => {})
- const uploadToCloud = mock(async () => [])
-
- expect(
- await migrateLegacyConversations({
- conversations: [conversation('a')],
- isLoggedIn: true,
- userId: undefined,
- importToServer,
- uploadToCloud,
- }),
- ).toEqual([])
- expect(importToServer).not.toHaveBeenCalled()
- expect(uploadToCloud).not.toHaveBeenCalled()
- })
-})
-
-describe('collectServerConversations', () => {
- it('pairs each summary with its detail and carries lastMessagedAt', async () => {
- const listSummaries = mock(async () => [
- { id: 'a', lastMessagedAt: 10 },
- { id: 'b', lastMessagedAt: 20 },
- ])
- const loadDetail = mock(async (id: string) => ({
- id,
- messages: [{ id: `${id}-m`, role: 'user', parts: [] }] as UIMessage[],
- }))
-
- const result = await collectServerConversations({
- listSummaries,
- loadDetail,
- })
-
- expect(result).toEqual([
- {
- id: 'a',
- lastMessagedAt: 10,
- messages: [{ id: 'a-m', role: 'user', parts: [] }],
- },
- {
- id: 'b',
- lastMessagedAt: 20,
- messages: [{ id: 'b-m', role: 'user', parts: [] }],
- },
- ])
- })
-
- it('drops a conversation deleted before its detail loaded', async () => {
- const listSummaries = mock(async () => [
- { id: 'a', lastMessagedAt: 10 },
- { id: 'gone', lastMessagedAt: 5 },
- ])
- const loadDetail = mock(async (id: string) =>
- id === 'gone' ? null : { id, messages: [] as UIMessage[] },
- )
-
- const result = await collectServerConversations({
- listSummaries,
- loadDetail,
- })
-
- expect(result.map((conversation) => conversation.id)).toEqual(['a'])
- })
-})
-
-describe('promoteServerConversations', () => {
- it('does nothing when the server has no conversations', async () => {
- const upload = mock(async () => [])
- const drain = mock(async () => {})
-
- const result = await promoteServerConversations({
- userId: 'u1',
- collect: mock(async () => []),
- upload,
- drain,
- })
-
- expect(result).toEqual({ uploadedIds: [], allUploaded: true })
- expect(upload).not.toHaveBeenCalled()
- expect(drain).not.toHaveBeenCalled()
- })
-
- it('drains every conversation the cloud confirms', async () => {
- const drain = mock((_id: string) => Promise.resolve())
-
- const result = await promoteServerConversations({
- userId: 'u1',
- collect: mock(async () => [conversation('a'), conversation('b')]),
- upload: mock(async () => ['a', 'b']),
- drain,
- })
-
- expect(result).toEqual({ uploadedIds: ['a', 'b'], allUploaded: true })
- expect(drain.mock.calls.map((call) => call[0]).sort()).toEqual(['a', 'b'])
- })
-
- it('keeps a failed conversation on the server and reports incomplete', async () => {
- const drain = mock(async () => {})
-
- const result = await promoteServerConversations({
- userId: 'u1',
- collect: mock(async () => [conversation('a'), conversation('b')]),
- upload: mock(async () => ['a']),
- drain,
- })
-
- expect(result).toEqual({ uploadedIds: ['a'], allUploaded: false })
- expect(drain).toHaveBeenCalledTimes(1)
- expect(drain).toHaveBeenCalledWith('a')
- })
-})
-
-describe('createSerialRunner', () => {
- it('runs tasks one at a time', async () => {
- const run = createSerialRunner()
- const order: string[] = []
-
- const first = run(async () => {
- order.push('1-start')
- await Promise.resolve()
- order.push('1-end')
- })
- const second = run(async () => {
- order.push('2-start')
- order.push('2-end')
- })
- await Promise.all([first, second])
-
- expect(order).toEqual(['1-start', '1-end', '2-start', '2-end'])
- })
-
- it('runs the next task even when the previous one rejects', async () => {
- const run = createSerialRunner()
- const ran: string[] = []
-
- const first = run(async () => {
- throw new Error('boom')
- })
- const second = run(async () => {
- ran.push('second')
- })
-
- await expect(first).rejects.toThrow('boom')
- await second
- expect(ran).toEqual(['second'])
- })
-})
diff --git a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts
deleted file mode 100644
index 49223f50f1..0000000000
--- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { useQueryClient } from '@tanstack/react-query'
-import { useEffect } from 'react'
-import { useSessionInfo } from '@/lib/auth/sessionStorage'
-import { conversationStorage } from '@/lib/conversations/conversationStorage'
-import { uploadConversations } from '@/lib/conversations/uploadConversationsToGraphql'
-import { sentry } from '@/lib/sentry/sentry'
-import {
- deleteServerConversationRow,
- fetchServerConversation,
- fetchServerConversations,
- importServerConversation,
- SERVER_CONVERSATIONS_QUERY_KEY,
-} from './conversations.hooks'
-import {
- collectServerConversations,
- createSerialRunner,
- migrateLegacyConversations,
- promoteServerConversations,
-} from './conversations-migration.helpers'
-
-/**
- * Drains any pre-upgrade `local:conversations` to their new home (cloud when
- * logged in, the local server otherwise). Idempotent: once storage is drained
- * subsequent runs are no-ops.
- */
-export function useLegacyConversationMigration(): void {
- const { sessionInfo } = useSessionInfo()
- const userId = sessionInfo.user?.id
- const queryClient = useQueryClient()
-
- useEffect(() => {
- let cancelled = false
- const run = async () => {
- const conversations = (await conversationStorage.getValue()) ?? []
- if (cancelled || conversations.length === 0) return
-
- const handledIds = await migrateLegacyConversations({
- conversations,
- isLoggedIn: !!userId,
- userId,
- importToServer: importServerConversation,
- uploadToCloud: uploadConversations,
- })
- if (cancelled || handledIds.length === 0) return
-
- const current = (await conversationStorage.getValue()) ?? []
- await conversationStorage.setValue(
- current.filter((conversation) => !handledIds.includes(conversation.id)),
- )
- if (!userId) {
- queryClient.invalidateQueries({
- queryKey: [SERVER_CONVERSATIONS_QUERY_KEY],
- })
- }
- }
- run().catch((error) => {
- sentry.captureException(error, {
- extra: { message: 'Legacy conversation migration failed' },
- })
- })
- return () => {
- cancelled = true
- }
- }, [userId, queryClient])
-}
-
-// Module-scoped so the promote survives history remounts (once per sign-in, not
-// once per history open); reset when the user is absent, or when a promote does
-// not fully complete, so leftovers retry.
-let lastPromotedUserId: string | undefined
-// Serialize so an account switch cannot run two promotions over the same
-// undrained server rows concurrently (which could upload them into two accounts).
-const runPromoteExclusive = createSerialRunner()
-
-/**
- * On sign-in, promote server-held (logged-out) history to the cloud (draining
- * each conversation the cloud confirms, so it cannot leak to a later sign-in),
- * then run `onPromoted` (e.g. to refresh the cloud history list) when anything
- * landed.
- */
-export function useSignInConversationPromote(onPromoted?: () => void): void {
- const { sessionInfo } = useSessionInfo()
- const userId = sessionInfo.user?.id
-
- useEffect(() => {
- if (!userId) {
- lastPromotedUserId = undefined
- return
- }
- if (lastPromotedUserId === userId) return
- lastPromotedUserId = userId
-
- let cancelled = false
- runPromoteExclusive(() =>
- promoteServerConversations({
- userId,
- collect: () =>
- collectServerConversations({
- listSummaries: fetchServerConversations,
- loadDetail: fetchServerConversation,
- }),
- upload: uploadConversations,
- drain: deleteServerConversationRow,
- }),
- )
- .then((result) => {
- // Reset the guard whenever the promote did not fully complete, even if
- // this effect was cancelled, so leftovers are retried and never linger
- // leak-eligible. Only the UI refresh is gated on cancellation.
- if (!result.allUploaded) lastPromotedUserId = undefined
- if (!cancelled && result.uploadedIds.length > 0) onPromoted?.()
- })
- .catch((error) => {
- lastPromotedUserId = undefined
- sentry.captureException(error, {
- extra: { message: 'Sign-in conversation promote failed' },
- })
- })
- return () => {
- cancelled = true
- }
- }, [userId, onPromoted])
-}
diff --git a/packages/browseros-agent/apps/app/modules/conversations/conversations.hooks.ts b/packages/browseros-agent/apps/app/modules/conversations/conversations.hooks.ts
index 00de850bcd..402377810e 100644
--- a/packages/browseros-agent/apps/app/modules/conversations/conversations.hooks.ts
+++ b/packages/browseros-agent/apps/app/modules/conversations/conversations.hooks.ts
@@ -58,24 +58,6 @@ export async function fetchServerConversation(
return { id: data.conversation.id, messages }
}
-export async function importServerConversation(conversation: {
- id: string
- messages: UIMessage[]
- lastMessagedAt: number
-}): Promise {
- const client = await conversationsClient()
- const response = await client[':conversationId'].$put({
- param: { conversationId: conversation.id },
- json: {
- messages: conversation.messages,
- lastMessagedAt: conversation.lastMessagedAt,
- },
- })
- if (!response.ok) {
- throw new Error(`Failed to import conversation (${response.status})`)
- }
-}
-
/** Deletes only the server row (tolerating 404); leaves execution history. */
export async function deleteServerConversationRow(
conversationId: string,
diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.api.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.api.ts
new file mode 100644
index 0000000000..3aa0c517a2
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.api.ts
@@ -0,0 +1,113 @@
+import type { ProviderRoutes } from '@browseros/server'
+import { hc } from 'hono/client'
+import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import { resolveAgentServerUrlWithRetry } from '@/modules/browseros/agent-server-url.helpers'
+import { toProviderConfigs, toProviderPayload } from './llm-providers.helpers'
+import { bumpProviderRevision } from './llm-providers.revision'
+
+async function providersClient() {
+ const baseUrl = await resolveAgentServerUrlWithRetry()
+ return hc(`${baseUrl}/providers`)
+}
+
+export async function putProvider(config: LlmProviderConfig): Promise {
+ const client = await providersClient()
+ const response = await client[':providerId'].$put({
+ param: { providerId: config.id },
+ json: toProviderPayload(config),
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to save provider (${response.status})`)
+ }
+ await bumpProviderRevision()
+}
+
+export async function deleteProvider(providerId: string): Promise {
+ const client = await providersClient()
+ const response = await client[':providerId'].$delete({
+ param: { providerId },
+ })
+ if (!response.ok && response.status !== 404) {
+ throw new Error(`Failed to delete provider (${response.status})`)
+ }
+ await bumpProviderRevision()
+}
+
+/**
+ * The selected provider's id, or null when none is set.
+ *
+ * Held on the server beside the providers it points at, so it covers acp
+ * agents as readily as llm ones. It used to sit in extension storage, which
+ * meant selecting an agent left this pointing at the previous llm provider.
+ */
+export async function fetchDefaultProviderId(): Promise {
+ const client = await providersClient()
+ const response = await client.default.$get()
+ if (!response.ok) {
+ throw new Error(`Failed to load the default provider (${response.status})`)
+ }
+ const { provider } = await response.json()
+ return provider?.id ?? null
+}
+
+export async function putDefaultProvider(providerId: string): Promise {
+ const client = await providersClient()
+ const response = await client.default.$put({ json: { providerId } })
+ if (!response.ok) {
+ throw new Error(`Failed to set the default provider (${response.status})`)
+ }
+ await bumpProviderRevision()
+}
+
+export async function listProviders(): Promise {
+ const client = await providersClient()
+ const response = await client.index.$get()
+ if (!response.ok) {
+ throw new Error(`Failed to load providers (${response.status})`)
+ }
+ const { providers } = await response.json()
+ return toProviderConfigs(providers)
+}
+
+/**
+ * Loads the provider list, seeding the built-in BrowserOS provider when the
+ * server has none.
+ *
+ * The seed lives here rather than in an effect so it can only run on a
+ * confirmed empty response. Reacting to an empty list in the component would
+ * fire on a failed load too, writing the default over a list that had simply
+ * not arrived yet. The write is a PUT on a fixed id, so a retried fetch cannot
+ * produce duplicates either.
+ */
+export async function fetchProviders(): Promise {
+ const configs = await listProviders()
+ if (configs.length > 0) return configs
+
+ const seeded = createDefaultBrowserOSProvider()
+ await putProvider(seeded)
+ return [seeded]
+}
+
+/**
+ * The list for callers outside React, returning null when the server could not
+ * be reached.
+ *
+ * Null rather than an empty array because the two mean different things to a
+ * caller resolving an explicitly chosen provider: absent means the provider
+ * was deleted and falling back is right, unreachable means the choice is
+ * simply unknown and running anyway would use the wrong credentials.
+ *
+ * These callers must not seed either. A background alarm firing while the
+ * server is still starting would otherwise write the default into a database
+ * the migration had not filled yet.
+ */
+export async function listProvidersOrNull(): Promise<
+ LlmProviderConfig[] | null
+> {
+ try {
+ return await listProviders()
+ } catch {
+ return null
+ }
+}
diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.test.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.test.ts
new file mode 100644
index 0000000000..6c221daef8
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from 'bun:test'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import {
+ type ProviderRow,
+ removedProviderIds,
+ toProviderConfig,
+ toProviderConfigs,
+ toProviderPayload,
+} from './llm-providers.helpers'
+
+function row(overrides: Partial = {}): ProviderRow {
+ return {
+ id: 'provider-1',
+ type: 'openai',
+ name: 'My OpenAI',
+ kind: 'llm',
+ baseUrl: null,
+ modelId: 'gpt-5.5',
+ supportsImages: true,
+ contextWindow: 200000,
+ temperature: 0.2,
+ hasApiKey: false,
+ hasAccessKeyId: false,
+ hasSecretAccessKey: false,
+ hasSessionToken: false,
+ resourceName: null,
+ region: null,
+ reasoningEffort: null,
+ reasoningSummary: null,
+ createdAt: 10,
+ updatedAt: 20,
+ ...overrides,
+ }
+}
+
+function config(overrides: Partial = {}) {
+ return {
+ id: 'provider-1',
+ type: 'openai',
+ name: 'My OpenAI',
+ modelId: 'gpt-5.5',
+ supportsImages: true,
+ contextWindow: 200000,
+ temperature: 0.2,
+ createdAt: 10,
+ updatedAt: 20,
+ ...overrides,
+ } as LlmProviderConfig
+}
+
+describe('toProviderConfig', () => {
+ // The column is nullable but the config type uses undefined, and the two are
+ // not interchangeable to anything doing `'apiKey' in provider`.
+ it('turns absent columns into undefined rather than null', () => {
+ const converted = toProviderConfig(row())
+
+ expect(converted?.baseUrl).toBeUndefined()
+ expect(converted?.reasoningSummary).toBeUndefined()
+ })
+
+ // Credentials never leave the server now, so the row reports only whether
+ // one is set and the config carries that instead.
+ it('carries the credential flags, not the credentials', () => {
+ const converted = toProviderConfig(
+ row({ hasApiKey: true, region: 'us-east-1' }),
+ )
+
+ expect(converted).toMatchObject({ hasApiKey: true, region: 'us-east-1' })
+ expect(converted?.apiKey).toBeUndefined()
+ })
+
+ // Coding agents share this table and this endpoint. Filtering on kind is
+ // what excludes them; the unknown-type guard below used to do it by accident.
+ it('rejects a coding agent', () => {
+ expect(
+ toProviderConfig(row({ kind: 'acp', type: 'claude', modelId: null })),
+ ).toBeNull()
+ })
+
+ // The row survives in the database and comes back on upgrade. Showing it
+ // would push an unknown key through the icon map and template lookup, both
+ // keyed by the provider union.
+ it('rejects a type this build does not know', () => {
+ expect(toProviderConfig(row({ type: 'some-future-provider' }))).toBeNull()
+ })
+
+ it('keeps a recognised reasoning summary and drops an unrecognised one', () => {
+ expect(
+ toProviderConfig(row({ reasoningSummary: 'concise' }))?.reasoningSummary,
+ ).toBe('concise')
+ expect(
+ toProviderConfig(row({ reasoningSummary: 'verbose' }))?.reasoningSummary,
+ ).toBeUndefined()
+ })
+})
+
+describe('toProviderConfigs', () => {
+ it('drops unusable rows without losing the rest', () => {
+ const converted = toProviderConfigs([
+ row(),
+ row({ id: 'provider-2', type: 'some-future-provider' }),
+ ])
+
+ expect(converted.map((provider) => provider.id)).toEqual(['provider-1'])
+ })
+})
+
+describe('toProviderPayload', () => {
+ // id travels in the path, so sending it in the body too would let the two
+ // disagree.
+ it('leaves the id out of the body', () => {
+ expect('id' in toProviderPayload(config())).toBe(false)
+ })
+
+ it('preserves the creation time so a save does not reset it', () => {
+ expect(toProviderPayload(config()).createdAt).toBe(10)
+ })
+})
+
+describe('removedProviderIds', () => {
+ it('names the ids that a save displaced', () => {
+ const before = [config(), config({ id: 'provider-2' })]
+ const after = [config()]
+
+ expect(removedProviderIds(before, after)).toEqual(['provider-2'])
+ })
+
+ it('names nothing when the save displaced nothing', () => {
+ const before = [config()]
+ expect(removedProviderIds(before, before)).toEqual([])
+ })
+})
diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.ts
new file mode 100644
index 0000000000..b5539fcfa6
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.ts
@@ -0,0 +1,192 @@
+import { isProviderType } from '@/lib/llm-providers/providerTemplates'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+
+/**
+ * A provider row as the server returns it.
+ *
+ * Absent values are null rather than undefined, and credentials are not here
+ * at all: the server reports only whether each is set, so a key cannot reach
+ * a surface that has no use for it.
+ */
+export interface ProviderRow {
+ id: string
+ kind: 'llm' | 'acp'
+ type: string
+ name: string
+ baseUrl: string | null
+ // Nullable since the table holds coding agents too, and those carry neither.
+ modelId: string | null
+ supportsImages: boolean
+ contextWindow: number | null
+ temperature: number
+ hasApiKey: boolean
+ hasAccessKeyId: boolean
+ hasSecretAccessKey: boolean
+ hasSessionToken: boolean
+ resourceName: string | null
+ region: string | null
+ reasoningEffort: string | null
+ reasoningSummary: string | null
+ createdAt: number
+ updatedAt: number
+}
+
+function orUndefined(value: T | null): T | undefined {
+ return value ?? undefined
+}
+
+function toReasoningSummary(
+ value: string | null,
+): LlmProviderConfig['reasoningSummary'] {
+ if (value === 'auto' || value === 'concise' || value === 'detailed') {
+ return value
+ }
+ return undefined
+}
+
+/**
+ * Converts a stored row to the config shape the app works in.
+ *
+ * Returns null for a type this build does not know, which happens after a
+ * downgrade from a build that added one. The row stays in the database and
+ * reappears on upgrade; showing it would push an unknown key through the icon
+ * map, the template lookup and the default base URLs, all keyed by the union.
+ */
+export function toProviderConfig(row: ProviderRow): LlmProviderConfig | null {
+ // Coding agents share this table and this endpoint, and are served to the
+ // surfaces that want them through their own hook. Filtering on kind says
+ // that; leaning on the unknown-type guard below to drop them happened to
+ // work and said something else entirely.
+ if (row.kind !== 'llm') return null
+ if (!isProviderType(row.type)) return null
+ if (row.modelId === null || row.contextWindow === null) return null
+
+ return {
+ id: row.id,
+ type: row.type,
+ name: row.name,
+ baseUrl: orUndefined(row.baseUrl),
+ modelId: row.modelId,
+ supportsImages: row.supportsImages,
+ contextWindow: row.contextWindow,
+ temperature: row.temperature,
+ hasApiKey: row.hasApiKey,
+ hasAccessKeyId: row.hasAccessKeyId,
+ hasSecretAccessKey: row.hasSecretAccessKey,
+ hasSessionToken: row.hasSessionToken,
+ resourceName: orUndefined(row.resourceName),
+ region: orUndefined(row.region),
+ reasoningEffort: orUndefined(row.reasoningEffort),
+ reasoningSummary: toReasoningSummary(row.reasoningSummary),
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ }
+}
+
+export function toProviderConfigs(rows: readonly ProviderRow[]) {
+ return rows
+ .map(toProviderConfig)
+ .filter((config): config is LlmProviderConfig => config !== null)
+}
+
+/** The request body for a provider write. `id` travels in the path instead. */
+export function toProviderPayload(config: LlmProviderConfig) {
+ return {
+ type: config.type,
+ name: config.name,
+ baseUrl: config.baseUrl,
+ modelId: config.modelId,
+ supportsImages: config.supportsImages,
+ contextWindow: config.contextWindow,
+ temperature: config.temperature,
+ apiKey: config.apiKey,
+ accessKeyId: config.accessKeyId,
+ secretAccessKey: config.secretAccessKey,
+ sessionToken: config.sessionToken,
+ resourceName: config.resourceName,
+ region: config.region,
+ reasoningEffort: config.reasoningEffort,
+ reasoningSummary: config.reasoningSummary,
+ createdAt: config.createdAt,
+ }
+}
+
+/**
+ * Provider types where a second copy makes no sense, because the credential is
+ * an OAuth grant held once per account rather than a key the user can hold
+ * several of.
+ */
+const SINGLE_INSTANCE_PROVIDER_TYPES = new Set([
+ 'chatgpt-pro',
+ 'github-copilot',
+ 'qwen-code',
+])
+
+export interface ProviderSavePlan {
+ saved: LlmProviderConfig
+ removedIds: string[]
+}
+
+/**
+ * Works out the writes a save turns into.
+ *
+ * Extension storage took the whole list at once, so collapsing an earlier copy
+ * of a single-instance provider fell out of replacing the array. Over HTTP the
+ * save is one PUT, so the copies it displaces have to be deleted explicitly,
+ * and the surviving id has to be the earlier one so the row keeps its
+ * identity rather than accumulating a new one per sign-in.
+ */
+export function planProviderSave(
+ current: readonly LlmProviderConfig[],
+ provider: LlmProviderConfig,
+ now = Date.now(),
+): ProviderSavePlan {
+ if (!SINGLE_INSTANCE_PROVIDER_TYPES.has(provider.type)) {
+ const existing = current.find((candidate) => candidate.id === provider.id)
+ return {
+ saved: existing
+ ? { ...provider, updatedAt: now }
+ : { ...provider, createdAt: now, updatedAt: now },
+ removedIds: [],
+ }
+ }
+
+ const existing =
+ current.find((candidate) => candidate.id === provider.id) ??
+ current.find((candidate) => candidate.type === provider.type)
+
+ const saved: LlmProviderConfig = {
+ ...provider,
+ id: existing?.id ?? provider.id,
+ createdAt: existing?.createdAt ?? now,
+ updatedAt: now,
+ }
+
+ const removedIds = current
+ .filter(
+ (candidate) =>
+ candidate.id !== saved.id &&
+ (candidate.type === provider.type || candidate.id === provider.id),
+ )
+ .map((candidate) => candidate.id)
+
+ return { saved, removedIds }
+}
+
+/**
+ * Ids present before a save but not after.
+ *
+ * Saving a single-instance provider (an OAuth one, where a second copy makes
+ * no sense) collapses any earlier copy into the saved one. In extension
+ * storage that fell out of writing the whole list at once; over HTTP the
+ * removals have to be issued explicitly.
+ */
+export function removedProviderIds(
+ before: readonly LlmProviderConfig[],
+ after: readonly LlmProviderConfig[],
+): string[] {
+ const kept = new Set(after.map((provider) => provider.id))
+ return before
+ .filter((provider) => !kept.has(provider.id))
+ .map((provider) => provider.id)
+}
diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.test.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.test.ts
index 24d4a5755e..ed18481658 100644
--- a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.test.ts
+++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.test.ts
@@ -4,8 +4,20 @@ import {
resolveDefaultProviderId,
resolveSelectedProvider,
} from '../../lib/llm-providers/provider-selection'
+import { planProviderSave } from './llm-providers.helpers'
const storageValues = new Map()
+const putDefaultProviderCalls: string[] = []
+
+mock.module('./llm-providers.api', () => ({
+ fetchProviders: async () => [],
+ fetchDefaultProviderId: async () => null,
+ putProvider: async () => undefined,
+ deleteProvider: async () => undefined,
+ putDefaultProvider: async (providerId: string) => {
+ putDefaultProviderCalls.push(providerId)
+ },
+}))
mock.module('@wxt-dev/storage', () => ({
storage: {
@@ -91,10 +103,6 @@ mock.module('../../lib/llm-providers/storage', () => ({
},
}))
-mock.module('@/lib/llm-providers/uploadLlmProvidersToGraphql', () => ({
- uploadLlmProvidersToGraphql: async () => {},
-}))
-
const timestamp = 1000
function providerConfig(
@@ -139,12 +147,9 @@ const providers: LlmProviderConfig[] = [
]
let persistDefaultProviderId: (providerId: string) => Promise
-let upsertProviderConfig: typeof import('./llm-providers.hooks').upsertProviderConfig
beforeAll(async () => {
- ;({ persistDefaultProviderId, upsertProviderConfig } = await import(
- './llm-providers.hooks'
- ))
+ ;({ persistDefaultProviderId } = await import('./llm-providers.hooks'))
})
beforeEach(() => {
@@ -160,16 +165,19 @@ describe('resolveSelectedProvider', () => {
})
describe('persistDefaultProviderId', () => {
- it('writes a provider id to default-provider storage', async () => {
+ // The selection moved to the server when the two provider tables merged, so
+ // it can name a coding agent as readily as an llm provider. It used to be an
+ // extension storage write, which is why it could only ever name the latter.
+ it('sends the provider id to the server', async () => {
await persistDefaultProviderId('anthropic-provider')
- expect(storageValues.get('local:default-provider-id')).toBe(
- 'anthropic-provider',
- )
+ expect(putDefaultProviderCalls).toEqual(['anthropic-provider'])
})
})
-describe('upsertProviderConfig', () => {
+describe('planProviderSave', () => {
+ // These are OAuth providers, where the credential is one grant per account,
+ // so a second copy is always a duplicate of the first rather than a choice.
it('replaces an existing OAuth provider by type while preserving its id', () => {
const existing = providerConfig({
id: 'chatgpt-pro-existing',
@@ -187,14 +195,13 @@ describe('upsertProviderConfig', () => {
contextWindow: 1050000,
})
- const result = upsertProviderConfig(
+ const { saved, removedIds } = planProviderSave(
[providers[0], existing],
incoming,
2222,
)
- expect(result).toHaveLength(2)
- expect(result[1]).toMatchObject({
+ expect(saved).toMatchObject({
id: 'chatgpt-pro-existing',
type: 'chatgpt-pro',
name: 'ChatGPT',
@@ -203,9 +210,12 @@ describe('upsertProviderConfig', () => {
createdAt: 1111,
updatedAt: 2222,
})
+ expect(removedIds).toEqual([])
})
- it('removes extra same-type OAuth rows on save', () => {
+ // Writing the whole list used to drop these implicitly. Over HTTP each one
+ // needs its own DELETE, so the plan has to name them.
+ it('names the extra same-type OAuth rows for deletion', () => {
const first = providerConfig({
id: 'chatgpt-pro-first',
type: 'chatgpt-pro',
@@ -222,28 +232,49 @@ describe('upsertProviderConfig', () => {
name: 'Fresh ChatGPT',
})
- const result = upsertProviderConfig([providers[0], first, second], incoming)
+ const { saved, removedIds } = planProviderSave(
+ [providers[0], first, second],
+ incoming,
+ )
- expect(
- result.filter((provider) => provider.type === 'chatgpt-pro'),
- ).toEqual([
- expect.objectContaining({
- id: 'chatgpt-pro-first',
- name: 'Fresh ChatGPT',
- }),
- ])
+ expect(saved).toMatchObject({
+ id: 'chatgpt-pro-first',
+ name: 'Fresh ChatGPT',
+ })
+ expect(removedIds).toEqual(['chatgpt-pro-second'])
})
it('allows multiple non-OAuth providers of the same type', () => {
const first = providerConfig({ id: 'openai-first', name: 'OpenAI 1' })
const second = providerConfig({ id: 'openai-second', name: 'OpenAI 2' })
- const result = upsertProviderConfig([first], second, 2222)
+ const { saved, removedIds } = planProviderSave([first], second, 2222)
+
+ expect(saved.id).toBe('openai-second')
+ expect(removedIds).toEqual([])
+ })
+
+ it('stamps a creation time on a provider that is new', () => {
+ const { saved } = planProviderSave(
+ [],
+ providerConfig({ id: 'openai-new' }),
+ 3333,
+ )
+
+ expect(saved.createdAt).toBe(3333)
+ expect(saved.updatedAt).toBe(3333)
+ })
+
+ it('keeps the original creation time when updating in place', () => {
+ const existing = providerConfig({ id: 'openai-1', createdAt: 1111 })
+ const { saved } = planProviderSave(
+ [existing],
+ { ...existing, name: 'Renamed' },
+ 4444,
+ )
- expect(result.map((provider) => provider.id)).toEqual([
- 'openai-first',
- 'openai-second',
- ])
+ expect(saved.createdAt).toBe(1111)
+ expect(saved.updatedAt).toBe(4444)
})
})
diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.ts
index a3add7fad7..fe1ec9a437 100644
--- a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.ts
+++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.ts
@@ -1,213 +1,164 @@
-import { useEffect, useMemo, useState } from 'react'
-import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useEffect } from 'react'
+import { createQuery } from 'react-query-kit'
import {
resolveDefaultProviderId,
resolveSelectedProvider,
-} from '../../lib/llm-providers/provider-selection'
+} from '@/lib/llm-providers/provider-selection'
+import { DEFAULT_PROVIDER_ID } from '@/lib/llm-providers/storage'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import {
- createDefaultProvidersConfig,
- DEFAULT_PROVIDER_ID,
- defaultProviderIdStorage,
- loadProviders,
- providersStorage,
-} from '../../lib/llm-providers/storage'
+ deleteProvider as deleteProviderRow,
+ fetchDefaultProviderId,
+ fetchProviders,
+ putDefaultProvider,
+ putProvider,
+} from './llm-providers.api'
+import { planProviderSave } from './llm-providers.helpers'
+import { watchProviderRevision } from './llm-providers.revision'
export interface UseLlmProvidersReturn {
providers: LlmProviderConfig[]
defaultProviderId: string
selectedProvider: LlmProviderConfig | null
isLoading: boolean
+ /**
+ * The server could not be reached, as opposed to reporting no providers.
+ * Callers must not treat this as an empty list: the difference is between
+ * offering to set up a first provider and saying the list is unavailable.
+ */
+ isUnavailable: boolean
+ /**
+ * Resolves with the row that was actually written. A single-instance save
+ * keeps the existing provider's id, so the caller must not assume the id it
+ * passed in is the one that persisted.
+ */
saveProvider: (provider: LlmProviderConfig) => Promise
setDefaultProvider: (providerId: string) => Promise
deleteProvider: (providerId: string) => Promise
}
-const SINGLE_INSTANCE_PROVIDER_TYPES = new Set([
- 'chatgpt-pro',
- 'github-copilot',
- 'qwen-code',
-])
+export const useProvidersQuery = createQuery({
+ queryKey: ['llm-providers'],
+ fetcher: fetchProviders,
+})
+
+/**
+ * The selected provider, held on the server beside the providers it points at.
+ *
+ * It lived in extension storage until the two provider tables were merged,
+ * which meant it could only ever name an llm provider: selecting an acp agent
+ * wrote the other pointer and left this one stale.
+ */
+export const useDefaultProviderIdQuery = createQuery({
+ queryKey: ['provider-default'],
+ fetcher: fetchDefaultProviderId,
+})
/** Persists the configured default provider id used by provider selection. */
-// Exported only for llm-providers.hooks.test.ts; fallow's graph skips test imports.
-// fallow-ignore-next-line unused-export
export async function persistDefaultProviderId(
providerId: string,
): Promise {
- await defaultProviderIdStorage.setValue(providerId)
+ await putDefaultProvider(providerId)
}
-/** Applies provider-save semantics before writing the full provider list. */
-export function upsertProviderConfig(
- currentProviders: LlmProviderConfig[],
- provider: LlmProviderConfig,
- now = Date.now(),
-): LlmProviderConfig[] {
- if (SINGLE_INSTANCE_PROVIDER_TYPES.has(provider.type)) {
- return upsertSingleInstanceProvider(currentProviders, provider, now)
- }
-
- const existingIndex = currentProviders.findIndex(
- (candidate) => candidate.id === provider.id,
+/**
+ * Keeps this surface current with provider writes made in another one.
+ *
+ * Each extension surface has its own query cache, and the rows live on the
+ * server where nothing can watch them. Writers bump a revision in extension
+ * storage instead, which does broadcast to every context, and each mounted
+ * view refetches. This is what `providersStorage.watch` used to do before the
+ * list moved off extension storage.
+ */
+function useProviderRevision(): void {
+ const queryClient = useQueryClient()
+
+ useEffect(
+ () =>
+ watchProviderRevision(() => {
+ queryClient.invalidateQueries({ queryKey: useProvidersQuery.getKey() })
+ queryClient.invalidateQueries({
+ queryKey: useDefaultProviderIdQuery.getKey(),
+ })
+ }),
+ [queryClient],
)
- if (existingIndex >= 0) {
- const updatedProviders = [...currentProviders]
- updatedProviders[existingIndex] = { ...provider, updatedAt: now }
- return updatedProviders
- }
-
- return [
- ...currentProviders,
- {
- ...provider,
- createdAt: now,
- updatedAt: now,
- },
- ]
-}
-
-function upsertSingleInstanceProvider(
- currentProviders: LlmProviderConfig[],
- provider: LlmProviderConfig,
- now: number,
-): LlmProviderConfig[] {
- const existing =
- currentProviders.find((candidate) => candidate.id === provider.id) ??
- currentProviders.find((candidate) => candidate.type === provider.type)
- const savedProvider = {
- ...provider,
- id: existing?.id ?? provider.id,
- createdAt: existing?.createdAt ?? now,
- updatedAt: now,
- }
- let inserted = false
-
- const updatedProviders = currentProviders.flatMap((candidate) => {
- if (candidate.id === savedProvider.id) {
- if (inserted) return []
- inserted = true
- return [savedProvider]
- }
- if (candidate.type === provider.type || candidate.id === provider.id) {
- return []
- }
- return [candidate]
- })
-
- if (!inserted) updatedProviders.push(savedProvider)
- return updatedProviders
}
/** Hook for managing LLM provider configurations. */
export function useLlmProviders(): UseLlmProvidersReturn {
- const [providers, setProviders] = useState([])
- const [defaultProviderId, setDefaultProviderId] =
- useState(DEFAULT_PROVIDER_ID)
- const [isLoading, setIsLoading] = useState(true)
-
- useEffect(() => {
- const loadData = async () => {
- setIsLoading(true)
- try {
- let [loadedProviders, loadedDefaultId] = await Promise.all([
- loadProviders(),
- defaultProviderIdStorage.getValue(),
- ])
-
- if (!loadedProviders || loadedProviders.length === 0) {
- loadedProviders = createDefaultProvidersConfig()
- await providersStorage.setValue(loadedProviders)
- }
-
- const resolvedDefaultId = resolveDefaultProviderId(
- loadedProviders,
- loadedDefaultId,
- )
- if (resolvedDefaultId !== loadedDefaultId) {
- await defaultProviderIdStorage.setValue(resolvedDefaultId)
- }
-
- setProviders(loadedProviders)
- setDefaultProviderId(resolvedDefaultId)
- } catch {
- } finally {
- setIsLoading(false)
- }
- }
-
- loadData()
- }, [])
-
- useEffect(() => {
- const unsubscribeProviders = providersStorage.watch((newProviders) => {
- if (newProviders) {
- setProviders(newProviders)
- }
+ const queryClient = useQueryClient()
+ const providersQuery = useProvidersQuery()
+ const defaultQuery = useDefaultProviderIdQuery()
+ useProviderRevision()
+ const storedDefaultId = defaultQuery.data ?? DEFAULT_PROVIDER_ID
+
+ const providers = providersQuery.data ?? []
+ const invalidate = () =>
+ queryClient.invalidateQueries({ queryKey: useProvidersQuery.getKey() })
+ const invalidateDefault = () =>
+ queryClient.invalidateQueries({
+ queryKey: useDefaultProviderIdQuery.getKey(),
})
- const unsubscribeDefaultId = defaultProviderIdStorage.watch(
- (newDefaultId) => {
- if (newDefaultId) {
- setDefaultProviderId(newDefaultId)
- }
- },
- )
-
- return () => {
- unsubscribeProviders()
- unsubscribeDefaultId()
- }
- }, [])
-
- const saveProvider = async (
- provider: LlmProviderConfig,
- ): Promise => {
- const currentProviders = (await providersStorage.getValue()) || []
- const updatedProviders = upsertProviderConfig(currentProviders, provider)
- await providersStorage.setValue(updatedProviders)
- // The single-instance upsert can keep an existing row's id, so a reconnect
- // saves under the existing id, not the fresh one on `provider`. Return the
- // row that was actually written so callers reference the id that persisted.
- return (
- updatedProviders.find((candidate) => candidate.id === provider.id) ??
- updatedProviders.find((candidate) => candidate.type === provider.type) ??
- provider
- )
- }
+ const saveMutation = useMutation({
+ mutationFn: async (provider: LlmProviderConfig) => {
+ const { saved, removedIds } = planProviderSave(providers, provider)
+ await putProvider(saved)
+ for (const id of removedIds) await deleteProviderRow(id)
+ // The row that persisted, which is not always the one passed in: a
+ // single-instance save keeps the earlier provider's id, and that is the
+ // id chat target selection has to reference.
+ return saved
+ },
+ onSuccess: invalidate,
+ })
- const setDefaultProviderFn = async (providerId: string) => {
- setDefaultProviderId(providerId)
- await persistDefaultProviderId(providerId)
- }
+ const deleteMutation = useMutation({
+ mutationFn: async (providerId: string) => {
+ // The built-in provider is what the app falls back to, so removing it
+ // would leave nothing to chat with.
+ if (providerId === DEFAULT_PROVIDER_ID) return
- const deleteProvider = async (providerId: string) => {
- if (providerId === DEFAULT_PROVIDER_ID) {
- return
- }
+ // Delete first. Moving the default before the row is gone leaves the
+ // provider configured but no longer default when the delete fails, with
+ // nothing to tell the user it happened. The reverse is harmless: a
+ // default id pointing at a deleted provider is repaired on read.
+ await deleteProviderRow(providerId)
- const currentProviders = (await providersStorage.getValue()) || []
- const updatedProviders = currentProviders.filter((p) => p.id !== providerId)
+ // Nothing to repoint by hand: deleting the row removes the default with
+ // it, and the next provider is chosen on read.
+ },
+ onSuccess: () => {
+ invalidate()
+ invalidateDefault()
+ },
+ })
- if (defaultProviderId === providerId) {
- const newDefaultId = updatedProviders[0]?.id || DEFAULT_PROVIDER_ID
- await defaultProviderIdStorage.setValue(newDefaultId)
- }
+ const setDefaultMutation = useMutation({
+ mutationFn: persistDefaultProviderId,
+ onSuccess: invalidateDefault,
+ })
- await providersStorage.setValue(updatedProviders)
+ const setDefaultProvider = async (providerId: string) => {
+ await setDefaultMutation.mutateAsync(providerId)
}
- const selectedProvider = useMemo(
- () => resolveSelectedProvider(providers, defaultProviderId),
- [providers, defaultProviderId],
- )
+ // Derived on read rather than repaired in storage: the write would be a side
+ // effect of rendering, and every reader resolves the id the same way anyway.
+ const defaultProviderId = resolveDefaultProviderId(providers, storedDefaultId)
return {
providers,
defaultProviderId,
- selectedProvider,
- isLoading,
- saveProvider,
- setDefaultProvider: setDefaultProviderFn,
- deleteProvider,
+ selectedProvider: resolveSelectedProvider(providers, defaultProviderId),
+ isLoading: providersQuery.isPending || defaultQuery.isPending,
+ isUnavailable: providersQuery.isError || defaultQuery.isError,
+ saveProvider: (provider) => saveMutation.mutateAsync(provider),
+ setDefaultProvider,
+ deleteProvider: async (providerId) => {
+ await deleteMutation.mutateAsync(providerId)
+ },
}
}
diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.revision.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.revision.ts
new file mode 100644
index 0000000000..c346f3dc20
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.revision.ts
@@ -0,0 +1,28 @@
+import { storage } from '@wxt-dev/storage'
+
+/**
+ * A change signal, not data.
+ *
+ * The provider list and the selected provider live on the server, and every
+ * extension surface holds its own query cache of them. Extension storage used
+ * to carry the data itself, so `watch` kept the side panel, the new tab, the
+ * settings page and the scheduled tasks page in step for free. Moving the data
+ * to the server kept the cache per surface and dropped the broadcast, so a
+ * provider added in one place stayed invisible to the others until they
+ * happened to refetch. This keeps the broadcast half.
+ *
+ * The value is a timestamp rather than a counter so two surfaces writing at
+ * once cannot lose a bump to a read-modify-write race.
+ */
+export const providerRevisionStorage = storage.defineItem(
+ 'local:provider-revision',
+ { fallback: 0 },
+)
+
+export async function bumpProviderRevision(): Promise {
+ await providerRevisionStorage.setValue(Date.now())
+}
+
+export function watchProviderRevision(onChange: () => void): () => void {
+ return providerRevisionStorage.watch(() => onChange())
+}
diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.test.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.test.ts
new file mode 100644
index 0000000000..c2e1311ddb
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.test.ts
@@ -0,0 +1,239 @@
+import { describe, expect, it } from 'bun:test'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import type { ScheduledJob } from '@/lib/schedules/scheduleTypes'
+import {
+ isImportableJob,
+ isImportableProvider,
+ mergeProviderSources,
+ parseProviderBackup,
+ toProviderImport,
+ toScheduledJobImport,
+} from './local-first-migration.helpers'
+
+function provider(
+ overrides: Partial = {},
+): LlmProviderConfig {
+ return {
+ id: 'provider-1',
+ type: 'openai',
+ name: 'My OpenAI',
+ modelId: 'gpt-5.5',
+ supportsImages: true,
+ contextWindow: 200000,
+ temperature: 0.2,
+ createdAt: 10,
+ updatedAt: 20,
+ ...overrides,
+ }
+}
+
+function job(overrides: Partial = {}): ScheduledJob {
+ return {
+ id: 'job-1',
+ name: 'Morning digest',
+ query: 'summarise my inbox',
+ scheduleType: 'daily',
+ scheduleTime: '09:00',
+ enabled: true,
+ createdAt: '2026-01-02T03:04:05.000Z',
+ updatedAt: '2026-01-02T03:04:05.000Z',
+ ...overrides,
+ }
+}
+
+describe('parseProviderBackup', () => {
+ it('reads the provider list out of the pref payload', () => {
+ const raw = JSON.stringify({
+ defaultProviderId: 'provider-1',
+ providers: [provider()],
+ })
+ expect(parseProviderBackup(raw).map((p) => p.id)).toEqual(['provider-1'])
+ })
+
+ // The backup is a fallback source, so a corrupt one must not stop the
+ // extension-storage providers from importing.
+ it('yields nothing rather than throwing on unusable input', () => {
+ expect(parseProviderBackup('not json')).toEqual([])
+ expect(parseProviderBackup('null')).toEqual([])
+ expect(parseProviderBackup(JSON.stringify({ providers: 'nope' }))).toEqual(
+ [],
+ )
+ expect(parseProviderBackup(undefined)).toEqual([])
+ expect(parseProviderBackup('')).toEqual([])
+ })
+
+ it('drops entries with no id', () => {
+ const raw = JSON.stringify({ providers: [provider(), { name: 'junk' }] })
+ expect(parseProviderBackup(raw)).toHaveLength(1)
+ })
+})
+
+describe('mergeProviderSources', () => {
+ // Extension storage is written on every save, so it is the current copy.
+ it('keeps the stored provider when both sources have the id', () => {
+ const merged = mergeProviderSources(
+ [provider({ name: 'Current' })],
+ [provider({ name: 'Stale backup' })],
+ )
+ expect(merged).toHaveLength(1)
+ expect(merged[0].name).toBe('Current')
+ })
+
+ // The reinstall case: extension storage was cleared, the per-profile pref
+ // outlived it, and the backup is the only remaining copy.
+ it('contributes backup providers that storage no longer has', () => {
+ const merged = mergeProviderSources([], [provider({ id: 'from-backup' })])
+ expect(merged.map((p) => p.id)).toEqual(['from-backup'])
+ })
+
+ it('does not duplicate a provider repeated within the backup', () => {
+ const merged = mergeProviderSources([], [provider(), provider()])
+ expect(merged).toHaveLength(1)
+ })
+})
+
+describe('toProviderImport', () => {
+ it('carries the credentials across', () => {
+ expect(
+ toProviderImport(
+ provider({
+ apiKey: 'sk-test',
+ accessKeyId: 'AKIA',
+ secretAccessKey: 'secret',
+ sessionToken: 'token',
+ }),
+ ),
+ ).toMatchObject({
+ apiKey: 'sk-test',
+ accessKeyId: 'AKIA',
+ secretAccessKey: 'secret',
+ sessionToken: 'token',
+ })
+ })
+
+ it('preserves the original creation time', () => {
+ expect(toProviderImport(provider()).createdAt).toBe(10)
+ })
+})
+
+describe('toScheduledJobImport', () => {
+ it('converts the ISO timestamps the extension holds to epoch', () => {
+ const imported = toScheduledJobImport(
+ job({ lastRunAt: '2026-01-03T00:00:00.000Z' }),
+ )
+ expect(imported.createdAt).toBe(Date.parse('2026-01-02T03:04:05.000Z'))
+ expect(imported.lastRunAt).toBe(Date.parse('2026-01-03T00:00:00.000Z'))
+ })
+
+ // NaN would fail validation and take the whole batch down with it, so the
+ // job lands with the server's own timestamp instead.
+ it('drops an unparseable timestamp rather than sending NaN', () => {
+ const imported = toScheduledJobImport(job({ createdAt: 'whenever' }))
+ expect(imported.createdAt).toBeUndefined()
+ expect(imported.name).toBe('Morning digest')
+ })
+
+ it('leaves an absent lastRunAt absent', () => {
+ expect(toScheduledJobImport(job()).lastRunAt).toBeUndefined()
+ })
+})
+
+describe('isImportableProvider', () => {
+ it('accepts a well formed provider', () => {
+ expect(isImportableProvider(provider())).toBe(true)
+ })
+
+ // A single entry the server rejects returns 400 for the whole batch, and
+ // because the pref backup has no migration path that failure would repeat on
+ // every startup with nothing the user could do about it.
+ it.each([
+ ['no id', { id: '' }],
+ ['no type', { type: '' }],
+ ['no name', { name: '' }],
+ ['no model', { modelId: '' }],
+ ])('rejects a provider with %s', (_label, overrides) => {
+ expect(isImportableProvider(provider(overrides as never))).toBe(false)
+ })
+
+ it('rejects a provider whose context window is not a number', () => {
+ expect(
+ isImportableProvider({ ...provider(), contextWindow: '200000' }),
+ ).toBe(false)
+ expect(isImportableProvider({ ...provider(), contextWindow: NaN })).toBe(
+ false,
+ )
+ })
+
+ // Storage migrations drop these; the pref backup never gets that treatment.
+ it('rejects provider types that no longer exist', () => {
+ for (const type of [
+ 'remote-hermes',
+ 'claude-code',
+ 'codex',
+ 'acp-custom',
+ ]) {
+ expect(isImportableProvider(provider({ type } as never))).toBe(false)
+ }
+ })
+
+ it('rejects values that are not objects', () => {
+ expect(isImportableProvider(null)).toBe(false)
+ expect(isImportableProvider('provider')).toBe(false)
+ })
+})
+
+describe('isImportableJob', () => {
+ it('accepts a well formed job', () => {
+ expect(isImportableJob(job())).toBe(true)
+ })
+
+ it('rejects a job missing the fields the server requires', () => {
+ expect(isImportableJob(job({ name: '' }))).toBe(false)
+ expect(isImportableJob(job({ query: '' }))).toBe(false)
+ })
+
+ it('rejects an unrecognised schedule type', () => {
+ expect(isImportableJob(job({ scheduleType: 'weekly' } as never))).toBe(
+ false,
+ )
+ })
+})
+
+describe('optional field sanitising', () => {
+ // The provider is valid where it matters, so it should still import; the
+ // junk field is dropped and the server applies its own default.
+ it('drops an optional field holding the wrong type', () => {
+ const imported = toProviderImport({
+ ...provider(),
+ temperature: 'warm',
+ supportsImages: 'yes',
+ baseUrl: 42,
+ } as never)
+
+ expect(imported.temperature).toBeUndefined()
+ expect(imported.supportsImages).toBeUndefined()
+ expect(imported.baseUrl).toBeUndefined()
+ expect(imported.modelId).toBe('gpt-5.5')
+ })
+
+ it('keeps optional fields that are the right type', () => {
+ const imported = toProviderImport(
+ provider({ baseUrl: 'https://api.openai.com/v1' }),
+ )
+ expect(imported.baseUrl).toBe('https://api.openai.com/v1')
+ expect(imported.temperature).toBe(0.2)
+ expect(imported.supportsImages).toBe(true)
+ })
+
+ it('drops a job field holding the wrong type', () => {
+ const imported = toScheduledJobImport({
+ ...job(),
+ scheduleInterval: 'hourly',
+ enabled: 'true',
+ } as never)
+
+ expect(imported.scheduleInterval).toBeUndefined()
+ expect(imported.enabled).toBeUndefined()
+ expect(imported.name).toBe('Morning digest')
+ })
+})
diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.ts
new file mode 100644
index 0000000000..02d24a2b47
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.ts
@@ -0,0 +1,199 @@
+import { REMOVED_PROVIDER_TYPES } from '@/lib/llm-providers/removed-provider-types'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import type { ScheduledJob } from '@/lib/schedules/scheduleTypes'
+
+/** Payload for `POST /providers/import`. */
+export interface ProviderImport {
+ id: string
+ type: string
+ name: string
+ baseUrl?: string
+ modelId: string
+ supportsImages?: boolean
+ contextWindow: number
+ temperature?: number
+ apiKey?: string
+ accessKeyId?: string
+ secretAccessKey?: string
+ sessionToken?: string
+ resourceName?: string
+ region?: string
+ reasoningEffort?: string
+ reasoningSummary?: string
+ createdAt?: number
+}
+
+/** Payload for `POST /scheduled-jobs/import`. */
+export interface ScheduledJobImport {
+ id: string
+ name: string
+ query: string
+ scheduleType: ScheduledJob['scheduleType']
+ scheduleTime?: string
+ scheduleInterval?: number
+ enabled?: boolean
+ providerId?: string
+ lastRunAt?: number
+ createdAt?: number
+}
+
+/**
+ * Reads the provider list out of the `browseros.providers` pref backup.
+ *
+ * The pref holds a JSON string of `LlmProvidersBackup`. It is a fallback
+ * source, so anything unparseable yields nothing rather than throwing: a
+ * corrupt backup must not stop the extension-storage providers from importing.
+ */
+export function parseProviderBackup(raw: unknown): LlmProviderConfig[] {
+ if (typeof raw !== 'string' || raw.length === 0) return []
+ try {
+ const parsed: unknown = JSON.parse(raw)
+ if (typeof parsed !== 'object' || parsed === null) return []
+ const providers = (parsed as { providers?: unknown }).providers
+ if (!Array.isArray(providers)) return []
+ return providers.filter(
+ (provider): provider is LlmProviderConfig =>
+ typeof provider === 'object' &&
+ provider !== null &&
+ typeof (provider as LlmProviderConfig).id === 'string',
+ )
+ } catch {
+ return []
+ }
+}
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === 'string' && value.length > 0
+}
+
+function optionalString(value: unknown): string | undefined {
+ return isNonEmptyString(value) ? value : undefined
+}
+
+function optionalNumber(value: unknown): number | undefined {
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
+}
+
+function optionalBoolean(value: unknown): boolean | undefined {
+ return typeof value === 'boolean' ? value : undefined
+}
+
+/**
+ * Whether a provider can be sent to the import endpoint.
+ *
+ * The required fields are exactly the ones the server requires, because a
+ * single entry it rejects returns 400 for the whole batch. That would be
+ * permanent rather than transient: the pref backup has no migration path, so
+ * the same bad entry would fail the import, block the scheduled jobs behind
+ * it, and leave the done marker unset to retry forever.
+ *
+ * Removed types are excluded for a related reason. Storage migrations drop
+ * them, the pref backup never gets that treatment, and importing one would
+ * put a provider of a type the app no longer supports into the database.
+ */
+export function isImportableProvider(
+ value: unknown,
+): value is LlmProviderConfig {
+ if (typeof value !== 'object' || value === null) return false
+ const provider = value as Partial
+ return (
+ isNonEmptyString(provider.id) &&
+ isNonEmptyString(provider.type) &&
+ !REMOVED_PROVIDER_TYPES.has(provider.type) &&
+ isNonEmptyString(provider.name) &&
+ isNonEmptyString(provider.modelId) &&
+ optionalNumber(provider.contextWindow) !== undefined
+ )
+}
+
+/** Same contract as `isImportableProvider`, for the scheduled jobs batch. */
+export function isImportableJob(value: unknown): value is ScheduledJob {
+ if (typeof value !== 'object' || value === null) return false
+ const job = value as Partial
+ return (
+ isNonEmptyString(job.id) &&
+ isNonEmptyString(job.name) &&
+ isNonEmptyString(job.query) &&
+ (job.scheduleType === 'daily' ||
+ job.scheduleType === 'hourly' ||
+ job.scheduleType === 'minutes')
+ )
+}
+
+/**
+ * Unions the two local provider sources, extension storage winning on id.
+ *
+ * Extension storage is what the app writes on every save, so it is the current
+ * copy. The pref backup only contributes providers missing from it, which is
+ * the reinstall case: extension storage was cleared and the per-profile pref
+ * outlived it.
+ */
+export function mergeProviderSources(
+ stored: readonly LlmProviderConfig[],
+ backup: readonly LlmProviderConfig[],
+): LlmProviderConfig[] {
+ const merged = [...stored]
+ const seen = new Set(stored.map((provider) => provider.id))
+ for (const provider of backup) {
+ if (seen.has(provider.id)) continue
+ seen.add(provider.id)
+ merged.push(provider)
+ }
+ return merged
+}
+
+/**
+ * Optional fields pass through a type check rather than straight across, so a
+ * provider that is well formed where it matters still imports when one of its
+ * optional fields holds junk. Dropping the field lets the server apply its own
+ * default; sending the wrong type would fail the whole batch.
+ */
+export function toProviderImport(config: LlmProviderConfig): ProviderImport {
+ return {
+ id: config.id,
+ type: config.type,
+ name: config.name,
+ baseUrl: optionalString(config.baseUrl),
+ modelId: config.modelId,
+ supportsImages: optionalBoolean(config.supportsImages),
+ contextWindow: config.contextWindow,
+ temperature: optionalNumber(config.temperature),
+ apiKey: optionalString(config.apiKey),
+ accessKeyId: optionalString(config.accessKeyId),
+ secretAccessKey: optionalString(config.secretAccessKey),
+ sessionToken: optionalString(config.sessionToken),
+ resourceName: optionalString(config.resourceName),
+ region: optionalString(config.region),
+ reasoningEffort: optionalString(config.reasoningEffort),
+ reasoningSummary: optionalString(config.reasoningSummary),
+ createdAt: optionalNumber(config.createdAt),
+ }
+}
+
+/**
+ * Jobs hold ISO strings here and epoch numbers in the database.
+ *
+ * An unparseable timestamp is dropped rather than sent as NaN, which would
+ * fail validation and take the whole batch with it. The server then stamps its
+ * own `createdAt`, so the job still lands.
+ */
+function toEpoch(value: unknown): number | undefined {
+ if (!isNonEmptyString(value)) return undefined
+ const parsed = Date.parse(value)
+ return Number.isNaN(parsed) ? undefined : parsed
+}
+
+export function toScheduledJobImport(job: ScheduledJob): ScheduledJobImport {
+ return {
+ id: job.id,
+ name: job.name,
+ query: job.query,
+ scheduleType: job.scheduleType,
+ scheduleTime: optionalString(job.scheduleTime),
+ scheduleInterval: optionalNumber(job.scheduleInterval),
+ enabled: optionalBoolean(job.enabled),
+ providerId: optionalString(job.providerId),
+ lastRunAt: toEpoch(job.lastRunAt),
+ createdAt: toEpoch(job.createdAt),
+ }
+}
diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.test.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.test.ts
new file mode 100644
index 0000000000..9bcf785c80
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.test.ts
@@ -0,0 +1,305 @@
+import { describe, expect, it } from 'bun:test'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import type {
+ ScheduledJob,
+ ScheduledJobRun,
+} from '@/lib/schedules/scheduleTypes'
+import {
+ type LocalFirstMigrationDeps,
+ type RunsMigrationDeps,
+ runLocalFirstMigration,
+ runScheduledRunsMigration,
+} from './local-first-migration'
+import type {
+ ProviderImport,
+ ScheduledJobImport,
+} from './local-first-migration.helpers'
+
+function provider(
+ overrides: Partial = {},
+): LlmProviderConfig {
+ return {
+ id: 'provider-1',
+ type: 'openai',
+ name: 'My OpenAI',
+ modelId: 'gpt-5.5',
+ supportsImages: true,
+ contextWindow: 200000,
+ temperature: 0.2,
+ apiKey: 'sk-test',
+ createdAt: 10,
+ updatedAt: 20,
+ ...overrides,
+ }
+}
+
+function job(overrides: Partial = {}): ScheduledJob {
+ return {
+ id: 'job-1',
+ name: 'Morning digest',
+ query: 'summarise my inbox',
+ scheduleType: 'daily',
+ enabled: true,
+ createdAt: '2026-01-02T03:04:05.000Z',
+ updatedAt: '2026-01-02T03:04:05.000Z',
+ ...overrides,
+ }
+}
+
+interface Harness {
+ deps: LocalFirstMigrationDeps
+ done: () => boolean
+ importedProviders: ProviderImport[][]
+ importedJobs: ScheduledJobImport[][]
+}
+
+function harness(overrides: Partial = {}): Harness {
+ let done = false
+ const importedProviders: ProviderImport[][] = []
+ const importedJobs: ScheduledJobImport[][] = []
+
+ return {
+ done: () => done,
+ importedProviders,
+ importedJobs,
+ deps: {
+ isDone: async () => done,
+ markDone: async () => {
+ done = true
+ },
+ loadStoredProviders: async () => [],
+ loadBackupProviders: async () => [],
+ loadScheduledJobs: async () => [],
+ importProviders: async (providers) => {
+ importedProviders.push(providers)
+ },
+ importScheduledJobs: async (jobs) => {
+ importedJobs.push(jobs)
+ },
+ ...overrides,
+ },
+ }
+}
+
+describe('runLocalFirstMigration', () => {
+ it('imports providers and jobs, then records that it ran', async () => {
+ const h = harness({
+ loadStoredProviders: async () => [provider()],
+ loadScheduledJobs: async () => [job()],
+ })
+
+ const result = await runLocalFirstMigration(h.deps)
+
+ expect(result).toEqual({
+ ranMigration: true,
+ providerCount: 1,
+ jobCount: 1,
+ })
+ expect(h.importedProviders[0][0]).toMatchObject({
+ id: 'provider-1',
+ apiKey: 'sk-test',
+ })
+ expect(h.importedJobs[0][0]).toMatchObject({ id: 'job-1' })
+ expect(h.done()).toBe(true)
+ })
+
+ // The whole point of the marker: providers the user has since deleted must
+ // not come back on the next startup.
+ it('does nothing once it has already run', async () => {
+ const h = harness({
+ isDone: async () => true,
+ loadStoredProviders: async () => [provider()],
+ })
+
+ const result = await runLocalFirstMigration(h.deps)
+
+ expect(result.ranMigration).toBe(false)
+ expect(h.importedProviders).toHaveLength(0)
+ })
+
+ it('unions the pref backup with extension storage', async () => {
+ const h = harness({
+ loadStoredProviders: async () => [provider()],
+ loadBackupProviders: async () => [provider({ id: 'from-backup' })],
+ })
+
+ await runLocalFirstMigration(h.deps)
+
+ expect(h.importedProviders[0].map((p) => p.id)).toEqual([
+ 'provider-1',
+ 'from-backup',
+ ])
+ })
+
+ it('marks itself done with nothing to import so it stops retrying', async () => {
+ const h = harness()
+
+ const result = await runLocalFirstMigration(h.deps)
+
+ expect(result).toEqual({
+ ranMigration: true,
+ providerCount: 0,
+ jobCount: 0,
+ })
+ expect(h.importedProviders).toHaveLength(0)
+ expect(h.importedJobs).toHaveLength(0)
+ expect(h.done()).toBe(true)
+ })
+
+ // The whole batch is one request, so an entry the server rejects would take
+ // the valid providers down with it, block the jobs queued behind it, and
+ // leave the marker unset to fail again on every startup.
+ it('drops an unusable backup entry instead of failing the batch', async () => {
+ const h = harness({
+ loadStoredProviders: async () => [provider()],
+ loadBackupProviders: async () =>
+ [
+ { id: 'stale', name: 'half a provider' },
+ provider({ id: 'removed-type', type: 'remote-hermes' as never }),
+ ] as never,
+ loadScheduledJobs: async () => [job()],
+ })
+
+ const result = await runLocalFirstMigration(h.deps)
+
+ expect(h.importedProviders[0].map((p) => p.id)).toEqual(['provider-1'])
+ expect(h.importedJobs).toHaveLength(1)
+ expect(result.ranMigration).toBe(true)
+ expect(h.done()).toBe(true)
+ })
+
+ it('drops a job the server would reject without losing the rest', async () => {
+ const h = harness({
+ loadScheduledJobs: async () =>
+ [job(), { id: 'broken', name: '', query: '' }] as never,
+ })
+
+ await runLocalFirstMigration(h.deps)
+
+ expect(h.importedJobs[0].map((j) => j.id)).toEqual(['job-1'])
+ expect(h.done()).toBe(true)
+ })
+
+ // Filtering runs before the merge, so an unusable stored entry cannot win
+ // the id and take a perfectly good backup copy down with it.
+ it('falls back to the backup copy when the stored one is unusable', async () => {
+ const h = harness({
+ loadStoredProviders: async () => [{ id: 'provider-1' }] as never,
+ loadBackupProviders: async () => [provider({ name: 'From backup' })],
+ })
+
+ await runLocalFirstMigration(h.deps)
+
+ expect(h.importedProviders[0]).toHaveLength(1)
+ expect(h.importedProviders[0][0].name).toBe('From backup')
+ })
+
+ // A failed run must retry on the next startup, which is only safe because
+ // the server inserts what is absent rather than replacing.
+ it('leaves itself unmarked when the import fails', async () => {
+ const h = harness({
+ loadStoredProviders: async () => [provider()],
+ importProviders: async () => {
+ throw new Error('server not up')
+ },
+ })
+
+ await expect(runLocalFirstMigration(h.deps)).rejects.toThrow(
+ 'server not up',
+ )
+ expect(h.done()).toBe(false)
+ })
+
+ it('does not mark itself done when the jobs import fails after providers landed', async () => {
+ const h = harness({
+ loadStoredProviders: async () => [provider()],
+ loadScheduledJobs: async () => [job()],
+ importScheduledJobs: async () => {
+ throw new Error('server not up')
+ },
+ })
+
+ await expect(runLocalFirstMigration(h.deps)).rejects.toThrow(
+ 'server not up',
+ )
+ expect(h.importedProviders).toHaveLength(1)
+ expect(h.done()).toBe(false)
+ })
+})
+
+function runsHarness(overrides: Partial = {}) {
+ let done = false
+ const imported: ScheduledJobRun[][] = []
+ return {
+ done: () => done,
+ imported,
+ deps: {
+ isDone: async () => done,
+ markDone: async () => {
+ done = true
+ },
+ loadRuns: async () => [],
+ importRuns: async (runs: ScheduledJobRun[]) => {
+ imported.push(runs)
+ },
+ ...overrides,
+ } as RunsMigrationDeps,
+ }
+}
+
+function jobRun(overrides: Partial = {}): ScheduledJobRun {
+ return {
+ id: 'run-1',
+ jobId: 'job-1',
+ status: 'completed',
+ startedAt: '2026-01-02T03:04:05.000Z',
+ ...overrides,
+ }
+}
+
+describe('runScheduledRunsMigration', () => {
+ it('imports run history and records that it ran', async () => {
+ const h = runsHarness({ loadRuns: async () => [jobRun()] })
+
+ const result = await runScheduledRunsMigration(h.deps)
+
+ expect(result).toEqual({ ranMigration: true, runCount: 1 })
+ expect(h.imported[0][0].id).toBe('run-1')
+ expect(h.done()).toBe(true)
+ })
+
+ it('does nothing once it has already run', async () => {
+ const h = runsHarness({
+ isDone: async () => true,
+ loadRuns: async () => [jobRun()],
+ })
+
+ expect((await runScheduledRunsMigration(h.deps)).ranMigration).toBe(false)
+ expect(h.imported).toHaveLength(0)
+ })
+
+ it('marks itself done with nothing to import so it stops retrying', async () => {
+ const h = runsHarness()
+
+ expect(await runScheduledRunsMigration(h.deps)).toEqual({
+ ranMigration: true,
+ runCount: 0,
+ })
+ expect(h.imported).toHaveLength(0)
+ expect(h.done()).toBe(true)
+ })
+
+ it('leaves itself unmarked when the import fails', async () => {
+ const h = runsHarness({
+ loadRuns: async () => [jobRun()],
+ importRuns: async () => {
+ throw new Error('server not up')
+ },
+ })
+
+ await expect(runScheduledRunsMigration(h.deps)).rejects.toThrow(
+ 'server not up',
+ )
+ expect(h.done()).toBe(false)
+ })
+})
diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.ts
new file mode 100644
index 0000000000..296d4f2331
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.ts
@@ -0,0 +1,139 @@
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import type {
+ ScheduledJob,
+ ScheduledJobRun,
+} from '@/lib/schedules/scheduleTypes'
+import type {
+ ProviderImport,
+ ScheduledJobImport,
+} from './local-first-migration.helpers'
+import {
+ isImportableJob,
+ isImportableProvider,
+ mergeProviderSources,
+ toProviderImport,
+ toScheduledJobImport,
+} from './local-first-migration.helpers'
+
+export interface LocalFirstMigrationDeps {
+ isDone: () => Promise
+ markDone: () => Promise
+ loadStoredProviders: () => Promise
+ loadBackupProviders: () => Promise
+ loadScheduledJobs: () => Promise
+ importProviders: (providers: ProviderImport[]) => Promise
+ importScheduledJobs: (jobs: ScheduledJobImport[]) => Promise
+}
+
+export interface LocalFirstMigrationResult {
+ ranMigration: boolean
+ providerCount: number
+ jobCount: number
+}
+
+const SKIPPED: LocalFirstMigrationResult = {
+ ranMigration: false,
+ providerCount: 0,
+ jobCount: 0,
+}
+
+/**
+ * Moves providers and scheduled jobs from extension storage into the server
+ * database, once.
+ *
+ * Only local sources are read. The cloud is deliberately not one: its
+ * scheduled jobs include every job deleted since the deletion queue lost its
+ * only reader, and its providers never carried credentials, so they are
+ * already handled by the incomplete-provider prompt in AI settings.
+ *
+ * The done marker is set only after both imports land. A failed run leaves it
+ * unset and retries on the next startup, which is safe because the server side
+ * inserts only what is absent.
+ */
+export async function runLocalFirstMigration(
+ deps: LocalFirstMigrationDeps,
+): Promise {
+ if (await deps.isDone()) return SKIPPED
+
+ const [stored, backup, jobs] = await Promise.all([
+ deps.loadStoredProviders(),
+ deps.loadBackupProviders(),
+ deps.loadScheduledJobs(),
+ ])
+
+ // Filtering happens before the merge, not after: an unusable stored entry
+ // would otherwise win the id and then be dropped, losing a provider whose
+ // backup copy was perfectly good.
+ const providers = mergeProviderSources(
+ stored.filter(isImportableProvider),
+ backup.filter(isImportableProvider),
+ ).map(toProviderImport)
+ const scheduledJobs = jobs.filter(isImportableJob).map(toScheduledJobImport)
+
+ if (providers.length > 0) await deps.importProviders(providers)
+ if (scheduledJobs.length > 0) await deps.importScheduledJobs(scheduledJobs)
+
+ await deps.markDone()
+
+ return {
+ ranMigration: true,
+ providerCount: providers.length,
+ jobCount: scheduledJobs.length,
+ }
+}
+
+export interface RunsMigrationDeps {
+ isDone: () => Promise
+ markDone: () => Promise
+ loadRuns: () => Promise
+ importRuns: (runs: ScheduledJobRun[]) => Promise
+}
+
+/**
+ * Moves scheduled run history from extension storage into the server, once.
+ *
+ * Separate from the provider and job import, and with its own marker, because
+ * that one must never run a second time. Extension storage is no longer
+ * written, so its provider list is frozen at whatever it held when it stopped;
+ * re-importing it would insert back a provider the user has since deleted,
+ * because absent is exactly what a deliberate delete looks like.
+ */
+export async function runScheduledRunsMigration(
+ deps: RunsMigrationDeps,
+): Promise<{ ranMigration: boolean; runCount: number }> {
+ if (await deps.isDone()) return { ranMigration: false, runCount: 0 }
+
+ const runs = await deps.loadRuns()
+ if (runs.length > 0) await deps.importRuns(runs)
+ await deps.markDone()
+
+ return { ranMigration: true, runCount: runs.length }
+}
+
+export interface DefaultProviderMigrationDeps {
+ isDone: () => Promise
+ markDone: () => Promise
+ loadStoredDefaultId: () => Promise
+ setDefault: (providerId: string) => Promise
+}
+
+/**
+ * Moves the selected provider from extension storage to the server, once.
+ *
+ * Its own marker, like the run history, because the provider and job import
+ * must never run twice and this cannot ride along with it. Skipping when the
+ * stored id is missing matters as much as writing when it is present: a
+ * default that no longer names anything would otherwise be pushed over one the
+ * user has since chosen on another surface.
+ */
+export async function runDefaultProviderMigration(
+ deps: DefaultProviderMigrationDeps,
+): Promise<{ ranMigration: boolean; providerId: string | null }> {
+ if (await deps.isDone()) return { ranMigration: false, providerId: null }
+
+ const storedId = await deps.loadStoredDefaultId()
+ if (storedId) await deps.setDefault(storedId)
+ await deps.markDone()
+
+ return { ranMigration: true, providerId: storedId }
+}
diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/start-local-first-migration.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/start-local-first-migration.ts
new file mode 100644
index 0000000000..7d7d4cff54
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/local-first-migration/start-local-first-migration.ts
@@ -0,0 +1,161 @@
+import type { ProviderRoutes, ScheduledJobRoutes } from '@browseros/server'
+import { storage } from '@wxt-dev/storage'
+import { hc } from 'hono/client'
+import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
+import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
+import {
+ defaultProviderIdStorage,
+ providersStorage,
+} from '@/lib/llm-providers/storage'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import {
+ scheduledJobRunStorage,
+ scheduledJobStorage,
+} from '@/lib/schedules/scheduleStorage'
+import { sentry } from '@/lib/sentry/sentry'
+import { resolveAgentServerUrlWithRetry } from '@/modules/browseros/agent-server-url.helpers'
+import { putDefaultProvider } from '@/modules/llm-providers/llm-providers.api'
+import { importScheduledJobRuns } from '@/modules/schedules/schedules.api'
+import {
+ runDefaultProviderMigration,
+ runLocalFirstMigration,
+ runScheduledRunsMigration,
+} from './local-first-migration'
+import {
+ type ProviderImport,
+ parseProviderBackup,
+ type ScheduledJobImport,
+} from './local-first-migration.helpers'
+import { waitForAgentServer } from './wait-for-agent-server'
+
+/**
+ * Per profile, because extension storage is per profile. Losing it costs a
+ * redundant import that inserts nothing, never a lost or overwritten row,
+ * which is what insert-if-absent on the server buys.
+ */
+export const migrationDoneStorage = storage.defineItem(
+ 'local:local-first-migration-done',
+ { fallback: false },
+)
+
+/**
+ * Runs carry their own marker rather than reusing the one above.
+ *
+ * Reusing it would mean re-running the provider and job import for everyone
+ * who has already migrated, and that import must never run twice: extension
+ * storage is frozen now, so it would insert back anything the user has since
+ * deleted through the new UI.
+ */
+export const runsMigrationDoneStorage = storage.defineItem(
+ 'local:local-first-runs-migration-done',
+ { fallback: false },
+)
+
+/** Its own marker too, for the reason on the one above. */
+export const defaultMigrationDoneStorage = storage.defineItem(
+ 'local:local-first-default-migration-done',
+ { fallback: false },
+)
+
+async function loadBackupProviders(): Promise {
+ try {
+ const pref = await getBrowserOSAdapter().getPref(BROWSEROS_PREFS.PROVIDERS)
+ return parseProviderBackup(pref?.value)
+ } catch {
+ // No BrowserOS API, or no backup written yet. Extension storage still runs.
+ return []
+ }
+}
+
+async function importProviders(providers: ProviderImport[]): Promise {
+ const baseUrl = await resolveAgentServerUrlWithRetry()
+ const client = hc(`${baseUrl}/providers`)
+ const response = await client.import.$post({ json: { providers } })
+ if (!response.ok) {
+ throw new Error(`Failed to import providers (${response.status})`)
+ }
+}
+
+async function importScheduledJobs(jobs: ScheduledJobImport[]): Promise {
+ const baseUrl = await resolveAgentServerUrlWithRetry()
+ const client = hc(`${baseUrl}/scheduled-jobs`)
+ const response = await client.import.$post({ json: { jobs } })
+ if (!response.ok) {
+ throw new Error(`Failed to import scheduled jobs (${response.status})`)
+ }
+}
+
+/**
+ * Runs the one-time imports once the server is up.
+ *
+ * Everything here happens when the background starts, which is also when the
+ * server starts, so firing straight away meant importing into a socket nothing
+ * was listening on. Each import then failed, and because a failed run leaves
+ * its marker unset it lost the same race on the next launch too, so a user
+ * upgrading saw their providers and scheduled tasks simply never arrive while
+ * the database migration looked like it had worked.
+ *
+ * Waiting on health first removes the race. A failure past that point is worth
+ * seeing rather than swallowing: it is the difference between a slow start and
+ * data that never came across.
+ */
+export function startLocalFirstMigration(): void {
+ void (async () => {
+ if (!(await waitForAgentServer())) {
+ sentry.captureException(
+ new Error('Agent server unreachable before the local-first import'),
+ {
+ extra: {
+ message:
+ 'Imports deferred to the next start; markers remain unset so they will run again',
+ },
+ },
+ )
+ return
+ }
+
+ // The default is chained onto the provider import rather than run
+ // alongside it: the id it names has to exist server side before it can be
+ // made default. The run history is independent and does not wait.
+ try {
+ await runLocalFirstMigration({
+ isDone: () => migrationDoneStorage.getValue(),
+ markDone: () => migrationDoneStorage.setValue(true),
+ loadStoredProviders: async () =>
+ (await providersStorage.getValue()) ?? [],
+ loadBackupProviders,
+ loadScheduledJobs: async () =>
+ (await scheduledJobStorage.getValue()) ?? [],
+ importProviders,
+ importScheduledJobs,
+ })
+ await runDefaultProviderMigration({
+ isDone: () => defaultMigrationDoneStorage.getValue(),
+ markDone: () => defaultMigrationDoneStorage.setValue(true),
+ loadStoredDefaultId: async () =>
+ (await defaultProviderIdStorage.getValue()) || null,
+ setDefault: putDefaultProvider,
+ })
+ } catch (error) {
+ // Reported rather than swallowed: a silent failure here is
+ // indistinguishable from the user's data having vanished, which is
+ // exactly how this went unnoticed.
+ sentry.captureException(error, {
+ extra: { message: 'Provider and scheduled job import failed' },
+ })
+ }
+
+ try {
+ await runScheduledRunsMigration({
+ isDone: () => runsMigrationDoneStorage.getValue(),
+ markDone: () => runsMigrationDoneStorage.setValue(true),
+ loadRuns: async () => (await scheduledJobRunStorage.getValue()) ?? [],
+ importRuns: importScheduledJobRuns,
+ })
+ } catch (error) {
+ sentry.captureException(error, {
+ extra: { message: 'Scheduled run history import failed' },
+ })
+ }
+ })()
+}
diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.test.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.test.ts
new file mode 100644
index 0000000000..ad00a5d519
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it, mock } from 'bun:test'
+
+// The agent server and the mcp proxy listen on different ports and can become
+// ready at different moments, so the probe has to ask the one the imports
+// actually address.
+mock.module('@/lib/browseros/helpers', () => ({
+ getAgentServerUrl: async () => 'http://127.0.0.1:9105',
+ getProxyPort: async () => 9106,
+ getMcpPort: async () => 9105,
+ getHealthCheckUrl: async () => 'http://127.0.0.1:9106/system/health',
+ getMcpServerUrl: async () => 'http://127.0.0.1:9106/mcp',
+}))
+
+const { waitForAgentServer, agentServerHealthUrl } = await import(
+ './wait-for-agent-server'
+)
+
+function harness(healthyAfter: number) {
+ let calls = 0
+ let clock = 0
+ return {
+ calls: () => calls,
+ elapsed: () => clock,
+ opts: {
+ isHealthy: async () => {
+ calls += 1
+ return calls > healthyAfter
+ },
+ now: () => clock,
+ sleep: async (ms: number) => {
+ clock += ms
+ },
+ timeoutMs: 60_000,
+ intervalMs: 1_000,
+ },
+ }
+}
+
+describe('waitForAgentServer', () => {
+ it('returns immediately when the server is already up', async () => {
+ const h = harness(0)
+
+ expect(await waitForAgentServer(h.opts)).toBe(true)
+ expect(h.calls()).toBe(1)
+ expect(h.elapsed()).toBe(0)
+ })
+
+ // The case this exists for: the background starts at the same moment as the
+ // server, so the first few probes find nothing listening.
+ it('waits out a server that is still starting', async () => {
+ const h = harness(6)
+
+ expect(await waitForAgentServer(h.opts)).toBe(true)
+ expect(h.calls()).toBe(7)
+ expect(h.elapsed()).toBe(6_000)
+ })
+
+ // Six seconds is roughly what was observed between the browser launching and
+ // the server answering, and the previous behaviour gave up inside two.
+ it('outlasts the gap that made the import fail', async () => {
+ const h = harness(6)
+ await waitForAgentServer(h.opts)
+
+ expect(h.elapsed()).toBeGreaterThan(1_500)
+ })
+
+ // Giving up rather than throwing is what lets the caller leave the markers
+ // unset, so the next start tries again.
+ it('reports failure rather than throwing when the server never answers', async () => {
+ const h = harness(Number.POSITIVE_INFINITY)
+
+ expect(await waitForAgentServer(h.opts)).toBe(false)
+ })
+
+ it('stops probing once the deadline passes', async () => {
+ const h = harness(Number.POSITIVE_INFINITY)
+ await waitForAgentServer(h.opts)
+
+ expect(h.elapsed()).toBeLessThanOrEqual(60_000)
+ expect(h.calls()).toBeLessThanOrEqual(62)
+ })
+
+ // Connection refused is the expected state early on, so a probe that throws
+ // has to read as not reachable rather than end the wait.
+ it('treats a throwing probe as not reachable and keeps waiting', async () => {
+ let calls = 0
+ let clock = 0
+
+ const result = await waitForAgentServer({
+ isHealthy: async () => {
+ calls += 1
+ if (calls < 3) throw new Error('connection refused')
+ return true
+ },
+ now: () => clock,
+ sleep: async (ms) => {
+ clock += ms
+ },
+ timeoutMs: 60_000,
+ intervalMs: 1_000,
+ })
+
+ expect(result).toBe(true)
+ expect(calls).toBe(3)
+ expect(clock).toBe(2_000)
+ })
+
+ // Probing the proxy would answer the wrong question: it can be up while the
+ // agent server is still starting, which leaves the original race open.
+ it('probes the agent server, not the proxy', async () => {
+ expect(await agentServerHealthUrl()).toBe(
+ 'http://127.0.0.1:9105/system/health',
+ )
+ })
+})
diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.ts
new file mode 100644
index 0000000000..08ee33653b
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.ts
@@ -0,0 +1,81 @@
+import { getAgentServerUrl } from '@/lib/browseros/helpers'
+
+/**
+ * Long enough to cover a cold start where the server is booting alongside the
+ * browser and has migrations of its own to apply, short enough that a genuinely
+ * absent server does not keep a background task alive all session.
+ */
+export const SERVER_WAIT_TIMEOUT_MS = 60_000
+export const SERVER_WAIT_INTERVAL_MS = 1_000
+
+export interface WaitForAgentServerOptions {
+ isHealthy?: () => Promise
+ timeoutMs?: number
+ intervalMs?: number
+ now?: () => number
+ sleep?: (ms: number) => Promise
+}
+
+/**
+ * Health on the agent server itself, not `getHealthCheckUrl`.
+ *
+ * That helper resolves the proxy port, while the imports address the agent
+ * server on the mcp port. They are separate services that can become ready at
+ * different moments, so probing the proxy would answer a question nobody
+ * asked: a proxy up first would wave the imports through into the same race
+ * this exists to close, and a proxy that is down would defer imports the agent
+ * server was ready to accept.
+ */
+export async function agentServerHealthUrl(): Promise {
+ return `${await getAgentServerUrl()}/system/health`
+}
+
+async function defaultIsHealthy(): Promise {
+ try {
+ const response = await fetch(await agentServerHealthUrl())
+ return response.ok
+ } catch {
+ return false
+ }
+}
+
+const defaultSleep = (ms: number) =>
+ new Promise((resolve) => setTimeout(resolve, ms))
+
+/**
+ * Waits until the local server answers, or gives up.
+ *
+ * The one-time import runs when the background starts, which is the same moment
+ * the server starts. It used to fire straight into a socket nothing was
+ * listening on yet and fail, and because a failed run leaves its marker unset
+ * it simply lost the same race on the next launch, so the imported data never
+ * appeared at all.
+ *
+ * Polling health first is what makes the import wait for its dependency rather
+ * than race it. Returning false rather than throwing keeps the caller's
+ * decision explicit: leave the markers unset and try again next start.
+ */
+export async function waitForAgentServer({
+ isHealthy = defaultIsHealthy,
+ timeoutMs = SERVER_WAIT_TIMEOUT_MS,
+ intervalMs = SERVER_WAIT_INTERVAL_MS,
+ now = Date.now,
+ sleep = defaultSleep,
+}: WaitForAgentServerOptions = {}): Promise {
+ const deadline = now() + timeoutMs
+
+ while (true) {
+ // A probe that throws means not reachable, not a reason to abandon the
+ // wait. The default one already swallows fetch errors; catching here means
+ // any probe behaves the same way.
+ let healthy = false
+ try {
+ healthy = await isHealthy()
+ } catch {
+ healthy = false
+ }
+ if (healthy) return true
+ if (now() >= deadline) return false
+ await sleep(intervalMs)
+ }
+}
diff --git a/packages/browseros-agent/apps/app/modules/schedules/schedules.api.ts b/packages/browseros-agent/apps/app/modules/schedules/schedules.api.ts
new file mode 100644
index 0000000000..f93766a134
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/schedules/schedules.api.ts
@@ -0,0 +1,126 @@
+import type {
+ ScheduledJobRoutes,
+ ScheduledJobRunRoutes,
+} from '@browseros/server'
+import { hc } from 'hono/client'
+import type {
+ ScheduledJob,
+ ScheduledJobRun,
+} from '@/lib/schedules/scheduleTypes'
+import { resolveAgentServerUrlWithRetry } from '@/modules/browseros/agent-server-url.helpers'
+import {
+ type ScheduledJobRow,
+ type ScheduledJobRunRow,
+ toScheduledJob,
+ toScheduledJobPayload,
+ toScheduledJobRun,
+ toScheduledJobRunPayload,
+} from './schedules.helpers'
+import { bumpScheduleRevision } from './schedules.revision'
+
+async function jobsClient() {
+ const baseUrl = await resolveAgentServerUrlWithRetry()
+ return hc(`${baseUrl}/scheduled-jobs`)
+}
+
+async function runsClient() {
+ const baseUrl = await resolveAgentServerUrlWithRetry()
+ return hc(`${baseUrl}/scheduled-job-runs`)
+}
+
+export async function listScheduledJobs(): Promise {
+ const client = await jobsClient()
+ const response = await client.index.$get()
+ if (!response.ok) {
+ throw new Error(`Failed to load scheduled jobs (${response.status})`)
+ }
+ const { jobs } = await response.json()
+ return (jobs as ScheduledJobRow[]).map(toScheduledJob)
+}
+
+export async function putScheduledJob(job: ScheduledJob): Promise {
+ const client = await jobsClient()
+ const response = await client[':jobId'].$put({
+ param: { jobId: job.id },
+ json: toScheduledJobPayload(job),
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to save scheduled job (${response.status})`)
+ }
+ await bumpScheduleRevision()
+}
+
+export async function deleteScheduledJob(jobId: string): Promise {
+ const client = await jobsClient()
+ const response = await client[':jobId'].$delete({ param: { jobId } })
+ if (!response.ok && response.status !== 404) {
+ throw new Error(`Failed to delete scheduled job (${response.status})`)
+ }
+ await bumpScheduleRevision()
+}
+
+export async function listScheduledJobRuns(): Promise {
+ const client = await runsClient()
+ const response = await client.index.$get()
+ if (!response.ok) {
+ throw new Error(`Failed to load run history (${response.status})`)
+ }
+ const { runs } = await response.json()
+ return (runs as ScheduledJobRunRow[]).map(toScheduledJobRun)
+}
+
+export async function putScheduledJobRun(run: ScheduledJobRun): Promise {
+ const client = await runsClient()
+ const response = await client[':runId'].$put({
+ param: { runId: run.id },
+ json: toScheduledJobRunPayload(run),
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to save run (${response.status})`)
+ }
+ await bumpScheduleRevision()
+}
+
+/**
+ * Jobs for callers outside React, returning null when the server could not be
+ * reached. The alarm runner uses this to tell "no jobs are due" apart from
+ * "the list did not load", which otherwise look identical and would silently
+ * skip every scheduled task.
+ */
+export async function listScheduledJobsOrNull(): Promise<
+ ScheduledJob[] | null
+> {
+ try {
+ return await listScheduledJobs()
+ } catch {
+ return null
+ }
+}
+
+export async function listScheduledJobRunsOrNull(): Promise<
+ ScheduledJobRun[] | null
+> {
+ try {
+ return await listScheduledJobRuns()
+ } catch {
+ return null
+ }
+}
+
+/** One-time import of run history from extension storage. */
+export async function importScheduledJobRuns(
+ runs: ScheduledJobRun[],
+): Promise {
+ const client = await runsClient()
+ const response = await client.import.$post({
+ json: {
+ runs: runs.map((run) => ({
+ ...toScheduledJobRunPayload(run),
+ id: run.id,
+ })),
+ },
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to import run history (${response.status})`)
+ }
+}
diff --git a/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.test.ts b/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.test.ts
new file mode 100644
index 0000000000..0e066e3f13
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from 'bun:test'
+import type {
+ ScheduledJob,
+ ScheduledJobRun,
+} from '@/lib/schedules/scheduleTypes'
+import {
+ applyLastRunAt,
+ type ScheduledJobRow,
+ type ScheduledJobRunRow,
+ toScheduledJob,
+ toScheduledJobPayload,
+ toScheduledJobRun,
+ toScheduledJobRunPayload,
+} from './schedules.helpers'
+
+const ISO = '2026-01-02T03:04:05.000Z'
+const EPOCH = Date.parse(ISO)
+
+function jobRow(overrides: Partial = {}): ScheduledJobRow {
+ return {
+ id: 'job-1',
+ name: 'Morning digest',
+ query: 'summarise my inbox',
+ scheduleType: 'daily',
+ scheduleTime: '09:00',
+ scheduleInterval: null,
+ enabled: true,
+ providerId: null,
+ lastRunAt: null,
+ createdAt: EPOCH,
+ updatedAt: EPOCH,
+ ...overrides,
+ }
+}
+
+function runRow(
+ overrides: Partial = {},
+): ScheduledJobRunRow {
+ return {
+ id: 'run-1',
+ jobId: 'job-1',
+ status: 'completed',
+ startedAt: EPOCH,
+ completedAt: null,
+ result: null,
+ finalResult: null,
+ executionLog: null,
+ toolCalls: null,
+ error: null,
+ ...overrides,
+ }
+}
+
+describe('toScheduledJob', () => {
+ // The database holds epoch integers; the extension has always held ISO
+ // strings and every consumer parses them that way.
+ it('converts epoch times back to ISO strings', () => {
+ const job = toScheduledJob(jobRow({ lastRunAt: EPOCH }))
+
+ expect(job.createdAt).toBe(ISO)
+ expect(job.lastRunAt).toBe(ISO)
+ })
+
+ it('turns absent columns into undefined rather than null', () => {
+ const job = toScheduledJob(jobRow())
+
+ expect(job.lastRunAt).toBeUndefined()
+ expect(job.providerId).toBeUndefined()
+ expect(job.scheduleInterval).toBeUndefined()
+ })
+})
+
+describe('toScheduledJobPayload', () => {
+ it('leaves the id out of the body', () => {
+ const job = toScheduledJob(jobRow())
+ expect('id' in toScheduledJobPayload(job)).toBe(false)
+ })
+
+ it('converts ISO times to epoch', () => {
+ const job = toScheduledJob(jobRow({ lastRunAt: EPOCH }))
+ const payload = toScheduledJobPayload(job)
+
+ expect(payload.lastRunAt).toBe(EPOCH)
+ expect(payload.createdAt).toBe(EPOCH)
+ })
+
+ it('drops an unparseable time rather than sending NaN', () => {
+ const job = { ...toScheduledJob(jobRow()), createdAt: 'whenever' }
+ expect(toScheduledJobPayload(job as ScheduledJob).createdAt).toBeUndefined()
+ })
+})
+
+describe('toScheduledJobRun', () => {
+ it('keeps the tool call log intact', () => {
+ const toolCalls = [
+ {
+ id: 'call-1',
+ name: 'browser_navigate',
+ input: { url: 'https://example.com' },
+ timestamp: ISO,
+ },
+ ]
+
+ expect(toScheduledJobRun(runRow({ toolCalls })).toolCalls).toEqual(
+ toolCalls,
+ )
+ })
+
+ it('converts start and completion times to ISO', () => {
+ const run = toScheduledJobRun(runRow({ completedAt: EPOCH }))
+
+ expect(run.startedAt).toBe(ISO)
+ expect(run.completedAt).toBe(ISO)
+ })
+
+ it('leaves an unfinished run without a completion time', () => {
+ expect(toScheduledJobRun(runRow()).completedAt).toBeUndefined()
+ })
+})
+
+describe('toScheduledJobRunPayload', () => {
+ // startedAt is required by the server, so it cannot be dropped the way an
+ // optional field can when it fails to parse.
+ it('falls back to now when the start time is unusable', () => {
+ const run = { ...toScheduledJobRun(runRow()), startedAt: 'whenever' }
+ const payload = toScheduledJobRunPayload(run as ScheduledJobRun)
+
+ expect(typeof payload.startedAt).toBe('number')
+ expect(Number.isNaN(payload.startedAt)).toBe(false)
+ })
+
+ it('round-trips a run through both conversions', () => {
+ const run = toScheduledJobRun(runRow({ completedAt: EPOCH }))
+ const payload = toScheduledJobRunPayload(run)
+
+ expect(payload.startedAt).toBe(EPOCH)
+ expect(payload.completedAt).toBe(EPOCH)
+ expect(payload.jobId).toBe('job-1')
+ })
+})
+
+describe('applyLastRunAt', () => {
+ const AT = '2026-02-03T00:00:00.000Z'
+
+ // A run can last minutes, and the job is editable throughout. Recording that
+ // it finished must not carry back the copy read before it started.
+ it('applies to the current copy, not an earlier one', () => {
+ const before = toScheduledJob(jobRow({ name: 'Old name' }))
+ const current = [toScheduledJob(jobRow({ name: 'Renamed mid-run' }))]
+
+ const updated = applyLastRunAt(current, before.id, AT)
+
+ expect(updated).toMatchObject({ name: 'Renamed mid-run', lastRunAt: AT })
+ })
+
+ it('keeps an edit made to any field while the run was going', () => {
+ const current = [
+ toScheduledJob(
+ jobRow({ enabled: false, query: 'changed', providerId: 'other' }),
+ ),
+ ]
+
+ expect(applyLastRunAt(current, 'job-1', AT)).toMatchObject({
+ enabled: false,
+ query: 'changed',
+ providerId: 'other',
+ })
+ })
+
+ it('returns null when the job was deleted during the run', () => {
+ expect(applyLastRunAt([], 'job-1', AT)).toBeNull()
+ })
+})
diff --git a/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.ts b/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.ts
new file mode 100644
index 0000000000..9f799bf5a0
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.ts
@@ -0,0 +1,129 @@
+import type {
+ ScheduledJob,
+ ScheduledJobRun,
+ ToolCallExecution,
+} from '@/lib/schedules/scheduleTypes'
+
+/** A job row as the server returns it: absent values are null, times are epoch. */
+export interface ScheduledJobRow {
+ id: string
+ name: string
+ query: string
+ scheduleType: ScheduledJob['scheduleType']
+ scheduleTime: string | null
+ scheduleInterval: number | null
+ enabled: boolean
+ providerId: string | null
+ lastRunAt: number | null
+ createdAt: number
+ updatedAt: number
+}
+
+/** A run row as the server returns it. */
+export interface ScheduledJobRunRow {
+ id: string
+ jobId: string
+ status: ScheduledJobRun['status']
+ startedAt: number
+ completedAt: number | null
+ result: string | null
+ finalResult: string | null
+ executionLog: string | null
+ toolCalls: ToolCallExecution[] | null
+ error: string | null
+}
+
+function orUndefined(value: T | null): T | undefined {
+ return value ?? undefined
+}
+
+function toIso(value: number | null): string | undefined {
+ return value === null ? undefined : new Date(value).toISOString()
+}
+
+function toEpoch(value: string | undefined): number | undefined {
+ if (!value) return undefined
+ const parsed = Date.parse(value)
+ return Number.isNaN(parsed) ? undefined : parsed
+}
+
+export function toScheduledJob(row: ScheduledJobRow): ScheduledJob {
+ return {
+ id: row.id,
+ name: row.name,
+ query: row.query,
+ scheduleType: row.scheduleType,
+ scheduleTime: orUndefined(row.scheduleTime),
+ scheduleInterval: orUndefined(row.scheduleInterval),
+ enabled: row.enabled,
+ providerId: orUndefined(row.providerId),
+ lastRunAt: toIso(row.lastRunAt),
+ // Not nullable in the database, so these always convert.
+ createdAt: new Date(row.createdAt).toISOString(),
+ updatedAt: new Date(row.updatedAt).toISOString(),
+ }
+}
+
+/** The request body for a job write. `id` travels in the path instead. */
+export function toScheduledJobPayload(job: ScheduledJob) {
+ return {
+ name: job.name,
+ query: job.query,
+ scheduleType: job.scheduleType,
+ scheduleTime: job.scheduleTime,
+ scheduleInterval: job.scheduleInterval,
+ enabled: job.enabled,
+ providerId: job.providerId,
+ lastRunAt: toEpoch(job.lastRunAt),
+ createdAt: toEpoch(job.createdAt),
+ }
+}
+
+/**
+ * The job to write back when recording that a run finished.
+ *
+ * Takes the current list rather than a job captured earlier: a run can last
+ * minutes, and the user can rename, reschedule, disable or repoint the job
+ * while it goes. Writing an earlier copy back would revert all of it.
+ *
+ * Returns null when the job was deleted during the run, so finishing does not
+ * resurrect it.
+ */
+export function applyLastRunAt(
+ jobs: readonly ScheduledJob[],
+ jobId: string,
+ at: string,
+): ScheduledJob | null {
+ const job = jobs.find((each) => each.id === jobId)
+ return job ? { ...job, lastRunAt: at } : null
+}
+
+export function toScheduledJobRun(row: ScheduledJobRunRow): ScheduledJobRun {
+ return {
+ id: row.id,
+ jobId: row.jobId,
+ status: row.status,
+ startedAt: new Date(row.startedAt).toISOString(),
+ completedAt: toIso(row.completedAt),
+ result: orUndefined(row.result),
+ finalResult: orUndefined(row.finalResult),
+ executionLog: orUndefined(row.executionLog),
+ toolCalls: orUndefined(row.toolCalls),
+ error: orUndefined(row.error),
+ }
+}
+
+export function toScheduledJobRunPayload(run: ScheduledJobRun) {
+ return {
+ jobId: run.jobId,
+ status: run.status,
+ // Required by the server, and a run always carries the time it began.
+ startedAt: toEpoch(run.startedAt) ?? Date.now(),
+ completedAt: toEpoch(run.completedAt),
+ result: run.result,
+ finalResult: run.finalResult,
+ executionLog: run.executionLog,
+ toolCalls: run.toolCalls,
+ error: run.error,
+ }
+}
diff --git a/packages/browseros-agent/apps/app/modules/schedules/schedules.hooks.ts b/packages/browseros-agent/apps/app/modules/schedules/schedules.hooks.ts
new file mode 100644
index 0000000000..2449e0c187
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/schedules/schedules.hooks.ts
@@ -0,0 +1,158 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useEffect } from 'react'
+import { createQuery } from 'react-query-kit'
+import { sendScheduleMessage } from '@/lib/messaging/schedules/scheduleMessages'
+import { createAlarmFromJob } from '@/lib/schedules/createAlarmFromJob'
+import type {
+ ScheduledJob,
+ ScheduledJobRun,
+} from '@/lib/schedules/scheduleTypes'
+import {
+ deleteScheduledJob,
+ listScheduledJobRuns,
+ listScheduledJobs,
+ putScheduledJob,
+} from './schedules.api'
+import { watchScheduleRevision } from './schedules.revision'
+
+const getAlarmName = (jobId: string) => `scheduled-job-${jobId}`
+
+export const useScheduledJobsQuery = createQuery({
+ queryKey: ['scheduled-jobs'],
+ fetcher: listScheduledJobs,
+})
+
+export const useScheduledJobRunsQuery = createQuery({
+ queryKey: ['scheduled-job-runs'],
+ fetcher: listScheduledJobRuns,
+})
+
+/**
+ * Keeps this view current with writes made in the background.
+ *
+ * The alarm runner records runs from a different context, which extension
+ * storage used to surface through `watch`. The rows live on the server now, so
+ * the background bumps a revision instead and every mounted view refetches.
+ */
+function useScheduleRevision(): void {
+ const queryClient = useQueryClient()
+
+ useEffect(
+ () =>
+ watchScheduleRevision(() => {
+ queryClient.invalidateQueries({
+ queryKey: useScheduledJobsQuery.getKey(),
+ })
+ queryClient.invalidateQueries({
+ queryKey: useScheduledJobRunsQuery.getKey(),
+ })
+ }),
+ [queryClient],
+ )
+}
+
+export interface UseScheduledJobsReturn {
+ jobs: ScheduledJob[]
+ /** The server could not be reached, as opposed to reporting no jobs. */
+ isUnavailable: boolean
+ addJob: (
+ job: Omit,
+ ) => Promise
+ removeJob: (id: string) => Promise
+ editJob: (
+ id: string,
+ updates: Omit,
+ ) => Promise
+ toggleJob: (id: string, enabled: boolean) => Promise
+ runJob: (id: string) => Promise
+}
+
+export function useScheduledJobs(): UseScheduledJobsReturn {
+ const queryClient = useQueryClient()
+ const jobsQuery = useScheduledJobsQuery()
+ useScheduleRevision()
+
+ const jobs = jobsQuery.data ?? []
+ const invalidate = () =>
+ queryClient.invalidateQueries({ queryKey: useScheduledJobsQuery.getKey() })
+
+ const saveMutation = useMutation({
+ mutationFn: async (job: ScheduledJob) => {
+ await putScheduledJob(job)
+ // The alarm is the thing that actually makes a schedule fire, so it is
+ // rebuilt from the saved job rather than the requested one.
+ await chrome.alarms.clear(getAlarmName(job.id))
+ if (job.enabled) await createAlarmFromJob(job)
+ },
+ onSuccess: invalidate,
+ })
+
+ const removeMutation = useMutation({
+ mutationFn: async (id: string) => {
+ await chrome.alarms.clear(getAlarmName(id))
+ // Runs are removed with the job by the cascade on the row.
+ await deleteScheduledJob(id)
+ },
+ onSuccess: () => {
+ invalidate()
+ queryClient.invalidateQueries({
+ queryKey: useScheduledJobRunsQuery.getKey(),
+ })
+ },
+ })
+
+ const save = async (job: ScheduledJob) => {
+ await saveMutation.mutateAsync(job)
+ }
+
+ return {
+ jobs,
+ isUnavailable: jobsQuery.isError,
+ addJob: async (job) => {
+ const now = new Date().toISOString()
+ await save({
+ ...job,
+ id: crypto.randomUUID(),
+ createdAt: now,
+ updatedAt: now,
+ })
+ },
+ removeJob: async (id) => {
+ await removeMutation.mutateAsync(id)
+ },
+ editJob: async (id, updates) => {
+ const existing = jobs.find((job) => job.id === id)
+ if (!existing) return
+ await save({
+ ...updates,
+ id,
+ createdAt: existing.createdAt,
+ updatedAt: new Date().toISOString(),
+ })
+ },
+ toggleJob: async (id, enabled) => {
+ const existing = jobs.find((job) => job.id === id)
+ if (!existing) return
+ await save({ ...existing, enabled, updatedAt: new Date().toISOString() })
+ },
+ runJob: (id) => sendScheduleMessage('runScheduledJob', { jobId: id }),
+ }
+}
+
+export interface UseScheduledJobRunsReturn {
+ jobRuns: ScheduledJobRun[]
+ isUnavailable: boolean
+ cancelJobRun: (runId: string) => Promise
+}
+
+export function useScheduledJobRuns(): UseScheduledJobRunsReturn {
+ const runsQuery = useScheduledJobRunsQuery()
+ useScheduleRevision()
+
+ return {
+ jobRuns: runsQuery.data ?? [],
+ isUnavailable: runsQuery.isError,
+ cancelJobRun: (runId) =>
+ sendScheduleMessage('cancelScheduledJobRun', { runId }),
+ }
+}
diff --git a/packages/browseros-agent/apps/app/modules/schedules/schedules.revision.ts b/packages/browseros-agent/apps/app/modules/schedules/schedules.revision.ts
new file mode 100644
index 0000000000..a25a89a56e
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/schedules/schedules.revision.ts
@@ -0,0 +1,27 @@
+import { storage } from '@wxt-dev/storage'
+
+/**
+ * A change signal, not data.
+ *
+ * Scheduled runs are written by the background while the side panel and new
+ * tab display them, and the two are separate contexts. Extension storage used
+ * to carry both the data and the notification, so `watch` kept every surface
+ * current for free. The data now lives on the server, which nothing can watch,
+ * so this keeps the notification half: the background bumps it after a write
+ * and the query cache is invalidated wherever a view is mounted.
+ *
+ * The value is a timestamp rather than a counter so two contexts writing at
+ * once cannot lose a bump to a read-modify-write race.
+ */
+export const scheduleRevisionStorage = storage.defineItem(
+ 'local:schedule-revision',
+ { fallback: 0 },
+)
+
+export async function bumpScheduleRevision(): Promise {
+ await scheduleRevisionStorage.setValue(Date.now())
+}
+
+export function watchScheduleRevision(onChange: () => void): () => void {
+ return scheduleRevisionStorage.watch(() => onChange())
+}
diff --git a/packages/browseros-agent/apps/app/package.json b/packages/browseros-agent/apps/app/package.json
index 54e096ea73..aa77264c6c 100644
--- a/packages/browseros-agent/apps/app/package.json
+++ b/packages/browseros-agent/apps/app/package.json
@@ -77,6 +77,7 @@
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.86.0",
+ "react-query-kit": "^3.3.4",
"react-resizable-panels": "^4.12.3",
"react-router": "^7.18.3",
"shiki": "^3.23.0",
diff --git a/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx b/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx
index 567fc726a3..e42c56dc55 100644
--- a/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx
+++ b/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx
@@ -2,7 +2,9 @@ import { useQueryClient } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
+import { CloudSyncRetiredNotice } from '@/components/cloud-sync/CloudSyncRetiredNotice'
import { BrowserClawPromoBanner } from '@/components/promo/BrowserClawPromoBanner'
+import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
@@ -50,6 +52,7 @@ export const BrowserOsAiPane: FC = () => {
saveProvider,
setDefaultProvider,
deleteProvider,
+ isUnavailable: providersUnavailable,
} = useLlmProviders()
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const { sessionInfo } = useSessionInfo()
@@ -254,6 +257,8 @@ export const BrowserOsAiPane: FC = () => {
+
+
@@ -270,6 +275,15 @@ export const BrowserOsAiPane: FC = () => {
+ {providersUnavailable ? (
+
+
+ Your providers could not be loaded because the BrowserOS server is
+ not reachable. They are still saved on this device.
+
+
+ ) : null}
+
= ({
accessKeyId: watchedAccessKeyId,
secretAccessKey: watchedSecretAccessKey,
region: watchedRegion,
+ stored: initialValues,
})
const handleTest = async () => {
diff --git a/packages/browseros-agent/apps/app/screens/auth/LogoutPage.tsx b/packages/browseros-agent/apps/app/screens/auth/LogoutPage.tsx
index 1be3444742..84f7932ebb 100644
--- a/packages/browseros-agent/apps/app/screens/auth/LogoutPage.tsx
+++ b/packages/browseros-agent/apps/app/screens/auth/LogoutPage.tsx
@@ -12,8 +12,6 @@ import {
} from '@/components/ui/card'
import { resetIdentity } from '@/lib/analytics/identify'
import { signOut } from '@/lib/auth/auth-client'
-import { providersStorage } from '@/lib/llm-providers/storage'
-import { scheduledJobStorage } from '@/lib/schedules/scheduleStorage'
export const LogoutPage: FC = () => {
const navigate = useNavigate()
@@ -22,8 +20,6 @@ export const LogoutPage: FC = () => {
// biome-ignore lint/correctness/useExhaustiveDependencies: must run only once to ensure the logout process happens successfully
useEffect(() => {
const performLogout = async () => {
- await providersStorage.removeValue()
- await scheduledJobStorage.removeValue()
queryClient.clear()
await clear()
diff --git a/packages/browseros-agent/apps/app/screens/newtab/index/ScheduleResults.tsx b/packages/browseros-agent/apps/app/screens/newtab/index/ScheduleResults.tsx
index 28f71b3a27..ff26b60289 100644
--- a/packages/browseros-agent/apps/app/screens/newtab/index/ScheduleResults.tsx
+++ b/packages/browseros-agent/apps/app/screens/newtab/index/ScheduleResults.tsx
@@ -30,7 +30,7 @@ import { track } from '@/lib/metrics/track'
import {
useScheduledJobRuns,
useScheduledJobs,
-} from '@/lib/schedules/scheduleStorage'
+} from '@/modules/schedules/schedules.hooks'
import {
countRunningRuns,
type JobRunWithDetails,
diff --git a/packages/browseros-agent/apps/app/screens/scheduled-tasks/NewScheduledTaskDialog.tsx b/packages/browseros-agent/apps/app/screens/scheduled-tasks/NewScheduledTaskDialog.tsx
index fb198b4389..cc6da02c4c 100644
--- a/packages/browseros-agent/apps/app/screens/scheduled-tasks/NewScheduledTaskDialog.tsx
+++ b/packages/browseros-agent/apps/app/screens/scheduled-tasks/NewScheduledTaskDialog.tsx
@@ -36,18 +36,15 @@ import {
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { SCHEDULED_TASK_PROMPT_REFINED_EVENT } from '@/lib/constants/analyticsEvents'
-import {
- findChatProviderById,
- resolveChatProvider,
-} from '@/lib/llm-providers/provider-runtime'
+import { resolveChatProvider } from '@/lib/llm-providers/provider-runtime'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
-import {
- defaultProviderIdStorage,
- providersStorage,
-} from '@/lib/llm-providers/storage'
-import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
+import type { ProviderType } from '@/lib/llm-providers/types'
import { track } from '@/lib/metrics/track'
import { refinePrompt } from '@/lib/schedules/refine-prompt'
+import { useAcpAgents } from '@/modules/agents/agents.hooks'
+import { toProviderOption } from '@/modules/chat/chat-session-request'
+import { buildSidepanelChatTargets } from '@/modules/chat/sidepanel-chat-targets'
+import { useLlmProviders } from '@/modules/llm-providers/llm-providers.hooks'
import type { ScheduledJob } from './types'
const formSchema = z
@@ -99,8 +96,13 @@ export const NewScheduledTaskDialog: FC = ({
onSave,
}) => {
const isEditing = !!initialValues
- const [providers, setProviders] = useState([])
- const [defaultProviderId, setDefaultProviderId] = useState('')
+ const { providers, defaultProviderId } = useLlmProviders()
+ const { agents } = useAcpAgents()
+ // A scheduled job can target a coding agent as readily as an llm provider
+ // now that both are rows of one table, so the picker offers both. While they
+ // were separate tables the job's provider reference could only ever name an
+ // llm one, which is why this dialog listed those alone.
+ const chatTargets = buildSidepanelChatTargets({ providers, agents })
const form = useForm({
resolver: zodResolver(formSchema),
@@ -123,17 +125,6 @@ export const NewScheduledTaskDialog: FC = ({
const refineRequestIdRef = useRef(0)
const isProgrammaticChange = useRef(false)
- useEffect(() => {
- if (!open) return
- Promise.all([
- providersStorage.getValue(),
- defaultProviderIdStorage.getValue(),
- ]).then(([providerList, defId]) => {
- setProviders(providerList ?? [])
- setDefaultProviderId(defId ?? '')
- })
- }, [open])
-
useEffect(() => {
if (open) {
refineRequestIdRef.current++
@@ -164,9 +155,12 @@ export const NewScheduledTaskDialog: FC = ({
}, [open, initialValues, form])
const resolvedProvider: Provider | null = (() => {
- const found =
- findChatProviderById(providers, selectedProviderId) ??
- resolveChatProvider(providers, defaultProviderId)
+ const chosen = chatTargets.find(
+ (target) => target.id === selectedProviderId,
+ )
+ if (chosen) return toProviderOption(chosen)
+
+ const found = resolveChatProvider(providers, defaultProviderId)
if (found) {
return {
kind: 'llm' as const,
@@ -186,12 +180,7 @@ export const NewScheduledTaskDialog: FC = ({
return null
})()
- const providerOptions: Provider[] = providers.map((provider) => ({
- kind: 'llm',
- id: provider.id,
- name: provider.name,
- type: provider.type,
- }))
+ const providerOptions: Provider[] = chatTargets.map(toProviderOption)
// Replace textarea content via execCommand so the browser's native undo
// stack (Cmd+Z / Ctrl+Z) records the change. Falls back to form.setValue
@@ -248,7 +237,11 @@ export const NewScheduledTaskDialog: FC = ({
}
const onSubmit = (values: FormValues) => {
- const provider = findChatProviderById(providers, values.providerId)
+ // Either kind, so an id that names an agent is stored as readily as one
+ // that names a provider.
+ const provider = chatTargets.find(
+ (target) => target.id === values.providerId,
+ )
onSave({
name: values.name.trim(),
query: values.query.trim(),
diff --git a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskCard.tsx b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskCard.tsx
index 255b98b88a..cd7d7beaf3 100644
--- a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskCard.tsx
+++ b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskCard.tsx
@@ -12,7 +12,7 @@ import {
Trash2,
XCircle,
} from 'lucide-react'
-import { type FC, useEffect, useMemo, useState } from 'react'
+import { type FC, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Collapsible,
@@ -21,9 +21,8 @@ import {
} from '@/components/ui/collapsible'
import { Switch } from '@/components/ui/switch'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
-import { providersStorage } from '@/lib/llm-providers/storage'
-import type { ProviderType } from '@/lib/llm-providers/types'
-import { useScheduledJobRuns } from '@/lib/schedules/scheduleStorage'
+import { useProvidersQuery } from '@/modules/llm-providers/llm-providers.hooks'
+import { useScheduledJobRuns } from '@/modules/schedules/schedules.hooks'
import type { ScheduledJob, ScheduledJobRun } from './types'
dayjs.extend(relativeTime)
@@ -83,24 +82,11 @@ export const ScheduledTaskCard: FC = ({
onRetryRun,
}) => {
const [isOpen, setIsOpen] = useState(false)
- const [providerInfo, setProviderInfo] = useState<{
- name: string
- type: ProviderType
- } | null>(null)
-
const { jobRuns } = useScheduledJobRuns()
-
- // Load provider info for display
- useEffect(() => {
- if (!job.providerId) {
- setProviderInfo(null)
- return
- }
- providersStorage.getValue().then((providers) => {
- const match = providers?.find((p) => p.id === job.providerId)
- setProviderInfo(match ? { name: match.name, type: match.type } : null)
- })
- }, [job.providerId])
+ const { data: providers = [] } = useProvidersQuery()
+ const providerInfo = job.providerId
+ ? (providers.find((provider) => provider.id === job.providerId) ?? null)
+ : null
const runs = useMemo(
() =>
diff --git a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskResults.tsx b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskResults.tsx
index 43b5271c66..565cf232f4 100644
--- a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskResults.tsx
+++ b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskResults.tsx
@@ -12,14 +12,14 @@ import {
import type { FC } from 'react'
import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
-import {
- useScheduledJobRuns,
- useScheduledJobs,
-} from '@/lib/schedules/scheduleStorage'
import type {
ScheduledJob,
ScheduledJobRun,
} from '@/lib/schedules/scheduleTypes'
+import {
+ useScheduledJobRuns,
+ useScheduledJobs,
+} from '@/modules/schedules/schedules.hooks'
dayjs.extend(relativeTime)
diff --git a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTasksPage.tsx b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTasksPage.tsx
index 1ecfa97e93..6acb51a4e7 100644
--- a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTasksPage.tsx
+++ b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTasksPage.tsx
@@ -23,12 +23,11 @@ import {
SCHEDULED_TASK_VIEW_RESULTS_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
+import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
import {
- scheduledJobRunStorage,
useScheduledJobRuns,
useScheduledJobs,
-} from '@/lib/schedules/scheduleStorage'
-import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
+} from '@/modules/schedules/schedules.hooks'
import { NewScheduledTaskDialog } from './NewScheduledTaskDialog'
import { ScheduledTaskResults } from './ScheduledTaskResults'
import { ScheduledTasksHeader } from './ScheduledTasksHeader'
@@ -44,7 +43,10 @@ export const ScheduledTasksPage: FC = () => {
useScheduledJobs()
const { jobRuns, cancelJobRun } = useScheduledJobRuns()
- const [activeTab, setActiveTab] = useState(null)
+ const [selectedTab, setSelectedTab] = useState(null)
+ // Derived rather than set from an effect, so it settles when the history
+ // arrives instead of on whatever a single mount-time read happened to see.
+ const activeTab = selectedTab ?? (jobRuns.length > 0 ? 'results' : 'tasks')
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingJob, setEditingJob] = useState(null)
const [deleteJobId, setDeleteJobId] = useState(null)
@@ -115,7 +117,7 @@ export const ScheduledTasksPage: FC = () => {
})
} else {
await addJob(data)
- setActiveTab('tasks')
+ setSelectedTab('tasks')
track(NEW_SCHEDULED_TASK_CREATED_EVENT, {
scheduleType: data.scheduleType,
interval: data.scheduleInterval,
@@ -149,12 +151,6 @@ export const ScheduledTasksPage: FC = () => {
track(SCHEDULED_TASK_VIEW_RESULTS_EVENT)
}
- useEffect(() => {
- scheduledJobRunStorage.getValue().then((runs) => {
- setActiveTab(runs && runs.length > 0 ? 'results' : 'tasks')
- })
- }, [])
-
const jobToDelete = deleteJobId
? jobs.find((j) => j.id === deleteJobId)
: null
@@ -163,35 +159,33 @@ export const ScheduledTasksPage: FC = () => {
- {activeTab && (
-
-
- Results
- Scheduled Tasks
-
-
-
-
-
-
-
-
-
-
- )}
+
+
+ Results
+ Scheduled Tasks
+
+
+
+
+
+
+
+
+
+
+let cloudProps: { userId: string; localIds: ReadonlySet } | null = null
+
+mock.module('@/lib/auth/sessionStorage', () => ({
+ useSessionInfo: () => ({
+ sessionInfo: { user: sessionUserId ? { id: sessionUserId } : undefined },
+ }),
+}))
+mock.module('@/modules/conversations/conversations.hooks', () => ({
+ useServerConversations: () => ({ data: localRows }),
+ useDeleteServerConversation: () => ({ mutate: () => {} }),
+}))
+mock.module('./local/LocalChatHistory', () => ({
+ LocalChatHistory: () => createElement('div', { 'data-testid': 'local' }),
+}))
+mock.module('./cloud/CloudChatHistory', () => ({
+ CloudChatHistory: (props: {
+ userId: string
+ localIds: ReadonlySet
+ }) => {
+ cloudProps = props
+ return createElement('div', { 'data-testid': 'cloud' })
+ },
+}))
+
+const { ChatHistory } = (await import('./ChatHistory')) as { ChatHistory: FC }
+
+beforeEach(() => {
+ sessionUserId = undefined
+ localRows = []
+ cloudProps = null
+})
+
+function render() {
+ return renderToStaticMarkup(createElement(ChatHistory))
+}
+
+describe('ChatHistory', () => {
+ it('always shows the local list', () => {
+ expect(render()).toContain('data-testid="local"')
+ })
+
+ // Signed out there is no account to read, so the cloud section is absent
+ // rather than empty.
+ it('omits the cloud section when signed out', () => {
+ expect(render()).not.toContain('data-testid="cloud"')
+ })
+
+ // It used to be one or the other: a signed-in user saw only the cloud and
+ // could not see what their own machine was storing.
+ it('shows both lists when signed in', () => {
+ sessionUserId = 'user-1'
+ const html = render()
+ expect(html).toContain('data-testid="local"')
+ expect(html).toContain('data-testid="cloud"')
+ })
+
+ it('puts the local list first', () => {
+ sessionUserId = 'user-1'
+ const html = render()
+ expect(html.indexOf('data-testid="local"')).toBeLessThan(
+ html.indexOf('data-testid="cloud"'),
+ )
+ })
+
+ // One id space across the stores, so a conversation synced before sync was
+ // turned off would otherwise appear in both lists.
+ it('passes the local ids to the cloud section so it can deduplicate', () => {
+ sessionUserId = 'user-1'
+ localRows = [
+ { id: 'a', lastMessagedAt: 1, lastUserMessage: 'hi' },
+ { id: 'b', lastMessagedAt: 2, lastUserMessage: 'there' },
+ ]
+ render()
+ expect(cloudProps?.userId).toBe('user-1')
+ expect([...(cloudProps?.localIds ?? [])].sort()).toEqual(['a', 'b'])
+ })
+
+ // Two lists in one scroll area. Each list owning its own worked only while
+ // exactly one of them ever rendered.
+ it('renders a single scroll container for both lists', () => {
+ sessionUserId = 'user-1'
+ const html = render()
+ expect((html.match(/ = ({ userId }) => {
- const { conversationId: activeConversationId } = useChatSessionContext()
- const queryClient = useQueryClient()
-
- const { data: profileData } = useGraphqlQuery(GetProfileIdByUserIdDocument, {
- userId,
- })
- const profileId = profileData?.profileByUserId?.rowId
-
- const {
- data: graphqlData,
- isLoading: isLoadingConversations,
- isFetching,
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
- } = useGraphqlInfiniteQuery(
- GetConversationsForHistoryDocument,
- // biome-ignore lint/style/noNonNullAssertion: guarded by enabled
- (cursor) => ({ profileId: profileId!, after: cursor }),
- {
- enabled: !!profileId,
- initialPageParam: undefined,
- getNextPageParam: (lastPage) =>
- lastPage.conversations?.pageInfo.hasNextPage
- ? lastPage.conversations.pageInfo.endCursor
- : undefined,
- placeholderData: keepPreviousData,
- },
- )
-
- const deleteConversationMutation = useGraphqlMutation(
- DeleteConversationDocument,
- {
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: [
- getQueryKeyFromDocument(GetConversationsForHistoryDocument),
- ],
- })
- },
- },
- )
-
- const handleDelete = (id: string) => {
- deleteConversationMutation.mutate({ rowId: id })
- }
-
- const conversations = useMemo(() => {
- if (!graphqlData?.pages) return []
-
- return graphqlData.pages.flatMap((page) =>
- (page.conversations?.nodes ?? [])
- .filter((node): node is NonNullable => node !== null)
- .map((node) => {
- const messages = node.conversationMessages.nodes
- .filter((m): m is NonNullable => m !== null)
- .map((m) => m.message as UIMessage)
-
- const timestamp = node.lastMessagedAt.endsWith('Z')
- ? node.lastMessagedAt
- : `${node.lastMessagedAt}Z`
-
- return {
- id: node.rowId,
- lastMessagedAt: new Date(timestamp).getTime(),
- lastUserMessage: extractLastUserMessage(messages),
- }
- }),
- )
- }, [graphqlData])
-
- const groupedConversations = useMemo(
- () => groupConversations(conversations),
- [conversations],
- )
-
- if (!profileId || isLoadingConversations) {
- return (
-
-
-
- )
- }
-
- return (
-
- )
-}
-
+/**
+ * History is the union of what is on this machine and what is still in the
+ * account, with the local list first and always present.
+ *
+ * It used to be one or the other: signed in showed only the cloud, signed out
+ * showed only the local server. That meant a signed-in user could not see the
+ * conversations their own machine was storing.
+ */
export const ChatHistory: FC = () => {
const { sessionInfo } = useSessionInfo()
const userId = sessionInfo.user?.id
- const queryClient = useQueryClient()
- // Drain any pre-upgrade local:conversations to their new home.
- useLegacyConversationMigration()
- // On sign-in, promote logged-out (server) history to the cloud, then refresh
- // the cloud list so it appears. Stable callback keeps the promote effect from
- // re-running on every render.
- const refreshCloudHistory = useCallback(() => {
- queryClient.invalidateQueries({
- queryKey: [getQueryKeyFromDocument(GetConversationsForHistoryDocument)],
- })
- }, [queryClient])
- useSignInConversationPromote(refreshCloudHistory)
-
- if (userId) {
- return
- }
+ // Same query key as LocalChatHistory, so this shares its cache rather than
+ // fetching a second time. Only the ids are needed, to keep a conversation
+ // that exists in both places from being listed twice.
+ const { data: localConversations = [] } = useServerConversations()
+ const localIds = useMemo(
+ () => new Set(localConversations.map((conversation) => conversation.id)),
+ [localConversations],
+ )
- return
+ // One scroll area for both lists. Each list used to own its own, which
+ // worked while only ever one of them rendered.
+ return (
+
+
+ {userId ? : null}
+
+ )
}
diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/cloud/CloudChatHistory.tsx b/packages/browseros-agent/apps/app/screens/sidepanel/history/cloud/CloudChatHistory.tsx
new file mode 100644
index 0000000000..2f15c2723a
--- /dev/null
+++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/cloud/CloudChatHistory.tsx
@@ -0,0 +1,171 @@
+import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
+import type { UIMessage } from 'ai'
+import type { FC } from 'react'
+import { useEffect, useMemo } from 'react'
+import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
+import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
+import { useChatSessionContext } from '@/modules/chat/chat-session-context'
+import { useGraphqlInfiniteQuery } from '@/modules/graphql/graphql-infinite-query.hooks'
+import { useGraphqlMutation } from '@/modules/graphql/graphql-mutation.hooks'
+import { useGraphqlQuery } from '@/modules/graphql/graphql-query.hooks'
+import { ConversationList } from '../components/ConversationList'
+import type { HistoryConversation } from '../components/types'
+import { extractLastUserMessage, groupConversations } from '../components/utils'
+import {
+ DeleteConversationDocument,
+ GetConversationsForHistoryDocument,
+} from '../graphql/chatHistoryDocument'
+import {
+ excludeLocalConversations,
+ hasAnyConversation,
+ shouldAdvanceCloudPage,
+} from '../history-union.helpers'
+
+export interface CloudChatHistoryProps {
+ userId: string
+ /** Ids already on this machine, so the same chat is not listed twice. */
+ localIds: ReadonlySet
+}
+
+/**
+ * Conversations that were synced to the account before sync was turned off.
+ *
+ * Read only and clearly separated rather than merged into the local list: it
+ * is a legacy shelf that empties when the cloud is retired, and blending it
+ * into the local history would hide that. Interleaving the two by date would
+ * also mean paginating two sources against one scroll position, which this
+ * deliberately avoids.
+ */
+export const CloudChatHistory: FC = ({
+ userId,
+ localIds,
+}) => {
+ const { conversationId: activeConversationId } = useChatSessionContext()
+ const queryClient = useQueryClient()
+
+ const { data: profileData } = useGraphqlQuery(GetProfileIdByUserIdDocument, {
+ userId,
+ })
+ const profileId = profileData?.profileByUserId?.rowId
+
+ const {
+ data: graphqlData,
+ isLoading: isLoadingConversations,
+ isFetching,
+ hasNextPage,
+ isFetchingNextPage,
+ isFetchNextPageError,
+ fetchNextPage,
+ } = useGraphqlInfiniteQuery(
+ GetConversationsForHistoryDocument,
+ // biome-ignore lint/style/noNonNullAssertion: guarded by enabled
+ (cursor) => ({ profileId: profileId!, after: cursor }),
+ {
+ enabled: !!profileId,
+ initialPageParam: undefined,
+ getNextPageParam: (lastPage) =>
+ lastPage.conversations?.pageInfo.hasNextPage
+ ? lastPage.conversations.pageInfo.endCursor
+ : undefined,
+ placeholderData: keepPreviousData,
+ },
+ )
+
+ const deleteConversationMutation = useGraphqlMutation(
+ DeleteConversationDocument,
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: [
+ getQueryKeyFromDocument(GetConversationsForHistoryDocument),
+ ],
+ })
+ },
+ },
+ )
+
+ const handleDelete = (id: string) => {
+ deleteConversationMutation.mutate({ rowId: id })
+ }
+
+ const conversations = useMemo(() => {
+ if (!graphqlData?.pages) return []
+
+ return graphqlData.pages.flatMap((page) =>
+ (page.conversations?.nodes ?? [])
+ .filter((node): node is NonNullable => node !== null)
+ .map((node) => {
+ const messages = node.conversationMessages.nodes
+ .filter((m): m is NonNullable => m !== null)
+ .map((m) => m.message as UIMessage)
+
+ const timestamp = node.lastMessagedAt.endsWith('Z')
+ ? node.lastMessagedAt
+ : `${node.lastMessagedAt}Z`
+
+ return {
+ id: node.rowId,
+ lastMessagedAt: new Date(timestamp).getTime(),
+ lastUserMessage: extractLastUserMessage(messages),
+ }
+ }),
+ )
+ }, [graphqlData])
+
+ const groupedConversations = useMemo(
+ () =>
+ groupConversations(excludeLocalConversations(conversations, localIds)),
+ [conversations, localIds],
+ )
+ const hasVisibleConversations = hasAnyConversation(groupedConversations)
+
+ // Pagination is normally driven by a sentinel inside the rendered list, so a
+ // page that deduplicates away to nothing would stop it dead: the section
+ // renders null, the sentinel never mounts, and cloud-only conversations on
+ // later pages stay invisible. That is the ordinary case right after this
+ // ships, because the most recent conversations are the ones that exist in
+ // both stores and they sort onto the first page.
+ //
+ // Advancing here is the only way to reach past them; it cannot be lifted
+ // into an event handler because there is no interaction to hang it on, and
+ // it terminates when the pages run out or a page fails.
+ const advance = shouldAdvanceCloudPage({
+ hasVisibleConversations,
+ hasNextPage: Boolean(hasNextPage),
+ isFetchingNextPage,
+ isLoading: isLoadingConversations,
+ hasPageError: isFetchNextPageError,
+ })
+ useEffect(() => {
+ if (advance) fetchNextPage()
+ }, [advance, fetchNextPage])
+
+ // Nothing to announce until there is something here. The loading case is
+ // silent too: this section sits below the local list, so a spinner would
+ // shift content the user is already reading.
+ if (!profileId || isLoadingConversations) return null
+ if (!hasVisibleConversations) return null
+
+ return (
+
+
+
+ Saved to your account
+
+
+ From before cloud sync was turned off. Still readable here, and not
+ stored on this device.
+
+
+
+
+ )
+}
diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/components/ConversationList.tsx b/packages/browseros-agent/apps/app/screens/sidepanel/history/components/ConversationList.tsx
index 7d255f5fa5..a1483403ab 100644
--- a/packages/browseros-agent/apps/app/screens/sidepanel/history/components/ConversationList.tsx
+++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/components/ConversationList.tsx
@@ -13,6 +13,11 @@ export interface ConversationListProps {
isFetchingNextPage?: boolean
onLoadMore?: () => void
isRefreshing?: boolean
+ /**
+ * Shown when this list has nothing in it. History can render two lists now,
+ * so the wording has to say which store is empty.
+ */
+ emptyMessage?: string
}
export const ConversationList: FC = ({
@@ -23,6 +28,7 @@ export const ConversationList: FC = ({
isFetchingNextPage,
onLoadMore,
isRefreshing,
+ emptyMessage = 'No conversations yet',
}) => {
const loadMoreRef = useRef(null)
@@ -57,64 +63,60 @@ export const ConversationList: FC = ({
groupedConversations.older.length > 0
return (
-
-
- {isRefreshing && (
-
-
- Fetching latest conversations
-
- )}
- {!hasConversations ? (
-
-
-
- No conversations yet
-
-
- Start a new chat
-
-
- ) : (
- <>
-
-
-
-
+
+ {isRefreshing && (
+
+
+ Fetching latest conversations
+
+ )}
+ {!hasConversations ? (
+
+
+
{emptyMessage}
+
+ Start a new chat
+
+
+ ) : (
+ <>
+
+
+
+
- {hasNextPage && (
-
- {isFetchingNextPage && (
-
- )}
-
- )}
- >
- )}
-
-
+ {hasNextPage && (
+
+ {isFetchingNextPage && (
+
+ )}
+
+ )}
+ >
+ )}
+
)
}
diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.test.ts b/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.test.ts
new file mode 100644
index 0000000000..79caabf9fd
--- /dev/null
+++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from 'bun:test'
+import type {
+ GroupedConversations,
+ HistoryConversation,
+} from './components/types'
+import {
+ excludeLocalConversations,
+ hasAnyConversation,
+ shouldAdvanceCloudPage,
+} from './history-union.helpers'
+
+function conversation(id: string): HistoryConversation {
+ return { id, lastMessagedAt: 1, lastUserMessage: 'hi' }
+}
+
+function grouped(
+ overrides: Partial = {},
+): GroupedConversations {
+ return { today: [], thisWeek: [], thisMonth: [], older: [], ...overrides }
+}
+
+describe('excludeLocalConversations', () => {
+ // One id space across extension storage, the local server and the cloud, so
+ // a conversation synced before sync was turned off appears in both lists.
+ it('drops a cloud conversation that also exists locally', () => {
+ const result = excludeLocalConversations(
+ [conversation('a'), conversation('b')],
+ new Set(['a']),
+ )
+ expect(result.map((c) => c.id)).toEqual(['b'])
+ })
+
+ it('keeps everything when nothing is local', () => {
+ const result = excludeLocalConversations(
+ [conversation('a'), conversation('b')],
+ new Set(),
+ )
+ expect(result.map((c) => c.id)).toEqual(['a', 'b'])
+ })
+
+ it('returns nothing when every cloud conversation is already local', () => {
+ const result = excludeLocalConversations(
+ [conversation('a')],
+ new Set(['a', 'b']),
+ )
+ expect(result).toEqual([])
+ })
+
+ it('does not mutate the input', () => {
+ const cloud = [conversation('a')]
+ excludeLocalConversations(cloud, new Set(['a']))
+ expect(cloud).toHaveLength(1)
+ })
+})
+
+describe('hasAnyConversation', () => {
+ it('is false for an empty set', () => {
+ expect(hasAnyConversation(grouped())).toBe(false)
+ })
+
+ for (const bucket of ['today', 'thisWeek', 'thisMonth', 'older'] as const) {
+ it(`is true when only ${bucket} has one`, () => {
+ expect(
+ hasAnyConversation(grouped({ [bucket]: [conversation('a')] })),
+ ).toBe(true)
+ })
+ }
+})
+
+describe('shouldAdvanceCloudPage', () => {
+ const stalled = {
+ hasVisibleConversations: false,
+ hasNextPage: true,
+ isFetchingNextPage: false,
+ isLoading: false,
+ hasPageError: false,
+ }
+
+ // The page that stalls is the ordinary one right after this ships: the most
+ // recent conversations exist in both stores and sort onto the first page, so
+ // it deduplicates away to nothing and the sentinel never mounts to pull the
+ // cloud-only conversations behind it.
+ it('advances when a page deduplicates away to nothing', () => {
+ expect(shouldAdvanceCloudPage(stalled)).toBe(true)
+ })
+
+ it('stops once something is visible, leaving the sentinel to take over', () => {
+ expect(
+ shouldAdvanceCloudPage({ ...stalled, hasVisibleConversations: true }),
+ ).toBe(false)
+ })
+
+ it('terminates when the pages run out', () => {
+ expect(shouldAdvanceCloudPage({ ...stalled, hasNextPage: false })).toBe(
+ false,
+ )
+ })
+
+ // Without these the effect would queue a second fetch on every render while
+ // the first is still in flight.
+ it('does not stack a fetch on top of one in flight', () => {
+ expect(
+ shouldAdvanceCloudPage({ ...stalled, isFetchingNextPage: true }),
+ ).toBe(false)
+ })
+
+ it('waits for the first page before advancing', () => {
+ expect(shouldAdvanceCloudPage({ ...stalled, isLoading: true })).toBe(false)
+ })
+
+ // A rejected fetch leaves every other input exactly as it was before the
+ // fetch started, so without this the section would retry forever.
+ it('stops after a page fails instead of retrying it forever', () => {
+ expect(shouldAdvanceCloudPage({ ...stalled, hasPageError: true })).toBe(
+ false,
+ )
+ })
+})
diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.ts b/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.ts
new file mode 100644
index 0000000000..43cea0167b
--- /dev/null
+++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.ts
@@ -0,0 +1,55 @@
+import type {
+ GroupedConversations,
+ HistoryConversation,
+} from './components/types'
+
+/**
+ * Drops cloud conversations that already exist on this machine.
+ *
+ * The same conversation id is used by extension storage, the local server and
+ * the cloud, so a conversation that was synced before sync was turned off
+ * exists in both lists. Local wins: it is the copy that keeps working.
+ */
+export function excludeLocalConversations(
+ cloud: readonly HistoryConversation[],
+ localIds: ReadonlySet,
+): HistoryConversation[] {
+ return cloud.filter((conversation) => !localIds.has(conversation.id))
+}
+
+/** Whether a grouped set has anything in it, in any bucket. */
+export function hasAnyConversation(grouped: GroupedConversations): boolean {
+ return (
+ grouped.today.length > 0 ||
+ grouped.thisWeek.length > 0 ||
+ grouped.thisMonth.length > 0 ||
+ grouped.older.length > 0
+ )
+}
+
+/**
+ * Whether the cloud section should pull the next page on its own.
+ *
+ * Pagination is normally driven by a sentinel inside the rendered list, which
+ * never mounts while the section has nothing visible. A page whose entries are
+ * all present locally deduplicates away to nothing, so without this the
+ * section stalls on that page and never reaches the cloud-only conversations
+ * behind it.
+ *
+ * A failed page has to stop it. `hasNextPage` is derived from the last
+ * successful page, so a rejected fetch leaves it true while the in-flight flag
+ * clears, returning every input to its pre-fetch value. Advancing again on
+ * that state retries a failing request forever with no user interaction.
+ */
+export function shouldAdvanceCloudPage(state: {
+ hasVisibleConversations: boolean
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+ isLoading: boolean
+ hasPageError: boolean
+}): boolean {
+ if (state.hasVisibleConversations) return false
+ if (state.hasPageError) return false
+ if (state.isLoading || state.isFetchingNextPage) return false
+ return state.hasNextPage
+}
diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/local/LocalChatHistory.tsx b/packages/browseros-agent/apps/app/screens/sidepanel/history/local/LocalChatHistory.tsx
index fd73a71a34..2113149fe0 100644
--- a/packages/browseros-agent/apps/app/screens/sidepanel/history/local/LocalChatHistory.tsx
+++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/local/LocalChatHistory.tsx
@@ -32,6 +32,7 @@ export const LocalChatHistory: FC = () => {
groupedConversations={groupedConversations}
activeConversationId={activeConversationId}
onDelete={(id) => deleteConversation.mutate(id)}
+ emptyMessage="No conversations on this device yet"
/>
)
}
diff --git a/packages/browseros-agent/apps/server/.gitignore b/packages/browseros-agent/apps/server/.gitignore
index 296f38a0cb..23eaf28c0b 100644
--- a/packages/browseros-agent/apps/server/.gitignore
+++ b/packages/browseros-agent/apps/server/.gitignore
@@ -1,5 +1,8 @@
tmp-shot-*/
tmp-upload-*/
.devtools
-db/
-identity/
+# Runtime directories at the app root. Anchored with a leading slash: an
+# unanchored `db/` also matches src/lib/db/ and tests/lib/db/, which silently
+# swallowed new schema files and migrations until they were force-added.
+/db/
+/identity/
diff --git a/packages/browseros-agent/apps/server/src/api/routes/chat.ts b/packages/browseros-agent/apps/server/src/api/routes/chat.ts
index ae172a0061..96f559fb47 100644
--- a/packages/browseros-agent/apps/server/src/api/routes/chat.ts
+++ b/packages/browseros-agent/apps/server/src/api/routes/chat.ts
@@ -5,19 +5,27 @@ import { createUIMessageStreamResponse } from 'ai'
import { Hono } from 'hono'
import { SessionStore } from '../../agent/session-store'
import type { AcpAgentRuntime } from '../../lib/agents/acp/acp-agent-runtime'
+import { SERVER_CREDENTIALED_PROVIDERS } from '../../lib/clients/llm/config'
import { logger } from '../../lib/logger'
import { metrics } from '../../lib/metrics'
+import { dbProviderStore } from '../../lib/providers/provider-store'
import { Sentry } from '../../lib/sentry'
+import {
+ type ChatProviderLookup,
+ hydrateChatProvider,
+} from '../services/chat-provider-config'
import { ChatService } from '../services/chat-service'
import type { ConversationRuns } from '../services/conversation-runs'
import type { KlavisService } from '../services/klavis'
import type { BrowserMcpModule } from '../services/mcp/browser-mcp-module'
import type { ServerActivity } from '../services/server-activity'
import {
+ type AcpChatRequest,
type BrowserOsChatRequest,
type ChatRequest,
ChatRequestSchema,
type Env,
+ type HydratedChatRequest,
} from '../types'
import { isTrustedAppRequest } from '../utils/request-auth'
import { ConversationIdParamSchema } from '../utils/validation'
@@ -33,6 +41,20 @@ interface ChatRouteDeps {
activity?: ServerActivity
acpRuntime?: AcpAgentRuntime
conversationRuns?: ConversationRuns
+ /** Injectable so the hydration path is testable without a database. */
+ providerStore?: ChatProviderLookup
+}
+
+/**
+ * The one place a route reads provider credentials.
+ *
+ * The store's ordinary reads return a projection without them, so building an
+ * outbound model request has to ask for them by name. Anything else that
+ * reaches for this lookup is doing something it should not.
+ */
+const credentialedProviderLookup: ChatProviderLookup = {
+ get: (id) => dbProviderStore.getWithCredentials(id),
+ getDefault: () => dbProviderStore.getDefaultWithCredentials(),
}
// /chat deliberately exposes a plain Hono type. Its AI SDK stream payloads are
@@ -58,11 +80,44 @@ export function createChatRoutes(deps: ChatRouteDeps): Hono {
const app = new Hono()
app.post('/', zValidator('json', ChatRequestSchema), async (c) => {
- const request = c.req.valid('json')
- const browserRequest = isBrowserOsChatRequest(request) ? request : null
- if (!browserRequest && !isTrustedAppRequest(c)) {
+ const parsed = c.req.valid('json')
+ const parsedBrowserRequest = isBrowserOsChatRequest(parsed) ? parsed : null
+ if (!parsedBrowserRequest && !isTrustedAppRequest(c)) {
return c.json({ error: 'Forbidden' }, 403)
}
+
+ // The provider configuration is filled from the stored row here rather than
+ // shipped on every message. A client from before this change sends it all
+ // inline and simply finds nothing to overlay.
+ let request: HydratedChatRequest
+ if (parsedBrowserRequest) {
+ const hydrated = await hydrateChatProvider(
+ parsedBrowserRequest,
+ deps.providerStore ?? credentialedProviderLookup,
+ )
+ if (!hydrated.ok) return c.json({ error: hydrated.error }, 400)
+ // A browseros request is otherwise allowed without the app-origin check,
+ // on the reasoning that it carries its own credentials and so can only
+ // spend what the caller already held.
+ //
+ // Two things break that reasoning, and both have to be caught. Naming a
+ // stored provider has the server supply the key. So does naming one of
+ // the provider types the server credentials itself: the oauth three take
+ // a token from this machine's store and browseros takes the gateway
+ // credential, none of which the request carries. A caller that genuinely
+ // brought its own key is as unrestricted as it was before.
+ const usesServerCredentials =
+ hydrated.usedStoredProvider ||
+ SERVER_CREDENTIALED_PROVIDERS.has(hydrated.request.provider)
+ if (usesServerCredentials && !isTrustedAppRequest(c)) {
+ return c.json({ error: 'Forbidden' }, 403)
+ }
+ request = hydrated.request
+ } else {
+ request = parsed as AcpChatRequest
+ }
+ const browserRequest = isBrowserOsChatRequest(request) ? request : null
+
const provider = browserRequest?.provider ?? request.target.type
const model = browserRequest?.model
const baseUrl = browserRequest?.baseUrl
diff --git a/packages/browseros-agent/apps/server/src/api/routes/index.ts b/packages/browseros-agent/apps/server/src/api/routes/index.ts
index 5a31270cb2..22f115494e 100644
--- a/packages/browseros-agent/apps/server/src/api/routes/index.ts
+++ b/packages/browseros-agent/apps/server/src/api/routes/index.ts
@@ -26,7 +26,10 @@ import { createMcpRoutes } from './mcp'
import { createMcpManagerRoutes } from './mcp-manager'
import { createOAuthRoutes } from './oauth'
import { createProviderRoutes } from './provider'
+import { createProvidersRoutes } from './providers'
import { createRefinePromptRoutes } from './refine-prompt'
+import { createScheduledJobRunRoutes } from './scheduled-job-runs'
+import { createScheduledJobRoutes } from './scheduled-jobs'
import { createShutdownRoute } from './shutdown'
import { createStatusRoute } from './status'
@@ -134,9 +137,19 @@ export function createApiRoutes(deps: CreateApiRoutesDeps) {
.use('/acpx/probe/*', requireTrustedAppOrigin())
.use('/agents/*', requireTrustedAppOrigin())
.use('/conversations/*', requireTrustedAppOrigin())
+ // These carry provider credentials in the clear, so they need the
+ // localhost + extension-origin check. The blanket requireTrustedOrigin
+ // above only rejects a request that carries a disallowed Origin header;
+ // one with no Origin at all passes it.
+ .use('/providers/*', requireTrustedAppOrigin())
+ .use('/scheduled-jobs/*', requireTrustedAppOrigin())
+ .use('/scheduled-job-runs/*', requireTrustedAppOrigin())
.route('/acpx/probe', createAcpxProbeRoutes({ resourcesDir }))
.route('/agents', resolvedAgentRoutes)
.route('/conversations', createConversationRoutes())
+ .route('/providers', createProvidersRoutes())
+ .route('/scheduled-jobs', createScheduledJobRoutes())
+ .route('/scheduled-job-runs', createScheduledJobRunRoutes())
)
}
diff --git a/packages/browseros-agent/apps/server/src/api/routes/providers.ts b/packages/browseros-agent/apps/server/src/api/routes/providers.ts
new file mode 100644
index 0000000000..5ee1628e2f
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/api/routes/providers.ts
@@ -0,0 +1,105 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { zValidator } from '@hono/zod-validator'
+import { Hono } from 'hono'
+import { z } from 'zod'
+import {
+ dbProviderStore,
+ type ProviderStore,
+} from '../../lib/providers/provider-store'
+import type { Env } from '../types'
+
+const IdParamSchema = z.object({ providerId: z.string().min(1) })
+
+const SetDefaultSchema = z.object({ providerId: z.string().min(1) })
+
+/**
+ * Mirrors the extension's provider config. `id` comes from the client rather
+ * than the database so a provider keeps one identity across the extension, the
+ * migration and this table, which is what makes re-importing idempotent.
+ */
+const UpsertProviderSchema = z.object({
+ profileId: z.string().nullish(),
+ type: z.string().min(1),
+ name: z.string().min(1),
+ baseUrl: z.string().nullish(),
+ modelId: z.string().min(1),
+ supportsImages: z.boolean().optional(),
+ contextWindow: z.number(),
+ temperature: z.number().optional(),
+ apiKey: z.string().nullish(),
+ accessKeyId: z.string().nullish(),
+ secretAccessKey: z.string().nullish(),
+ sessionToken: z.string().nullish(),
+ resourceName: z.string().nullish(),
+ region: z.string().nullish(),
+ reasoningEffort: z.string().nullish(),
+ reasoningSummary: z.string().nullish(),
+ createdAt: z.number().optional(),
+})
+
+/**
+ * Bulk one-time import from extension storage.
+ *
+ * Insert-if-absent, not upsert: the app writes to this table directly, so a
+ * second run must fill gaps without replacing a provider edited since. Each id
+ * comes back in exactly one of the two lists so the caller can report what was
+ * already present.
+ */
+const ImportProvidersSchema = z.object({
+ providers: z.array(UpsertProviderSchema.extend({ id: z.string().min(1) })),
+})
+
+export function createProvidersRoutes(options: { store?: ProviderStore } = {}) {
+ const store = options.store ?? dbProviderStore
+
+ return (
+ new Hono()
+ .get('/', async (c) => c.json({ providers: await store.list() }))
+ // The one selected provider, of any kind. Kept ahead of /:providerId so
+ // the literal path is not read as an id.
+ .get('/default', async (c) =>
+ c.json({ provider: await store.getDefault() }),
+ )
+ .put('/default', zValidator('json', SetDefaultSchema), async (c) => {
+ const updated = await store.setDefault(c.req.valid('json').providerId)
+ if (!updated) return c.json({ error: 'Unknown provider' }, 404)
+ return c.json({ provider: await store.getDefault() })
+ })
+ .post('/import', zValidator('json', ImportProvidersSchema), async (c) => {
+ const imported: string[] = []
+ const skipped: string[] = []
+ for (const provider of c.req.valid('json').providers) {
+ const saved = await store.insertIfAbsent(provider)
+ ;(saved ? imported : skipped).push(provider.id)
+ }
+ return c.json({ imported, skipped })
+ })
+ .get('/:providerId', zValidator('param', IdParamSchema), async (c) => {
+ const provider = await store.get(c.req.valid('param').providerId)
+ if (!provider) return c.json({ error: 'Unknown provider' }, 404)
+ return c.json({ provider })
+ })
+ .put(
+ '/:providerId',
+ zValidator('param', IdParamSchema),
+ zValidator('json', UpsertProviderSchema),
+ async (c) => {
+ const provider = await store.upsert({
+ ...c.req.valid('json'),
+ id: c.req.valid('param').providerId,
+ })
+ return c.json({ provider })
+ },
+ )
+ .delete('/:providerId', zValidator('param', IdParamSchema), async (c) => {
+ const deleted = await store.remove(c.req.valid('param').providerId)
+ if (!deleted) return c.json({ error: 'Unknown provider' }, 404)
+ return c.json({ success: true })
+ })
+ )
+}
diff --git a/packages/browseros-agent/apps/server/src/api/routes/scheduled-job-runs.ts b/packages/browseros-agent/apps/server/src/api/routes/scheduled-job-runs.ts
new file mode 100644
index 0000000000..60f7d3c090
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/api/routes/scheduled-job-runs.ts
@@ -0,0 +1,93 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { zValidator } from '@hono/zod-validator'
+import { Hono } from 'hono'
+import { z } from 'zod'
+import {
+ dbScheduledJobRunStore,
+ type ScheduledJobRunStore,
+} from '../../lib/schedules/run-store'
+import type { Env } from '../types'
+
+const IdParamSchema = z.object({ runId: z.string().min(1) })
+
+const ToolCallSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ input: z.unknown(),
+ output: z.unknown().optional(),
+ error: z.string().optional(),
+ timestamp: z.string(),
+})
+
+/**
+ * Timestamps arrive as epoch numbers. The extension holds ISO strings, so the
+ * conversion belongs on its side of this boundary, keeping the database
+ * consistent with the other tables here.
+ */
+const UpsertRunSchema = z.object({
+ profileId: z.string().nullish(),
+ jobId: z.string().min(1),
+ status: z.enum(['running', 'completed', 'failed']),
+ startedAt: z.number(),
+ completedAt: z.number().nullish(),
+ result: z.string().nullish(),
+ finalResult: z.string().nullish(),
+ executionLog: z.string().nullish(),
+ toolCalls: z.array(ToolCallSchema).nullish(),
+ error: z.string().nullish(),
+ createdAt: z.number().optional(),
+})
+
+/** Bulk one-time import. Insert-if-absent, for the reason on the provider route. */
+const ImportRunsSchema = z.object({
+ runs: z.array(UpsertRunSchema.extend({ id: z.string().min(1) })),
+})
+
+export function createScheduledJobRunRoutes(
+ options: { store?: ScheduledJobRunStore } = {},
+) {
+ const store = options.store ?? dbScheduledJobRunStore
+
+ return new Hono()
+ .get('/', async (c) => c.json({ runs: await store.list() }))
+ .post('/import', zValidator('json', ImportRunsSchema), async (c) => {
+ const imported: string[] = []
+ const skipped: string[] = []
+ for (const run of c.req.valid('json').runs) {
+ const saved = await store.insertIfAbsent(run)
+ ;(saved ? imported : skipped).push(run.id)
+ }
+ return c.json({ imported, skipped })
+ })
+ .get('/:runId', zValidator('param', IdParamSchema), async (c) => {
+ const run = await store.get(c.req.valid('param').runId)
+ if (!run) return c.json({ error: 'Unknown run' }, 404)
+ return c.json({ run })
+ })
+ .put(
+ '/:runId',
+ zValidator('param', IdParamSchema),
+ zValidator('json', UpsertRunSchema),
+ async (c) => {
+ const run = await store.upsert({
+ ...c.req.valid('json'),
+ id: c.req.valid('param').runId,
+ })
+ // Every write, not just the first: a run is written twice, when it
+ // starts and when it finishes, and pruning is bounded and idempotent.
+ // The import path deliberately does not prune, so it stays additive.
+ await store.prune(run.jobId)
+ return c.json({ run })
+ },
+ )
+ .delete('/:runId', zValidator('param', IdParamSchema), async (c) => {
+ const deleted = await store.remove(c.req.valid('param').runId)
+ if (!deleted) return c.json({ error: 'Unknown run' }, 404)
+ return c.json({ success: true })
+ })
+}
diff --git a/packages/browseros-agent/apps/server/src/api/routes/scheduled-jobs.ts b/packages/browseros-agent/apps/server/src/api/routes/scheduled-jobs.ts
new file mode 100644
index 0000000000..b227a94f4b
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/api/routes/scheduled-jobs.ts
@@ -0,0 +1,79 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { zValidator } from '@hono/zod-validator'
+import { Hono } from 'hono'
+import { z } from 'zod'
+import {
+ dbScheduledJobStore,
+ type ScheduledJobStore,
+} from '../../lib/schedules/schedule-store'
+import type { Env } from '../types'
+
+const IdParamSchema = z.object({ jobId: z.string().min(1) })
+
+/**
+ * Timestamps arrive as epoch numbers. The extension holds ISO strings today,
+ * so the conversion belongs on its side of this boundary, keeping the database
+ * consistent with the other tables here.
+ */
+const UpsertJobSchema = z.object({
+ profileId: z.string().nullish(),
+ name: z.string().min(1),
+ query: z.string().min(1),
+ scheduleType: z.enum(['daily', 'hourly', 'minutes']),
+ scheduleTime: z.string().nullish(),
+ scheduleInterval: z.number().nullish(),
+ enabled: z.boolean().optional(),
+ providerId: z.string().nullish(),
+ lastRunAt: z.number().nullish(),
+ createdAt: z.number().optional(),
+})
+
+/** Bulk one-time import. Insert-if-absent, for the reason on the provider route. */
+const ImportJobsSchema = z.object({
+ jobs: z.array(UpsertJobSchema.extend({ id: z.string().min(1) })),
+})
+
+export function createScheduledJobRoutes(
+ options: { store?: ScheduledJobStore } = {},
+) {
+ const store = options.store ?? dbScheduledJobStore
+
+ return new Hono()
+ .get('/', async (c) => c.json({ jobs: await store.list() }))
+ .post('/import', zValidator('json', ImportJobsSchema), async (c) => {
+ const imported: string[] = []
+ const skipped: string[] = []
+ for (const job of c.req.valid('json').jobs) {
+ const saved = await store.insertIfAbsent(job)
+ ;(saved ? imported : skipped).push(job.id)
+ }
+ return c.json({ imported, skipped })
+ })
+ .get('/:jobId', zValidator('param', IdParamSchema), async (c) => {
+ const job = await store.get(c.req.valid('param').jobId)
+ if (!job) return c.json({ error: 'Unknown scheduled job' }, 404)
+ return c.json({ job })
+ })
+ .put(
+ '/:jobId',
+ zValidator('param', IdParamSchema),
+ zValidator('json', UpsertJobSchema),
+ async (c) => {
+ const job = await store.upsert({
+ ...c.req.valid('json'),
+ id: c.req.valid('param').jobId,
+ })
+ return c.json({ job })
+ },
+ )
+ .delete('/:jobId', zValidator('param', IdParamSchema), async (c) => {
+ const deleted = await store.remove(c.req.valid('param').jobId)
+ if (!deleted) return c.json({ error: 'Unknown scheduled job' }, 404)
+ return c.json({ success: true })
+ })
+}
diff --git a/packages/browseros-agent/apps/server/src/api/services/chat-provider-config.ts b/packages/browseros-agent/apps/server/src/api/services/chat-provider-config.ts
new file mode 100644
index 0000000000..78323932ce
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/api/services/chat-provider-config.ts
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { LLMConfig } from '@browseros/shared/schemas/llm'
+import type { ProviderRow } from '../../lib/db/schema'
+import type {
+ BrowserOsChatRequest,
+ HydratedBrowserOsChatRequest,
+} from '../types'
+
+export interface ChatProviderLookup {
+ get(id: string): Promise
+ getDefault(): Promise
+}
+
+export type HydrationResult =
+ | {
+ ok: true
+ request: HydratedBrowserOsChatRequest
+ /**
+ * Whether the configuration came from a stored row rather than from the
+ * request. The caller has to gate on this: supplying the user's
+ * credentials is a privilege the request itself does not carry, where
+ * sending its own is not.
+ */
+ usedStoredProvider: boolean
+ }
+ | { ok: false; error: string }
+
+function toLlmConfig(
+ row: ProviderRow,
+): Partial & { model?: string } {
+ return {
+ provider: row.type as LLMConfig['provider'],
+ providerId: row.id,
+ model: row.modelId ?? undefined,
+ apiKey: row.apiKey ?? undefined,
+ baseUrl: row.baseUrl ?? undefined,
+ resourceName: row.resourceName ?? undefined,
+ region: row.region ?? undefined,
+ accessKeyId: row.accessKeyId ?? undefined,
+ secretAccessKey: row.secretAccessKey ?? undefined,
+ sessionToken: row.sessionToken ?? undefined,
+ reasoningEffort: row.reasoningEffort as LLMConfig['reasoningEffort'],
+ reasoningSummary: row.reasoningSummary as LLMConfig['reasoningSummary'],
+ }
+}
+
+/**
+ * Fills a chat request's provider configuration from the stored row.
+ *
+ * The server owns the provider list and which one is selected, so a client only
+ * has to name an id, and with none given the selected provider is used. The
+ * row wins over anything sent inline, because it is the source of truth and a
+ * client may be holding a copy from before an edit.
+ *
+ * A request that names nothing the server knows keeps whatever it sent, which
+ * is how a client from before this change still works: it ships the whole
+ * configuration and never relies on the lookup.
+ */
+export async function hydrateChatProvider(
+ request: BrowserOsChatRequest,
+ store: ChatProviderLookup,
+): Promise {
+ const namedId = request.target.providerId
+ const row = namedId ? await store.get(namedId) : await store.getDefault()
+
+ if (row && row.kind !== 'llm') {
+ // Reached by naming an acp agent on the browseros path, or by having one
+ // selected while the client sends no target. Falling through would run the
+ // conversation on some other provider entirely.
+ return {
+ ok: false,
+ error: `Provider ${row.id} is a coding agent and cannot serve a browseros chat request`,
+ }
+ }
+
+ const hydrated = row
+ ? { ...request, ...toLlmConfig(row) }
+ : { ...request, providerId: namedId }
+
+ if (!hydrated.provider) {
+ return {
+ ok: false,
+ error: namedId
+ ? `Unknown provider ${namedId}`
+ : 'No provider given and none is selected',
+ }
+ }
+
+ const providerId = row?.id ?? namedId
+ if (!providerId) {
+ return { ok: false, error: 'No provider given and none is selected' }
+ }
+
+ return {
+ ok: true,
+ usedStoredProvider: row !== null,
+ request: {
+ ...hydrated,
+ provider: hydrated.provider,
+ contextWindowSize: row?.contextWindow ?? request.contextWindowSize,
+ supportsImages: row ? row.supportsImages : request.supportsImages,
+ target: { type: 'browseros', providerId },
+ },
+ }
+}
diff --git a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts
index e447294605..61feb1e90d 100644
--- a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts
+++ b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts
@@ -50,8 +50,9 @@ import type { ServerActivity } from '../services/server-activity'
import type {
AcpChatRequest,
BrowserContext,
- BrowserOsChatRequest,
ChatRequest,
+ HydratedBrowserOsChatRequest,
+ HydratedChatRequest,
} from '../types'
import { resolveBrowserContextPageIds } from '../utils/resolve-browser-context-page-ids'
import {
@@ -108,7 +109,7 @@ export class ChatService {
}
async processMessage(
- request: ChatRequest,
+ request: HydratedChatRequest,
_requestAbortSignal: AbortSignal,
): Promise {
try {
@@ -134,7 +135,7 @@ export class ChatService {
}
return await this.processBrowserOsMessage(
- request as BrowserOsChatRequest,
+ request as HydratedBrowserOsChatRequest,
abortSignal,
updateMessages,
)
@@ -179,7 +180,7 @@ export class ChatService {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: session changes and message persistence must share one ordered transaction
private async processBrowserOsMessage(
- request: BrowserOsChatRequest,
+ request: HydratedBrowserOsChatRequest,
abortSignal: AbortSignal,
updateMessages: (messages: UIMessage[]) => boolean,
): Promise> {
diff --git a/packages/browseros-agent/apps/server/src/api/types.ts b/packages/browseros-agent/apps/server/src/api/types.ts
index 4083387868..b63de54795 100644
--- a/packages/browseros-agent/apps/server/src/api/types.ts
+++ b/packages/browseros-agent/apps/server/src/api/types.ts
@@ -78,15 +78,33 @@ const ChatInputSchema = z.object({
.optional(),
})
-const BrowserOsChatRequestSchema = AgentLLMConfigSchema.merge(ChatInputSchema)
+/**
+ * The provider half of a chat request, now optional.
+ *
+ * The server holds the provider list and which one is selected, so a client
+ * only has to name an id, and need not even do that: with nothing given the
+ * selected provider is used. Every field stays accepted because the extension
+ * updates independently of the browser binary, so a shipped build can be
+ * running a client that still sends the whole configuration inline. The server
+ * can stop requiring these; it cannot stop accepting them.
+ */
+const OptionalAgentLLMConfigSchema = LLMConfigSchema.partial().extend({
+ model: z.string().min(1).optional(),
+ upstreamProvider: z.string().optional(),
+})
+
+const BrowserOsChatRequestSchema = OptionalAgentLLMConfigSchema.merge(
+ ChatInputSchema,
+)
.extend({
- target: BrowserOsAgentTargetSchema.optional(),
+ target: BrowserOsAgentTargetSchema.partial({ providerId: true }).optional(),
})
.transform((request) => ({
...request,
- target: request.target ?? {
+ target: {
type: 'browseros' as const,
- providerId: request.providerId || request.provider,
+ providerId:
+ request.target?.providerId || request.providerId || request.provider,
},
}))
@@ -101,6 +119,21 @@ export const ChatRequestSchema = z.union([
export type AcpChatRequest = z.infer
export type BrowserOsChatRequest = z.infer
+
+/**
+ * A browseros request after its provider has been filled from the stored row.
+ *
+ * The wire shape leaves the provider optional so a client can send an id alone,
+ * or nothing at all. Everything past the route boundary needs it resolved, and
+ * this type is what says so rather than an assertion at the call site.
+ */
+export type HydratedBrowserOsChatRequest = BrowserOsChatRequest & {
+ provider: NonNullable
+ target: { type: 'browseros'; providerId: string }
+}
+
+/** What the chat service works on: every provider already resolved. */
+export type HydratedChatRequest = AcpChatRequest | HydratedBrowserOsChatRequest
export type ChatRequest = z.infer
export type Env = {
diff --git a/packages/browseros-agent/apps/server/src/lib/agents/storage/acp-agent-store.ts b/packages/browseros-agent/apps/server/src/lib/agents/storage/acp-agent-store.ts
index 508dcaa26f..5d29d21311 100644
--- a/packages/browseros-agent/apps/server/src/lib/agents/storage/acp-agent-store.ts
+++ b/packages/browseros-agent/apps/server/src/lib/agents/storage/acp-agent-store.ts
@@ -1,8 +1,8 @@
import { randomUUID } from 'node:crypto'
import { CustomAcpAgentConfigSchema } from '@browseros/shared/schemas/agent'
-import { desc, eq } from 'drizzle-orm'
+import { and, desc, eq } from 'drizzle-orm'
import { type BrowserOsDatabase, getDb } from '../../db'
-import { type AcpAgentRow, acpAgents } from '../../db/schema'
+import { type ProviderRow, providers } from '../../db/schema'
import { logger } from '../../logger'
import type {
AcpAgentDefinition,
@@ -38,6 +38,17 @@ export interface AcpAgentStore {
delete(id: string): Promise
}
+/**
+ * ACP agents are rows in the unified providers table, distinguished by kind.
+ *
+ * The store keeps its own shape rather than folding into the provider store:
+ * agents are created with a generated id and updated field by field, where
+ * providers are upserted under an id the client already holds. Both are
+ * legitimate ways to reach the same table, and the shipped /agents contract
+ * depends on this one.
+ */
+const isAgent = eq(providers.kind, 'acp')
+
export class DbAcpAgentStore implements AcpAgentStore {
private readonly db: BrowserOsDatabase
private writeQueue: Promise = Promise.resolve()
@@ -49,23 +60,30 @@ export class DbAcpAgentStore implements AcpAgentStore {
async list(): Promise {
return this.db
.select()
- .from(acpAgents)
- .orderBy(desc(acpAgents.updatedAt))
+ .from(providers)
+ .where(isAgent)
+ .orderBy(desc(providers.updatedAt))
.all()
.map(toAcpAgentDefinition)
}
async get(id: string): Promise {
const row =
- this.db.select().from(acpAgents).where(eq(acpAgents.id, id)).get() ?? null
+ this.db
+ .select()
+ .from(providers)
+ .where(and(isAgent, eq(providers.id, id)))
+ .get() ?? null
return row ? toAcpAgentDefinition(row) : null
}
async create(input: CreateAcpAgentInput): Promise {
return this.withWriteLock(async () => {
const now = Date.now()
- const row: AcpAgentRow = {
+ const row = {
id: randomUUID(),
+ kind: 'acp' as const,
+ profileId: null,
name: input.name.trim(),
type: input.type,
modelId: optionalText(input.modelId),
@@ -77,8 +95,11 @@ export class DbAcpAgentStore implements AcpAgentStore {
createdAt: now,
updatedAt: now,
}
- this.db.insert(acpAgents).values(row).run()
- const agent = toAcpAgentDefinition(row)
+ // returning(), not the object built above: the unified table fills the
+ // columns only LLM providers use, so the row that lands is wider than
+ // what was inserted.
+ const saved = this.db.insert(providers).values(row).returning().get()
+ const agent = toAcpAgentDefinition(saved)
logger.info('ACP agent created', {
agentId: agent.id,
type: agent.type,
@@ -93,11 +114,14 @@ export class DbAcpAgentStore implements AcpAgentStore {
): Promise {
return this.withWriteLock(async () => {
const existing =
- this.db.select().from(acpAgents).where(eq(acpAgents.id, id)).get() ??
- null
+ this.db
+ .select()
+ .from(providers)
+ .where(and(isAgent, eq(providers.id, id)))
+ .get() ?? null
if (!existing) return null
- const next: AcpAgentRow = {
+ const next: ProviderRow = {
...existing,
name: input.name === undefined ? existing.name : input.name.trim(),
modelId:
@@ -118,7 +142,11 @@ export class DbAcpAgentStore implements AcpAgentStore {
: JSON.stringify(input.customConfig),
updatedAt: Date.now(),
}
- this.db.update(acpAgents).set(next).where(eq(acpAgents.id, id)).run()
+ this.db
+ .update(providers)
+ .set(next)
+ .where(and(isAgent, eq(providers.id, id)))
+ .run()
logger.info('ACP agent updated', { agentId: id })
return toAcpAgentDefinition(next)
})
@@ -127,7 +155,10 @@ export class DbAcpAgentStore implements AcpAgentStore {
async delete(id: string): Promise {
return this.withWriteLock(async () => {
if (!(await this.get(id))) return false
- this.db.delete(acpAgents).where(eq(acpAgents.id, id)).run()
+ this.db
+ .delete(providers)
+ .where(and(isAgent, eq(providers.id, id)))
+ .run()
logger.info('ACP agent deleted', { agentId: id })
return true
})
@@ -150,11 +181,11 @@ export function deriveAcpSessionKey(
return `acp:${agentId}:${conversationId}`
}
-function toAcpAgentDefinition(row: AcpAgentRow): AcpAgentDefinition {
+function toAcpAgentDefinition(row: ProviderRow): AcpAgentDefinition {
return {
id: row.id,
name: row.name,
- type: row.type,
+ type: row.type as AcpAgentType,
modelId: row.modelId ?? undefined,
reasoningEffort: row.reasoningEffort ?? undefined,
workingDirectory: row.workingDirectory ?? undefined,
diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts
index 73ea64e71f..9b62d57892 100644
--- a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts
+++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts
@@ -19,6 +19,22 @@ import type { ResolvedLLMConfig } from './types'
const CHATGPT_PROVIDER_DISPLAY_NAME = 'ChatGPT'
+/**
+ * Provider types whose credentials come from the server, not the request.
+ *
+ * The OAuth three take a token from this machine's oauth store and browseros
+ * takes the gateway credential, so a request naming one of these spends
+ * something the caller never had to hold. Callers that gate on trust need to
+ * know that, and this set has to mirror the branches below exactly, which is
+ * why it lives beside them.
+ */
+export const SERVER_CREDENTIALED_PROVIDERS: ReadonlySet = new Set([
+ LLM_PROVIDERS.CHATGPT_PRO,
+ LLM_PROVIDERS.GITHUB_COPILOT,
+ LLM_PROVIDERS.QWEN_CODE,
+ LLM_PROVIDERS.BROWSEROS,
+])
+
export async function resolveLLMConfig(
config: LLMConfig,
browserosId?: string,
diff --git a/packages/browseros-agent/apps/server/src/lib/db/client.ts b/packages/browseros-agent/apps/server/src/lib/db/client.ts
index 261aac90bb..8495a1055d 100644
--- a/packages/browseros-agent/apps/server/src/lib/db/client.ts
+++ b/packages/browseros-agent/apps/server/src/lib/db/client.ts
@@ -51,9 +51,12 @@ export function openBrowserOsDatabase(options: OpenDbOptions): DbHandle {
if (migrationsDir) {
migrate(db, { migrationsFolder: migrationsDir })
} else {
- logger.warn('Drizzle migrations unavailable; bootstrapping current schema', {
- dbPath: options.dbPath,
- })
+ logger.warn(
+ 'Drizzle migrations unavailable; bootstrapping current schema',
+ {
+ dbPath: options.dbPath,
+ },
+ )
bootstrapCurrentSchema(sqlite)
}
}
@@ -97,7 +100,9 @@ export function resolveMigrationsDir(
/** Accepts only migration folders Drizzle can read without filesystem errors. */
function hasCompleteMigrationSet(migrationsDir: string): boolean {
- const journal = readDrizzleJournal(join(migrationsDir, 'meta', '_journal.json'))
+ const journal = readDrizzleJournal(
+ join(migrationsDir, 'meta', '_journal.json'),
+ )
if (!journal) return false
const journalTags = new Set(journal.entries.map((entry) => entry.tag))
@@ -216,30 +221,120 @@ const currentMigrationHistory = [
hash: '561eb1075d7487ffe0394e587eef7ba35ccd892e3e3b53acace579cb0477576b',
createdAt: 1787580067090,
},
+ {
+ tag: '0008_add_llm_providers_and_scheduled_jobs',
+ hash: '1e36c60be880a222ae150858c5248a433556bd974c52164c42d1955e84ba6606',
+ createdAt: 1788319873053,
+ },
+ {
+ tag: '0009_add_scheduled_job_runs',
+ hash: '188a9503d889be46926bd6d4d660a1c016c90fac71447c25eec73e421b90fc96',
+ createdAt: 1788413695569,
+ },
+ {
+ tag: '0010_add_unified_providers_table',
+ hash: '9e5731582228e0de16bb28f5465cfd62ec2662822f43122f84b8039dd2c0cf0b',
+ createdAt: 1788426799725,
+ },
+ {
+ tag: '0011_drop_split_provider_tables',
+ hash: 'eb0fa2687c80caf919248f28cda5cd955e01a671b2104308b4d04ec55d450611',
+ createdAt: 1788426855683,
+ },
]
// TODO(nikhil): Remove this fallback once Windows/Linux packaging always includes Drizzle migrations.
const currentSchemaStatements = [
`
- CREATE TABLE IF NOT EXISTS acp_agents (
+ CREATE TABLE IF NOT EXISTS providers (
id text PRIMARY KEY NOT NULL,
- name text NOT NULL,
+ profile_id text,
+ kind text NOT NULL,
type text NOT NULL,
+ name text NOT NULL,
model_id text,
reasoning_effort text,
+ is_default integer DEFAULT false NOT NULL,
+ created_at integer NOT NULL,
+ updated_at integer NOT NULL,
+ base_url text,
+ supports_images integer DEFAULT true NOT NULL,
+ context_window integer,
+ temperature real DEFAULT 0.2 NOT NULL,
+ api_key text,
+ access_key_id text,
+ secret_access_key text,
+ session_token text,
+ resource_name text,
+ region text,
+ reasoning_summary text,
working_directory text,
custom_config text,
+ CONSTRAINT "providers_llm_requires_model_and_context" CHECK("providers"."kind" <> 'llm' OR ("providers"."model_id" IS NOT NULL AND "providers"."context_window" IS NOT NULL))
+ )
+ `,
+ `
+ CREATE INDEX IF NOT EXISTS providers_profile_id_idx
+ ON providers (profile_id)
+ `,
+ `
+ CREATE INDEX IF NOT EXISTS providers_kind_updated_at_idx
+ ON providers (kind, updated_at)
+ `,
+ `
+ CREATE UNIQUE INDEX IF NOT EXISTS providers_one_default
+ ON providers (is_default) WHERE "providers"."is_default" = 1
+ `,
+ `
+ CREATE TABLE IF NOT EXISTS scheduled_jobs (
+ id text PRIMARY KEY NOT NULL,
+ profile_id text,
+ name text NOT NULL,
+ query text NOT NULL,
+ schedule_type text NOT NULL,
+ schedule_time text,
+ schedule_interval integer,
+ enabled integer DEFAULT true NOT NULL,
+ provider_id text,
+ last_run_at integer,
created_at integer NOT NULL,
- updated_at integer NOT NULL
+ updated_at integer NOT NULL,
+ FOREIGN KEY (provider_id) REFERENCES providers(id) ON UPDATE no action ON DELETE set null
+ )
+ `,
+ `
+ CREATE INDEX IF NOT EXISTS scheduled_jobs_profile_id_idx
+ ON scheduled_jobs (profile_id)
+ `,
+ `
+ CREATE INDEX IF NOT EXISTS scheduled_jobs_enabled_idx
+ ON scheduled_jobs (enabled)
+ `,
+ `
+ CREATE TABLE IF NOT EXISTS scheduled_job_runs (
+ id text PRIMARY KEY NOT NULL,
+ profile_id text,
+ job_id text NOT NULL,
+ status text NOT NULL,
+ started_at integer NOT NULL,
+ completed_at integer,
+ result text,
+ final_result text,
+ execution_log text,
+ tool_calls text,
+ error text,
+ created_at integer NOT NULL,
+ updated_at integer NOT NULL,
+ FOREIGN KEY (job_id) REFERENCES scheduled_jobs(id) ON UPDATE no action ON DELETE cascade
)
`,
`
- CREATE INDEX IF NOT EXISTS acp_agents_updated_at_idx
- ON acp_agents (updated_at)
+ CREATE INDEX IF NOT EXISTS scheduled_job_runs_job_id_idx
+ ON scheduled_job_runs (job_id)
`,
`
- CREATE INDEX IF NOT EXISTS acp_agents_type_updated_at_idx
- ON acp_agents (type, updated_at)
+ CREATE INDEX IF NOT EXISTS scheduled_job_runs_started_at_idx
+ ON scheduled_job_runs (started_at)
`,
`
CREATE TABLE IF NOT EXISTS oauth_tokens (
diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/0008_add_llm_providers_and_scheduled_jobs.sql b/packages/browseros-agent/apps/server/src/lib/db/migrations/0008_add_llm_providers_and_scheduled_jobs.sql
new file mode 100644
index 0000000000..edbc615398
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/0008_add_llm_providers_and_scheduled_jobs.sql
@@ -0,0 +1,41 @@
+CREATE TABLE `llm_providers` (
+ `id` text PRIMARY KEY NOT NULL,
+ `profile_id` text,
+ `type` text NOT NULL,
+ `name` text NOT NULL,
+ `base_url` text,
+ `model_id` text NOT NULL,
+ `supports_images` integer DEFAULT true NOT NULL,
+ `context_window` integer NOT NULL,
+ `temperature` real DEFAULT 0.2 NOT NULL,
+ `api_key` text,
+ `access_key_id` text,
+ `secret_access_key` text,
+ `session_token` text,
+ `resource_name` text,
+ `region` text,
+ `reasoning_effort` text,
+ `reasoning_summary` text,
+ `created_at` integer NOT NULL,
+ `updated_at` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `llm_providers_profile_id_idx` ON `llm_providers` (`profile_id`);--> statement-breakpoint
+CREATE TABLE `scheduled_jobs` (
+ `id` text PRIMARY KEY NOT NULL,
+ `profile_id` text,
+ `name` text NOT NULL,
+ `query` text NOT NULL,
+ `schedule_type` text NOT NULL,
+ `schedule_time` text,
+ `schedule_interval` integer,
+ `enabled` integer DEFAULT true NOT NULL,
+ `provider_id` text,
+ `last_run_at` integer,
+ `created_at` integer NOT NULL,
+ `updated_at` integer NOT NULL,
+ FOREIGN KEY (`provider_id`) REFERENCES `llm_providers`(`id`) ON UPDATE no action ON DELETE set null
+);
+--> statement-breakpoint
+CREATE INDEX `scheduled_jobs_profile_id_idx` ON `scheduled_jobs` (`profile_id`);--> statement-breakpoint
+CREATE INDEX `scheduled_jobs_enabled_idx` ON `scheduled_jobs` (`enabled`);
\ No newline at end of file
diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/0009_add_scheduled_job_runs.sql b/packages/browseros-agent/apps/server/src/lib/db/migrations/0009_add_scheduled_job_runs.sql
new file mode 100644
index 0000000000..18bfb0547b
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/0009_add_scheduled_job_runs.sql
@@ -0,0 +1,19 @@
+CREATE TABLE `scheduled_job_runs` (
+ `id` text PRIMARY KEY NOT NULL,
+ `profile_id` text,
+ `job_id` text NOT NULL,
+ `status` text NOT NULL,
+ `started_at` integer NOT NULL,
+ `completed_at` integer,
+ `result` text,
+ `final_result` text,
+ `execution_log` text,
+ `tool_calls` text,
+ `error` text,
+ `created_at` integer NOT NULL,
+ `updated_at` integer NOT NULL,
+ FOREIGN KEY (`job_id`) REFERENCES `scheduled_jobs`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `scheduled_job_runs_job_id_idx` ON `scheduled_job_runs` (`job_id`);--> statement-breakpoint
+CREATE INDEX `scheduled_job_runs_started_at_idx` ON `scheduled_job_runs` (`started_at`);
\ No newline at end of file
diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/0010_add_unified_providers_table.sql b/packages/browseros-agent/apps/server/src/lib/db/migrations/0010_add_unified_providers_table.sql
new file mode 100644
index 0000000000..deca25311c
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/0010_add_unified_providers_table.sql
@@ -0,0 +1,63 @@
+CREATE TABLE `providers` (
+ `id` text PRIMARY KEY NOT NULL,
+ `profile_id` text,
+ `kind` text NOT NULL,
+ `type` text NOT NULL,
+ `name` text NOT NULL,
+ `model_id` text,
+ `reasoning_effort` text,
+ `is_default` integer DEFAULT false NOT NULL,
+ `created_at` integer NOT NULL,
+ `updated_at` integer NOT NULL,
+ `base_url` text,
+ `supports_images` integer DEFAULT true NOT NULL,
+ `context_window` integer,
+ `temperature` real DEFAULT 0.2 NOT NULL,
+ `api_key` text,
+ `access_key_id` text,
+ `secret_access_key` text,
+ `session_token` text,
+ `resource_name` text,
+ `region` text,
+ `reasoning_summary` text,
+ `working_directory` text,
+ `custom_config` text,
+ CONSTRAINT "providers_llm_requires_model_and_context" CHECK("providers"."kind" <> 'llm' OR ("providers"."model_id" IS NOT NULL AND "providers"."context_window" IS NOT NULL))
+);
+--> statement-breakpoint
+CREATE INDEX `providers_profile_id_idx` ON `providers` (`profile_id`);--> statement-breakpoint
+CREATE INDEX `providers_kind_updated_at_idx` ON `providers` (`kind`,`updated_at`);--> statement-breakpoint
+CREATE UNIQUE INDEX `providers_one_default` ON `providers` (`is_default`) WHERE "providers"."is_default" = 1;--> statement-breakpoint
+--- Copy both source tables in. Written by hand because drizzle generates
+--- schema changes, not data moves. A colliding id across the two sources
+--- fails the insert rather than silently dropping a row; ids are client
+--- generated uuids plus the fixed 'browseros' literal, so a collision would
+--- mean something is already wrong.
+INSERT INTO `providers` (
+ `id`, `profile_id`, `kind`, `type`, `name`, `model_id`, `reasoning_effort`,
+ `is_default`, `created_at`, `updated_at`,
+ `base_url`, `supports_images`, `context_window`, `temperature`,
+ `api_key`, `access_key_id`, `secret_access_key`, `session_token`,
+ `resource_name`, `region`, `reasoning_summary`
+)
+SELECT
+ `id`, `profile_id`, 'llm', `type`, `name`, `model_id`, `reasoning_effort`,
+ 0, `created_at`, `updated_at`,
+ `base_url`, `supports_images`, `context_window`, `temperature`,
+ `api_key`, `access_key_id`, `secret_access_key`, `session_token`,
+ `resource_name`, `region`, `reasoning_summary`
+FROM `llm_providers`;
+--> statement-breakpoint
+--- ACP agents carry no profile, model context or credentials, so those stay
+--- null or take the column default. The check constraint only requires a model
+--- and a context window for kind = 'llm'.
+INSERT INTO `providers` (
+ `id`, `kind`, `type`, `name`, `model_id`, `reasoning_effort`,
+ `is_default`, `created_at`, `updated_at`,
+ `working_directory`, `custom_config`
+)
+SELECT
+ `id`, 'acp', `type`, `name`, `model_id`, `reasoning_effort`,
+ 0, `created_at`, `updated_at`,
+ `working_directory`, `custom_config`
+FROM `acp_agents`;
diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/0011_drop_split_provider_tables.sql b/packages/browseros-agent/apps/server/src/lib/db/migrations/0011_drop_split_provider_tables.sql
new file mode 100644
index 0000000000..4ea9a7a835
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/0011_drop_split_provider_tables.sql
@@ -0,0 +1,32 @@
+--- Order matters here, and differs from what drizzle generated.
+---
+--- Dropping llm_providers while foreign keys are enforced fires the
+--- ON DELETE SET NULL on scheduled_jobs.provider_id, so every job silently
+--- loses the provider it was pointed at and falls back to the default on its
+--- next run. The drops therefore happen inside the foreign_keys=OFF block and
+--- after scheduled_jobs has been rebuilt against the new table.
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_scheduled_jobs` (
+ `id` text PRIMARY KEY NOT NULL,
+ `profile_id` text,
+ `name` text NOT NULL,
+ `query` text NOT NULL,
+ `schedule_type` text NOT NULL,
+ `schedule_time` text,
+ `schedule_interval` integer,
+ `enabled` integer DEFAULT true NOT NULL,
+ `provider_id` text,
+ `last_run_at` integer,
+ `created_at` integer NOT NULL,
+ `updated_at` integer NOT NULL,
+ FOREIGN KEY (`provider_id`) REFERENCES `providers`(`id`) ON UPDATE no action ON DELETE set null
+);
+--> statement-breakpoint
+INSERT INTO `__new_scheduled_jobs`("id", "profile_id", "name", "query", "schedule_type", "schedule_time", "schedule_interval", "enabled", "provider_id", "last_run_at", "created_at", "updated_at") SELECT "id", "profile_id", "name", "query", "schedule_type", "schedule_time", "schedule_interval", "enabled", "provider_id", "last_run_at", "created_at", "updated_at" FROM `scheduled_jobs`;--> statement-breakpoint
+DROP TABLE `scheduled_jobs`;--> statement-breakpoint
+ALTER TABLE `__new_scheduled_jobs` RENAME TO `scheduled_jobs`;--> statement-breakpoint
+DROP TABLE `acp_agents`;--> statement-breakpoint
+DROP TABLE `llm_providers`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;--> statement-breakpoint
+CREATE INDEX `scheduled_jobs_profile_id_idx` ON `scheduled_jobs` (`profile_id`);--> statement-breakpoint
+CREATE INDEX `scheduled_jobs_enabled_idx` ON `scheduled_jobs` (`enabled`);
diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0008_snapshot.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0008_snapshot.json
new file mode 100644
index 0000000000..6cf73176a9
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0008_snapshot.json
@@ -0,0 +1,547 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "32fefd99-fdd9-49aa-b9b3-54f485b933c2",
+ "prevId": "573c3669-aa07-4cee-a4b2-ad109a6407b3",
+ "tables": {
+ "acp_agents": {
+ "name": "acp_agents",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_effort": {
+ "name": "reasoning_effort",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "working_directory": {
+ "name": "working_directory",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_config": {
+ "name": "custom_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "acp_agents_updated_at_idx": {
+ "name": "acp_agents_updated_at_idx",
+ "columns": [
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "acp_agents_type_updated_at_idx": {
+ "name": "acp_agents_type_updated_at_idx",
+ "columns": [
+ "type",
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "conversations": {
+ "name": "conversations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "messages": {
+ "name": "messages",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_user_message": {
+ "name": "last_user_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_messaged_at": {
+ "name": "last_messaged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "conversations_last_messaged_at_idx": {
+ "name": "conversations_last_messaged_at_idx",
+ "columns": [
+ "last_messaged_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "llm_providers": {
+ "name": "llm_providers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "supports_images": {
+ "name": "supports_images",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "context_window": {
+ "name": "context_window",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "temperature": {
+ "name": "temperature",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0.2
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_key_id": {
+ "name": "access_key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secret_access_key": {
+ "name": "secret_access_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_token": {
+ "name": "session_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resource_name": {
+ "name": "resource_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_effort": {
+ "name": "reasoning_effort",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_summary": {
+ "name": "reasoning_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "llm_providers_profile_id_idx": {
+ "name": "llm_providers_profile_id_idx",
+ "columns": [
+ "profile_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "oauth_tokens": {
+ "name": "oauth_tokens",
+ "columns": {
+ "browseros_id": {
+ "name": "browseros_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "oauth_tokens_browseros_id_idx": {
+ "name": "oauth_tokens_browseros_id_idx",
+ "columns": [
+ "browseros_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "oauth_tokens_browseros_id_provider_pk": {
+ "columns": [
+ "browseros_id",
+ "provider"
+ ],
+ "name": "oauth_tokens_browseros_id_provider_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scheduled_jobs": {
+ "name": "scheduled_jobs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_type": {
+ "name": "schedule_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_time": {
+ "name": "schedule_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "schedule_interval": {
+ "name": "schedule_interval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "scheduled_jobs_profile_id_idx": {
+ "name": "scheduled_jobs_profile_id_idx",
+ "columns": [
+ "profile_id"
+ ],
+ "isUnique": false
+ },
+ "scheduled_jobs_enabled_idx": {
+ "name": "scheduled_jobs_enabled_idx",
+ "columns": [
+ "enabled"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "scheduled_jobs_provider_id_llm_providers_id_fk": {
+ "name": "scheduled_jobs_provider_id_llm_providers_id_fk",
+ "tableFrom": "scheduled_jobs",
+ "tableTo": "llm_providers",
+ "columnsFrom": [
+ "provider_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0009_snapshot.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0009_snapshot.json
new file mode 100644
index 0000000000..09ef43c3db
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0009_snapshot.json
@@ -0,0 +1,677 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "fc4dc284-30c2-478e-ac1e-677d0c078794",
+ "prevId": "32fefd99-fdd9-49aa-b9b3-54f485b933c2",
+ "tables": {
+ "acp_agents": {
+ "name": "acp_agents",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_effort": {
+ "name": "reasoning_effort",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "working_directory": {
+ "name": "working_directory",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_config": {
+ "name": "custom_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "acp_agents_updated_at_idx": {
+ "name": "acp_agents_updated_at_idx",
+ "columns": [
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "acp_agents_type_updated_at_idx": {
+ "name": "acp_agents_type_updated_at_idx",
+ "columns": [
+ "type",
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "conversations": {
+ "name": "conversations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "messages": {
+ "name": "messages",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_user_message": {
+ "name": "last_user_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_messaged_at": {
+ "name": "last_messaged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "conversations_last_messaged_at_idx": {
+ "name": "conversations_last_messaged_at_idx",
+ "columns": [
+ "last_messaged_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "llm_providers": {
+ "name": "llm_providers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "supports_images": {
+ "name": "supports_images",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "context_window": {
+ "name": "context_window",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "temperature": {
+ "name": "temperature",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0.2
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_key_id": {
+ "name": "access_key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secret_access_key": {
+ "name": "secret_access_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_token": {
+ "name": "session_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resource_name": {
+ "name": "resource_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_effort": {
+ "name": "reasoning_effort",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_summary": {
+ "name": "reasoning_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "llm_providers_profile_id_idx": {
+ "name": "llm_providers_profile_id_idx",
+ "columns": [
+ "profile_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "oauth_tokens": {
+ "name": "oauth_tokens",
+ "columns": {
+ "browseros_id": {
+ "name": "browseros_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "oauth_tokens_browseros_id_idx": {
+ "name": "oauth_tokens_browseros_id_idx",
+ "columns": [
+ "browseros_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "oauth_tokens_browseros_id_provider_pk": {
+ "columns": [
+ "browseros_id",
+ "provider"
+ ],
+ "name": "oauth_tokens_browseros_id_provider_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scheduled_job_runs": {
+ "name": "scheduled_job_runs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "result": {
+ "name": "result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "final_result": {
+ "name": "final_result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "execution_log": {
+ "name": "execution_log",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tool_calls": {
+ "name": "tool_calls",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "scheduled_job_runs_job_id_idx": {
+ "name": "scheduled_job_runs_job_id_idx",
+ "columns": [
+ "job_id"
+ ],
+ "isUnique": false
+ },
+ "scheduled_job_runs_started_at_idx": {
+ "name": "scheduled_job_runs_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "scheduled_job_runs_job_id_scheduled_jobs_id_fk": {
+ "name": "scheduled_job_runs_job_id_scheduled_jobs_id_fk",
+ "tableFrom": "scheduled_job_runs",
+ "tableTo": "scheduled_jobs",
+ "columnsFrom": [
+ "job_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scheduled_jobs": {
+ "name": "scheduled_jobs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_type": {
+ "name": "schedule_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_time": {
+ "name": "schedule_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "schedule_interval": {
+ "name": "schedule_interval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "scheduled_jobs_profile_id_idx": {
+ "name": "scheduled_jobs_profile_id_idx",
+ "columns": [
+ "profile_id"
+ ],
+ "isUnique": false
+ },
+ "scheduled_jobs_enabled_idx": {
+ "name": "scheduled_jobs_enabled_idx",
+ "columns": [
+ "enabled"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "scheduled_jobs_provider_id_llm_providers_id_fk": {
+ "name": "scheduled_jobs_provider_id_llm_providers_id_fk",
+ "tableFrom": "scheduled_jobs",
+ "tableTo": "llm_providers",
+ "columnsFrom": [
+ "provider_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0010_snapshot.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0010_snapshot.json
new file mode 100644
index 0000000000..d87d87f9fc
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0010_snapshot.json
@@ -0,0 +1,880 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "d77129b9-b676-47df-a674-db84f0e5ca7d",
+ "prevId": "fc4dc284-30c2-478e-ac1e-677d0c078794",
+ "tables": {
+ "acp_agents": {
+ "name": "acp_agents",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_effort": {
+ "name": "reasoning_effort",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "working_directory": {
+ "name": "working_directory",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_config": {
+ "name": "custom_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "acp_agents_updated_at_idx": {
+ "name": "acp_agents_updated_at_idx",
+ "columns": [
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "acp_agents_type_updated_at_idx": {
+ "name": "acp_agents_type_updated_at_idx",
+ "columns": [
+ "type",
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "conversations": {
+ "name": "conversations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "messages": {
+ "name": "messages",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_user_message": {
+ "name": "last_user_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_messaged_at": {
+ "name": "last_messaged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "conversations_last_messaged_at_idx": {
+ "name": "conversations_last_messaged_at_idx",
+ "columns": [
+ "last_messaged_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "llm_providers": {
+ "name": "llm_providers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "supports_images": {
+ "name": "supports_images",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "context_window": {
+ "name": "context_window",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "temperature": {
+ "name": "temperature",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0.2
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_key_id": {
+ "name": "access_key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secret_access_key": {
+ "name": "secret_access_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_token": {
+ "name": "session_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resource_name": {
+ "name": "resource_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_effort": {
+ "name": "reasoning_effort",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_summary": {
+ "name": "reasoning_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "llm_providers_profile_id_idx": {
+ "name": "llm_providers_profile_id_idx",
+ "columns": [
+ "profile_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "oauth_tokens": {
+ "name": "oauth_tokens",
+ "columns": {
+ "browseros_id": {
+ "name": "browseros_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "oauth_tokens_browseros_id_idx": {
+ "name": "oauth_tokens_browseros_id_idx",
+ "columns": [
+ "browseros_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "oauth_tokens_browseros_id_provider_pk": {
+ "columns": [
+ "browseros_id",
+ "provider"
+ ],
+ "name": "oauth_tokens_browseros_id_provider_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "providers": {
+ "name": "providers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_effort": {
+ "name": "reasoning_effort",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "supports_images": {
+ "name": "supports_images",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "context_window": {
+ "name": "context_window",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "temperature": {
+ "name": "temperature",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0.2
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_key_id": {
+ "name": "access_key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secret_access_key": {
+ "name": "secret_access_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_token": {
+ "name": "session_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resource_name": {
+ "name": "resource_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_summary": {
+ "name": "reasoning_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "working_directory": {
+ "name": "working_directory",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_config": {
+ "name": "custom_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "providers_profile_id_idx": {
+ "name": "providers_profile_id_idx",
+ "columns": [
+ "profile_id"
+ ],
+ "isUnique": false
+ },
+ "providers_kind_updated_at_idx": {
+ "name": "providers_kind_updated_at_idx",
+ "columns": [
+ "kind",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "providers_one_default": {
+ "name": "providers_one_default",
+ "columns": [
+ "is_default"
+ ],
+ "isUnique": true,
+ "where": "\"providers\".\"is_default\" = 1"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "providers_llm_requires_model_and_context": {
+ "name": "providers_llm_requires_model_and_context",
+ "value": "\"providers\".\"kind\" <> 'llm' OR (\"providers\".\"model_id\" IS NOT NULL AND \"providers\".\"context_window\" IS NOT NULL)"
+ }
+ }
+ },
+ "scheduled_job_runs": {
+ "name": "scheduled_job_runs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "result": {
+ "name": "result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "final_result": {
+ "name": "final_result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "execution_log": {
+ "name": "execution_log",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tool_calls": {
+ "name": "tool_calls",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "scheduled_job_runs_job_id_idx": {
+ "name": "scheduled_job_runs_job_id_idx",
+ "columns": [
+ "job_id"
+ ],
+ "isUnique": false
+ },
+ "scheduled_job_runs_started_at_idx": {
+ "name": "scheduled_job_runs_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "scheduled_job_runs_job_id_scheduled_jobs_id_fk": {
+ "name": "scheduled_job_runs_job_id_scheduled_jobs_id_fk",
+ "tableFrom": "scheduled_job_runs",
+ "tableTo": "scheduled_jobs",
+ "columnsFrom": [
+ "job_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scheduled_jobs": {
+ "name": "scheduled_jobs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_type": {
+ "name": "schedule_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_time": {
+ "name": "schedule_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "schedule_interval": {
+ "name": "schedule_interval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "scheduled_jobs_profile_id_idx": {
+ "name": "scheduled_jobs_profile_id_idx",
+ "columns": [
+ "profile_id"
+ ],
+ "isUnique": false
+ },
+ "scheduled_jobs_enabled_idx": {
+ "name": "scheduled_jobs_enabled_idx",
+ "columns": [
+ "enabled"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "scheduled_jobs_provider_id_llm_providers_id_fk": {
+ "name": "scheduled_jobs_provider_id_llm_providers_id_fk",
+ "tableFrom": "scheduled_jobs",
+ "tableTo": "llm_providers",
+ "columnsFrom": [
+ "provider_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0011_snapshot.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0011_snapshot.json
new file mode 100644
index 0000000000..fd23904ba9
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0011_snapshot.json
@@ -0,0 +1,638 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "e20078ea-5f98-4bb8-9bf6-f02a2be1badc",
+ "prevId": "d77129b9-b676-47df-a674-db84f0e5ca7d",
+ "tables": {
+ "conversations": {
+ "name": "conversations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "messages": {
+ "name": "messages",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_user_message": {
+ "name": "last_user_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_messaged_at": {
+ "name": "last_messaged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "conversations_last_messaged_at_idx": {
+ "name": "conversations_last_messaged_at_idx",
+ "columns": [
+ "last_messaged_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "oauth_tokens": {
+ "name": "oauth_tokens",
+ "columns": {
+ "browseros_id": {
+ "name": "browseros_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "oauth_tokens_browseros_id_idx": {
+ "name": "oauth_tokens_browseros_id_idx",
+ "columns": [
+ "browseros_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "oauth_tokens_browseros_id_provider_pk": {
+ "columns": [
+ "browseros_id",
+ "provider"
+ ],
+ "name": "oauth_tokens_browseros_id_provider_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "providers": {
+ "name": "providers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_effort": {
+ "name": "reasoning_effort",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "supports_images": {
+ "name": "supports_images",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "context_window": {
+ "name": "context_window",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "temperature": {
+ "name": "temperature",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0.2
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_key_id": {
+ "name": "access_key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secret_access_key": {
+ "name": "secret_access_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_token": {
+ "name": "session_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resource_name": {
+ "name": "resource_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_summary": {
+ "name": "reasoning_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "working_directory": {
+ "name": "working_directory",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_config": {
+ "name": "custom_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "providers_profile_id_idx": {
+ "name": "providers_profile_id_idx",
+ "columns": [
+ "profile_id"
+ ],
+ "isUnique": false
+ },
+ "providers_kind_updated_at_idx": {
+ "name": "providers_kind_updated_at_idx",
+ "columns": [
+ "kind",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "providers_one_default": {
+ "name": "providers_one_default",
+ "columns": [
+ "is_default"
+ ],
+ "isUnique": true,
+ "where": "\"providers\".\"is_default\" = 1"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "providers_llm_requires_model_and_context": {
+ "name": "providers_llm_requires_model_and_context",
+ "value": "\"providers\".\"kind\" <> 'llm' OR (\"providers\".\"model_id\" IS NOT NULL AND \"providers\".\"context_window\" IS NOT NULL)"
+ }
+ }
+ },
+ "scheduled_job_runs": {
+ "name": "scheduled_job_runs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "result": {
+ "name": "result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "final_result": {
+ "name": "final_result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "execution_log": {
+ "name": "execution_log",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tool_calls": {
+ "name": "tool_calls",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "scheduled_job_runs_job_id_idx": {
+ "name": "scheduled_job_runs_job_id_idx",
+ "columns": [
+ "job_id"
+ ],
+ "isUnique": false
+ },
+ "scheduled_job_runs_started_at_idx": {
+ "name": "scheduled_job_runs_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "scheduled_job_runs_job_id_scheduled_jobs_id_fk": {
+ "name": "scheduled_job_runs_job_id_scheduled_jobs_id_fk",
+ "tableFrom": "scheduled_job_runs",
+ "tableTo": "scheduled_jobs",
+ "columnsFrom": [
+ "job_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scheduled_jobs": {
+ "name": "scheduled_jobs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_type": {
+ "name": "schedule_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_time": {
+ "name": "schedule_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "schedule_interval": {
+ "name": "schedule_interval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "scheduled_jobs_profile_id_idx": {
+ "name": "scheduled_jobs_profile_id_idx",
+ "columns": [
+ "profile_id"
+ ],
+ "isUnique": false
+ },
+ "scheduled_jobs_enabled_idx": {
+ "name": "scheduled_jobs_enabled_idx",
+ "columns": [
+ "enabled"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "scheduled_jobs_provider_id_providers_id_fk": {
+ "name": "scheduled_jobs_provider_id_providers_id_fk",
+ "tableFrom": "scheduled_jobs",
+ "tableTo": "providers",
+ "columnsFrom": [
+ "provider_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/_journal.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/_journal.json
index cb49a4a8d1..4cf5c4f079 100644
--- a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/_journal.json
+++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/_journal.json
@@ -57,6 +57,34 @@
"when": 1787580067090,
"tag": "0007_add_custom_acp_agents",
"breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "6",
+ "when": 1788319873053,
+ "tag": "0008_add_llm_providers_and_scheduled_jobs",
+ "breakpoints": true
+ },
+ {
+ "idx": 9,
+ "version": "6",
+ "when": 1788413695569,
+ "tag": "0009_add_scheduled_job_runs",
+ "breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "6",
+ "when": 1788426799725,
+ "tag": "0010_add_unified_providers_table",
+ "breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "6",
+ "when": 1788426855683,
+ "tag": "0011_drop_split_provider_tables",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/agents.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/agents.ts
deleted file mode 100644
index fa50c8a8f5..0000000000
--- a/packages/browseros-agent/apps/server/src/lib/db/schema/agents.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright 2025 BrowserOS
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
-import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
-
-export const acpAgents = sqliteTable(
- 'acp_agents',
- {
- id: text('id').primaryKey(),
- name: text('name').notNull(),
- type: text('type', { enum: ['claude', 'codex', 'custom'] }).notNull(),
- modelId: text('model_id'),
- reasoningEffort: text('reasoning_effort'),
- workingDirectory: text('working_directory'),
- customConfig: text('custom_config'),
- createdAt: integer('created_at').notNull(),
- updatedAt: integer('updated_at').notNull(),
- },
- (table) => [
- index('acp_agents_updated_at_idx').on(table.updatedAt),
- index('acp_agents_type_updated_at_idx').on(table.type, table.updatedAt),
- ],
-)
-
-export type AcpAgentRow = InferSelectModel
-export type NewAcpAgentRow = InferInsertModel
diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/index.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/index.ts
index 79cac79fdf..978b4e5d67 100644
--- a/packages/browseros-agent/apps/server/src/lib/db/schema/index.ts
+++ b/packages/browseros-agent/apps/server/src/lib/db/schema/index.ts
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-export * from './agents'
export * from './conversations'
export * from './oauth'
+export * from './providers'
+export * from './scheduled-job-runs'
+export * from './scheduled-jobs'
diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/providers.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/providers.ts
new file mode 100644
index 0000000000..fe86cf4254
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/schema/providers.ts
@@ -0,0 +1,93 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
+import { sql } from 'drizzle-orm'
+import {
+ check,
+ index,
+ integer,
+ real,
+ sqliteTable,
+ text,
+ uniqueIndex,
+} from 'drizzle-orm/sqlite-core'
+
+/**
+ * Everything the chat can be pointed at, in one table.
+ *
+ * LLM providers and ACP agents were separate tables, but both are simply
+ * providers for a conversation and everything above the database already said
+ * so: the chat target is one union with a kind on the client, and the wire
+ * target has always been a discriminated union. Keeping them apart meant two
+ * selection pointers that could disagree, and a scheduled job that could only
+ * ever reference an LLM provider.
+ *
+ * `kind` carries the distinction. Columns that only one kind uses are nullable
+ * and grouped below, with the per-kind requirements held by a check constraint
+ * rather than by the column definitions.
+ *
+ * `profileId` is reserved and currently always null, as in the other tables.
+ */
+export const providers = sqliteTable(
+ 'providers',
+ {
+ id: text('id').primaryKey(),
+ profileId: text('profile_id'),
+ kind: text('kind', { enum: ['llm', 'acp'] }).notNull(),
+ type: text('type').notNull(),
+ name: text('name').notNull(),
+ /** Required for an llm provider, optional for an acp agent. */
+ modelId: text('model_id'),
+ reasoningEffort: text('reasoning_effort'),
+ /** At most one row may set this; the unique index below enforces it. */
+ isDefault: integer('is_default', { mode: 'boolean' })
+ .notNull()
+ .default(false),
+ createdAt: integer('created_at').notNull(),
+ updatedAt: integer('updated_at').notNull(),
+
+ baseUrl: text('base_url'),
+ supportsImages: integer('supports_images', { mode: 'boolean' })
+ .notNull()
+ .default(true),
+ contextWindow: integer('context_window'),
+ // Real, not integer: the default is 0.2 and an integer column floors it to 0.
+ temperature: real('temperature').notNull().default(0.2),
+ apiKey: text('api_key'),
+ accessKeyId: text('access_key_id'),
+ secretAccessKey: text('secret_access_key'),
+ sessionToken: text('session_token'),
+ resourceName: text('resource_name'),
+ region: text('region'),
+ reasoningSummary: text('reasoning_summary'),
+
+ workingDirectory: text('working_directory'),
+ customConfig: text('custom_config'),
+ },
+ (table) => [
+ index('providers_profile_id_idx').on(table.profileId),
+ index('providers_kind_updated_at_idx').on(table.kind, table.updatedAt),
+ // Every row in this index has is_default = 1, so uniqueness on that single
+ // column admits exactly one default row.
+ //
+ // Deliberately not keyed by profile_id. SQLite treats NULLs as distinct in
+ // a unique index, so a (profile_id, is_default) pair would let every row be
+ // default at once while profile_id is unset, which is its state today. The
+ // per-profile form belongs in the migration that starts populating
+ // profile_id, not in this one.
+ uniqueIndex('providers_one_default')
+ .on(table.isDefault)
+ .where(sql`${table.isDefault} = 1`),
+ check(
+ 'providers_llm_requires_model_and_context',
+ sql`${table.kind} <> 'llm' OR (${table.modelId} IS NOT NULL AND ${table.contextWindow} IS NOT NULL)`,
+ ),
+ ],
+)
+
+export type ProviderRow = InferSelectModel
+export type NewProviderRow = InferInsertModel
diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-job-runs.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-job-runs.ts
new file mode 100644
index 0000000000..20a777e5cc
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-job-runs.ts
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
+import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
+import { scheduledJobs } from './scheduled-jobs'
+
+/**
+ * One tool invocation recorded during a run, as the extension shapes it.
+ *
+ * `input` is optional here where the extension has it required. An `unknown`
+ * already admits undefined, so the two describe the same values, and zod infers
+ * a key of that type as optional. Matching the validator keeps this honest
+ * rather than asserting the difference away at the route boundary.
+ */
+export interface ToolCallExecution {
+ id: string
+ name: string
+ input?: unknown
+ output?: unknown
+ error?: string
+ timestamp: string
+}
+
+/**
+ * History of scheduled job executions.
+ *
+ * Cascades on job delete, unlike the job to provider reference which is
+ * `set null`. A job whose provider was removed is a job needing attention; a
+ * run whose job was removed means nothing, and deleting a job already removed
+ * its runs before this table existed.
+ *
+ * Timestamps are epoch integers here while the extension holds ISO strings,
+ * matching the other tables. The route layer converts.
+ */
+export const scheduledJobRuns = sqliteTable(
+ 'scheduled_job_runs',
+ {
+ id: text('id').primaryKey(),
+ profileId: text('profile_id'),
+ jobId: text('job_id')
+ .notNull()
+ .references(() => scheduledJobs.id, { onDelete: 'cascade' }),
+ status: text('status', {
+ enum: ['running', 'completed', 'failed'],
+ }).notNull(),
+ startedAt: integer('started_at').notNull(),
+ completedAt: integer('completed_at'),
+ result: text('result'),
+ finalResult: text('final_result'),
+ executionLog: text('execution_log'),
+ toolCalls: text('tool_calls', { mode: 'json' }).$type<
+ ToolCallExecution[]
+ >(),
+ error: text('error'),
+ createdAt: integer('created_at').notNull(),
+ updatedAt: integer('updated_at').notNull(),
+ },
+ (table) => [
+ index('scheduled_job_runs_job_id_idx').on(table.jobId),
+ index('scheduled_job_runs_started_at_idx').on(table.startedAt),
+ ],
+)
+
+export type ScheduledJobRunRow = InferSelectModel
+export type NewScheduledJobRunRow = InferInsertModel
diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-jobs.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-jobs.ts
new file mode 100644
index 0000000000..f7a48d4dc9
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-jobs.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
+import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
+import { providers } from './providers'
+
+/**
+ * Scheduled jobs, mirroring the shape the extension holds today.
+ *
+ * `providerId` points at the unified providers table, so a job can target an
+ * ACP agent as readily as an LLM provider. While the two were separate tables
+ * this reference could only ever name an LLM provider.
+ *
+ * The reference is deliberately not a foreign key with a cascade: a job whose
+ * provider was deleted should surface as a job needing attention, not vanish
+ * silently on a delete the user made elsewhere.
+ *
+ * Timestamps are epoch integers here while the extension holds ISO strings.
+ * The database is internally consistent this way, and the route layer converts.
+ */
+export const scheduledJobs = sqliteTable(
+ 'scheduled_jobs',
+ {
+ id: text('id').primaryKey(),
+ profileId: text('profile_id'),
+ name: text('name').notNull(),
+ query: text('query').notNull(),
+ scheduleType: text('schedule_type', {
+ enum: ['daily', 'hourly', 'minutes'],
+ }).notNull(),
+ scheduleTime: text('schedule_time'),
+ scheduleInterval: integer('schedule_interval'),
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+ providerId: text('provider_id').references(() => providers.id, {
+ onDelete: 'set null',
+ }),
+ lastRunAt: integer('last_run_at'),
+ createdAt: integer('created_at').notNull(),
+ updatedAt: integer('updated_at').notNull(),
+ },
+ (table) => [
+ index('scheduled_jobs_profile_id_idx').on(table.profileId),
+ index('scheduled_jobs_enabled_idx').on(table.enabled),
+ ],
+)
+
+export type ScheduledJobRow = InferSelectModel
+export type NewScheduledJobRow = InferInsertModel
diff --git a/packages/browseros-agent/apps/server/src/lib/providers/provider-store.ts b/packages/browseros-agent/apps/server/src/lib/providers/provider-store.ts
new file mode 100644
index 0000000000..1a283c8caa
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/providers/provider-store.ts
@@ -0,0 +1,264 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { and, eq, ne, sql } from 'drizzle-orm'
+import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
+import { getDb } from '../db'
+import { type NewProviderRow, type ProviderRow, providers } from '../db/schema'
+
+/**
+ * Every column except the four that hold secrets, plus flags saying whether
+ * each is set so the UI can still show that a key exists.
+ *
+ * Drizzle has no view, but naming the columns gives the same guarantee: a
+ * caller of the public reads cannot receive a credential even by accident,
+ * where a `select()` would hand them out on every list, get and default.
+ */
+function isSet(column: AnySQLiteColumn) {
+ return sql`${column} IS NOT NULL AND ${column} <> ''`.mapWith(
+ Boolean,
+ )
+}
+
+const publicColumns = {
+ id: providers.id,
+ profileId: providers.profileId,
+ kind: providers.kind,
+ type: providers.type,
+ name: providers.name,
+ modelId: providers.modelId,
+ reasoningEffort: providers.reasoningEffort,
+ isDefault: providers.isDefault,
+ createdAt: providers.createdAt,
+ updatedAt: providers.updatedAt,
+ baseUrl: providers.baseUrl,
+ supportsImages: providers.supportsImages,
+ contextWindow: providers.contextWindow,
+ temperature: providers.temperature,
+ resourceName: providers.resourceName,
+ region: providers.region,
+ reasoningSummary: providers.reasoningSummary,
+ workingDirectory: providers.workingDirectory,
+ customConfig: providers.customConfig,
+ // Empty counts as unset, matching what the upsert treats as not supplied.
+ // Otherwise a blank field would read back as a stored credential.
+ hasApiKey: isSet(providers.apiKey),
+ hasAccessKeyId: isSet(providers.accessKeyId),
+ hasSecretAccessKey: isSet(providers.secretAccessKey),
+ hasSessionToken: isSet(providers.sessionToken),
+}
+
+export type PublicProviderRow = {
+ [K in keyof typeof publicColumns]: K extends `has${string}`
+ ? boolean
+ : ProviderRow[Extract]
+}
+
+/**
+ * The store stamps `updatedAt` and defaults `createdAt`, so callers supply
+ * neither. `createdAt` stays optional so an import can preserve the original
+ * creation time when it has one.
+ */
+export type ProviderUpsert = Omit<
+ NewProviderRow,
+ 'updatedAt' | 'createdAt' | 'kind'
+> & {
+ createdAt?: number
+}
+
+export interface ProviderStore {
+ /** Every provider, whatever its kind, without credentials. */
+ list(): Promise
+ /** Only the LLM providers, without credentials. */
+ listLlm(): Promise
+ get(id: string): Promise
+ /**
+ * The full row, credentials included. Only for callers inside the server
+ * that have to build an outbound request, never for a route response.
+ */
+ getWithCredentials(id: string): Promise
+ /** Insert or replace by id. This is the app's ordinary write path. */
+ upsert(row: ProviderUpsert): Promise
+ /**
+ * Insert only when the id is absent; returns null when a row already exists.
+ *
+ * The one-time import uses this rather than `upsert` because the app writes
+ * to this table directly as well. A second import run must never replace a
+ * provider the user has edited since with the stale copy still sitting in
+ * extension storage.
+ */
+ insertIfAbsent(row: ProviderUpsert): Promise
+ remove(id: string): Promise
+ /** The one selected provider, of any kind, or null when none is set. */
+ getDefault(): Promise
+ /** The selected provider with its credentials, for the same callers as above. */
+ getDefaultWithCredentials(): Promise
+ /**
+ * Points the default at one provider of any kind. Returns false when the id
+ * is unknown, so a stale pointer cannot be stored.
+ */
+ setDefault(id: string): Promise
+}
+
+async function list(): Promise {
+ return getDb().select(publicColumns).from(providers).all()
+}
+
+async function listLlm(): Promise {
+ return getDb()
+ .select(publicColumns)
+ .from(providers)
+ .where(eq(providers.kind, 'llm'))
+ .all()
+}
+
+async function get(id: string): Promise {
+ const [row] = await getDb()
+ .select(publicColumns)
+ .from(providers)
+ .where(eq(providers.id, id))
+ .limit(1)
+ return row ?? null
+}
+
+async function getWithCredentials(id: string): Promise {
+ const [row] = await getDb()
+ .select()
+ .from(providers)
+ .where(eq(providers.id, id))
+ .limit(1)
+ return row ?? null
+}
+
+const CREDENTIAL_FIELDS = [
+ 'apiKey',
+ 'accessKeyId',
+ 'secretAccessKey',
+ 'sessionToken',
+] as const
+
+/**
+ * Drops credential fields the caller did not supply, so they keep their stored
+ * value.
+ *
+ * Reads no longer return credentials, so a client editing a provider cannot
+ * send back what it never received. A plain whole-row upsert would then write
+ * over a working key on every rename.
+ *
+ * An empty string counts as not supplied, not as an instruction to clear. A
+ * form field that was never filled in submits as `''` rather than undefined,
+ * so treating the two differently would wipe the key on exactly the edit this
+ * exists to protect. Clearing is deliberate and explicit: send null.
+ */
+function withoutAbsentCredentials>(
+ row: T,
+): Partial {
+ const next: Record = { ...row }
+ for (const field of CREDENTIAL_FIELDS) {
+ if (next[field] === undefined || next[field] === '') delete next[field]
+ }
+ return next as Partial
+}
+
+async function upsert(row: ProviderUpsert): Promise {
+ const now = Date.now()
+ const values = { ...row, kind: 'llm' as const }
+ const [saved] = await getDb()
+ .insert(providers)
+ .values({ ...values, createdAt: row.createdAt ?? now, updatedAt: now })
+ .onConflictDoUpdate({
+ target: providers.id,
+ // createdAt is deliberately absent: re-importing a provider must not
+ // rewrite when the user originally created it. isDefault likewise, so a
+ // save does not silently move the selection.
+ set: {
+ ...withoutAbsentCredentials(values),
+ createdAt: undefined,
+ isDefault: undefined,
+ updatedAt: now,
+ },
+ })
+ .returning()
+ return saved
+}
+
+async function insertIfAbsent(
+ row: ProviderUpsert,
+): Promise {
+ const now = Date.now()
+ // onConflictDoNothing returns no row on conflict, so the absent/present
+ // decision and the write are one statement rather than a select then insert.
+ const [saved] = await getDb()
+ .insert(providers)
+ .values({
+ ...row,
+ kind: 'llm' as const,
+ createdAt: row.createdAt ?? now,
+ updatedAt: now,
+ })
+ .onConflictDoNothing({ target: providers.id })
+ .returning()
+ return saved ?? null
+}
+
+async function remove(id: string): Promise {
+ const deleted = await getDb()
+ .delete(providers)
+ .where(eq(providers.id, id))
+ .returning({ id: providers.id })
+ return deleted.length > 0
+}
+
+async function getDefault(): Promise {
+ const [row] = await getDb()
+ .select(publicColumns)
+ .from(providers)
+ .where(eq(providers.isDefault, true))
+ .limit(1)
+ return row ?? null
+}
+
+async function getDefaultWithCredentials(): Promise {
+ const [row] = await getDb()
+ .select()
+ .from(providers)
+ .where(eq(providers.isDefault, true))
+ .limit(1)
+ return row ?? null
+}
+
+async function setDefault(id: string): Promise {
+ const target = await get(id)
+ if (!target) return false
+
+ // Clearing first is required, not tidiness: a partial unique index allows one
+ // row with is_default = 1, so setting the new one before clearing the old
+ // would violate it.
+ return getDb().transaction((tx) => {
+ tx.update(providers)
+ .set({ isDefault: false })
+ .where(and(eq(providers.isDefault, true), ne(providers.id, id)))
+ .run()
+ tx.update(providers)
+ .set({ isDefault: true })
+ .where(eq(providers.id, id))
+ .run()
+ return true
+ })
+}
+
+export const dbProviderStore: ProviderStore = {
+ list,
+ listLlm,
+ get,
+ getWithCredentials,
+ upsert,
+ insertIfAbsent,
+ remove,
+ getDefault,
+ getDefaultWithCredentials,
+ setDefault,
+}
diff --git a/packages/browseros-agent/apps/server/src/lib/schedules/run-store.ts b/packages/browseros-agent/apps/server/src/lib/schedules/run-store.ts
new file mode 100644
index 0000000000..a5d76c064f
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/schedules/run-store.ts
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { desc, eq, inArray } from 'drizzle-orm'
+import { getDb } from '../db'
+import {
+ type NewScheduledJobRunRow,
+ type ScheduledJobRunRow,
+ scheduledJobRuns,
+} from '../db/schema'
+
+/**
+ * The store stamps `updatedAt` and defaults `createdAt`, so callers supply
+ * neither. `createdAt` stays optional so an import can preserve the original
+ * creation time when it has one.
+ */
+export type ScheduledJobRunUpsert = Omit<
+ NewScheduledJobRunRow,
+ 'updatedAt' | 'createdAt'
+> & {
+ createdAt?: number
+}
+
+/**
+ * Runs kept per job. The extension applied this cap when it owned the history,
+ * trimming as it created each run; keeping the number here means it holds
+ * however the run was written rather than only on the path that happened to
+ * enforce it.
+ */
+export const MAX_RUNS_PER_JOB = 15
+
+export interface ScheduledJobRunStore {
+ list(): Promise
+ get(id: string): Promise
+ /** Insert or replace by id. A run is written once when it starts and again
+ * when it finishes, so this is the ordinary write path. */
+ upsert(row: ScheduledJobRunUpsert): Promise
+ /** Insert only when the id is absent; returns null when a row already
+ * exists. Used by the one-time import for the reason on the provider store. */
+ insertIfAbsent(row: ScheduledJobRunUpsert): Promise
+ remove(id: string): Promise
+ /** Drops all but the newest `keep` runs of a job. Returns how many went. */
+ prune(jobId: string, keep?: number): Promise
+}
+
+async function list(): Promise {
+ return getDb()
+ .select()
+ .from(scheduledJobRuns)
+ .orderBy(desc(scheduledJobRuns.startedAt))
+ .all()
+}
+
+async function get(id: string): Promise {
+ const [row] = await getDb()
+ .select()
+ .from(scheduledJobRuns)
+ .where(eq(scheduledJobRuns.id, id))
+ .limit(1)
+ return row ?? null
+}
+
+async function upsert(row: ScheduledJobRunUpsert): Promise {
+ const now = Date.now()
+ const [saved] = await getDb()
+ .insert(scheduledJobRuns)
+ .values({ ...row, createdAt: row.createdAt ?? now, updatedAt: now })
+ .onConflictDoUpdate({
+ target: scheduledJobRuns.id,
+ set: { ...row, createdAt: undefined, updatedAt: now },
+ })
+ .returning()
+ return saved
+}
+
+async function insertIfAbsent(
+ row: ScheduledJobRunUpsert,
+): Promise {
+ const now = Date.now()
+ const [saved] = await getDb()
+ .insert(scheduledJobRuns)
+ .values({ ...row, createdAt: row.createdAt ?? now, updatedAt: now })
+ .onConflictDoNothing({ target: scheduledJobRuns.id })
+ .returning()
+ return saved ?? null
+}
+
+async function prune(
+ jobId: string,
+ keep: number = MAX_RUNS_PER_JOB,
+): Promise {
+ const rows = await getDb()
+ .select({ id: scheduledJobRuns.id })
+ .from(scheduledJobRuns)
+ .where(eq(scheduledJobRuns.jobId, jobId))
+ .orderBy(desc(scheduledJobRuns.startedAt))
+ .all()
+
+ const stale = rows.slice(keep).map((row) => row.id)
+ if (stale.length === 0) return 0
+
+ await getDb()
+ .delete(scheduledJobRuns)
+ .where(inArray(scheduledJobRuns.id, stale))
+ return stale.length
+}
+
+async function remove(id: string): Promise {
+ const deleted = await getDb()
+ .delete(scheduledJobRuns)
+ .where(eq(scheduledJobRuns.id, id))
+ .returning({ id: scheduledJobRuns.id })
+ return deleted.length > 0
+}
+
+export const dbScheduledJobRunStore: ScheduledJobRunStore = {
+ list,
+ get,
+ upsert,
+ insertIfAbsent,
+ remove,
+ prune,
+}
diff --git a/packages/browseros-agent/apps/server/src/lib/schedules/schedule-store.ts b/packages/browseros-agent/apps/server/src/lib/schedules/schedule-store.ts
new file mode 100644
index 0000000000..dbbb24be27
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/schedules/schedule-store.ts
@@ -0,0 +1,93 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { eq } from 'drizzle-orm'
+import { getDb } from '../db'
+import {
+ type NewScheduledJobRow,
+ type ScheduledJobRow,
+ scheduledJobs,
+} from '../db/schema'
+
+/**
+ * The store stamps `updatedAt` and defaults `createdAt`, so callers supply
+ * neither. `createdAt` stays optional so an import can preserve the original
+ * creation time when it has one.
+ */
+export type ScheduledJobUpsert = Omit<
+ NewScheduledJobRow,
+ 'updatedAt' | 'createdAt'
+> & {
+ createdAt?: number
+}
+
+export interface ScheduledJobStore {
+ list(): Promise
+ get(id: string): Promise
+ /** Insert or replace by id. This is the app's ordinary write path. */
+ upsert(row: ScheduledJobUpsert): Promise
+ /**
+ * Insert only when the id is absent; returns null when a row already exists.
+ * See the note on the provider store: the import must never overwrite a job
+ * the user has edited since.
+ */
+ insertIfAbsent(row: ScheduledJobUpsert): Promise
+ remove(id: string): Promise
+}
+
+async function list(): Promise {
+ return getDb().select().from(scheduledJobs).all()
+}
+
+async function get(id: string): Promise