From 7fdd9557aeaad90b5c705e12dc6edc4291fc8a43 Mon Sep 17 00:00:00 2001
From: Dani Akash
Date: Wed, 2 Sep 2026 08:55:38 +0530
Subject: [PATCH 01/12] chore(app): add react-query-kit for the local-first
storage epic
The epic moves provider and schedule config from extension storage onto the
local server, which means a large set of new query and mutation hooks over
Hono RPC. Kit factories are the repo standard for those, and the app is on
plain react-query today, so the dependency lands before the hooks do.
3.3.4 peers on @tanstack/react-query ^4 || ^5; the app is on ^5.101.4.
---
packages/browseros-agent/apps/app/package.json | 1 +
packages/browseros-agent/bun.lock | 1 +
2 files changed, 2 insertions(+)
diff --git a/packages/browseros-agent/apps/app/package.json b/packages/browseros-agent/apps/app/package.json
index a630b0944f..6501872355 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.85.0",
+ "react-query-kit": "^3.3.4",
"react-resizable-panels": "^4.12.2",
"react-router": "^7.18.2",
"shiki": "^3.23.0",
diff --git a/packages/browseros-agent/bun.lock b/packages/browseros-agent/bun.lock
index 4d00e62579..938cf3d919 100644
--- a/packages/browseros-agent/bun.lock
+++ b/packages/browseros-agent/bun.lock
@@ -84,6 +84,7 @@
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
+ "react-query-kit": "^3.3.4",
"react-resizable-panels": "^4.12.2",
"react-router": "^7.18.2",
"shiki": "^3.23.0",
From 88b39bba595a04145fe099c365c15b589be3c868 Mon Sep 17 00:00:00 2001
From: Dani Akash
Date: Wed, 2 Sep 2026 09:03:47 +0530
Subject: [PATCH 02/12] feat(server): add local storage for llm providers and
scheduled jobs
First phase of moving provider and schedule config off the cloud and onto the
machine. Server side only: nothing reads or writes these tables yet, so the
extension is unaffected and this ships dark.
Two tables beside the existing agents, conversations and oauth. Credentials
live here in the clear, next to the oauth tokens already in this database,
protected by filesystem permissions and nothing more. That is the same
posture they had in extension storage, and it is why the cloud copy of a
provider was never usable: it deliberately never carried a key.
Both tables carry a nullable profile_id, always null for now. No extension
API exposes a browser profile identifier, so every profile on a machine
shares one database. The column exists so isolation can be turned on later
without a second migration.
Upserts key on the client-supplied id and leave created_at alone on conflict.
The migration that follows re-runs per profile and after partial failures, so
landing twice has to be indistinguishable from landing once.
The job to provider reference sets null rather than cascading. A job whose
provider was deleted should surface as needing attention rather than
disappearing because of a delete made elsewhere.
---
.../apps/server/src/api/routes/index.ts | 4 +
.../server/src/api/routes/llm-providers.ts | 72 +++++++
.../server/src/api/routes/scheduled-jobs.ts | 65 ++++++
.../src/lib/db/migrations/meta/_journal.json | 7 +
.../apps/server/src/lib/db/schema/index.ts | 2 +
.../src/lib/llm-providers/provider-store.ts | 76 +++++++
.../src/lib/schedules/schedule-store.ts | 74 +++++++
.../tests/api/routes/llm-providers.test.ts | 185 ++++++++++++++++++
.../tests/api/routes/scheduled-jobs.test.ts | 144 ++++++++++++++
9 files changed, 629 insertions(+)
create mode 100644 packages/browseros-agent/apps/server/src/api/routes/llm-providers.ts
create mode 100644 packages/browseros-agent/apps/server/src/api/routes/scheduled-jobs.ts
create mode 100644 packages/browseros-agent/apps/server/src/lib/llm-providers/provider-store.ts
create mode 100644 packages/browseros-agent/apps/server/src/lib/schedules/schedule-store.ts
create mode 100644 packages/browseros-agent/apps/server/tests/api/routes/llm-providers.test.ts
create mode 100644 packages/browseros-agent/apps/server/tests/api/routes/scheduled-jobs.test.ts
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..0df41b483e 100644
--- a/packages/browseros-agent/apps/server/src/api/routes/index.ts
+++ b/packages/browseros-agent/apps/server/src/api/routes/index.ts
@@ -22,11 +22,13 @@ import { createConversationRoutes } from './conversations'
import { createCreditsRoutes } from './credits'
import { createHealthRoute } from './health'
import { createKlavisRoutes } from './klavis'
+import { createLlmProviderRoutes } from './llm-providers'
import { createMcpRoutes } from './mcp'
import { createMcpManagerRoutes } from './mcp-manager'
import { createOAuthRoutes } from './oauth'
import { createProviderRoutes } from './provider'
import { createRefinePromptRoutes } from './refine-prompt'
+import { createScheduledJobRoutes } from './scheduled-jobs'
import { createShutdownRoute } from './shutdown'
import { createStatusRoute } from './status'
@@ -137,6 +139,8 @@ export function createApiRoutes(deps: CreateApiRoutesDeps) {
.route('/acpx/probe', createAcpxProbeRoutes({ resourcesDir }))
.route('/agents', resolvedAgentRoutes)
.route('/conversations', createConversationRoutes())
+ .route('/llm-providers', createLlmProviderRoutes())
+ .route('/scheduled-jobs', createScheduledJobRoutes())
)
}
diff --git a/packages/browseros-agent/apps/server/src/api/routes/llm-providers.ts b/packages/browseros-agent/apps/server/src/api/routes/llm-providers.ts
new file mode 100644
index 0000000000..09720efe50
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/api/routes/llm-providers.ts
@@ -0,0 +1,72 @@
+/**
+ * @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 {
+ dbLlmProviderStore,
+ type LlmProviderStore,
+} from '../../lib/llm-providers/provider-store'
+import type { Env } from '../types'
+
+const IdParamSchema = z.object({ providerId: z.string().min(1) })
+
+/**
+ * Mirrors the extension's LlmProviderConfig. `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(),
+})
+
+export function createLlmProviderRoutes(
+ options: { store?: LlmProviderStore } = {},
+) {
+ const store = options.store ?? dbLlmProviderStore
+
+ return new Hono()
+ .get('/', async (c) => c.json({ providers: await store.list() }))
+ .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-jobs.ts b/packages/browseros-agent/apps/server/src/api/routes/scheduled-jobs.ts
new file mode 100644
index 0000000000..522cea75a1
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/api/routes/scheduled-jobs.ts
@@ -0,0 +1,65 @@
+/**
+ * @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(),
+})
+
+export function createScheduledJobRoutes(
+ options: { store?: ScheduledJobStore } = {},
+) {
+ const store = options.store ?? dbScheduledJobStore
+
+ return new Hono()
+ .get('/', async (c) => c.json({ jobs: await store.list() }))
+ .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/lib/db/migrations/meta/_journal.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/_journal.json
index cb49a4a8d1..989dc620e9 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,13 @@
"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
}
]
}
\ No newline at end of file
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..00f485e165 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
@@ -6,4 +6,6 @@
export * from './agents'
export * from './conversations'
+export * from './llm-providers'
+export * from './scheduled-jobs'
export * from './oauth'
diff --git a/packages/browseros-agent/apps/server/src/lib/llm-providers/provider-store.ts b/packages/browseros-agent/apps/server/src/lib/llm-providers/provider-store.ts
new file mode 100644
index 0000000000..758b44ed0b
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/llm-providers/provider-store.ts
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { eq } from 'drizzle-orm'
+import { getDb } from '../db'
+import {
+ type LlmProviderRow,
+ llmProviders,
+ type NewLlmProviderRow,
+} 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 LlmProviderUpsert = Omit<
+ NewLlmProviderRow,
+ 'updatedAt' | 'createdAt'
+> & {
+ createdAt?: number
+}
+
+export interface LlmProviderStore {
+ list(): Promise
+ get(id: string): Promise
+ /** Insert or replace by id. The migration relies on this being idempotent. */
+ upsert(row: LlmProviderUpsert): Promise
+ remove(id: string): Promise
+}
+
+async function list(): Promise {
+ return getDb().select().from(llmProviders).all()
+}
+
+async function get(id: string): Promise {
+ const [row] = await getDb()
+ .select()
+ .from(llmProviders)
+ .where(eq(llmProviders.id, id))
+ .limit(1)
+ return row ?? null
+}
+
+async function upsert(row: LlmProviderUpsert): Promise {
+ const now = Date.now()
+ const [saved] = await getDb()
+ .insert(llmProviders)
+ .values({ ...row, createdAt: row.createdAt ?? now, updatedAt: now })
+ .onConflictDoUpdate({
+ target: llmProviders.id,
+ // createdAt is deliberately absent: re-importing a provider must not
+ // rewrite when the user originally created it.
+ set: { ...row, createdAt: undefined, updatedAt: now },
+ })
+ .returning()
+ return saved
+}
+
+async function remove(id: string): Promise {
+ const deleted = await getDb()
+ .delete(llmProviders)
+ .where(eq(llmProviders.id, id))
+ .returning({ id: llmProviders.id })
+ return deleted.length > 0
+}
+
+export const dbLlmProviderStore: LlmProviderStore = {
+ list,
+ get,
+ upsert,
+ remove,
+}
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..d264a0cab6
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/schedules/schedule-store.ts
@@ -0,0 +1,74 @@
+/**
+ * @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. The migration relies on this being idempotent. */
+ upsert(row: ScheduledJobUpsert): Promise
+ remove(id: string): Promise
+}
+
+async function list(): Promise {
+ return getDb().select().from(scheduledJobs).all()
+}
+
+async function get(id: string): Promise {
+ const [row] = await getDb()
+ .select()
+ .from(scheduledJobs)
+ .where(eq(scheduledJobs.id, id))
+ .limit(1)
+ return row ?? null
+}
+
+async function upsert(row: ScheduledJobUpsert): Promise {
+ const now = Date.now()
+ const [saved] = await getDb()
+ .insert(scheduledJobs)
+ .values({ ...row, createdAt: row.createdAt ?? now, updatedAt: now })
+ .onConflictDoUpdate({
+ target: scheduledJobs.id,
+ set: { ...row, createdAt: undefined, updatedAt: now },
+ })
+ .returning()
+ return saved
+}
+
+async function remove(id: string): Promise {
+ const deleted = await getDb()
+ .delete(scheduledJobs)
+ .where(eq(scheduledJobs.id, id))
+ .returning({ id: scheduledJobs.id })
+ return deleted.length > 0
+}
+
+export const dbScheduledJobStore: ScheduledJobStore = {
+ list,
+ get,
+ upsert,
+ remove,
+}
diff --git a/packages/browseros-agent/apps/server/tests/api/routes/llm-providers.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/llm-providers.test.ts
new file mode 100644
index 0000000000..ee23a5dd13
--- /dev/null
+++ b/packages/browseros-agent/apps/server/tests/api/routes/llm-providers.test.ts
@@ -0,0 +1,185 @@
+import { describe, expect, it } from 'bun:test'
+import { createLlmProviderRoutes } from '../../../src/api/routes/llm-providers'
+import type { LlmProviderRow } from '../../../src/lib/db/schema'
+import type {
+ LlmProviderStore,
+ LlmProviderUpsert,
+} from '../../../src/lib/llm-providers/provider-store'
+
+const PROVIDER_ID = 'provider-1'
+
+function row(overrides: Partial = {}): LlmProviderRow {
+ return {
+ id: PROVIDER_ID,
+ profileId: null,
+ type: 'openai',
+ name: 'My OpenAI',
+ baseUrl: 'https://api.openai.com/v1',
+ modelId: 'gpt-5.5',
+ supportsImages: true,
+ contextWindow: 200000,
+ temperature: 0.2,
+ apiKey: 'sk-test',
+ accessKeyId: null,
+ secretAccessKey: null,
+ sessionToken: null,
+ resourceName: null,
+ region: null,
+ reasoningEffort: null,
+ reasoningSummary: null,
+ createdAt: 1,
+ updatedAt: 1,
+ ...overrides,
+ }
+}
+
+function memoryStore(initial: LlmProviderRow[] = []) {
+ const rows = new Map(initial.map((r) => [r.id, r]))
+ const store: LlmProviderStore = {
+ list: async () => [...rows.values()],
+ get: async (id) => rows.get(id) ?? null,
+ upsert: async (input: LlmProviderUpsert) => {
+ const existing = rows.get(input.id)
+ const saved = {
+ ...row(),
+ ...input,
+ createdAt: existing?.createdAt ?? input.createdAt ?? 100,
+ updatedAt: 200,
+ } as LlmProviderRow
+ rows.set(saved.id, saved)
+ return saved
+ },
+ remove: async (id) => rows.delete(id),
+ }
+ return { store, rows }
+}
+
+const body = {
+ type: 'openai',
+ name: 'My OpenAI',
+ modelId: 'gpt-5.5',
+ contextWindow: 200000,
+ apiKey: 'sk-test',
+}
+
+describe('llm provider routes', () => {
+ it('lists providers', async () => {
+ const routes = createLlmProviderRoutes(memoryStore([row()]))
+ const response = await routes.request('/')
+ expect(response.status).toBe(200)
+ expect(await response.json()).toMatchObject({
+ providers: [{ id: PROVIDER_ID, name: 'My OpenAI' }],
+ })
+ })
+
+ it('gets one provider', async () => {
+ const routes = createLlmProviderRoutes(memoryStore([row()]))
+ const response = await routes.request(`/${PROVIDER_ID}`)
+ expect(response.status).toBe(200)
+ expect(await response.json()).toMatchObject({
+ provider: { id: PROVIDER_ID },
+ })
+ })
+
+ it('returns 404 for an unknown provider', async () => {
+ const routes = createLlmProviderRoutes(memoryStore())
+ expect((await routes.request(`/${PROVIDER_ID}`)).status).toBe(404)
+ })
+
+ it('creates a provider under the id from the path', async () => {
+ const { store, rows } = memoryStore()
+ const routes = createLlmProviderRoutes({ store })
+
+ const response = await routes.request(`/${PROVIDER_ID}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+
+ expect(response.status).toBe(200)
+ expect(rows.get(PROVIDER_ID)?.name).toBe('My OpenAI')
+ })
+
+ // The migration re-runs on every profile and after a partial failure, so a
+ // repeated PUT has to land on the same row rather than a second one.
+ it('is idempotent: putting the same id twice keeps one row', async () => {
+ const { store, rows } = memoryStore()
+ const routes = createLlmProviderRoutes({ store })
+ const put = () =>
+ routes.request(`/${PROVIDER_ID}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+
+ await put()
+ await put()
+
+ expect(rows.size).toBe(1)
+ })
+
+ it('keeps the original creation time when a provider is re-imported', async () => {
+ const { store, rows } = memoryStore([row({ createdAt: 42 })])
+ const routes = createLlmProviderRoutes({ store })
+
+ await routes.request(`/${PROVIDER_ID}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...body, createdAt: 999 }),
+ })
+
+ expect(rows.get(PROVIDER_ID)?.createdAt).toBe(42)
+ })
+
+ it('rejects a body missing required fields', async () => {
+ const routes = createLlmProviderRoutes(memoryStore())
+ const response = await routes.request(`/${PROVIDER_ID}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: 'no type or model' }),
+ })
+ expect(response.status).toBe(400)
+ })
+
+ it('deletes a provider', async () => {
+ const { store, rows } = memoryStore([row()])
+ const routes = createLlmProviderRoutes({ store })
+
+ const response = await routes.request(`/${PROVIDER_ID}`, {
+ method: 'DELETE',
+ })
+ expect(response.status).toBe(200)
+ expect(rows.size).toBe(0)
+ })
+
+ it('returns 404 deleting an unknown provider', async () => {
+ const routes = createLlmProviderRoutes(memoryStore())
+ expect(
+ (await routes.request(`/${PROVIDER_ID}`, { method: 'DELETE' })).status,
+ ).toBe(404)
+ })
+
+ // Credentials are the reason this table exists rather than staying remote.
+ it('round-trips credentials, which the cloud never carried', async () => {
+ const { store, rows } = memoryStore()
+ const routes = createLlmProviderRoutes({ store })
+
+ await routes.request(`/${PROVIDER_ID}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ ...body,
+ type: 'bedrock',
+ accessKeyId: 'AKIA',
+ secretAccessKey: 'secret',
+ sessionToken: 'token',
+ }),
+ })
+
+ expect(rows.get(PROVIDER_ID)).toMatchObject({
+ accessKeyId: 'AKIA',
+ secretAccessKey: 'secret',
+ sessionToken: 'token',
+ })
+ })
+})
diff --git a/packages/browseros-agent/apps/server/tests/api/routes/scheduled-jobs.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/scheduled-jobs.test.ts
new file mode 100644
index 0000000000..3f7d92a530
--- /dev/null
+++ b/packages/browseros-agent/apps/server/tests/api/routes/scheduled-jobs.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'bun:test'
+import { createScheduledJobRoutes } from '../../../src/api/routes/scheduled-jobs'
+import type { ScheduledJobRow } from '../../../src/lib/db/schema'
+import type {
+ ScheduledJobStore,
+ ScheduledJobUpsert,
+} from '../../../src/lib/schedules/schedule-store'
+
+const JOB_ID = 'job-1'
+
+function row(overrides: Partial = {}): ScheduledJobRow {
+ return {
+ id: JOB_ID,
+ profileId: null,
+ name: 'Morning digest',
+ query: 'summarise my inbox',
+ scheduleType: 'daily',
+ scheduleTime: '09:00',
+ scheduleInterval: null,
+ enabled: true,
+ providerId: 'provider-1',
+ lastRunAt: null,
+ createdAt: 1,
+ updatedAt: 1,
+ ...overrides,
+ }
+}
+
+function memoryStore(initial: ScheduledJobRow[] = []) {
+ const rows = new Map(initial.map((r) => [r.id, r]))
+ const store: ScheduledJobStore = {
+ list: async () => [...rows.values()],
+ get: async (id) => rows.get(id) ?? null,
+ upsert: async (input: ScheduledJobUpsert) => {
+ const existing = rows.get(input.id)
+ const saved = {
+ ...row(),
+ ...input,
+ createdAt: existing?.createdAt ?? input.createdAt ?? 100,
+ updatedAt: 200,
+ } as ScheduledJobRow
+ rows.set(saved.id, saved)
+ return saved
+ },
+ remove: async (id) => rows.delete(id),
+ }
+ return { store, rows }
+}
+
+const body = {
+ name: 'Morning digest',
+ query: 'summarise my inbox',
+ scheduleType: 'daily' as const,
+ scheduleTime: '09:00',
+ providerId: 'provider-1',
+}
+
+function put(
+ routes: ReturnType,
+ payload: unknown,
+) {
+ return routes.request(`/${JOB_ID}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+}
+
+describe('scheduled job routes', () => {
+ it('lists jobs', async () => {
+ const routes = createScheduledJobRoutes(memoryStore([row()]))
+ const response = await routes.request('/')
+ expect(response.status).toBe(200)
+ expect(await response.json()).toMatchObject({
+ jobs: [{ id: JOB_ID, name: 'Morning digest' }],
+ })
+ })
+
+ it('returns 404 for an unknown job', async () => {
+ const routes = createScheduledJobRoutes(memoryStore())
+ expect((await routes.request(`/${JOB_ID}`)).status).toBe(404)
+ })
+
+ it('creates a job under the id from the path', async () => {
+ const { store, rows } = memoryStore()
+ const response = await put(createScheduledJobRoutes({ store }), body)
+ expect(response.status).toBe(200)
+ expect(rows.get(JOB_ID)?.query).toBe('summarise my inbox')
+ })
+
+ it('is idempotent: putting the same id twice keeps one row', async () => {
+ const { store, rows } = memoryStore()
+ const routes = createScheduledJobRoutes({ store })
+ await put(routes, body)
+ await put(routes, body)
+ expect(rows.size).toBe(1)
+ })
+
+ // The job keeps pointing at the provider it was created against, which is
+ // the reference the migration has to preserve when both move together.
+ it('preserves the provider reference', async () => {
+ const { store, rows } = memoryStore()
+ await put(createScheduledJobRoutes({ store }), body)
+ expect(rows.get(JOB_ID)?.providerId).toBe('provider-1')
+ })
+
+ it('accepts a job with no provider attached', async () => {
+ const { store, rows } = memoryStore()
+ const response = await put(createScheduledJobRoutes({ store }), {
+ ...body,
+ providerId: null,
+ })
+ expect(response.status).toBe(200)
+ expect(rows.get(JOB_ID)?.providerId).toBeNull()
+ })
+
+ it('rejects an unknown schedule type', async () => {
+ const routes = createScheduledJobRoutes(memoryStore())
+ const response = await put(routes, { ...body, scheduleType: 'weekly' })
+ expect(response.status).toBe(400)
+ })
+
+ it('rejects a body missing the query', async () => {
+ const routes = createScheduledJobRoutes(memoryStore())
+ const response = await put(routes, { name: 'no query' })
+ expect(response.status).toBe(400)
+ })
+
+ it('deletes a job', async () => {
+ const { store, rows } = memoryStore([row()])
+ const routes = createScheduledJobRoutes({ store })
+ expect(
+ (await routes.request(`/${JOB_ID}`, { method: 'DELETE' })).status,
+ ).toBe(200)
+ expect(rows.size).toBe(0)
+ })
+
+ it('returns 404 deleting an unknown job', async () => {
+ const routes = createScheduledJobRoutes(memoryStore())
+ expect(
+ (await routes.request(`/${JOB_ID}`, { method: 'DELETE' })).status,
+ ).toBe(404)
+ })
+})
From 86237e4871082f0566a2b2763d1423ad32075177 Mon Sep 17 00:00:00 2001
From: Dani Akash
Date: Wed, 2 Sep 2026 09:04:23 +0530
Subject: [PATCH 03/12] feat(server): add the schema and migration for the new
tables
The previous commit shipped the routes, stores and tests but not the tables
they depend on. The server .gitignore has a bare db/ rule, meant for a
runtime database directory, which also matches src/lib/db/ and silently
swallowed the new schema files and the migration. The existing schema files
are tracked only because they were force-added the same way.
---
...8_add_llm_providers_and_scheduled_jobs.sql | 41 ++
.../lib/db/migrations/meta/0008_snapshot.json | 547 ++++++++++++++++++
.../server/src/lib/db/schema/llm-providers.ts | 56 ++
.../src/lib/db/schema/scheduled-jobs.ts | 52 ++
4 files changed, 696 insertions(+)
create mode 100644 packages/browseros-agent/apps/server/src/lib/db/migrations/0008_add_llm_providers_and_scheduled_jobs.sql
create mode 100644 packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0008_snapshot.json
create mode 100644 packages/browseros-agent/apps/server/src/lib/db/schema/llm-providers.ts
create mode 100644 packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-jobs.ts
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/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/schema/llm-providers.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/llm-providers.ts
new file mode 100644
index 0000000000..655867a6ac
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/db/schema/llm-providers.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
+import { index, integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
+
+/**
+ * LLM providers, mirroring the shape the extension holds today.
+ *
+ * Credentials live here in the clear, alongside the OAuth tokens already in
+ * this database. Nothing beyond filesystem permissions protects them, which is
+ * the same posture they had in extension storage.
+ *
+ * `profileId` is reserved and currently always null: every browser profile on
+ * a machine shares one database, because no extension API exposes a profile
+ * identifier. The column exists so isolation can be switched on later without
+ * a second migration.
+ */
+export const llmProviders = sqliteTable(
+ 'llm_providers',
+ {
+ id: text('id').primaryKey(),
+ profileId: text('profile_id'),
+ type: text('type').notNull(),
+ name: text('name').notNull(),
+ baseUrl: text('base_url'),
+ modelId: text('model_id').notNull(),
+ supportsImages: integer('supports_images', { mode: 'boolean' })
+ .notNull()
+ .default(true),
+ contextWindow: integer('context_window').notNull(),
+ // 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'),
+
+ reasoningEffort: text('reasoning_effort'),
+ reasoningSummary: text('reasoning_summary'),
+
+ createdAt: integer('created_at').notNull(),
+ updatedAt: integer('updated_at').notNull(),
+ },
+ (table) => [index('llm_providers_profile_id_idx').on(table.profileId)],
+)
+
+export type LlmProviderRow = InferSelectModel
+export type NewLlmProviderRow = 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..97c4aaa903
--- /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 { llmProviders } from './llm-providers'
+
+/**
+ * Scheduled jobs, mirroring the shape the extension holds today.
+ *
+ * `providerId` keeps the extension's field name rather than the cloud's
+ * `llmProviderId`, because the extension copy is the migration source and the
+ * one that carries credentials.
+ *
+ * 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(() => llmProviders.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
From 8c73b8527b2fff70327282d67b1da996e80ead9d Mon Sep 17 00:00:00 2001
From: Dani Akash
Date: Wed, 2 Sep 2026 09:05:46 +0530
Subject: [PATCH 04/12] fix(server): anchor the db and identity ignore rules to
the app root
A bare db/ rule matches a directory of that name at any depth, so it covered
src/lib/db/ and tests/lib/db/ as well as the runtime directory it was meant
for. New schema files and migrations landed ignored, and the existing ones
are tracked only because they were force-added.
Anchoring both rules with a leading slash keeps the runtime directories
ignored while leaving source and tests alone. Nothing on disk was missing
from the repository, so this closes a trap rather than recovering anything.
---
packages/browseros-agent/apps/server/.gitignore | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
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/
From 689a01607ff4b2cef9bd6d5da24ea5f8e75b9532 Mon Sep 17 00:00:00 2001
From: Dani Akash
Date: Wed, 2 Sep 2026 14:15:45 +0530
Subject: [PATCH 05/12] feat(app): stop syncing to the cloud (#2519)
* feat(app): stop syncing to the cloud and say so
Every write path to the cloud is gone. Providers, scheduled jobs and chat
turns now stay on the machine, and a one-time notice on the settings page
tells the user what changed.
The chat change is the significant one. A signed-in user's turns were
uploaded by the client while a signed-out user's were persisted by the local
server during /chat. Everyone takes the local path now, so history lands in
SQLite regardless of session. The client keeps no write of its own, which
also retires the durable turn buffer that existed only to survive an
interrupted upload. Incognito still persists nowhere.
The legacy conversation migration is kept but no longer branches on session:
it always drains pre-upgrade local:conversations into the local server, which
is the direction the rest of this work moves data.
The sign-in promote is deleted outright rather than disabled. It uploaded the
local server's history and then deleted the rows it had uploaded, so under
this model it would move data off the machine and drop the local copy.
Cloud reads are untouched. Cloud history still displays and a cloud
conversation can still be opened, which the next phase turns into a union
with the local list.
The notice is past tense because sync stops in this same release, so a
warning about the future would describe something that already happened. It
answers the question people actually have, which is whether they are losing
anything: providers, agents and schedules keep working, cloud chats stay
visible in history for now.
Two consequences of the ignore fix that shipped with the tables. Biome
respects .gitignore, so src/lib/db was never linted and had drift, including
in the schema files added last phase. The hand-written files are formatted
here; the drizzle-generated migration metadata is excluded instead, since
formatting it would fight the generator on every migration.
* fix(app): do not drain a legacy conversation the server did not store
The import route is insert-if-absent, so an id already on the server is
answered with a success that wrote nothing. The client only checked the HTTP
status, reported the conversation as handled, and the caller then deleted the
legacy copy from extension storage. Where the existing server row was an
older, shorter version of the same conversation, the messages it did not
contain were gone.
The route already reported this as `imported`; the client discarded it.
A skipped import is now only treated as handled once the server row is
confirmed to hold every message the legacy copy has, compared by message id
rather than by count so a same-length but different row is not mistaken for
the same content. Anything unconfirmed stays in storage for the next attempt.
The bug predates this branch, but only logged-out users reached this path
before. Routing everyone through it made a latent problem universal, so it
belongs here rather than in a follow-up.
---
.../CloudSyncRetiredNotice.test.tsx | 48 +++
.../cloud-sync/CloudSyncRetiredNotice.tsx | 63 ++++
.../apps/app/entrypoints/background/index.ts | 24 +-
.../app/lib/cloud-sync/cloud-sync-storage.ts | 7 +
.../active-conversation-buffer.test.ts | 97 ------
.../active-conversation-buffer.ts | 72 -----
.../uploadConversationsToGraphql.ts | 105 -------
.../app/lib/llm-providers/storage.test.ts | 4 -
.../apps/app/lib/llm-providers/storage.ts | 24 --
.../uploadLlmProvidersToGraphql.ts | 121 --------
.../lib/schedules/syncSchedulesToBackend.ts | 262 -----------------
.../app/modules/chat/chat-session.hooks.ts | 91 ++----
.../chat/remote-conversation-save.hooks.ts | 116 --------
.../conversations-migration.helpers.ts | 136 +++------
.../conversations-migration.test.ts | 278 ++++++------------
.../conversations/conversations-migration.ts | 68 +----
.../conversations/conversations.hooks.ts | 10 +-
.../llm-providers/llm-providers.hooks.test.ts | 4 -
.../screens/ai-settings/BrowserOsAiPane.tsx | 3 +
.../screens/sidepanel/history/ChatHistory.tsx | 19 +-
.../apps/server/src/lib/db/client.ts | 13 +-
.../apps/server/src/lib/db/schema/index.ts | 2 +-
.../server/src/lib/db/schema/llm-providers.ts | 8 +-
.../apps/server/tests/lib/db/index.test.ts | 2 +-
packages/browseros-agent/biome.json | 3 +-
25 files changed, 318 insertions(+), 1262 deletions(-)
create mode 100644 packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.test.tsx
create mode 100644 packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.tsx
create mode 100644 packages/browseros-agent/apps/app/lib/cloud-sync/cloud-sync-storage.ts
delete mode 100644 packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.test.ts
delete mode 100644 packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.ts
delete mode 100644 packages/browseros-agent/apps/app/lib/conversations/uploadConversationsToGraphql.ts
delete mode 100644 packages/browseros-agent/apps/app/lib/llm-providers/uploadLlmProvidersToGraphql.ts
delete mode 100644 packages/browseros-agent/apps/app/lib/schedules/syncSchedulesToBackend.ts
delete mode 100644 packages/browseros-agent/apps/app/modules/chat/remote-conversation-save.hooks.ts
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..8956d0f887 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,10 +20,6 @@ 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'
@@ -59,8 +50,6 @@ export default defineBackground(() => {
Capabilities.initialize().catch(() => null)
setupLlmProvidersBackupToBrowserOS()
- setupLlmProvidersSyncToBackend()
- setupScheduledJobsSyncToBackend()
scheduledJobRuns()
@@ -151,17 +140,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/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/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/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..d8edcf0be9 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 {
@@ -11,7 +10,6 @@ import {
DEFAULT_PROVIDER_NAME,
} from './provider-selection'
import type { LlmProviderConfig, LlmProvidersBackup } from './types'
-import { uploadLlmProvidersToGraphql } from './uploadLlmProvidersToGraphql'
export { DEFAULT_PROVIDER_ID } from './provider-selection'
@@ -78,28 +76,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/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/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.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/conversations/conversations-migration.helpers.ts b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts
index d8ea52c558..337a6615b3 100644
--- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts
+++ b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts
@@ -1,41 +1,60 @@
-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
+ importToServer: (conversation: Conversation) => Promise<{ imported: boolean }>
+ /** Reads back a server row, to check a skipped import already holds it all. */
+ loadFromServer: (
+ id: string,
+ ) => Promise<{ messages: Array<{ id: string }> } | null>
+}
+
+/** Whether every message in the legacy copy is already on the server row. */
+function serverHoldsEveryMessage(
+ legacy: Conversation,
+ server: { messages: Array<{ id: string }> } | null,
+): boolean {
+ if (!server) return false
+ const stored = new Set(server.messages.map((message) => message.id))
+ return legacy.messages.every((message) => stored.has(message.id))
}
/**
- * 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.
+ * One-shot migration of pre-upgrade `local:conversations` into the local
+ * server. Returns the ids that were handled so the caller can drain them from
+ * storage; anything not returned is left in place for the next attempt.
+ *
+ * This used to send a logged-in user's history to the cloud instead. It now
+ * takes the local path for everyone, which is the direction the rest of this
+ * work moves data.
+ *
+ * The import is insert-if-absent, so a conversation whose id is already on the
+ * server is answered with a success that wrote nothing. Reporting that as
+ * handled would delete the legacy copy against a server row that might be an
+ * older, shorter version of the same conversation, losing whatever it does not
+ * contain. A skipped import is therefore only handled once the server row is
+ * confirmed to hold every message the legacy copy has.
*/
export async function migrateLegacyConversations({
conversations,
- isLoggedIn,
- userId,
importToServer,
- uploadToCloud,
+ loadFromServer,
}: 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)
+ const { imported } = await importToServer(conversation)
+ if (imported) {
+ migrated.push(conversation.id)
+ continue
+ }
+
+ const server = await loadFromServer(conversation.id)
+ if (serverHoldsEveryMessage(conversation, server)) {
+ migrated.push(conversation.id)
+ }
} catch {
// Leave unmigrated conversations in place for the next attempt.
}
@@ -43,81 +62,6 @@ export async function migrateLegacyConversations({
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 {
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
index 13a925ac70..01009e438e 100644
--- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.test.ts
+++ b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.test.ts
@@ -1,236 +1,154 @@
-import { describe, expect, it, mock } from 'bun:test'
-import type { UIMessage } from 'ai'
+import { describe, expect, it } from 'bun:test'
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 }
+function conversation(id: string, messageIds: string[] = ['m1']): Conversation {
+ return {
+ id,
+ messages: messageIds.map((mid) => ({ id: mid })),
+ lastMessagedAt: 1,
+ } as unknown as Conversation
}
-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 neverLoaded = async () => {
+ throw new Error('should not read the server row')
+}
+describe('migrateLegacyConversations', () => {
+ it('does nothing when there is nothing to migrate', async () => {
const handled = await migrateLegacyConversations({
- conversations: [conversation('a'), conversation('b')],
- isLoggedIn: true,
- userId: 'user-1',
- importToServer,
- uploadToCloud,
+ conversations: [],
+ importToServer: async () => {
+ throw new Error('should not be called')
+ },
+ loadFromServer: neverLoaded,
})
-
- expect(handled).toEqual(['a'])
- expect(uploadToCloud).toHaveBeenCalledWith(
- [conversation('a'), conversation('b')],
- 'user-1',
- )
- expect(importToServer).not.toHaveBeenCalled()
+ expect(handled).toEqual([])
})
- it('imports to the server when logged out', async () => {
- const importToServer = mock(async () => {})
- const uploadToCloud = mock(async () => [])
-
+ it('imports every conversation into the local server', async () => {
+ const imported: string[] = []
const handled = await migrateLegacyConversations({
conversations: [conversation('a'), conversation('b')],
- isLoggedIn: false,
- userId: undefined,
- importToServer,
- uploadToCloud,
+ importToServer: async (c) => {
+ imported.push(c.id)
+ return { imported: true }
+ },
+ loadFromServer: neverLoaded,
})
-
+ expect(imported).toEqual(['a', 'b'])
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')
- })
-
+ // A conversation that fails is left in storage so the next attempt retries
+ // it, rather than being dropped as handled.
+ it('reports only the conversations that imported', async () => {
const handled = await migrateLegacyConversations({
conversations: [conversation('a'), conversation('b'), conversation('c')],
- isLoggedIn: false,
- userId: undefined,
- importToServer,
- uploadToCloud: mock(async () => []),
+ importToServer: async (c) => {
+ if (c.id === 'b') throw new Error('server unavailable')
+ return { imported: true }
+ },
+ loadFromServer: neverLoaded,
})
-
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()
+ it('reports nothing when the server is unreachable', async () => {
+ const handled = await migrateLegacyConversations({
+ conversations: [conversation('a')],
+ importToServer: async () => {
+ throw new Error('server unavailable')
+ },
+ loadFromServer: neverLoaded,
+ })
+ expect(handled).toEqual([])
})
})
-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,
- })
+// The import is insert-if-absent: an id already on the server answers with a
+// success that wrote nothing. Draining on that alone deletes the legacy copy
+// against a row that may be an older, shorter version of it.
+describe('migrateLegacyConversations when the import is skipped', () => {
+ const skipped = async () => ({ imported: false })
- 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('drains when the server row already holds every message', async () => {
+ const handled = await migrateLegacyConversations({
+ conversations: [conversation('a', ['m1', 'm2'])],
+ importToServer: skipped,
+ loadFromServer: async () => ({
+ messages: [{ id: 'm1' }, { id: 'm2' }, { id: 'm3' }],
+ }),
+ })
+ expect(handled).toEqual(['a'])
})
- 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,
+ it('keeps the legacy copy when the server row is missing messages', async () => {
+ const handled = await migrateLegacyConversations({
+ conversations: [conversation('a', ['m1', 'm2'])],
+ importToServer: skipped,
+ loadFromServer: async () => ({ messages: [{ id: 'm1' }] }),
})
-
- expect(result.map((conversation) => conversation.id)).toEqual(['a'])
+ expect(handled).toEqual([])
})
-})
-
-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,
+ // Same length, different messages: a count comparison would wrongly drain.
+ it('keeps the legacy copy when the server row differs but is the same size', async () => {
+ const handled = await migrateLegacyConversations({
+ conversations: [conversation('a', ['m1', 'm2'])],
+ importToServer: skipped,
+ loadFromServer: async () => ({
+ messages: [{ id: 'm1' }, { id: 'other' }],
+ }),
})
-
- expect(result).toEqual({ uploadedIds: [], allUploaded: true })
- expect(upload).not.toHaveBeenCalled()
- expect(drain).not.toHaveBeenCalled()
+ expect(handled).toEqual([])
})
- 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,
+ it('keeps the legacy copy when the server row cannot be read', async () => {
+ const handled = await migrateLegacyConversations({
+ conversations: [conversation('a')],
+ importToServer: skipped,
+ loadFromServer: async () => null,
})
-
- expect(result).toEqual({ uploadedIds: ['a', 'b'], allUploaded: true })
- expect(drain.mock.calls.map((call) => call[0]).sort()).toEqual(['a', 'b'])
+ expect(handled).toEqual([])
})
- 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,
+ it('does not drain when reading the server row throws', async () => {
+ const handled = await migrateLegacyConversations({
+ conversations: [conversation('a')],
+ importToServer: skipped,
+ loadFromServer: async () => {
+ throw new Error('server unavailable')
+ },
})
-
- expect(result).toEqual({ uploadedIds: ['a'], allUploaded: false })
- expect(drain).toHaveBeenCalledTimes(1)
- expect(drain).toHaveBeenCalledWith('a')
+ expect(handled).toEqual([])
})
})
describe('createSerialRunner', () => {
- it('runs tasks one at a time', async () => {
+ it('runs tasks one at a time in order', async () => {
const run = createSerialRunner()
const order: string[] = []
+ const task = (id: string, ms: number) => async () => {
+ await new Promise((resolve) => setTimeout(resolve, ms))
+ order.push(id)
+ return id
+ }
- 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])
+ await Promise.all([run(task('slow', 20)), run(task('fast', 1))])
- expect(order).toEqual(['1-start', '1-end', '2-start', '2-end'])
+ expect(order).toEqual(['slow', 'fast'])
})
- it('runs the next task even when the previous one rejects', async () => {
+ it('keeps running after a task rejects', async () => {
const run = createSerialRunner()
- const ran: string[] = []
-
- const first = run(async () => {
+ await run(async () => {
throw new Error('boom')
- })
- const second = run(async () => {
- ran.push('second')
- })
+ }).catch(() => undefined)
- await expect(first).rejects.toThrow('boom')
- await second
- expect(ran).toEqual(['second'])
+ await expect(run(async () => 'next')).resolves.toBe('next')
})
})
diff --git a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts
index 49223f50f1..2b64bda3e5 100644
--- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts
+++ b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts
@@ -2,20 +2,15 @@ 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'
/**
@@ -25,7 +20,7 @@ import {
*/
export function useLegacyConversationMigration(): void {
const { sessionInfo } = useSessionInfo()
- const userId = sessionInfo.user?.id
+ const _userId = sessionInfo.user?.id
const queryClient = useQueryClient()
useEffect(() => {
@@ -36,10 +31,8 @@ export function useLegacyConversationMigration(): void {
const handledIds = await migrateLegacyConversations({
conversations,
- isLoggedIn: !!userId,
- userId,
importToServer: importServerConversation,
- uploadToCloud: uploadConversations,
+ loadFromServer: fetchServerConversation,
})
if (cancelled || handledIds.length === 0) return
@@ -47,11 +40,9 @@ export function useLegacyConversationMigration(): void {
await conversationStorage.setValue(
current.filter((conversation) => !handledIds.includes(conversation.id)),
)
- if (!userId) {
- queryClient.invalidateQueries({
- queryKey: [SERVER_CONVERSATIONS_QUERY_KEY],
- })
- }
+ queryClient.invalidateQueries({
+ queryKey: [SERVER_CONVERSATIONS_QUERY_KEY],
+ })
}
run().catch((error) => {
sentry.captureException(error, {
@@ -61,16 +52,16 @@ export function useLegacyConversationMigration(): void {
return () => {
cancelled = true
}
- }, [userId, queryClient])
+ }, [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
+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()
+const _runPromoteExclusive = createSerialRunner()
/**
* On sign-in, promote server-held (logged-out) history to the cloud (draining
@@ -78,46 +69,3 @@ const runPromoteExclusive = createSerialRunner()
* 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..cdfa4ee75c 100644
--- a/packages/browseros-agent/apps/app/modules/conversations/conversations.hooks.ts
+++ b/packages/browseros-agent/apps/app/modules/conversations/conversations.hooks.ts
@@ -58,11 +58,17 @@ export async function fetchServerConversation(
return { id: data.conversation.id, messages }
}
+/**
+ * Returns whether the conversation was actually written. The route is
+ * insert-if-absent, so an id that already exists is answered with a success
+ * and `imported: false`, and the caller must not treat that as stored: the
+ * existing row could be an older, shorter copy of the same conversation.
+ */
export async function importServerConversation(conversation: {
id: string
messages: UIMessage[]
lastMessagedAt: number
-}): Promise {
+}): Promise<{ imported: boolean }> {
const client = await conversationsClient()
const response = await client[':conversationId'].$put({
param: { conversationId: conversation.id },
@@ -74,6 +80,8 @@ export async function importServerConversation(conversation: {
if (!response.ok) {
throw new Error(`Failed to import conversation (${response.status})`)
}
+ const data = await response.json()
+ return { imported: 'imported' in data ? Boolean(data.imported) : false }
}
/** Deletes only the server row (tolerating 404); leaves execution history. */
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..7efbfbad77 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
@@ -91,10 +91,6 @@ mock.module('../../lib/llm-providers/storage', () => ({
},
}))
-mock.module('@/lib/llm-providers/uploadLlmProvidersToGraphql', () => ({
- uploadLlmProvidersToGraphql: async () => {},
-}))
-
const timestamp = 1000
function providerConfig(
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 16074f4072..a389044a98 100644
--- a/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx
+++ b/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx
@@ -2,6 +2,7 @@ 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 {
AlertDialog,
@@ -413,6 +414,8 @@ export const BrowserOsAiPane: FC = () => {
+
+
diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/ChatHistory.tsx b/packages/browseros-agent/apps/app/screens/sidepanel/history/ChatHistory.tsx
index 77f65bc252..ecb3286b93 100644
--- a/packages/browseros-agent/apps/app/screens/sidepanel/history/ChatHistory.tsx
+++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/ChatHistory.tsx
@@ -2,15 +2,12 @@ import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
import type { UIMessage } from 'ai'
import { Loader2 } from 'lucide-react'
import type { FC } from 'react'
-import { useCallback, useMemo } from 'react'
+import { useMemo } from 'react'
import { useSessionInfo } from '@/lib/auth/sessionStorage'
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
import { useChatSessionContext } from '@/modules/chat/chat-session-context'
-import {
- useLegacyConversationMigration,
- useSignInConversationPromote,
-} from '@/modules/conversations/conversations-migration'
+import { useLegacyConversationMigration } from '@/modules/conversations/conversations-migration'
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'
@@ -124,18 +121,8 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
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.
+ // Drain any pre-upgrade local:conversations into the local server.
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
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..c53de801ab 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))
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 00f485e165..44b4e7de06 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
@@ -7,5 +7,5 @@
export * from './agents'
export * from './conversations'
export * from './llm-providers'
-export * from './scheduled-jobs'
export * from './oauth'
+export * from './scheduled-jobs'
diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/llm-providers.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/llm-providers.ts
index 655867a6ac..efab06e7e1 100644
--- a/packages/browseros-agent/apps/server/src/lib/db/schema/llm-providers.ts
+++ b/packages/browseros-agent/apps/server/src/lib/db/schema/llm-providers.ts
@@ -5,7 +5,13 @@
*/
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
-import { index, integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
+import {
+ index,
+ integer,
+ real,
+ sqliteTable,
+ text,
+} from 'drizzle-orm/sqlite-core'
/**
* LLM providers, mirroring the shape the extension holds today.
diff --git a/packages/browseros-agent/apps/server/tests/lib/db/index.test.ts b/packages/browseros-agent/apps/server/tests/lib/db/index.test.ts
index 2aa880960f..379cedb234 100644
--- a/packages/browseros-agent/apps/server/tests/lib/db/index.test.ts
+++ b/packages/browseros-agent/apps/server/tests/lib/db/index.test.ts
@@ -3,8 +3,8 @@
* Copyright 2025 BrowserOS
*/
-import { afterEach, describe, expect, it } from 'bun:test'
import { Database as BunDatabase } from 'bun:sqlite'
+import { afterEach, describe, expect, it } from 'bun:test'
import { existsSync, mkdirSync, mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
diff --git a/packages/browseros-agent/biome.json b/packages/browseros-agent/biome.json
index 1772057681..95b2334ed5 100644
--- a/packages/browseros-agent/biome.json
+++ b/packages/browseros-agent/biome.json
@@ -13,7 +13,8 @@
"!**/*.svg",
"!packages/claw-api/src/generated",
"!contracts/claw-mcp/fixtures/pages",
- "!crates/browseros-core/tests/data/captured"
+ "!crates/browseros-core/tests/data/captured",
+ "!apps/server/src/lib/db/migrations"
]
},
"formatter": {
From f7aa30fbc0c21ead3cd8b71d95834d0c3ffde771 Mon Sep 17 00:00:00 2001
From: Dani Akash
Date: Wed, 2 Sep 2026 16:17:53 +0530
Subject: [PATCH 06/12] feat(app): show local and cloud history together
(#2520)
* feat(app): show local and cloud history together
History was one or the other: signed in showed only the cloud list, signed
out showed only the local server. A signed-in user could not see the
conversations their own machine was storing, which is now where every new
chat lands.
The local list is always shown and comes first. Conversations still held in
the account appear beneath it under their own heading, saying what they are
and that they do not live on this device. Grouped rather than interleaved:
the cloud is a shelf that empties when it is retired, blending it into the
local list would hide that, and merging two cursor-paginated sources by date
against one scroll position buys nothing here.
Deduplicated by id, local winning. The same conversation id is used by
extension storage, the local server and the cloud, so anything synced before
sync was turned off exists in both lists.
Two composition problems came out of rendering both at once. The list owned
its own scroll container and rendered a , which was fine while exactly
one ever rendered and would have been two competing scroll areas and two
landmarks side by side; the screen owns a single one now. The empty state
also read "No conversations yet" directly above a populated cloud section,
which is the common case immediately after this ships, so it names the store
it is talking about instead.
* fix(app): keep paging the cloud past a page that is entirely local
Cloud pagination is driven by a sentinel rendered inside the list, and the
list is not rendered while the section has nothing to show. A page whose
conversations all exist locally deduplicates away to nothing, so the section
returned null, the sentinel never mounted, and the cloud-only conversations
behind that page could not be reached.
Two guards were involved. The section returned early when nothing was
visible, and the sentinel itself sits in the branch the list renders only
when it has conversations, so removing the first guard alone would not have
helped.
The stalling page is the ordinary one immediately after this ships. Legacy
conversations are drained into the local server while the same conversations
are already in the account, and being the most recent they sort onto the
first cloud page.
The section now pulls the next page itself while it has nothing visible and
pages remain, handing back to the sentinel as soon as something renders. It
terminates when the pages run out, so a user whose whole account history is
duplicated locally walks the pages once and is shown nothing, which is
correct.
The decision is a pure function so the conditions are testable without a
renderer, including that a fetch is not stacked on one already in flight.
* refactor(app): drop the legacy conversation drain
Conversations are already written straight to SQLite: the server persists
each completed turn during /chat. The drain was a separate path that read
pre-upgrade local:conversations from extension storage and posted them back
to the server, which is a hop the data does not need.
It also ran only for logged-out users before this epic, and widening it to
everyone was not asked for. That widening was the sole way a conversation
could end up in both the account and SQLite, which is what the deduplication
in the history union exists to handle.
Nothing writes local:conversations any more, so the leftovers stay in
extension storage untouched rather than being deleted. Conversation history
is the data we accept losing when the cloud is retired, so paying to move it
was the wrong trade.
Removes the migration module, its helpers and tests, the client import
helper, and the legacy storage definition, along with a dead serial runner
left behind when the sign-in promote was removed.
Deduplication and the paging that goes past a fully deduplicated page stay.
Overlap is now only possible from a promote that uploaded to the account and
then failed to delete the local rows, which could easily cover a whole page.
* fix(app): stop cloud history auto-advance after a failed page
A rejected fetchNextPage leaves hasNextPage true, because it is derived
from the last successful page, while the in-flight flag clears. Every
input to the advance guard returned to its pre-fetch value, so the
section restarted the fetch with no user interaction and a persistently
failing request retried forever.
Guard on isFetchNextPageError so a failed page settles instead.
---
.../lib/conversations/conversationStorage.ts | 15 --
.../conversations-migration.helpers.ts | 77 --------
.../conversations-migration.test.ts | 154 ----------------
.../conversations/conversations-migration.ts | 71 --------
.../conversations/conversations.hooks.ts | 26 ---
.../sidepanel/history/ChatHistory.test.tsx | 96 ++++++++++
.../screens/sidepanel/history/ChatHistory.tsx | 148 +++------------
.../history/cloud/CloudChatHistory.tsx | 171 ++++++++++++++++++
.../history/components/ConversationList.tsx | 118 ++++++------
.../history/history-union.helpers.test.ts | 118 ++++++++++++
.../history/history-union.helpers.ts | 55 ++++++
.../history/local/LocalChatHistory.tsx | 1 +
12 files changed, 527 insertions(+), 523 deletions(-)
delete mode 100644 packages/browseros-agent/apps/app/lib/conversations/conversationStorage.ts
delete mode 100644 packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts
delete mode 100644 packages/browseros-agent/apps/app/modules/conversations/conversations-migration.test.ts
delete mode 100644 packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts
create mode 100644 packages/browseros-agent/apps/app/screens/sidepanel/history/ChatHistory.test.tsx
create mode 100644 packages/browseros-agent/apps/app/screens/sidepanel/history/cloud/CloudChatHistory.tsx
create mode 100644 packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.test.ts
create mode 100644 packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.ts
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/modules/conversations/conversations-migration.helpers.ts b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts
deleted file mode 100644
index 337a6615b3..0000000000
--- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import type { Conversation } from '@/lib/conversations/conversationStorage'
-
-export interface MigrateLegacyConversationsOptions {
- conversations: Conversation[]
- importToServer: (conversation: Conversation) => Promise<{ imported: boolean }>
- /** Reads back a server row, to check a skipped import already holds it all. */
- loadFromServer: (
- id: string,
- ) => Promise<{ messages: Array<{ id: string }> } | null>
-}
-
-/** Whether every message in the legacy copy is already on the server row. */
-function serverHoldsEveryMessage(
- legacy: Conversation,
- server: { messages: Array<{ id: string }> } | null,
-): boolean {
- if (!server) return false
- const stored = new Set(server.messages.map((message) => message.id))
- return legacy.messages.every((message) => stored.has(message.id))
-}
-
-/**
- * One-shot migration of pre-upgrade `local:conversations` into the local
- * server. Returns the ids that were handled so the caller can drain them from
- * storage; anything not returned is left in place for the next attempt.
- *
- * This used to send a logged-in user's history to the cloud instead. It now
- * takes the local path for everyone, which is the direction the rest of this
- * work moves data.
- *
- * The import is insert-if-absent, so a conversation whose id is already on the
- * server is answered with a success that wrote nothing. Reporting that as
- * handled would delete the legacy copy against a server row that might be an
- * older, shorter version of the same conversation, losing whatever it does not
- * contain. A skipped import is therefore only handled once the server row is
- * confirmed to hold every message the legacy copy has.
- */
-export async function migrateLegacyConversations({
- conversations,
- importToServer,
- loadFromServer,
-}: MigrateLegacyConversationsOptions): Promise {
- if (conversations.length === 0) return []
-
- const migrated: string[] = []
- for (const conversation of conversations) {
- try {
- const { imported } = await importToServer(conversation)
- if (imported) {
- migrated.push(conversation.id)
- continue
- }
-
- const server = await loadFromServer(conversation.id)
- if (serverHoldsEveryMessage(conversation, server)) {
- migrated.push(conversation.id)
- }
- } catch {
- // Leave unmigrated conversations in place for the next attempt.
- }
- }
- return migrated
-}
-
-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 01009e438e..0000000000
--- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.test.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import { describe, expect, it } from 'bun:test'
-import type { Conversation } from '@/lib/conversations/conversationStorage'
-import {
- createSerialRunner,
- migrateLegacyConversations,
-} from './conversations-migration.helpers'
-
-function conversation(id: string, messageIds: string[] = ['m1']): Conversation {
- return {
- id,
- messages: messageIds.map((mid) => ({ id: mid })),
- lastMessagedAt: 1,
- } as unknown as Conversation
-}
-
-const neverLoaded = async () => {
- throw new Error('should not read the server row')
-}
-
-describe('migrateLegacyConversations', () => {
- it('does nothing when there is nothing to migrate', async () => {
- const handled = await migrateLegacyConversations({
- conversations: [],
- importToServer: async () => {
- throw new Error('should not be called')
- },
- loadFromServer: neverLoaded,
- })
- expect(handled).toEqual([])
- })
-
- it('imports every conversation into the local server', async () => {
- const imported: string[] = []
- const handled = await migrateLegacyConversations({
- conversations: [conversation('a'), conversation('b')],
- importToServer: async (c) => {
- imported.push(c.id)
- return { imported: true }
- },
- loadFromServer: neverLoaded,
- })
- expect(imported).toEqual(['a', 'b'])
- expect(handled).toEqual(['a', 'b'])
- })
-
- // A conversation that fails is left in storage so the next attempt retries
- // it, rather than being dropped as handled.
- it('reports only the conversations that imported', async () => {
- const handled = await migrateLegacyConversations({
- conversations: [conversation('a'), conversation('b'), conversation('c')],
- importToServer: async (c) => {
- if (c.id === 'b') throw new Error('server unavailable')
- return { imported: true }
- },
- loadFromServer: neverLoaded,
- })
- expect(handled).toEqual(['a', 'c'])
- })
-
- it('reports nothing when the server is unreachable', async () => {
- const handled = await migrateLegacyConversations({
- conversations: [conversation('a')],
- importToServer: async () => {
- throw new Error('server unavailable')
- },
- loadFromServer: neverLoaded,
- })
- expect(handled).toEqual([])
- })
-})
-
-// The import is insert-if-absent: an id already on the server answers with a
-// success that wrote nothing. Draining on that alone deletes the legacy copy
-// against a row that may be an older, shorter version of it.
-describe('migrateLegacyConversations when the import is skipped', () => {
- const skipped = async () => ({ imported: false })
-
- it('drains when the server row already holds every message', async () => {
- const handled = await migrateLegacyConversations({
- conversations: [conversation('a', ['m1', 'm2'])],
- importToServer: skipped,
- loadFromServer: async () => ({
- messages: [{ id: 'm1' }, { id: 'm2' }, { id: 'm3' }],
- }),
- })
- expect(handled).toEqual(['a'])
- })
-
- it('keeps the legacy copy when the server row is missing messages', async () => {
- const handled = await migrateLegacyConversations({
- conversations: [conversation('a', ['m1', 'm2'])],
- importToServer: skipped,
- loadFromServer: async () => ({ messages: [{ id: 'm1' }] }),
- })
- expect(handled).toEqual([])
- })
-
- // Same length, different messages: a count comparison would wrongly drain.
- it('keeps the legacy copy when the server row differs but is the same size', async () => {
- const handled = await migrateLegacyConversations({
- conversations: [conversation('a', ['m1', 'm2'])],
- importToServer: skipped,
- loadFromServer: async () => ({
- messages: [{ id: 'm1' }, { id: 'other' }],
- }),
- })
- expect(handled).toEqual([])
- })
-
- it('keeps the legacy copy when the server row cannot be read', async () => {
- const handled = await migrateLegacyConversations({
- conversations: [conversation('a')],
- importToServer: skipped,
- loadFromServer: async () => null,
- })
- expect(handled).toEqual([])
- })
-
- it('does not drain when reading the server row throws', async () => {
- const handled = await migrateLegacyConversations({
- conversations: [conversation('a')],
- importToServer: skipped,
- loadFromServer: async () => {
- throw new Error('server unavailable')
- },
- })
- expect(handled).toEqual([])
- })
-})
-
-describe('createSerialRunner', () => {
- it('runs tasks one at a time in order', async () => {
- const run = createSerialRunner()
- const order: string[] = []
- const task = (id: string, ms: number) => async () => {
- await new Promise((resolve) => setTimeout(resolve, ms))
- order.push(id)
- return id
- }
-
- await Promise.all([run(task('slow', 20)), run(task('fast', 1))])
-
- expect(order).toEqual(['slow', 'fast'])
- })
-
- it('keeps running after a task rejects', async () => {
- const run = createSerialRunner()
- await run(async () => {
- throw new Error('boom')
- }).catch(() => undefined)
-
- await expect(run(async () => 'next')).resolves.toBe('next')
- })
-})
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 2b64bda3e5..0000000000
--- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts
+++ /dev/null
@@ -1,71 +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 { sentry } from '@/lib/sentry/sentry'
-import {
- fetchServerConversation,
- importServerConversation,
- SERVER_CONVERSATIONS_QUERY_KEY,
-} from './conversations.hooks'
-import {
- createSerialRunner,
- migrateLegacyConversations,
-} 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,
- importToServer: importServerConversation,
- loadFromServer: fetchServerConversation,
- })
- if (cancelled || handledIds.length === 0) return
-
- const current = (await conversationStorage.getValue()) ?? []
- await conversationStorage.setValue(
- current.filter((conversation) => !handledIds.includes(conversation.id)),
- )
- queryClient.invalidateQueries({
- queryKey: [SERVER_CONVERSATIONS_QUERY_KEY],
- })
- }
- run().catch((error) => {
- sentry.captureException(error, {
- extra: { message: 'Legacy conversation migration failed' },
- })
- })
- return () => {
- cancelled = true
- }
- }, [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.
- */
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 cdfa4ee75c..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,32 +58,6 @@ export async function fetchServerConversation(
return { id: data.conversation.id, messages }
}
-/**
- * Returns whether the conversation was actually written. The route is
- * insert-if-absent, so an id that already exists is answered with a success
- * and `imported: false`, and the caller must not treat that as stored: the
- * existing row could be an older, shorter copy of the same conversation.
- */
-export async function importServerConversation(conversation: {
- id: string
- messages: UIMessage[]
- lastMessagedAt: number
-}): Promise<{ imported: boolean }> {
- 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})`)
- }
- const data = await response.json()
- return { imported: 'imported' in data ? Boolean(data.imported) : false }
-}
-
/** Deletes only the server row (tolerating 404); leaves execution history. */
export async function deleteServerConversationRow(
conversationId: string,
diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/ChatHistory.test.tsx b/packages/browseros-agent/apps/app/screens/sidepanel/history/ChatHistory.test.tsx
new file mode 100644
index 0000000000..ae69cccd6a
--- /dev/null
+++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/ChatHistory.test.tsx
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it, mock } from 'bun:test'
+import { createElement, type FC } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+
+let sessionUserId: string | undefined
+let localRows: Array<{
+ id: string
+ lastMessagedAt: number
+ lastUserMessage: string
+}>
+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
- // Drain any pre-upgrade local:conversations into the local server.
- useLegacyConversationMigration()
-
- 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"
/>
)
}
From bfa7e8bd25868ebb3a2d48899509c4849b5a1b91 Mon Sep 17 00:00:00 2001
From: Dani Akash
Date: Thu, 3 Sep 2026 09:46:44 +0530
Subject: [PATCH 07/12] feat: migrate providers and scheduled jobs into local
storage (#2523)
* feat(server): add insert-if-absent import for providers and jobs
The import must fill gaps without replacing. The app writes to these
tables directly, so an upsert would let a second run restore a stale
copy over a row the user edited since. onConflictDoNothing gives the
absent-or-present decision in one statement.
Also guards /llm-providers and /scheduled-jobs with the app-origin
check the other protected routes already use. The blanket trusted-origin
middleware only rejects a request carrying a disallowed Origin, so one
with no Origin passed straight through to rows holding API keys.
* feat(app): migrate providers and scheduled jobs into the server once
Reads extension storage and the browseros.providers pref backup, unions
them with storage winning, and posts both to the import endpoints. The
pref backup covers the reinstall case where extension storage was
cleared but the per-profile pref outlived it.
The cloud is not a source. Its scheduled jobs include every job deleted
since the deletion queue lost its only reader, so importing them would
bring deleted jobs back. Its providers never carried credentials and
already surface through the incomplete-provider prompt in AI settings.
A done marker in per-profile storage stops it repeating. The marker is
set only after both imports land, so a failure retries on next startup,
which is safe because the server inserts only what is absent.
* fix(app): drop unimportable entries instead of failing the batch
The import is one request, so a single entry the server rejects returned
400 for every provider in it, blocked the scheduled jobs behind it, and
left the done marker unset. That would repeat on every startup, because
the pref backup it came from has no migration path and the user cannot
edit it.
Providers and jobs are now checked against exactly what the server
requires, and optional fields holding the wrong type are dropped so the
server default applies rather than the batch failing. Filtering runs
before the merge so an unusable stored entry cannot win the id and take
a good backup copy with it.
Removed provider types are excluded too. Storage migrations drop them,
the pref backup never gets that treatment, so a stale one could import a
provider of a type the app no longer supports.
---
.../apps/app/entrypoints/background/index.ts | 2 +
.../llm-providers/removed-provider-types.ts | 22 ++
.../apps/app/lib/llm-providers/storage.ts | 17 +-
.../local-first-migration.helpers.test.ts | 239 ++++++++++++++++++
.../local-first-migration.helpers.ts | 199 +++++++++++++++
.../local-first-migration.test.ts | 223 ++++++++++++++++
.../local-first-migration.ts | 80 ++++++
.../start-local-first-migration.ts | 66 +++++
.../apps/server/src/api/routes/index.ts | 6 +
.../server/src/api/routes/llm-providers.ts | 21 ++
.../server/src/api/routes/scheduled-jobs.ts | 14 +
.../src/lib/llm-providers/provider-store.ts | 26 +-
.../src/lib/schedules/schedule-store.ts | 21 +-
.../browseros-agent/apps/server/src/rpc.ts | 4 +
.../server/tests/api/routes/index.test.ts | 32 +++
.../tests/api/routes/llm-providers.test.ts | 68 +++++
.../tests/api/routes/scheduled-jobs.test.ts | 43 ++++
.../lib/llm-providers/provider-store.test.ts | 87 +++++++
18 files changed, 1152 insertions(+), 18 deletions(-)
create mode 100644 packages/browseros-agent/apps/app/lib/llm-providers/removed-provider-types.ts
create mode 100644 packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.test.ts
create mode 100644 packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.ts
create mode 100644 packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.test.ts
create mode 100644 packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.ts
create mode 100644 packages/browseros-agent/apps/app/modules/local-first-migration/start-local-first-migration.ts
create mode 100644 packages/browseros-agent/apps/server/tests/lib/llm-providers/provider-store.test.ts
diff --git a/packages/browseros-agent/apps/app/entrypoints/background/index.ts b/packages/browseros-agent/apps/app/entrypoints/background/index.ts
index 8956d0f887..de64151ec2 100644
--- a/packages/browseros-agent/apps/app/entrypoints/background/index.ts
+++ b/packages/browseros-agent/apps/app/entrypoints/background/index.ts
@@ -23,6 +23,7 @@ import { authRedirectPathStorage } from '@/lib/onboarding/onboardingStorage'
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 = [
@@ -50,6 +51,7 @@ export default defineBackground(() => {
Capabilities.initialize().catch(() => null)
setupLlmProvidersBackupToBrowserOS()
+ startLocalFirstMigration()
scheduledJobRuns()
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.ts b/packages/browseros-agent/apps/app/lib/llm-providers/storage.ts
index d8edcf0be9..c79b068a61 100644
--- a/packages/browseros-agent/apps/app/lib/llm-providers/storage.ts
+++ b/packages/browseros-agent/apps/app/lib/llm-providers/storage.ts
@@ -9,26 +9,11 @@ import {
DEFAULT_PROVIDER_ID,
DEFAULT_PROVIDER_NAME,
} from './provider-selection'
+import { dropRemovedProviderConfigs } from './removed-provider-types'
import type { LlmProviderConfig, LlmProvidersBackup } from './types'
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',
{
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..9d4d06c773
--- /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 /llm-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..fc7a329e4b
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.test.ts
@@ -0,0 +1,223 @@
+import { describe, expect, it } from 'bun:test'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import type { ScheduledJob } from '@/lib/schedules/scheduleTypes'
+import {
+ type LocalFirstMigrationDeps,
+ runLocalFirstMigration,
+} 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)
+ })
+})
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..53234c608d
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.ts
@@ -0,0 +1,80 @@
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import type { ScheduledJob } 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,
+ }
+}
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..aa5656be38
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/local-first-migration/start-local-first-migration.ts
@@ -0,0 +1,66 @@
+import type { LlmProviderRoutes, 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 { providersStorage } from '@/lib/llm-providers/storage'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import { scheduledJobStorage } from '@/lib/schedules/scheduleStorage'
+import { resolveAgentServerUrlWithRetry } from '@/modules/browseros/agent-server-url.helpers'
+import { runLocalFirstMigration } from './local-first-migration'
+import {
+ type ProviderImport,
+ parseProviderBackup,
+ type ScheduledJobImport,
+} from './local-first-migration.helpers'
+
+/**
+ * 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 },
+)
+
+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}/llm-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})`)
+ }
+}
+
+/** Fire and forget from the background; a failure retries on next startup. */
+export function startLocalFirstMigration(): void {
+ void runLocalFirstMigration({
+ isDone: () => migrationDoneStorage.getValue(),
+ markDone: () => migrationDoneStorage.setValue(true),
+ loadStoredProviders: async () => (await providersStorage.getValue()) ?? [],
+ loadBackupProviders,
+ loadScheduledJobs: async () => (await scheduledJobStorage.getValue()) ?? [],
+ importProviders,
+ importScheduledJobs,
+ }).catch(() => null)
+}
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 0df41b483e..c991576c6e 100644
--- a/packages/browseros-agent/apps/server/src/api/routes/index.ts
+++ b/packages/browseros-agent/apps/server/src/api/routes/index.ts
@@ -136,6 +136,12 @@ 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('/llm-providers/*', requireTrustedAppOrigin())
+ .use('/scheduled-jobs/*', requireTrustedAppOrigin())
.route('/acpx/probe', createAcpxProbeRoutes({ resourcesDir }))
.route('/agents', resolvedAgentRoutes)
.route('/conversations', createConversationRoutes())
diff --git a/packages/browseros-agent/apps/server/src/api/routes/llm-providers.ts b/packages/browseros-agent/apps/server/src/api/routes/llm-providers.ts
index 09720efe50..16d14e27d9 100644
--- a/packages/browseros-agent/apps/server/src/api/routes/llm-providers.ts
+++ b/packages/browseros-agent/apps/server/src/api/routes/llm-providers.ts
@@ -40,6 +40,18 @@ const UpsertProviderSchema = z.object({
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 createLlmProviderRoutes(
options: { store?: LlmProviderStore } = {},
) {
@@ -47,6 +59,15 @@ export function createLlmProviderRoutes(
return new Hono()
.get('/', async (c) => c.json({ providers: await store.list() }))
+ .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)
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
index 522cea75a1..b227a94f4b 100644
--- a/packages/browseros-agent/apps/server/src/api/routes/scheduled-jobs.ts
+++ b/packages/browseros-agent/apps/server/src/api/routes/scheduled-jobs.ts
@@ -33,6 +33,11 @@ const UpsertJobSchema = z.object({
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 } = {},
) {
@@ -40,6 +45,15 @@ export function createScheduledJobRoutes(
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)
diff --git a/packages/browseros-agent/apps/server/src/lib/llm-providers/provider-store.ts b/packages/browseros-agent/apps/server/src/lib/llm-providers/provider-store.ts
index 758b44ed0b..590749e42b 100644
--- a/packages/browseros-agent/apps/server/src/lib/llm-providers/provider-store.ts
+++ b/packages/browseros-agent/apps/server/src/lib/llm-providers/provider-store.ts
@@ -27,8 +27,17 @@ export type LlmProviderUpsert = Omit<
export interface LlmProviderStore {
list(): Promise
get(id: string): Promise
- /** Insert or replace by id. The migration relies on this being idempotent. */
+ /** Insert or replace by id. This is the app's ordinary write path. */
upsert(row: LlmProviderUpsert): 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
+ * directly to this table 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: LlmProviderUpsert): Promise
remove(id: string): Promise
}
@@ -60,6 +69,20 @@ async function upsert(row: LlmProviderUpsert): Promise {
return saved
}
+async function insertIfAbsent(
+ row: LlmProviderUpsert,
+): 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(llmProviders)
+ .values({ ...row, createdAt: row.createdAt ?? now, updatedAt: now })
+ .onConflictDoNothing({ target: llmProviders.id })
+ .returning()
+ return saved ?? null
+}
+
async function remove(id: string): Promise {
const deleted = await getDb()
.delete(llmProviders)
@@ -72,5 +95,6 @@ export const dbLlmProviderStore: LlmProviderStore = {
list,
get,
upsert,
+ insertIfAbsent,
remove,
}
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
index d264a0cab6..dbbb24be27 100644
--- a/packages/browseros-agent/apps/server/src/lib/schedules/schedule-store.ts
+++ b/packages/browseros-agent/apps/server/src/lib/schedules/schedule-store.ts
@@ -27,8 +27,14 @@ export type ScheduledJobUpsert = Omit<
export interface ScheduledJobStore {
list(): Promise
get(id: string): Promise
- /** Insert or replace by id. The migration relies on this being idempotent. */
+ /** 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
}
@@ -58,6 +64,18 @@ async function upsert(row: ScheduledJobUpsert): Promise {
return saved
}
+async function insertIfAbsent(
+ row: ScheduledJobUpsert,
+): Promise {
+ const now = Date.now()
+ const [saved] = await getDb()
+ .insert(scheduledJobs)
+ .values({ ...row, createdAt: row.createdAt ?? now, updatedAt: now })
+ .onConflictDoNothing({ target: scheduledJobs.id })
+ .returning()
+ return saved ?? null
+}
+
async function remove(id: string): Promise {
const deleted = await getDb()
.delete(scheduledJobs)
@@ -70,5 +88,6 @@ export const dbScheduledJobStore: ScheduledJobStore = {
list,
get,
upsert,
+ insertIfAbsent,
remove,
}
diff --git a/packages/browseros-agent/apps/server/src/rpc.ts b/packages/browseros-agent/apps/server/src/rpc.ts
index fd3c21e5ac..6323a7aa13 100644
--- a/packages/browseros-agent/apps/server/src/rpc.ts
+++ b/packages/browseros-agent/apps/server/src/rpc.ts
@@ -1,5 +1,7 @@
import type { createAgentRoutes } from './api/routes/agents'
import type { createConversationRoutes } from './api/routes/conversations'
+import type { createLlmProviderRoutes } from './api/routes/llm-providers'
+import type { createScheduledJobRoutes } from './api/routes/scheduled-jobs'
// Per-route client contracts for `hc`. Each protected route module is mounted at
// its own path in createApiRoutes, and the extension builds a small typed client
@@ -12,3 +14,5 @@ import type { createConversationRoutes } from './api/routes/conversations'
// runtime, no wrapper) while tracking the route definitions automatically.
export type ConversationRoutes = ReturnType
export type AgentRoutes = ReturnType
+export type LlmProviderRoutes = ReturnType
+export type ScheduledJobRoutes = ReturnType
diff --git a/packages/browseros-agent/apps/server/tests/api/routes/index.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/index.test.ts
index 07aa4f446d..2d50c7f7a6 100644
--- a/packages/browseros-agent/apps/server/tests/api/routes/index.test.ts
+++ b/packages/browseros-agent/apps/server/tests/api/routes/index.test.ts
@@ -210,4 +210,36 @@ describe('createApiRoutes', () => {
expect(response.status).toBe(403)
})
+
+ // These rows hold provider API keys in the clear. The blanket
+ // requireTrustedOrigin only rejects a request that carries a disallowed
+ // Origin, so a request with none passes it and the prefix guard is the only
+ // thing standing between another local process and the credentials.
+ it('keeps provider credentials behind app-origin auth', async () => {
+ const app = createTestApp()
+
+ expect((await app.request('/llm-providers')).status).toBe(403)
+ expect(
+ (
+ await app.request('/llm-providers', {}, {
+ server: { requestIP: () => ({ address: '192.168.1.20' }) },
+ } as never)
+ ).status,
+ ).toBe(403)
+ })
+
+ it('keeps scheduled jobs behind app-origin auth', async () => {
+ const app = createTestApp()
+
+ expect((await app.request('/scheduled-jobs')).status).toBe(403)
+ expect(
+ (
+ await app.request('/scheduled-jobs/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ jobs: [] }),
+ })
+ ).status,
+ ).toBe(403)
+ })
})
diff --git a/packages/browseros-agent/apps/server/tests/api/routes/llm-providers.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/llm-providers.test.ts
index ee23a5dd13..5c3a942b78 100644
--- a/packages/browseros-agent/apps/server/tests/api/routes/llm-providers.test.ts
+++ b/packages/browseros-agent/apps/server/tests/api/routes/llm-providers.test.ts
@@ -49,6 +49,10 @@ function memoryStore(initial: LlmProviderRow[] = []) {
rows.set(saved.id, saved)
return saved
},
+ insertIfAbsent: async (input: LlmProviderUpsert) => {
+ if (rows.has(input.id)) return null
+ return store.upsert(input)
+ },
remove: async (id) => rows.delete(id),
}
return { store, rows }
@@ -182,4 +186,68 @@ describe('llm provider routes', () => {
sessionToken: 'token',
})
})
+
+ describe('import', () => {
+ async function importProviders(
+ routes: ReturnType,
+ providers: unknown[],
+ ) {
+ return routes.request('/import', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ providers }),
+ })
+ }
+
+ it('inserts a provider that is not there yet', async () => {
+ const { store, rows } = memoryStore()
+ const routes = createLlmProviderRoutes({ store })
+ const response = await importProviders(routes, [
+ { ...body, id: PROVIDER_ID },
+ ])
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({
+ imported: [PROVIDER_ID],
+ skipped: [],
+ })
+ expect(rows.get(PROVIDER_ID)).toMatchObject({ apiKey: 'sk-test' })
+ })
+
+ // The whole reason import is insert-if-absent: the app writes here
+ // directly, so a second run must not restore the pre-edit copy that is
+ // still sitting in extension storage.
+ it('leaves an existing provider untouched and reports it skipped', async () => {
+ const { store, rows } = memoryStore([row({ name: 'Edited since' })])
+ const routes = createLlmProviderRoutes({ store })
+ const response = await importProviders(routes, [
+ { ...body, id: PROVIDER_ID, name: 'Stale copy' },
+ ])
+
+ expect(await response.json()).toEqual({
+ imported: [],
+ skipped: [PROVIDER_ID],
+ })
+ expect(rows.get(PROVIDER_ID)?.name).toBe('Edited since')
+ })
+
+ it('partitions a mixed batch', async () => {
+ const { store } = memoryStore([row()])
+ const routes = createLlmProviderRoutes({ store })
+ const response = await importProviders(routes, [
+ { ...body, id: PROVIDER_ID },
+ { ...body, id: 'provider-2' },
+ ])
+
+ expect(await response.json()).toEqual({
+ imported: ['provider-2'],
+ skipped: [PROVIDER_ID],
+ })
+ })
+
+ it('rejects a provider with no id', async () => {
+ const routes = createLlmProviderRoutes(memoryStore())
+ expect((await importProviders(routes, [body])).status).toBe(400)
+ })
+ })
})
diff --git a/packages/browseros-agent/apps/server/tests/api/routes/scheduled-jobs.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/scheduled-jobs.test.ts
index 3f7d92a530..49f3c19946 100644
--- a/packages/browseros-agent/apps/server/tests/api/routes/scheduled-jobs.test.ts
+++ b/packages/browseros-agent/apps/server/tests/api/routes/scheduled-jobs.test.ts
@@ -42,6 +42,10 @@ function memoryStore(initial: ScheduledJobRow[] = []) {
rows.set(saved.id, saved)
return saved
},
+ insertIfAbsent: async (input: ScheduledJobUpsert) => {
+ if (rows.has(input.id)) return null
+ return store.upsert(input)
+ },
remove: async (id) => rows.delete(id),
}
return { store, rows }
@@ -141,4 +145,43 @@ describe('scheduled job routes', () => {
(await routes.request(`/${JOB_ID}`, { method: 'DELETE' })).status,
).toBe(404)
})
+
+ describe('import', () => {
+ async function importJobs(
+ routes: ReturnType,
+ jobs: unknown[],
+ ) {
+ return routes.request('/import', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ jobs }),
+ })
+ }
+
+ it('inserts a job that is not there yet', async () => {
+ const { store, rows } = memoryStore()
+ const routes = createScheduledJobRoutes({ store })
+ const response = await importJobs(routes, [{ ...body, id: JOB_ID }])
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({ imported: [JOB_ID], skipped: [] })
+ expect(rows.get(JOB_ID)).toMatchObject({ name: 'Morning digest' })
+ })
+
+ it('leaves an existing job untouched and reports it skipped', async () => {
+ const { store, rows } = memoryStore([row({ name: 'Edited since' })])
+ const routes = createScheduledJobRoutes({ store })
+ const response = await importJobs(routes, [
+ { ...body, id: JOB_ID, name: 'Stale copy' },
+ ])
+
+ expect(await response.json()).toEqual({ imported: [], skipped: [JOB_ID] })
+ expect(rows.get(JOB_ID)?.name).toBe('Edited since')
+ })
+
+ it('rejects a job with no id', async () => {
+ const routes = createScheduledJobRoutes(memoryStore())
+ expect((await importJobs(routes, [body])).status).toBe(400)
+ })
+ })
})
diff --git a/packages/browseros-agent/apps/server/tests/lib/llm-providers/provider-store.test.ts b/packages/browseros-agent/apps/server/tests/lib/llm-providers/provider-store.test.ts
new file mode 100644
index 0000000000..f8df789eda
--- /dev/null
+++ b/packages/browseros-agent/apps/server/tests/lib/llm-providers/provider-store.test.ts
@@ -0,0 +1,87 @@
+import { afterEach, describe, expect, test } from 'bun:test'
+import { mkdtempSync } from 'node:fs'
+import { rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { closeDb, initializeDb } from '../../../src/lib/db'
+import { dbLlmProviderStore } from '../../../src/lib/llm-providers/provider-store'
+
+const PROVIDER_ID = 'provider-1'
+
+function baseProvider() {
+ return {
+ id: PROVIDER_ID,
+ type: 'openai',
+ name: 'My OpenAI',
+ modelId: 'gpt-5.5',
+ contextWindow: 200000,
+ apiKey: 'sk-test',
+ }
+}
+
+describe('dbLlmProviderStore', () => {
+ const tempDirs: string[] = []
+
+ afterEach(async () => {
+ closeDb()
+ await Promise.all(
+ tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
+ )
+ tempDirs.length = 0
+ })
+
+ function useTempDb() {
+ const dir = mkdtempSync(join(tmpdir(), 'browseros-providers-test-'))
+ tempDirs.push(dir)
+ initializeDb({ dbPath: join(dir, 'db', 'browseros.sqlite') })
+ }
+
+ test('insertIfAbsent writes a provider that is not there yet', async () => {
+ useTempDb()
+
+ const saved = await dbLlmProviderStore.insertIfAbsent(baseProvider())
+
+ expect(saved?.id).toBe(PROVIDER_ID)
+ expect(saved?.apiKey).toBe('sk-test')
+ expect(await dbLlmProviderStore.list()).toHaveLength(1)
+ })
+
+ // The behaviour the whole import design rests on: onConflictDoNothing must
+ // return no row, and must leave the existing one exactly as it was.
+ test('insertIfAbsent returns null and changes nothing when the id exists', async () => {
+ useTempDb()
+ await dbLlmProviderStore.upsert({ ...baseProvider(), name: 'Edited since' })
+
+ const saved = await dbLlmProviderStore.insertIfAbsent({
+ ...baseProvider(),
+ name: 'Stale copy',
+ apiKey: 'sk-stale',
+ })
+
+ expect(saved).toBeNull()
+ const existing = await dbLlmProviderStore.get(PROVIDER_ID)
+ expect(existing?.name).toBe('Edited since')
+ expect(existing?.apiKey).toBe('sk-test')
+ })
+
+ // Integer would floor this to 0 and silently make every model deterministic.
+ test('temperature survives as a fraction', async () => {
+ useTempDb()
+ await dbLlmProviderStore.insertIfAbsent({
+ ...baseProvider(),
+ temperature: 0.2,
+ })
+
+ expect((await dbLlmProviderStore.get(PROVIDER_ID))?.temperature).toBe(0.2)
+ })
+
+ test('insertIfAbsent preserves the creation time it is given', async () => {
+ useTempDb()
+ await dbLlmProviderStore.insertIfAbsent({
+ ...baseProvider(),
+ createdAt: 42,
+ })
+
+ expect((await dbLlmProviderStore.get(PROVIDER_ID))?.createdAt).toBe(42)
+ })
+})
From fdd01e8b03f31c3d1a3bf3588a1de23814fb8f9b Mon Sep 17 00:00:00 2001
From: Dani Akash
Date: Thu, 3 Sep 2026 11:24:30 +0530
Subject: [PATCH 08/12] feat: read and write llm providers through the server
(#2537)
* feat(app): read and write llm providers through the server
useLlmProviders keeps its exact interface so both consumers, AI settings
and chat target selection, are untouched. Underneath it is now a
react-query-kit query over Hono RPC instead of extension storage.
It gains an unavailable state. Previously an empty list meant the user
had no providers, and the hook seeded the built-in one in response. Over
HTTP a failed load looks the same as an empty one, so seeding moved into
the fetcher where it can only run on a confirmed empty response, and AI
settings now says the list could not be loaded rather than showing none.
Saving a single-instance provider used to collapse earlier copies as a
side effect of writing the whole list. That is now an explicit plan of
one PUT and the deletes it displaces.
The default provider id stays in extension storage. It is a per-profile
preference and every profile shares one database, so a column would make
them share a default too. A stale id costs nothing because it is
resolved on read.
Logout no longer deletes providers or scheduled jobs. That was right
while they were account data synced to the cloud; they are now local
data the account does not back.
* fix(app): do not substitute a provider the caller did not choose
An unreachable provider list returned an empty array, so a scheduled job
that named a provider found no match and fell through to the built-in
one. It ran on a different model with different credentials and was
recorded as completed. The list being unreachable says nothing about
whether that provider exists, so the two cases are now distinguished and
naming a provider that cannot be loaded fails the run instead. A
provider that was genuinely deleted still falls back, as before.
Deleting a provider also persisted the replacement default before
attempting the delete, so a failed delete left the provider configured
but no longer default with nothing to show for it. The delete goes
first; a default id left pointing at a deleted provider is repaired on
read.
* fix(app): never resolve a provider from a list that failed to load
The previous guard only covered a job that named a provider, which left
the same hole one step over. A job that names none still has a choice
behind it: the configured default, whose id lives in extension storage
but whose model and credentials live in the list. So an unreachable list
sent those runs to the built-in provider and recorded them completed,
which is the case this guard existed to prevent.
The condition drops to the list itself, which also states the invariant
plainly. An empty list keeps the fallback, because that is the server
answering that it genuinely has no providers rather than not answering.
* feat: move scheduled jobs and run history to the server (#2538)
* feat(server): add local storage for scheduled job runs
Job definitions had a table; their run history did not, so it was the
one part of the domain with nowhere to live on this side.
Runs cascade on job delete, unlike the job to provider reference which
is set null. A job whose provider was removed is a job needing
attention, whereas a run whose job was removed means nothing, and
deleting a job already removed its runs before this table existed.
The tool call log is a json column. Its input field is optional here
where the extension has it required: an unknown already admits
undefined, so the two describe the same values, and matching the
validator avoids asserting the difference away at the route boundary.
* feat(server): carry the per-job run cap across with the runs
The extension kept fifteen runs per job, trimming as it created each
one. Now that it no longer owns the history that policy has to live
here, or the table grows without bound.
It applies on every write rather than only on creation, which is bounded
and idempotent, so it holds however the run was written. The import path
does not prune, staying purely additive; the next real run trims.
* feat(app): read and write scheduled jobs and runs through the server
The hooks keep their shape, so the tasks page, the results view, the
card and the new tab panel are unchanged apart from where they import
from. Both gain an unavailable state, since an empty list and an
unreachable server are now the same shape without one.
The alarm runner distinguishes them everywhere it reads. Treating a
failed load as an empty list would read as nothing being scheduled:
alarms would not be rebuilt on startup and schedules would quietly stop
firing, with no failed run to show for it. It skips the pass instead and
retries on the next startup.
Extension storage no longer carries the data, but it still carries the
change signal. Runs are written by the background while the side panel
and new tab display them, and storage watch is what kept those in step.
A revision item is bumped after a write so every mounted view refetches.
Run history is imported once, under its own marker. It cannot share the
provider and job marker because that import must never run twice:
extension storage is frozen now, so a second pass would insert back
whatever the user has since deleted.
Also removes the scheduled job deletion queue, whose only reader went
when sync did, and the mount-time storage read that chose the opening
tab, which is now derived so it settles when the history arrives.
* fix(app): record a finished run against the current job
Recording that a run finished wrote back the job as it was read before
the run started. A run can take minutes and the job stays editable
throughout, so a rename, a schedule change, a disable or a different
provider chosen while it was going would be silently reverted.
The old code merged into a freshly read list; passing the job object
instead was an attempt to save a read and is what lost the update. It
takes an id again, so a stale snapshot cannot be handed to it, and it
skips the write when the job was deleted mid-run rather than
resurrecting it.
---
.../background/scheduledJobRuns.ts | 116 ++-
.../lib/llm-providers/providerTemplates.ts | 13 +
.../lib/schedules/getChatServerResponse.ts | 43 +-
.../lib/schedules/provider-resolution.test.ts | 79 ++
.../apps/app/lib/schedules/refine-prompt.ts | 16 +-
.../apps/app/lib/schedules/scheduleStorage.ts | 149 +---
.../llm-providers/llm-providers.api.ts | 88 +++
.../llm-providers.helpers.test.ts | 125 ++++
.../llm-providers/llm-providers.helpers.ts | 178 +++++
.../llm-providers/llm-providers.hooks.test.ts | 69 +-
.../llm-providers/llm-providers.hooks.ts | 254 +++----
.../local-first-migration.test.ts | 84 ++-
.../local-first-migration.ts | 33 +-
.../start-local-first-migration.ts | 31 +-
.../app/modules/schedules/schedules.api.ts | 126 ++++
.../schedules/schedules.helpers.test.ts | 173 +++++
.../modules/schedules/schedules.helpers.ts | 129 ++++
.../app/modules/schedules/schedules.hooks.ts | 158 ++++
.../modules/schedules/schedules.revision.ts | 27 +
.../screens/ai-settings/BrowserOsAiPane.tsx | 11 +
.../apps/app/screens/auth/LogoutPage.tsx | 4 -
.../screens/newtab/index/ScheduleResults.tsx | 2 +-
.../NewScheduledTaskDialog.tsx | 21 +-
.../scheduled-tasks/ScheduledTaskCard.tsx | 28 +-
.../scheduled-tasks/ScheduledTaskResults.tsx | 8 +-
.../scheduled-tasks/ScheduledTasksPage.tsx | 74 +-
.../apps/server/src/api/routes/index.ts | 3 +
.../src/api/routes/scheduled-job-runs.ts | 93 +++
.../0009_add_scheduled_job_runs.sql | 19 +
.../lib/db/migrations/meta/0009_snapshot.json | 677 ++++++++++++++++++
.../src/lib/db/migrations/meta/_journal.json | 7 +
.../apps/server/src/lib/db/schema/index.ts | 1 +
.../src/lib/db/schema/scheduled-job-runs.ts | 69 ++
.../server/src/lib/schedules/run-store.ts | 126 ++++
.../browseros-agent/apps/server/src/rpc.ts | 4 +
.../server/tests/api/routes/index.test.ts | 15 +
.../api/routes/scheduled-job-runs.test.ts | 202 ++++++
.../tests/lib/schedules/run-store.test.ts | 170 +++++
38 files changed, 2935 insertions(+), 490 deletions(-)
create mode 100644 packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.api.ts
create mode 100644 packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.test.ts
create mode 100644 packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.ts
create mode 100644 packages/browseros-agent/apps/app/modules/schedules/schedules.api.ts
create mode 100644 packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.test.ts
create mode 100644 packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.ts
create mode 100644 packages/browseros-agent/apps/app/modules/schedules/schedules.hooks.ts
create mode 100644 packages/browseros-agent/apps/app/modules/schedules/schedules.revision.ts
create mode 100644 packages/browseros-agent/apps/server/src/api/routes/scheduled-job-runs.ts
create mode 100644 packages/browseros-agent/apps/server/src/lib/db/migrations/0009_add_scheduled_job_runs.sql
create mode 100644 packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0009_snapshot.json
create mode 100644 packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-job-runs.ts
create mode 100644 packages/browseros-agent/apps/server/src/lib/schedules/run-store.ts
create mode 100644 packages/browseros-agent/apps/server/tests/api/routes/scheduled-job-runs.test.ts
create mode 100644 packages/browseros-agent/apps/server/tests/lib/schedules/run-store.test.ts
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/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/schedules/getChatServerResponse.ts b/packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts
index 489590dab9..734c3e7ef8 100644
--- a/packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts
+++ b/packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts
@@ -4,12 +4,12 @@ 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 { listProvidersOrNull } from '@/modules/llm-providers/llm-providers.api'
import {
findChatProviderById,
resolveChatProvider,
@@ -73,23 +73,42 @@ 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 => {
+ // One read for both branches: the list is now a request rather than a local
+ // storage lookup, and the explicit-provider path used to fetch it twice.
+ const loaded = await listProvidersOrNull()
+
+ // Never resolve a provider from a list that failed to load. A job that named
+ // one must not quietly run on a different one, and a job that named none
+ // still has a choice behind it: the configured default, whose id lives in
+ // extension storage but whose credentials and model live in that list. Either
+ // way, substituting the built-in would spend the wrong credentials on the
+ // wrong model and still record the run as completed.
+ //
+ // An empty list is a different answer and keeps the fallback: the server
+ // answered, and it really has no providers.
+ if (loaded === null) {
+ throw new Error(
+ 'Cannot reach the BrowserOS server to load the selected provider',
+ )
+ }
+
+ const providers = loaded
+
if (providerId) {
- const providers = await providersStorage.getValue()
- const match = findChatProviderById(providers ?? [], providerId)
+ const match = findChatProviderById(providers, providerId)
if (match) return match
}
- return (await getDefaultProvider()) ?? createDefaultBrowserOSProvider()
+
+ if (providers.length > 0) {
+ const defaultProviderId = await defaultProviderIdStorage.getValue()
+ const provider = resolveChatProvider(providers, defaultProviderId)
+ if (provider) return provider
+ }
+
+ return createDefaultBrowserOSProvider()
}
export async function getChatServerResponse(
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..40f481b963 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',
@@ -160,3 +169,73 @@ const providers: LlmProviderConfig[] = [
updatedAt: timestamp,
},
]
+
+describe('provider resolution when the server is unreachable', () => {
+ // The list being unreachable says nothing about whether the chosen provider
+ // exists, so running anyway would spend the wrong credentials on the wrong
+ // model. The job runner turns this into a failed run the user can see.
+ it('fails a scheduled job that named a provider', async () => {
+ storageValues.set('unreachable', true)
+ const { getChatServerResponse } = await import('./getChatServerResponse')
+
+ await expect(
+ getChatServerResponse({
+ message: 'Run my schedule',
+ providerId: 'anthropic-sonnet',
+ }),
+ ).rejects.toThrow('Cannot reach the BrowserOS server')
+
+ expect(fetchBodies).toHaveLength(0)
+ })
+
+ it('fails a refine that named a provider', async () => {
+ storageValues.set('unreachable', true)
+ const { refinePrompt } = await import('./refine-prompt')
+
+ await expect(
+ refinePrompt({
+ prompt: 'Check mail',
+ name: 'Morning brief',
+ providerId: 'anthropic-sonnet',
+ }),
+ ).rejects.toThrow('Cannot reach the BrowserOS server')
+ })
+
+ // A job that named nothing still has a choice behind it: the configured
+ // default. Its id is in extension storage but its model and credentials are
+ // in the list that failed to load, so the built-in is not a safe stand-in.
+ it('fails a scheduled job that relies on the configured default', async () => {
+ storageValues.set('unreachable', true)
+ const { getChatServerResponse } = await import('./getChatServerResponse')
+
+ await expect(
+ getChatServerResponse({ message: 'Run my schedule' }),
+ ).rejects.toThrow('Cannot reach the BrowserOS server')
+
+ expect(fetchBodies).toHaveLength(0)
+ })
+
+ // An empty list is a different answer from an unreachable one: the server
+ // replied and really has no providers, so the built-in is correct.
+ it('still falls back to the built-in provider when the server has none', async () => {
+ storageValues.set('providers', [])
+ const { getChatServerResponse } = await import('./getChatServerResponse')
+
+ await getChatServerResponse({ message: 'Run my schedule' })
+
+ expect(fetchBodies[0]).toMatchObject({ provider: 'browseros' })
+ })
+
+ // A provider that was genuinely deleted still falls back, as before. Only
+ // the unreachable case is treated as unsafe.
+ it('falls back when the named provider no longer exists', async () => {
+ const { getChatServerResponse } = await import('./getChatServerResponse')
+
+ await getChatServerResponse({
+ message: 'Run my schedule',
+ providerId: 'deleted-provider',
+ })
+
+ expect(fetchBodies[0]).toMatchObject({ provider: 'anthropic' })
+ })
+})
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/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..aa2c82fbec
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.api.ts
@@ -0,0 +1,88 @@
+import type { LlmProviderRoutes } 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 {
+ type ProviderRow,
+ toProviderConfigs,
+ toProviderPayload,
+} from './llm-providers.helpers'
+
+async function providersClient() {
+ const baseUrl = await resolveAgentServerUrlWithRetry()
+ return hc(`${baseUrl}/llm-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})`)
+ }
+}
+
+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})`)
+ }
+}
+
+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 as ProviderRow[])
+}
+
+/**
+ * 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..f854625a67
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.test.ts
@@ -0,0 +1,125 @@
+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',
+ baseUrl: null,
+ modelId: 'gpt-5.5',
+ supportsImages: true,
+ contextWindow: 200000,
+ temperature: 0.2,
+ apiKey: null,
+ accessKeyId: null,
+ secretAccessKey: null,
+ sessionToken: null,
+ 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?.apiKey).toBeUndefined()
+ expect(converted?.reasoningSummary).toBeUndefined()
+ })
+
+ it('carries the credentials across', () => {
+ const converted = toProviderConfig(
+ row({ apiKey: 'sk-test', accessKeyId: 'AKIA', region: 'us-east-1' }),
+ )
+
+ expect(converted).toMatchObject({
+ apiKey: 'sk-test',
+ accessKeyId: 'AKIA',
+ region: 'us-east-1',
+ })
+ })
+
+ // 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..eeeed8b734
--- /dev/null
+++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.ts
@@ -0,0 +1,178 @@
+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, not undefined. */
+export interface ProviderRow {
+ id: string
+ type: string
+ name: string
+ baseUrl: string | null
+ modelId: string
+ supportsImages: boolean
+ contextWindow: number
+ temperature: number
+ apiKey: string | null
+ accessKeyId: string | null
+ secretAccessKey: string | null
+ sessionToken: string | null
+ 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 {
+ if (!isProviderType(row.type)) 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,
+ apiKey: orUndefined(row.apiKey),
+ accessKeyId: orUndefined(row.accessKeyId),
+ secretAccessKey: orUndefined(row.secretAccessKey),
+ sessionToken: orUndefined(row.sessionToken),
+ 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 7efbfbad77..703814363a 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,6 +4,7 @@ import {
resolveDefaultProviderId,
resolveSelectedProvider,
} from '../../lib/llm-providers/provider-selection'
+import { planProviderSave } from './llm-providers.helpers'
const storageValues = new Map()
@@ -135,12 +136,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(() => {
@@ -165,7 +163,9 @@ describe('persistDefaultProviderId', () => {
})
})
-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',
@@ -183,14 +183,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',
@@ -199,9 +198,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',
@@ -218,28 +220,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 e6c0b745e1..256facfbdb 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,203 +1,141 @@
-import { useEffect, useMemo, useState } from 'react'
-import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useEffect, useState } from 'react'
+import { createQuery } from 'react-query-kit'
import {
resolveDefaultProviderId,
resolveSelectedProvider,
-} from '../../lib/llm-providers/provider-selection'
+} from '@/lib/llm-providers/provider-selection'
import {
- createDefaultProvidersConfig,
DEFAULT_PROVIDER_ID,
defaultProviderIdStorage,
- loadProviders,
- providersStorage,
-} from '../../lib/llm-providers/storage'
+} from '@/lib/llm-providers/storage'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import {
+ deleteProvider as deleteProviderRow,
+ fetchProviders,
+ putProvider,
+} from './llm-providers.api'
+import { planProviderSave } from './llm-providers.helpers'
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
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,
+})
/** 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)
}
-/** 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,
- )
- 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([])
+/**
+ * The default provider id stays in extension storage rather than the database.
+ *
+ * It is a per-profile preference, and every profile on a machine shares one
+ * database, so a column would make them share a default too. A stale id costs
+ * nothing because `resolveDefaultProviderId` repairs it on read.
+ */
+function useDefaultProviderId(): [string, (id: string) => void] {
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)
- }
+ let cancelled = false
+ defaultProviderIdStorage.getValue().then((stored) => {
+ if (!cancelled && stored) setDefaultProviderId(stored)
+ })
+ const unwatch = defaultProviderIdStorage.watch((next) => {
+ if (next) setDefaultProviderId(next)
})
-
- const unsubscribeDefaultId = defaultProviderIdStorage.watch(
- (newDefaultId) => {
- if (newDefaultId) {
- setDefaultProviderId(newDefaultId)
- }
- },
- )
-
return () => {
- unsubscribeProviders()
- unsubscribeDefaultId()
+ cancelled = true
+ unwatch()
}
}, [])
- const saveProvider = async (provider: LlmProviderConfig) => {
- const currentProviders = (await providersStorage.getValue()) || []
- const updatedProviders = upsertProviderConfig(currentProviders, provider)
- await providersStorage.setValue(updatedProviders)
- }
-
- const setDefaultProviderFn = async (providerId: string) => {
- setDefaultProviderId(providerId)
- await persistDefaultProviderId(providerId)
- }
-
- const deleteProvider = async (providerId: string) => {
- if (providerId === DEFAULT_PROVIDER_ID) {
- return
- }
+ return [defaultProviderId, setDefaultProviderId]
+}
- const currentProviders = (await providersStorage.getValue()) || []
- const updatedProviders = currentProviders.filter((p) => p.id !== providerId)
+/** Hook for managing LLM provider configurations. */
+export function useLlmProviders(): UseLlmProvidersReturn {
+ const queryClient = useQueryClient()
+ const providersQuery = useProvidersQuery()
+ const [storedDefaultId, setStoredDefaultId] = useDefaultProviderId()
+
+ const providers = providersQuery.data ?? []
+ const invalidate = () =>
+ queryClient.invalidateQueries({ queryKey: useProvidersQuery.getKey() })
+
+ const saveMutation = useMutation({
+ mutationFn: async (provider: LlmProviderConfig) => {
+ const { saved, removedIds } = planProviderSave(providers, provider)
+ await putProvider(saved)
+ for (const id of removedIds) await deleteProviderRow(id)
+ },
+ onSuccess: invalidate,
+ })
- if (defaultProviderId === providerId) {
- const newDefaultId = updatedProviders[0]?.id || DEFAULT_PROVIDER_ID
- await defaultProviderIdStorage.setValue(newDefaultId)
- }
+ 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
+
+ // 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)
+
+ if (storedDefaultId === providerId) {
+ const nextDefault =
+ providers.find((provider) => provider.id !== providerId)?.id ??
+ DEFAULT_PROVIDER_ID
+ setStoredDefaultId(nextDefault)
+ await persistDefaultProviderId(nextDefault)
+ }
+ },
+ onSuccess: invalidate,
+ })
- await providersStorage.setValue(updatedProviders)
+ const setDefaultProvider = async (providerId: string) => {
+ setStoredDefaultId(providerId)
+ await persistDefaultProviderId(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,
+ isUnavailable: providersQuery.isError,
+ saveProvider: async (provider) => {
+ await saveMutation.mutateAsync(provider)
+ },
+ setDefaultProvider,
+ deleteProvider: async (providerId) => {
+ await deleteMutation.mutateAsync(providerId)
+ },
}
}
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
index fc7a329e4b..9bcf785c80 100644
--- 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
@@ -1,9 +1,14 @@
import { describe, expect, it } from 'bun:test'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
-import type { ScheduledJob } from '@/lib/schedules/scheduleTypes'
+import type {
+ ScheduledJob,
+ ScheduledJobRun,
+} from '@/lib/schedules/scheduleTypes'
import {
type LocalFirstMigrationDeps,
+ type RunsMigrationDeps,
runLocalFirstMigration,
+ runScheduledRunsMigration,
} from './local-first-migration'
import type {
ProviderImport,
@@ -221,3 +226,80 @@ describe('runLocalFirstMigration', () => {
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
index 53234c608d..db05dee217 100644
--- 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
@@ -1,5 +1,8 @@
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
-import type { ScheduledJob } from '@/lib/schedules/scheduleTypes'
+import type {
+ ScheduledJob,
+ ScheduledJobRun,
+} from '@/lib/schedules/scheduleTypes'
import type {
ProviderImport,
ScheduledJobImport,
@@ -78,3 +81,31 @@ export async function runLocalFirstMigration(
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 }
+}
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
index aa5656be38..6aec57d986 100644
--- 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
@@ -5,9 +5,16 @@ import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
import { providersStorage } from '@/lib/llm-providers/storage'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
-import { scheduledJobStorage } from '@/lib/schedules/scheduleStorage'
+import {
+ scheduledJobRunStorage,
+ scheduledJobStorage,
+} from '@/lib/schedules/scheduleStorage'
import { resolveAgentServerUrlWithRetry } from '@/modules/browseros/agent-server-url.helpers'
-import { runLocalFirstMigration } from './local-first-migration'
+import { importScheduledJobRuns } from '@/modules/schedules/schedules.api'
+import {
+ runLocalFirstMigration,
+ runScheduledRunsMigration,
+} from './local-first-migration'
import {
type ProviderImport,
parseProviderBackup,
@@ -24,6 +31,19 @@ export const migrationDoneStorage = storage.defineItem(
{ 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 },
+)
+
async function loadBackupProviders(): Promise {
try {
const pref = await getBrowserOSAdapter().getPref(BROWSEROS_PREFS.PROVIDERS)
@@ -63,4 +83,11 @@ export function startLocalFirstMigration(): void {
importProviders,
importScheduledJobs,
}).catch(() => null)
+
+ void runScheduledRunsMigration({
+ isDone: () => runsMigrationDoneStorage.getValue(),
+ markDone: () => runsMigrationDoneStorage.setValue(true),
+ loadRuns: async () => (await scheduledJobRunStorage.getValue()) ?? [],
+ importRuns: importScheduledJobRuns,
+ }).catch(() => null)
}
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/screens/ai-settings/BrowserOsAiPane.tsx b/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx
index a389044a98..cf9ea64e74 100644
--- a/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx
+++ b/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx
@@ -4,6 +4,7 @@ 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,
@@ -112,6 +113,7 @@ export const BrowserOsAiPane: FC = () => {
saveProvider,
setDefaultProvider,
deleteProvider,
+ isUnavailable: providersUnavailable,
} = useLlmProviders()
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const { sessionInfo } = useSessionInfo()
@@ -432,6 +434,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}
+
{
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..efb23f98b7 100644
--- a/packages/browseros-agent/apps/app/screens/scheduled-tasks/NewScheduledTaskDialog.tsx
+++ b/packages/browseros-agent/apps/app/screens/scheduled-tasks/NewScheduledTaskDialog.tsx
@@ -41,13 +41,10 @@ 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 { useLlmProviders } from '@/modules/llm-providers/llm-providers.hooks'
import type { ScheduledJob } from './types'
const formSchema = z
@@ -99,8 +96,7 @@ export const NewScheduledTaskDialog: FC = ({
onSave,
}) => {
const isEditing = !!initialValues
- const [providers, setProviders] = useState([])
- const [defaultProviderId, setDefaultProviderId] = useState('')
+ const { providers, defaultProviderId } = useLlmProviders()
const form = useForm({
resolver: zodResolver(formSchema),
@@ -123,17 +119,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++
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
+
+
+
+
+
+
+
+
+
+ ()
+ .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/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/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/_journal.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/_journal.json
index 989dc620e9..fa25ff7510 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
@@ -64,6 +64,13 @@
"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
}
]
}
\ No newline at end of file
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 44b4e7de06..1e17fe0bed 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
@@ -8,4 +8,5 @@ export * from './agents'
export * from './conversations'
export * from './llm-providers'
export * from './oauth'
+export * from './scheduled-job-runs'
export * from './scheduled-jobs'
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/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/rpc.ts b/packages/browseros-agent/apps/server/src/rpc.ts
index 6323a7aa13..5ad91024cd 100644
--- a/packages/browseros-agent/apps/server/src/rpc.ts
+++ b/packages/browseros-agent/apps/server/src/rpc.ts
@@ -1,6 +1,7 @@
import type { createAgentRoutes } from './api/routes/agents'
import type { createConversationRoutes } from './api/routes/conversations'
import type { createLlmProviderRoutes } from './api/routes/llm-providers'
+import type { createScheduledJobRunRoutes } from './api/routes/scheduled-job-runs'
import type { createScheduledJobRoutes } from './api/routes/scheduled-jobs'
// Per-route client contracts for `hc`. Each protected route module is mounted at
@@ -16,3 +17,6 @@ export type ConversationRoutes = ReturnType
export type AgentRoutes = ReturnType
export type LlmProviderRoutes = ReturnType
export type ScheduledJobRoutes = ReturnType
+export type ScheduledJobRunRoutes = ReturnType<
+ typeof createScheduledJobRunRoutes
+>
diff --git a/packages/browseros-agent/apps/server/tests/api/routes/index.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/index.test.ts
index 2d50c7f7a6..8a91fff7db 100644
--- a/packages/browseros-agent/apps/server/tests/api/routes/index.test.ts
+++ b/packages/browseros-agent/apps/server/tests/api/routes/index.test.ts
@@ -228,6 +228,21 @@ describe('createApiRoutes', () => {
).toBe(403)
})
+ it('keeps scheduled job runs behind app-origin auth', async () => {
+ const app = createTestApp()
+
+ expect((await app.request('/scheduled-job-runs')).status).toBe(403)
+ expect(
+ (
+ await app.request('/scheduled-job-runs/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ runs: [] }),
+ })
+ ).status,
+ ).toBe(403)
+ })
+
it('keeps scheduled jobs behind app-origin auth', async () => {
const app = createTestApp()
diff --git a/packages/browseros-agent/apps/server/tests/api/routes/scheduled-job-runs.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/scheduled-job-runs.test.ts
new file mode 100644
index 0000000000..88d41f5645
--- /dev/null
+++ b/packages/browseros-agent/apps/server/tests/api/routes/scheduled-job-runs.test.ts
@@ -0,0 +1,202 @@
+import { describe, expect, it } from 'bun:test'
+import { createScheduledJobRunRoutes } from '../../../src/api/routes/scheduled-job-runs'
+import type { ScheduledJobRunRow } from '../../../src/lib/db/schema'
+import type {
+ ScheduledJobRunStore,
+ ScheduledJobRunUpsert,
+} from '../../../src/lib/schedules/run-store'
+
+const RUN_ID = 'run-1'
+
+function row(overrides: Partial = {}): ScheduledJobRunRow {
+ return {
+ id: RUN_ID,
+ profileId: null,
+ jobId: 'job-1',
+ status: 'completed',
+ startedAt: 1000,
+ completedAt: 2000,
+ result: 'done',
+ finalResult: null,
+ executionLog: null,
+ toolCalls: null,
+ error: null,
+ createdAt: 1,
+ updatedAt: 2,
+ ...overrides,
+ }
+}
+
+function memoryStore(initial: ScheduledJobRunRow[] = []) {
+ const rows = new Map(initial.map((r) => [r.id, r]))
+ const store: ScheduledJobRunStore = {
+ list: async () => [...rows.values()],
+ get: async (id) => rows.get(id) ?? null,
+ upsert: async (input: ScheduledJobRunUpsert) => {
+ const existing = rows.get(input.id)
+ const saved = {
+ ...row(),
+ ...input,
+ createdAt: existing?.createdAt ?? input.createdAt ?? 100,
+ updatedAt: 200,
+ } as ScheduledJobRunRow
+ rows.set(saved.id, saved)
+ return saved
+ },
+ insertIfAbsent: async (input: ScheduledJobRunUpsert) => {
+ if (rows.has(input.id)) return null
+ return store.upsert(input)
+ },
+ remove: async (id) => rows.delete(id),
+ prune: async (jobId, keep = 15) => {
+ const ofJob = [...rows.values()]
+ .filter((r) => r.jobId === jobId)
+ .sort((a, b) => b.startedAt - a.startedAt)
+ const stale = ofJob.slice(keep)
+ for (const run of stale) rows.delete(run.id)
+ return stale.length
+ },
+ }
+ return { store, rows }
+}
+
+const body = {
+ jobId: 'job-1',
+ status: 'running' as const,
+ startedAt: 1000,
+}
+
+function put(
+ routes: ReturnType,
+ payload: unknown,
+ runId = RUN_ID,
+) {
+ return routes.request(`/${runId}`, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+}
+
+describe('scheduled job run routes', () => {
+ it('lists runs', async () => {
+ const routes = createScheduledJobRunRoutes(memoryStore([row()]))
+ const response = await routes.request('/')
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toMatchObject({ runs: [{ id: RUN_ID }] })
+ })
+
+ it('writes a run', async () => {
+ const { store, rows } = memoryStore()
+ const response = await put(createScheduledJobRunRoutes({ store }), body)
+
+ expect(response.status).toBe(200)
+ expect(rows.get(RUN_ID)).toMatchObject({ status: 'running' })
+ })
+
+ it('keeps the tool call log across a write', async () => {
+ const { store, rows } = memoryStore()
+ const toolCalls = [
+ {
+ id: 'call-1',
+ name: 'browser_navigate',
+ input: { url: 'https://example.com' },
+ timestamp: '2026-01-02T03:04:05.000Z',
+ },
+ ]
+
+ await put(createScheduledJobRunRoutes({ store }), { ...body, toolCalls })
+
+ expect(rows.get(RUN_ID)?.toolCalls).toEqual(toolCalls)
+ })
+
+ // The cap moved here from the extension, so a write has to apply it or the
+ // history grows without bound now that nothing else trims it.
+ it('trims a job past the run cap on write', async () => {
+ const existing = Array.from({ length: 15 }, (_, i) =>
+ row({ id: `run-${i}`, startedAt: 1000 + i }),
+ )
+ const { store, rows } = memoryStore(existing)
+
+ await put(
+ createScheduledJobRunRoutes({ store }),
+ { ...body, startedAt: 9999 },
+ 'run-new',
+ )
+
+ expect(rows.size).toBe(15)
+ expect(rows.has('run-0')).toBe(false)
+ expect(rows.has('run-new')).toBe(true)
+ })
+
+ it('rejects a status the schema does not know', async () => {
+ const routes = createScheduledJobRunRoutes(memoryStore())
+ const response = await put(routes, { ...body, status: 'cancelled' })
+
+ expect(response.status).toBe(400)
+ })
+
+ it('returns 404 for an unknown run', async () => {
+ const routes = createScheduledJobRunRoutes(memoryStore())
+ expect((await routes.request(`/${RUN_ID}`)).status).toBe(404)
+ })
+
+ it('deletes a run', async () => {
+ const { store, rows } = memoryStore([row()])
+ const routes = createScheduledJobRunRoutes({ store })
+
+ expect(
+ (await routes.request(`/${RUN_ID}`, { method: 'DELETE' })).status,
+ ).toBe(200)
+ expect(rows.size).toBe(0)
+ })
+
+ describe('import', () => {
+ async function importRuns(
+ routes: ReturnType,
+ runs: unknown[],
+ ) {
+ return routes.request('/import', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ runs }),
+ })
+ }
+
+ it('inserts a run that is not there yet', async () => {
+ const { store, rows } = memoryStore()
+ const response = await importRuns(
+ createScheduledJobRunRoutes({ store }),
+ [{ ...body, id: RUN_ID }],
+ )
+
+ expect(await response.json()).toEqual({ imported: [RUN_ID], skipped: [] })
+ expect(rows.size).toBe(1)
+ })
+
+ it('leaves an existing run untouched and reports it skipped', async () => {
+ const { store, rows } = memoryStore([row({ result: 'original' })])
+ const response = await importRuns(
+ createScheduledJobRunRoutes({ store }),
+ [{ ...body, id: RUN_ID, result: 'stale import' }],
+ )
+
+ expect(await response.json()).toEqual({ imported: [], skipped: [RUN_ID] })
+ expect(rows.get(RUN_ID)?.result).toBe('original')
+ })
+
+ // The list route is /, so a run whose id is "import" would otherwise be
+ // reachable at the same path as the import endpoint.
+ it('does not treat the import path as a run id', async () => {
+ const { store } = memoryStore()
+ const response = await importRuns(
+ createScheduledJobRunRoutes({ store }),
+ [],
+ )
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({ imported: [], skipped: [] })
+ })
+ })
+})
diff --git a/packages/browseros-agent/apps/server/tests/lib/schedules/run-store.test.ts b/packages/browseros-agent/apps/server/tests/lib/schedules/run-store.test.ts
new file mode 100644
index 0000000000..e4b8eea753
--- /dev/null
+++ b/packages/browseros-agent/apps/server/tests/lib/schedules/run-store.test.ts
@@ -0,0 +1,170 @@
+import { afterEach, describe, expect, test } from 'bun:test'
+import { mkdtempSync } from 'node:fs'
+import { rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { closeDb, initializeDb } from '../../../src/lib/db'
+import {
+ dbScheduledJobRunStore,
+ MAX_RUNS_PER_JOB,
+} from '../../../src/lib/schedules/run-store'
+import { dbScheduledJobStore } from '../../../src/lib/schedules/schedule-store'
+
+const JOB_ID = 'job-1'
+const RUN_ID = 'run-1'
+
+function baseJob() {
+ return {
+ id: JOB_ID,
+ name: 'Morning digest',
+ query: 'summarise my inbox',
+ scheduleType: 'daily' as const,
+ }
+}
+
+function baseRun(overrides: Record = {}) {
+ return {
+ id: RUN_ID,
+ jobId: JOB_ID,
+ status: 'completed' as const,
+ startedAt: 1000,
+ ...overrides,
+ }
+}
+
+describe('dbScheduledJobRunStore', () => {
+ const tempDirs: string[] = []
+
+ afterEach(async () => {
+ closeDb()
+ await Promise.all(
+ tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
+ )
+ tempDirs.length = 0
+ })
+
+ async function useTempDbWithJob() {
+ const dir = mkdtempSync(join(tmpdir(), 'browseros-runs-test-'))
+ tempDirs.push(dir)
+ initializeDb({ dbPath: join(dir, 'db', 'browseros.sqlite') })
+ await dbScheduledJobStore.upsert(baseJob())
+ }
+
+ test('round-trips a run including its tool calls', async () => {
+ await useTempDbWithJob()
+ const toolCalls = [
+ {
+ id: 'call-1',
+ name: 'browser_navigate',
+ input: { url: 'https://example.com' },
+ output: { ok: true },
+ timestamp: '2026-01-02T03:04:05.000Z',
+ },
+ ]
+
+ await dbScheduledJobRunStore.upsert(baseRun({ toolCalls }))
+
+ expect((await dbScheduledJobRunStore.get(RUN_ID))?.toolCalls).toEqual(
+ toolCalls,
+ )
+ })
+
+ // A run is written when it starts and again when it finishes, so the update
+ // path is the ordinary one rather than an edge case.
+ test('upsert moves a run from running to completed', async () => {
+ await useTempDbWithJob()
+ await dbScheduledJobRunStore.upsert(baseRun({ status: 'running' }))
+
+ await dbScheduledJobRunStore.upsert(
+ baseRun({ status: 'completed', completedAt: 2000, result: 'done' }),
+ )
+
+ const saved = await dbScheduledJobRunStore.get(RUN_ID)
+ expect(saved).toMatchObject({
+ status: 'completed',
+ completedAt: 2000,
+ result: 'done',
+ })
+ expect(await dbScheduledJobRunStore.list()).toHaveLength(1)
+ })
+
+ test('insertIfAbsent leaves an existing run untouched', async () => {
+ await useTempDbWithJob()
+ await dbScheduledJobRunStore.upsert(baseRun({ result: 'original' }))
+
+ const saved = await dbScheduledJobRunStore.insertIfAbsent(
+ baseRun({ result: 'stale import' }),
+ )
+
+ expect(saved).toBeNull()
+ expect((await dbScheduledJobRunStore.get(RUN_ID))?.result).toBe('original')
+ })
+
+ // Cascade, unlike the job to provider reference which is set null. A run
+ // whose job is gone means nothing, and deleting a job already removed its
+ // runs before this table existed.
+ test('deleting a job removes its runs', async () => {
+ await useTempDbWithJob()
+ await dbScheduledJobRunStore.upsert(baseRun())
+
+ await dbScheduledJobStore.remove(JOB_ID)
+
+ expect(await dbScheduledJobRunStore.list()).toEqual([])
+ })
+
+ test('lists newest first', async () => {
+ await useTempDbWithJob()
+ await dbScheduledJobRunStore.upsert(baseRun({ id: 'old', startedAt: 1000 }))
+ await dbScheduledJobRunStore.upsert(baseRun({ id: 'new', startedAt: 3000 }))
+
+ expect((await dbScheduledJobRunStore.list()).map((r) => r.id)).toEqual([
+ 'new',
+ 'old',
+ ])
+ })
+
+ // The extension applied this cap while it owned the history, so keeping it
+ // is preserving behaviour rather than adding a policy.
+ test('prune keeps the newest runs of a job and drops the rest', async () => {
+ await useTempDbWithJob()
+ for (let i = 0; i < MAX_RUNS_PER_JOB + 5; i += 1) {
+ await dbScheduledJobRunStore.upsert(
+ baseRun({ id: `run-${i}`, startedAt: 1000 + i }),
+ )
+ }
+
+ const dropped = await dbScheduledJobRunStore.prune(JOB_ID)
+
+ expect(dropped).toBe(5)
+ const remaining = await dbScheduledJobRunStore.list()
+ expect(remaining).toHaveLength(MAX_RUNS_PER_JOB)
+ expect(remaining[0].startedAt).toBe(1000 + MAX_RUNS_PER_JOB + 4)
+ })
+
+ test('prune leaves a job under the cap alone', async () => {
+ await useTempDbWithJob()
+ await dbScheduledJobRunStore.upsert(baseRun())
+
+ expect(await dbScheduledJobRunStore.prune(JOB_ID)).toBe(0)
+ expect(await dbScheduledJobRunStore.list()).toHaveLength(1)
+ })
+
+ test('prune only touches the job it was given', async () => {
+ await useTempDbWithJob()
+ await dbScheduledJobStore.upsert({ ...baseJob(), id: 'job-2' })
+ await dbScheduledJobRunStore.upsert(
+ baseRun({ id: 'other', jobId: 'job-2' }),
+ )
+ for (let i = 0; i < MAX_RUNS_PER_JOB + 2; i += 1) {
+ await dbScheduledJobRunStore.upsert(
+ baseRun({ id: `run-${i}`, startedAt: 1000 + i }),
+ )
+ }
+
+ await dbScheduledJobRunStore.prune(JOB_ID)
+
+ const ids = (await dbScheduledJobRunStore.list()).map((r) => r.id)
+ expect(ids).toContain('other')
+ expect(ids).toHaveLength(MAX_RUNS_PER_JOB + 1)
+ })
+})
From 92df7a9a493108057295b062cca1c749394e9842 Mon Sep 17 00:00:00 2001
From: Dani Akash
Date: Thu, 3 Sep 2026 14:27:17 +0530
Subject: [PATCH 09/12] chore: sync the local-first storage epic with main
(#2539)
* fix(server): steer ACP agents to browseros, not a co-installed browseros-neo (#2517)
* fix(server): steer ACP agents to browseros, not a co-installed browseros-neo
* refactor(server): replace ACP skill file with system prompt + workspace CLAUDE.md/AGENTS.md
* refactor(server): slim the agent system prompt and move tool guidance into the tools (#2521)
* refactor(server): slim the agent system prompt, move tool guidance into tools (TKT-947)
* docs: tidy prompt comments
* fix(browser-mcp): fence run structured output so untrusted values reach the model marked
* test(server): expect fenced run structured output in browser + dual-era tests
* feat(app): give first-run its own setup step instead of the settings page (#2511)
* feat(app): give first-run its own setup step instead of the settings page
Finishing the native onboarding dropped the user on the full AI settings
screen: sidebar, configured list, promos, default-target control, usage and
billing links. That is an administrative surface, and it was someone's first
minute with the product.
Adds #/onboarding/ai, a bare route beside features and outside every layout,
carrying the provider catalogue and nothing else. Connecting anything hands
off to #/home, which is the new tab page, so the first thing after setup is
the thing the product is for.
The handoff fires on the transition to connected, never on the state. A
subscription template takes the user off the page and back, so success
arrives as a change to the provider list rather than from a submit handler,
and a user who opens the route with providers already configured has to stay
on it rather than being bounced.
Connected cannot mean a non-empty provider list: a built-in entry is seeded
on first load, so it means any provider that is not that one, or any agent.
The dialog and OAuth wiring moves out of BrowserOsAiPane into a shared hook,
since the catalogue only raises intent and something has to own the four
dialogs. The settings page behaves exactly as before.
Includes a skip, because both onboarding exits still land here and the page
has no sidebar to escape through.
* fix(app): hand off when an already-configured user connects something
The handoff compared a boolean: not-connected becoming connected. For anyone
who already had a provider or an agent that boolean was true on arrival and
stayed true, so adding another connected nothing and the page never moved.
Only a profile with nothing configured could ever reach the new tab page.
It now compares a count against a baseline taken when both lists settle, so
what matters is whether the user connected something on this visit rather
than whether they had ever connected anything. Deleting does not count: the
count has to grow.
Readiness now waits on the agent list too, via its flag rather than
. The two lists load on separate async chains, and that hook
documents that reads false for a render while the list is still
empty, so a baseline taken on the providers alone could miss existing agents
and fire the moment they arrived.
The previous behaviour was covered by a test asserting a user who arrives
already connected is not handed off. That test encoded the bug, so it is
replaced by two that cover adding to an existing setup.
* refactor(app): hand off from onboarding on the add callback, make added provider default
* fix(app): set the added provider or agent as the active chat target before handoff
* fix(app): hand off with the persisted provider id so an OAuth reconnect resolves
* chore: browseros-claw update
* chore: bump version
* chore: bump app onboarding version to 0.0.1 (#2524)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* chore(release): snapshot browseros server alpha v0.0.152
Automated release snapshot update.
* chore: bump server version to 0.0.152 (#2526)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* chore(release): update extension alpha feeds to 0.0.146.0
Automated release snapshot update.
* chore: bump agent extension version to 0.0.146.0 (#2528)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* chore: sync internal-docs submodule (#2529)
Co-authored-by: browseros-bot
* feat: unify product installation identity (#2530)
* feat(chromium): unify product installation metrics
* feat(agent): share BrowserOS installation identity
* feat(claw): separate installation identity from consent
* fix(dev): share product state root with Chromium
* test(agent): cover canonical installation identity
* fix(agent): clean failed identity publishes
* style(claw): satisfy analytics lint
* feat: agents never steal focus in BrowserClaw (#2531)
* feat(claw-mcp): tabs new and pages.newPage always open in the background
Agents can no longer request a foreground tab. The background field is
still accepted and ignored so clients holding the old schema keep working,
but it is hidden from the tool schema. Conformance cases stop assuming an
agent-opened page becomes the active tab.
* feat(patches): automation never steals focus pref and Browser gates
Adds browseros.automation_never_steals_focus (default on for BrowserClaw).
With it on, a tab with a DevTools client attached cannot switch the user's
active tab or raise the window through Browser::ActivateContents, and tabs
or popups its pages open after an agent click land in the background
(Browser::AddNewContents, mirroring the upstream actor gate).
* feat(patches): Browser.createTab defaults to background; activate commands honour the focus pref
createTab now opens tabs in the background unless background=false is
passed, and an explicit false only selects the tab within its window.
Under browseros.automation_never_steals_focus, activateTab stops raising
the window, activateWindow becomes a no-op, and createWindow plus
setWindowVisibility(activate) show windows inactive.
* test(claw-mcp): retired tabs background field stays accepted but inert
* chore: bump version
* ci: grant nightly call sites the permissions their workflows declare (#2532)
* ci: grant nightly call sites the permissions their workflows declare
The nightly family workflow had never started: a called workflow can only
narrow the caller's GITHUB_TOKEN, so any call site whose ceiling is below
what the called workflow declares fails the entire run at validation time,
before a single job is created (run 33689075661, startup_failure).
Six call sites were short:
- prepare/finalize-claw-server granted contents: write, but
release-claw-server.yml declares publish-ota and reflect-version with
pull-requests: write. Both are skipped here (publish_ota: false,
state_owner: suite) but validation is static and runs before if:.
- build-browseros/build-browserclaw had no permissions block, so they
inherited the workflow-level permissions: {} and granted nothing to
nightly-macos-product.yml, which declares contents: read.
- server-ota/claw-server-ota granted contents: read to
publish-server-ota.yml, which declares contents + pull-requests write
to publish the feed snapshot and its reconciliation pull request.
Ceilings now match what release-browseros.yml and release-browserclaw.yml
already use for the same called workflows.
* ci: keep the nightly ceilings minimal
Narrows the previous commit to the only call sites that actually elevate.
Only job-level permissions inside a called workflow are validated against
the caller's ceiling; a callee's workflow-level block is a default for
standalone runs and is supplied by the caller when it is invoked through
workflow_call. release-claw-server.yml is the only callee here that
declares job-level permissions (publish-ota and reflect-version, both
pull-requests: write), so it is the only ceiling that had to widen.
Reverted as unnecessary:
- build-browseros/build-browserclaw: nightly-macos-product.yml declares no
job-level permissions and never checks out or uses the token.
- server-ota/claw-server-ota: publish-server-ota.yml declares none either,
and in suite mode the writes belong to reconcile-state, so contents: read
is the correct least-privilege ceiling. ci_workflow_test asserts it.
* ci: cover the permissions the nightly's build and OTA callees declare (#2533)
The nightly still failed validation after the claw-server fix. Bisecting
with push-triggered copies of the workflow on a scratch branch localised
two more call sites; each was proven in isolation:
- build-browseros/build-browserclaw had no permissions block, so they
inherited the workflow-level permissions: {} and granted nothing to
nightly-macos-product.yml, which declares contents: read.
- server-ota/claw-server-ota granted contents: read to
publish-server-ota.yml, which declares contents and pull-requests write.
A called workflow can only narrow the caller's GITHUB_TOKEN, and that is
checked statically for the whole nested tree before any job is created, so
a short ceiling rejects the entire run. With both covered, a full copy of
the workflow created all 17 jobs and stopped at the intended
'must run from refs/heads/main' guard.
ci_workflow_test asserted the contents: read ceiling that caused this, so
it encoded the bug; updated to the ceiling that actually validates.
* chore: sync internal-docs submodule (#2535)
Co-authored-by: browseros-bot
---------
Co-authored-by: Nikhil Sonti
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: browseros-bot
---
.github/workflows/nightly.yml | 12 +-
.../apps/app-onboard/package.json | 2 +-
.../apps/app/entrypoints/app/App.tsx | 8 +
.../llm-providers/llm-providers.hooks.ts | 15 +-
.../browseros-agent/apps/app/package.json | 2 +-
.../screens/ai-settings/BrowserOsAiPane.tsx | 203 +--
.../ai-settings/CustomCodingAgentDialog.tsx | 8 +-
.../ai-settings/NewCodingAgentDialog.tsx | 6 +-
.../ai-settings/add-provider.hooks.tsx | 285 ++++
.../onboarding-ai/OnboardingAiPage.tsx | 84 ++
.../src/analytics/installation.rs | 142 ++
.../claw-server-rust/src/analytics/mod.rs | 1 +
.../claw-server-rust/src/analytics/service.rs | 107 +-
.../claw-server-rust/src/analytics/state.rs | 66 +-
.../apps/claw-server-rust/src/runtime.rs | 7 +
.../apps/claw-server-rust/tests/telemetry.rs | 11 +-
.../browseros-agent/apps/server/package.json | 2 +-
.../resources/skills/browseros/SKILL.md | 12 -
.../apps/server/src/agent/prompt.ts | 512 +------
.../src/lib/agents/acp/acp-agent-policy.ts | 33 +-
.../src/lib/agents/acp/acp-agent-runtime.ts | 10 +
.../lib/agents/acp/browseros-instructions.ts | 55 +
.../src/lib/agents/acp/browseros-skill.ts | 46 -
.../apps/server/src/lib/identity.ts | 48 +-
.../apps/server/src/lib/installation-id.ts | 99 ++
.../apps/server/src/lib/metrics.ts | 23 +-
.../browseros-agent/apps/server/src/main.ts | 47 +-
.../apps/server/tests/agent/prompt.test.ts | 1224 +++--------------
.../tests/api/routes/mcp-dual-era.test.ts | 7 +-
.../apps/server/tests/build.test.ts | 8 -
.../lib/agents/acp/acp-agent-policy.test.ts | 10 +-
.../lib/agents/acp/acp-agent-runtime.test.ts | 17 +-
.../agents/acp/browseros-instructions.test.ts | 59 +
.../lib/agents/acp/browseros-skill.test.ts | 49 -
.../apps/server/tests/lib/identity.test.ts | 48 +-
.../server/tests/lib/installation-id.test.ts | 60 +
.../apps/server/tests/lib/metrics.test.ts | 37 +-
.../apps/server/tests/main.test.ts | 49 +-
.../tests/tools/browser/register.test.ts | 71 +-
packages/browseros-agent/bun.lock | 6 +-
.../tests/cases-snapshot-concurrency.ts | 1 -
.../contracts/claw-mcp/tests/cases-tabs.ts | 40 +-
.../claw-mcp/tests/rust-conformance.test.ts | 1 -
.../crates/browseros-mcp/src/tests.rs | 3 +
.../crates/browseros-mcp/src/tools/mod.rs | 4 -
.../crates/browseros-mcp/src/tools/run.rs | 27 +-
.../crates/browseros-mcp/src/tools/tabs.rs | 38 +-
.../packages/browser-mcp/src/tools/act.ts | 2 +-
.../browser-mcp/src/tools/history.test.ts | 2 +
.../packages/browser-mcp/src/tools/history.ts | 12 +-
.../browser-mcp/src/tools/register.test.ts | 18 +-
.../packages/browser-mcp/src/tools/run.ts | 25 +-
.../build/config/server-prod-resources.json | 8 -
.../browseros-agent/tools/dev/cmd/watch.go | 84 +-
.../tools/dev/cmd/watch_test.go | 69 +-
.../browseros/bos_build/ci_workflow_test.py | 9 +-
.../chrome/browser/browseros/core/BUILD.gn | 6 +-
.../browser/browseros/core/browseros_prefs.cc | 12 +-
.../browser/browseros/core/browseros_prefs.h | 18 +-
.../browseros/core/browseros_product_state.cc | 72 +
.../browseros/core/browseros_product_state.h | 26 +
.../chrome/browser/browseros/metrics/BUILD.gn | 29 +-
.../metrics/browseros_installation_id.cc | 135 ++
.../metrics/browseros_installation_id.h | 29 +
.../browseros/metrics/browseros_metrics.cc | 81 +-
.../browseros/metrics/browseros_metrics.h | 29 +-
.../metrics/browseros_metrics_extra_parts.cc | 69 +
.../metrics/browseros_metrics_extra_parts.h | 24 +
.../metrics/browseros_metrics_prefs.cc | 34 -
.../metrics/browseros_metrics_prefs.h | 30 -
.../metrics/browseros_metrics_service.cc | 259 ++--
.../metrics/browseros_metrics_service.h | 104 +-
.../browseros_metrics_service_factory.cc | 64 -
.../browseros_metrics_service_factory.h | 54 -
.../chrome/browser/browseros/server/BUILD.gn | 5 +-
.../server/browseros_server_manager.cc | 21 +-
.../chrome/browser/chrome_browser_main.cc | 24 +-
.../devtools/protocol/browser_handler.cc | 54 +-
.../api/browser_os/browser_os_api.cc | 10 +-
.../metrics/chrome_metrics_service_client.cc | 4 +-
.../chrome/browser/prefs/browser_prefs.cc | 20 +-
...hrome_browser_main_extra_parts_profiles.cc | 20 -
.../chrome/browser/ui/browser.cc | 70 +-
.../ui/startup/startup_browser_creator.cc | 12 +-
.../chrome/common/pref_names.h | 13 +-
.../devtools_protocol/domains/Browser.pdl | 9 +-
.../browseros/resources/BROWSEROS_VERSION | 4 +-
updates/extensions/bundled-manifest.xml | 2 +-
updates/extensions/update-manifest.alpha.xml | 2 +-
updates/server/appcast-claw-server.xml | 34 +-
updates/server/appcast-server.alpha.xml | 34 +-
91 files changed, 2492 insertions(+), 2755 deletions(-)
create mode 100644 packages/browseros-agent/apps/app/screens/ai-settings/add-provider.hooks.tsx
create mode 100644 packages/browseros-agent/apps/app/screens/onboarding-ai/OnboardingAiPage.tsx
create mode 100644 packages/browseros-agent/apps/claw-server-rust/src/analytics/installation.rs
delete mode 100644 packages/browseros-agent/apps/server/resources/skills/browseros/SKILL.md
create mode 100644 packages/browseros-agent/apps/server/src/lib/agents/acp/browseros-instructions.ts
delete mode 100644 packages/browseros-agent/apps/server/src/lib/agents/acp/browseros-skill.ts
create mode 100644 packages/browseros-agent/apps/server/src/lib/installation-id.ts
create mode 100644 packages/browseros-agent/apps/server/tests/lib/agents/acp/browseros-instructions.test.ts
delete mode 100644 packages/browseros-agent/apps/server/tests/lib/agents/acp/browseros-skill.test.ts
create mode 100644 packages/browseros-agent/apps/server/tests/lib/installation-id.test.ts
create mode 100644 packages/browseros/chromium_patches/chrome/browser/browseros/core/browseros_product_state.cc
create mode 100644 packages/browseros/chromium_patches/chrome/browser/browseros/core/browseros_product_state.h
create mode 100644 packages/browseros/chromium_patches/chrome/browser/browseros/metrics/browseros_installation_id.cc
create mode 100644 packages/browseros/chromium_patches/chrome/browser/browseros/metrics/browseros_installation_id.h
create mode 100644 packages/browseros/chromium_patches/chrome/browser/browseros/metrics/browseros_metrics_extra_parts.cc
create mode 100644 packages/browseros/chromium_patches/chrome/browser/browseros/metrics/browseros_metrics_extra_parts.h
delete mode 100644 packages/browseros/chromium_patches/chrome/browser/browseros/metrics/browseros_metrics_prefs.cc
delete mode 100644 packages/browseros/chromium_patches/chrome/browser/browseros/metrics/browseros_metrics_prefs.h
delete mode 100644 packages/browseros/chromium_patches/chrome/browser/browseros/metrics/browseros_metrics_service_factory.cc
delete mode 100644 packages/browseros/chromium_patches/chrome/browser/browseros/metrics/browseros_metrics_service_factory.h
delete mode 100644 packages/browseros/chromium_patches/chrome/browser/profiles/chrome_browser_main_extra_parts_profiles.cc
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 6547a0cde2..e0ec583207 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -101,6 +101,7 @@ jobs:
uses: ./.github/workflows/release-claw-server.yml
permissions:
contents: write
+ pull-requests: write
with:
mode: build
defer_finalize: true
@@ -181,6 +182,8 @@ jobs:
name: Build signed BrowserOS nightly
needs: [transaction, verify-components]
uses: ./.github/workflows/nightly-macos-product.yml
+ permissions:
+ contents: read
with:
product: browseros
state_ref: ${{ needs.transaction.outputs.state_ref }}
@@ -196,6 +199,8 @@ jobs:
name: Build signed BrowserOS neo nightly
needs: [transaction, verify-components]
uses: ./.github/workflows/nightly-macos-product.yml
+ permissions:
+ contents: read
with:
product: browserclaw
state_ref: ${{ needs.transaction.outputs.state_ref }}
@@ -228,6 +233,7 @@ jobs:
uses: ./.github/workflows/release-claw-server.yml
permissions:
contents: write
+ pull-requests: write
with:
mode: finalize
state_owner: suite
@@ -273,7 +279,8 @@ jobs:
needs: [transaction, finalize-server]
uses: ./.github/workflows/publish-server-ota.yml
permissions:
- contents: read
+ contents: write
+ pull-requests: write
with:
product: browseros
version: ${{ needs.transaction.outputs.server_version }}
@@ -287,7 +294,8 @@ jobs:
needs: [transaction, finalize-claw-server]
uses: ./.github/workflows/publish-server-ota.yml
permissions:
- contents: read
+ contents: write
+ pull-requests: write
with:
product: browserclaw
version: ${{ needs.transaction.outputs.claw_server_version }}
diff --git a/packages/browseros-agent/apps/app-onboard/package.json b/packages/browseros-agent/apps/app-onboard/package.json
index 6ba87683c9..ed7c18e5df 100644
--- a/packages/browseros-agent/apps/app-onboard/package.json
+++ b/packages/browseros-agent/apps/app-onboard/package.json
@@ -2,7 +2,7 @@
"name": "@browseros/app-onboard",
"type": "module",
"private": true,
- "version": "0.0.0",
+ "version": "0.0.1",
"description": "Standalone Vite app for the BrowserOS onboarding flow (app theme, base-ui).",
"scripts": {
"dev": "vite",
diff --git a/packages/browseros-agent/apps/app/entrypoints/app/App.tsx b/packages/browseros-agent/apps/app/entrypoints/app/App.tsx
index 5dfc3dbe51..20dd2ecfbf 100644
--- a/packages/browseros-agent/apps/app/entrypoints/app/App.tsx
+++ b/packages/browseros-agent/apps/app/entrypoints/app/App.tsx
@@ -16,6 +16,7 @@ import { MCPSettingsPage } from '@/screens/mcp-settings/MCPSettingsPage'
import { NewTabChat } from '@/screens/newtab/index/NewTabChat'
import { NewTabLayout } from '@/screens/newtab/layout/NewTabLayout'
import { Personalize } from '@/screens/newtab/personalize/Personalize'
+import { OnboardingAiPage } from '@/screens/onboarding-ai/OnboardingAiPage'
import { ProfilePage } from '@/screens/profile/ProfilePage'
import { ScheduledTasksPage } from '@/screens/scheduled-tasks/ScheduledTasksPage'
import { UsagePage } from '@/screens/usage/UsagePage'
@@ -89,6 +90,13 @@ export const App: FC = () => {
} />
+ {/* First-run setup, opened by the native onboarding on completion.
+ Outside every layout route on purpose: no sidebar, no chrome. */}
+
+ } />
+ } />
+
+
} />
Promise
+ /**
+ * 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
}
@@ -88,6 +93,10 @@ export function useLlmProviders(): UseLlmProvidersReturn {
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,
})
@@ -130,9 +139,7 @@ export function useLlmProviders(): UseLlmProvidersReturn {
selectedProvider: resolveSelectedProvider(providers, defaultProviderId),
isLoading: providersQuery.isPending,
isUnavailable: providersQuery.isError,
- saveProvider: async (provider) => {
- await saveMutation.mutateAsync(provider)
- },
+ saveProvider: (provider) => saveMutation.mutateAsync(provider),
setDefaultProvider,
deleteProvider: async (providerId) => {
await deleteMutation.mutateAsync(providerId)
diff --git a/packages/browseros-agent/apps/app/package.json b/packages/browseros-agent/apps/app/package.json
index 6501872355..cbb97afb60 100644
--- a/packages/browseros-agent/apps/app/package.json
+++ b/packages/browseros-agent/apps/app/package.json
@@ -2,7 +2,7 @@
"name": "@browseros/app",
"description": "manifest.json description",
"private": true,
- "version": "0.0.145",
+ "version": "0.0.146",
"type": "module",
"scripts": {
"dev": "test -d generated/graphql || bun run codegen; bun --env-file=../../.env.development wxt",
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 cf9ea64e74..e42c56dc55 100644
--- a/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx
+++ b/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx
@@ -17,39 +17,20 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useSessionInfo } from '@/lib/auth/sessionStorage'
-import {
- CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
- CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
- CHATGPT_PRO_OAUTH_STARTED_EVENT,
- GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
- GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
- GITHUB_COPILOT_OAUTH_STARTED_EVENT,
- QWEN_CODE_OAUTH_COMPLETED_EVENT,
- QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
- QWEN_CODE_OAUTH_STARTED_EVENT,
-} from '@/lib/constants/analyticsEvents'
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
-import { CHATGPT_PROVIDER_DISPLAY_NAME } from '@/lib/llm-providers/provider-display-names'
-import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates'
import { testProvider } from '@/lib/llm-providers/testProvider'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { track } from '@/lib/metrics/track'
import { sentry } from '@/lib/sentry/sentry'
-import type { AcpAgent, AcpAgentType } from '@/modules/agents/acp-agent-types'
import { useAgentServerUrl } from '@/modules/browseros/agent-server-url.hooks'
import { useGraphqlMutation } from '@/modules/graphql/graphql-mutation.hooks'
import { useGraphqlQuery } from '@/modules/graphql/graphql-query.hooks'
import { useLlmProviders } from '@/modules/llm-providers/llm-providers.hooks'
-import {
- type OAuthProviderFlowConfig,
- useOAuthProviderFlow,
-} from '@/modules/llm-providers/oauth-provider-flow.hooks'
import { AddProviderSection } from './AddProviderSection'
+import { AddProviderDialogs, useAddProvider } from './add-provider.hooks'
import { ConfiguredTargetsList } from './ConfiguredTargetsList'
-import { CustomCodingAgentDialog } from './CustomCodingAgentDialog'
import { useCodingAgents } from './coding-agents.hooks'
-import { DeviceCodeDialog } from './DeviceCodeDialog'
import { useDefaultChatTarget } from './default-chat-target.hooks'
import {
DeleteRemoteLlmProviderDocument,
@@ -58,51 +39,9 @@ import {
import type { IncompleteProvider } from './IncompleteProviderCard'
import { IncompleteProvidersList } from './IncompleteProvidersList'
import { McpPromoBanner } from './McpPromoBanner'
-import { NewCodingAgentDialog } from './NewCodingAgentDialog'
import { NewProviderDialog } from './NewProviderDialog'
import { partitionSyncedProviders } from './synced-providers'
-// All OAuth providers share the same flow via useOAuthProviderFlow
-const OAUTH_PROVIDERS_CONFIG: Record = {
- 'chatgpt-pro': {
- providerType: 'chatgpt-pro',
- displayName: CHATGPT_PROVIDER_DISPLAY_NAME,
- startedEvent: CHATGPT_PRO_OAUTH_STARTED_EVENT,
- completedEvent: CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
- disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
- },
- 'github-copilot': {
- providerType: 'github-copilot',
- displayName: 'GitHub Copilot',
- startedEvent: GITHUB_COPILOT_OAUTH_STARTED_EVENT,
- completedEvent: GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
- disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
- clientAuth: {
- deviceCodeEndpoint: 'https://github.com/login/device/code',
- tokenEndpoint: 'https://github.com/login/oauth/access_token',
- clientId: 'Ov23li8tweQw6odWQebz',
- scopes: 'read:user',
- requiresPKCE: false,
- contentType: 'json',
- },
- },
- 'qwen-code': {
- providerType: 'qwen-code',
- displayName: 'Qwen Code',
- startedEvent: QWEN_CODE_OAUTH_STARTED_EVENT,
- completedEvent: QWEN_CODE_OAUTH_COMPLETED_EVENT,
- disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
- clientAuth: {
- deviceCodeEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
- tokenEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/token',
- clientId: 'f0304373b74a44d2b584a3fb70ca9e56',
- scopes: 'openid profile email model.completion',
- requiresPKCE: true,
- contentType: 'form',
- },
- },
-}
-
/**
* BrowserOS AI pane — manage LLM providers and the default model.
*/
@@ -184,16 +123,7 @@ export const BrowserOsAiPane: FC = () => {
}
}, [deleteRemoteProvider, retiredProviderIds])
- const [isNewDialogOpen, setIsNewDialogOpen] = useState(false)
- const [newAgentType, setNewAgentType] = useState(null)
- const [customAgentDialogOpen, setCustomAgentDialogOpen] = useState(false)
- const [editingCustomAgent, setEditingCustomAgent] = useState(
- null,
- )
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
- const [templateValues, setTemplateValues] = useState<
- Partial | undefined
- >()
const [editingProvider, setEditingProvider] =
useState(null)
const [providerToDelete, setProviderToDelete] =
@@ -204,96 +134,8 @@ export const BrowserOsAiPane: FC = () => {
null,
)
- // OAuth flows — shared hook eliminates per-provider duplication
- const chatgptPro = useOAuthProviderFlow(
- OAUTH_PROVIDERS_CONFIG['chatgpt-pro'],
- providers,
- saveProvider,
- )
- const copilot = useOAuthProviderFlow(
- OAUTH_PROVIDERS_CONFIG['github-copilot'],
- providers,
- saveProvider,
- )
- const qwenCode = useOAuthProviderFlow(
- OAUTH_PROVIDERS_CONFIG['qwen-code'],
- providers,
- saveProvider,
- )
-
- const activeDeviceCode =
- chatgptPro.pendingDeviceCode ??
- copilot.pendingDeviceCode ??
- qwenCode.pendingDeviceCode
- const clearActiveDeviceCode = () => {
- chatgptPro.clearDeviceCode()
- copilot.clearDeviceCode()
- qwenCode.clearDeviceCode()
- }
-
- const oauthFlows: Record<
- string,
- {
- startOAuthFlow: (url: string | undefined) => Promise
- disconnect: () => Promise
- disconnectedEvent: string
- }
- > = {
- 'chatgpt-pro': {
- startOAuthFlow: chatgptPro.startOAuthFlow,
- disconnect: chatgptPro.disconnect,
- disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
- },
- 'github-copilot': {
- startOAuthFlow: copilot.startOAuthFlow,
- disconnect: copilot.disconnect,
- disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
- },
- 'qwen-code': {
- startOAuthFlow: qwenCode.startOAuthFlow,
- disconnect: qwenCode.disconnect,
- disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
- },
- }
-
- const handleAddProvider = () => {
- setTemplateValues(undefined)
- setIsNewDialogOpen(true)
- }
-
- const handleUseTemplate = (template: ProviderTemplate) => {
- // OAuth providers: trigger OAuth flow
- const oauthFlow = oauthFlows[template.id]
- if (oauthFlow) {
- oauthFlow.startOAuthFlow(agentServerUrl ?? undefined)
- return
- }
-
- setTemplateValues({
- type: template.id,
- name: template.name,
- baseUrl: template.defaultBaseUrl,
- modelId: template.defaultModelId,
- supportsImages: template.supportsImages,
- contextWindow: template.contextWindow,
- temperature: 0.2,
- })
- setIsNewDialogOpen(true)
- }
-
- const handleUseCodingAgentTemplate = (type: AcpAgentType) => {
- setNewAgentType(type)
- }
-
- const handleCreateCustomAgent = () => {
- setEditingCustomAgent(null)
- setCustomAgentDialogOpen(true)
- }
-
- const handleEditCustomAgent = (agent: AcpAgent) => {
- setEditingCustomAgent(agent)
- setCustomAgentDialogOpen(true)
- }
+ const addProvider = useAddProvider({ providers, saveProvider })
+ const { oauthFlows } = addProvider
const handleEditProvider = (provider: LlmProviderConfig) => {
setEditingProvider(provider)
@@ -322,7 +164,7 @@ export const BrowserOsAiPane: FC = () => {
const handleAddKeysToIncomplete = (provider: IncompleteProvider) => {
const timestamp = Date.now()
- setTemplateValues({
+ addProvider.openProviderForm({
id: provider.rowId,
type: provider.type as LlmProviderConfig['type'],
name: provider.name,
@@ -336,7 +178,6 @@ export const BrowserOsAiPane: FC = () => {
createdAt: timestamp,
updatedAt: timestamp,
})
- setIsNewDialogOpen(true)
}
const handleDeleteIncompleteProvider = (provider: IncompleteProvider) => {
@@ -428,7 +269,7 @@ export const BrowserOsAiPane: FC = () => {
({providers.length + coding.agents.length})
-
)
}
diff --git a/packages/browseros-agent/apps/app/screens/ai-settings/CustomCodingAgentDialog.tsx b/packages/browseros-agent/apps/app/screens/ai-settings/CustomCodingAgentDialog.tsx
index 26bb123dc9..3a218ec25c 100644
--- a/packages/browseros-agent/apps/app/screens/ai-settings/CustomCodingAgentDialog.tsx
+++ b/packages/browseros-agent/apps/app/screens/ai-settings/CustomCodingAgentDialog.tsx
@@ -44,12 +44,15 @@ export interface CustomCodingAgentDialogProps {
onOpenChange: (open: boolean) => void
/** When set, the dialog edits this agent instead of creating a new one. */
agent?: AcpAgent | null
+ /** Fires with the new agent's id after a custom agent is created (not on edit). */
+ onSaved?: (agentId: string) => void
}
export const CustomCodingAgentDialog: FC = ({
open,
onOpenChange,
agent,
+ onSaved,
}) => {
const createAgent = useCreateAcpAgent()
const updateAgent = useUpdateAcpAgent()
@@ -121,6 +124,7 @@ export const CustomCodingAgentDialog: FC = ({
systemPromptAppend,
icon: logoKey,
})
+ let createdId: string | undefined
if (isEdit && agent) {
await updateAgent.mutateAsync({
agentId: agent.id,
@@ -133,7 +137,7 @@ export const CustomCodingAgentDialog: FC = ({
},
})
} else {
- await createAgent.mutateAsync({
+ const created = await createAgent.mutateAsync({
name: name.trim(),
type: 'custom',
modelId: modelId || undefined,
@@ -141,8 +145,10 @@ export const CustomCodingAgentDialog: FC = ({
workingDirectory: workingDirectory.trim() || undefined,
customConfig,
})
+ createdId = created.id
}
onOpenChange(false)
+ if (createdId) onSaved?.(createdId)
}
return (
diff --git a/packages/browseros-agent/apps/app/screens/ai-settings/NewCodingAgentDialog.tsx b/packages/browseros-agent/apps/app/screens/ai-settings/NewCodingAgentDialog.tsx
index bde028aac0..d0e7c4acb4 100644
--- a/packages/browseros-agent/apps/app/screens/ai-settings/NewCodingAgentDialog.tsx
+++ b/packages/browseros-agent/apps/app/screens/ai-settings/NewCodingAgentDialog.tsx
@@ -29,12 +29,15 @@ export interface NewCodingAgentDialogProps {
type: AcpAgentType | null
open: boolean
onOpenChange: (open: boolean) => void
+ /** Fires with the new agent's id after it is successfully created. */
+ onSaved?: (agentId: string) => void
}
export const NewCodingAgentDialog: FC = ({
type,
open,
onOpenChange,
+ onSaved,
}) => {
const createAgent = useCreateAcpAgent()
const probe = useAcpAgentProbe(type ?? undefined, open)
@@ -55,13 +58,14 @@ export const NewCodingAgentDialog: FC = ({
const handleCreate = async () => {
if (!type || !name.trim()) return
- await createAgent.mutateAsync({
+ const created = await createAgent.mutateAsync({
name: name.trim(),
type,
modelId: modelId || undefined,
reasoningEffort: reasoningEffort || undefined,
})
onOpenChange(false)
+ onSaved?.(created.id)
}
return (
diff --git a/packages/browseros-agent/apps/app/screens/ai-settings/add-provider.hooks.tsx b/packages/browseros-agent/apps/app/screens/ai-settings/add-provider.hooks.tsx
new file mode 100644
index 0000000000..bee23a8fed
--- /dev/null
+++ b/packages/browseros-agent/apps/app/screens/ai-settings/add-provider.hooks.tsx
@@ -0,0 +1,285 @@
+import { type FC, useCallback, useState } from 'react'
+import {
+ CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
+ CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
+ CHATGPT_PRO_OAUTH_STARTED_EVENT,
+ GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
+ GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
+ GITHUB_COPILOT_OAUTH_STARTED_EVENT,
+ QWEN_CODE_OAUTH_COMPLETED_EVENT,
+ QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
+ QWEN_CODE_OAUTH_STARTED_EVENT,
+} from '@/lib/constants/analyticsEvents'
+import { CHATGPT_PROVIDER_DISPLAY_NAME } from '@/lib/llm-providers/provider-display-names'
+import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates'
+import type { LlmProviderConfig } from '@/lib/llm-providers/types'
+import type { AcpAgent, AcpAgentType } from '@/modules/agents/acp-agent-types'
+import { useAgentServerUrl } from '@/modules/browseros/agent-server-url.hooks'
+import {
+ type OAuthProviderFlowConfig,
+ useOAuthProviderFlow,
+} from '@/modules/llm-providers/oauth-provider-flow.hooks'
+import { CustomCodingAgentDialog } from './CustomCodingAgentDialog'
+import { DeviceCodeDialog } from './DeviceCodeDialog'
+import { NewCodingAgentDialog } from './NewCodingAgentDialog'
+import { NewProviderDialog } from './NewProviderDialog'
+
+/** All OAuth providers share the same flow via useOAuthProviderFlow. */
+export const OAUTH_PROVIDERS_CONFIG: Record = {
+ 'chatgpt-pro': {
+ providerType: 'chatgpt-pro',
+ displayName: CHATGPT_PROVIDER_DISPLAY_NAME,
+ startedEvent: CHATGPT_PRO_OAUTH_STARTED_EVENT,
+ completedEvent: CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
+ disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
+ },
+ 'github-copilot': {
+ providerType: 'github-copilot',
+ displayName: 'GitHub Copilot',
+ startedEvent: GITHUB_COPILOT_OAUTH_STARTED_EVENT,
+ completedEvent: GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
+ disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
+ clientAuth: {
+ deviceCodeEndpoint: 'https://github.com/login/device/code',
+ tokenEndpoint: 'https://github.com/login/oauth/access_token',
+ clientId: 'Ov23li8tweQw6odWQebz',
+ scopes: 'read:user',
+ requiresPKCE: false,
+ contentType: 'json',
+ },
+ },
+ 'qwen-code': {
+ providerType: 'qwen-code',
+ displayName: 'Qwen Code',
+ startedEvent: QWEN_CODE_OAUTH_STARTED_EVENT,
+ completedEvent: QWEN_CODE_OAUTH_COMPLETED_EVENT,
+ disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
+ clientAuth: {
+ deviceCodeEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
+ tokenEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/token',
+ clientId: 'f0304373b74a44d2b584a3fb70ca9e56',
+ scopes: 'openid profile email model.completion',
+ requiresPKCE: true,
+ contentType: 'form',
+ },
+ },
+}
+
+export interface OAuthFlowEntry {
+ startOAuthFlow: (url: string | undefined) => Promise
+ disconnect: () => Promise
+ disconnectedEvent: string
+}
+
+export interface AddProviderController {
+ /** Wired straight into AddProviderSection. */
+ onUseTemplate: (template: ProviderTemplate) => void
+ onCreateAgent: (type: AcpAgentType) => void
+ onCreateCustomAgent: () => void
+ /**
+ * Opens the provider form directly. No argument is the "+ Add" case; the
+ * settings page passes prefill when completing a synced-but-keyless provider.
+ */
+ openProviderForm: (values?: Partial) => void
+ openCustomAgentEditor: (agent: AcpAgent) => void
+ /** Consumed by the settings page's delete path to revoke a token. */
+ oauthFlows: Record
+ dialogs: AddProviderDialogState
+}
+
+interface AddProviderDialogState {
+ isNewDialogOpen: boolean
+ setIsNewDialogOpen: (open: boolean) => void
+ templateValues: Partial | undefined
+ newAgentType: AcpAgentType | null
+ setNewAgentType: (type: AcpAgentType | null) => void
+ customAgentDialogOpen: boolean
+ setCustomAgentDialogOpen: (open: boolean) => void
+ editingCustomAgent: AcpAgent | null
+ activeDeviceCode: ReturnType['pendingDeviceCode']
+ clearActiveDeviceCode: () => void
+ onSaveProvider: (provider: LlmProviderConfig) => Promise
+ onAgentAdded?: (agentId: string) => void
+}
+
+/**
+ * Everything needed to turn "user picked a provider" into a saved provider:
+ * the three OAuth flows, the dialog state, and the handlers that decide which
+ * of the two paths a template takes.
+ *
+ * Extracted from BrowserOsAiPane so the first-run setup screen can offer the
+ * same catalogue without inheriting the settings page's configured list,
+ * promos, default-target control and delete flows.
+ *
+ * `providers` and `saveProvider` are arguments rather than a `useLlmProviders()`
+ * call inside: that hook holds its own useState, so a second instance would be
+ * a second copy of the provider list that silently diverges from the caller's.
+ */
+export function useAddProvider(input: {
+ providers: LlmProviderConfig[]
+ saveProvider: (provider: LlmProviderConfig) => Promise
+ /** Fires once a provider is successfully added on any path, OAuth included. */
+ onProviderAdded?: (provider: LlmProviderConfig) => void | Promise
+ /** Fires once a coding agent is successfully created. */
+ onAgentAdded?: (agentId: string) => void
+}): AddProviderController {
+ const {
+ providers,
+ saveProvider: rawSaveProvider,
+ onProviderAdded,
+ onAgentAdded,
+ } = input
+ // Every add path funnels through saveProvider: the dialog form calls it, and
+ // so do all three OAuth flows on token success (they poll in this mounted
+ // page, they do not navigate away). Wrapping it once is the single definitive
+ // "a provider was added" signal, with no list-watching or baselines.
+ const saveProvider = useCallback(
+ async (provider: LlmProviderConfig) => {
+ // Report the row that was actually persisted: a single-instance reconnect
+ // keeps the existing id, which is the id the chat-target selection needs.
+ const saved = await rawSaveProvider(provider)
+ await onProviderAdded?.(saved)
+ },
+ [rawSaveProvider, onProviderAdded],
+ )
+ const { baseUrl: agentServerUrl } = useAgentServerUrl()
+
+ const [isNewDialogOpen, setIsNewDialogOpen] = useState(false)
+ const [newAgentType, setNewAgentType] = useState(null)
+ const [customAgentDialogOpen, setCustomAgentDialogOpen] = useState(false)
+ const [editingCustomAgent, setEditingCustomAgent] = useState(
+ null,
+ )
+ const [templateValues, setTemplateValues] = useState<
+ Partial | undefined
+ >()
+
+ const chatgptPro = useOAuthProviderFlow(
+ OAUTH_PROVIDERS_CONFIG['chatgpt-pro'],
+ providers,
+ saveProvider,
+ )
+ const copilot = useOAuthProviderFlow(
+ OAUTH_PROVIDERS_CONFIG['github-copilot'],
+ providers,
+ saveProvider,
+ )
+ const qwenCode = useOAuthProviderFlow(
+ OAUTH_PROVIDERS_CONFIG['qwen-code'],
+ providers,
+ saveProvider,
+ )
+
+ const activeDeviceCode =
+ chatgptPro.pendingDeviceCode ??
+ copilot.pendingDeviceCode ??
+ qwenCode.pendingDeviceCode
+
+ const oauthFlows: Record = {
+ 'chatgpt-pro': {
+ startOAuthFlow: chatgptPro.startOAuthFlow,
+ disconnect: chatgptPro.disconnect,
+ disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
+ },
+ 'github-copilot': {
+ startOAuthFlow: copilot.startOAuthFlow,
+ disconnect: copilot.disconnect,
+ disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
+ },
+ 'qwen-code': {
+ startOAuthFlow: qwenCode.startOAuthFlow,
+ disconnect: qwenCode.disconnect,
+ disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
+ },
+ }
+
+ return {
+ onUseTemplate: (template) => {
+ // A subscription template signs in rather than collecting a key, so it
+ // leaves the page instead of opening the form.
+ const oauthFlow = oauthFlows[template.id]
+ if (oauthFlow) {
+ oauthFlow.startOAuthFlow(agentServerUrl ?? undefined)
+ return
+ }
+
+ setTemplateValues({
+ type: template.id,
+ name: template.name,
+ baseUrl: template.defaultBaseUrl,
+ modelId: template.defaultModelId,
+ supportsImages: template.supportsImages,
+ contextWindow: template.contextWindow,
+ temperature: 0.2,
+ })
+ setIsNewDialogOpen(true)
+ },
+ onCreateAgent: setNewAgentType,
+ onCreateCustomAgent: () => {
+ setEditingCustomAgent(null)
+ setCustomAgentDialogOpen(true)
+ },
+ openProviderForm: (values) => {
+ setTemplateValues(values)
+ setIsNewDialogOpen(true)
+ },
+ openCustomAgentEditor: (agent) => {
+ setEditingCustomAgent(agent)
+ setCustomAgentDialogOpen(true)
+ },
+ oauthFlows,
+ dialogs: {
+ isNewDialogOpen,
+ setIsNewDialogOpen,
+ templateValues,
+ newAgentType,
+ setNewAgentType,
+ customAgentDialogOpen,
+ setCustomAgentDialogOpen,
+ editingCustomAgent,
+ activeDeviceCode,
+ clearActiveDeviceCode: () => {
+ chatgptPro.clearDeviceCode()
+ copilot.clearDeviceCode()
+ qwenCode.clearDeviceCode()
+ },
+ onSaveProvider: saveProvider,
+ onAgentAdded,
+ },
+ }
+}
+
+/** The dialogs the add path can open. Rendered by every screen that adds. */
+export const AddProviderDialogs: FC<{ controller: AddProviderController }> = ({
+ controller,
+}) => {
+ const d = controller.dialogs
+ return (
+ <>
+
+ {
+ if (!open) d.setNewAgentType(null)
+ }}
+ onSaved={d.onAgentAdded}
+ />
+
+
+ >
+ )
+}
diff --git a/packages/browseros-agent/apps/app/screens/onboarding-ai/OnboardingAiPage.tsx b/packages/browseros-agent/apps/app/screens/onboarding-ai/OnboardingAiPage.tsx
new file mode 100644
index 0000000000..cdb9672b37
--- /dev/null
+++ b/packages/browseros-agent/apps/app/screens/onboarding-ai/OnboardingAiPage.tsx
@@ -0,0 +1,84 @@
+import type { FC } from 'react'
+import { useNavigate } from 'react-router'
+import { commitChatTargetSelection } from '@/modules/chat/sidepanel-chat-targets'
+import { useLlmProviders } from '@/modules/llm-providers/llm-providers.hooks'
+import { AddProviderSection } from '@/screens/ai-settings/AddProviderSection'
+import {
+ AddProviderDialogs,
+ useAddProvider,
+} from '@/screens/ai-settings/add-provider.hooks'
+
+/**
+ * First-run setup, reached from the native onboarding rather than the sidebar.
+ *
+ * Deliberately not BrowserOsAiPane: this is someone's first minute with the
+ * product, so it carries the catalogue and nothing else, no configured list,
+ * promos, default-target control or delete flows. It renders outside every
+ * layout route, so there is no sidebar either.
+ *
+ * The handoff to the new tab page is a direct callback from the add itself, not
+ * a reaction to the provider/agent lists changing: every add path already
+ * funnels through one success point, so there is nothing to watch or debounce.
+ */
+export const OnboardingAiPage: FC = () => {
+ const navigate = useNavigate()
+ const { providers, saveProvider, setDefaultProvider } = useLlmProviders()
+
+ const goHome = () => navigate('/home', { replace: true })
+
+ // Adding a provider or a coding agent both count as connecting something, so
+ // either makes what was just added the active chat target and then hands off.
+ // commitChatTargetSelection writes the unified selection new chats read (and
+ // updates the default-provider id for an LLM target); await it before the hop
+ // so the new tab page opens on the target the user just set up.
+ const addProvider = useAddProvider({
+ providers,
+ saveProvider,
+ onProviderAdded: async (provider) => {
+ await commitChatTargetSelection(
+ { kind: 'llm', id: provider.id },
+ { setDefaultProvider },
+ )
+ goHome()
+ },
+ onAgentAdded: async (agentId) => {
+ await commitChatTargetSelection(
+ { kind: 'acp', id: agentId },
+ { setDefaultProvider },
+ )
+ goHome()
+ },
+ })
+
+ return (
+
+
+
+ Set up your agent
+
+
+ Connect a provider or a coding agent harness you already use. You can
+ change this any time in settings.
+
+
+
+
+
+
+ Skip for now
+
+
+
+
+
+
+ )
+}
diff --git a/packages/browseros-agent/apps/claw-server-rust/src/analytics/installation.rs b/packages/browseros-agent/apps/claw-server-rust/src/analytics/installation.rs
new file mode 100644
index 0000000000..00827e2ec3
--- /dev/null
+++ b/packages/browseros-agent/apps/claw-server-rust/src/analytics/installation.rs
@@ -0,0 +1,142 @@
+use serde::{Deserialize, Serialize};
+use std::{
+ fs as std_fs,
+ io::{self, Write},
+ path::{Path, PathBuf},
+};
+use tempfile::NamedTempFile;
+use uuid::Uuid;
+
+const INSTALLATION_FILE: &str = "installation.json";
+
+#[derive(Debug, Deserialize, Serialize)]
+struct InstallationFile {
+ install_id: String,
+}
+
+pub(crate) fn installation_path(browserclaw_dir: &Path) -> PathBuf {
+ browserclaw_dir.join(INSTALLATION_FILE)
+}
+
+/**
+ * Loads BrowserClaw's product-wide identity without ever repairing a malformed file.
+ * Chromium may race this sidecar during startup, so the blocking publisher uses a hard link to
+ * make destination creation exclusive and adopts the winner's UUID when another process wins.
+ */
+pub(crate) async fn load_or_create_installation_id(browserclaw_dir: &Path) -> Option {
+ let browserclaw_dir = browserclaw_dir.to_path_buf();
+ match tokio::task::spawn_blocking(move || load_or_create_blocking(&browserclaw_dir)).await {
+ Ok(Ok(install_id)) => Some(install_id),
+ Ok(Err(error)) => {
+ tracing::warn!(%error, "installation identity unavailable; analytics disabled");
+ None
+ }
+ Err(error) => {
+ tracing::warn!(%error, "installation identity worker failed; analytics disabled");
+ None
+ }
+ }
+}
+
+fn load_or_create_blocking(browserclaw_dir: &Path) -> io::Result {
+ let path = installation_path(browserclaw_dir);
+ match read_installation_id(&path) {
+ Ok(install_id) => return Ok(install_id),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {}
+ Err(error) => return Err(error),
+ }
+
+ std_fs::create_dir_all(browserclaw_dir)?;
+ let candidate_id = Uuid::new_v4().to_string();
+ let installation = InstallationFile {
+ install_id: candidate_id.clone(),
+ };
+ let mut raw = serde_json::to_string_pretty(&installation).map_err(io::Error::other)?;
+ raw.push('\n');
+
+ let mut temporary = NamedTempFile::new_in(browserclaw_dir)?;
+ temporary.write_all(raw.as_bytes())?;
+ temporary.flush()?;
+ temporary.as_file().sync_all()?;
+ let temporary_path = temporary.into_temp_path();
+
+ match std_fs::hard_link(&temporary_path, &path) {
+ Ok(()) => Ok(candidate_id),
+ Err(error) if error.kind() == io::ErrorKind::AlreadyExists => read_installation_id(&path),
+ Err(error) => Err(error),
+ }
+}
+
+fn read_installation_id(path: &Path) -> io::Result {
+ let raw = std_fs::read_to_string(path)?;
+ let installation: InstallationFile = serde_json::from_str(&raw)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
+ let parsed = Uuid::parse_str(&installation.install_id)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
+ if parsed.hyphenated().to_string() != installation.install_id.to_ascii_lowercase() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "install_id must use canonical UUID syntax",
+ ));
+ }
+ Ok(installation.install_id)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::Value;
+ use tempfile::tempdir;
+
+ #[tokio::test]
+ async fn missing_identity_creates_and_reuses_the_one_field_file() -> anyhow::Result<()> {
+ let directory = tempdir()?;
+ let first = load_or_create_installation_id(directory.path())
+ .await
+ .ok_or_else(|| anyhow::anyhow!("missing installation identity"))?;
+ let second = load_or_create_installation_id(directory.path())
+ .await
+ .ok_or_else(|| anyhow::anyhow!("missing installation identity"))?;
+
+ assert_eq!(second, first);
+ let raw = std_fs::read_to_string(installation_path(directory.path()))?;
+ assert!(raw.ends_with('\n'));
+ let value: Value = serde_json::from_str(&raw)?;
+ assert_eq!(value.as_object().map(serde_json::Map::len), Some(1));
+ assert_eq!(value["install_id"], first);
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn concurrent_creators_converge_on_one_identity() -> anyhow::Result<()> {
+ let directory = tempdir()?;
+ let mut tasks = Vec::new();
+ for _ in 0..12 {
+ let root = directory.path().to_path_buf();
+ tasks.push(tokio::spawn(async move {
+ load_or_create_installation_id(&root).await
+ }));
+ }
+
+ let mut ids = Vec::new();
+ for task in tasks {
+ ids.push(
+ task.await?
+ .ok_or_else(|| anyhow::anyhow!("missing installation identity"))?,
+ );
+ }
+ assert!(ids.iter().all(|install_id| install_id == &ids[0]));
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn malformed_identity_is_preserved_and_disables_analytics() -> anyhow::Result<()> {
+ let directory = tempdir()?;
+ let path = installation_path(directory.path());
+ std_fs::write(&path, "{not json")?;
+
+ assert_eq!(load_or_create_installation_id(directory.path()).await, None);
+ assert_eq!(std_fs::read_to_string(path)?, "{not json");
+ Ok(())
+ }
+}
diff --git a/packages/browseros-agent/apps/claw-server-rust/src/analytics/mod.rs b/packages/browseros-agent/apps/claw-server-rust/src/analytics/mod.rs
index 9a5c5b21c0..72670fc3ec 100644
--- a/packages/browseros-agent/apps/claw-server-rust/src/analytics/mod.rs
+++ b/packages/browseros-agent/apps/claw-server-rust/src/analytics/mod.rs
@@ -1,4 +1,5 @@
pub mod events;
+mod installation;
mod service;
mod state;
diff --git a/packages/browseros-agent/apps/claw-server-rust/src/analytics/service.rs b/packages/browseros-agent/apps/claw-server-rust/src/analytics/service.rs
index eb0d98cda2..0d02491144 100644
--- a/packages/browseros-agent/apps/claw-server-rust/src/analytics/service.rs
+++ b/packages/browseros-agent/apps/claw-server-rust/src/analytics/service.rs
@@ -1,6 +1,7 @@
use super::{
AnalyticsSink,
events::{self, EventDefinition},
+ installation::load_or_create_installation_id,
state::{AnalyticsState, TelemetryState, load_or_create_state, persist_state, state_path},
};
use crate::error::{AppError, AppResult};
@@ -25,8 +26,13 @@ const MAX_QUEUE_SIZE: usize = 256;
const SERVER_VERSION: &str = "server_version";
const OS_PLATFORM: &str = "os_platform";
+const INSTALL_ID: &str = "install_id";
+const PRODUCT: &str = "product";
+const SURFACE: &str = "surface";
const PROCESS_PERSON_PROFILE: &str = "$process_person_profile";
const IS_SERVER: &str = "$is_server";
+const PRODUCT_BROWSERCLAW: &str = "browserclaw";
+const SURFACE_SERVER: &str = "server";
#[derive(Debug, Clone)]
struct AnalyticsConfig {
@@ -80,6 +86,7 @@ struct ActiveClient {
pub struct AnalyticsService {
path: PathBuf,
+ installation_id: Option,
config: AnalyticsConfig,
state: Mutex,
active: RwLock