diff --git a/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.test.tsx b/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.test.tsx new file mode 100644 index 0000000000..13fcafdd43 --- /dev/null +++ b/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.test.tsx @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, mock } from 'bun:test' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +let dismissed = false +mock.module('@/lib/cloud-sync/cloud-sync-storage', () => ({ + cloudSyncNoticeDismissedStorage: { + getValue: async () => dismissed, + setValue: async (value: boolean) => { + dismissed = value + }, + }, +})) + +const { CloudSyncRetiredNotice } = await import('./CloudSyncRetiredNotice') + +beforeEach(() => { + dismissed = false +}) + +describe('CloudSyncRetiredNotice', () => { + // Dismissal is read asynchronously from extension storage, so the first + // paint must not flash a banner the user already dismissed. + it('renders nothing before the dismissal state is known', () => { + const html = renderToStaticMarkup(createElement(CloudSyncRetiredNotice)) + expect(html).toBe('') + }) +}) + +describe('the copy', () => { + const source = require('node:fs').readFileSync( + new URL('./CloudSyncRetiredNotice.tsx', import.meta.url).pathname, + 'utf8', + ) + + // Sync stops in the same release this ships, so a future-tense warning + // would describe something that has already happened. + it('states what changed rather than warning about it', () => { + expect(source).toContain('has been turned off') + expect(source).not.toMatch(/will (stop|soon)/i) + }) + + // The question people actually have is whether they are losing anything. + it('says what keeps working and what does not', () => { + expect(source).toContain('keep working') + expect(source).toContain('history') + }) +}) diff --git a/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.tsx b/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.tsx new file mode 100644 index 0000000000..7c9efea60b --- /dev/null +++ b/packages/browseros-agent/apps/app/components/cloud-sync/CloudSyncRetiredNotice.tsx @@ -0,0 +1,63 @@ +import { HardDrive, X } from 'lucide-react' +import { type FC, useEffect, useState } from 'react' +import { cloudSyncNoticeDismissedStorage } from '@/lib/cloud-sync/cloud-sync-storage' + +/** + * Tells the user what changed, once, wherever their synced data used to live. + * + * Deliberately past tense. Sync stops in the same release this ships, so a + * warning about the future would be describing something that has already + * happened. It also answers the question people will actually have, which is + * not whether sync is going away but whether they are about to lose anything. + * + * Dismissal persists: this is a one-time announcement, not a standing banner, + * and it should not reappear on every visit to settings. + */ +export const CloudSyncRetiredNotice: FC = () => { + const [visible, setVisible] = useState(false) + + // Reading persisted dismissal is an async read from extension storage, so + // the banner starts hidden and appears only once we know it was not dismissed. + useEffect(() => { + let cancelled = false + cloudSyncNoticeDismissedStorage.getValue().then((dismissed) => { + if (!cancelled) setVisible(!dismissed) + }) + return () => { + cancelled = true + } + }, []) + + if (!visible) return null + + const dismiss = () => { + setVisible(false) + void cloudSyncNoticeDismissedStorage.setValue(true) + } + + return ( +
+
+ +
+
+

+ Your data now stays on this device +

+

+ Cloud sync has been turned off. Your providers, agents and schedules + are stored on this machine and keep working. Chats saved to the cloud + stay visible in history for now. +

+
+ +
+ ) +} diff --git a/packages/browseros-agent/apps/app/entrypoints/background/index.ts b/packages/browseros-agent/apps/app/entrypoints/background/index.ts index 7e0fcdc16c..de64151ec2 100644 --- a/packages/browseros-agent/apps/app/entrypoints/background/index.ts +++ b/packages/browseros-agent/apps/app/entrypoints/background/index.ts @@ -1,5 +1,4 @@ import { storage } from '@wxt-dev/storage' -import { sessionStorage } from '@/lib/auth/sessionStorage' import { Capabilities } from '@/lib/browseros/capabilities' import { createConversationPanelBroker } from '@/lib/browseros/conversationPanelBroker.browser' import { getHealthCheckUrl, getMcpServerUrl } from '@/lib/browseros/helpers' @@ -12,11 +11,7 @@ import { toggleSidePanel, } from '@/lib/browseros/toggleSidePanel' import { checkAndShowChangelog } from '@/lib/changelog/changelog-notifier' -import { - setupLlmProvidersBackupToBrowserOS, - setupLlmProvidersSyncToBackend, - syncLlmProviders, -} from '@/lib/llm-providers/storage' +import { setupLlmProvidersBackupToBrowserOS } from '@/lib/llm-providers/storage' import { fetchMcpTools } from '@/lib/mcp/client' import { onRuntimeMessage, @@ -25,13 +20,10 @@ import { import { onServerMessage } from '@/lib/messaging/server/serverMessages' import { onOpenSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch' import { authRedirectPathStorage } from '@/lib/onboarding/onboardingStorage' -import { - setupScheduledJobsSyncToBackend, - syncScheduledJobs, -} from '@/lib/schedules/syncSchedulesToBackend' import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage' import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage' import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage' +import { startLocalFirstMigration } from '@/modules/local-first-migration/start-local-first-migration' import { scheduledJobRuns } from './scheduledJobRuns' const LEGACY_TOOL_APPROVAL_STORAGE_KEYS = [ @@ -59,8 +51,7 @@ export default defineBackground(() => { Capabilities.initialize().catch(() => null) setupLlmProvidersBackupToBrowserOS() - setupLlmProvidersSyncToBackend() - setupScheduledJobsSyncToBackend() + startLocalFirstMigration() scheduledJobRuns() @@ -151,17 +142,6 @@ export default defineBackground(() => { }) }) - sessionStorage.watch(async (newSession) => { - if (newSession?.user?.id) { - try { - await syncLlmProviders() - } catch {} - try { - await syncScheduledJobs() - } catch {} - } - }) - onServerMessage('checkHealth', async () => { try { const url = await getHealthCheckUrl() diff --git a/packages/browseros-agent/apps/app/entrypoints/background/scheduledJobRuns.ts b/packages/browseros-agent/apps/app/entrypoints/background/scheduledJobRuns.ts index 7fc8cf45fe..297442055b 100644 --- a/packages/browseros-agent/apps/app/entrypoints/background/scheduledJobRuns.ts +++ b/packages/browseros-agent/apps/app/entrypoints/background/scheduledJobRuns.ts @@ -1,45 +1,51 @@ import { onScheduleMessage } from '@/lib/messaging/schedules/scheduleMessages' import { createAlarmFromJob } from '@/lib/schedules/createAlarmFromJob' import { getChatServerResponse } from '@/lib/schedules/getChatServerResponse' -import { - scheduledJobRunStorage, - scheduledJobStorage, -} from '@/lib/schedules/scheduleStorage' import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes' +import { + listScheduledJobRunsOrNull, + listScheduledJobsOrNull, + putScheduledJob, + putScheduledJobRun, +} from '@/modules/schedules/schedules.api' +import { applyLastRunAt } from '@/modules/schedules/schedules.helpers' -const MAX_RUNS_PER_JOB = 15 const STALE_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000 const runAbortControllers = new Map() export const scheduledJobRuns = async () => { + // Every read below distinguishes an unreachable server from an empty list. + // Treating the two alike would look like "nothing is scheduled": alarms would + // not be rebuilt on startup and schedules would quietly stop firing, with no + // failed run to show for it. Skipping the pass instead leaves the next + // startup to retry. const cleanupStaleJobRuns = async () => { - const current = (await scheduledJobRunStorage.getValue()) ?? [] + const current = await listScheduledJobRunsOrNull() + if (current === null) return const now = Date.now() - const updated = current.map((run) => { - if (run.status !== 'running') return run - - const startedAt = new Date(run.startedAt).getTime() - if (now - startedAt > STALE_TIMEOUT_MS) { - return { - ...run, - status: 'failed' as const, - completedAt: new Date().toISOString(), - result: 'Job timed out!', - } - } - return run - }) + const stale = current.filter( + (run) => + run.status === 'running' && + now - new Date(run.startedAt).getTime() > STALE_TIMEOUT_MS, + ) - await scheduledJobRunStorage.setValue(updated) + for (const run of stale) { + await putScheduledJobRun({ + ...run, + status: 'failed', + completedAt: new Date().toISOString(), + result: 'Job timed out!', + }) + } } const syncAlarmState = async () => { - const jobs = (await scheduledJobStorage.getValue()).filter( - (each) => each.enabled, - ) + const loaded = await listScheduledJobsOrNull() + if (loaded === null) return + const jobs = loaded.filter((each) => each.enabled) for (let i = 0; i < jobs.length; i++) { const job = jobs[i] @@ -56,6 +62,8 @@ export const scheduledJobRuns = async () => { jobId: string, status: ScheduledJobRun['status'], ): Promise => { + // Trimming to the per-job cap happens on the server now, so creating a run + // no longer has to rewrite the job's whole history to stay bounded. const jobRun: ScheduledJobRun = { id: crypto.randomUUID(), jobId, @@ -63,48 +71,37 @@ export const scheduledJobRuns = async () => { status, } - const current = (await scheduledJobRunStorage.getValue()) ?? [] - const otherJobRuns = current.filter((r) => r.jobId !== jobId) - const thisJobRuns = current - .filter((r) => r.jobId === jobId) - .sort( - (a, b) => - new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), - ) - .slice(0, MAX_RUNS_PER_JOB - 1) - - await scheduledJobRunStorage.setValue([ - ...otherJobRuns, - ...thisJobRuns, - jobRun, - ]) + await putScheduledJobRun(jobRun) return jobRun } + // Takes the run rather than its id: the caller already holds it, and merging + // locally avoids re-reading a list to update one row. const updateJobRun = async ( - runId: string, + run: ScheduledJobRun, updates: Partial>, ) => { - const current = (await scheduledJobRunStorage.getValue()) ?? [] - await scheduledJobRunStorage.setValue( - current.map((r) => (r.id === runId ? { ...r, ...updates } : r)), - ) + await putScheduledJobRun({ ...run, ...updates }) } + // Takes an id, not the job: a snapshot captured before the run would be + // minutes stale by the time this writes, and putting it back would revert any + // edit made while the run was going. const updateJobLastRunAt = async (jobId: string) => { - const current = (await scheduledJobStorage.getValue()) ?? [] - await scheduledJobStorage.setValue( - current.map((j) => - j.id === jobId ? { ...j, lastRunAt: new Date().toISOString() } : j, - ), - ) + const jobs = await listScheduledJobsOrNull() + if (jobs === null) return + + const updated = applyLastRunAt(jobs, jobId, new Date().toISOString()) + if (updated) await putScheduledJob(updated) } const executeScheduledJob = async (jobId: string): Promise => { - const job = (await scheduledJobStorage.getValue()).find( - (each) => each.id === jobId, - ) + const jobs = await listScheduledJobsOrNull() + if (jobs === null) { + throw new Error('Cannot reach the BrowserOS server to load the job') + } + const job = jobs.find((each) => each.id === jobId) if (!job) { throw new Error(`Job not found: ${jobId}`) } @@ -120,7 +117,7 @@ export const scheduledJobRuns = async () => { providerId: job.providerId, }) - await updateJobRun(jobRun.id, { + await updateJobRun(jobRun, { status: 'completed', completedAt: new Date().toISOString(), result: response.text, @@ -135,7 +132,7 @@ export const scheduledJobRuns = async () => { : e instanceof Error ? e.message : String(e) - await updateJobRun(jobRun.id, { + await updateJobRun(jobRun, { status: 'failed', completedAt: new Date().toISOString(), result: errorMessage, @@ -155,10 +152,11 @@ export const scheduledJobRuns = async () => { runningMissedJobs = true try { - const jobs = (await scheduledJobStorage.getValue()).filter( - (j) => j.enabled, - ) - const runs = (await scheduledJobRunStorage.getValue()) ?? [] + const loadedJobs = await listScheduledJobsOrNull() + const runs = await listScheduledJobRunsOrNull() + if (loadedJobs === null || runs === null) return + + const jobs = loadedJobs.filter((j) => j.enabled) const now = Date.now() const cutoff = now - TWENTY_FOUR_HOURS_MS diff --git a/packages/browseros-agent/apps/app/lib/cloud-sync/cloud-sync-storage.ts b/packages/browseros-agent/apps/app/lib/cloud-sync/cloud-sync-storage.ts new file mode 100644 index 0000000000..fcc1474958 --- /dev/null +++ b/packages/browseros-agent/apps/app/lib/cloud-sync/cloud-sync-storage.ts @@ -0,0 +1,7 @@ +import { storage } from '#imports' + +/** One-time announcement, so dismissal has to outlive the session. */ +export const cloudSyncNoticeDismissedStorage = storage.defineItem( + 'local:cloudSyncNoticeDismissed', + { fallback: false }, +) diff --git a/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.test.ts b/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.test.ts deleted file mode 100644 index 02bfd50cbe..0000000000 --- a/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { - type ActiveConversationBufferEntry, - pruneFlushedEntries, - runExclusiveBufferWrite, - selectBufferEntriesForUser, - upsertBufferEntry, -} from './active-conversation-buffer.helpers' - -const entry = ( - id: string, - userId: string, - lastMessagedAt = 0, -): ActiveConversationBufferEntry => ({ - id, - userId, - messages: [], - lastMessagedAt, -}) - -describe('upsertBufferEntry', () => { - it('appends a conversation that is not buffered yet', () => { - const result = upsertBufferEntry([entry('a', 'A')], entry('b', 'A')) - expect(result.map((e) => e.id)).toEqual(['a', 'b']) - }) - - it('replaces an existing conversation instead of duplicating it', () => { - const updated = { ...entry('a', 'A'), lastMessagedAt: 5 } - const result = upsertBufferEntry( - [entry('a', 'A'), entry('b', 'A')], - updated, - ) - expect(result.map((e) => e.id)).toEqual(['b', 'a']) - expect(result.find((e) => e.id === 'a')?.lastMessagedAt).toBe(5) - }) -}) - -describe('selectBufferEntriesForUser', () => { - it('keeps only the given user and never another account', () => { - const all = [entry('a', 'A'), entry('b', 'B'), entry('c', 'A')] - expect(selectBufferEntriesForUser(all, 'A').map((e) => e.id)).toEqual([ - 'a', - 'c', - ]) - expect(selectBufferEntriesForUser(all, 'B').map((e) => e.id)).toEqual(['b']) - }) -}) - -describe('pruneFlushedEntries', () => { - it('removes only the exact snapshots that were flushed', () => { - const all = [entry('a', 'A', 1), entry('b', 'A', 1), entry('c', 'A', 1)] - const flushed = [entry('a', 'A', 1), entry('c', 'A', 1)] - expect(pruneFlushedEntries(all, flushed).map((e) => e.id)).toEqual(['b']) - }) - - it('keeps a newer snapshot written for the same id during the upload', () => { - // 'a' was uploaded at t=1 but rewritten at t=2 while the upload ran. - const latest = [entry('a', 'A', 2), entry('b', 'A', 1)] - const flushed = [entry('a', 'A', 1)] - expect(pruneFlushedEntries(latest, flushed).map((e) => e.id)).toEqual([ - 'a', - 'b', - ]) - }) - - it('keeps entries whose upload was not confirmed', () => { - const latest = [entry('a', 'A', 1), entry('b', 'A', 1)] - expect(pruneFlushedEntries(latest, []).map((e) => e.id)).toEqual(['a', 'b']) - }) -}) - -describe('runExclusiveBufferWrite', () => { - it('serializes overlapping mutations instead of interleaving them', async () => { - const order: string[] = [] - const first = runExclusiveBufferWrite(async () => { - order.push('a-start') - await Promise.resolve() - await Promise.resolve() - order.push('a-end') - }) - const second = runExclusiveBufferWrite(async () => { - order.push('b-start') - order.push('b-end') - }) - await Promise.all([first, second]) - expect(order).toEqual(['a-start', 'a-end', 'b-start', 'b-end']) - }) - - it('keeps draining the queue after a mutation rejects', async () => { - await expect( - runExclusiveBufferWrite(async () => { - throw new Error('boom') - }), - ).rejects.toThrow('boom') - await expect(runExclusiveBufferWrite(async () => 'ok')).resolves.toBe('ok') - }) -}) diff --git a/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.ts b/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.ts deleted file mode 100644 index 79b30c3775..0000000000 --- a/packages/browseros-agent/apps/app/lib/conversations/active-conversation-buffer.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { storage } from '@wxt-dev/storage' -import { - type ActiveConversationBufferEntry, - pruneFlushedEntries, - runExclusiveBufferWrite, - selectBufferEntriesForUser, - upsertBufferEntry, -} from './active-conversation-buffer.helpers' -import type { Conversation } from './conversationStorage' - -export type { ActiveConversationBufferEntry } from './active-conversation-buffer.helpers' - -/** - * Transient buffer of in-flight signed-in conversations. Never read by the - * history panel; it only guarantees the cloud eventually receives the - * conversation. Entries are removed once flushed, so it never grows into a - * local mirror of history. - */ -export const activeConversationBufferStorage = storage.defineItem< - ActiveConversationBufferEntry[] ->('local:activeConversationBuffer', { fallback: [] }) - -/** Persists (upserts) the in-flight conversation into the buffer. */ -export async function bufferActiveConversation( - entry: ActiveConversationBufferEntry, -): Promise { - await runExclusiveBufferWrite(async () => { - const current = (await activeConversationBufferStorage.getValue()) ?? [] - await activeConversationBufferStorage.setValue( - upsertBufferEntry(current, entry), - ) - }) -} - -/** - * Uploads the current user's buffered conversations to the cloud via the given - * uploader (which returns the ids it confirmed reached the cloud), then removes - * only those exact snapshots. Entries whose upload failed, and any newer - * snapshot written for the same conversation during the upload, are kept for a - * later retry. Only the current user's entries are ever touched, so a previous - * account's un-synced conversation is never pushed into this account's cloud. - */ -export async function flushActiveConversationBuffer( - userId: string, - upload: (conversations: Conversation[]) => Promise, -): Promise { - const current = (await activeConversationBufferStorage.getValue()) ?? [] - const mine = selectBufferEntriesForUser(current, userId) - if (mine.length === 0) return - - const uploadedIds = new Set( - await upload( - mine.map(({ id, messages, lastMessagedAt }) => ({ - id, - messages, - lastMessagedAt, - })), - ), - ) - const flushed = mine.filter((e) => uploadedIds.has(e.id)) - if (flushed.length === 0) return - - // The upload above ran outside the lock so writes weren't blocked; take the - // lock only to re-read the latest buffer and remove the flushed snapshots, so - // a concurrent write is neither clobbered by nor clobbers this prune. - await runExclusiveBufferWrite(async () => { - const latest = (await activeConversationBufferStorage.getValue()) ?? [] - await activeConversationBufferStorage.setValue( - pruneFlushedEntries(latest, flushed), - ) - }) -} diff --git a/packages/browseros-agent/apps/app/lib/conversations/conversationStorage.ts b/packages/browseros-agent/apps/app/lib/conversations/conversationStorage.ts deleted file mode 100644 index 2bda17497c..0000000000 --- a/packages/browseros-agent/apps/app/lib/conversations/conversationStorage.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { storage } from '@wxt-dev/storage' -import type { UIMessage } from 'ai' - -export interface Conversation { - id: string - messages: UIMessage[] - lastMessagedAt: number -} - -export const conversationStorage = storage.defineItem( - 'local:conversations', - { - fallback: [], - }, -) diff --git a/packages/browseros-agent/apps/app/lib/conversations/uploadConversationsToGraphql.ts b/packages/browseros-agent/apps/app/lib/conversations/uploadConversationsToGraphql.ts deleted file mode 100644 index 16b41611fd..0000000000 --- a/packages/browseros-agent/apps/app/lib/conversations/uploadConversationsToGraphql.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { execute } from '@/lib/graphql/execute' -import { sessionStorage } from '../auth/sessionStorage' -import { sentry } from '../sentry/sentry' -import type { Conversation } from './conversationStorage' -import { - BulkCreateConversationMessagesDocument, - ConversationExistsDocument, - CreateConversationForUploadDocument, - GetProfileIdByUserIdDocument, - GetUploadedMessageCountDocument, -} from './graphql/uploadConversationDocument' - -/** - * Uploads each conversation to the cloud idempotently (creating it and - * appending only the messages the cloud does not already have) and returns the - * ids that are now fully in the cloud. A missing session/profile yields an empty - * result and per-conversation errors are swallowed and omitted, so a caller can - * safely retry whatever is not returned. Does not touch local storage. - * - * Pass `expectedUserId` to bind the upload to a specific account: if the live - * session no longer belongs to that user (a session switch races the upload), - * nothing is uploaded, so one account's buffered conversation is never created - * under another account's profile. - */ -export async function uploadConversations( - conversations: Conversation[], - expectedUserId?: string, -): Promise { - if (conversations.length === 0) return [] - - const sessionInfo = await sessionStorage.getValue() - const userId = sessionInfo?.user?.id - if (!userId) return [] - if (expectedUserId && userId !== expectedUserId) return [] - - const profileResult = await execute(GetProfileIdByUserIdDocument, { userId }) - const profileId = profileResult.profileByUserId?.rowId - if (!profileId) return [] - - const uploadedIds: string[] = [] - - for (const conversation of conversations) { - try { - const existsResult = await execute(ConversationExistsDocument, { - pConversationId: conversation.id, - }) - - let uploadedCount = 0 - - if (existsResult.conversationExists) { - const countResult = await execute(GetUploadedMessageCountDocument, { - conversationId: conversation.id, - }) - uploadedCount = countResult.conversationMessages?.totalCount ?? 0 - - if (uploadedCount >= conversation.messages.length) { - uploadedIds.push(conversation.id) - continue - } - } else { - await execute(CreateConversationForUploadDocument, { - input: { - conversation: { - rowId: conversation.id, - profileId, - lastMessagedAt: new Date( - conversation.lastMessagedAt, - ).toISOString(), - createdAt: new Date(conversation.lastMessagedAt).toISOString(), - }, - }, - }) - } - - const remainingMessages = conversation.messages.slice(uploadedCount) - - if (remainingMessages.length > 0) { - const BATCH_SIZE = 50 - for (let i = 0; i < remainingMessages.length; i += BATCH_SIZE) { - const batch = remainingMessages.slice(i, i + BATCH_SIZE) - await execute(BulkCreateConversationMessagesDocument, { - input: { - pConversationId: conversation.id, - pMessages: batch.map((msg, batchIndex) => ({ - orderIndex: uploadedCount + i + batchIndex, - message: msg, - })), - }, - }) - } - } - - uploadedIds.push(conversation.id) - } catch (error) { - sentry.captureException(error, { - extra: { - conversationId: conversation.id, - messageCount: conversation.messages.length, - }, - }) - } - } - - return uploadedIds -} diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/providerTemplates.ts b/packages/browseros-agent/apps/app/lib/llm-providers/providerTemplates.ts index b8dc5fb4e5..52e79052c1 100644 --- a/packages/browseros-agent/apps/app/lib/llm-providers/providerTemplates.ts +++ b/packages/browseros-agent/apps/app/lib/llm-providers/providerTemplates.ts @@ -199,6 +199,19 @@ const DEFAULT_BASE_URLS: Record = { * Get default base URL for a provider type * @public */ +/** + * Whether a stored type string is one this build understands. + * + * Keyed off DEFAULT_BASE_URLS because it is a `Record`, + * so the compiler keeps it exhaustive as the union changes. Used to filter + * rows written by a newer build after a downgrade: icons, templates and base + * URLs are all keyed by this union, so an unknown type would read as + * undefined through every one of them. + */ +export function isProviderType(value: string): value is ProviderType { + return Object.hasOwn(DEFAULT_BASE_URLS, value) +} + export const getDefaultBaseUrlForProviders = (type: ProviderType): string => { return DEFAULT_BASE_URLS[type] || '' } diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/removed-provider-types.ts b/packages/browseros-agent/apps/app/lib/llm-providers/removed-provider-types.ts new file mode 100644 index 0000000000..092cf10b67 --- /dev/null +++ b/packages/browseros-agent/apps/app/lib/llm-providers/removed-provider-types.ts @@ -0,0 +1,22 @@ +import type { LlmProviderConfig } from './types' + +/** + * Provider types that no longer exist. Storage migrations 4 and 5 strip these, + * but the `browseros.providers` pref backup has no migration path, so a stale + * backup can still be holding them. + */ +export const REMOVED_PROVIDER_TYPES = new Set([ + 'remote-hermes', + 'claude-code', + 'codex', + 'acp-custom', +]) + +export function dropRemovedProviderConfigs( + providers: LlmProviderConfig[] | null, +): LlmProviderConfig[] | null { + if (!providers) return providers + return providers.filter( + (provider) => !REMOVED_PROVIDER_TYPES.has(String(provider.type)), + ) +} diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/storage.test.ts b/packages/browseros-agent/apps/app/lib/llm-providers/storage.test.ts index 2213d687d2..3b262fb593 100644 --- a/packages/browseros-agent/apps/app/lib/llm-providers/storage.test.ts +++ b/packages/browseros-agent/apps/app/lib/llm-providers/storage.test.ts @@ -54,10 +54,6 @@ mock.module('@/lib/browseros/prefs', () => ({ BROWSEROS_PREFS: { PROVIDERS: 'browseros.providers' }, })) -mock.module('./uploadLlmProvidersToGraphql', () => ({ - uploadLlmProvidersToGraphql: async () => {}, -})) - let loadProviders: typeof import('./storage').loadProviders let providersStorage: typeof import('./storage').providersStorage diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/storage.ts b/packages/browseros-agent/apps/app/lib/llm-providers/storage.ts index 8ec881ace6..c79b068a61 100644 --- a/packages/browseros-agent/apps/app/lib/llm-providers/storage.ts +++ b/packages/browseros-agent/apps/app/lib/llm-providers/storage.ts @@ -1,5 +1,4 @@ import { storage } from '@wxt-dev/storage' -import { sessionStorage } from '@/lib/auth/sessionStorage' import { getBrowserOSAdapter } from '@/lib/browseros/adapter' import { BROWSEROS_PREFS } from '@/lib/browseros/prefs' import { @@ -10,27 +9,11 @@ import { DEFAULT_PROVIDER_ID, DEFAULT_PROVIDER_NAME, } from './provider-selection' +import { dropRemovedProviderConfigs } from './removed-provider-types' import type { LlmProviderConfig, LlmProvidersBackup } from './types' -import { uploadLlmProvidersToGraphql } from './uploadLlmProvidersToGraphql' export { DEFAULT_PROVIDER_ID } from './provider-selection' -const REMOVED_PROVIDER_TYPES = new Set([ - 'remote-hermes', - 'claude-code', - 'codex', - 'acp-custom', -]) - -function dropRemovedProviderConfigs( - providers: LlmProviderConfig[] | null, -): LlmProviderConfig[] | null { - if (!providers) return providers - return providers.filter( - (provider) => !REMOVED_PROVIDER_TYPES.has(String(provider.type)), - ) -} - export const providersStorage = storage.defineItem( 'local:llm-providers', { @@ -78,28 +61,6 @@ export function setupLlmProvidersBackupToBrowserOS(): () => void { return unsubscribe } -export async function syncLlmProviders(): Promise { - const providers = await providersStorage.getValue() - if (!providers || providers.length === 0) return - - const session = await sessionStorage.getValue() - const userId = session?.user?.id - if (!userId) return - - await uploadLlmProvidersToGraphql(providers, userId) -} - -export function setupLlmProvidersSyncToBackend(): () => void { - syncLlmProviders().catch(() => {}) - - const unsubscribe = providersStorage.watch(async () => { - try { - await syncLlmProviders() - } catch {} - }) - return unsubscribe -} - export async function loadProviders(): Promise { const providers = (await providersStorage.getValue()) || [] const supportedProviders = dropRemovedProviderConfigs(providers) ?? [] diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/types.ts b/packages/browseros-agent/apps/app/lib/llm-providers/types.ts index ba72264631..71088669d8 100644 --- a/packages/browseros-agent/apps/app/lib/llm-providers/types.ts +++ b/packages/browseros-agent/apps/app/lib/llm-providers/types.ts @@ -62,6 +62,15 @@ export interface LlmProviderConfig { // so it is stored as a free string validated against the model at selection time. reasoningEffort?: string reasoningSummary?: 'auto' | 'concise' | 'detailed' + + // Whether a credential is stored on the server, for providers read back from + // it. The values themselves never leave the server, so a form editing an + // existing provider sees these rather than the secret, and leaving a field + // blank keeps what is stored. + hasApiKey?: boolean + hasAccessKeyId?: boolean + hasSecretAccessKey?: boolean + hasSessionToken?: boolean } /** diff --git a/packages/browseros-agent/apps/app/lib/llm-providers/uploadLlmProvidersToGraphql.ts b/packages/browseros-agent/apps/app/lib/llm-providers/uploadLlmProvidersToGraphql.ts deleted file mode 100644 index 6fa130c2e1..0000000000 --- a/packages/browseros-agent/apps/app/lib/llm-providers/uploadLlmProvidersToGraphql.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { isEqual, omit } from 'es-toolkit' -import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument' -import { execute } from '@/lib/graphql/execute' -import { sentry } from '@/lib/sentry/sentry' -import { - CreateLlmProviderForUploadDocument, - GetLlmProvidersByProfileIdDocument, - UpdateLlmProviderForUploadDocument, -} from './graphql/uploadLlmProviderDocument' -import type { LlmProviderConfig } from './types' - -type RemoteProvider = { - rowId: string - type: string - name: string - baseUrl: string | null - modelId: string - supportsImages: boolean - contextWindow: number | null - temperature: number | null - resourceName: string | null - region: string | null -} - -const IGNORED_FIELDS = [ - 'id', - 'createdAt', - 'updatedAt', - 'apiKey', - 'accessKeyId', - 'secretAccessKey', - 'sessionToken', -] as const - -function toComparable(provider: LlmProviderConfig) { - const data = omit(provider, IGNORED_FIELDS) - return { - ...data, - baseUrl: data.baseUrl ?? null, - resourceName: data.resourceName ?? null, - region: data.region ?? null, - } -} - -export async function uploadLlmProvidersToGraphql( - providers: LlmProviderConfig[], - userId: string, -) { - if (providers.length === 0) return - - const profileResult = await execute(GetProfileIdByUserIdDocument, { userId }) - const profileId = profileResult.profileByUserId?.rowId - if (!profileId) return - - const remoteResult = await execute(GetLlmProvidersByProfileIdDocument, { - profileId, - }) - const remoteProviders = new Map() - for (const node of remoteResult.llmProviders?.nodes ?? []) { - if (node) { - remoteProviders.set(node.rowId, node as RemoteProvider) - } - } - - for (const provider of providers) { - if (provider.type === 'browseros') continue - - try { - const remote = remoteProviders.get(provider.id) - - if (remote) { - if (isEqual(toComparable(provider), omit(remote, ['rowId']))) continue - - await execute(UpdateLlmProviderForUploadDocument, { - input: { - rowId: provider.id, - patch: { - type: provider.type, - name: provider.name, - baseUrl: provider.baseUrl ?? null, - modelId: provider.modelId, - supportsImages: provider.supportsImages, - contextWindow: provider.contextWindow, - temperature: provider.temperature, - resourceName: provider.resourceName ?? null, - region: provider.region ?? null, - updatedAt: new Date(provider.updatedAt).toISOString(), - }, - }, - }) - } else { - await execute(CreateLlmProviderForUploadDocument, { - input: { - llmProvider: { - rowId: provider.id, - profileId, - type: provider.type, - name: provider.name, - baseUrl: provider.baseUrl ?? null, - modelId: provider.modelId, - supportsImages: provider.supportsImages, - contextWindow: provider.contextWindow, - temperature: provider.temperature, - resourceName: provider.resourceName ?? null, - region: provider.region ?? null, - createdAt: new Date(provider.createdAt).toISOString(), - updatedAt: new Date(provider.updatedAt).toISOString(), - }, - }, - }) - } - } catch (error) { - sentry.captureException(error, { - extra: { - providerId: provider.id, - providerName: provider.name, - }, - }) - } - } -} diff --git a/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.test.ts b/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.test.ts index 75a6db23c6..6d487a17a0 100644 --- a/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.test.ts +++ b/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.test.ts @@ -25,7 +25,46 @@ describe('buildChatRequestBody', () => { type: 'browseros', providerId: 'provider-1', }) - expect(body.providerId).toBe('provider-1') + }) + + // The provider is named and nothing more. Its model, endpoint and + // credentials are resolved from the id server side, so an api key and an aws + // secret no longer cross the wire on every message. + it('sends no provider configuration or credentials', () => { + const body = buildChatRequestBody({ + conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43', + provider: { + ...provider, + id: 'bedrock-1', + type: 'bedrock', + apiKey: 'sk-secret', + accessKeyId: 'AKIA', + secretAccessKey: 'aws-secret', + sessionToken: 'token', + baseUrl: 'https://example.com', + }, + }) + + for (const field of [ + 'apiKey', + 'accessKeyId', + 'secretAccessKey', + 'sessionToken', + 'baseUrl', + 'model', + 'provider', + 'providerId', + 'providerType', + 'providerName', + 'temperature', + 'contextWindowSize', + 'region', + 'resourceName', + ]) { + expect(field in body).toBe(false) + } + expect(JSON.stringify(body)).not.toContain('aws-secret') + expect(JSON.stringify(body)).not.toContain('sk-secret') }) it('preserves browser context and chat metadata', () => { diff --git a/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.ts b/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.ts index d186ac731b..a0ff34c6a6 100644 --- a/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.ts +++ b/packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.ts @@ -40,7 +40,16 @@ export interface ChatRequestBrowserContext { export interface ChatRequestBodyParams { conversationId: string - provider: LlmProviderConfig + /** + * The provider config, when the caller already holds it. Only the id is sent; + * the rest is used to describe what the chosen model can do, which comes from + * a catalogue the extension bundles. + * + * Callers that hold nothing but an id, such as the scheduled runner, pass + * `providerId` instead and let the server resolve the rest. + */ + provider?: LlmProviderConfig + providerId?: string message?: string mode?: ChatMode browserContext?: ChatRequestBrowserContext @@ -61,6 +70,7 @@ export interface ChatRequestBodyParams { export const buildChatRequestBody = ({ conversationId, provider, + providerId, message = '', mode, browserContext, @@ -74,31 +84,26 @@ export const buildChatRequestBody = ({ selectedTextSource, isScheduledTask, }: ChatRequestBodyParams) => ({ - target: { type: 'browseros' as const, providerId: provider.id }, + // The provider is named, not described. The server holds the list and which + // one is selected, so it resolves the model, endpoint and credentials from + // the id. Those used to travel on every message, which meant the api key and + // the aws secret crossed the wire each time the user pressed send. + target: { + type: 'browseros' as const, + // Absent when the caller has neither, which tells the server to use the + // selected provider. + providerId: provider?.id ?? providerId, + }, message, - provider: provider.type, - providerId: provider.id, - providerType: provider.type, - providerName: provider.name, - apiKey: provider.apiKey, - baseUrl: provider.baseUrl, conversationId, - model: provider.modelId ?? 'default', mode, - contextWindowSize: provider.contextWindow, - temperature: provider.temperature, - resourceName: provider.resourceName, - accessKeyId: provider.accessKeyId, - secretAccessKey: provider.secretAccessKey, - region: provider.region, - sessionToken: provider.sessionToken, - reasoningEffort: provider.reasoningEffort, - reasoningSummary: provider.reasoningSummary, browserContext, userSystemPrompt, userWorkingDir, - supportsImages: supportsImages ?? provider.supportsImages, - supportsReasoning: resolvesSupportsReasoning(provider), + // Sent because the caller can override what the provider says, and because + // the reasoning answer comes from a model catalogue the extension bundles. + supportsImages: supportsImages ?? provider?.supportsImages, + supportsReasoning: provider ? resolvesSupportsReasoning(provider) : undefined, previousConversation, historyMode, declinedApps: declinedApps?.length ? declinedApps : undefined, diff --git a/packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts b/packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts index 489590dab9..fbad4e5314 100644 --- a/packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts +++ b/packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts @@ -1,19 +1,9 @@ import { chatErrorMessage } from '@browseros/shared/schemas/chat-error' import { createParser, type EventSourceMessage } from 'eventsource-parser' import { getAgentServerUrl } from '@/lib/browseros/helpers' -import { - createDefaultBrowserOSProvider, - defaultProviderIdStorage, - providersStorage, -} from '@/lib/llm-providers/storage' -import type { LlmProviderConfig } from '@/lib/llm-providers/types' import { mcpServerStorage } from '@/lib/mcp/mcpServerStorage' import { buildChatRequestBody } from '@/lib/messaging/server/buildChatRequestBody' import type { ChatMode } from '@/modules/chat/chat-types' -import { - findChatProviderById, - resolveChatProvider, -} from '../llm-providers/provider-runtime' import { personalizationStorage } from '../personalization/personalizationStorage' import { scheduleSystemPrompt } from './scheduleSystemPrompt' import type { ToolCallExecution } from './scheduleTypes' @@ -73,30 +63,15 @@ interface StreamParseState { receivedFinish: boolean } -const getDefaultProvider = async (): Promise => { - const providers = await providersStorage.getValue() - if (!providers?.length) return null - - const defaultProviderId = await defaultProviderIdStorage.getValue() - return resolveChatProvider(providers, defaultProviderId) -} - -const resolveProvider = async ( - providerId?: string, -): Promise => { - if (providerId) { - const providers = await providersStorage.getValue() - const match = findChatProviderById(providers ?? [], providerId) - if (match) return match - } - return (await getDefaultProvider()) ?? createDefaultBrowserOSProvider() -} - export async function getChatServerResponse( request: ChatServerRequest, ): Promise { const agentServerUrl = await getAgentServerUrl() - const provider = await resolveProvider(request.providerId) + // No provider lookup here any more. The server holds the list and the + // selection, so a job names an id or names nothing and the server resolves + // it. That also removes the guard this path needed when it did the lookup + // itself: an unreachable list could not be told apart from an empty one, so + // a job risked running on the built-in provider with the wrong credentials. const conversationId = request.conversationId ?? crypto.randomUUID() const personalization = await personalizationStorage.getValue() @@ -119,9 +94,9 @@ export async function getChatServerResponse( body: JSON.stringify({ messages: [{ role: 'user', content: request.message }], ...buildChatRequestBody({ + providerId: request.providerId, message: request.message, conversationId, - provider, mode: request.mode ?? 'agent', browserContext: request.activeTab || @@ -138,7 +113,6 @@ export async function getChatServerResponse( } : undefined, userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`, - supportsImages: provider.supportsImages, isScheduledTask: true, }), }), diff --git a/packages/browseros-agent/apps/app/lib/schedules/provider-resolution.test.ts b/packages/browseros-agent/apps/app/lib/schedules/provider-resolution.test.ts index 3a39c1b805..13fde55a48 100644 --- a/packages/browseros-agent/apps/app/lib/schedules/provider-resolution.test.ts +++ b/packages/browseros-agent/apps/app/lib/schedules/provider-resolution.test.ts @@ -46,6 +46,15 @@ mock.module('@/lib/llm-providers/storage', () => ({ }, })) +// The provider list is a request now, not a storage read. Mocked here so the +// fetch stub below still sees only the chat call it is asserting on. +mock.module('@/modules/llm-providers/llm-providers.api', () => ({ + listProvidersOrNull: async () => + storageValues.has('unreachable') + ? null + : ((storageValues.get('providers') as LlmProviderConfig[]) ?? []), +})) + mock.module('@/lib/browseros/helpers', () => ({ getAgentServerUrl: async () => 'http://127.0.0.1:9105', getMcpServerUrl: async () => 'http://127.0.0.1:9106/mcp', @@ -69,6 +78,22 @@ mock.module('../personalization/personalizationStorage', () => ({ }, })) +// Only the refine path still resolves a provider on this side; the scheduled +// path names an id and lets the server do it. +const providers: LlmProviderConfig[] = [ + { + id: 'anthropic-sonnet', + type: 'anthropic', + name: 'Anthropic Sonnet', + modelId: 'claude-sonnet-4-6', + supportsImages: true, + contextWindow: 200000, + temperature: 0.2, + createdAt: 0, + updatedAt: 0, + }, +] + beforeEach(() => { storageValues.clear() fetchBodies.length = 0 @@ -97,7 +122,12 @@ afterEach(() => { }) describe('scheduled provider resolution', () => { - it('uses an explicit scheduled provider', async () => { + // The runner names the provider and stops there. Its model, endpoint and + // credentials are resolved from the id on the server, which is what let the + // client-side lookup and its unreachable-versus-empty guard go: the guard + // existed because a failed lookup and an empty list looked the same, and a + // job risked running on the built-in provider with the wrong credentials. + it('names the provider and sends no configuration', async () => { const { getChatServerResponse } = await import('./getChatServerResponse') await getChatServerResponse({ @@ -106,10 +136,24 @@ describe('scheduled provider resolution', () => { }) expect(fetchBodies[0]).toMatchObject({ - provider: 'anthropic', - providerName: 'Anthropic Sonnet', - model: 'claude-sonnet-4-6', + target: { type: 'browseros', providerId: 'anthropic-sonnet' }, + isScheduledTask: true, }) + for (const field of ['apiKey', 'model', 'provider', 'baseUrl']) { + expect(field in fetchBodies[0]).toBe(false) + } + }) + + // A job created without picking a provider names none, and the server uses + // whichever is selected. + it('leaves the provider unnamed when the job has none', async () => { + const { getChatServerResponse } = await import('./getChatServerResponse') + + await getChatServerResponse({ message: 'Run my schedule' }) + + expect( + (fetchBodies[0] as { target: { providerId?: string } }).target.providerId, + ).toBeUndefined() }) it('uses an explicit refine provider', async () => { @@ -132,31 +176,3 @@ describe('scheduled provider resolution', () => { }) }) }) - -const timestamp = 1000 - -const providers: LlmProviderConfig[] = [ - { - id: 'browseros', - type: 'browseros', - name: 'BrowserOS', - modelId: 'browseros-auto', - supportsImages: true, - contextWindow: 200000, - temperature: 0.2, - createdAt: timestamp, - updatedAt: timestamp, - }, - { - id: 'anthropic-sonnet', - type: 'anthropic', - name: 'Anthropic Sonnet', - modelId: 'claude-sonnet-4-6', - apiKey: 'sk-ant', - supportsImages: true, - contextWindow: 200000, - temperature: 0.2, - createdAt: timestamp, - updatedAt: timestamp, - }, -] diff --git a/packages/browseros-agent/apps/app/lib/schedules/refine-prompt.ts b/packages/browseros-agent/apps/app/lib/schedules/refine-prompt.ts index 7d2608ca18..72b6aa5d01 100644 --- a/packages/browseros-agent/apps/app/lib/schedules/refine-prompt.ts +++ b/packages/browseros-agent/apps/app/lib/schedules/refine-prompt.ts @@ -2,9 +2,9 @@ import { getAgentServerUrl } from '@/lib/browseros/helpers' import { createDefaultBrowserOSProvider, defaultProviderIdStorage, - providersStorage, } from '@/lib/llm-providers/storage' import type { LlmProviderConfig } from '@/lib/llm-providers/types' +import { listProvidersOrNull } from '@/modules/llm-providers/llm-providers.api' import { findChatProviderById, resolveChatProvider, @@ -13,8 +13,18 @@ import { const resolveProvider = async ( providerId?: string, ): Promise => { - const providers = await providersStorage.getValue() - if (providers?.length) { + const loaded = await listProvidersOrNull() + // Same rule as the scheduled run: the configured default is a choice too, and + // its model and credentials are in the list that failed to load. Callers here + // already catch and surface this. + if (loaded === null) { + throw new Error( + 'Cannot reach the BrowserOS server to load the selected provider', + ) + } + + const providers = loaded + if (providers.length) { const explicitProvider = findChatProviderById(providers, providerId) if (explicitProvider) return explicitProvider diff --git a/packages/browseros-agent/apps/app/lib/schedules/scheduleStorage.ts b/packages/browseros-agent/apps/app/lib/schedules/scheduleStorage.ts index 41aeaedcda..971186056e 100644 --- a/packages/browseros-agent/apps/app/lib/schedules/scheduleStorage.ts +++ b/packages/browseros-agent/apps/app/lib/schedules/scheduleStorage.ts @@ -1,10 +1,12 @@ import { storage } from '@wxt-dev/storage' -import { useEffect, useState } from 'react' -import { sendScheduleMessage } from '@/lib/messaging/schedules/scheduleMessages' -import { createAlarmFromJob } from './createAlarmFromJob' import type { ScheduledJob, ScheduledJobRun } from './scheduleTypes' -const getAlarmName = (jobId: string) => `scheduled-job-${jobId}` +/** + * Legacy extension storage for scheduled jobs and their runs. + * + * The server owns both now. These items remain only as the source the one-time + * import reads, and nothing writes them any more. + */ export const scheduledJobStorage = storage.defineItem( 'local:scheduledJobs', @@ -19,142 +21,3 @@ export const scheduledJobRunStorage = storage.defineItem( fallback: [], }, ) - -export const pendingDeletionStorage = storage.defineItem( - 'local:scheduledJobsPendingDeletion', - { - fallback: [], - }, -) - -export function useScheduledJobs() { - const [jobs, setJobs] = useState([]) - - useEffect(() => { - scheduledJobStorage.getValue().then(setJobs) - const unwatch = scheduledJobStorage.watch((newValue) => { - setJobs(newValue ?? []) - }) - return unwatch - }, []) - - const addJob = async ( - job: Omit, - ) => { - const now = new Date().toISOString() - const newJob: ScheduledJob = { - id: crypto.randomUUID(), - createdAt: now, - updatedAt: now, - ...job, - } - const current = (await scheduledJobStorage.getValue()) ?? [] - await scheduledJobStorage.setValue([...current, newJob]) - - if (newJob.enabled) { - await createAlarmFromJob(newJob) - } - } - - const removeJob = async (id: string) => { - await chrome.alarms.clear(getAlarmName(id)) - - const pending = (await pendingDeletionStorage.getValue()) ?? [] - if (!pending.includes(id)) { - await pendingDeletionStorage.setValue([...pending, id]) - } - - const currentJobs = (await scheduledJobStorage.getValue()) ?? [] - await scheduledJobStorage.setValue(currentJobs.filter((j) => j.id !== id)) - - const currentRuns = (await scheduledJobRunStorage.getValue()) ?? [] - await scheduledJobRunStorage.setValue( - currentRuns.filter((r) => r.jobId !== id), - ) - } - - const toggleJob = async (id: string, enabled: boolean) => { - const current = (await scheduledJobStorage.getValue()) ?? [] - const job = current.find((j) => j.id === id) - if (!job) return - - const updatedAt = new Date().toISOString() - await scheduledJobStorage.setValue( - current.map((j) => (j.id === id ? { ...j, enabled, updatedAt } : j)), - ) - - if (enabled) { - await createAlarmFromJob({ ...job, enabled }) - } else { - await chrome.alarms.clear(getAlarmName(id)) - } - } - - const editJob = async ( - id: string, - updates: Omit, - ) => { - const current = (await scheduledJobStorage.getValue()) ?? [] - const existingJob = current.find((j) => j.id === id) - if (!existingJob) return - - const updatedJob: ScheduledJob = { - id, - createdAt: existingJob.createdAt, - updatedAt: new Date().toISOString(), - ...updates, - } - await scheduledJobStorage.setValue( - current.map((j) => (j.id === id ? updatedJob : j)), - ) - - await chrome.alarms.clear(getAlarmName(id)) - if (updatedJob.enabled) { - await createAlarmFromJob(updatedJob) - } - } - - const runJob = async (id: string) => { - return sendScheduleMessage('runScheduledJob', { jobId: id }) - } - - return { jobs, addJob, removeJob, editJob, toggleJob, runJob } -} - -export function useScheduledJobRuns() { - const [jobRuns, setJobRuns] = useState([]) - - useEffect(() => { - scheduledJobRunStorage.getValue().then(setJobRuns) - const unwatch = scheduledJobRunStorage.watch((newValue) => { - setJobRuns(newValue ?? []) - }) - return unwatch - }, []) - - const addJobRun = async (jobRun: ScheduledJobRun) => { - const current = (await scheduledJobRunStorage.getValue()) ?? [] - await scheduledJobRunStorage.setValue([...current, jobRun]) - } - - const removeJobRun = async (id: string) => { - const current = (await scheduledJobRunStorage.getValue()) ?? [] - await scheduledJobRunStorage.setValue(current.filter((r) => r.id !== id)) - } - - const editJobRun = async ( - id: string, - updates: Partial>, - ) => { - const current = (await scheduledJobRunStorage.getValue()) ?? [] - await scheduledJobRunStorage.setValue( - current.map((r) => (r.id === id ? { ...r, ...updates } : r)), - ) - } - - const cancelJobRun = async (runId: string) => { - return sendScheduleMessage('cancelScheduledJobRun', { runId }) - } - - return { jobRuns, addJobRun, removeJobRun, editJobRun, cancelJobRun } -} diff --git a/packages/browseros-agent/apps/app/lib/schedules/syncSchedulesToBackend.ts b/packages/browseros-agent/apps/app/lib/schedules/syncSchedulesToBackend.ts deleted file mode 100644 index fcc9633d1f..0000000000 --- a/packages/browseros-agent/apps/app/lib/schedules/syncSchedulesToBackend.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { isEqual, omit } from 'es-toolkit' -import { sessionStorage } from '@/lib/auth/sessionStorage' -import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument' -import { execute } from '@/lib/graphql/execute' -import { sentry } from '@/lib/sentry/sentry' -import { createAlarmFromJob } from './createAlarmFromJob' -import { - CreateScheduledJobDocument, - DeleteScheduledJobDocument, - GetScheduledJobsByProfileIdDocument, - UpdateScheduledJobDocument, -} from './graphql/syncSchedulesDocument' -import { pendingDeletionStorage, scheduledJobStorage } from './scheduleStorage' -import type { ScheduledJob } from './scheduleTypes' - -type RemoteScheduledJob = { - rowId: string - name: string - query: string - scheduleType: string - scheduleTime: string | null - scheduleInterval: number | null - enabled: boolean - llmProviderId: string | null - createdAt: string - updatedAt: string - lastRunAt: string | null -} - -const IGNORED_FIELDS = ['id', 'createdAt', 'lastRunAt'] as const - -function toComparable(job: ScheduledJob) { - const data = omit(job, IGNORED_FIELDS) - return { - ...data, - scheduleTime: data.scheduleTime ?? null, - scheduleInterval: data.scheduleInterval ?? null, - providerId: data.providerId ?? null, - } -} - -function remoteToComparable(job: RemoteScheduledJob) { - return { - name: job.name, - query: job.query, - scheduleType: job.scheduleType as ScheduledJob['scheduleType'], - scheduleTime: job.scheduleTime, - scheduleInterval: job.scheduleInterval, - enabled: job.enabled, - providerId: job.llmProviderId, - } -} - -function normalizeTimestamp(ts: string): string { - return ts.endsWith('Z') ? ts : `${ts}Z` -} - -function remoteToLocal(remote: RemoteScheduledJob): ScheduledJob { - return { - id: remote.rowId, - name: remote.name, - query: remote.query, - scheduleType: remote.scheduleType as ScheduledJob['scheduleType'], - scheduleTime: remote.scheduleTime ?? undefined, - scheduleInterval: remote.scheduleInterval ?? undefined, - enabled: remote.enabled, - providerId: remote.llmProviderId ?? undefined, - createdAt: normalizeTimestamp(remote.createdAt), - updatedAt: normalizeTimestamp(remote.updatedAt), - lastRunAt: remote.lastRunAt - ? normalizeTimestamp(remote.lastRunAt) - : undefined, - } -} - -function getLocalUpdatedAt(job: ScheduledJob): Date { - return new Date(job.updatedAt || job.createdAt) -} - -function getRemoteUpdatedAt(remote: RemoteScheduledJob): Date { - return new Date(normalizeTimestamp(remote.updatedAt)) -} - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO(dani) refactor to reduce complexity -async function syncSchedulesToBackend( - localJobs: ScheduledJob[], - userId: string, -): Promise { - const profileResult = await execute(GetProfileIdByUserIdDocument, { userId }) - const profileId = profileResult.profileByUserId?.rowId - if (!profileId) return - - const remoteResult = await execute(GetScheduledJobsByProfileIdDocument, { - profileId, - }) - - const remoteJobs = new Map() - for (const node of remoteResult.scheduledJobs?.nodes ?? []) { - if (node) { - remoteJobs.set(node.rowId, node as RemoteScheduledJob) - } - } - - const pendingDeletions = new Set( - (await pendingDeletionStorage.getValue()) ?? [], - ) - const resolvedDeletions = new Set() - - for (const rowId of pendingDeletions) { - if (remoteJobs.has(rowId)) { - try { - await execute(DeleteScheduledJobDocument, { rowId }) - remoteJobs.delete(rowId) - resolvedDeletions.add(rowId) - } catch (error) { - sentry.captureException(error, { - extra: { jobId: rowId, context: 'sync-pending-deletion' }, - }) - } - } else { - resolvedDeletions.add(rowId) - } - } - - const latestPending = (await pendingDeletionStorage.getValue()) ?? [] - await pendingDeletionStorage.setValue( - latestPending.filter((id) => !resolvedDeletions.has(id)), - ) - - const localJobsMap = new Map(localJobs.map((j) => [j.id, j])) - const jobsToAddLocally: ScheduledJob[] = [] - const jobsToUpdateLocally: ScheduledJob[] = [] - - for (const [rowId, remote] of remoteJobs) { - const localJob = localJobsMap.get(rowId) - if (!localJob) { - jobsToAddLocally.push(remoteToLocal(remote)) - } else { - const localTime = getLocalUpdatedAt(localJob) - const remoteTime = getRemoteUpdatedAt(remote) - - if (remoteTime > localTime) { - jobsToUpdateLocally.push(remoteToLocal(remote)) - } - } - } - - if (jobsToAddLocally.length > 0 || jobsToUpdateLocally.length > 0) { - const currentJobs = (await scheduledJobStorage.getValue()) ?? [] - const existingIds = new Set(currentJobs.map((j) => j.id)) - - const newJobs = jobsToAddLocally.filter((j) => !existingIds.has(j.id)) - - const mergedJobs = currentJobs.map((j) => { - const updated = jobsToUpdateLocally.find((u) => u.id === j.id) - return updated ?? j - }) - - if (newJobs.length > 0 || jobsToUpdateLocally.length > 0) { - await scheduledJobStorage.setValue([...mergedJobs, ...newJobs]) - - for (const job of [...newJobs, ...jobsToUpdateLocally]) { - try { - const alarmName = `scheduled-job-${job.id}` - await chrome.alarms.clear(alarmName) - if (job.enabled) { - await createAlarmFromJob(job) - } - } catch { - // Alarm operations may fail in non-background context - } - } - } - } - - for (const job of localJobs) { - try { - const remote = remoteJobs.get(job.id) - - if (remote) { - const localTime = getLocalUpdatedAt(job) - const remoteTime = getRemoteUpdatedAt(remote) - - if (remoteTime >= localTime) continue - - if (isEqual(toComparable(job), remoteToComparable(remote))) continue - - await execute(UpdateScheduledJobDocument, { - input: { - rowId: job.id, - patch: { - name: job.name, - query: job.query, - scheduleType: job.scheduleType, - scheduleTime: job.scheduleTime ?? null, - scheduleInterval: job.scheduleInterval ?? null, - enabled: job.enabled, - llmProviderId: job.providerId ?? null, - lastRunAt: job.lastRunAt - ? new Date(job.lastRunAt).toISOString() - : null, - updatedAt: job.updatedAt || new Date().toISOString(), - }, - }, - }) - } else { - await execute(CreateScheduledJobDocument, { - input: { - scheduledJob: { - rowId: job.id, - profileId, - name: job.name, - query: job.query, - scheduleType: job.scheduleType, - scheduleTime: job.scheduleTime ?? null, - scheduleInterval: job.scheduleInterval ?? null, - enabled: job.enabled, - llmProviderId: job.providerId ?? null, - createdAt: new Date(job.createdAt).toISOString(), - updatedAt: job.updatedAt || new Date().toISOString(), - lastRunAt: job.lastRunAt - ? new Date(job.lastRunAt).toISOString() - : null, - }, - }, - }) - } - } catch (error) { - sentry.captureException(error, { - extra: { - jobId: job.id, - jobName: job.name, - }, - }) - } - } -} - -export async function syncScheduledJobs(): Promise { - const jobs = await scheduledJobStorage.getValue() - if (!jobs) return - - const session = await sessionStorage.getValue() - const userId = session?.user?.id - if (!userId) return - - await syncSchedulesToBackend(jobs, userId) -} - -export function setupScheduledJobsSyncToBackend(): () => void { - syncScheduledJobs().catch(() => {}) - - const unsubscribe = scheduledJobStorage.watch(async () => { - try { - await syncScheduledJobs() - } catch { - // Sync failed silently - will retry on next storage change - } - }) - - return unsubscribe -} diff --git a/packages/browseros-agent/apps/app/modules/chat/chat-session-request.test.ts b/packages/browseros-agent/apps/app/modules/chat/chat-session-request.test.ts index 2708216641..4b6c88bfb7 100644 --- a/packages/browseros-agent/apps/app/modules/chat/chat-session-request.test.ts +++ b/packages/browseros-agent/apps/app/modules/chat/chat-session-request.test.ts @@ -21,9 +21,11 @@ describe('chat request preparation', () => { expect(request.api).toBe('http://127.0.0.1:5151/chat') expect(request.body).toMatchObject({ target: { type: 'browseros', providerId: 'browseros' }, - provider: 'browseros', message: 'Summarize this page', }) + // The provider is named, not described: its configuration is resolved + // from the id on the server. + expect('provider' in request.body).toBe(false) }) it('sends ACP agents to the same endpoint without provider fields', () => { diff --git a/packages/browseros-agent/apps/app/modules/chat/chat-session.hooks.ts b/packages/browseros-agent/apps/app/modules/chat/chat-session.hooks.ts index 32973236be..31ec26170f 100644 --- a/packages/browseros-agent/apps/app/modules/chat/chat-session.hooks.ts +++ b/packages/browseros-agent/apps/app/modules/chat/chat-session.hooks.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSearchParams } from 'react-router' import useDeepCompareEffect from 'use-deep-compare-effect' import type { Provider } from '@/components/chat/chatComponentTypes' +import { useSessionInfo } from '@/lib/auth/sessionStorage' import { conversationForTab, conversationPanelViewsStorage, @@ -24,12 +25,7 @@ import { MESSAGE_SENT_EVENT, PROVIDER_SELECTED_EVENT, } from '@/lib/constants/analyticsEvents' -import { - bufferActiveConversation, - flushActiveConversationBuffer, -} from '@/lib/conversations/active-conversation-buffer' import { formatConversationHistory } from '@/lib/conversations/formatConversationHistory' -import { uploadConversations } from '@/lib/conversations/uploadConversationsToGraphql' import { declinedAppsStorage } from '@/lib/declined-apps/storage' import { resolveChatProvider } from '@/lib/llm-providers/provider-runtime' import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage' @@ -64,7 +60,6 @@ import { fetchConversationRunState, } from './conversation-run-client' import { useExecutionHistoryTracker } from './execution-history-tracker.hooks' -import { useRemoteConversationSave } from './remote-conversation-save.hooks' import { toLlmProviderConfig } from './sidepanel-chat-targets' import { stripImageToolOutputs } from './tool-output-strip' @@ -218,23 +213,24 @@ export const useChatSession = (options?: ChatSessionOptions) => { error: agentUrlError, } = useAgentServerUrl() - const { - isLoggedIn, - userId, - saveConversation: saveRemoteConversation, - resetConversation: resetRemoteConversation, - markMessagesAsSaved, - } = useRemoteConversationSave() + // Identity is still needed to read a cloud conversation back. Nothing on + // this screen writes to the cloud any more. + const { sessionInfo } = useSessionInfo() + const userId = sessionInfo.user?.id + const isLoggedIn = !!userId const [searchParams, setSearchParams] = useSearchParams() const conversationIdParam = searchParams.get('conversationId') - // 'local': the local server owns history (persisted during /chat, loaded from - // SQLite); 'cloud': the client owns it (logged-in cloud sync, or incognito). + // 'local': the local server owns history, persisting it to SQLite during + // /chat. Every signed-in user now takes this path too, where the client used + // to upload their turns to the cloud instead. 'cloud' survives only as the + // incognito case, where it means nothing is persisted at all, because the + // client no longer writes anywhere. // Read via a ref because the transport closure below is created only once. const historyModeRef = useRef<'local' | 'cloud'>('cloud') useEffect(() => { - historyModeRef.current = !isLoggedIn && persistHistory ? 'local' : 'cloud' - }, [isLoggedIn, persistHistory]) + historyModeRef.current = persistHistory ? 'local' : 'cloud' + }, [persistHistory]) const agentUrlRef = useRef(agentServerUrl) @@ -657,7 +653,6 @@ export const useChatSession = (options?: ChatSessionOptions) => { conversationIdParam as ReturnType, ) setMessages(restoredMessages) - markMessagesAsSaved(conversationIdParam, restoredMessages) } setRestoredConversationId(conversationIdParam) setSearchParams({}, { replace: true }) @@ -766,66 +761,17 @@ export const useChatSession = (options?: ChatSessionOptions) => { const messagesToSave = getPersistableMessages(messages) if (messagesToSave.length === 0) return - // Skip all history writes in incognito so the chat never becomes durable - // (neither local nor cloud) and can't surface in a normal window (#1189). - // Logged-out history is owned by the local server (persisted during /chat - // in local mode), so only the cloud lane writes from the client now. - if (persistHistory && isLoggedIn) { - // Buffer the settled turn durably before the fire-and-forget cloud - // write, so an interrupted navigation still lets the next mount sync it - // (#559). - if (userId) { - void bufferActiveConversation({ - id: conversationIdRef.current, - messages: messagesToSave, - lastMessagedAt: Date.now(), - userId, - }) - } - saveRemoteConversation(conversationIdRef.current, messagesToSave) - } + // The local server persists every turn during /chat, so the client has no + // history write of its own left. Incognito still writes nowhere (#1189). invalidateCredits() }, [status]) // Save the in-flight conversation before it can be lost: on page hide (full // navigation, tab switch, close) and on unmount, because an in-app SPA route - // change to Settings unmounts the chat while the page stays visible, so - // visibilitychange never fires. Reads the latest messages either way; the next - // mount then syncs it to the cloud (#559). The settled turn is also buffered - // at turn end above. This effect's deps are the auth pair, not messages, so - // the unmount write runs once, not on every token. - useEffect(() => { - if (!persistHistory || !isLoggedIn || !userId) return - const writeBuffer = () => { - const latest = getPersistableMessages(messagesRef.current) - if (latest.length === 0) return - void bufferActiveConversation({ - id: conversationIdRef.current, - messages: latest, - lastMessagedAt: Date.now(), - userId, - }) - } - const onHide = () => { - if (document.visibilityState === 'hidden') writeBuffer() - } - document.addEventListener('visibilitychange', onHide) - return () => { - document.removeEventListener('visibilitychange', onHide) - writeBuffer() - } - }, [persistHistory, isLoggedIn, userId]) - - // On mount (and on sign-in), push any buffered in-flight conversations for the - // current user to the cloud so an interrupted chat still lands in history. It - // is never restored into the active conversation; recovery is via history. - useEffect(() => { - if (!persistHistory || !isLoggedIn || !userId) return - void flushActiveConversationBuffer(userId, (conversations) => - uploadConversations(conversations, userId), - ) - }, [persistHistory, isLoggedIn, userId]) + // The durable turn buffer and its flush lived here to survive an + // interrupted cloud upload. The local server persists each turn during + // /chat, so there is nothing left to buffer. useEffect(() => { if (chatError) invalidateCredits() @@ -969,7 +915,6 @@ export const useChatSession = (options?: ChatSessionOptions) => { // (via the restore effect's cleanup), so a stale response can't revive the // old conversation over this new blank session. setSearchParams({}, { replace: true }) - resetRemoteConversation() } const handleSelectProvider = (provider: Provider) => { diff --git a/packages/browseros-agent/apps/app/modules/chat/remote-conversation-save.hooks.ts b/packages/browseros-agent/apps/app/modules/chat/remote-conversation-save.hooks.ts deleted file mode 100644 index 4a44c6c4d8..0000000000 --- a/packages/browseros-agent/apps/app/modules/chat/remote-conversation-save.hooks.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { UIMessage } from 'ai' -import { useCallback, useRef } from 'react' -import { useSessionInfo } from '@/lib/auth/sessionStorage' -import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument' -import { execute } from '@/lib/graphql/execute' -import { sentry } from '@/lib/sentry/sentry' -import { - AppendConversationMessageDocument, - CreateConversationWithMessageDocument, - UpdateConversationLastMessagedAtDocument, -} from './chat-session-document' - -export function useRemoteConversationSave() { - const { sessionInfo } = useSessionInfo() - const userId = sessionInfo.user?.id - - const profileIdRef = useRef(null) - const createdConversationsRef = useRef>(new Set()) - const savedMessageIdsRef = useRef>(new Set()) - - const getProfileId = async (): Promise => { - if (profileIdRef.current) return profileIdRef.current - if (!userId) return null - - const result = await execute(GetProfileIdByUserIdDocument, { userId }) - const profileId = result.profileByUserId?.rowId ?? null - profileIdRef.current = profileId - return profileId - } - - const saveConversation = async ( - conversationId: string, - messages: UIMessage[], - ) => { - if (!userId || messages.length === 0) return - - const profileId = await getProfileId() - if (!profileId) return - - const isNewConversation = - !createdConversationsRef.current.has(conversationId) - const newMessages = messages.filter( - (msg) => !savedMessageIdsRef.current.has(msg.id), - ) - - if (newMessages.length === 0) return - - try { - if (isNewConversation && newMessages.length > 0) { - const firstMessage = newMessages[0] - await execute(CreateConversationWithMessageDocument, { - conversationId, - profileId, - message: firstMessage, - }) - createdConversationsRef.current.add(conversationId) - savedMessageIdsRef.current.add(firstMessage.id) - - for (let i = 1; i < newMessages.length; i++) { - const msg = newMessages[i] - const orderIndex = messages.findIndex((m) => m.id === msg.id) - await execute(AppendConversationMessageDocument, { - messageId: msg.id, - conversationId, - orderIndex, - message: msg, - }) - savedMessageIdsRef.current.add(msg.id) - } - } else { - for (const msg of newMessages) { - const orderIndex = messages.findIndex((m) => m.id === msg.id) - await execute(AppendConversationMessageDocument, { - messageId: msg.id, - conversationId, - orderIndex, - message: msg, - }) - savedMessageIdsRef.current.add(msg.id) - } - - await execute(UpdateConversationLastMessagedAtDocument, { - conversationId, - }) - } - } catch (error) { - sentry.captureException(error, { - extra: { - message: 'Failed to save conversation to remote', - }, - }) - } - } - - const resetConversation = () => { - savedMessageIdsRef.current = new Set() - } - - const markMessagesAsSaved = useCallback( - (conversationId: string, messages: UIMessage[]) => { - createdConversationsRef.current.add(conversationId) - for (const msg of messages) { - savedMessageIdsRef.current.add(msg.id) - } - }, - [], - ) - - return { - isLoggedIn: !!userId, - userId, - saveConversation, - resetConversation, - markMessagesAsSaved, - } -} diff --git a/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.test.ts b/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.test.ts index 11000895ce..a1e3989c2e 100644 --- a/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.test.ts +++ b/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.test.ts @@ -204,7 +204,11 @@ describe('commitChatTargetSelection', () => { expect(setDefaultProvider).toHaveBeenCalledWith(provider.id) }) - it('persists an ACP selection without touching the default provider id', async () => { + // Selecting an agent used to leave the default pointing at whichever llm + // provider was chosen before it, because the two lived in separate tables and + // the default could only name the llm one. They are one table now, so there + // is one selection and it records whatever was picked. + it('records an ACP selection as the default too', async () => { const store = createSelectionStore() const setDefaultProvider = mock(async (_id: string) => {}) @@ -215,7 +219,7 @@ describe('commitChatTargetSelection', () => { ) expect(await store.getValue()).toEqual({ kind: 'acp', id: agent.id }) - expect(setDefaultProvider).not.toHaveBeenCalled() + expect(setDefaultProvider).toHaveBeenCalledWith(agent.id) }) it('clears the selection without touching the default provider id', async () => { @@ -241,3 +245,51 @@ function createSelectionStore( watch: () => () => {}, } } + +// Each extension surface holds its own cache of a list that lives on the +// server, so one can be a refetch behind another. Repairing against a list +// that has not caught up destroys a choice the user just made, which is what +// made selecting a new provider appear to revert to BrowserOS. +describe('resolveRepairedSelection with an incomplete list', () => { + const browserosTarget = { + kind: 'llm' as const, + id: 'browseros', + name: 'BrowserOS', + type: 'browseros' as const, + provider: {} as never, + } + + it('leaves a selection this surface has not seen yet alone', () => { + expect( + resolveRepairedSelection({ + selection: { kind: 'llm', id: 'just-created' }, + resolvedTarget: browserosTarget, + ready: true, + knownIds: new Set(['browseros']), + }).repair, + ).toBe(false) + }) + + // The case repair exists for: the provider is gone from a list that does + // know about it, so the selection genuinely dangles. + it('still repairs a selection the list can account for', () => { + expect( + resolveRepairedSelection({ + selection: { kind: 'llm', id: 'deleted-but-known' }, + resolvedTarget: browserosTarget, + ready: true, + knownIds: new Set(['browseros', 'deleted-but-known']), + }), + ).toEqual({ repair: true, selection: { kind: 'llm', id: 'browseros' } }) + }) + + it('repairs as before when no list is given', () => { + expect( + resolveRepairedSelection({ + selection: { kind: 'llm', id: 'gone' }, + resolvedTarget: browserosTarget, + ready: true, + }).repair, + ).toBe(true) + }) +}) diff --git a/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.ts b/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.ts index 7a54c9e5e1..5519e8b4cd 100644 --- a/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.ts +++ b/packages/browseros-agent/apps/app/modules/chat/sidepanel-chat-targets.ts @@ -121,24 +121,39 @@ export type RepairSelectionDecision = | { repair: true; selection: SidepanelChatTargetSelection | null } /** - * Decides whether a persisted sidebar selection needs repair. It never repairs - * an ACP selection: the agents list is fetch-backed and can be stale (a - * persisted react-query cache, or a different extension context that has not - * refetched a newly-created agent), so repairing here would wipe a valid ACP - * default and silently downgrade it to the LLM fallback. Stale ACP selections - * are cleaned by `clearSidepanelChatTargetSelectionForAgent` on delete, and - * `resolveSidepanelChatTarget` already falls back non-destructively at render. - * Only LLM selections are repaired, since providers load reliably from local - * storage, and only once loads are settled. + * Decides whether a persisted sidebar selection needs repair. + * + * Repair exists for one case: the selection names something that has been + * deleted, so the sidebar would otherwise point at nothing. Absence from the + * list in hand is not evidence of that. Each extension surface holds its own + * query cache of a list that lives on the server, so a provider added in + * another surface is missing here until this one refetches, and rewriting the + * selection then destroys a choice the user just made. That is what made + * picking a new provider appear to revert to BrowserOS while picking an agent + * worked, since ACP selections were already exempt. + * + * So a selection is only repaired when the list is known to be complete, and a + * selection this list cannot confirm is left alone for the next render, when + * the revision signal will have brought the list up to date. + * `resolveSidepanelChatTarget` already falls back non-destructively for + * display, so nothing is broken while that happens. Stale entries are still + * cleaned on delete by `clearSidepanelChatTargetSelectionForAgent`. */ export function resolveRepairedSelection({ selection, resolvedTarget, ready, + knownIds, }: { selection: SidepanelChatTargetSelection | null resolvedTarget: SidepanelChatTarget | undefined ready: boolean + /** + * Every id this surface currently knows about, of either kind. A selection + * naming something absent from it is treated as not yet loaded rather than + * as deleted. + */ + knownIds?: ReadonlySet }): RepairSelectionDecision { if (!ready || !selection) return { repair: false } if (selection.kind === 'acp') return { repair: false } @@ -149,6 +164,8 @@ export function resolveRepairedSelection({ ) { return { repair: false } } + // Inconclusive rather than deleted: this surface has not seen it yet. + if (knownIds && !knownIds.has(selection.id)) return { repair: false } return { repair: true, selection: resolvedTarget @@ -187,13 +204,22 @@ export async function saveSidepanelChatTargetSelection( * target, also updates the default-provider id so both stores stay consistent. * Keeping this in one place is what prevents surfaces from drifting apart. */ +/** + * Records the chosen chat target. + * + * One write, for either kind. While llm providers and acp agents were separate + * tables the default could only name an llm one, so this wrote the selection + * unconditionally and the default only when the kind happened to be llm. + * Choosing an agent left the default pointing at whichever provider was + * selected before it, a stale shadow of the real choice. + */ export async function commitChatTargetSelection( selection: SidepanelChatTargetSelection | null, deps: { setDefaultProvider: (providerId: string) => Promise }, store?: SidepanelChatTargetSelectionWriter, ): Promise { await saveSidepanelChatTargetSelection(selection, store) - if (selection?.kind === 'llm') await deps.setDefaultProvider(selection.id) + if (selection) await deps.setDefaultProvider(selection.id) } export async function clearSidepanelChatTargetSelectionForAgent( diff --git a/packages/browseros-agent/apps/app/modules/chat/use-chat-target-selection.ts b/packages/browseros-agent/apps/app/modules/chat/use-chat-target-selection.ts index 6904ba8384..c15556de76 100644 --- a/packages/browseros-agent/apps/app/modules/chat/use-chat-target-selection.ts +++ b/packages/browseros-agent/apps/app/modules/chat/use-chat-target-selection.ts @@ -97,11 +97,18 @@ export function useChatTargetSelection() { selection: targetSelection, resolvedTarget: selectedChatTarget, ready, + knownIds: new Set(chatTargets.map((target) => target.id)), }) if (!decision.repair) return setTargetSelection(decision.selection) void persistSidepanelChatTargetSelection(selectedChatTarget) - }, [agentsSettled, isLoadingProviders, selectedChatTarget, targetSelection]) + }, [ + agentsSettled, + chatTargets, + isLoadingProviders, + selectedChatTarget, + targetSelection, + ]) const selectedLlmProviderRef = useRef( selectedLlmProvider, diff --git a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts deleted file mode 100644 index d8ea52c558..0000000000 --- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.helpers.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { UIMessage } from 'ai' -import type { Conversation } from '@/lib/conversations/conversationStorage' - -export interface MigrateLegacyConversationsOptions { - conversations: Conversation[] - isLoggedIn: boolean - userId: string | undefined - importToServer: (conversation: Conversation) => Promise - uploadToCloud: ( - conversations: Conversation[], - userId: string, - ) => Promise -} - -/** - * One-shot migration of pre-upgrade `local:conversations`. A logged-in user - * keeps the old promote-to-cloud behavior; a logged-out user's history moves to - * the local server. Returns the ids that were handled so the caller can drain - * them from storage; a conversation that fails to migrate is left for a retry. - */ -export async function migrateLegacyConversations({ - conversations, - isLoggedIn, - userId, - importToServer, - uploadToCloud, -}: MigrateLegacyConversationsOptions): Promise { - if (conversations.length === 0) return [] - - if (isLoggedIn) { - return userId ? uploadToCloud(conversations, userId) : [] - } - - const migrated: string[] = [] - for (const conversation of conversations) { - try { - await importToServer(conversation) - migrated.push(conversation.id) - } catch { - // Leave unmigrated conversations in place for the next attempt. - } - } - return migrated -} - -export interface CollectServerConversationsOptions { - listSummaries: () => Promise> - loadDetail: ( - id: string, - ) => Promise<{ id: string; messages: UIMessage[] } | null> -} - -/** - * Reads every server conversation with its messages, shaped for a cloud upload. - * Drops any conversation deleted between the list and its detail fetch. - */ -export async function collectServerConversations({ - listSummaries, - loadDetail, -}: CollectServerConversationsOptions): Promise { - const summaries = await listSummaries() - const details = await Promise.all( - summaries.map(async (summary) => { - const detail = await loadDetail(summary.id) - return detail - ? { - id: detail.id, - messages: detail.messages, - lastMessagedAt: summary.lastMessagedAt, - } - : null - }), - ) - return details.filter( - (conversation): conversation is Conversation => conversation !== null, - ) -} - -export interface PromoteServerConversationsOptions { - userId: string - collect: () => Promise - upload: (conversations: Conversation[], userId: string) => Promise - drain: (id: string) => Promise -} - -export interface PromoteResult { - uploadedIds: string[] - allUploaded: boolean -} - -/** - * Promotes the local server's (logged-out) history to the cloud, then drains - * only the conversations the cloud confirmed. Draining is what keeps a later - * sign-in under a different account from re-uploading someone else's retained - * history, and it leaves any failed conversation on the server for a retry. - */ -export async function promoteServerConversations({ - userId, - collect, - upload, - drain, -}: PromoteServerConversationsOptions): Promise { - const conversations = await collect() - if (conversations.length === 0) return { uploadedIds: [], allUploaded: true } - - const uploadedIds = await upload(conversations, userId) - await Promise.all(uploadedIds.map((id) => drain(id))) - - return { - uploadedIds, - allUploaded: uploadedIds.length === conversations.length, - } -} - -/** - * Returns a runner that executes tasks one at a time. The promote must not - * overlap across an account switch: a serialized second promotion waits for the - * first to upload and drain, so it never re-uploads the same unowned server rows - * into a different account. - */ -export function createSerialRunner(): ( - task: () => Promise, -) => Promise { - let chain: Promise = Promise.resolve() - return (task: () => Promise): Promise => { - const result = chain.then(task, task) - chain = result.then( - () => undefined, - () => undefined, - ) - return result - } -} diff --git a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.test.ts b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.test.ts deleted file mode 100644 index 13a925ac70..0000000000 --- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, expect, it, mock } from 'bun:test' -import type { UIMessage } from 'ai' -import type { Conversation } from '@/lib/conversations/conversationStorage' -import { - collectServerConversations, - createSerialRunner, - migrateLegacyConversations, - promoteServerConversations, -} from './conversations-migration.helpers' - -function conversation(id: string): Conversation { - const messages: UIMessage[] = [ - { id: `${id}-m`, role: 'user', parts: [{ type: 'text', text: id }] }, - ] - return { id, messages, lastMessagedAt: 1 } -} - -describe('migrateLegacyConversations', () => { - it('does nothing when there are no conversations', async () => { - const importToServer = mock(async () => {}) - const uploadToCloud = mock(async () => []) - - expect( - await migrateLegacyConversations({ - conversations: [], - isLoggedIn: false, - userId: undefined, - importToServer, - uploadToCloud, - }), - ).toEqual([]) - expect(importToServer).not.toHaveBeenCalled() - expect(uploadToCloud).not.toHaveBeenCalled() - }) - - it('uploads to the cloud when logged in', async () => { - const importToServer = mock(async () => {}) - const uploadToCloud = mock(async () => ['a']) - - const handled = await migrateLegacyConversations({ - conversations: [conversation('a'), conversation('b')], - isLoggedIn: true, - userId: 'user-1', - importToServer, - uploadToCloud, - }) - - expect(handled).toEqual(['a']) - expect(uploadToCloud).toHaveBeenCalledWith( - [conversation('a'), conversation('b')], - 'user-1', - ) - expect(importToServer).not.toHaveBeenCalled() - }) - - it('imports to the server when logged out', async () => { - const importToServer = mock(async () => {}) - const uploadToCloud = mock(async () => []) - - const handled = await migrateLegacyConversations({ - conversations: [conversation('a'), conversation('b')], - isLoggedIn: false, - userId: undefined, - importToServer, - uploadToCloud, - }) - - expect(handled).toEqual(['a', 'b']) - expect(importToServer).toHaveBeenCalledTimes(2) - expect(uploadToCloud).not.toHaveBeenCalled() - }) - - it('only reports the conversations that imported successfully', async () => { - const importToServer = mock(async (conv: Conversation) => { - if (conv.id === 'b') throw new Error('server down') - }) - - const handled = await migrateLegacyConversations({ - conversations: [conversation('a'), conversation('b'), conversation('c')], - isLoggedIn: false, - userId: undefined, - importToServer, - uploadToCloud: mock(async () => []), - }) - - expect(handled).toEqual(['a', 'c']) - }) - - it('does not touch the server for a logged-in state that lacks a user id', async () => { - const importToServer = mock(async () => {}) - const uploadToCloud = mock(async () => []) - - expect( - await migrateLegacyConversations({ - conversations: [conversation('a')], - isLoggedIn: true, - userId: undefined, - importToServer, - uploadToCloud, - }), - ).toEqual([]) - expect(importToServer).not.toHaveBeenCalled() - expect(uploadToCloud).not.toHaveBeenCalled() - }) -}) - -describe('collectServerConversations', () => { - it('pairs each summary with its detail and carries lastMessagedAt', async () => { - const listSummaries = mock(async () => [ - { id: 'a', lastMessagedAt: 10 }, - { id: 'b', lastMessagedAt: 20 }, - ]) - const loadDetail = mock(async (id: string) => ({ - id, - messages: [{ id: `${id}-m`, role: 'user', parts: [] }] as UIMessage[], - })) - - const result = await collectServerConversations({ - listSummaries, - loadDetail, - }) - - expect(result).toEqual([ - { - id: 'a', - lastMessagedAt: 10, - messages: [{ id: 'a-m', role: 'user', parts: [] }], - }, - { - id: 'b', - lastMessagedAt: 20, - messages: [{ id: 'b-m', role: 'user', parts: [] }], - }, - ]) - }) - - it('drops a conversation deleted before its detail loaded', async () => { - const listSummaries = mock(async () => [ - { id: 'a', lastMessagedAt: 10 }, - { id: 'gone', lastMessagedAt: 5 }, - ]) - const loadDetail = mock(async (id: string) => - id === 'gone' ? null : { id, messages: [] as UIMessage[] }, - ) - - const result = await collectServerConversations({ - listSummaries, - loadDetail, - }) - - expect(result.map((conversation) => conversation.id)).toEqual(['a']) - }) -}) - -describe('promoteServerConversations', () => { - it('does nothing when the server has no conversations', async () => { - const upload = mock(async () => []) - const drain = mock(async () => {}) - - const result = await promoteServerConversations({ - userId: 'u1', - collect: mock(async () => []), - upload, - drain, - }) - - expect(result).toEqual({ uploadedIds: [], allUploaded: true }) - expect(upload).not.toHaveBeenCalled() - expect(drain).not.toHaveBeenCalled() - }) - - it('drains every conversation the cloud confirms', async () => { - const drain = mock((_id: string) => Promise.resolve()) - - const result = await promoteServerConversations({ - userId: 'u1', - collect: mock(async () => [conversation('a'), conversation('b')]), - upload: mock(async () => ['a', 'b']), - drain, - }) - - expect(result).toEqual({ uploadedIds: ['a', 'b'], allUploaded: true }) - expect(drain.mock.calls.map((call) => call[0]).sort()).toEqual(['a', 'b']) - }) - - it('keeps a failed conversation on the server and reports incomplete', async () => { - const drain = mock(async () => {}) - - const result = await promoteServerConversations({ - userId: 'u1', - collect: mock(async () => [conversation('a'), conversation('b')]), - upload: mock(async () => ['a']), - drain, - }) - - expect(result).toEqual({ uploadedIds: ['a'], allUploaded: false }) - expect(drain).toHaveBeenCalledTimes(1) - expect(drain).toHaveBeenCalledWith('a') - }) -}) - -describe('createSerialRunner', () => { - it('runs tasks one at a time', async () => { - const run = createSerialRunner() - const order: string[] = [] - - const first = run(async () => { - order.push('1-start') - await Promise.resolve() - order.push('1-end') - }) - const second = run(async () => { - order.push('2-start') - order.push('2-end') - }) - await Promise.all([first, second]) - - expect(order).toEqual(['1-start', '1-end', '2-start', '2-end']) - }) - - it('runs the next task even when the previous one rejects', async () => { - const run = createSerialRunner() - const ran: string[] = [] - - const first = run(async () => { - throw new Error('boom') - }) - const second = run(async () => { - ran.push('second') - }) - - await expect(first).rejects.toThrow('boom') - await second - expect(ran).toEqual(['second']) - }) -}) diff --git a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts b/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts deleted file mode 100644 index 49223f50f1..0000000000 --- a/packages/browseros-agent/apps/app/modules/conversations/conversations-migration.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import { useEffect } from 'react' -import { useSessionInfo } from '@/lib/auth/sessionStorage' -import { conversationStorage } from '@/lib/conversations/conversationStorage' -import { uploadConversations } from '@/lib/conversations/uploadConversationsToGraphql' -import { sentry } from '@/lib/sentry/sentry' -import { - deleteServerConversationRow, - fetchServerConversation, - fetchServerConversations, - importServerConversation, - SERVER_CONVERSATIONS_QUERY_KEY, -} from './conversations.hooks' -import { - collectServerConversations, - createSerialRunner, - migrateLegacyConversations, - promoteServerConversations, -} from './conversations-migration.helpers' - -/** - * Drains any pre-upgrade `local:conversations` to their new home (cloud when - * logged in, the local server otherwise). Idempotent: once storage is drained - * subsequent runs are no-ops. - */ -export function useLegacyConversationMigration(): void { - const { sessionInfo } = useSessionInfo() - const userId = sessionInfo.user?.id - const queryClient = useQueryClient() - - useEffect(() => { - let cancelled = false - const run = async () => { - const conversations = (await conversationStorage.getValue()) ?? [] - if (cancelled || conversations.length === 0) return - - const handledIds = await migrateLegacyConversations({ - conversations, - isLoggedIn: !!userId, - userId, - importToServer: importServerConversation, - uploadToCloud: uploadConversations, - }) - if (cancelled || handledIds.length === 0) return - - const current = (await conversationStorage.getValue()) ?? [] - await conversationStorage.setValue( - current.filter((conversation) => !handledIds.includes(conversation.id)), - ) - if (!userId) { - queryClient.invalidateQueries({ - queryKey: [SERVER_CONVERSATIONS_QUERY_KEY], - }) - } - } - run().catch((error) => { - sentry.captureException(error, { - extra: { message: 'Legacy conversation migration failed' }, - }) - }) - return () => { - cancelled = true - } - }, [userId, queryClient]) -} - -// Module-scoped so the promote survives history remounts (once per sign-in, not -// once per history open); reset when the user is absent, or when a promote does -// not fully complete, so leftovers retry. -let lastPromotedUserId: string | undefined -// Serialize so an account switch cannot run two promotions over the same -// undrained server rows concurrently (which could upload them into two accounts). -const runPromoteExclusive = createSerialRunner() - -/** - * On sign-in, promote server-held (logged-out) history to the cloud (draining - * each conversation the cloud confirms, so it cannot leak to a later sign-in), - * then run `onPromoted` (e.g. to refresh the cloud history list) when anything - * landed. - */ -export function useSignInConversationPromote(onPromoted?: () => void): void { - const { sessionInfo } = useSessionInfo() - const userId = sessionInfo.user?.id - - useEffect(() => { - if (!userId) { - lastPromotedUserId = undefined - return - } - if (lastPromotedUserId === userId) return - lastPromotedUserId = userId - - let cancelled = false - runPromoteExclusive(() => - promoteServerConversations({ - userId, - collect: () => - collectServerConversations({ - listSummaries: fetchServerConversations, - loadDetail: fetchServerConversation, - }), - upload: uploadConversations, - drain: deleteServerConversationRow, - }), - ) - .then((result) => { - // Reset the guard whenever the promote did not fully complete, even if - // this effect was cancelled, so leftovers are retried and never linger - // leak-eligible. Only the UI refresh is gated on cancellation. - if (!result.allUploaded) lastPromotedUserId = undefined - if (!cancelled && result.uploadedIds.length > 0) onPromoted?.() - }) - .catch((error) => { - lastPromotedUserId = undefined - sentry.captureException(error, { - extra: { message: 'Sign-in conversation promote failed' }, - }) - }) - return () => { - cancelled = true - } - }, [userId, onPromoted]) -} diff --git a/packages/browseros-agent/apps/app/modules/conversations/conversations.hooks.ts b/packages/browseros-agent/apps/app/modules/conversations/conversations.hooks.ts index 00de850bcd..402377810e 100644 --- a/packages/browseros-agent/apps/app/modules/conversations/conversations.hooks.ts +++ b/packages/browseros-agent/apps/app/modules/conversations/conversations.hooks.ts @@ -58,24 +58,6 @@ export async function fetchServerConversation( return { id: data.conversation.id, messages } } -export async function importServerConversation(conversation: { - id: string - messages: UIMessage[] - lastMessagedAt: number -}): Promise { - const client = await conversationsClient() - const response = await client[':conversationId'].$put({ - param: { conversationId: conversation.id }, - json: { - messages: conversation.messages, - lastMessagedAt: conversation.lastMessagedAt, - }, - }) - if (!response.ok) { - throw new Error(`Failed to import conversation (${response.status})`) - } -} - /** Deletes only the server row (tolerating 404); leaves execution history. */ export async function deleteServerConversationRow( conversationId: string, diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.api.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.api.ts new file mode 100644 index 0000000000..3aa0c517a2 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.api.ts @@ -0,0 +1,113 @@ +import type { ProviderRoutes } from '@browseros/server' +import { hc } from 'hono/client' +import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage' +import type { LlmProviderConfig } from '@/lib/llm-providers/types' +import { resolveAgentServerUrlWithRetry } from '@/modules/browseros/agent-server-url.helpers' +import { toProviderConfigs, toProviderPayload } from './llm-providers.helpers' +import { bumpProviderRevision } from './llm-providers.revision' + +async function providersClient() { + const baseUrl = await resolveAgentServerUrlWithRetry() + return hc(`${baseUrl}/providers`) +} + +export async function putProvider(config: LlmProviderConfig): Promise { + const client = await providersClient() + const response = await client[':providerId'].$put({ + param: { providerId: config.id }, + json: toProviderPayload(config), + }) + if (!response.ok) { + throw new Error(`Failed to save provider (${response.status})`) + } + await bumpProviderRevision() +} + +export async function deleteProvider(providerId: string): Promise { + const client = await providersClient() + const response = await client[':providerId'].$delete({ + param: { providerId }, + }) + if (!response.ok && response.status !== 404) { + throw new Error(`Failed to delete provider (${response.status})`) + } + await bumpProviderRevision() +} + +/** + * The selected provider's id, or null when none is set. + * + * Held on the server beside the providers it points at, so it covers acp + * agents as readily as llm ones. It used to sit in extension storage, which + * meant selecting an agent left this pointing at the previous llm provider. + */ +export async function fetchDefaultProviderId(): Promise { + const client = await providersClient() + const response = await client.default.$get() + if (!response.ok) { + throw new Error(`Failed to load the default provider (${response.status})`) + } + const { provider } = await response.json() + return provider?.id ?? null +} + +export async function putDefaultProvider(providerId: string): Promise { + const client = await providersClient() + const response = await client.default.$put({ json: { providerId } }) + if (!response.ok) { + throw new Error(`Failed to set the default provider (${response.status})`) + } + await bumpProviderRevision() +} + +export async function listProviders(): Promise { + const client = await providersClient() + const response = await client.index.$get() + if (!response.ok) { + throw new Error(`Failed to load providers (${response.status})`) + } + const { providers } = await response.json() + return toProviderConfigs(providers) +} + +/** + * Loads the provider list, seeding the built-in BrowserOS provider when the + * server has none. + * + * The seed lives here rather than in an effect so it can only run on a + * confirmed empty response. Reacting to an empty list in the component would + * fire on a failed load too, writing the default over a list that had simply + * not arrived yet. The write is a PUT on a fixed id, so a retried fetch cannot + * produce duplicates either. + */ +export async function fetchProviders(): Promise { + const configs = await listProviders() + if (configs.length > 0) return configs + + const seeded = createDefaultBrowserOSProvider() + await putProvider(seeded) + return [seeded] +} + +/** + * The list for callers outside React, returning null when the server could not + * be reached. + * + * Null rather than an empty array because the two mean different things to a + * caller resolving an explicitly chosen provider: absent means the provider + * was deleted and falling back is right, unreachable means the choice is + * simply unknown and running anyway would use the wrong credentials. + * + * These callers must not seed either. A background alarm firing while the + * server is still starting would otherwise write the default into a database + * the migration had not filled yet. + */ +export async function listProvidersOrNull(): Promise< + LlmProviderConfig[] | null +> { + try { + return await listProviders() + } catch { + return null + } +} diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.test.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.test.ts new file mode 100644 index 0000000000..6c221daef8 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'bun:test' +import type { LlmProviderConfig } from '@/lib/llm-providers/types' +import { + type ProviderRow, + removedProviderIds, + toProviderConfig, + toProviderConfigs, + toProviderPayload, +} from './llm-providers.helpers' + +function row(overrides: Partial = {}): ProviderRow { + return { + id: 'provider-1', + type: 'openai', + name: 'My OpenAI', + kind: 'llm', + baseUrl: null, + modelId: 'gpt-5.5', + supportsImages: true, + contextWindow: 200000, + temperature: 0.2, + hasApiKey: false, + hasAccessKeyId: false, + hasSecretAccessKey: false, + hasSessionToken: false, + resourceName: null, + region: null, + reasoningEffort: null, + reasoningSummary: null, + createdAt: 10, + updatedAt: 20, + ...overrides, + } +} + +function config(overrides: Partial = {}) { + return { + id: 'provider-1', + type: 'openai', + name: 'My OpenAI', + modelId: 'gpt-5.5', + supportsImages: true, + contextWindow: 200000, + temperature: 0.2, + createdAt: 10, + updatedAt: 20, + ...overrides, + } as LlmProviderConfig +} + +describe('toProviderConfig', () => { + // The column is nullable but the config type uses undefined, and the two are + // not interchangeable to anything doing `'apiKey' in provider`. + it('turns absent columns into undefined rather than null', () => { + const converted = toProviderConfig(row()) + + expect(converted?.baseUrl).toBeUndefined() + expect(converted?.reasoningSummary).toBeUndefined() + }) + + // Credentials never leave the server now, so the row reports only whether + // one is set and the config carries that instead. + it('carries the credential flags, not the credentials', () => { + const converted = toProviderConfig( + row({ hasApiKey: true, region: 'us-east-1' }), + ) + + expect(converted).toMatchObject({ hasApiKey: true, region: 'us-east-1' }) + expect(converted?.apiKey).toBeUndefined() + }) + + // Coding agents share this table and this endpoint. Filtering on kind is + // what excludes them; the unknown-type guard below used to do it by accident. + it('rejects a coding agent', () => { + expect( + toProviderConfig(row({ kind: 'acp', type: 'claude', modelId: null })), + ).toBeNull() + }) + + // The row survives in the database and comes back on upgrade. Showing it + // would push an unknown key through the icon map and template lookup, both + // keyed by the provider union. + it('rejects a type this build does not know', () => { + expect(toProviderConfig(row({ type: 'some-future-provider' }))).toBeNull() + }) + + it('keeps a recognised reasoning summary and drops an unrecognised one', () => { + expect( + toProviderConfig(row({ reasoningSummary: 'concise' }))?.reasoningSummary, + ).toBe('concise') + expect( + toProviderConfig(row({ reasoningSummary: 'verbose' }))?.reasoningSummary, + ).toBeUndefined() + }) +}) + +describe('toProviderConfigs', () => { + it('drops unusable rows without losing the rest', () => { + const converted = toProviderConfigs([ + row(), + row({ id: 'provider-2', type: 'some-future-provider' }), + ]) + + expect(converted.map((provider) => provider.id)).toEqual(['provider-1']) + }) +}) + +describe('toProviderPayload', () => { + // id travels in the path, so sending it in the body too would let the two + // disagree. + it('leaves the id out of the body', () => { + expect('id' in toProviderPayload(config())).toBe(false) + }) + + it('preserves the creation time so a save does not reset it', () => { + expect(toProviderPayload(config()).createdAt).toBe(10) + }) +}) + +describe('removedProviderIds', () => { + it('names the ids that a save displaced', () => { + const before = [config(), config({ id: 'provider-2' })] + const after = [config()] + + expect(removedProviderIds(before, after)).toEqual(['provider-2']) + }) + + it('names nothing when the save displaced nothing', () => { + const before = [config()] + expect(removedProviderIds(before, before)).toEqual([]) + }) +}) diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.ts new file mode 100644 index 0000000000..b5539fcfa6 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.helpers.ts @@ -0,0 +1,192 @@ +import { isProviderType } from '@/lib/llm-providers/providerTemplates' +import type { LlmProviderConfig } from '@/lib/llm-providers/types' + +/** + * A provider row as the server returns it. + * + * Absent values are null rather than undefined, and credentials are not here + * at all: the server reports only whether each is set, so a key cannot reach + * a surface that has no use for it. + */ +export interface ProviderRow { + id: string + kind: 'llm' | 'acp' + type: string + name: string + baseUrl: string | null + // Nullable since the table holds coding agents too, and those carry neither. + modelId: string | null + supportsImages: boolean + contextWindow: number | null + temperature: number + hasApiKey: boolean + hasAccessKeyId: boolean + hasSecretAccessKey: boolean + hasSessionToken: boolean + resourceName: string | null + region: string | null + reasoningEffort: string | null + reasoningSummary: string | null + createdAt: number + updatedAt: number +} + +function orUndefined(value: T | null): T | undefined { + return value ?? undefined +} + +function toReasoningSummary( + value: string | null, +): LlmProviderConfig['reasoningSummary'] { + if (value === 'auto' || value === 'concise' || value === 'detailed') { + return value + } + return undefined +} + +/** + * Converts a stored row to the config shape the app works in. + * + * Returns null for a type this build does not know, which happens after a + * downgrade from a build that added one. The row stays in the database and + * reappears on upgrade; showing it would push an unknown key through the icon + * map, the template lookup and the default base URLs, all keyed by the union. + */ +export function toProviderConfig(row: ProviderRow): LlmProviderConfig | null { + // Coding agents share this table and this endpoint, and are served to the + // surfaces that want them through their own hook. Filtering on kind says + // that; leaning on the unknown-type guard below to drop them happened to + // work and said something else entirely. + if (row.kind !== 'llm') return null + if (!isProviderType(row.type)) return null + if (row.modelId === null || row.contextWindow === null) return null + + return { + id: row.id, + type: row.type, + name: row.name, + baseUrl: orUndefined(row.baseUrl), + modelId: row.modelId, + supportsImages: row.supportsImages, + contextWindow: row.contextWindow, + temperature: row.temperature, + hasApiKey: row.hasApiKey, + hasAccessKeyId: row.hasAccessKeyId, + hasSecretAccessKey: row.hasSecretAccessKey, + hasSessionToken: row.hasSessionToken, + resourceName: orUndefined(row.resourceName), + region: orUndefined(row.region), + reasoningEffort: orUndefined(row.reasoningEffort), + reasoningSummary: toReasoningSummary(row.reasoningSummary), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } +} + +export function toProviderConfigs(rows: readonly ProviderRow[]) { + return rows + .map(toProviderConfig) + .filter((config): config is LlmProviderConfig => config !== null) +} + +/** The request body for a provider write. `id` travels in the path instead. */ +export function toProviderPayload(config: LlmProviderConfig) { + return { + type: config.type, + name: config.name, + baseUrl: config.baseUrl, + modelId: config.modelId, + supportsImages: config.supportsImages, + contextWindow: config.contextWindow, + temperature: config.temperature, + apiKey: config.apiKey, + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + sessionToken: config.sessionToken, + resourceName: config.resourceName, + region: config.region, + reasoningEffort: config.reasoningEffort, + reasoningSummary: config.reasoningSummary, + createdAt: config.createdAt, + } +} + +/** + * Provider types where a second copy makes no sense, because the credential is + * an OAuth grant held once per account rather than a key the user can hold + * several of. + */ +const SINGLE_INSTANCE_PROVIDER_TYPES = new Set([ + 'chatgpt-pro', + 'github-copilot', + 'qwen-code', +]) + +export interface ProviderSavePlan { + saved: LlmProviderConfig + removedIds: string[] +} + +/** + * Works out the writes a save turns into. + * + * Extension storage took the whole list at once, so collapsing an earlier copy + * of a single-instance provider fell out of replacing the array. Over HTTP the + * save is one PUT, so the copies it displaces have to be deleted explicitly, + * and the surviving id has to be the earlier one so the row keeps its + * identity rather than accumulating a new one per sign-in. + */ +export function planProviderSave( + current: readonly LlmProviderConfig[], + provider: LlmProviderConfig, + now = Date.now(), +): ProviderSavePlan { + if (!SINGLE_INSTANCE_PROVIDER_TYPES.has(provider.type)) { + const existing = current.find((candidate) => candidate.id === provider.id) + return { + saved: existing + ? { ...provider, updatedAt: now } + : { ...provider, createdAt: now, updatedAt: now }, + removedIds: [], + } + } + + const existing = + current.find((candidate) => candidate.id === provider.id) ?? + current.find((candidate) => candidate.type === provider.type) + + const saved: LlmProviderConfig = { + ...provider, + id: existing?.id ?? provider.id, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + } + + const removedIds = current + .filter( + (candidate) => + candidate.id !== saved.id && + (candidate.type === provider.type || candidate.id === provider.id), + ) + .map((candidate) => candidate.id) + + return { saved, removedIds } +} + +/** + * Ids present before a save but not after. + * + * Saving a single-instance provider (an OAuth one, where a second copy makes + * no sense) collapses any earlier copy into the saved one. In extension + * storage that fell out of writing the whole list at once; over HTTP the + * removals have to be issued explicitly. + */ +export function removedProviderIds( + before: readonly LlmProviderConfig[], + after: readonly LlmProviderConfig[], +): string[] { + const kept = new Set(after.map((provider) => provider.id)) + return before + .filter((provider) => !kept.has(provider.id)) + .map((provider) => provider.id) +} diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.test.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.test.ts index 24d4a5755e..ed18481658 100644 --- a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.test.ts +++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.test.ts @@ -4,8 +4,20 @@ import { resolveDefaultProviderId, resolveSelectedProvider, } from '../../lib/llm-providers/provider-selection' +import { planProviderSave } from './llm-providers.helpers' const storageValues = new Map() +const putDefaultProviderCalls: string[] = [] + +mock.module('./llm-providers.api', () => ({ + fetchProviders: async () => [], + fetchDefaultProviderId: async () => null, + putProvider: async () => undefined, + deleteProvider: async () => undefined, + putDefaultProvider: async (providerId: string) => { + putDefaultProviderCalls.push(providerId) + }, +})) mock.module('@wxt-dev/storage', () => ({ storage: { @@ -91,10 +103,6 @@ mock.module('../../lib/llm-providers/storage', () => ({ }, })) -mock.module('@/lib/llm-providers/uploadLlmProvidersToGraphql', () => ({ - uploadLlmProvidersToGraphql: async () => {}, -})) - const timestamp = 1000 function providerConfig( @@ -139,12 +147,9 @@ const providers: LlmProviderConfig[] = [ ] let persistDefaultProviderId: (providerId: string) => Promise -let upsertProviderConfig: typeof import('./llm-providers.hooks').upsertProviderConfig beforeAll(async () => { - ;({ persistDefaultProviderId, upsertProviderConfig } = await import( - './llm-providers.hooks' - )) + ;({ persistDefaultProviderId } = await import('./llm-providers.hooks')) }) beforeEach(() => { @@ -160,16 +165,19 @@ describe('resolveSelectedProvider', () => { }) describe('persistDefaultProviderId', () => { - it('writes a provider id to default-provider storage', async () => { + // The selection moved to the server when the two provider tables merged, so + // it can name a coding agent as readily as an llm provider. It used to be an + // extension storage write, which is why it could only ever name the latter. + it('sends the provider id to the server', async () => { await persistDefaultProviderId('anthropic-provider') - expect(storageValues.get('local:default-provider-id')).toBe( - 'anthropic-provider', - ) + expect(putDefaultProviderCalls).toEqual(['anthropic-provider']) }) }) -describe('upsertProviderConfig', () => { +describe('planProviderSave', () => { + // These are OAuth providers, where the credential is one grant per account, + // so a second copy is always a duplicate of the first rather than a choice. it('replaces an existing OAuth provider by type while preserving its id', () => { const existing = providerConfig({ id: 'chatgpt-pro-existing', @@ -187,14 +195,13 @@ describe('upsertProviderConfig', () => { contextWindow: 1050000, }) - const result = upsertProviderConfig( + const { saved, removedIds } = planProviderSave( [providers[0], existing], incoming, 2222, ) - expect(result).toHaveLength(2) - expect(result[1]).toMatchObject({ + expect(saved).toMatchObject({ id: 'chatgpt-pro-existing', type: 'chatgpt-pro', name: 'ChatGPT', @@ -203,9 +210,12 @@ describe('upsertProviderConfig', () => { createdAt: 1111, updatedAt: 2222, }) + expect(removedIds).toEqual([]) }) - it('removes extra same-type OAuth rows on save', () => { + // Writing the whole list used to drop these implicitly. Over HTTP each one + // needs its own DELETE, so the plan has to name them. + it('names the extra same-type OAuth rows for deletion', () => { const first = providerConfig({ id: 'chatgpt-pro-first', type: 'chatgpt-pro', @@ -222,28 +232,49 @@ describe('upsertProviderConfig', () => { name: 'Fresh ChatGPT', }) - const result = upsertProviderConfig([providers[0], first, second], incoming) + const { saved, removedIds } = planProviderSave( + [providers[0], first, second], + incoming, + ) - expect( - result.filter((provider) => provider.type === 'chatgpt-pro'), - ).toEqual([ - expect.objectContaining({ - id: 'chatgpt-pro-first', - name: 'Fresh ChatGPT', - }), - ]) + expect(saved).toMatchObject({ + id: 'chatgpt-pro-first', + name: 'Fresh ChatGPT', + }) + expect(removedIds).toEqual(['chatgpt-pro-second']) }) it('allows multiple non-OAuth providers of the same type', () => { const first = providerConfig({ id: 'openai-first', name: 'OpenAI 1' }) const second = providerConfig({ id: 'openai-second', name: 'OpenAI 2' }) - const result = upsertProviderConfig([first], second, 2222) + const { saved, removedIds } = planProviderSave([first], second, 2222) + + expect(saved.id).toBe('openai-second') + expect(removedIds).toEqual([]) + }) + + it('stamps a creation time on a provider that is new', () => { + const { saved } = planProviderSave( + [], + providerConfig({ id: 'openai-new' }), + 3333, + ) + + expect(saved.createdAt).toBe(3333) + expect(saved.updatedAt).toBe(3333) + }) + + it('keeps the original creation time when updating in place', () => { + const existing = providerConfig({ id: 'openai-1', createdAt: 1111 }) + const { saved } = planProviderSave( + [existing], + { ...existing, name: 'Renamed' }, + 4444, + ) - expect(result.map((provider) => provider.id)).toEqual([ - 'openai-first', - 'openai-second', - ]) + expect(saved.createdAt).toBe(1111) + expect(saved.updatedAt).toBe(4444) }) }) diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.ts index a3add7fad7..fe1ec9a437 100644 --- a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.ts +++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.hooks.ts @@ -1,213 +1,164 @@ -import { useEffect, useMemo, useState } from 'react' -import type { LlmProviderConfig } from '@/lib/llm-providers/types' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { createQuery } from 'react-query-kit' import { resolveDefaultProviderId, resolveSelectedProvider, -} from '../../lib/llm-providers/provider-selection' +} from '@/lib/llm-providers/provider-selection' +import { DEFAULT_PROVIDER_ID } from '@/lib/llm-providers/storage' +import type { LlmProviderConfig } from '@/lib/llm-providers/types' import { - createDefaultProvidersConfig, - DEFAULT_PROVIDER_ID, - defaultProviderIdStorage, - loadProviders, - providersStorage, -} from '../../lib/llm-providers/storage' + deleteProvider as deleteProviderRow, + fetchDefaultProviderId, + fetchProviders, + putDefaultProvider, + putProvider, +} from './llm-providers.api' +import { planProviderSave } from './llm-providers.helpers' +import { watchProviderRevision } from './llm-providers.revision' export interface UseLlmProvidersReturn { providers: LlmProviderConfig[] defaultProviderId: string selectedProvider: LlmProviderConfig | null isLoading: boolean + /** + * The server could not be reached, as opposed to reporting no providers. + * Callers must not treat this as an empty list: the difference is between + * offering to set up a first provider and saying the list is unavailable. + */ + isUnavailable: boolean + /** + * Resolves with the row that was actually written. A single-instance save + * keeps the existing provider's id, so the caller must not assume the id it + * passed in is the one that persisted. + */ saveProvider: (provider: LlmProviderConfig) => Promise setDefaultProvider: (providerId: string) => Promise deleteProvider: (providerId: string) => Promise } -const SINGLE_INSTANCE_PROVIDER_TYPES = new Set([ - 'chatgpt-pro', - 'github-copilot', - 'qwen-code', -]) +export const useProvidersQuery = createQuery({ + queryKey: ['llm-providers'], + fetcher: fetchProviders, +}) + +/** + * The selected provider, held on the server beside the providers it points at. + * + * It lived in extension storage until the two provider tables were merged, + * which meant it could only ever name an llm provider: selecting an acp agent + * wrote the other pointer and left this one stale. + */ +export const useDefaultProviderIdQuery = createQuery({ + queryKey: ['provider-default'], + fetcher: fetchDefaultProviderId, +}) /** Persists the configured default provider id used by provider selection. */ -// Exported only for llm-providers.hooks.test.ts; fallow's graph skips test imports. -// fallow-ignore-next-line unused-export export async function persistDefaultProviderId( providerId: string, ): Promise { - await defaultProviderIdStorage.setValue(providerId) + await putDefaultProvider(providerId) } -/** Applies provider-save semantics before writing the full provider list. */ -export function upsertProviderConfig( - currentProviders: LlmProviderConfig[], - provider: LlmProviderConfig, - now = Date.now(), -): LlmProviderConfig[] { - if (SINGLE_INSTANCE_PROVIDER_TYPES.has(provider.type)) { - return upsertSingleInstanceProvider(currentProviders, provider, now) - } - - const existingIndex = currentProviders.findIndex( - (candidate) => candidate.id === provider.id, +/** + * Keeps this surface current with provider writes made in another one. + * + * Each extension surface has its own query cache, and the rows live on the + * server where nothing can watch them. Writers bump a revision in extension + * storage instead, which does broadcast to every context, and each mounted + * view refetches. This is what `providersStorage.watch` used to do before the + * list moved off extension storage. + */ +function useProviderRevision(): void { + const queryClient = useQueryClient() + + useEffect( + () => + watchProviderRevision(() => { + queryClient.invalidateQueries({ queryKey: useProvidersQuery.getKey() }) + queryClient.invalidateQueries({ + queryKey: useDefaultProviderIdQuery.getKey(), + }) + }), + [queryClient], ) - if (existingIndex >= 0) { - const updatedProviders = [...currentProviders] - updatedProviders[existingIndex] = { ...provider, updatedAt: now } - return updatedProviders - } - - return [ - ...currentProviders, - { - ...provider, - createdAt: now, - updatedAt: now, - }, - ] -} - -function upsertSingleInstanceProvider( - currentProviders: LlmProviderConfig[], - provider: LlmProviderConfig, - now: number, -): LlmProviderConfig[] { - const existing = - currentProviders.find((candidate) => candidate.id === provider.id) ?? - currentProviders.find((candidate) => candidate.type === provider.type) - const savedProvider = { - ...provider, - id: existing?.id ?? provider.id, - createdAt: existing?.createdAt ?? now, - updatedAt: now, - } - let inserted = false - - const updatedProviders = currentProviders.flatMap((candidate) => { - if (candidate.id === savedProvider.id) { - if (inserted) return [] - inserted = true - return [savedProvider] - } - if (candidate.type === provider.type || candidate.id === provider.id) { - return [] - } - return [candidate] - }) - - if (!inserted) updatedProviders.push(savedProvider) - return updatedProviders } /** Hook for managing LLM provider configurations. */ export function useLlmProviders(): UseLlmProvidersReturn { - const [providers, setProviders] = useState([]) - const [defaultProviderId, setDefaultProviderId] = - useState(DEFAULT_PROVIDER_ID) - const [isLoading, setIsLoading] = useState(true) - - useEffect(() => { - const loadData = async () => { - setIsLoading(true) - try { - let [loadedProviders, loadedDefaultId] = await Promise.all([ - loadProviders(), - defaultProviderIdStorage.getValue(), - ]) - - if (!loadedProviders || loadedProviders.length === 0) { - loadedProviders = createDefaultProvidersConfig() - await providersStorage.setValue(loadedProviders) - } - - const resolvedDefaultId = resolveDefaultProviderId( - loadedProviders, - loadedDefaultId, - ) - if (resolvedDefaultId !== loadedDefaultId) { - await defaultProviderIdStorage.setValue(resolvedDefaultId) - } - - setProviders(loadedProviders) - setDefaultProviderId(resolvedDefaultId) - } catch { - } finally { - setIsLoading(false) - } - } - - loadData() - }, []) - - useEffect(() => { - const unsubscribeProviders = providersStorage.watch((newProviders) => { - if (newProviders) { - setProviders(newProviders) - } + const queryClient = useQueryClient() + const providersQuery = useProvidersQuery() + const defaultQuery = useDefaultProviderIdQuery() + useProviderRevision() + const storedDefaultId = defaultQuery.data ?? DEFAULT_PROVIDER_ID + + const providers = providersQuery.data ?? [] + const invalidate = () => + queryClient.invalidateQueries({ queryKey: useProvidersQuery.getKey() }) + const invalidateDefault = () => + queryClient.invalidateQueries({ + queryKey: useDefaultProviderIdQuery.getKey(), }) - const unsubscribeDefaultId = defaultProviderIdStorage.watch( - (newDefaultId) => { - if (newDefaultId) { - setDefaultProviderId(newDefaultId) - } - }, - ) - - return () => { - unsubscribeProviders() - unsubscribeDefaultId() - } - }, []) - - const saveProvider = async ( - provider: LlmProviderConfig, - ): Promise => { - const currentProviders = (await providersStorage.getValue()) || [] - const updatedProviders = upsertProviderConfig(currentProviders, provider) - await providersStorage.setValue(updatedProviders) - // The single-instance upsert can keep an existing row's id, so a reconnect - // saves under the existing id, not the fresh one on `provider`. Return the - // row that was actually written so callers reference the id that persisted. - return ( - updatedProviders.find((candidate) => candidate.id === provider.id) ?? - updatedProviders.find((candidate) => candidate.type === provider.type) ?? - provider - ) - } + const saveMutation = useMutation({ + mutationFn: async (provider: LlmProviderConfig) => { + const { saved, removedIds } = planProviderSave(providers, provider) + await putProvider(saved) + for (const id of removedIds) await deleteProviderRow(id) + // The row that persisted, which is not always the one passed in: a + // single-instance save keeps the earlier provider's id, and that is the + // id chat target selection has to reference. + return saved + }, + onSuccess: invalidate, + }) - const setDefaultProviderFn = async (providerId: string) => { - setDefaultProviderId(providerId) - await persistDefaultProviderId(providerId) - } + const deleteMutation = useMutation({ + mutationFn: async (providerId: string) => { + // The built-in provider is what the app falls back to, so removing it + // would leave nothing to chat with. + if (providerId === DEFAULT_PROVIDER_ID) return - const deleteProvider = async (providerId: string) => { - if (providerId === DEFAULT_PROVIDER_ID) { - return - } + // Delete first. Moving the default before the row is gone leaves the + // provider configured but no longer default when the delete fails, with + // nothing to tell the user it happened. The reverse is harmless: a + // default id pointing at a deleted provider is repaired on read. + await deleteProviderRow(providerId) - const currentProviders = (await providersStorage.getValue()) || [] - const updatedProviders = currentProviders.filter((p) => p.id !== providerId) + // Nothing to repoint by hand: deleting the row removes the default with + // it, and the next provider is chosen on read. + }, + onSuccess: () => { + invalidate() + invalidateDefault() + }, + }) - if (defaultProviderId === providerId) { - const newDefaultId = updatedProviders[0]?.id || DEFAULT_PROVIDER_ID - await defaultProviderIdStorage.setValue(newDefaultId) - } + const setDefaultMutation = useMutation({ + mutationFn: persistDefaultProviderId, + onSuccess: invalidateDefault, + }) - await providersStorage.setValue(updatedProviders) + const setDefaultProvider = async (providerId: string) => { + await setDefaultMutation.mutateAsync(providerId) } - const selectedProvider = useMemo( - () => resolveSelectedProvider(providers, defaultProviderId), - [providers, defaultProviderId], - ) + // Derived on read rather than repaired in storage: the write would be a side + // effect of rendering, and every reader resolves the id the same way anyway. + const defaultProviderId = resolveDefaultProviderId(providers, storedDefaultId) return { providers, defaultProviderId, - selectedProvider, - isLoading, - saveProvider, - setDefaultProvider: setDefaultProviderFn, - deleteProvider, + selectedProvider: resolveSelectedProvider(providers, defaultProviderId), + isLoading: providersQuery.isPending || defaultQuery.isPending, + isUnavailable: providersQuery.isError || defaultQuery.isError, + saveProvider: (provider) => saveMutation.mutateAsync(provider), + setDefaultProvider, + deleteProvider: async (providerId) => { + await deleteMutation.mutateAsync(providerId) + }, } } diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.revision.ts b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.revision.ts new file mode 100644 index 0000000000..c346f3dc20 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/llm-providers/llm-providers.revision.ts @@ -0,0 +1,28 @@ +import { storage } from '@wxt-dev/storage' + +/** + * A change signal, not data. + * + * The provider list and the selected provider live on the server, and every + * extension surface holds its own query cache of them. Extension storage used + * to carry the data itself, so `watch` kept the side panel, the new tab, the + * settings page and the scheduled tasks page in step for free. Moving the data + * to the server kept the cache per surface and dropped the broadcast, so a + * provider added in one place stayed invisible to the others until they + * happened to refetch. This keeps the broadcast half. + * + * The value is a timestamp rather than a counter so two surfaces writing at + * once cannot lose a bump to a read-modify-write race. + */ +export const providerRevisionStorage = storage.defineItem( + 'local:provider-revision', + { fallback: 0 }, +) + +export async function bumpProviderRevision(): Promise { + await providerRevisionStorage.setValue(Date.now()) +} + +export function watchProviderRevision(onChange: () => void): () => void { + return providerRevisionStorage.watch(() => onChange()) +} diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.test.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.test.ts new file mode 100644 index 0000000000..c2e1311ddb --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'bun:test' +import type { LlmProviderConfig } from '@/lib/llm-providers/types' +import type { ScheduledJob } from '@/lib/schedules/scheduleTypes' +import { + isImportableJob, + isImportableProvider, + mergeProviderSources, + parseProviderBackup, + toProviderImport, + toScheduledJobImport, +} from './local-first-migration.helpers' + +function provider( + overrides: Partial = {}, +): LlmProviderConfig { + return { + id: 'provider-1', + type: 'openai', + name: 'My OpenAI', + modelId: 'gpt-5.5', + supportsImages: true, + contextWindow: 200000, + temperature: 0.2, + createdAt: 10, + updatedAt: 20, + ...overrides, + } +} + +function job(overrides: Partial = {}): ScheduledJob { + return { + id: 'job-1', + name: 'Morning digest', + query: 'summarise my inbox', + scheduleType: 'daily', + scheduleTime: '09:00', + enabled: true, + createdAt: '2026-01-02T03:04:05.000Z', + updatedAt: '2026-01-02T03:04:05.000Z', + ...overrides, + } +} + +describe('parseProviderBackup', () => { + it('reads the provider list out of the pref payload', () => { + const raw = JSON.stringify({ + defaultProviderId: 'provider-1', + providers: [provider()], + }) + expect(parseProviderBackup(raw).map((p) => p.id)).toEqual(['provider-1']) + }) + + // The backup is a fallback source, so a corrupt one must not stop the + // extension-storage providers from importing. + it('yields nothing rather than throwing on unusable input', () => { + expect(parseProviderBackup('not json')).toEqual([]) + expect(parseProviderBackup('null')).toEqual([]) + expect(parseProviderBackup(JSON.stringify({ providers: 'nope' }))).toEqual( + [], + ) + expect(parseProviderBackup(undefined)).toEqual([]) + expect(parseProviderBackup('')).toEqual([]) + }) + + it('drops entries with no id', () => { + const raw = JSON.stringify({ providers: [provider(), { name: 'junk' }] }) + expect(parseProviderBackup(raw)).toHaveLength(1) + }) +}) + +describe('mergeProviderSources', () => { + // Extension storage is written on every save, so it is the current copy. + it('keeps the stored provider when both sources have the id', () => { + const merged = mergeProviderSources( + [provider({ name: 'Current' })], + [provider({ name: 'Stale backup' })], + ) + expect(merged).toHaveLength(1) + expect(merged[0].name).toBe('Current') + }) + + // The reinstall case: extension storage was cleared, the per-profile pref + // outlived it, and the backup is the only remaining copy. + it('contributes backup providers that storage no longer has', () => { + const merged = mergeProviderSources([], [provider({ id: 'from-backup' })]) + expect(merged.map((p) => p.id)).toEqual(['from-backup']) + }) + + it('does not duplicate a provider repeated within the backup', () => { + const merged = mergeProviderSources([], [provider(), provider()]) + expect(merged).toHaveLength(1) + }) +}) + +describe('toProviderImport', () => { + it('carries the credentials across', () => { + expect( + toProviderImport( + provider({ + apiKey: 'sk-test', + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + }), + ), + ).toMatchObject({ + apiKey: 'sk-test', + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + }) + }) + + it('preserves the original creation time', () => { + expect(toProviderImport(provider()).createdAt).toBe(10) + }) +}) + +describe('toScheduledJobImport', () => { + it('converts the ISO timestamps the extension holds to epoch', () => { + const imported = toScheduledJobImport( + job({ lastRunAt: '2026-01-03T00:00:00.000Z' }), + ) + expect(imported.createdAt).toBe(Date.parse('2026-01-02T03:04:05.000Z')) + expect(imported.lastRunAt).toBe(Date.parse('2026-01-03T00:00:00.000Z')) + }) + + // NaN would fail validation and take the whole batch down with it, so the + // job lands with the server's own timestamp instead. + it('drops an unparseable timestamp rather than sending NaN', () => { + const imported = toScheduledJobImport(job({ createdAt: 'whenever' })) + expect(imported.createdAt).toBeUndefined() + expect(imported.name).toBe('Morning digest') + }) + + it('leaves an absent lastRunAt absent', () => { + expect(toScheduledJobImport(job()).lastRunAt).toBeUndefined() + }) +}) + +describe('isImportableProvider', () => { + it('accepts a well formed provider', () => { + expect(isImportableProvider(provider())).toBe(true) + }) + + // A single entry the server rejects returns 400 for the whole batch, and + // because the pref backup has no migration path that failure would repeat on + // every startup with nothing the user could do about it. + it.each([ + ['no id', { id: '' }], + ['no type', { type: '' }], + ['no name', { name: '' }], + ['no model', { modelId: '' }], + ])('rejects a provider with %s', (_label, overrides) => { + expect(isImportableProvider(provider(overrides as never))).toBe(false) + }) + + it('rejects a provider whose context window is not a number', () => { + expect( + isImportableProvider({ ...provider(), contextWindow: '200000' }), + ).toBe(false) + expect(isImportableProvider({ ...provider(), contextWindow: NaN })).toBe( + false, + ) + }) + + // Storage migrations drop these; the pref backup never gets that treatment. + it('rejects provider types that no longer exist', () => { + for (const type of [ + 'remote-hermes', + 'claude-code', + 'codex', + 'acp-custom', + ]) { + expect(isImportableProvider(provider({ type } as never))).toBe(false) + } + }) + + it('rejects values that are not objects', () => { + expect(isImportableProvider(null)).toBe(false) + expect(isImportableProvider('provider')).toBe(false) + }) +}) + +describe('isImportableJob', () => { + it('accepts a well formed job', () => { + expect(isImportableJob(job())).toBe(true) + }) + + it('rejects a job missing the fields the server requires', () => { + expect(isImportableJob(job({ name: '' }))).toBe(false) + expect(isImportableJob(job({ query: '' }))).toBe(false) + }) + + it('rejects an unrecognised schedule type', () => { + expect(isImportableJob(job({ scheduleType: 'weekly' } as never))).toBe( + false, + ) + }) +}) + +describe('optional field sanitising', () => { + // The provider is valid where it matters, so it should still import; the + // junk field is dropped and the server applies its own default. + it('drops an optional field holding the wrong type', () => { + const imported = toProviderImport({ + ...provider(), + temperature: 'warm', + supportsImages: 'yes', + baseUrl: 42, + } as never) + + expect(imported.temperature).toBeUndefined() + expect(imported.supportsImages).toBeUndefined() + expect(imported.baseUrl).toBeUndefined() + expect(imported.modelId).toBe('gpt-5.5') + }) + + it('keeps optional fields that are the right type', () => { + const imported = toProviderImport( + provider({ baseUrl: 'https://api.openai.com/v1' }), + ) + expect(imported.baseUrl).toBe('https://api.openai.com/v1') + expect(imported.temperature).toBe(0.2) + expect(imported.supportsImages).toBe(true) + }) + + it('drops a job field holding the wrong type', () => { + const imported = toScheduledJobImport({ + ...job(), + scheduleInterval: 'hourly', + enabled: 'true', + } as never) + + expect(imported.scheduleInterval).toBeUndefined() + expect(imported.enabled).toBeUndefined() + expect(imported.name).toBe('Morning digest') + }) +}) diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.ts new file mode 100644 index 0000000000..02d24a2b47 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.helpers.ts @@ -0,0 +1,199 @@ +import { REMOVED_PROVIDER_TYPES } from '@/lib/llm-providers/removed-provider-types' +import type { LlmProviderConfig } from '@/lib/llm-providers/types' +import type { ScheduledJob } from '@/lib/schedules/scheduleTypes' + +/** Payload for `POST /providers/import`. */ +export interface ProviderImport { + id: string + type: string + name: string + baseUrl?: string + modelId: string + supportsImages?: boolean + contextWindow: number + temperature?: number + apiKey?: string + accessKeyId?: string + secretAccessKey?: string + sessionToken?: string + resourceName?: string + region?: string + reasoningEffort?: string + reasoningSummary?: string + createdAt?: number +} + +/** Payload for `POST /scheduled-jobs/import`. */ +export interface ScheduledJobImport { + id: string + name: string + query: string + scheduleType: ScheduledJob['scheduleType'] + scheduleTime?: string + scheduleInterval?: number + enabled?: boolean + providerId?: string + lastRunAt?: number + createdAt?: number +} + +/** + * Reads the provider list out of the `browseros.providers` pref backup. + * + * The pref holds a JSON string of `LlmProvidersBackup`. It is a fallback + * source, so anything unparseable yields nothing rather than throwing: a + * corrupt backup must not stop the extension-storage providers from importing. + */ +export function parseProviderBackup(raw: unknown): LlmProviderConfig[] { + if (typeof raw !== 'string' || raw.length === 0) return [] + try { + const parsed: unknown = JSON.parse(raw) + if (typeof parsed !== 'object' || parsed === null) return [] + const providers = (parsed as { providers?: unknown }).providers + if (!Array.isArray(providers)) return [] + return providers.filter( + (provider): provider is LlmProviderConfig => + typeof provider === 'object' && + provider !== null && + typeof (provider as LlmProviderConfig).id === 'string', + ) + } catch { + return [] + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function optionalString(value: unknown): string | undefined { + return isNonEmptyString(value) ? value : undefined +} + +function optionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function optionalBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} + +/** + * Whether a provider can be sent to the import endpoint. + * + * The required fields are exactly the ones the server requires, because a + * single entry it rejects returns 400 for the whole batch. That would be + * permanent rather than transient: the pref backup has no migration path, so + * the same bad entry would fail the import, block the scheduled jobs behind + * it, and leave the done marker unset to retry forever. + * + * Removed types are excluded for a related reason. Storage migrations drop + * them, the pref backup never gets that treatment, and importing one would + * put a provider of a type the app no longer supports into the database. + */ +export function isImportableProvider( + value: unknown, +): value is LlmProviderConfig { + if (typeof value !== 'object' || value === null) return false + const provider = value as Partial + return ( + isNonEmptyString(provider.id) && + isNonEmptyString(provider.type) && + !REMOVED_PROVIDER_TYPES.has(provider.type) && + isNonEmptyString(provider.name) && + isNonEmptyString(provider.modelId) && + optionalNumber(provider.contextWindow) !== undefined + ) +} + +/** Same contract as `isImportableProvider`, for the scheduled jobs batch. */ +export function isImportableJob(value: unknown): value is ScheduledJob { + if (typeof value !== 'object' || value === null) return false + const job = value as Partial + return ( + isNonEmptyString(job.id) && + isNonEmptyString(job.name) && + isNonEmptyString(job.query) && + (job.scheduleType === 'daily' || + job.scheduleType === 'hourly' || + job.scheduleType === 'minutes') + ) +} + +/** + * Unions the two local provider sources, extension storage winning on id. + * + * Extension storage is what the app writes on every save, so it is the current + * copy. The pref backup only contributes providers missing from it, which is + * the reinstall case: extension storage was cleared and the per-profile pref + * outlived it. + */ +export function mergeProviderSources( + stored: readonly LlmProviderConfig[], + backup: readonly LlmProviderConfig[], +): LlmProviderConfig[] { + const merged = [...stored] + const seen = new Set(stored.map((provider) => provider.id)) + for (const provider of backup) { + if (seen.has(provider.id)) continue + seen.add(provider.id) + merged.push(provider) + } + return merged +} + +/** + * Optional fields pass through a type check rather than straight across, so a + * provider that is well formed where it matters still imports when one of its + * optional fields holds junk. Dropping the field lets the server apply its own + * default; sending the wrong type would fail the whole batch. + */ +export function toProviderImport(config: LlmProviderConfig): ProviderImport { + return { + id: config.id, + type: config.type, + name: config.name, + baseUrl: optionalString(config.baseUrl), + modelId: config.modelId, + supportsImages: optionalBoolean(config.supportsImages), + contextWindow: config.contextWindow, + temperature: optionalNumber(config.temperature), + apiKey: optionalString(config.apiKey), + accessKeyId: optionalString(config.accessKeyId), + secretAccessKey: optionalString(config.secretAccessKey), + sessionToken: optionalString(config.sessionToken), + resourceName: optionalString(config.resourceName), + region: optionalString(config.region), + reasoningEffort: optionalString(config.reasoningEffort), + reasoningSummary: optionalString(config.reasoningSummary), + createdAt: optionalNumber(config.createdAt), + } +} + +/** + * Jobs hold ISO strings here and epoch numbers in the database. + * + * An unparseable timestamp is dropped rather than sent as NaN, which would + * fail validation and take the whole batch with it. The server then stamps its + * own `createdAt`, so the job still lands. + */ +function toEpoch(value: unknown): number | undefined { + if (!isNonEmptyString(value)) return undefined + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? undefined : parsed +} + +export function toScheduledJobImport(job: ScheduledJob): ScheduledJobImport { + return { + id: job.id, + name: job.name, + query: job.query, + scheduleType: job.scheduleType, + scheduleTime: optionalString(job.scheduleTime), + scheduleInterval: optionalNumber(job.scheduleInterval), + enabled: optionalBoolean(job.enabled), + providerId: optionalString(job.providerId), + lastRunAt: toEpoch(job.lastRunAt), + createdAt: toEpoch(job.createdAt), + } +} diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.test.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.test.ts new file mode 100644 index 0000000000..9bcf785c80 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'bun:test' +import type { LlmProviderConfig } from '@/lib/llm-providers/types' +import type { + ScheduledJob, + ScheduledJobRun, +} from '@/lib/schedules/scheduleTypes' +import { + type LocalFirstMigrationDeps, + type RunsMigrationDeps, + runLocalFirstMigration, + runScheduledRunsMigration, +} from './local-first-migration' +import type { + ProviderImport, + ScheduledJobImport, +} from './local-first-migration.helpers' + +function provider( + overrides: Partial = {}, +): LlmProviderConfig { + return { + id: 'provider-1', + type: 'openai', + name: 'My OpenAI', + modelId: 'gpt-5.5', + supportsImages: true, + contextWindow: 200000, + temperature: 0.2, + apiKey: 'sk-test', + createdAt: 10, + updatedAt: 20, + ...overrides, + } +} + +function job(overrides: Partial = {}): ScheduledJob { + return { + id: 'job-1', + name: 'Morning digest', + query: 'summarise my inbox', + scheduleType: 'daily', + enabled: true, + createdAt: '2026-01-02T03:04:05.000Z', + updatedAt: '2026-01-02T03:04:05.000Z', + ...overrides, + } +} + +interface Harness { + deps: LocalFirstMigrationDeps + done: () => boolean + importedProviders: ProviderImport[][] + importedJobs: ScheduledJobImport[][] +} + +function harness(overrides: Partial = {}): Harness { + let done = false + const importedProviders: ProviderImport[][] = [] + const importedJobs: ScheduledJobImport[][] = [] + + return { + done: () => done, + importedProviders, + importedJobs, + deps: { + isDone: async () => done, + markDone: async () => { + done = true + }, + loadStoredProviders: async () => [], + loadBackupProviders: async () => [], + loadScheduledJobs: async () => [], + importProviders: async (providers) => { + importedProviders.push(providers) + }, + importScheduledJobs: async (jobs) => { + importedJobs.push(jobs) + }, + ...overrides, + }, + } +} + +describe('runLocalFirstMigration', () => { + it('imports providers and jobs, then records that it ran', async () => { + const h = harness({ + loadStoredProviders: async () => [provider()], + loadScheduledJobs: async () => [job()], + }) + + const result = await runLocalFirstMigration(h.deps) + + expect(result).toEqual({ + ranMigration: true, + providerCount: 1, + jobCount: 1, + }) + expect(h.importedProviders[0][0]).toMatchObject({ + id: 'provider-1', + apiKey: 'sk-test', + }) + expect(h.importedJobs[0][0]).toMatchObject({ id: 'job-1' }) + expect(h.done()).toBe(true) + }) + + // The whole point of the marker: providers the user has since deleted must + // not come back on the next startup. + it('does nothing once it has already run', async () => { + const h = harness({ + isDone: async () => true, + loadStoredProviders: async () => [provider()], + }) + + const result = await runLocalFirstMigration(h.deps) + + expect(result.ranMigration).toBe(false) + expect(h.importedProviders).toHaveLength(0) + }) + + it('unions the pref backup with extension storage', async () => { + const h = harness({ + loadStoredProviders: async () => [provider()], + loadBackupProviders: async () => [provider({ id: 'from-backup' })], + }) + + await runLocalFirstMigration(h.deps) + + expect(h.importedProviders[0].map((p) => p.id)).toEqual([ + 'provider-1', + 'from-backup', + ]) + }) + + it('marks itself done with nothing to import so it stops retrying', async () => { + const h = harness() + + const result = await runLocalFirstMigration(h.deps) + + expect(result).toEqual({ + ranMigration: true, + providerCount: 0, + jobCount: 0, + }) + expect(h.importedProviders).toHaveLength(0) + expect(h.importedJobs).toHaveLength(0) + expect(h.done()).toBe(true) + }) + + // The whole batch is one request, so an entry the server rejects would take + // the valid providers down with it, block the jobs queued behind it, and + // leave the marker unset to fail again on every startup. + it('drops an unusable backup entry instead of failing the batch', async () => { + const h = harness({ + loadStoredProviders: async () => [provider()], + loadBackupProviders: async () => + [ + { id: 'stale', name: 'half a provider' }, + provider({ id: 'removed-type', type: 'remote-hermes' as never }), + ] as never, + loadScheduledJobs: async () => [job()], + }) + + const result = await runLocalFirstMigration(h.deps) + + expect(h.importedProviders[0].map((p) => p.id)).toEqual(['provider-1']) + expect(h.importedJobs).toHaveLength(1) + expect(result.ranMigration).toBe(true) + expect(h.done()).toBe(true) + }) + + it('drops a job the server would reject without losing the rest', async () => { + const h = harness({ + loadScheduledJobs: async () => + [job(), { id: 'broken', name: '', query: '' }] as never, + }) + + await runLocalFirstMigration(h.deps) + + expect(h.importedJobs[0].map((j) => j.id)).toEqual(['job-1']) + expect(h.done()).toBe(true) + }) + + // Filtering runs before the merge, so an unusable stored entry cannot win + // the id and take a perfectly good backup copy down with it. + it('falls back to the backup copy when the stored one is unusable', async () => { + const h = harness({ + loadStoredProviders: async () => [{ id: 'provider-1' }] as never, + loadBackupProviders: async () => [provider({ name: 'From backup' })], + }) + + await runLocalFirstMigration(h.deps) + + expect(h.importedProviders[0]).toHaveLength(1) + expect(h.importedProviders[0][0].name).toBe('From backup') + }) + + // A failed run must retry on the next startup, which is only safe because + // the server inserts what is absent rather than replacing. + it('leaves itself unmarked when the import fails', async () => { + const h = harness({ + loadStoredProviders: async () => [provider()], + importProviders: async () => { + throw new Error('server not up') + }, + }) + + await expect(runLocalFirstMigration(h.deps)).rejects.toThrow( + 'server not up', + ) + expect(h.done()).toBe(false) + }) + + it('does not mark itself done when the jobs import fails after providers landed', async () => { + const h = harness({ + loadStoredProviders: async () => [provider()], + loadScheduledJobs: async () => [job()], + importScheduledJobs: async () => { + throw new Error('server not up') + }, + }) + + await expect(runLocalFirstMigration(h.deps)).rejects.toThrow( + 'server not up', + ) + expect(h.importedProviders).toHaveLength(1) + expect(h.done()).toBe(false) + }) +}) + +function runsHarness(overrides: Partial = {}) { + let done = false + const imported: ScheduledJobRun[][] = [] + return { + done: () => done, + imported, + deps: { + isDone: async () => done, + markDone: async () => { + done = true + }, + loadRuns: async () => [], + importRuns: async (runs: ScheduledJobRun[]) => { + imported.push(runs) + }, + ...overrides, + } as RunsMigrationDeps, + } +} + +function jobRun(overrides: Partial = {}): ScheduledJobRun { + return { + id: 'run-1', + jobId: 'job-1', + status: 'completed', + startedAt: '2026-01-02T03:04:05.000Z', + ...overrides, + } +} + +describe('runScheduledRunsMigration', () => { + it('imports run history and records that it ran', async () => { + const h = runsHarness({ loadRuns: async () => [jobRun()] }) + + const result = await runScheduledRunsMigration(h.deps) + + expect(result).toEqual({ ranMigration: true, runCount: 1 }) + expect(h.imported[0][0].id).toBe('run-1') + expect(h.done()).toBe(true) + }) + + it('does nothing once it has already run', async () => { + const h = runsHarness({ + isDone: async () => true, + loadRuns: async () => [jobRun()], + }) + + expect((await runScheduledRunsMigration(h.deps)).ranMigration).toBe(false) + expect(h.imported).toHaveLength(0) + }) + + it('marks itself done with nothing to import so it stops retrying', async () => { + const h = runsHarness() + + expect(await runScheduledRunsMigration(h.deps)).toEqual({ + ranMigration: true, + runCount: 0, + }) + expect(h.imported).toHaveLength(0) + expect(h.done()).toBe(true) + }) + + it('leaves itself unmarked when the import fails', async () => { + const h = runsHarness({ + loadRuns: async () => [jobRun()], + importRuns: async () => { + throw new Error('server not up') + }, + }) + + await expect(runScheduledRunsMigration(h.deps)).rejects.toThrow( + 'server not up', + ) + expect(h.done()).toBe(false) + }) +}) diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.ts new file mode 100644 index 0000000000..296d4f2331 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/local-first-migration/local-first-migration.ts @@ -0,0 +1,139 @@ +import type { LlmProviderConfig } from '@/lib/llm-providers/types' +import type { + ScheduledJob, + ScheduledJobRun, +} from '@/lib/schedules/scheduleTypes' +import type { + ProviderImport, + ScheduledJobImport, +} from './local-first-migration.helpers' +import { + isImportableJob, + isImportableProvider, + mergeProviderSources, + toProviderImport, + toScheduledJobImport, +} from './local-first-migration.helpers' + +export interface LocalFirstMigrationDeps { + isDone: () => Promise + markDone: () => Promise + loadStoredProviders: () => Promise + loadBackupProviders: () => Promise + loadScheduledJobs: () => Promise + importProviders: (providers: ProviderImport[]) => Promise + importScheduledJobs: (jobs: ScheduledJobImport[]) => Promise +} + +export interface LocalFirstMigrationResult { + ranMigration: boolean + providerCount: number + jobCount: number +} + +const SKIPPED: LocalFirstMigrationResult = { + ranMigration: false, + providerCount: 0, + jobCount: 0, +} + +/** + * Moves providers and scheduled jobs from extension storage into the server + * database, once. + * + * Only local sources are read. The cloud is deliberately not one: its + * scheduled jobs include every job deleted since the deletion queue lost its + * only reader, and its providers never carried credentials, so they are + * already handled by the incomplete-provider prompt in AI settings. + * + * The done marker is set only after both imports land. A failed run leaves it + * unset and retries on the next startup, which is safe because the server side + * inserts only what is absent. + */ +export async function runLocalFirstMigration( + deps: LocalFirstMigrationDeps, +): Promise { + if (await deps.isDone()) return SKIPPED + + const [stored, backup, jobs] = await Promise.all([ + deps.loadStoredProviders(), + deps.loadBackupProviders(), + deps.loadScheduledJobs(), + ]) + + // Filtering happens before the merge, not after: an unusable stored entry + // would otherwise win the id and then be dropped, losing a provider whose + // backup copy was perfectly good. + const providers = mergeProviderSources( + stored.filter(isImportableProvider), + backup.filter(isImportableProvider), + ).map(toProviderImport) + const scheduledJobs = jobs.filter(isImportableJob).map(toScheduledJobImport) + + if (providers.length > 0) await deps.importProviders(providers) + if (scheduledJobs.length > 0) await deps.importScheduledJobs(scheduledJobs) + + await deps.markDone() + + return { + ranMigration: true, + providerCount: providers.length, + jobCount: scheduledJobs.length, + } +} + +export interface RunsMigrationDeps { + isDone: () => Promise + markDone: () => Promise + loadRuns: () => Promise + importRuns: (runs: ScheduledJobRun[]) => Promise +} + +/** + * Moves scheduled run history from extension storage into the server, once. + * + * Separate from the provider and job import, and with its own marker, because + * that one must never run a second time. Extension storage is no longer + * written, so its provider list is frozen at whatever it held when it stopped; + * re-importing it would insert back a provider the user has since deleted, + * because absent is exactly what a deliberate delete looks like. + */ +export async function runScheduledRunsMigration( + deps: RunsMigrationDeps, +): Promise<{ ranMigration: boolean; runCount: number }> { + if (await deps.isDone()) return { ranMigration: false, runCount: 0 } + + const runs = await deps.loadRuns() + if (runs.length > 0) await deps.importRuns(runs) + await deps.markDone() + + return { ranMigration: true, runCount: runs.length } +} + +export interface DefaultProviderMigrationDeps { + isDone: () => Promise + markDone: () => Promise + loadStoredDefaultId: () => Promise + setDefault: (providerId: string) => Promise +} + +/** + * Moves the selected provider from extension storage to the server, once. + * + * Its own marker, like the run history, because the provider and job import + * must never run twice and this cannot ride along with it. Skipping when the + * stored id is missing matters as much as writing when it is present: a + * default that no longer names anything would otherwise be pushed over one the + * user has since chosen on another surface. + */ +export async function runDefaultProviderMigration( + deps: DefaultProviderMigrationDeps, +): Promise<{ ranMigration: boolean; providerId: string | null }> { + if (await deps.isDone()) return { ranMigration: false, providerId: null } + + const storedId = await deps.loadStoredDefaultId() + if (storedId) await deps.setDefault(storedId) + await deps.markDone() + + return { ranMigration: true, providerId: storedId } +} diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/start-local-first-migration.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/start-local-first-migration.ts new file mode 100644 index 0000000000..7d7d4cff54 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/local-first-migration/start-local-first-migration.ts @@ -0,0 +1,161 @@ +import type { ProviderRoutes, ScheduledJobRoutes } from '@browseros/server' +import { storage } from '@wxt-dev/storage' +import { hc } from 'hono/client' +import { getBrowserOSAdapter } from '@/lib/browseros/adapter' +import { BROWSEROS_PREFS } from '@/lib/browseros/prefs' +import { + defaultProviderIdStorage, + providersStorage, +} from '@/lib/llm-providers/storage' +import type { LlmProviderConfig } from '@/lib/llm-providers/types' +import { + scheduledJobRunStorage, + scheduledJobStorage, +} from '@/lib/schedules/scheduleStorage' +import { sentry } from '@/lib/sentry/sentry' +import { resolveAgentServerUrlWithRetry } from '@/modules/browseros/agent-server-url.helpers' +import { putDefaultProvider } from '@/modules/llm-providers/llm-providers.api' +import { importScheduledJobRuns } from '@/modules/schedules/schedules.api' +import { + runDefaultProviderMigration, + runLocalFirstMigration, + runScheduledRunsMigration, +} from './local-first-migration' +import { + type ProviderImport, + parseProviderBackup, + type ScheduledJobImport, +} from './local-first-migration.helpers' +import { waitForAgentServer } from './wait-for-agent-server' + +/** + * Per profile, because extension storage is per profile. Losing it costs a + * redundant import that inserts nothing, never a lost or overwritten row, + * which is what insert-if-absent on the server buys. + */ +export const migrationDoneStorage = storage.defineItem( + 'local:local-first-migration-done', + { fallback: false }, +) + +/** + * Runs carry their own marker rather than reusing the one above. + * + * Reusing it would mean re-running the provider and job import for everyone + * who has already migrated, and that import must never run twice: extension + * storage is frozen now, so it would insert back anything the user has since + * deleted through the new UI. + */ +export const runsMigrationDoneStorage = storage.defineItem( + 'local:local-first-runs-migration-done', + { fallback: false }, +) + +/** Its own marker too, for the reason on the one above. */ +export const defaultMigrationDoneStorage = storage.defineItem( + 'local:local-first-default-migration-done', + { fallback: false }, +) + +async function loadBackupProviders(): Promise { + try { + const pref = await getBrowserOSAdapter().getPref(BROWSEROS_PREFS.PROVIDERS) + return parseProviderBackup(pref?.value) + } catch { + // No BrowserOS API, or no backup written yet. Extension storage still runs. + return [] + } +} + +async function importProviders(providers: ProviderImport[]): Promise { + const baseUrl = await resolveAgentServerUrlWithRetry() + const client = hc(`${baseUrl}/providers`) + const response = await client.import.$post({ json: { providers } }) + if (!response.ok) { + throw new Error(`Failed to import providers (${response.status})`) + } +} + +async function importScheduledJobs(jobs: ScheduledJobImport[]): Promise { + const baseUrl = await resolveAgentServerUrlWithRetry() + const client = hc(`${baseUrl}/scheduled-jobs`) + const response = await client.import.$post({ json: { jobs } }) + if (!response.ok) { + throw new Error(`Failed to import scheduled jobs (${response.status})`) + } +} + +/** + * Runs the one-time imports once the server is up. + * + * Everything here happens when the background starts, which is also when the + * server starts, so firing straight away meant importing into a socket nothing + * was listening on. Each import then failed, and because a failed run leaves + * its marker unset it lost the same race on the next launch too, so a user + * upgrading saw their providers and scheduled tasks simply never arrive while + * the database migration looked like it had worked. + * + * Waiting on health first removes the race. A failure past that point is worth + * seeing rather than swallowing: it is the difference between a slow start and + * data that never came across. + */ +export function startLocalFirstMigration(): void { + void (async () => { + if (!(await waitForAgentServer())) { + sentry.captureException( + new Error('Agent server unreachable before the local-first import'), + { + extra: { + message: + 'Imports deferred to the next start; markers remain unset so they will run again', + }, + }, + ) + return + } + + // The default is chained onto the provider import rather than run + // alongside it: the id it names has to exist server side before it can be + // made default. The run history is independent and does not wait. + try { + await runLocalFirstMigration({ + isDone: () => migrationDoneStorage.getValue(), + markDone: () => migrationDoneStorage.setValue(true), + loadStoredProviders: async () => + (await providersStorage.getValue()) ?? [], + loadBackupProviders, + loadScheduledJobs: async () => + (await scheduledJobStorage.getValue()) ?? [], + importProviders, + importScheduledJobs, + }) + await runDefaultProviderMigration({ + isDone: () => defaultMigrationDoneStorage.getValue(), + markDone: () => defaultMigrationDoneStorage.setValue(true), + loadStoredDefaultId: async () => + (await defaultProviderIdStorage.getValue()) || null, + setDefault: putDefaultProvider, + }) + } catch (error) { + // Reported rather than swallowed: a silent failure here is + // indistinguishable from the user's data having vanished, which is + // exactly how this went unnoticed. + sentry.captureException(error, { + extra: { message: 'Provider and scheduled job import failed' }, + }) + } + + try { + await runScheduledRunsMigration({ + isDone: () => runsMigrationDoneStorage.getValue(), + markDone: () => runsMigrationDoneStorage.setValue(true), + loadRuns: async () => (await scheduledJobRunStorage.getValue()) ?? [], + importRuns: importScheduledJobRuns, + }) + } catch (error) { + sentry.captureException(error, { + extra: { message: 'Scheduled run history import failed' }, + }) + } + })() +} diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.test.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.test.ts new file mode 100644 index 0000000000..ad00a5d519 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, mock } from 'bun:test' + +// The agent server and the mcp proxy listen on different ports and can become +// ready at different moments, so the probe has to ask the one the imports +// actually address. +mock.module('@/lib/browseros/helpers', () => ({ + getAgentServerUrl: async () => 'http://127.0.0.1:9105', + getProxyPort: async () => 9106, + getMcpPort: async () => 9105, + getHealthCheckUrl: async () => 'http://127.0.0.1:9106/system/health', + getMcpServerUrl: async () => 'http://127.0.0.1:9106/mcp', +})) + +const { waitForAgentServer, agentServerHealthUrl } = await import( + './wait-for-agent-server' +) + +function harness(healthyAfter: number) { + let calls = 0 + let clock = 0 + return { + calls: () => calls, + elapsed: () => clock, + opts: { + isHealthy: async () => { + calls += 1 + return calls > healthyAfter + }, + now: () => clock, + sleep: async (ms: number) => { + clock += ms + }, + timeoutMs: 60_000, + intervalMs: 1_000, + }, + } +} + +describe('waitForAgentServer', () => { + it('returns immediately when the server is already up', async () => { + const h = harness(0) + + expect(await waitForAgentServer(h.opts)).toBe(true) + expect(h.calls()).toBe(1) + expect(h.elapsed()).toBe(0) + }) + + // The case this exists for: the background starts at the same moment as the + // server, so the first few probes find nothing listening. + it('waits out a server that is still starting', async () => { + const h = harness(6) + + expect(await waitForAgentServer(h.opts)).toBe(true) + expect(h.calls()).toBe(7) + expect(h.elapsed()).toBe(6_000) + }) + + // Six seconds is roughly what was observed between the browser launching and + // the server answering, and the previous behaviour gave up inside two. + it('outlasts the gap that made the import fail', async () => { + const h = harness(6) + await waitForAgentServer(h.opts) + + expect(h.elapsed()).toBeGreaterThan(1_500) + }) + + // Giving up rather than throwing is what lets the caller leave the markers + // unset, so the next start tries again. + it('reports failure rather than throwing when the server never answers', async () => { + const h = harness(Number.POSITIVE_INFINITY) + + expect(await waitForAgentServer(h.opts)).toBe(false) + }) + + it('stops probing once the deadline passes', async () => { + const h = harness(Number.POSITIVE_INFINITY) + await waitForAgentServer(h.opts) + + expect(h.elapsed()).toBeLessThanOrEqual(60_000) + expect(h.calls()).toBeLessThanOrEqual(62) + }) + + // Connection refused is the expected state early on, so a probe that throws + // has to read as not reachable rather than end the wait. + it('treats a throwing probe as not reachable and keeps waiting', async () => { + let calls = 0 + let clock = 0 + + const result = await waitForAgentServer({ + isHealthy: async () => { + calls += 1 + if (calls < 3) throw new Error('connection refused') + return true + }, + now: () => clock, + sleep: async (ms) => { + clock += ms + }, + timeoutMs: 60_000, + intervalMs: 1_000, + }) + + expect(result).toBe(true) + expect(calls).toBe(3) + expect(clock).toBe(2_000) + }) + + // Probing the proxy would answer the wrong question: it can be up while the + // agent server is still starting, which leaves the original race open. + it('probes the agent server, not the proxy', async () => { + expect(await agentServerHealthUrl()).toBe( + 'http://127.0.0.1:9105/system/health', + ) + }) +}) diff --git a/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.ts b/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.ts new file mode 100644 index 0000000000..08ee33653b --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/local-first-migration/wait-for-agent-server.ts @@ -0,0 +1,81 @@ +import { getAgentServerUrl } from '@/lib/browseros/helpers' + +/** + * Long enough to cover a cold start where the server is booting alongside the + * browser and has migrations of its own to apply, short enough that a genuinely + * absent server does not keep a background task alive all session. + */ +export const SERVER_WAIT_TIMEOUT_MS = 60_000 +export const SERVER_WAIT_INTERVAL_MS = 1_000 + +export interface WaitForAgentServerOptions { + isHealthy?: () => Promise + timeoutMs?: number + intervalMs?: number + now?: () => number + sleep?: (ms: number) => Promise +} + +/** + * Health on the agent server itself, not `getHealthCheckUrl`. + * + * That helper resolves the proxy port, while the imports address the agent + * server on the mcp port. They are separate services that can become ready at + * different moments, so probing the proxy would answer a question nobody + * asked: a proxy up first would wave the imports through into the same race + * this exists to close, and a proxy that is down would defer imports the agent + * server was ready to accept. + */ +export async function agentServerHealthUrl(): Promise { + return `${await getAgentServerUrl()}/system/health` +} + +async function defaultIsHealthy(): Promise { + try { + const response = await fetch(await agentServerHealthUrl()) + return response.ok + } catch { + return false + } +} + +const defaultSleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Waits until the local server answers, or gives up. + * + * The one-time import runs when the background starts, which is the same moment + * the server starts. It used to fire straight into a socket nothing was + * listening on yet and fail, and because a failed run leaves its marker unset + * it simply lost the same race on the next launch, so the imported data never + * appeared at all. + * + * Polling health first is what makes the import wait for its dependency rather + * than race it. Returning false rather than throwing keeps the caller's + * decision explicit: leave the markers unset and try again next start. + */ +export async function waitForAgentServer({ + isHealthy = defaultIsHealthy, + timeoutMs = SERVER_WAIT_TIMEOUT_MS, + intervalMs = SERVER_WAIT_INTERVAL_MS, + now = Date.now, + sleep = defaultSleep, +}: WaitForAgentServerOptions = {}): Promise { + const deadline = now() + timeoutMs + + while (true) { + // A probe that throws means not reachable, not a reason to abandon the + // wait. The default one already swallows fetch errors; catching here means + // any probe behaves the same way. + let healthy = false + try { + healthy = await isHealthy() + } catch { + healthy = false + } + if (healthy) return true + if (now() >= deadline) return false + await sleep(intervalMs) + } +} diff --git a/packages/browseros-agent/apps/app/modules/schedules/schedules.api.ts b/packages/browseros-agent/apps/app/modules/schedules/schedules.api.ts new file mode 100644 index 0000000000..f93766a134 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/schedules/schedules.api.ts @@ -0,0 +1,126 @@ +import type { + ScheduledJobRoutes, + ScheduledJobRunRoutes, +} from '@browseros/server' +import { hc } from 'hono/client' +import type { + ScheduledJob, + ScheduledJobRun, +} from '@/lib/schedules/scheduleTypes' +import { resolveAgentServerUrlWithRetry } from '@/modules/browseros/agent-server-url.helpers' +import { + type ScheduledJobRow, + type ScheduledJobRunRow, + toScheduledJob, + toScheduledJobPayload, + toScheduledJobRun, + toScheduledJobRunPayload, +} from './schedules.helpers' +import { bumpScheduleRevision } from './schedules.revision' + +async function jobsClient() { + const baseUrl = await resolveAgentServerUrlWithRetry() + return hc(`${baseUrl}/scheduled-jobs`) +} + +async function runsClient() { + const baseUrl = await resolveAgentServerUrlWithRetry() + return hc(`${baseUrl}/scheduled-job-runs`) +} + +export async function listScheduledJobs(): Promise { + const client = await jobsClient() + const response = await client.index.$get() + if (!response.ok) { + throw new Error(`Failed to load scheduled jobs (${response.status})`) + } + const { jobs } = await response.json() + return (jobs as ScheduledJobRow[]).map(toScheduledJob) +} + +export async function putScheduledJob(job: ScheduledJob): Promise { + const client = await jobsClient() + const response = await client[':jobId'].$put({ + param: { jobId: job.id }, + json: toScheduledJobPayload(job), + }) + if (!response.ok) { + throw new Error(`Failed to save scheduled job (${response.status})`) + } + await bumpScheduleRevision() +} + +export async function deleteScheduledJob(jobId: string): Promise { + const client = await jobsClient() + const response = await client[':jobId'].$delete({ param: { jobId } }) + if (!response.ok && response.status !== 404) { + throw new Error(`Failed to delete scheduled job (${response.status})`) + } + await bumpScheduleRevision() +} + +export async function listScheduledJobRuns(): Promise { + const client = await runsClient() + const response = await client.index.$get() + if (!response.ok) { + throw new Error(`Failed to load run history (${response.status})`) + } + const { runs } = await response.json() + return (runs as ScheduledJobRunRow[]).map(toScheduledJobRun) +} + +export async function putScheduledJobRun(run: ScheduledJobRun): Promise { + const client = await runsClient() + const response = await client[':runId'].$put({ + param: { runId: run.id }, + json: toScheduledJobRunPayload(run), + }) + if (!response.ok) { + throw new Error(`Failed to save run (${response.status})`) + } + await bumpScheduleRevision() +} + +/** + * Jobs for callers outside React, returning null when the server could not be + * reached. The alarm runner uses this to tell "no jobs are due" apart from + * "the list did not load", which otherwise look identical and would silently + * skip every scheduled task. + */ +export async function listScheduledJobsOrNull(): Promise< + ScheduledJob[] | null +> { + try { + return await listScheduledJobs() + } catch { + return null + } +} + +export async function listScheduledJobRunsOrNull(): Promise< + ScheduledJobRun[] | null +> { + try { + return await listScheduledJobRuns() + } catch { + return null + } +} + +/** One-time import of run history from extension storage. */ +export async function importScheduledJobRuns( + runs: ScheduledJobRun[], +): Promise { + const client = await runsClient() + const response = await client.import.$post({ + json: { + runs: runs.map((run) => ({ + ...toScheduledJobRunPayload(run), + id: run.id, + })), + }, + }) + if (!response.ok) { + throw new Error(`Failed to import run history (${response.status})`) + } +} diff --git a/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.test.ts b/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.test.ts new file mode 100644 index 0000000000..0e066e3f13 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'bun:test' +import type { + ScheduledJob, + ScheduledJobRun, +} from '@/lib/schedules/scheduleTypes' +import { + applyLastRunAt, + type ScheduledJobRow, + type ScheduledJobRunRow, + toScheduledJob, + toScheduledJobPayload, + toScheduledJobRun, + toScheduledJobRunPayload, +} from './schedules.helpers' + +const ISO = '2026-01-02T03:04:05.000Z' +const EPOCH = Date.parse(ISO) + +function jobRow(overrides: Partial = {}): ScheduledJobRow { + return { + id: 'job-1', + name: 'Morning digest', + query: 'summarise my inbox', + scheduleType: 'daily', + scheduleTime: '09:00', + scheduleInterval: null, + enabled: true, + providerId: null, + lastRunAt: null, + createdAt: EPOCH, + updatedAt: EPOCH, + ...overrides, + } +} + +function runRow( + overrides: Partial = {}, +): ScheduledJobRunRow { + return { + id: 'run-1', + jobId: 'job-1', + status: 'completed', + startedAt: EPOCH, + completedAt: null, + result: null, + finalResult: null, + executionLog: null, + toolCalls: null, + error: null, + ...overrides, + } +} + +describe('toScheduledJob', () => { + // The database holds epoch integers; the extension has always held ISO + // strings and every consumer parses them that way. + it('converts epoch times back to ISO strings', () => { + const job = toScheduledJob(jobRow({ lastRunAt: EPOCH })) + + expect(job.createdAt).toBe(ISO) + expect(job.lastRunAt).toBe(ISO) + }) + + it('turns absent columns into undefined rather than null', () => { + const job = toScheduledJob(jobRow()) + + expect(job.lastRunAt).toBeUndefined() + expect(job.providerId).toBeUndefined() + expect(job.scheduleInterval).toBeUndefined() + }) +}) + +describe('toScheduledJobPayload', () => { + it('leaves the id out of the body', () => { + const job = toScheduledJob(jobRow()) + expect('id' in toScheduledJobPayload(job)).toBe(false) + }) + + it('converts ISO times to epoch', () => { + const job = toScheduledJob(jobRow({ lastRunAt: EPOCH })) + const payload = toScheduledJobPayload(job) + + expect(payload.lastRunAt).toBe(EPOCH) + expect(payload.createdAt).toBe(EPOCH) + }) + + it('drops an unparseable time rather than sending NaN', () => { + const job = { ...toScheduledJob(jobRow()), createdAt: 'whenever' } + expect(toScheduledJobPayload(job as ScheduledJob).createdAt).toBeUndefined() + }) +}) + +describe('toScheduledJobRun', () => { + it('keeps the tool call log intact', () => { + const toolCalls = [ + { + id: 'call-1', + name: 'browser_navigate', + input: { url: 'https://example.com' }, + timestamp: ISO, + }, + ] + + expect(toScheduledJobRun(runRow({ toolCalls })).toolCalls).toEqual( + toolCalls, + ) + }) + + it('converts start and completion times to ISO', () => { + const run = toScheduledJobRun(runRow({ completedAt: EPOCH })) + + expect(run.startedAt).toBe(ISO) + expect(run.completedAt).toBe(ISO) + }) + + it('leaves an unfinished run without a completion time', () => { + expect(toScheduledJobRun(runRow()).completedAt).toBeUndefined() + }) +}) + +describe('toScheduledJobRunPayload', () => { + // startedAt is required by the server, so it cannot be dropped the way an + // optional field can when it fails to parse. + it('falls back to now when the start time is unusable', () => { + const run = { ...toScheduledJobRun(runRow()), startedAt: 'whenever' } + const payload = toScheduledJobRunPayload(run as ScheduledJobRun) + + expect(typeof payload.startedAt).toBe('number') + expect(Number.isNaN(payload.startedAt)).toBe(false) + }) + + it('round-trips a run through both conversions', () => { + const run = toScheduledJobRun(runRow({ completedAt: EPOCH })) + const payload = toScheduledJobRunPayload(run) + + expect(payload.startedAt).toBe(EPOCH) + expect(payload.completedAt).toBe(EPOCH) + expect(payload.jobId).toBe('job-1') + }) +}) + +describe('applyLastRunAt', () => { + const AT = '2026-02-03T00:00:00.000Z' + + // A run can last minutes, and the job is editable throughout. Recording that + // it finished must not carry back the copy read before it started. + it('applies to the current copy, not an earlier one', () => { + const before = toScheduledJob(jobRow({ name: 'Old name' })) + const current = [toScheduledJob(jobRow({ name: 'Renamed mid-run' }))] + + const updated = applyLastRunAt(current, before.id, AT) + + expect(updated).toMatchObject({ name: 'Renamed mid-run', lastRunAt: AT }) + }) + + it('keeps an edit made to any field while the run was going', () => { + const current = [ + toScheduledJob( + jobRow({ enabled: false, query: 'changed', providerId: 'other' }), + ), + ] + + expect(applyLastRunAt(current, 'job-1', AT)).toMatchObject({ + enabled: false, + query: 'changed', + providerId: 'other', + }) + }) + + it('returns null when the job was deleted during the run', () => { + expect(applyLastRunAt([], 'job-1', AT)).toBeNull() + }) +}) diff --git a/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.ts b/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.ts new file mode 100644 index 0000000000..9f799bf5a0 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/schedules/schedules.helpers.ts @@ -0,0 +1,129 @@ +import type { + ScheduledJob, + ScheduledJobRun, + ToolCallExecution, +} from '@/lib/schedules/scheduleTypes' + +/** A job row as the server returns it: absent values are null, times are epoch. */ +export interface ScheduledJobRow { + id: string + name: string + query: string + scheduleType: ScheduledJob['scheduleType'] + scheduleTime: string | null + scheduleInterval: number | null + enabled: boolean + providerId: string | null + lastRunAt: number | null + createdAt: number + updatedAt: number +} + +/** A run row as the server returns it. */ +export interface ScheduledJobRunRow { + id: string + jobId: string + status: ScheduledJobRun['status'] + startedAt: number + completedAt: number | null + result: string | null + finalResult: string | null + executionLog: string | null + toolCalls: ToolCallExecution[] | null + error: string | null +} + +function orUndefined(value: T | null): T | undefined { + return value ?? undefined +} + +function toIso(value: number | null): string | undefined { + return value === null ? undefined : new Date(value).toISOString() +} + +function toEpoch(value: string | undefined): number | undefined { + if (!value) return undefined + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? undefined : parsed +} + +export function toScheduledJob(row: ScheduledJobRow): ScheduledJob { + return { + id: row.id, + name: row.name, + query: row.query, + scheduleType: row.scheduleType, + scheduleTime: orUndefined(row.scheduleTime), + scheduleInterval: orUndefined(row.scheduleInterval), + enabled: row.enabled, + providerId: orUndefined(row.providerId), + lastRunAt: toIso(row.lastRunAt), + // Not nullable in the database, so these always convert. + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + } +} + +/** The request body for a job write. `id` travels in the path instead. */ +export function toScheduledJobPayload(job: ScheduledJob) { + return { + name: job.name, + query: job.query, + scheduleType: job.scheduleType, + scheduleTime: job.scheduleTime, + scheduleInterval: job.scheduleInterval, + enabled: job.enabled, + providerId: job.providerId, + lastRunAt: toEpoch(job.lastRunAt), + createdAt: toEpoch(job.createdAt), + } +} + +/** + * The job to write back when recording that a run finished. + * + * Takes the current list rather than a job captured earlier: a run can last + * minutes, and the user can rename, reschedule, disable or repoint the job + * while it goes. Writing an earlier copy back would revert all of it. + * + * Returns null when the job was deleted during the run, so finishing does not + * resurrect it. + */ +export function applyLastRunAt( + jobs: readonly ScheduledJob[], + jobId: string, + at: string, +): ScheduledJob | null { + const job = jobs.find((each) => each.id === jobId) + return job ? { ...job, lastRunAt: at } : null +} + +export function toScheduledJobRun(row: ScheduledJobRunRow): ScheduledJobRun { + return { + id: row.id, + jobId: row.jobId, + status: row.status, + startedAt: new Date(row.startedAt).toISOString(), + completedAt: toIso(row.completedAt), + result: orUndefined(row.result), + finalResult: orUndefined(row.finalResult), + executionLog: orUndefined(row.executionLog), + toolCalls: orUndefined(row.toolCalls), + error: orUndefined(row.error), + } +} + +export function toScheduledJobRunPayload(run: ScheduledJobRun) { + return { + jobId: run.jobId, + status: run.status, + // Required by the server, and a run always carries the time it began. + startedAt: toEpoch(run.startedAt) ?? Date.now(), + completedAt: toEpoch(run.completedAt), + result: run.result, + finalResult: run.finalResult, + executionLog: run.executionLog, + toolCalls: run.toolCalls, + error: run.error, + } +} diff --git a/packages/browseros-agent/apps/app/modules/schedules/schedules.hooks.ts b/packages/browseros-agent/apps/app/modules/schedules/schedules.hooks.ts new file mode 100644 index 0000000000..2449e0c187 --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/schedules/schedules.hooks.ts @@ -0,0 +1,158 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { createQuery } from 'react-query-kit' +import { sendScheduleMessage } from '@/lib/messaging/schedules/scheduleMessages' +import { createAlarmFromJob } from '@/lib/schedules/createAlarmFromJob' +import type { + ScheduledJob, + ScheduledJobRun, +} from '@/lib/schedules/scheduleTypes' +import { + deleteScheduledJob, + listScheduledJobRuns, + listScheduledJobs, + putScheduledJob, +} from './schedules.api' +import { watchScheduleRevision } from './schedules.revision' + +const getAlarmName = (jobId: string) => `scheduled-job-${jobId}` + +export const useScheduledJobsQuery = createQuery({ + queryKey: ['scheduled-jobs'], + fetcher: listScheduledJobs, +}) + +export const useScheduledJobRunsQuery = createQuery({ + queryKey: ['scheduled-job-runs'], + fetcher: listScheduledJobRuns, +}) + +/** + * Keeps this view current with writes made in the background. + * + * The alarm runner records runs from a different context, which extension + * storage used to surface through `watch`. The rows live on the server now, so + * the background bumps a revision instead and every mounted view refetches. + */ +function useScheduleRevision(): void { + const queryClient = useQueryClient() + + useEffect( + () => + watchScheduleRevision(() => { + queryClient.invalidateQueries({ + queryKey: useScheduledJobsQuery.getKey(), + }) + queryClient.invalidateQueries({ + queryKey: useScheduledJobRunsQuery.getKey(), + }) + }), + [queryClient], + ) +} + +export interface UseScheduledJobsReturn { + jobs: ScheduledJob[] + /** The server could not be reached, as opposed to reporting no jobs. */ + isUnavailable: boolean + addJob: ( + job: Omit, + ) => Promise + removeJob: (id: string) => Promise + editJob: ( + id: string, + updates: Omit, + ) => Promise + toggleJob: (id: string, enabled: boolean) => Promise + runJob: (id: string) => Promise +} + +export function useScheduledJobs(): UseScheduledJobsReturn { + const queryClient = useQueryClient() + const jobsQuery = useScheduledJobsQuery() + useScheduleRevision() + + const jobs = jobsQuery.data ?? [] + const invalidate = () => + queryClient.invalidateQueries({ queryKey: useScheduledJobsQuery.getKey() }) + + const saveMutation = useMutation({ + mutationFn: async (job: ScheduledJob) => { + await putScheduledJob(job) + // The alarm is the thing that actually makes a schedule fire, so it is + // rebuilt from the saved job rather than the requested one. + await chrome.alarms.clear(getAlarmName(job.id)) + if (job.enabled) await createAlarmFromJob(job) + }, + onSuccess: invalidate, + }) + + const removeMutation = useMutation({ + mutationFn: async (id: string) => { + await chrome.alarms.clear(getAlarmName(id)) + // Runs are removed with the job by the cascade on the row. + await deleteScheduledJob(id) + }, + onSuccess: () => { + invalidate() + queryClient.invalidateQueries({ + queryKey: useScheduledJobRunsQuery.getKey(), + }) + }, + }) + + const save = async (job: ScheduledJob) => { + await saveMutation.mutateAsync(job) + } + + return { + jobs, + isUnavailable: jobsQuery.isError, + addJob: async (job) => { + const now = new Date().toISOString() + await save({ + ...job, + id: crypto.randomUUID(), + createdAt: now, + updatedAt: now, + }) + }, + removeJob: async (id) => { + await removeMutation.mutateAsync(id) + }, + editJob: async (id, updates) => { + const existing = jobs.find((job) => job.id === id) + if (!existing) return + await save({ + ...updates, + id, + createdAt: existing.createdAt, + updatedAt: new Date().toISOString(), + }) + }, + toggleJob: async (id, enabled) => { + const existing = jobs.find((job) => job.id === id) + if (!existing) return + await save({ ...existing, enabled, updatedAt: new Date().toISOString() }) + }, + runJob: (id) => sendScheduleMessage('runScheduledJob', { jobId: id }), + } +} + +export interface UseScheduledJobRunsReturn { + jobRuns: ScheduledJobRun[] + isUnavailable: boolean + cancelJobRun: (runId: string) => Promise +} + +export function useScheduledJobRuns(): UseScheduledJobRunsReturn { + const runsQuery = useScheduledJobRunsQuery() + useScheduleRevision() + + return { + jobRuns: runsQuery.data ?? [], + isUnavailable: runsQuery.isError, + cancelJobRun: (runId) => + sendScheduleMessage('cancelScheduledJobRun', { runId }), + } +} diff --git a/packages/browseros-agent/apps/app/modules/schedules/schedules.revision.ts b/packages/browseros-agent/apps/app/modules/schedules/schedules.revision.ts new file mode 100644 index 0000000000..a25a89a56e --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/schedules/schedules.revision.ts @@ -0,0 +1,27 @@ +import { storage } from '@wxt-dev/storage' + +/** + * A change signal, not data. + * + * Scheduled runs are written by the background while the side panel and new + * tab display them, and the two are separate contexts. Extension storage used + * to carry both the data and the notification, so `watch` kept every surface + * current for free. The data now lives on the server, which nothing can watch, + * so this keeps the notification half: the background bumps it after a write + * and the query cache is invalidated wherever a view is mounted. + * + * The value is a timestamp rather than a counter so two contexts writing at + * once cannot lose a bump to a read-modify-write race. + */ +export const scheduleRevisionStorage = storage.defineItem( + 'local:schedule-revision', + { fallback: 0 }, +) + +export async function bumpScheduleRevision(): Promise { + await scheduleRevisionStorage.setValue(Date.now()) +} + +export function watchScheduleRevision(onChange: () => void): () => void { + return scheduleRevisionStorage.watch(() => onChange()) +} diff --git a/packages/browseros-agent/apps/app/package.json b/packages/browseros-agent/apps/app/package.json index 54e096ea73..aa77264c6c 100644 --- a/packages/browseros-agent/apps/app/package.json +++ b/packages/browseros-agent/apps/app/package.json @@ -77,6 +77,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-hook-form": "^7.86.0", + "react-query-kit": "^3.3.4", "react-resizable-panels": "^4.12.3", "react-router": "^7.18.3", "shiki": "^3.23.0", diff --git a/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx b/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx index 567fc726a3..e42c56dc55 100644 --- a/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx +++ b/packages/browseros-agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx @@ -2,7 +2,9 @@ import { useQueryClient } from '@tanstack/react-query' import { Plus } from 'lucide-react' import { type FC, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' +import { CloudSyncRetiredNotice } from '@/components/cloud-sync/CloudSyncRetiredNotice' import { BrowserClawPromoBanner } from '@/components/promo/BrowserClawPromoBanner' +import { Alert, AlertDescription } from '@/components/ui/alert' import { AlertDialog, AlertDialogAction, @@ -50,6 +52,7 @@ export const BrowserOsAiPane: FC = () => { saveProvider, setDefaultProvider, deleteProvider, + isUnavailable: providersUnavailable, } = useLlmProviders() const { baseUrl: agentServerUrl } = useAgentServerUrl() const { sessionInfo } = useSessionInfo() @@ -254,6 +257,8 @@ export const BrowserOsAiPane: FC = () => {

+ +
@@ -270,6 +275,15 @@ export const BrowserOsAiPane: FC = () => { + {providersUnavailable ? ( + + + Your providers could not be loaded because the BrowserOS server is + not reachable. They are still saved on this device. + + + ) : null} + = ({ accessKeyId: watchedAccessKeyId, secretAccessKey: watchedSecretAccessKey, region: watchedRegion, + stored: initialValues, }) const handleTest = async () => { diff --git a/packages/browseros-agent/apps/app/screens/auth/LogoutPage.tsx b/packages/browseros-agent/apps/app/screens/auth/LogoutPage.tsx index 1be3444742..84f7932ebb 100644 --- a/packages/browseros-agent/apps/app/screens/auth/LogoutPage.tsx +++ b/packages/browseros-agent/apps/app/screens/auth/LogoutPage.tsx @@ -12,8 +12,6 @@ import { } from '@/components/ui/card' import { resetIdentity } from '@/lib/analytics/identify' import { signOut } from '@/lib/auth/auth-client' -import { providersStorage } from '@/lib/llm-providers/storage' -import { scheduledJobStorage } from '@/lib/schedules/scheduleStorage' export const LogoutPage: FC = () => { const navigate = useNavigate() @@ -22,8 +20,6 @@ export const LogoutPage: FC = () => { // biome-ignore lint/correctness/useExhaustiveDependencies: must run only once to ensure the logout process happens successfully useEffect(() => { const performLogout = async () => { - await providersStorage.removeValue() - await scheduledJobStorage.removeValue() queryClient.clear() await clear() diff --git a/packages/browseros-agent/apps/app/screens/newtab/index/ScheduleResults.tsx b/packages/browseros-agent/apps/app/screens/newtab/index/ScheduleResults.tsx index 28f71b3a27..ff26b60289 100644 --- a/packages/browseros-agent/apps/app/screens/newtab/index/ScheduleResults.tsx +++ b/packages/browseros-agent/apps/app/screens/newtab/index/ScheduleResults.tsx @@ -30,7 +30,7 @@ import { track } from '@/lib/metrics/track' import { useScheduledJobRuns, useScheduledJobs, -} from '@/lib/schedules/scheduleStorage' +} from '@/modules/schedules/schedules.hooks' import { countRunningRuns, type JobRunWithDetails, diff --git a/packages/browseros-agent/apps/app/screens/scheduled-tasks/NewScheduledTaskDialog.tsx b/packages/browseros-agent/apps/app/screens/scheduled-tasks/NewScheduledTaskDialog.tsx index fb198b4389..cc6da02c4c 100644 --- a/packages/browseros-agent/apps/app/screens/scheduled-tasks/NewScheduledTaskDialog.tsx +++ b/packages/browseros-agent/apps/app/screens/scheduled-tasks/NewScheduledTaskDialog.tsx @@ -36,18 +36,15 @@ import { } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { SCHEDULED_TASK_PROMPT_REFINED_EVENT } from '@/lib/constants/analyticsEvents' -import { - findChatProviderById, - resolveChatProvider, -} from '@/lib/llm-providers/provider-runtime' +import { resolveChatProvider } from '@/lib/llm-providers/provider-runtime' import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons' -import { - defaultProviderIdStorage, - providersStorage, -} from '@/lib/llm-providers/storage' -import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types' +import type { ProviderType } from '@/lib/llm-providers/types' import { track } from '@/lib/metrics/track' import { refinePrompt } from '@/lib/schedules/refine-prompt' +import { useAcpAgents } from '@/modules/agents/agents.hooks' +import { toProviderOption } from '@/modules/chat/chat-session-request' +import { buildSidepanelChatTargets } from '@/modules/chat/sidepanel-chat-targets' +import { useLlmProviders } from '@/modules/llm-providers/llm-providers.hooks' import type { ScheduledJob } from './types' const formSchema = z @@ -99,8 +96,13 @@ export const NewScheduledTaskDialog: FC = ({ onSave, }) => { const isEditing = !!initialValues - const [providers, setProviders] = useState([]) - const [defaultProviderId, setDefaultProviderId] = useState('') + const { providers, defaultProviderId } = useLlmProviders() + const { agents } = useAcpAgents() + // A scheduled job can target a coding agent as readily as an llm provider + // now that both are rows of one table, so the picker offers both. While they + // were separate tables the job's provider reference could only ever name an + // llm one, which is why this dialog listed those alone. + const chatTargets = buildSidepanelChatTargets({ providers, agents }) const form = useForm({ resolver: zodResolver(formSchema), @@ -123,17 +125,6 @@ export const NewScheduledTaskDialog: FC = ({ const refineRequestIdRef = useRef(0) const isProgrammaticChange = useRef(false) - useEffect(() => { - if (!open) return - Promise.all([ - providersStorage.getValue(), - defaultProviderIdStorage.getValue(), - ]).then(([providerList, defId]) => { - setProviders(providerList ?? []) - setDefaultProviderId(defId ?? '') - }) - }, [open]) - useEffect(() => { if (open) { refineRequestIdRef.current++ @@ -164,9 +155,12 @@ export const NewScheduledTaskDialog: FC = ({ }, [open, initialValues, form]) const resolvedProvider: Provider | null = (() => { - const found = - findChatProviderById(providers, selectedProviderId) ?? - resolveChatProvider(providers, defaultProviderId) + const chosen = chatTargets.find( + (target) => target.id === selectedProviderId, + ) + if (chosen) return toProviderOption(chosen) + + const found = resolveChatProvider(providers, defaultProviderId) if (found) { return { kind: 'llm' as const, @@ -186,12 +180,7 @@ export const NewScheduledTaskDialog: FC = ({ return null })() - const providerOptions: Provider[] = providers.map((provider) => ({ - kind: 'llm', - id: provider.id, - name: provider.name, - type: provider.type, - })) + const providerOptions: Provider[] = chatTargets.map(toProviderOption) // Replace textarea content via execCommand so the browser's native undo // stack (Cmd+Z / Ctrl+Z) records the change. Falls back to form.setValue @@ -248,7 +237,11 @@ export const NewScheduledTaskDialog: FC = ({ } const onSubmit = (values: FormValues) => { - const provider = findChatProviderById(providers, values.providerId) + // Either kind, so an id that names an agent is stored as readily as one + // that names a provider. + const provider = chatTargets.find( + (target) => target.id === values.providerId, + ) onSave({ name: values.name.trim(), query: values.query.trim(), diff --git a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskCard.tsx b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskCard.tsx index 255b98b88a..cd7d7beaf3 100644 --- a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskCard.tsx +++ b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskCard.tsx @@ -12,7 +12,7 @@ import { Trash2, XCircle, } from 'lucide-react' -import { type FC, useEffect, useMemo, useState } from 'react' +import { type FC, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Collapsible, @@ -21,9 +21,8 @@ import { } from '@/components/ui/collapsible' import { Switch } from '@/components/ui/switch' import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons' -import { providersStorage } from '@/lib/llm-providers/storage' -import type { ProviderType } from '@/lib/llm-providers/types' -import { useScheduledJobRuns } from '@/lib/schedules/scheduleStorage' +import { useProvidersQuery } from '@/modules/llm-providers/llm-providers.hooks' +import { useScheduledJobRuns } from '@/modules/schedules/schedules.hooks' import type { ScheduledJob, ScheduledJobRun } from './types' dayjs.extend(relativeTime) @@ -83,24 +82,11 @@ export const ScheduledTaskCard: FC = ({ onRetryRun, }) => { const [isOpen, setIsOpen] = useState(false) - const [providerInfo, setProviderInfo] = useState<{ - name: string - type: ProviderType - } | null>(null) - const { jobRuns } = useScheduledJobRuns() - - // Load provider info for display - useEffect(() => { - if (!job.providerId) { - setProviderInfo(null) - return - } - providersStorage.getValue().then((providers) => { - const match = providers?.find((p) => p.id === job.providerId) - setProviderInfo(match ? { name: match.name, type: match.type } : null) - }) - }, [job.providerId]) + const { data: providers = [] } = useProvidersQuery() + const providerInfo = job.providerId + ? (providers.find((provider) => provider.id === job.providerId) ?? null) + : null const runs = useMemo( () => diff --git a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskResults.tsx b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskResults.tsx index 43b5271c66..565cf232f4 100644 --- a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskResults.tsx +++ b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTaskResults.tsx @@ -12,14 +12,14 @@ import { import type { FC } from 'react' import { useMemo } from 'react' import { Button } from '@/components/ui/button' -import { - useScheduledJobRuns, - useScheduledJobs, -} from '@/lib/schedules/scheduleStorage' import type { ScheduledJob, ScheduledJobRun, } from '@/lib/schedules/scheduleTypes' +import { + useScheduledJobRuns, + useScheduledJobs, +} from '@/modules/schedules/schedules.hooks' dayjs.extend(relativeTime) diff --git a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTasksPage.tsx b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTasksPage.tsx index 1ecfa97e93..6acb51a4e7 100644 --- a/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTasksPage.tsx +++ b/packages/browseros-agent/apps/app/screens/scheduled-tasks/ScheduledTasksPage.tsx @@ -23,12 +23,11 @@ import { SCHEDULED_TASK_VIEW_RESULTS_EVENT, } from '@/lib/constants/analyticsEvents' import { track } from '@/lib/metrics/track' +import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes' import { - scheduledJobRunStorage, useScheduledJobRuns, useScheduledJobs, -} from '@/lib/schedules/scheduleStorage' -import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes' +} from '@/modules/schedules/schedules.hooks' import { NewScheduledTaskDialog } from './NewScheduledTaskDialog' import { ScheduledTaskResults } from './ScheduledTaskResults' import { ScheduledTasksHeader } from './ScheduledTasksHeader' @@ -44,7 +43,10 @@ export const ScheduledTasksPage: FC = () => { useScheduledJobs() const { jobRuns, cancelJobRun } = useScheduledJobRuns() - const [activeTab, setActiveTab] = useState(null) + const [selectedTab, setSelectedTab] = useState(null) + // Derived rather than set from an effect, so it settles when the history + // arrives instead of on whatever a single mount-time read happened to see. + const activeTab = selectedTab ?? (jobRuns.length > 0 ? 'results' : 'tasks') const [isDialogOpen, setIsDialogOpen] = useState(false) const [editingJob, setEditingJob] = useState(null) const [deleteJobId, setDeleteJobId] = useState(null) @@ -115,7 +117,7 @@ export const ScheduledTasksPage: FC = () => { }) } else { await addJob(data) - setActiveTab('tasks') + setSelectedTab('tasks') track(NEW_SCHEDULED_TASK_CREATED_EVENT, { scheduleType: data.scheduleType, interval: data.scheduleInterval, @@ -149,12 +151,6 @@ export const ScheduledTasksPage: FC = () => { track(SCHEDULED_TASK_VIEW_RESULTS_EVENT) } - useEffect(() => { - scheduledJobRunStorage.getValue().then((runs) => { - setActiveTab(runs && runs.length > 0 ? 'results' : 'tasks') - }) - }, []) - const jobToDelete = deleteJobId ? jobs.find((j) => j.id === deleteJobId) : null @@ -163,35 +159,33 @@ export const ScheduledTasksPage: FC = () => {
- {activeTab && ( - - - Results - Scheduled Tasks - - - - - - - - - - - )} + + + Results + Scheduled Tasks + + + + + + + + + + +let cloudProps: { userId: string; localIds: ReadonlySet } | null = null + +mock.module('@/lib/auth/sessionStorage', () => ({ + useSessionInfo: () => ({ + sessionInfo: { user: sessionUserId ? { id: sessionUserId } : undefined }, + }), +})) +mock.module('@/modules/conversations/conversations.hooks', () => ({ + useServerConversations: () => ({ data: localRows }), + useDeleteServerConversation: () => ({ mutate: () => {} }), +})) +mock.module('./local/LocalChatHistory', () => ({ + LocalChatHistory: () => createElement('div', { 'data-testid': 'local' }), +})) +mock.module('./cloud/CloudChatHistory', () => ({ + CloudChatHistory: (props: { + userId: string + localIds: ReadonlySet + }) => { + cloudProps = props + return createElement('div', { 'data-testid': 'cloud' }) + }, +})) + +const { ChatHistory } = (await import('./ChatHistory')) as { ChatHistory: FC } + +beforeEach(() => { + sessionUserId = undefined + localRows = [] + cloudProps = null +}) + +function render() { + return renderToStaticMarkup(createElement(ChatHistory)) +} + +describe('ChatHistory', () => { + it('always shows the local list', () => { + expect(render()).toContain('data-testid="local"') + }) + + // Signed out there is no account to read, so the cloud section is absent + // rather than empty. + it('omits the cloud section when signed out', () => { + expect(render()).not.toContain('data-testid="cloud"') + }) + + // It used to be one or the other: a signed-in user saw only the cloud and + // could not see what their own machine was storing. + it('shows both lists when signed in', () => { + sessionUserId = 'user-1' + const html = render() + expect(html).toContain('data-testid="local"') + expect(html).toContain('data-testid="cloud"') + }) + + it('puts the local list first', () => { + sessionUserId = 'user-1' + const html = render() + expect(html.indexOf('data-testid="local"')).toBeLessThan( + html.indexOf('data-testid="cloud"'), + ) + }) + + // One id space across the stores, so a conversation synced before sync was + // turned off would otherwise appear in both lists. + it('passes the local ids to the cloud section so it can deduplicate', () => { + sessionUserId = 'user-1' + localRows = [ + { id: 'a', lastMessagedAt: 1, lastUserMessage: 'hi' }, + { id: 'b', lastMessagedAt: 2, lastUserMessage: 'there' }, + ] + render() + expect(cloudProps?.userId).toBe('user-1') + expect([...(cloudProps?.localIds ?? [])].sort()).toEqual(['a', 'b']) + }) + + // Two lists in one scroll area. Each list owning its own worked only while + // exactly one of them ever rendered. + it('renders a single scroll container for both lists', () => { + sessionUserId = 'user-1' + const html = render() + expect((html.match(/
= ({ userId }) => { - const { conversationId: activeConversationId } = useChatSessionContext() - const queryClient = useQueryClient() - - const { data: profileData } = useGraphqlQuery(GetProfileIdByUserIdDocument, { - userId, - }) - const profileId = profileData?.profileByUserId?.rowId - - const { - data: graphqlData, - isLoading: isLoadingConversations, - isFetching, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - } = useGraphqlInfiniteQuery( - GetConversationsForHistoryDocument, - // biome-ignore lint/style/noNonNullAssertion: guarded by enabled - (cursor) => ({ profileId: profileId!, after: cursor }), - { - enabled: !!profileId, - initialPageParam: undefined, - getNextPageParam: (lastPage) => - lastPage.conversations?.pageInfo.hasNextPage - ? lastPage.conversations.pageInfo.endCursor - : undefined, - placeholderData: keepPreviousData, - }, - ) - - const deleteConversationMutation = useGraphqlMutation( - DeleteConversationDocument, - { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: [ - getQueryKeyFromDocument(GetConversationsForHistoryDocument), - ], - }) - }, - }, - ) - - const handleDelete = (id: string) => { - deleteConversationMutation.mutate({ rowId: id }) - } - - const conversations = useMemo(() => { - if (!graphqlData?.pages) return [] - - return graphqlData.pages.flatMap((page) => - (page.conversations?.nodes ?? []) - .filter((node): node is NonNullable => node !== null) - .map((node) => { - const messages = node.conversationMessages.nodes - .filter((m): m is NonNullable => m !== null) - .map((m) => m.message as UIMessage) - - const timestamp = node.lastMessagedAt.endsWith('Z') - ? node.lastMessagedAt - : `${node.lastMessagedAt}Z` - - return { - id: node.rowId, - lastMessagedAt: new Date(timestamp).getTime(), - lastUserMessage: extractLastUserMessage(messages), - } - }), - ) - }, [graphqlData]) - - const groupedConversations = useMemo( - () => groupConversations(conversations), - [conversations], - ) - - if (!profileId || isLoadingConversations) { - return ( -
- -
- ) - } - - return ( - - ) -} - +/** + * History is the union of what is on this machine and what is still in the + * account, with the local list first and always present. + * + * It used to be one or the other: signed in showed only the cloud, signed out + * showed only the local server. That meant a signed-in user could not see the + * conversations their own machine was storing. + */ export const ChatHistory: FC = () => { const { sessionInfo } = useSessionInfo() const userId = sessionInfo.user?.id - const queryClient = useQueryClient() - // Drain any pre-upgrade local:conversations to their new home. - useLegacyConversationMigration() - // On sign-in, promote logged-out (server) history to the cloud, then refresh - // the cloud list so it appears. Stable callback keeps the promote effect from - // re-running on every render. - const refreshCloudHistory = useCallback(() => { - queryClient.invalidateQueries({ - queryKey: [getQueryKeyFromDocument(GetConversationsForHistoryDocument)], - }) - }, [queryClient]) - useSignInConversationPromote(refreshCloudHistory) - - if (userId) { - return - } + // Same query key as LocalChatHistory, so this shares its cache rather than + // fetching a second time. Only the ids are needed, to keep a conversation + // that exists in both places from being listed twice. + const { data: localConversations = [] } = useServerConversations() + const localIds = useMemo( + () => new Set(localConversations.map((conversation) => conversation.id)), + [localConversations], + ) - return + // One scroll area for both lists. Each list used to own its own, which + // worked while only ever one of them rendered. + return ( +
+ + {userId ? : null} +
+ ) } diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/cloud/CloudChatHistory.tsx b/packages/browseros-agent/apps/app/screens/sidepanel/history/cloud/CloudChatHistory.tsx new file mode 100644 index 0000000000..2f15c2723a --- /dev/null +++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/cloud/CloudChatHistory.tsx @@ -0,0 +1,171 @@ +import { keepPreviousData, useQueryClient } from '@tanstack/react-query' +import type { UIMessage } from 'ai' +import type { FC } from 'react' +import { useEffect, useMemo } from 'react' +import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument' +import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument' +import { useChatSessionContext } from '@/modules/chat/chat-session-context' +import { useGraphqlInfiniteQuery } from '@/modules/graphql/graphql-infinite-query.hooks' +import { useGraphqlMutation } from '@/modules/graphql/graphql-mutation.hooks' +import { useGraphqlQuery } from '@/modules/graphql/graphql-query.hooks' +import { ConversationList } from '../components/ConversationList' +import type { HistoryConversation } from '../components/types' +import { extractLastUserMessage, groupConversations } from '../components/utils' +import { + DeleteConversationDocument, + GetConversationsForHistoryDocument, +} from '../graphql/chatHistoryDocument' +import { + excludeLocalConversations, + hasAnyConversation, + shouldAdvanceCloudPage, +} from '../history-union.helpers' + +export interface CloudChatHistoryProps { + userId: string + /** Ids already on this machine, so the same chat is not listed twice. */ + localIds: ReadonlySet +} + +/** + * Conversations that were synced to the account before sync was turned off. + * + * Read only and clearly separated rather than merged into the local list: it + * is a legacy shelf that empties when the cloud is retired, and blending it + * into the local history would hide that. Interleaving the two by date would + * also mean paginating two sources against one scroll position, which this + * deliberately avoids. + */ +export const CloudChatHistory: FC = ({ + userId, + localIds, +}) => { + const { conversationId: activeConversationId } = useChatSessionContext() + const queryClient = useQueryClient() + + const { data: profileData } = useGraphqlQuery(GetProfileIdByUserIdDocument, { + userId, + }) + const profileId = profileData?.profileByUserId?.rowId + + const { + data: graphqlData, + isLoading: isLoadingConversations, + isFetching, + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + fetchNextPage, + } = useGraphqlInfiniteQuery( + GetConversationsForHistoryDocument, + // biome-ignore lint/style/noNonNullAssertion: guarded by enabled + (cursor) => ({ profileId: profileId!, after: cursor }), + { + enabled: !!profileId, + initialPageParam: undefined, + getNextPageParam: (lastPage) => + lastPage.conversations?.pageInfo.hasNextPage + ? lastPage.conversations.pageInfo.endCursor + : undefined, + placeholderData: keepPreviousData, + }, + ) + + const deleteConversationMutation = useGraphqlMutation( + DeleteConversationDocument, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ + getQueryKeyFromDocument(GetConversationsForHistoryDocument), + ], + }) + }, + }, + ) + + const handleDelete = (id: string) => { + deleteConversationMutation.mutate({ rowId: id }) + } + + const conversations = useMemo(() => { + if (!graphqlData?.pages) return [] + + return graphqlData.pages.flatMap((page) => + (page.conversations?.nodes ?? []) + .filter((node): node is NonNullable => node !== null) + .map((node) => { + const messages = node.conversationMessages.nodes + .filter((m): m is NonNullable => m !== null) + .map((m) => m.message as UIMessage) + + const timestamp = node.lastMessagedAt.endsWith('Z') + ? node.lastMessagedAt + : `${node.lastMessagedAt}Z` + + return { + id: node.rowId, + lastMessagedAt: new Date(timestamp).getTime(), + lastUserMessage: extractLastUserMessage(messages), + } + }), + ) + }, [graphqlData]) + + const groupedConversations = useMemo( + () => + groupConversations(excludeLocalConversations(conversations, localIds)), + [conversations, localIds], + ) + const hasVisibleConversations = hasAnyConversation(groupedConversations) + + // Pagination is normally driven by a sentinel inside the rendered list, so a + // page that deduplicates away to nothing would stop it dead: the section + // renders null, the sentinel never mounts, and cloud-only conversations on + // later pages stay invisible. That is the ordinary case right after this + // ships, because the most recent conversations are the ones that exist in + // both stores and they sort onto the first page. + // + // Advancing here is the only way to reach past them; it cannot be lifted + // into an event handler because there is no interaction to hang it on, and + // it terminates when the pages run out or a page fails. + const advance = shouldAdvanceCloudPage({ + hasVisibleConversations, + hasNextPage: Boolean(hasNextPage), + isFetchingNextPage, + isLoading: isLoadingConversations, + hasPageError: isFetchNextPageError, + }) + useEffect(() => { + if (advance) fetchNextPage() + }, [advance, fetchNextPage]) + + // Nothing to announce until there is something here. The loading case is + // silent too: this section sits below the local list, so a spinner would + // shift content the user is already reading. + if (!profileId || isLoadingConversations) return null + if (!hasVisibleConversations) return null + + return ( +
+
+

+ Saved to your account +

+

+ From before cloud sync was turned off. Still readable here, and not + stored on this device. +

+
+ +
+ ) +} diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/components/ConversationList.tsx b/packages/browseros-agent/apps/app/screens/sidepanel/history/components/ConversationList.tsx index 7d255f5fa5..a1483403ab 100644 --- a/packages/browseros-agent/apps/app/screens/sidepanel/history/components/ConversationList.tsx +++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/components/ConversationList.tsx @@ -13,6 +13,11 @@ export interface ConversationListProps { isFetchingNextPage?: boolean onLoadMore?: () => void isRefreshing?: boolean + /** + * Shown when this list has nothing in it. History can render two lists now, + * so the wording has to say which store is empty. + */ + emptyMessage?: string } export const ConversationList: FC = ({ @@ -23,6 +28,7 @@ export const ConversationList: FC = ({ isFetchingNextPage, onLoadMore, isRefreshing, + emptyMessage = 'No conversations yet', }) => { const loadMoreRef = useRef(null) @@ -57,64 +63,60 @@ export const ConversationList: FC = ({ groupedConversations.older.length > 0 return ( -
-
- {isRefreshing && ( -
- - Fetching latest conversations -
- )} - {!hasConversations ? ( -
- -

- No conversations yet -

- - Start a new chat - -
- ) : ( - <> - - - - +
+ {isRefreshing && ( +
+ + Fetching latest conversations +
+ )} + {!hasConversations ? ( +
+ +

{emptyMessage}

+ + Start a new chat + +
+ ) : ( + <> + + + + - {hasNextPage && ( -
- {isFetchingNextPage && ( - - )} -
- )} - - )} -
-
+ {hasNextPage && ( +
+ {isFetchingNextPage && ( + + )} +
+ )} + + )} +
) } diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.test.ts b/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.test.ts new file mode 100644 index 0000000000..79caabf9fd --- /dev/null +++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'bun:test' +import type { + GroupedConversations, + HistoryConversation, +} from './components/types' +import { + excludeLocalConversations, + hasAnyConversation, + shouldAdvanceCloudPage, +} from './history-union.helpers' + +function conversation(id: string): HistoryConversation { + return { id, lastMessagedAt: 1, lastUserMessage: 'hi' } +} + +function grouped( + overrides: Partial = {}, +): GroupedConversations { + return { today: [], thisWeek: [], thisMonth: [], older: [], ...overrides } +} + +describe('excludeLocalConversations', () => { + // One id space across extension storage, the local server and the cloud, so + // a conversation synced before sync was turned off appears in both lists. + it('drops a cloud conversation that also exists locally', () => { + const result = excludeLocalConversations( + [conversation('a'), conversation('b')], + new Set(['a']), + ) + expect(result.map((c) => c.id)).toEqual(['b']) + }) + + it('keeps everything when nothing is local', () => { + const result = excludeLocalConversations( + [conversation('a'), conversation('b')], + new Set(), + ) + expect(result.map((c) => c.id)).toEqual(['a', 'b']) + }) + + it('returns nothing when every cloud conversation is already local', () => { + const result = excludeLocalConversations( + [conversation('a')], + new Set(['a', 'b']), + ) + expect(result).toEqual([]) + }) + + it('does not mutate the input', () => { + const cloud = [conversation('a')] + excludeLocalConversations(cloud, new Set(['a'])) + expect(cloud).toHaveLength(1) + }) +}) + +describe('hasAnyConversation', () => { + it('is false for an empty set', () => { + expect(hasAnyConversation(grouped())).toBe(false) + }) + + for (const bucket of ['today', 'thisWeek', 'thisMonth', 'older'] as const) { + it(`is true when only ${bucket} has one`, () => { + expect( + hasAnyConversation(grouped({ [bucket]: [conversation('a')] })), + ).toBe(true) + }) + } +}) + +describe('shouldAdvanceCloudPage', () => { + const stalled = { + hasVisibleConversations: false, + hasNextPage: true, + isFetchingNextPage: false, + isLoading: false, + hasPageError: false, + } + + // The page that stalls is the ordinary one right after this ships: the most + // recent conversations exist in both stores and sort onto the first page, so + // it deduplicates away to nothing and the sentinel never mounts to pull the + // cloud-only conversations behind it. + it('advances when a page deduplicates away to nothing', () => { + expect(shouldAdvanceCloudPage(stalled)).toBe(true) + }) + + it('stops once something is visible, leaving the sentinel to take over', () => { + expect( + shouldAdvanceCloudPage({ ...stalled, hasVisibleConversations: true }), + ).toBe(false) + }) + + it('terminates when the pages run out', () => { + expect(shouldAdvanceCloudPage({ ...stalled, hasNextPage: false })).toBe( + false, + ) + }) + + // Without these the effect would queue a second fetch on every render while + // the first is still in flight. + it('does not stack a fetch on top of one in flight', () => { + expect( + shouldAdvanceCloudPage({ ...stalled, isFetchingNextPage: true }), + ).toBe(false) + }) + + it('waits for the first page before advancing', () => { + expect(shouldAdvanceCloudPage({ ...stalled, isLoading: true })).toBe(false) + }) + + // A rejected fetch leaves every other input exactly as it was before the + // fetch started, so without this the section would retry forever. + it('stops after a page fails instead of retrying it forever', () => { + expect(shouldAdvanceCloudPage({ ...stalled, hasPageError: true })).toBe( + false, + ) + }) +}) diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.ts b/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.ts new file mode 100644 index 0000000000..43cea0167b --- /dev/null +++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/history-union.helpers.ts @@ -0,0 +1,55 @@ +import type { + GroupedConversations, + HistoryConversation, +} from './components/types' + +/** + * Drops cloud conversations that already exist on this machine. + * + * The same conversation id is used by extension storage, the local server and + * the cloud, so a conversation that was synced before sync was turned off + * exists in both lists. Local wins: it is the copy that keeps working. + */ +export function excludeLocalConversations( + cloud: readonly HistoryConversation[], + localIds: ReadonlySet, +): HistoryConversation[] { + return cloud.filter((conversation) => !localIds.has(conversation.id)) +} + +/** Whether a grouped set has anything in it, in any bucket. */ +export function hasAnyConversation(grouped: GroupedConversations): boolean { + return ( + grouped.today.length > 0 || + grouped.thisWeek.length > 0 || + grouped.thisMonth.length > 0 || + grouped.older.length > 0 + ) +} + +/** + * Whether the cloud section should pull the next page on its own. + * + * Pagination is normally driven by a sentinel inside the rendered list, which + * never mounts while the section has nothing visible. A page whose entries are + * all present locally deduplicates away to nothing, so without this the + * section stalls on that page and never reaches the cloud-only conversations + * behind it. + * + * A failed page has to stop it. `hasNextPage` is derived from the last + * successful page, so a rejected fetch leaves it true while the in-flight flag + * clears, returning every input to its pre-fetch value. Advancing again on + * that state retries a failing request forever with no user interaction. + */ +export function shouldAdvanceCloudPage(state: { + hasVisibleConversations: boolean + hasNextPage: boolean + isFetchingNextPage: boolean + isLoading: boolean + hasPageError: boolean +}): boolean { + if (state.hasVisibleConversations) return false + if (state.hasPageError) return false + if (state.isLoading || state.isFetchingNextPage) return false + return state.hasNextPage +} diff --git a/packages/browseros-agent/apps/app/screens/sidepanel/history/local/LocalChatHistory.tsx b/packages/browseros-agent/apps/app/screens/sidepanel/history/local/LocalChatHistory.tsx index fd73a71a34..2113149fe0 100644 --- a/packages/browseros-agent/apps/app/screens/sidepanel/history/local/LocalChatHistory.tsx +++ b/packages/browseros-agent/apps/app/screens/sidepanel/history/local/LocalChatHistory.tsx @@ -32,6 +32,7 @@ export const LocalChatHistory: FC = () => { groupedConversations={groupedConversations} activeConversationId={activeConversationId} onDelete={(id) => deleteConversation.mutate(id)} + emptyMessage="No conversations on this device yet" /> ) } diff --git a/packages/browseros-agent/apps/server/.gitignore b/packages/browseros-agent/apps/server/.gitignore index 296f38a0cb..23eaf28c0b 100644 --- a/packages/browseros-agent/apps/server/.gitignore +++ b/packages/browseros-agent/apps/server/.gitignore @@ -1,5 +1,8 @@ tmp-shot-*/ tmp-upload-*/ .devtools -db/ -identity/ +# Runtime directories at the app root. Anchored with a leading slash: an +# unanchored `db/` also matches src/lib/db/ and tests/lib/db/, which silently +# swallowed new schema files and migrations until they were force-added. +/db/ +/identity/ diff --git a/packages/browseros-agent/apps/server/src/api/routes/chat.ts b/packages/browseros-agent/apps/server/src/api/routes/chat.ts index ae172a0061..96f559fb47 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/chat.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/chat.ts @@ -5,19 +5,27 @@ import { createUIMessageStreamResponse } from 'ai' import { Hono } from 'hono' import { SessionStore } from '../../agent/session-store' import type { AcpAgentRuntime } from '../../lib/agents/acp/acp-agent-runtime' +import { SERVER_CREDENTIALED_PROVIDERS } from '../../lib/clients/llm/config' import { logger } from '../../lib/logger' import { metrics } from '../../lib/metrics' +import { dbProviderStore } from '../../lib/providers/provider-store' import { Sentry } from '../../lib/sentry' +import { + type ChatProviderLookup, + hydrateChatProvider, +} from '../services/chat-provider-config' import { ChatService } from '../services/chat-service' import type { ConversationRuns } from '../services/conversation-runs' import type { KlavisService } from '../services/klavis' import type { BrowserMcpModule } from '../services/mcp/browser-mcp-module' import type { ServerActivity } from '../services/server-activity' import { + type AcpChatRequest, type BrowserOsChatRequest, type ChatRequest, ChatRequestSchema, type Env, + type HydratedChatRequest, } from '../types' import { isTrustedAppRequest } from '../utils/request-auth' import { ConversationIdParamSchema } from '../utils/validation' @@ -33,6 +41,20 @@ interface ChatRouteDeps { activity?: ServerActivity acpRuntime?: AcpAgentRuntime conversationRuns?: ConversationRuns + /** Injectable so the hydration path is testable without a database. */ + providerStore?: ChatProviderLookup +} + +/** + * The one place a route reads provider credentials. + * + * The store's ordinary reads return a projection without them, so building an + * outbound model request has to ask for them by name. Anything else that + * reaches for this lookup is doing something it should not. + */ +const credentialedProviderLookup: ChatProviderLookup = { + get: (id) => dbProviderStore.getWithCredentials(id), + getDefault: () => dbProviderStore.getDefaultWithCredentials(), } // /chat deliberately exposes a plain Hono type. Its AI SDK stream payloads are @@ -58,11 +80,44 @@ export function createChatRoutes(deps: ChatRouteDeps): Hono { const app = new Hono() app.post('/', zValidator('json', ChatRequestSchema), async (c) => { - const request = c.req.valid('json') - const browserRequest = isBrowserOsChatRequest(request) ? request : null - if (!browserRequest && !isTrustedAppRequest(c)) { + const parsed = c.req.valid('json') + const parsedBrowserRequest = isBrowserOsChatRequest(parsed) ? parsed : null + if (!parsedBrowserRequest && !isTrustedAppRequest(c)) { return c.json({ error: 'Forbidden' }, 403) } + + // The provider configuration is filled from the stored row here rather than + // shipped on every message. A client from before this change sends it all + // inline and simply finds nothing to overlay. + let request: HydratedChatRequest + if (parsedBrowserRequest) { + const hydrated = await hydrateChatProvider( + parsedBrowserRequest, + deps.providerStore ?? credentialedProviderLookup, + ) + if (!hydrated.ok) return c.json({ error: hydrated.error }, 400) + // A browseros request is otherwise allowed without the app-origin check, + // on the reasoning that it carries its own credentials and so can only + // spend what the caller already held. + // + // Two things break that reasoning, and both have to be caught. Naming a + // stored provider has the server supply the key. So does naming one of + // the provider types the server credentials itself: the oauth three take + // a token from this machine's store and browseros takes the gateway + // credential, none of which the request carries. A caller that genuinely + // brought its own key is as unrestricted as it was before. + const usesServerCredentials = + hydrated.usedStoredProvider || + SERVER_CREDENTIALED_PROVIDERS.has(hydrated.request.provider) + if (usesServerCredentials && !isTrustedAppRequest(c)) { + return c.json({ error: 'Forbidden' }, 403) + } + request = hydrated.request + } else { + request = parsed as AcpChatRequest + } + const browserRequest = isBrowserOsChatRequest(request) ? request : null + const provider = browserRequest?.provider ?? request.target.type const model = browserRequest?.model const baseUrl = browserRequest?.baseUrl diff --git a/packages/browseros-agent/apps/server/src/api/routes/index.ts b/packages/browseros-agent/apps/server/src/api/routes/index.ts index 5a31270cb2..22f115494e 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/index.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/index.ts @@ -26,7 +26,10 @@ import { createMcpRoutes } from './mcp' import { createMcpManagerRoutes } from './mcp-manager' import { createOAuthRoutes } from './oauth' import { createProviderRoutes } from './provider' +import { createProvidersRoutes } from './providers' import { createRefinePromptRoutes } from './refine-prompt' +import { createScheduledJobRunRoutes } from './scheduled-job-runs' +import { createScheduledJobRoutes } from './scheduled-jobs' import { createShutdownRoute } from './shutdown' import { createStatusRoute } from './status' @@ -134,9 +137,19 @@ export function createApiRoutes(deps: CreateApiRoutesDeps) { .use('/acpx/probe/*', requireTrustedAppOrigin()) .use('/agents/*', requireTrustedAppOrigin()) .use('/conversations/*', requireTrustedAppOrigin()) + // These carry provider credentials in the clear, so they need the + // localhost + extension-origin check. The blanket requireTrustedOrigin + // above only rejects a request that carries a disallowed Origin header; + // one with no Origin at all passes it. + .use('/providers/*', requireTrustedAppOrigin()) + .use('/scheduled-jobs/*', requireTrustedAppOrigin()) + .use('/scheduled-job-runs/*', requireTrustedAppOrigin()) .route('/acpx/probe', createAcpxProbeRoutes({ resourcesDir })) .route('/agents', resolvedAgentRoutes) .route('/conversations', createConversationRoutes()) + .route('/providers', createProvidersRoutes()) + .route('/scheduled-jobs', createScheduledJobRoutes()) + .route('/scheduled-job-runs', createScheduledJobRunRoutes()) ) } diff --git a/packages/browseros-agent/apps/server/src/api/routes/providers.ts b/packages/browseros-agent/apps/server/src/api/routes/providers.ts new file mode 100644 index 0000000000..5ee1628e2f --- /dev/null +++ b/packages/browseros-agent/apps/server/src/api/routes/providers.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { z } from 'zod' +import { + dbProviderStore, + type ProviderStore, +} from '../../lib/providers/provider-store' +import type { Env } from '../types' + +const IdParamSchema = z.object({ providerId: z.string().min(1) }) + +const SetDefaultSchema = z.object({ providerId: z.string().min(1) }) + +/** + * Mirrors the extension's provider config. `id` comes from the client rather + * than the database so a provider keeps one identity across the extension, the + * migration and this table, which is what makes re-importing idempotent. + */ +const UpsertProviderSchema = z.object({ + profileId: z.string().nullish(), + type: z.string().min(1), + name: z.string().min(1), + baseUrl: z.string().nullish(), + modelId: z.string().min(1), + supportsImages: z.boolean().optional(), + contextWindow: z.number(), + temperature: z.number().optional(), + apiKey: z.string().nullish(), + accessKeyId: z.string().nullish(), + secretAccessKey: z.string().nullish(), + sessionToken: z.string().nullish(), + resourceName: z.string().nullish(), + region: z.string().nullish(), + reasoningEffort: z.string().nullish(), + reasoningSummary: z.string().nullish(), + createdAt: z.number().optional(), +}) + +/** + * Bulk one-time import from extension storage. + * + * Insert-if-absent, not upsert: the app writes to this table directly, so a + * second run must fill gaps without replacing a provider edited since. Each id + * comes back in exactly one of the two lists so the caller can report what was + * already present. + */ +const ImportProvidersSchema = z.object({ + providers: z.array(UpsertProviderSchema.extend({ id: z.string().min(1) })), +}) + +export function createProvidersRoutes(options: { store?: ProviderStore } = {}) { + const store = options.store ?? dbProviderStore + + return ( + new Hono() + .get('/', async (c) => c.json({ providers: await store.list() })) + // The one selected provider, of any kind. Kept ahead of /:providerId so + // the literal path is not read as an id. + .get('/default', async (c) => + c.json({ provider: await store.getDefault() }), + ) + .put('/default', zValidator('json', SetDefaultSchema), async (c) => { + const updated = await store.setDefault(c.req.valid('json').providerId) + if (!updated) return c.json({ error: 'Unknown provider' }, 404) + return c.json({ provider: await store.getDefault() }) + }) + .post('/import', zValidator('json', ImportProvidersSchema), async (c) => { + const imported: string[] = [] + const skipped: string[] = [] + for (const provider of c.req.valid('json').providers) { + const saved = await store.insertIfAbsent(provider) + ;(saved ? imported : skipped).push(provider.id) + } + return c.json({ imported, skipped }) + }) + .get('/:providerId', zValidator('param', IdParamSchema), async (c) => { + const provider = await store.get(c.req.valid('param').providerId) + if (!provider) return c.json({ error: 'Unknown provider' }, 404) + return c.json({ provider }) + }) + .put( + '/:providerId', + zValidator('param', IdParamSchema), + zValidator('json', UpsertProviderSchema), + async (c) => { + const provider = await store.upsert({ + ...c.req.valid('json'), + id: c.req.valid('param').providerId, + }) + return c.json({ provider }) + }, + ) + .delete('/:providerId', zValidator('param', IdParamSchema), async (c) => { + const deleted = await store.remove(c.req.valid('param').providerId) + if (!deleted) return c.json({ error: 'Unknown provider' }, 404) + return c.json({ success: true }) + }) + ) +} diff --git a/packages/browseros-agent/apps/server/src/api/routes/scheduled-job-runs.ts b/packages/browseros-agent/apps/server/src/api/routes/scheduled-job-runs.ts new file mode 100644 index 0000000000..60f7d3c090 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/api/routes/scheduled-job-runs.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { z } from 'zod' +import { + dbScheduledJobRunStore, + type ScheduledJobRunStore, +} from '../../lib/schedules/run-store' +import type { Env } from '../types' + +const IdParamSchema = z.object({ runId: z.string().min(1) }) + +const ToolCallSchema = z.object({ + id: z.string(), + name: z.string(), + input: z.unknown(), + output: z.unknown().optional(), + error: z.string().optional(), + timestamp: z.string(), +}) + +/** + * Timestamps arrive as epoch numbers. The extension holds ISO strings, so the + * conversion belongs on its side of this boundary, keeping the database + * consistent with the other tables here. + */ +const UpsertRunSchema = z.object({ + profileId: z.string().nullish(), + jobId: z.string().min(1), + status: z.enum(['running', 'completed', 'failed']), + startedAt: z.number(), + completedAt: z.number().nullish(), + result: z.string().nullish(), + finalResult: z.string().nullish(), + executionLog: z.string().nullish(), + toolCalls: z.array(ToolCallSchema).nullish(), + error: z.string().nullish(), + createdAt: z.number().optional(), +}) + +/** Bulk one-time import. Insert-if-absent, for the reason on the provider route. */ +const ImportRunsSchema = z.object({ + runs: z.array(UpsertRunSchema.extend({ id: z.string().min(1) })), +}) + +export function createScheduledJobRunRoutes( + options: { store?: ScheduledJobRunStore } = {}, +) { + const store = options.store ?? dbScheduledJobRunStore + + return new Hono() + .get('/', async (c) => c.json({ runs: await store.list() })) + .post('/import', zValidator('json', ImportRunsSchema), async (c) => { + const imported: string[] = [] + const skipped: string[] = [] + for (const run of c.req.valid('json').runs) { + const saved = await store.insertIfAbsent(run) + ;(saved ? imported : skipped).push(run.id) + } + return c.json({ imported, skipped }) + }) + .get('/:runId', zValidator('param', IdParamSchema), async (c) => { + const run = await store.get(c.req.valid('param').runId) + if (!run) return c.json({ error: 'Unknown run' }, 404) + return c.json({ run }) + }) + .put( + '/:runId', + zValidator('param', IdParamSchema), + zValidator('json', UpsertRunSchema), + async (c) => { + const run = await store.upsert({ + ...c.req.valid('json'), + id: c.req.valid('param').runId, + }) + // Every write, not just the first: a run is written twice, when it + // starts and when it finishes, and pruning is bounded and idempotent. + // The import path deliberately does not prune, so it stays additive. + await store.prune(run.jobId) + return c.json({ run }) + }, + ) + .delete('/:runId', zValidator('param', IdParamSchema), async (c) => { + const deleted = await store.remove(c.req.valid('param').runId) + if (!deleted) return c.json({ error: 'Unknown run' }, 404) + return c.json({ success: true }) + }) +} diff --git a/packages/browseros-agent/apps/server/src/api/routes/scheduled-jobs.ts b/packages/browseros-agent/apps/server/src/api/routes/scheduled-jobs.ts new file mode 100644 index 0000000000..b227a94f4b --- /dev/null +++ b/packages/browseros-agent/apps/server/src/api/routes/scheduled-jobs.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { z } from 'zod' +import { + dbScheduledJobStore, + type ScheduledJobStore, +} from '../../lib/schedules/schedule-store' +import type { Env } from '../types' + +const IdParamSchema = z.object({ jobId: z.string().min(1) }) + +/** + * Timestamps arrive as epoch numbers. The extension holds ISO strings today, + * so the conversion belongs on its side of this boundary, keeping the database + * consistent with the other tables here. + */ +const UpsertJobSchema = z.object({ + profileId: z.string().nullish(), + name: z.string().min(1), + query: z.string().min(1), + scheduleType: z.enum(['daily', 'hourly', 'minutes']), + scheduleTime: z.string().nullish(), + scheduleInterval: z.number().nullish(), + enabled: z.boolean().optional(), + providerId: z.string().nullish(), + lastRunAt: z.number().nullish(), + createdAt: z.number().optional(), +}) + +/** Bulk one-time import. Insert-if-absent, for the reason on the provider route. */ +const ImportJobsSchema = z.object({ + jobs: z.array(UpsertJobSchema.extend({ id: z.string().min(1) })), +}) + +export function createScheduledJobRoutes( + options: { store?: ScheduledJobStore } = {}, +) { + const store = options.store ?? dbScheduledJobStore + + return new Hono() + .get('/', async (c) => c.json({ jobs: await store.list() })) + .post('/import', zValidator('json', ImportJobsSchema), async (c) => { + const imported: string[] = [] + const skipped: string[] = [] + for (const job of c.req.valid('json').jobs) { + const saved = await store.insertIfAbsent(job) + ;(saved ? imported : skipped).push(job.id) + } + return c.json({ imported, skipped }) + }) + .get('/:jobId', zValidator('param', IdParamSchema), async (c) => { + const job = await store.get(c.req.valid('param').jobId) + if (!job) return c.json({ error: 'Unknown scheduled job' }, 404) + return c.json({ job }) + }) + .put( + '/:jobId', + zValidator('param', IdParamSchema), + zValidator('json', UpsertJobSchema), + async (c) => { + const job = await store.upsert({ + ...c.req.valid('json'), + id: c.req.valid('param').jobId, + }) + return c.json({ job }) + }, + ) + .delete('/:jobId', zValidator('param', IdParamSchema), async (c) => { + const deleted = await store.remove(c.req.valid('param').jobId) + if (!deleted) return c.json({ error: 'Unknown scheduled job' }, 404) + return c.json({ success: true }) + }) +} diff --git a/packages/browseros-agent/apps/server/src/api/services/chat-provider-config.ts b/packages/browseros-agent/apps/server/src/api/services/chat-provider-config.ts new file mode 100644 index 0000000000..78323932ce --- /dev/null +++ b/packages/browseros-agent/apps/server/src/api/services/chat-provider-config.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { LLMConfig } from '@browseros/shared/schemas/llm' +import type { ProviderRow } from '../../lib/db/schema' +import type { + BrowserOsChatRequest, + HydratedBrowserOsChatRequest, +} from '../types' + +export interface ChatProviderLookup { + get(id: string): Promise + getDefault(): Promise +} + +export type HydrationResult = + | { + ok: true + request: HydratedBrowserOsChatRequest + /** + * Whether the configuration came from a stored row rather than from the + * request. The caller has to gate on this: supplying the user's + * credentials is a privilege the request itself does not carry, where + * sending its own is not. + */ + usedStoredProvider: boolean + } + | { ok: false; error: string } + +function toLlmConfig( + row: ProviderRow, +): Partial & { model?: string } { + return { + provider: row.type as LLMConfig['provider'], + providerId: row.id, + model: row.modelId ?? undefined, + apiKey: row.apiKey ?? undefined, + baseUrl: row.baseUrl ?? undefined, + resourceName: row.resourceName ?? undefined, + region: row.region ?? undefined, + accessKeyId: row.accessKeyId ?? undefined, + secretAccessKey: row.secretAccessKey ?? undefined, + sessionToken: row.sessionToken ?? undefined, + reasoningEffort: row.reasoningEffort as LLMConfig['reasoningEffort'], + reasoningSummary: row.reasoningSummary as LLMConfig['reasoningSummary'], + } +} + +/** + * Fills a chat request's provider configuration from the stored row. + * + * The server owns the provider list and which one is selected, so a client only + * has to name an id, and with none given the selected provider is used. The + * row wins over anything sent inline, because it is the source of truth and a + * client may be holding a copy from before an edit. + * + * A request that names nothing the server knows keeps whatever it sent, which + * is how a client from before this change still works: it ships the whole + * configuration and never relies on the lookup. + */ +export async function hydrateChatProvider( + request: BrowserOsChatRequest, + store: ChatProviderLookup, +): Promise { + const namedId = request.target.providerId + const row = namedId ? await store.get(namedId) : await store.getDefault() + + if (row && row.kind !== 'llm') { + // Reached by naming an acp agent on the browseros path, or by having one + // selected while the client sends no target. Falling through would run the + // conversation on some other provider entirely. + return { + ok: false, + error: `Provider ${row.id} is a coding agent and cannot serve a browseros chat request`, + } + } + + const hydrated = row + ? { ...request, ...toLlmConfig(row) } + : { ...request, providerId: namedId } + + if (!hydrated.provider) { + return { + ok: false, + error: namedId + ? `Unknown provider ${namedId}` + : 'No provider given and none is selected', + } + } + + const providerId = row?.id ?? namedId + if (!providerId) { + return { ok: false, error: 'No provider given and none is selected' } + } + + return { + ok: true, + usedStoredProvider: row !== null, + request: { + ...hydrated, + provider: hydrated.provider, + contextWindowSize: row?.contextWindow ?? request.contextWindowSize, + supportsImages: row ? row.supportsImages : request.supportsImages, + target: { type: 'browseros', providerId }, + }, + } +} diff --git a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts index e447294605..61feb1e90d 100644 --- a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts @@ -50,8 +50,9 @@ import type { ServerActivity } from '../services/server-activity' import type { AcpChatRequest, BrowserContext, - BrowserOsChatRequest, ChatRequest, + HydratedBrowserOsChatRequest, + HydratedChatRequest, } from '../types' import { resolveBrowserContextPageIds } from '../utils/resolve-browser-context-page-ids' import { @@ -108,7 +109,7 @@ export class ChatService { } async processMessage( - request: ChatRequest, + request: HydratedChatRequest, _requestAbortSignal: AbortSignal, ): Promise { try { @@ -134,7 +135,7 @@ export class ChatService { } return await this.processBrowserOsMessage( - request as BrowserOsChatRequest, + request as HydratedBrowserOsChatRequest, abortSignal, updateMessages, ) @@ -179,7 +180,7 @@ export class ChatService { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: session changes and message persistence must share one ordered transaction private async processBrowserOsMessage( - request: BrowserOsChatRequest, + request: HydratedBrowserOsChatRequest, abortSignal: AbortSignal, updateMessages: (messages: UIMessage[]) => boolean, ): Promise> { diff --git a/packages/browseros-agent/apps/server/src/api/types.ts b/packages/browseros-agent/apps/server/src/api/types.ts index 4083387868..b63de54795 100644 --- a/packages/browseros-agent/apps/server/src/api/types.ts +++ b/packages/browseros-agent/apps/server/src/api/types.ts @@ -78,15 +78,33 @@ const ChatInputSchema = z.object({ .optional(), }) -const BrowserOsChatRequestSchema = AgentLLMConfigSchema.merge(ChatInputSchema) +/** + * The provider half of a chat request, now optional. + * + * The server holds the provider list and which one is selected, so a client + * only has to name an id, and need not even do that: with nothing given the + * selected provider is used. Every field stays accepted because the extension + * updates independently of the browser binary, so a shipped build can be + * running a client that still sends the whole configuration inline. The server + * can stop requiring these; it cannot stop accepting them. + */ +const OptionalAgentLLMConfigSchema = LLMConfigSchema.partial().extend({ + model: z.string().min(1).optional(), + upstreamProvider: z.string().optional(), +}) + +const BrowserOsChatRequestSchema = OptionalAgentLLMConfigSchema.merge( + ChatInputSchema, +) .extend({ - target: BrowserOsAgentTargetSchema.optional(), + target: BrowserOsAgentTargetSchema.partial({ providerId: true }).optional(), }) .transform((request) => ({ ...request, - target: request.target ?? { + target: { type: 'browseros' as const, - providerId: request.providerId || request.provider, + providerId: + request.target?.providerId || request.providerId || request.provider, }, })) @@ -101,6 +119,21 @@ export const ChatRequestSchema = z.union([ export type AcpChatRequest = z.infer export type BrowserOsChatRequest = z.infer + +/** + * A browseros request after its provider has been filled from the stored row. + * + * The wire shape leaves the provider optional so a client can send an id alone, + * or nothing at all. Everything past the route boundary needs it resolved, and + * this type is what says so rather than an assertion at the call site. + */ +export type HydratedBrowserOsChatRequest = BrowserOsChatRequest & { + provider: NonNullable + target: { type: 'browseros'; providerId: string } +} + +/** What the chat service works on: every provider already resolved. */ +export type HydratedChatRequest = AcpChatRequest | HydratedBrowserOsChatRequest export type ChatRequest = z.infer export type Env = { diff --git a/packages/browseros-agent/apps/server/src/lib/agents/storage/acp-agent-store.ts b/packages/browseros-agent/apps/server/src/lib/agents/storage/acp-agent-store.ts index 508dcaa26f..5d29d21311 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/storage/acp-agent-store.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/storage/acp-agent-store.ts @@ -1,8 +1,8 @@ import { randomUUID } from 'node:crypto' import { CustomAcpAgentConfigSchema } from '@browseros/shared/schemas/agent' -import { desc, eq } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { type BrowserOsDatabase, getDb } from '../../db' -import { type AcpAgentRow, acpAgents } from '../../db/schema' +import { type ProviderRow, providers } from '../../db/schema' import { logger } from '../../logger' import type { AcpAgentDefinition, @@ -38,6 +38,17 @@ export interface AcpAgentStore { delete(id: string): Promise } +/** + * ACP agents are rows in the unified providers table, distinguished by kind. + * + * The store keeps its own shape rather than folding into the provider store: + * agents are created with a generated id and updated field by field, where + * providers are upserted under an id the client already holds. Both are + * legitimate ways to reach the same table, and the shipped /agents contract + * depends on this one. + */ +const isAgent = eq(providers.kind, 'acp') + export class DbAcpAgentStore implements AcpAgentStore { private readonly db: BrowserOsDatabase private writeQueue: Promise = Promise.resolve() @@ -49,23 +60,30 @@ export class DbAcpAgentStore implements AcpAgentStore { async list(): Promise { return this.db .select() - .from(acpAgents) - .orderBy(desc(acpAgents.updatedAt)) + .from(providers) + .where(isAgent) + .orderBy(desc(providers.updatedAt)) .all() .map(toAcpAgentDefinition) } async get(id: string): Promise { const row = - this.db.select().from(acpAgents).where(eq(acpAgents.id, id)).get() ?? null + this.db + .select() + .from(providers) + .where(and(isAgent, eq(providers.id, id))) + .get() ?? null return row ? toAcpAgentDefinition(row) : null } async create(input: CreateAcpAgentInput): Promise { return this.withWriteLock(async () => { const now = Date.now() - const row: AcpAgentRow = { + const row = { id: randomUUID(), + kind: 'acp' as const, + profileId: null, name: input.name.trim(), type: input.type, modelId: optionalText(input.modelId), @@ -77,8 +95,11 @@ export class DbAcpAgentStore implements AcpAgentStore { createdAt: now, updatedAt: now, } - this.db.insert(acpAgents).values(row).run() - const agent = toAcpAgentDefinition(row) + // returning(), not the object built above: the unified table fills the + // columns only LLM providers use, so the row that lands is wider than + // what was inserted. + const saved = this.db.insert(providers).values(row).returning().get() + const agent = toAcpAgentDefinition(saved) logger.info('ACP agent created', { agentId: agent.id, type: agent.type, @@ -93,11 +114,14 @@ export class DbAcpAgentStore implements AcpAgentStore { ): Promise { return this.withWriteLock(async () => { const existing = - this.db.select().from(acpAgents).where(eq(acpAgents.id, id)).get() ?? - null + this.db + .select() + .from(providers) + .where(and(isAgent, eq(providers.id, id))) + .get() ?? null if (!existing) return null - const next: AcpAgentRow = { + const next: ProviderRow = { ...existing, name: input.name === undefined ? existing.name : input.name.trim(), modelId: @@ -118,7 +142,11 @@ export class DbAcpAgentStore implements AcpAgentStore { : JSON.stringify(input.customConfig), updatedAt: Date.now(), } - this.db.update(acpAgents).set(next).where(eq(acpAgents.id, id)).run() + this.db + .update(providers) + .set(next) + .where(and(isAgent, eq(providers.id, id))) + .run() logger.info('ACP agent updated', { agentId: id }) return toAcpAgentDefinition(next) }) @@ -127,7 +155,10 @@ export class DbAcpAgentStore implements AcpAgentStore { async delete(id: string): Promise { return this.withWriteLock(async () => { if (!(await this.get(id))) return false - this.db.delete(acpAgents).where(eq(acpAgents.id, id)).run() + this.db + .delete(providers) + .where(and(isAgent, eq(providers.id, id))) + .run() logger.info('ACP agent deleted', { agentId: id }) return true }) @@ -150,11 +181,11 @@ export function deriveAcpSessionKey( return `acp:${agentId}:${conversationId}` } -function toAcpAgentDefinition(row: AcpAgentRow): AcpAgentDefinition { +function toAcpAgentDefinition(row: ProviderRow): AcpAgentDefinition { return { id: row.id, name: row.name, - type: row.type, + type: row.type as AcpAgentType, modelId: row.modelId ?? undefined, reasoningEffort: row.reasoningEffort ?? undefined, workingDirectory: row.workingDirectory ?? undefined, diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts index 73ea64e71f..9b62d57892 100644 --- a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts +++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts @@ -19,6 +19,22 @@ import type { ResolvedLLMConfig } from './types' const CHATGPT_PROVIDER_DISPLAY_NAME = 'ChatGPT' +/** + * Provider types whose credentials come from the server, not the request. + * + * The OAuth three take a token from this machine's oauth store and browseros + * takes the gateway credential, so a request naming one of these spends + * something the caller never had to hold. Callers that gate on trust need to + * know that, and this set has to mirror the branches below exactly, which is + * why it lives beside them. + */ +export const SERVER_CREDENTIALED_PROVIDERS: ReadonlySet = new Set([ + LLM_PROVIDERS.CHATGPT_PRO, + LLM_PROVIDERS.GITHUB_COPILOT, + LLM_PROVIDERS.QWEN_CODE, + LLM_PROVIDERS.BROWSEROS, +]) + export async function resolveLLMConfig( config: LLMConfig, browserosId?: string, diff --git a/packages/browseros-agent/apps/server/src/lib/db/client.ts b/packages/browseros-agent/apps/server/src/lib/db/client.ts index 261aac90bb..8495a1055d 100644 --- a/packages/browseros-agent/apps/server/src/lib/db/client.ts +++ b/packages/browseros-agent/apps/server/src/lib/db/client.ts @@ -51,9 +51,12 @@ export function openBrowserOsDatabase(options: OpenDbOptions): DbHandle { if (migrationsDir) { migrate(db, { migrationsFolder: migrationsDir }) } else { - logger.warn('Drizzle migrations unavailable; bootstrapping current schema', { - dbPath: options.dbPath, - }) + logger.warn( + 'Drizzle migrations unavailable; bootstrapping current schema', + { + dbPath: options.dbPath, + }, + ) bootstrapCurrentSchema(sqlite) } } @@ -97,7 +100,9 @@ export function resolveMigrationsDir( /** Accepts only migration folders Drizzle can read without filesystem errors. */ function hasCompleteMigrationSet(migrationsDir: string): boolean { - const journal = readDrizzleJournal(join(migrationsDir, 'meta', '_journal.json')) + const journal = readDrizzleJournal( + join(migrationsDir, 'meta', '_journal.json'), + ) if (!journal) return false const journalTags = new Set(journal.entries.map((entry) => entry.tag)) @@ -216,30 +221,120 @@ const currentMigrationHistory = [ hash: '561eb1075d7487ffe0394e587eef7ba35ccd892e3e3b53acace579cb0477576b', createdAt: 1787580067090, }, + { + tag: '0008_add_llm_providers_and_scheduled_jobs', + hash: '1e36c60be880a222ae150858c5248a433556bd974c52164c42d1955e84ba6606', + createdAt: 1788319873053, + }, + { + tag: '0009_add_scheduled_job_runs', + hash: '188a9503d889be46926bd6d4d660a1c016c90fac71447c25eec73e421b90fc96', + createdAt: 1788413695569, + }, + { + tag: '0010_add_unified_providers_table', + hash: '9e5731582228e0de16bb28f5465cfd62ec2662822f43122f84b8039dd2c0cf0b', + createdAt: 1788426799725, + }, + { + tag: '0011_drop_split_provider_tables', + hash: 'eb0fa2687c80caf919248f28cda5cd955e01a671b2104308b4d04ec55d450611', + createdAt: 1788426855683, + }, ] // TODO(nikhil): Remove this fallback once Windows/Linux packaging always includes Drizzle migrations. const currentSchemaStatements = [ ` - CREATE TABLE IF NOT EXISTS acp_agents ( + CREATE TABLE IF NOT EXISTS providers ( id text PRIMARY KEY NOT NULL, - name text NOT NULL, + profile_id text, + kind text NOT NULL, type text NOT NULL, + name text NOT NULL, model_id text, reasoning_effort text, + is_default integer DEFAULT false NOT NULL, + created_at integer NOT NULL, + updated_at integer NOT NULL, + base_url text, + supports_images integer DEFAULT true NOT NULL, + context_window integer, + temperature real DEFAULT 0.2 NOT NULL, + api_key text, + access_key_id text, + secret_access_key text, + session_token text, + resource_name text, + region text, + reasoning_summary text, working_directory text, custom_config text, + CONSTRAINT "providers_llm_requires_model_and_context" CHECK("providers"."kind" <> 'llm' OR ("providers"."model_id" IS NOT NULL AND "providers"."context_window" IS NOT NULL)) + ) + `, + ` + CREATE INDEX IF NOT EXISTS providers_profile_id_idx + ON providers (profile_id) + `, + ` + CREATE INDEX IF NOT EXISTS providers_kind_updated_at_idx + ON providers (kind, updated_at) + `, + ` + CREATE UNIQUE INDEX IF NOT EXISTS providers_one_default + ON providers (is_default) WHERE "providers"."is_default" = 1 + `, + ` + CREATE TABLE IF NOT EXISTS scheduled_jobs ( + id text PRIMARY KEY NOT NULL, + profile_id text, + name text NOT NULL, + query text NOT NULL, + schedule_type text NOT NULL, + schedule_time text, + schedule_interval integer, + enabled integer DEFAULT true NOT NULL, + provider_id text, + last_run_at integer, created_at integer NOT NULL, - updated_at integer NOT NULL + updated_at integer NOT NULL, + FOREIGN KEY (provider_id) REFERENCES providers(id) ON UPDATE no action ON DELETE set null + ) + `, + ` + CREATE INDEX IF NOT EXISTS scheduled_jobs_profile_id_idx + ON scheduled_jobs (profile_id) + `, + ` + CREATE INDEX IF NOT EXISTS scheduled_jobs_enabled_idx + ON scheduled_jobs (enabled) + `, + ` + CREATE TABLE IF NOT EXISTS scheduled_job_runs ( + id text PRIMARY KEY NOT NULL, + profile_id text, + job_id text NOT NULL, + status text NOT NULL, + started_at integer NOT NULL, + completed_at integer, + result text, + final_result text, + execution_log text, + tool_calls text, + error text, + created_at integer NOT NULL, + updated_at integer NOT NULL, + FOREIGN KEY (job_id) REFERENCES scheduled_jobs(id) ON UPDATE no action ON DELETE cascade ) `, ` - CREATE INDEX IF NOT EXISTS acp_agents_updated_at_idx - ON acp_agents (updated_at) + CREATE INDEX IF NOT EXISTS scheduled_job_runs_job_id_idx + ON scheduled_job_runs (job_id) `, ` - CREATE INDEX IF NOT EXISTS acp_agents_type_updated_at_idx - ON acp_agents (type, updated_at) + CREATE INDEX IF NOT EXISTS scheduled_job_runs_started_at_idx + ON scheduled_job_runs (started_at) `, ` CREATE TABLE IF NOT EXISTS oauth_tokens ( diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/0008_add_llm_providers_and_scheduled_jobs.sql b/packages/browseros-agent/apps/server/src/lib/db/migrations/0008_add_llm_providers_and_scheduled_jobs.sql new file mode 100644 index 0000000000..edbc615398 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/0008_add_llm_providers_and_scheduled_jobs.sql @@ -0,0 +1,41 @@ +CREATE TABLE `llm_providers` ( + `id` text PRIMARY KEY NOT NULL, + `profile_id` text, + `type` text NOT NULL, + `name` text NOT NULL, + `base_url` text, + `model_id` text NOT NULL, + `supports_images` integer DEFAULT true NOT NULL, + `context_window` integer NOT NULL, + `temperature` real DEFAULT 0.2 NOT NULL, + `api_key` text, + `access_key_id` text, + `secret_access_key` text, + `session_token` text, + `resource_name` text, + `region` text, + `reasoning_effort` text, + `reasoning_summary` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `llm_providers_profile_id_idx` ON `llm_providers` (`profile_id`);--> statement-breakpoint +CREATE TABLE `scheduled_jobs` ( + `id` text PRIMARY KEY NOT NULL, + `profile_id` text, + `name` text NOT NULL, + `query` text NOT NULL, + `schedule_type` text NOT NULL, + `schedule_time` text, + `schedule_interval` integer, + `enabled` integer DEFAULT true NOT NULL, + `provider_id` text, + `last_run_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`provider_id`) REFERENCES `llm_providers`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `scheduled_jobs_profile_id_idx` ON `scheduled_jobs` (`profile_id`);--> statement-breakpoint +CREATE INDEX `scheduled_jobs_enabled_idx` ON `scheduled_jobs` (`enabled`); \ No newline at end of file diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/0009_add_scheduled_job_runs.sql b/packages/browseros-agent/apps/server/src/lib/db/migrations/0009_add_scheduled_job_runs.sql new file mode 100644 index 0000000000..18bfb0547b --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/0009_add_scheduled_job_runs.sql @@ -0,0 +1,19 @@ +CREATE TABLE `scheduled_job_runs` ( + `id` text PRIMARY KEY NOT NULL, + `profile_id` text, + `job_id` text NOT NULL, + `status` text NOT NULL, + `started_at` integer NOT NULL, + `completed_at` integer, + `result` text, + `final_result` text, + `execution_log` text, + `tool_calls` text, + `error` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`job_id`) REFERENCES `scheduled_jobs`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `scheduled_job_runs_job_id_idx` ON `scheduled_job_runs` (`job_id`);--> statement-breakpoint +CREATE INDEX `scheduled_job_runs_started_at_idx` ON `scheduled_job_runs` (`started_at`); \ No newline at end of file diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/0010_add_unified_providers_table.sql b/packages/browseros-agent/apps/server/src/lib/db/migrations/0010_add_unified_providers_table.sql new file mode 100644 index 0000000000..deca25311c --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/0010_add_unified_providers_table.sql @@ -0,0 +1,63 @@ +CREATE TABLE `providers` ( + `id` text PRIMARY KEY NOT NULL, + `profile_id` text, + `kind` text NOT NULL, + `type` text NOT NULL, + `name` text NOT NULL, + `model_id` text, + `reasoning_effort` text, + `is_default` integer DEFAULT false NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `base_url` text, + `supports_images` integer DEFAULT true NOT NULL, + `context_window` integer, + `temperature` real DEFAULT 0.2 NOT NULL, + `api_key` text, + `access_key_id` text, + `secret_access_key` text, + `session_token` text, + `resource_name` text, + `region` text, + `reasoning_summary` text, + `working_directory` text, + `custom_config` text, + CONSTRAINT "providers_llm_requires_model_and_context" CHECK("providers"."kind" <> 'llm' OR ("providers"."model_id" IS NOT NULL AND "providers"."context_window" IS NOT NULL)) +); +--> statement-breakpoint +CREATE INDEX `providers_profile_id_idx` ON `providers` (`profile_id`);--> statement-breakpoint +CREATE INDEX `providers_kind_updated_at_idx` ON `providers` (`kind`,`updated_at`);--> statement-breakpoint +CREATE UNIQUE INDEX `providers_one_default` ON `providers` (`is_default`) WHERE "providers"."is_default" = 1;--> statement-breakpoint +--- Copy both source tables in. Written by hand because drizzle generates +--- schema changes, not data moves. A colliding id across the two sources +--- fails the insert rather than silently dropping a row; ids are client +--- generated uuids plus the fixed 'browseros' literal, so a collision would +--- mean something is already wrong. +INSERT INTO `providers` ( + `id`, `profile_id`, `kind`, `type`, `name`, `model_id`, `reasoning_effort`, + `is_default`, `created_at`, `updated_at`, + `base_url`, `supports_images`, `context_window`, `temperature`, + `api_key`, `access_key_id`, `secret_access_key`, `session_token`, + `resource_name`, `region`, `reasoning_summary` +) +SELECT + `id`, `profile_id`, 'llm', `type`, `name`, `model_id`, `reasoning_effort`, + 0, `created_at`, `updated_at`, + `base_url`, `supports_images`, `context_window`, `temperature`, + `api_key`, `access_key_id`, `secret_access_key`, `session_token`, + `resource_name`, `region`, `reasoning_summary` +FROM `llm_providers`; +--> statement-breakpoint +--- ACP agents carry no profile, model context or credentials, so those stay +--- null or take the column default. The check constraint only requires a model +--- and a context window for kind = 'llm'. +INSERT INTO `providers` ( + `id`, `kind`, `type`, `name`, `model_id`, `reasoning_effort`, + `is_default`, `created_at`, `updated_at`, + `working_directory`, `custom_config` +) +SELECT + `id`, 'acp', `type`, `name`, `model_id`, `reasoning_effort`, + 0, `created_at`, `updated_at`, + `working_directory`, `custom_config` +FROM `acp_agents`; diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/0011_drop_split_provider_tables.sql b/packages/browseros-agent/apps/server/src/lib/db/migrations/0011_drop_split_provider_tables.sql new file mode 100644 index 0000000000..4ea9a7a835 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/0011_drop_split_provider_tables.sql @@ -0,0 +1,32 @@ +--- Order matters here, and differs from what drizzle generated. +--- +--- Dropping llm_providers while foreign keys are enforced fires the +--- ON DELETE SET NULL on scheduled_jobs.provider_id, so every job silently +--- loses the provider it was pointed at and falls back to the default on its +--- next run. The drops therefore happen inside the foreign_keys=OFF block and +--- after scheduled_jobs has been rebuilt against the new table. +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_scheduled_jobs` ( + `id` text PRIMARY KEY NOT NULL, + `profile_id` text, + `name` text NOT NULL, + `query` text NOT NULL, + `schedule_type` text NOT NULL, + `schedule_time` text, + `schedule_interval` integer, + `enabled` integer DEFAULT true NOT NULL, + `provider_id` text, + `last_run_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`provider_id`) REFERENCES `providers`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +INSERT INTO `__new_scheduled_jobs`("id", "profile_id", "name", "query", "schedule_type", "schedule_time", "schedule_interval", "enabled", "provider_id", "last_run_at", "created_at", "updated_at") SELECT "id", "profile_id", "name", "query", "schedule_type", "schedule_time", "schedule_interval", "enabled", "provider_id", "last_run_at", "created_at", "updated_at" FROM `scheduled_jobs`;--> statement-breakpoint +DROP TABLE `scheduled_jobs`;--> statement-breakpoint +ALTER TABLE `__new_scheduled_jobs` RENAME TO `scheduled_jobs`;--> statement-breakpoint +DROP TABLE `acp_agents`;--> statement-breakpoint +DROP TABLE `llm_providers`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `scheduled_jobs_profile_id_idx` ON `scheduled_jobs` (`profile_id`);--> statement-breakpoint +CREATE INDEX `scheduled_jobs_enabled_idx` ON `scheduled_jobs` (`enabled`); diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0008_snapshot.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000000..6cf73176a9 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,547 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "32fefd99-fdd9-49aa-b9b3-54f485b933c2", + "prevId": "573c3669-aa07-4cee-a4b2-ad109a6407b3", + "tables": { + "acp_agents": { + "name": "acp_agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "working_directory": { + "name": "working_directory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_config": { + "name": "custom_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "acp_agents_updated_at_idx": { + "name": "acp_agents_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "acp_agents_type_updated_at_idx": { + "name": "acp_agents_type_updated_at_idx", + "columns": [ + "type", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_message": { + "name": "last_user_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_messaged_at": { + "name": "last_messaged_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_last_messaged_at_idx": { + "name": "conversations_last_messaged_at_idx", + "columns": [ + "last_messaged_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "llm_providers": { + "name": "llm_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supports_images": { + "name": "supports_images", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0.2 + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_key_id": { + "name": "access_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_access_key": { + "name": "secret_access_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_summary": { + "name": "reasoning_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "llm_providers_profile_id_idx": { + "name": "llm_providers_profile_id_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_tokens": { + "name": "oauth_tokens", + "columns": { + "browseros_id": { + "name": "browseros_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_tokens_browseros_id_idx": { + "name": "oauth_tokens_browseros_id_idx", + "columns": [ + "browseros_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "oauth_tokens_browseros_id_provider_pk": { + "columns": [ + "browseros_id", + "provider" + ], + "name": "oauth_tokens_browseros_id_provider_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_jobs": { + "name": "scheduled_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_time": { + "name": "schedule_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scheduled_jobs_profile_id_idx": { + "name": "scheduled_jobs_profile_id_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + }, + "scheduled_jobs_enabled_idx": { + "name": "scheduled_jobs_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "scheduled_jobs_provider_id_llm_providers_id_fk": { + "name": "scheduled_jobs_provider_id_llm_providers_id_fk", + "tableFrom": "scheduled_jobs", + "tableTo": "llm_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0009_snapshot.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000000..09ef43c3db --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0009_snapshot.json @@ -0,0 +1,677 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "fc4dc284-30c2-478e-ac1e-677d0c078794", + "prevId": "32fefd99-fdd9-49aa-b9b3-54f485b933c2", + "tables": { + "acp_agents": { + "name": "acp_agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "working_directory": { + "name": "working_directory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_config": { + "name": "custom_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "acp_agents_updated_at_idx": { + "name": "acp_agents_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "acp_agents_type_updated_at_idx": { + "name": "acp_agents_type_updated_at_idx", + "columns": [ + "type", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_message": { + "name": "last_user_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_messaged_at": { + "name": "last_messaged_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_last_messaged_at_idx": { + "name": "conversations_last_messaged_at_idx", + "columns": [ + "last_messaged_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "llm_providers": { + "name": "llm_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supports_images": { + "name": "supports_images", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0.2 + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_key_id": { + "name": "access_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_access_key": { + "name": "secret_access_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_summary": { + "name": "reasoning_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "llm_providers_profile_id_idx": { + "name": "llm_providers_profile_id_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_tokens": { + "name": "oauth_tokens", + "columns": { + "browseros_id": { + "name": "browseros_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_tokens_browseros_id_idx": { + "name": "oauth_tokens_browseros_id_idx", + "columns": [ + "browseros_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "oauth_tokens_browseros_id_provider_pk": { + "columns": [ + "browseros_id", + "provider" + ], + "name": "oauth_tokens_browseros_id_provider_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_job_runs": { + "name": "scheduled_job_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_result": { + "name": "final_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_log": { + "name": "execution_log", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scheduled_job_runs_job_id_idx": { + "name": "scheduled_job_runs_job_id_idx", + "columns": [ + "job_id" + ], + "isUnique": false + }, + "scheduled_job_runs_started_at_idx": { + "name": "scheduled_job_runs_started_at_idx", + "columns": [ + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "scheduled_job_runs_job_id_scheduled_jobs_id_fk": { + "name": "scheduled_job_runs_job_id_scheduled_jobs_id_fk", + "tableFrom": "scheduled_job_runs", + "tableTo": "scheduled_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_jobs": { + "name": "scheduled_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_time": { + "name": "schedule_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scheduled_jobs_profile_id_idx": { + "name": "scheduled_jobs_profile_id_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + }, + "scheduled_jobs_enabled_idx": { + "name": "scheduled_jobs_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "scheduled_jobs_provider_id_llm_providers_id_fk": { + "name": "scheduled_jobs_provider_id_llm_providers_id_fk", + "tableFrom": "scheduled_jobs", + "tableTo": "llm_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0010_snapshot.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000000..d87d87f9fc --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,880 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d77129b9-b676-47df-a674-db84f0e5ca7d", + "prevId": "fc4dc284-30c2-478e-ac1e-677d0c078794", + "tables": { + "acp_agents": { + "name": "acp_agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "working_directory": { + "name": "working_directory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_config": { + "name": "custom_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "acp_agents_updated_at_idx": { + "name": "acp_agents_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "acp_agents_type_updated_at_idx": { + "name": "acp_agents_type_updated_at_idx", + "columns": [ + "type", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_message": { + "name": "last_user_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_messaged_at": { + "name": "last_messaged_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_last_messaged_at_idx": { + "name": "conversations_last_messaged_at_idx", + "columns": [ + "last_messaged_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "llm_providers": { + "name": "llm_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supports_images": { + "name": "supports_images", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0.2 + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_key_id": { + "name": "access_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_access_key": { + "name": "secret_access_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_summary": { + "name": "reasoning_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "llm_providers_profile_id_idx": { + "name": "llm_providers_profile_id_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_tokens": { + "name": "oauth_tokens", + "columns": { + "browseros_id": { + "name": "browseros_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_tokens_browseros_id_idx": { + "name": "oauth_tokens_browseros_id_idx", + "columns": [ + "browseros_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "oauth_tokens_browseros_id_provider_pk": { + "columns": [ + "browseros_id", + "provider" + ], + "name": "oauth_tokens_browseros_id_provider_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "providers": { + "name": "providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supports_images": { + "name": "supports_images", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0.2 + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_key_id": { + "name": "access_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_access_key": { + "name": "secret_access_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_summary": { + "name": "reasoning_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "working_directory": { + "name": "working_directory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_config": { + "name": "custom_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "providers_profile_id_idx": { + "name": "providers_profile_id_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + }, + "providers_kind_updated_at_idx": { + "name": "providers_kind_updated_at_idx", + "columns": [ + "kind", + "updated_at" + ], + "isUnique": false + }, + "providers_one_default": { + "name": "providers_one_default", + "columns": [ + "is_default" + ], + "isUnique": true, + "where": "\"providers\".\"is_default\" = 1" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "providers_llm_requires_model_and_context": { + "name": "providers_llm_requires_model_and_context", + "value": "\"providers\".\"kind\" <> 'llm' OR (\"providers\".\"model_id\" IS NOT NULL AND \"providers\".\"context_window\" IS NOT NULL)" + } + } + }, + "scheduled_job_runs": { + "name": "scheduled_job_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_result": { + "name": "final_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_log": { + "name": "execution_log", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scheduled_job_runs_job_id_idx": { + "name": "scheduled_job_runs_job_id_idx", + "columns": [ + "job_id" + ], + "isUnique": false + }, + "scheduled_job_runs_started_at_idx": { + "name": "scheduled_job_runs_started_at_idx", + "columns": [ + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "scheduled_job_runs_job_id_scheduled_jobs_id_fk": { + "name": "scheduled_job_runs_job_id_scheduled_jobs_id_fk", + "tableFrom": "scheduled_job_runs", + "tableTo": "scheduled_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_jobs": { + "name": "scheduled_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_time": { + "name": "schedule_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scheduled_jobs_profile_id_idx": { + "name": "scheduled_jobs_profile_id_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + }, + "scheduled_jobs_enabled_idx": { + "name": "scheduled_jobs_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "scheduled_jobs_provider_id_llm_providers_id_fk": { + "name": "scheduled_jobs_provider_id_llm_providers_id_fk", + "tableFrom": "scheduled_jobs", + "tableTo": "llm_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0011_snapshot.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000000..fd23904ba9 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,638 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e20078ea-5f98-4bb8-9bf6-f02a2be1badc", + "prevId": "d77129b9-b676-47df-a674-db84f0e5ca7d", + "tables": { + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_message": { + "name": "last_user_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_messaged_at": { + "name": "last_messaged_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_last_messaged_at_idx": { + "name": "conversations_last_messaged_at_idx", + "columns": [ + "last_messaged_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_tokens": { + "name": "oauth_tokens", + "columns": { + "browseros_id": { + "name": "browseros_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_tokens_browseros_id_idx": { + "name": "oauth_tokens_browseros_id_idx", + "columns": [ + "browseros_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "oauth_tokens_browseros_id_provider_pk": { + "columns": [ + "browseros_id", + "provider" + ], + "name": "oauth_tokens_browseros_id_provider_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "providers": { + "name": "providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supports_images": { + "name": "supports_images", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0.2 + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_key_id": { + "name": "access_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_access_key": { + "name": "secret_access_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_summary": { + "name": "reasoning_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "working_directory": { + "name": "working_directory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_config": { + "name": "custom_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "providers_profile_id_idx": { + "name": "providers_profile_id_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + }, + "providers_kind_updated_at_idx": { + "name": "providers_kind_updated_at_idx", + "columns": [ + "kind", + "updated_at" + ], + "isUnique": false + }, + "providers_one_default": { + "name": "providers_one_default", + "columns": [ + "is_default" + ], + "isUnique": true, + "where": "\"providers\".\"is_default\" = 1" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "providers_llm_requires_model_and_context": { + "name": "providers_llm_requires_model_and_context", + "value": "\"providers\".\"kind\" <> 'llm' OR (\"providers\".\"model_id\" IS NOT NULL AND \"providers\".\"context_window\" IS NOT NULL)" + } + } + }, + "scheduled_job_runs": { + "name": "scheduled_job_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_result": { + "name": "final_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_log": { + "name": "execution_log", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scheduled_job_runs_job_id_idx": { + "name": "scheduled_job_runs_job_id_idx", + "columns": [ + "job_id" + ], + "isUnique": false + }, + "scheduled_job_runs_started_at_idx": { + "name": "scheduled_job_runs_started_at_idx", + "columns": [ + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "scheduled_job_runs_job_id_scheduled_jobs_id_fk": { + "name": "scheduled_job_runs_job_id_scheduled_jobs_id_fk", + "tableFrom": "scheduled_job_runs", + "tableTo": "scheduled_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_jobs": { + "name": "scheduled_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_time": { + "name": "schedule_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scheduled_jobs_profile_id_idx": { + "name": "scheduled_jobs_profile_id_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + }, + "scheduled_jobs_enabled_idx": { + "name": "scheduled_jobs_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "scheduled_jobs_provider_id_providers_id_fk": { + "name": "scheduled_jobs_provider_id_providers_id_fk", + "tableFrom": "scheduled_jobs", + "tableTo": "providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/_journal.json b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/_journal.json index cb49a4a8d1..4cf5c4f079 100644 --- a/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/_journal.json +++ b/packages/browseros-agent/apps/server/src/lib/db/migrations/meta/_journal.json @@ -57,6 +57,34 @@ "when": 1787580067090, "tag": "0007_add_custom_acp_agents", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1788319873053, + "tag": "0008_add_llm_providers_and_scheduled_jobs", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1788413695569, + "tag": "0009_add_scheduled_job_runs", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1788426799725, + "tag": "0010_add_unified_providers_table", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1788426855683, + "tag": "0011_drop_split_provider_tables", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/agents.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/agents.ts deleted file mode 100644 index fa50c8a8f5..0000000000 --- a/packages/browseros-agent/apps/server/src/lib/db/schema/agents.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @license - * Copyright 2025 BrowserOS - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' -import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' - -export const acpAgents = sqliteTable( - 'acp_agents', - { - id: text('id').primaryKey(), - name: text('name').notNull(), - type: text('type', { enum: ['claude', 'codex', 'custom'] }).notNull(), - modelId: text('model_id'), - reasoningEffort: text('reasoning_effort'), - workingDirectory: text('working_directory'), - customConfig: text('custom_config'), - createdAt: integer('created_at').notNull(), - updatedAt: integer('updated_at').notNull(), - }, - (table) => [ - index('acp_agents_updated_at_idx').on(table.updatedAt), - index('acp_agents_type_updated_at_idx').on(table.type, table.updatedAt), - ], -) - -export type AcpAgentRow = InferSelectModel -export type NewAcpAgentRow = InferInsertModel diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/index.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/index.ts index 79cac79fdf..978b4e5d67 100644 --- a/packages/browseros-agent/apps/server/src/lib/db/schema/index.ts +++ b/packages/browseros-agent/apps/server/src/lib/db/schema/index.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -export * from './agents' export * from './conversations' export * from './oauth' +export * from './providers' +export * from './scheduled-job-runs' +export * from './scheduled-jobs' diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/providers.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/providers.ts new file mode 100644 index 0000000000..fe86cf4254 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/db/schema/providers.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' +import { sql } from 'drizzle-orm' +import { + check, + index, + integer, + real, + sqliteTable, + text, + uniqueIndex, +} from 'drizzle-orm/sqlite-core' + +/** + * Everything the chat can be pointed at, in one table. + * + * LLM providers and ACP agents were separate tables, but both are simply + * providers for a conversation and everything above the database already said + * so: the chat target is one union with a kind on the client, and the wire + * target has always been a discriminated union. Keeping them apart meant two + * selection pointers that could disagree, and a scheduled job that could only + * ever reference an LLM provider. + * + * `kind` carries the distinction. Columns that only one kind uses are nullable + * and grouped below, with the per-kind requirements held by a check constraint + * rather than by the column definitions. + * + * `profileId` is reserved and currently always null, as in the other tables. + */ +export const providers = sqliteTable( + 'providers', + { + id: text('id').primaryKey(), + profileId: text('profile_id'), + kind: text('kind', { enum: ['llm', 'acp'] }).notNull(), + type: text('type').notNull(), + name: text('name').notNull(), + /** Required for an llm provider, optional for an acp agent. */ + modelId: text('model_id'), + reasoningEffort: text('reasoning_effort'), + /** At most one row may set this; the unique index below enforces it. */ + isDefault: integer('is_default', { mode: 'boolean' }) + .notNull() + .default(false), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + + baseUrl: text('base_url'), + supportsImages: integer('supports_images', { mode: 'boolean' }) + .notNull() + .default(true), + contextWindow: integer('context_window'), + // Real, not integer: the default is 0.2 and an integer column floors it to 0. + temperature: real('temperature').notNull().default(0.2), + apiKey: text('api_key'), + accessKeyId: text('access_key_id'), + secretAccessKey: text('secret_access_key'), + sessionToken: text('session_token'), + resourceName: text('resource_name'), + region: text('region'), + reasoningSummary: text('reasoning_summary'), + + workingDirectory: text('working_directory'), + customConfig: text('custom_config'), + }, + (table) => [ + index('providers_profile_id_idx').on(table.profileId), + index('providers_kind_updated_at_idx').on(table.kind, table.updatedAt), + // Every row in this index has is_default = 1, so uniqueness on that single + // column admits exactly one default row. + // + // Deliberately not keyed by profile_id. SQLite treats NULLs as distinct in + // a unique index, so a (profile_id, is_default) pair would let every row be + // default at once while profile_id is unset, which is its state today. The + // per-profile form belongs in the migration that starts populating + // profile_id, not in this one. + uniqueIndex('providers_one_default') + .on(table.isDefault) + .where(sql`${table.isDefault} = 1`), + check( + 'providers_llm_requires_model_and_context', + sql`${table.kind} <> 'llm' OR (${table.modelId} IS NOT NULL AND ${table.contextWindow} IS NOT NULL)`, + ), + ], +) + +export type ProviderRow = InferSelectModel +export type NewProviderRow = InferInsertModel diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-job-runs.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-job-runs.ts new file mode 100644 index 0000000000..20a777e5cc --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-job-runs.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { scheduledJobs } from './scheduled-jobs' + +/** + * One tool invocation recorded during a run, as the extension shapes it. + * + * `input` is optional here where the extension has it required. An `unknown` + * already admits undefined, so the two describe the same values, and zod infers + * a key of that type as optional. Matching the validator keeps this honest + * rather than asserting the difference away at the route boundary. + */ +export interface ToolCallExecution { + id: string + name: string + input?: unknown + output?: unknown + error?: string + timestamp: string +} + +/** + * History of scheduled job executions. + * + * Cascades on job delete, unlike the job to provider reference which is + * `set null`. A job whose provider was removed is a job needing attention; a + * run whose job was removed means nothing, and deleting a job already removed + * its runs before this table existed. + * + * Timestamps are epoch integers here while the extension holds ISO strings, + * matching the other tables. The route layer converts. + */ +export const scheduledJobRuns = sqliteTable( + 'scheduled_job_runs', + { + id: text('id').primaryKey(), + profileId: text('profile_id'), + jobId: text('job_id') + .notNull() + .references(() => scheduledJobs.id, { onDelete: 'cascade' }), + status: text('status', { + enum: ['running', 'completed', 'failed'], + }).notNull(), + startedAt: integer('started_at').notNull(), + completedAt: integer('completed_at'), + result: text('result'), + finalResult: text('final_result'), + executionLog: text('execution_log'), + toolCalls: text('tool_calls', { mode: 'json' }).$type< + ToolCallExecution[] + >(), + error: text('error'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + }, + (table) => [ + index('scheduled_job_runs_job_id_idx').on(table.jobId), + index('scheduled_job_runs_started_at_idx').on(table.startedAt), + ], +) + +export type ScheduledJobRunRow = InferSelectModel +export type NewScheduledJobRunRow = InferInsertModel diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-jobs.ts b/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-jobs.ts new file mode 100644 index 0000000000..f7a48d4dc9 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/db/schema/scheduled-jobs.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { providers } from './providers' + +/** + * Scheduled jobs, mirroring the shape the extension holds today. + * + * `providerId` points at the unified providers table, so a job can target an + * ACP agent as readily as an LLM provider. While the two were separate tables + * this reference could only ever name an LLM provider. + * + * The reference is deliberately not a foreign key with a cascade: a job whose + * provider was deleted should surface as a job needing attention, not vanish + * silently on a delete the user made elsewhere. + * + * Timestamps are epoch integers here while the extension holds ISO strings. + * The database is internally consistent this way, and the route layer converts. + */ +export const scheduledJobs = sqliteTable( + 'scheduled_jobs', + { + id: text('id').primaryKey(), + profileId: text('profile_id'), + name: text('name').notNull(), + query: text('query').notNull(), + scheduleType: text('schedule_type', { + enum: ['daily', 'hourly', 'minutes'], + }).notNull(), + scheduleTime: text('schedule_time'), + scheduleInterval: integer('schedule_interval'), + enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), + providerId: text('provider_id').references(() => providers.id, { + onDelete: 'set null', + }), + lastRunAt: integer('last_run_at'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + }, + (table) => [ + index('scheduled_jobs_profile_id_idx').on(table.profileId), + index('scheduled_jobs_enabled_idx').on(table.enabled), + ], +) + +export type ScheduledJobRow = InferSelectModel +export type NewScheduledJobRow = InferInsertModel diff --git a/packages/browseros-agent/apps/server/src/lib/providers/provider-store.ts b/packages/browseros-agent/apps/server/src/lib/providers/provider-store.ts new file mode 100644 index 0000000000..1a283c8caa --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/providers/provider-store.ts @@ -0,0 +1,264 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { and, eq, ne, sql } from 'drizzle-orm' +import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core' +import { getDb } from '../db' +import { type NewProviderRow, type ProviderRow, providers } from '../db/schema' + +/** + * Every column except the four that hold secrets, plus flags saying whether + * each is set so the UI can still show that a key exists. + * + * Drizzle has no view, but naming the columns gives the same guarantee: a + * caller of the public reads cannot receive a credential even by accident, + * where a `select()` would hand them out on every list, get and default. + */ +function isSet(column: AnySQLiteColumn) { + return sql`${column} IS NOT NULL AND ${column} <> ''`.mapWith( + Boolean, + ) +} + +const publicColumns = { + id: providers.id, + profileId: providers.profileId, + kind: providers.kind, + type: providers.type, + name: providers.name, + modelId: providers.modelId, + reasoningEffort: providers.reasoningEffort, + isDefault: providers.isDefault, + createdAt: providers.createdAt, + updatedAt: providers.updatedAt, + baseUrl: providers.baseUrl, + supportsImages: providers.supportsImages, + contextWindow: providers.contextWindow, + temperature: providers.temperature, + resourceName: providers.resourceName, + region: providers.region, + reasoningSummary: providers.reasoningSummary, + workingDirectory: providers.workingDirectory, + customConfig: providers.customConfig, + // Empty counts as unset, matching what the upsert treats as not supplied. + // Otherwise a blank field would read back as a stored credential. + hasApiKey: isSet(providers.apiKey), + hasAccessKeyId: isSet(providers.accessKeyId), + hasSecretAccessKey: isSet(providers.secretAccessKey), + hasSessionToken: isSet(providers.sessionToken), +} + +export type PublicProviderRow = { + [K in keyof typeof publicColumns]: K extends `has${string}` + ? boolean + : ProviderRow[Extract] +} + +/** + * The store stamps `updatedAt` and defaults `createdAt`, so callers supply + * neither. `createdAt` stays optional so an import can preserve the original + * creation time when it has one. + */ +export type ProviderUpsert = Omit< + NewProviderRow, + 'updatedAt' | 'createdAt' | 'kind' +> & { + createdAt?: number +} + +export interface ProviderStore { + /** Every provider, whatever its kind, without credentials. */ + list(): Promise + /** Only the LLM providers, without credentials. */ + listLlm(): Promise + get(id: string): Promise + /** + * The full row, credentials included. Only for callers inside the server + * that have to build an outbound request, never for a route response. + */ + getWithCredentials(id: string): Promise + /** Insert or replace by id. This is the app's ordinary write path. */ + upsert(row: ProviderUpsert): Promise + /** + * Insert only when the id is absent; returns null when a row already exists. + * + * The one-time import uses this rather than `upsert` because the app writes + * to this table directly as well. A second import run must never replace a + * provider the user has edited since with the stale copy still sitting in + * extension storage. + */ + insertIfAbsent(row: ProviderUpsert): Promise + remove(id: string): Promise + /** The one selected provider, of any kind, or null when none is set. */ + getDefault(): Promise + /** The selected provider with its credentials, for the same callers as above. */ + getDefaultWithCredentials(): Promise + /** + * Points the default at one provider of any kind. Returns false when the id + * is unknown, so a stale pointer cannot be stored. + */ + setDefault(id: string): Promise +} + +async function list(): Promise { + return getDb().select(publicColumns).from(providers).all() +} + +async function listLlm(): Promise { + return getDb() + .select(publicColumns) + .from(providers) + .where(eq(providers.kind, 'llm')) + .all() +} + +async function get(id: string): Promise { + const [row] = await getDb() + .select(publicColumns) + .from(providers) + .where(eq(providers.id, id)) + .limit(1) + return row ?? null +} + +async function getWithCredentials(id: string): Promise { + const [row] = await getDb() + .select() + .from(providers) + .where(eq(providers.id, id)) + .limit(1) + return row ?? null +} + +const CREDENTIAL_FIELDS = [ + 'apiKey', + 'accessKeyId', + 'secretAccessKey', + 'sessionToken', +] as const + +/** + * Drops credential fields the caller did not supply, so they keep their stored + * value. + * + * Reads no longer return credentials, so a client editing a provider cannot + * send back what it never received. A plain whole-row upsert would then write + * over a working key on every rename. + * + * An empty string counts as not supplied, not as an instruction to clear. A + * form field that was never filled in submits as `''` rather than undefined, + * so treating the two differently would wipe the key on exactly the edit this + * exists to protect. Clearing is deliberate and explicit: send null. + */ +function withoutAbsentCredentials>( + row: T, +): Partial { + const next: Record = { ...row } + for (const field of CREDENTIAL_FIELDS) { + if (next[field] === undefined || next[field] === '') delete next[field] + } + return next as Partial +} + +async function upsert(row: ProviderUpsert): Promise { + const now = Date.now() + const values = { ...row, kind: 'llm' as const } + const [saved] = await getDb() + .insert(providers) + .values({ ...values, createdAt: row.createdAt ?? now, updatedAt: now }) + .onConflictDoUpdate({ + target: providers.id, + // createdAt is deliberately absent: re-importing a provider must not + // rewrite when the user originally created it. isDefault likewise, so a + // save does not silently move the selection. + set: { + ...withoutAbsentCredentials(values), + createdAt: undefined, + isDefault: undefined, + updatedAt: now, + }, + }) + .returning() + return saved +} + +async function insertIfAbsent( + row: ProviderUpsert, +): Promise { + const now = Date.now() + // onConflictDoNothing returns no row on conflict, so the absent/present + // decision and the write are one statement rather than a select then insert. + const [saved] = await getDb() + .insert(providers) + .values({ + ...row, + kind: 'llm' as const, + createdAt: row.createdAt ?? now, + updatedAt: now, + }) + .onConflictDoNothing({ target: providers.id }) + .returning() + return saved ?? null +} + +async function remove(id: string): Promise { + const deleted = await getDb() + .delete(providers) + .where(eq(providers.id, id)) + .returning({ id: providers.id }) + return deleted.length > 0 +} + +async function getDefault(): Promise { + const [row] = await getDb() + .select(publicColumns) + .from(providers) + .where(eq(providers.isDefault, true)) + .limit(1) + return row ?? null +} + +async function getDefaultWithCredentials(): Promise { + const [row] = await getDb() + .select() + .from(providers) + .where(eq(providers.isDefault, true)) + .limit(1) + return row ?? null +} + +async function setDefault(id: string): Promise { + const target = await get(id) + if (!target) return false + + // Clearing first is required, not tidiness: a partial unique index allows one + // row with is_default = 1, so setting the new one before clearing the old + // would violate it. + return getDb().transaction((tx) => { + tx.update(providers) + .set({ isDefault: false }) + .where(and(eq(providers.isDefault, true), ne(providers.id, id))) + .run() + tx.update(providers) + .set({ isDefault: true }) + .where(eq(providers.id, id)) + .run() + return true + }) +} + +export const dbProviderStore: ProviderStore = { + list, + listLlm, + get, + getWithCredentials, + upsert, + insertIfAbsent, + remove, + getDefault, + getDefaultWithCredentials, + setDefault, +} diff --git a/packages/browseros-agent/apps/server/src/lib/schedules/run-store.ts b/packages/browseros-agent/apps/server/src/lib/schedules/run-store.ts new file mode 100644 index 0000000000..a5d76c064f --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/schedules/run-store.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { desc, eq, inArray } from 'drizzle-orm' +import { getDb } from '../db' +import { + type NewScheduledJobRunRow, + type ScheduledJobRunRow, + scheduledJobRuns, +} from '../db/schema' + +/** + * The store stamps `updatedAt` and defaults `createdAt`, so callers supply + * neither. `createdAt` stays optional so an import can preserve the original + * creation time when it has one. + */ +export type ScheduledJobRunUpsert = Omit< + NewScheduledJobRunRow, + 'updatedAt' | 'createdAt' +> & { + createdAt?: number +} + +/** + * Runs kept per job. The extension applied this cap when it owned the history, + * trimming as it created each run; keeping the number here means it holds + * however the run was written rather than only on the path that happened to + * enforce it. + */ +export const MAX_RUNS_PER_JOB = 15 + +export interface ScheduledJobRunStore { + list(): Promise + get(id: string): Promise + /** Insert or replace by id. A run is written once when it starts and again + * when it finishes, so this is the ordinary write path. */ + upsert(row: ScheduledJobRunUpsert): Promise + /** Insert only when the id is absent; returns null when a row already + * exists. Used by the one-time import for the reason on the provider store. */ + insertIfAbsent(row: ScheduledJobRunUpsert): Promise + remove(id: string): Promise + /** Drops all but the newest `keep` runs of a job. Returns how many went. */ + prune(jobId: string, keep?: number): Promise +} + +async function list(): Promise { + return getDb() + .select() + .from(scheduledJobRuns) + .orderBy(desc(scheduledJobRuns.startedAt)) + .all() +} + +async function get(id: string): Promise { + const [row] = await getDb() + .select() + .from(scheduledJobRuns) + .where(eq(scheduledJobRuns.id, id)) + .limit(1) + return row ?? null +} + +async function upsert(row: ScheduledJobRunUpsert): Promise { + const now = Date.now() + const [saved] = await getDb() + .insert(scheduledJobRuns) + .values({ ...row, createdAt: row.createdAt ?? now, updatedAt: now }) + .onConflictDoUpdate({ + target: scheduledJobRuns.id, + set: { ...row, createdAt: undefined, updatedAt: now }, + }) + .returning() + return saved +} + +async function insertIfAbsent( + row: ScheduledJobRunUpsert, +): Promise { + const now = Date.now() + const [saved] = await getDb() + .insert(scheduledJobRuns) + .values({ ...row, createdAt: row.createdAt ?? now, updatedAt: now }) + .onConflictDoNothing({ target: scheduledJobRuns.id }) + .returning() + return saved ?? null +} + +async function prune( + jobId: string, + keep: number = MAX_RUNS_PER_JOB, +): Promise { + const rows = await getDb() + .select({ id: scheduledJobRuns.id }) + .from(scheduledJobRuns) + .where(eq(scheduledJobRuns.jobId, jobId)) + .orderBy(desc(scheduledJobRuns.startedAt)) + .all() + + const stale = rows.slice(keep).map((row) => row.id) + if (stale.length === 0) return 0 + + await getDb() + .delete(scheduledJobRuns) + .where(inArray(scheduledJobRuns.id, stale)) + return stale.length +} + +async function remove(id: string): Promise { + const deleted = await getDb() + .delete(scheduledJobRuns) + .where(eq(scheduledJobRuns.id, id)) + .returning({ id: scheduledJobRuns.id }) + return deleted.length > 0 +} + +export const dbScheduledJobRunStore: ScheduledJobRunStore = { + list, + get, + upsert, + insertIfAbsent, + remove, + prune, +} diff --git a/packages/browseros-agent/apps/server/src/lib/schedules/schedule-store.ts b/packages/browseros-agent/apps/server/src/lib/schedules/schedule-store.ts new file mode 100644 index 0000000000..dbbb24be27 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/schedules/schedule-store.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { eq } from 'drizzle-orm' +import { getDb } from '../db' +import { + type NewScheduledJobRow, + type ScheduledJobRow, + scheduledJobs, +} from '../db/schema' + +/** + * The store stamps `updatedAt` and defaults `createdAt`, so callers supply + * neither. `createdAt` stays optional so an import can preserve the original + * creation time when it has one. + */ +export type ScheduledJobUpsert = Omit< + NewScheduledJobRow, + 'updatedAt' | 'createdAt' +> & { + createdAt?: number +} + +export interface ScheduledJobStore { + list(): Promise + get(id: string): Promise + /** Insert or replace by id. This is the app's ordinary write path. */ + upsert(row: ScheduledJobUpsert): Promise + /** + * Insert only when the id is absent; returns null when a row already exists. + * See the note on the provider store: the import must never overwrite a job + * the user has edited since. + */ + insertIfAbsent(row: ScheduledJobUpsert): Promise + remove(id: string): Promise +} + +async function list(): Promise { + return getDb().select().from(scheduledJobs).all() +} + +async function get(id: string): Promise { + 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 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) + .where(eq(scheduledJobs.id, id)) + .returning({ id: scheduledJobs.id }) + return deleted.length > 0 +} + +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..3030096dd3 100644 --- a/packages/browseros-agent/apps/server/src/rpc.ts +++ b/packages/browseros-agent/apps/server/src/rpc.ts @@ -1,5 +1,8 @@ import type { createAgentRoutes } from './api/routes/agents' import type { createConversationRoutes } from './api/routes/conversations' +import type { createProvidersRoutes } from './api/routes/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 // its own path in createApiRoutes, and the extension builds a small typed client @@ -12,3 +15,8 @@ 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 ProviderRoutes = ReturnType +export type ScheduledJobRoutes = ReturnType +export type ScheduledJobRunRoutes = ReturnType< + typeof createScheduledJobRunRoutes +> diff --git a/packages/browseros-agent/apps/server/tests/api/chat-request-schema.test.ts b/packages/browseros-agent/apps/server/tests/api/chat-request-schema.test.ts index 92ff8f1b0d..59dbd77c42 100644 --- a/packages/browseros-agent/apps/server/tests/api/chat-request-schema.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/chat-request-schema.test.ts @@ -69,7 +69,10 @@ describe('ChatRequestSchema agent targets', () => { }) } - it('rejects a malformed explicit target', () => { + // A browseros target no longer has to name a provider. The server holds the + // list and which one is selected, so the id is resolved there; this used to + // be rejected because the request had to carry the whole configuration. + it('accepts a browseros target with no provider id', () => { const parsed = ChatRequestSchema.safeParse({ target: { type: 'browseros' }, conversationId: crypto.randomUUID(), @@ -79,7 +82,18 @@ describe('ChatRequestSchema agent targets', () => { model: 'gpt-5', }) - expect(parsed.success).toBe(false) + expect(parsed.success).toBe(true) + }) + + // The smallest body the endpoint accepts: what to say and which conversation + // it belongs to. Everything about the provider is resolved server side. + it('accepts a request carrying only a message and a conversation', () => { + const parsed = ChatRequestSchema.safeParse({ + conversationId: crypto.randomUUID(), + message: 'hello', + }) + + expect(parsed.success).toBe(true) }) it('rejects an untargeted legacy ACP provider request', () => { diff --git a/packages/browseros-agent/apps/server/tests/api/routes/chat-provider-guard.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/chat-provider-guard.test.ts new file mode 100644 index 0000000000..d07bed6c9b --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/api/routes/chat-provider-guard.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'bun:test' +import { createChatRoutes } from '../../../src/api/routes/chat' +import type { ChatProviderLookup } from '../../../src/api/services/chat-provider-config' +import type { ProviderRow } from '../../../src/lib/db/schema' + +function storedProvider(): ProviderRow { + return { + id: 'anthropic-1', + profileId: null, + kind: 'llm', + type: 'anthropic', + name: 'My Claude', + modelId: 'claude-sonnet-4-6', + reasoningEffort: null, + isDefault: true, + createdAt: 1, + updatedAt: 2, + baseUrl: null, + supportsImages: true, + contextWindow: 200000, + temperature: 0.2, + apiKey: 'sk-stored', + accessKeyId: null, + secretAccessKey: null, + sessionToken: null, + resourceName: null, + region: null, + reasoningSummary: null, + workingDirectory: null, + customConfig: null, + } +} + +const providerStore: ChatProviderLookup = { + get: async (id) => (id === 'anthropic-1' ? storedProvider() : null), + getDefault: async () => storedProvider(), +} + +function routes() { + return createChatRoutes({ + browser: { isCdpConnected: () => false } as never, + browserMcp: {} as never, + serverPort: 32123, + providerStore, + }) +} + +function chatBody(extra: Record = {}) { + return JSON.stringify({ + conversationId: '00000000-0000-4000-8000-000000000003', + message: 'hello', + ...extra, + }) +} + +/** + * A browseros chat request is deliberately allowed without the app-origin + * check, because it normally carries its own credentials. Naming a stored + * provider changes that: the server supplies the user's key, so any local + * process could spend it. These cover the line between the two. + */ +describe('chat provider credentials', () => { + it('refuses an untrusted caller that names a stored provider', async () => { + const response = await routes().request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: chatBody({ + target: { type: 'browseros', providerId: 'anthropic-1' }, + }), + }) + + expect(response.status).toBe(403) + }) + + // Naming nothing resolves the selected provider, which is the same privilege + // by a shorter route. + it('refuses an untrusted caller that relies on the selected provider', async () => { + const response = await routes().request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: chatBody(), + }) + + expect(response.status).toBe(403) + }) + + // The provider types the server credentials itself. An unknown id means no + // row is read, so the provenance flag alone would wave these through, and + // the resolver would then hand over this machine's oauth token or the + // gateway credential to a caller that proved nothing. + it.each(['chatgpt-pro', 'github-copilot', 'qwen-code', 'browseros'])( + 'refuses an untrusted caller naming %s with an unknown id', + async (provider) => { + const response = await routes().request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: chatBody({ + target: { type: 'browseros', providerId: 'not-stored' }, + provider, + model: 'some-model', + }), + }) + + expect(response.status).toBe(403) + }, + ) + + // Bringing your own configuration is what this path always allowed, and it + // stays allowed: nothing of the user's is being spent. + it('does not gate a request that brought its own configuration', async () => { + const response = await routes().request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: chatBody({ + target: { type: 'browseros', providerId: 'not-stored' }, + provider: 'openai', + model: 'gpt-5.5', + apiKey: 'sk-caller-own', + }), + }) + + expect(response.status).not.toBe(403) + }) +}) 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..d31a80bdf9 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,51 @@ 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('/providers')).status).toBe(403) + expect( + ( + await app.request('/providers', {}, { + server: { requestIP: () => ({ address: '192.168.1.20' }) }, + } as never) + ).status, + ).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() + + 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/providers.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/providers.test.ts new file mode 100644 index 0000000000..6d2d09ec99 --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/api/routes/providers.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from 'bun:test' +import { createProvidersRoutes } from '../../../src/api/routes/providers' +import type { ProviderRow } from '../../../src/lib/db/schema' +import type { + ProviderStore, + ProviderUpsert, +} from '../../../src/lib/providers/provider-store' + +const PROVIDER_ID = 'provider-1' + +function row(overrides: Partial = {}): ProviderRow { + 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: ProviderRow[] = []) { + const rows = new Map(initial.map((r) => [r.id, r])) + const store: ProviderStore = { + list: async () => [...rows.values()], + get: async (id) => rows.get(id) ?? null, + upsert: async (input: ProviderUpsert) => { + const existing = rows.get(input.id) + const saved = { + ...row(), + ...input, + createdAt: existing?.createdAt ?? input.createdAt ?? 100, + updatedAt: 200, + } as ProviderRow + rows.set(saved.id, saved) + return saved + }, + insertIfAbsent: async (input: ProviderUpsert) => { + if (rows.has(input.id)) return null + return store.upsert(input) + }, + remove: async (id) => rows.delete(id), + listLlm: async () => [...rows.values()].filter((row) => row.kind === 'llm'), + getDefault: async () => + [...rows.values()].find((row) => row.isDefault) ?? null, + setDefault: async (id) => { + if (!rows.has(id)) return false + for (const row of rows.values()) row.isDefault = row.id === id + return true + }, + } + 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 = createProvidersRoutes(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 = createProvidersRoutes(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 = createProvidersRoutes(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 = createProvidersRoutes({ 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 = createProvidersRoutes({ 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 = createProvidersRoutes({ 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 = createProvidersRoutes(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 = createProvidersRoutes({ 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 = createProvidersRoutes(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 = createProvidersRoutes({ 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', + }) + }) + + 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 = createProvidersRoutes({ 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 = createProvidersRoutes({ 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 = createProvidersRoutes({ 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 = createProvidersRoutes(memoryStore()) + expect((await importProviders(routes, [body])).status).toBe(400) + }) + }) + + describe('default', () => { + it('reports no default when none is set', async () => { + const routes = createProvidersRoutes(memoryStore([row()])) + const response = await routes.request('/default') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ provider: null }) + }) + + it('points the default at a provider', async () => { + const { store, rows } = memoryStore([row()]) + const routes = createProvidersRoutes({ store }) + + const response = await routes.request('/default', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ providerId: PROVIDER_ID }), + }) + + expect(response.status).toBe(200) + expect(rows.get(PROVIDER_ID)?.isDefault).toBe(true) + }) + + it('refuses an unknown provider rather than storing a stale pointer', async () => { + const routes = createProvidersRoutes(memoryStore()) + const response = await routes.request('/default', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ providerId: 'nope' }), + }) + + expect(response.status).toBe(404) + }) + + // /default is declared before /:providerId, so the literal path is not + // swallowed as an id. + it('does not read the default path as a provider id', async () => { + const { store } = memoryStore([row({ id: 'default' })]) + const routes = createProvidersRoutes({ store }) + + expect(await (await routes.request('/default')).json()).toEqual({ + provider: null, + }) + }) + }) +}) 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/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..49f3c19946 --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/api/routes/scheduled-jobs.test.ts @@ -0,0 +1,187 @@ +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 + }, + insertIfAbsent: async (input: ScheduledJobUpsert) => { + if (rows.has(input.id)) return null + return store.upsert(input) + }, + 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) + }) + + 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/api/services/chat-provider-config.test.ts b/packages/browseros-agent/apps/server/tests/api/services/chat-provider-config.test.ts new file mode 100644 index 0000000000..1a3fae408d --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/api/services/chat-provider-config.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'bun:test' +import { + type ChatProviderLookup, + hydrateChatProvider, +} from '../../../src/api/services/chat-provider-config' +import type { BrowserOsChatRequest } from '../../../src/api/types' +import type { ProviderRow } from '../../../src/lib/db/schema' + +function row(overrides: Partial = {}): ProviderRow { + return { + id: 'anthropic-1', + profileId: null, + kind: 'llm', + type: 'anthropic', + name: 'My Claude', + modelId: 'claude-sonnet-4-6', + reasoningEffort: null, + isDefault: false, + createdAt: 1, + updatedAt: 2, + baseUrl: null, + supportsImages: true, + contextWindow: 200000, + temperature: 0.2, + apiKey: 'sk-stored', + accessKeyId: null, + secretAccessKey: null, + sessionToken: null, + resourceName: null, + region: null, + reasoningSummary: null, + workingDirectory: null, + customConfig: null, + ...overrides, + } +} + +function lookup(rows: ProviderRow[] = []): ChatProviderLookup { + return { + get: async (id) => rows.find((r) => r.id === id) ?? null, + getDefault: async () => rows.find((r) => r.isDefault) ?? null, + } +} + +function request( + overrides: Record = {}, +): BrowserOsChatRequest { + return { + conversationId: '00000000-0000-4000-8000-000000000001', + message: 'hello', + target: { type: 'browseros', providerId: undefined }, + ...overrides, + } as BrowserOsChatRequest +} + +describe('hydrateChatProvider', () => { + it('fills the configuration from a named provider', async () => { + const result = await hydrateChatProvider( + request({ target: { type: 'browseros', providerId: 'anthropic-1' } }), + lookup([row()]), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.request).toMatchObject({ + provider: 'anthropic', + model: 'claude-sonnet-4-6', + apiKey: 'sk-stored', + contextWindowSize: 200000, + }) + }) + + // The point of the change: a body carrying nothing but a message and a + // conversation still resolves, because the server knows what is selected. + it('falls back to the selected provider when none is named', async () => { + const result = await hydrateChatProvider( + request(), + lookup([row({ isDefault: true })]), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.request.target.providerId).toBe('anthropic-1') + expect(result.request.provider).toBe('anthropic') + }) + + // The row is the source of truth, so a client holding a copy from before an + // edit does not get to override it. + it('prefers the stored row over anything sent inline', async () => { + const result = await hydrateChatProvider( + request({ + target: { type: 'browseros', providerId: 'anthropic-1' }, + provider: 'openai', + model: 'stale-model', + apiKey: 'sk-stale', + }), + lookup([row()]), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.request).toMatchObject({ + provider: 'anthropic', + model: 'claude-sonnet-4-6', + apiKey: 'sk-stored', + }) + }) + + // A client from before this change ships the whole configuration and never + // relies on the lookup, so it has to keep working against a server that + // knows nothing about the id it names. + it('keeps an inline configuration when the server has no such row', async () => { + const result = await hydrateChatProvider( + request({ + target: { type: 'browseros', providerId: 'unknown-1' }, + provider: 'openai', + model: 'gpt-5.5', + apiKey: 'sk-inline', + }), + lookup([]), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.request).toMatchObject({ + provider: 'openai', + model: 'gpt-5.5', + apiKey: 'sk-inline', + }) + }) + + it('refuses a request that names nothing and has no selection', async () => { + const result = await hydrateChatProvider(request(), lookup([])) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error).toMatch(/none is selected/) + }) + + // Now that either kind can be the default, the browseros path has to say so + // rather than running the conversation on some other provider. + it('refuses to serve a coding agent on the browseros path', async () => { + const result = await hydrateChatProvider( + request(), + lookup([ + row({ id: 'acp-1', kind: 'acp', type: 'claude', isDefault: true }), + ]), + ) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error).toMatch(/coding agent/) + }) + + // The route gates on this: supplying the user's credentials is a privilege + // the request does not carry on its own. + it('reports when the configuration came from storage', async () => { + const result = await hydrateChatProvider( + request({ target: { type: 'browseros', providerId: 'anthropic-1' } }), + lookup([row()]), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.usedStoredProvider).toBe(true) + }) + + it('reports when the request brought its own configuration', async () => { + const result = await hydrateChatProvider( + request({ + target: { type: 'browseros', providerId: 'unknown-1' }, + provider: 'openai', + model: 'gpt-5.5', + apiKey: 'sk-inline', + }), + lookup([]), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.usedStoredProvider).toBe(false) + }) +}) diff --git a/packages/browseros-agent/apps/server/tests/api/services/chat-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/chat-service.test.ts index dd392d4599..59a50970da 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/chat-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/chat-service.test.ts @@ -97,7 +97,15 @@ mock.module('../../../src/agent/ai-sdk-agent', () => ({ }, })) +// A module factory is a total replacement: anything it omits disappears for +// every file that imports this module afterwards, and bun's registry is +// process wide, so the failure surfaces somewhere else entirely and only when +// file ordering puts that file second. Re-export the real module and override +// the one function under test. +import * as llmConfigModule from '../../../src/lib/clients/llm/config' + mock.module('../../../src/lib/clients/llm/config', () => ({ + ...llmConfigModule, resolveLLMConfig: resolveLLMConfigSpy, })) diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/acp-agent-store.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/acp-agent-store.test.ts index e744c11347..e897c7a9db 100644 --- a/packages/browseros-agent/apps/server/tests/lib/agents/acp-agent-store.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/agents/acp-agent-store.test.ts @@ -187,11 +187,11 @@ describe('DbAcpAgentStore', () => { .get(), ).toEqual({ count: 0 }) expect( - handle.sqlite.query('SELECT COUNT(*) AS count FROM acp_agents').get(), + handle.sqlite.query('SELECT COUNT(*) AS count FROM providers').get(), ).toEqual({ count: 0 }) expect( handle.sqlite - .query('PRAGMA table_info(acp_agents)') + .query('PRAGMA table_info(providers)') .all() .some((column) => (column as { name: string }).name === 'pinned'), ).toBe(false) 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..3e7f94b5dc 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,14 +3,15 @@ * Copyright 2025 BrowserOS */ -import { afterEach, describe, expect, it } from 'bun:test' import { Database as BunDatabase } from 'bun:sqlite' -import { existsSync, mkdirSync, mkdtempSync } from 'node:fs' +import { afterEach, describe, expect, it } from 'bun:test' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readFileSync } 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 { acpAgents } from '../../../src/lib/db/schema' +import { providers } from '../../../src/lib/db/schema' describe('database initialization', () => { const tempDirs: string[] = [] @@ -28,7 +29,7 @@ describe('database initialization', () => { const dbPath = join(dir, 'nested', 'browseros.sqlite') const handle = initializeDb({ dbPath }) - const rows = handle.db.select().from(acpAgents).all() + const rows = handle.db.select().from(providers).all() expect(existsSync(dbPath)).toBe(true) expect(rows).toEqual([]) @@ -52,7 +53,7 @@ describe('database initialization', () => { }) expectCurrentSchema(handle) - expect(handle.db.select().from(acpAgents).all()).toEqual([]) + expect(handle.db.select().from(providers).all()).toEqual([]) }) it('bootstraps the current schema when a migration directory is empty', () => { @@ -67,7 +68,7 @@ describe('database initialization', () => { expect(handle.migrationsDir).toBe(null) expectCurrentSchema(handle) - expect(handle.db.select().from(acpAgents).all()).toEqual([]) + expect(handle.db.select().from(providers).all()).toEqual([]) }) it('skips empty packaged migration resources', () => { @@ -82,7 +83,7 @@ describe('database initialization', () => { }) expect(handle.migrationsDir).not.toBe(packagedMigrationsDir) - expect(handle.db.select().from(acpAgents).all()).toEqual([]) + expect(handle.db.select().from(providers).all()).toEqual([]) }) it('does not rerun old migrations after fallback schema bootstrap', () => { @@ -171,7 +172,7 @@ describe('database initialization', () => { .get() expect(legacyTable).toBeNull() - expect(handle.db.select().from(acpAgents).all()).toEqual([]) + expect(handle.db.select().from(providers).all()).toEqual([]) }) function expectCurrentSchema(handle: ReturnType): void { @@ -181,7 +182,9 @@ describe('database initialization', () => { SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ( - 'acp_agents', + 'providers', + 'scheduled_jobs', + 'scheduled_job_runs', 'oauth_tokens', '__drizzle_migrations' ) @@ -191,10 +194,16 @@ describe('database initialization', () => { .all() .map((row) => row.name) + // The fallback has to produce the schema as it stands after every + // migration, so the two split provider tables are absent and the unified + // one is present. It drifted behind once already, which is what this list + // is here to catch. expect(tables).toEqual([ '__drizzle_migrations', - 'acp_agents', 'oauth_tokens', + 'providers', + 'scheduled_job_runs', + 'scheduled_jobs', ]) const migrations = handle.sqlite .query<{ hash: string; createdAt: number }, []>( @@ -216,37 +225,29 @@ describe('database initialization', () => { } }) -const expectedMigrationHistory = [ - { - hash: 'aadfc2e86410febb11a974d25d99d5f7196aa797d9635ced9a18cd4eeb503b61', - createdAt: 1777750582590, - }, - { - hash: '19e693f7b1adcd1d932fa6cf5638b5b158c66ea5de4f154bc59311f4d6f71261', - createdAt: 1777752799806, - }, - { - hash: '02b11bf1dc34a5a289efd216233a48f0b7b950cfc33eaa7ebe6dcbb15d07f75c', - createdAt: 1777902205667, - }, - { - hash: '34387e59aa1f0d6dc44c95836d2363b72982663c50d05d0c67ee58c211209f52', - createdAt: 1781916712443, - }, - { - hash: '76d3a9d6c383995df79b6d8f66ae1bedd0b97b1f44e90c047d8853666bbcc9fd', - createdAt: 1785893663690, - }, - { - hash: '44a8d4afc62cc58f0f958f633e5262331370d1e1538981b69c1ec2cb807a3154', - createdAt: 1785900211901, - }, - { - hash: 'e9a01f94d41f7718c66039a8483302f6db7c7de946f99987a6dd2e78613bce90', - createdAt: 1786538823114, - }, - { - hash: '561eb1075d7487ffe0394e587eef7ba35ccd892e3e3b53acace579cb0477576b', - createdAt: 1787580067090, - }, -] +/** + * Derived from the journal rather than transcribed. + * + * The bootstrap fallback carries its own copy of this history, and a hand + * written duplicate here is what let that copy fall four migrations behind + * without any test noticing. Reading the journal and hashing the files means + * adding a migration and forgetting the fallback now fails. + */ +const expectedMigrationHistory = JSON.parse( + readFileSync( + join(import.meta.dir, '../../../src/lib/db/migrations/meta/_journal.json'), + 'utf8', + ), +).entries.map((entry: { tag: string; when: number }) => ({ + hash: createHash('sha256') + .update( + readFileSync( + join( + import.meta.dir, + `../../../src/lib/db/migrations/${entry.tag}.sql`, + ), + ), + ) + .digest('hex'), + createdAt: entry.when, +})) diff --git a/packages/browseros-agent/apps/server/tests/lib/providers/provider-store.test.ts b/packages/browseros-agent/apps/server/tests/lib/providers/provider-store.test.ts new file mode 100644 index 0000000000..1d6a20694a --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/lib/providers/provider-store.test.ts @@ -0,0 +1,325 @@ +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, getDb, initializeDb } from '../../../src/lib/db' +import { providers } from '../../../src/lib/db/schema' +import { dbProviderStore } from '../../../src/lib/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('dbProviderStore', () => { + 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 dbProviderStore.insertIfAbsent(baseProvider()) + + expect(saved?.id).toBe(PROVIDER_ID) + expect(saved?.apiKey).toBe('sk-test') + expect(await dbProviderStore.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 dbProviderStore.upsert({ ...baseProvider(), name: 'Edited since' }) + + const saved = await dbProviderStore.insertIfAbsent({ + ...baseProvider(), + name: 'Stale copy', + apiKey: 'sk-stale', + }) + + expect(saved).toBeNull() + const existing = await dbProviderStore.get(PROVIDER_ID) + expect(existing?.name).toBe('Edited since') + // The credential is checked through the credentialed read: the ordinary + // one no longer returns it. + expect( + (await dbProviderStore.getWithCredentials(PROVIDER_ID))?.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 dbProviderStore.insertIfAbsent({ + ...baseProvider(), + temperature: 0.2, + }) + + expect((await dbProviderStore.get(PROVIDER_ID))?.temperature).toBe(0.2) + }) + + test('insertIfAbsent preserves the creation time it is given', async () => { + useTempDb() + await dbProviderStore.insertIfAbsent({ + ...baseProvider(), + createdAt: 42, + }) + + expect((await dbProviderStore.get(PROVIDER_ID))?.createdAt).toBe(42) + }) + + // The whole point of merging the tables: one default, of either kind. + test('the default can be an acp agent, not only an llm provider', async () => { + useTempDb() + await dbProviderStore.upsert(baseProvider()) + getDb() + .insert(providers) + .values({ + id: 'acp-1', + kind: 'acp', + type: 'claude', + name: 'Claude Code', + createdAt: 1, + updatedAt: 1, + }) + .run() + + expect(await dbProviderStore.setDefault('acp-1')).toBe(true) + + expect(await dbProviderStore.getDefault()).toMatchObject({ + id: 'acp-1', + kind: 'acp', + }) + }) + + // A partial unique index allows one default row, so moving it has to clear + // the old one first or the write violates the index. + test('setting a default clears the previous one', async () => { + useTempDb() + await dbProviderStore.upsert(baseProvider()) + await dbProviderStore.upsert({ ...baseProvider(), id: 'provider-2' }) + + await dbProviderStore.setDefault(PROVIDER_ID) + await dbProviderStore.setDefault('provider-2') + + expect((await dbProviderStore.getDefault())?.id).toBe('provider-2') + expect( + (await dbProviderStore.list()).filter((row) => row.isDefault), + ).toHaveLength(1) + }) + + test('an unknown id is refused rather than stored as a stale pointer', async () => { + useTempDb() + expect(await dbProviderStore.setDefault('nope')).toBe(false) + expect(await dbProviderStore.getDefault()).toBeNull() + }) + + test('deleting the default leaves no default behind', async () => { + useTempDb() + await dbProviderStore.upsert(baseProvider()) + await dbProviderStore.setDefault(PROVIDER_ID) + + await dbProviderStore.remove(PROVIDER_ID) + + expect(await dbProviderStore.getDefault()).toBeNull() + }) + + // A save must not move the selection as a side effect. + test('upserting the default provider keeps it default', async () => { + useTempDb() + await dbProviderStore.upsert(baseProvider()) + await dbProviderStore.setDefault(PROVIDER_ID) + + await dbProviderStore.upsert({ ...baseProvider(), name: 'Renamed' }) + + expect(await dbProviderStore.getDefault()).toMatchObject({ + id: PROVIDER_ID, + name: 'Renamed', + }) + }) + + test('listLlm excludes acp agents', async () => { + useTempDb() + await dbProviderStore.upsert(baseProvider()) + getDb() + .insert(providers) + .values({ + id: 'acp-1', + kind: 'acp', + type: 'claude', + name: 'Claude Code', + createdAt: 1, + updatedAt: 1, + }) + .run() + + expect((await dbProviderStore.listLlm()).map((r) => r.id)).toEqual([ + PROVIDER_ID, + ]) + expect(await dbProviderStore.list()).toHaveLength(2) + }) + + describe('credentials', () => { + // Every provider read used to hand back the api key and the aws secret, + // on the list, the get and the default alike. + test('the ordinary reads return no credentials', async () => { + useTempDb() + await dbProviderStore.upsert(baseProvider()) + await dbProviderStore.setDefault(PROVIDER_ID) + + for (const row of [ + await dbProviderStore.get(PROVIDER_ID), + (await dbProviderStore.list())[0], + (await dbProviderStore.listLlm())[0], + await dbProviderStore.getDefault(), + ]) { + for (const field of [ + 'apiKey', + 'accessKeyId', + 'secretAccessKey', + 'sessionToken', + ]) { + expect(row && field in row).toBe(false) + } + } + }) + + // The UI still has to show that a key is set, without being given it. + test('the ordinary reads report whether a credential is set', async () => { + useTempDb() + await dbProviderStore.upsert(baseProvider()) + await dbProviderStore.upsert({ + ...baseProvider(), + id: 'no-key', + apiKey: undefined, + }) + + expect((await dbProviderStore.get(PROVIDER_ID))?.hasApiKey).toBe(true) + expect((await dbProviderStore.get('no-key'))?.hasApiKey).toBe(false) + }) + + test('the credentialed read still returns them', async () => { + useTempDb() + await dbProviderStore.upsert(baseProvider()) + + expect( + (await dbProviderStore.getWithCredentials(PROVIDER_ID))?.apiKey, + ).toBe('sk-test') + }) + + // Reads no longer return the key, so an edit cannot send it back. Writing + // undefined over a working credential on every rename is the failure this + // prevents. + test('an absent credential keeps its stored value', async () => { + useTempDb() + await dbProviderStore.upsert(baseProvider()) + + await dbProviderStore.upsert({ + id: PROVIDER_ID, + type: 'openai', + name: 'Renamed', + modelId: 'gpt-5.5', + contextWindow: 200000, + }) + + const saved = await dbProviderStore.getWithCredentials(PROVIDER_ID) + expect(saved?.name).toBe('Renamed') + expect(saved?.apiKey).toBe('sk-test') + }) + + // A form field the user never filled in submits as an empty string, not + // as undefined. Treating that as an instruction to clear would wipe the key + // on exactly the edit this protects. + test('an empty credential is treated as not supplied', async () => { + useTempDb() + await dbProviderStore.upsert(baseProvider()) + + await dbProviderStore.upsert({ ...baseProvider(), apiKey: '' }) + + expect( + (await dbProviderStore.getWithCredentials(PROVIDER_ID))?.apiKey, + ).toBe('sk-test') + }) + + // And an empty value must not read back as a stored credential either. + // Every flag, not just the api key: they are one definition and a + // divergence would only show on the credential nobody tested. + test('an empty credential does not report as set', async () => { + useTempDb() + await dbProviderStore.upsert({ + ...baseProvider(), + apiKey: '', + accessKeyId: '', + secretAccessKey: '', + sessionToken: '', + }) + + const row = await dbProviderStore.get(PROVIDER_ID) + expect(row?.hasApiKey).toBe(false) + expect(row?.hasAccessKeyId).toBe(false) + expect(row?.hasSecretAccessKey).toBe(false) + expect(row?.hasSessionToken).toBe(false) + }) + + test('every credential survives a blank edit, not just the api key', async () => { + useTempDb() + await dbProviderStore.upsert({ + ...baseProvider(), + accessKeyId: 'AKIA', + secretAccessKey: 'aws-secret', + sessionToken: 'token', + }) + + await dbProviderStore.upsert({ + ...baseProvider(), + apiKey: '', + accessKeyId: '', + secretAccessKey: '', + sessionToken: '', + }) + + expect( + await dbProviderStore.getWithCredentials(PROVIDER_ID), + ).toMatchObject({ + apiKey: 'sk-test', + accessKeyId: 'AKIA', + secretAccessKey: 'aws-secret', + sessionToken: 'token', + }) + }) + + test('an explicitly null credential clears it', async () => { + useTempDb() + await dbProviderStore.upsert(baseProvider()) + + await dbProviderStore.upsert({ ...baseProvider(), apiKey: null }) + + expect( + (await dbProviderStore.getWithCredentials(PROVIDER_ID))?.apiKey, + ).toBeNull() + expect((await dbProviderStore.get(PROVIDER_ID))?.hasApiKey).toBe(false) + }) + }) +}) 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) + }) +}) diff --git a/packages/browseros-agent/apps/server/tests/server.integration.test.ts b/packages/browseros-agent/apps/server/tests/server.integration.test.ts index 7c379c8c02..a2468e1f60 100644 --- a/packages/browseros-agent/apps/server/tests/server.integration.test.ts +++ b/packages/browseros-agent/apps/server/tests/server.integration.test.ts @@ -169,6 +169,12 @@ describe('HTTP Server Integration Tests', () => { method: 'POST', headers: { 'Content-Type': 'application/json', + // The browseros provider takes the server's gateway credential + // rather than one the request carries, so this path is only open + // to the extension. Chrome puts this header on every fetch the app + // makes, including the ones the background alarm runner makes to + // the already guarded schedule routes. + Origin: 'chrome-extension://bflpfmnmnokmjhmgnolecpppdbdophmk', }, body: JSON.stringify({ conversationId, 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": { diff --git a/packages/browseros-agent/bun.lock b/packages/browseros-agent/bun.lock index b2ce08ae8d..9697c48bae 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.86.0", + "react-query-kit": "^3.3.4", "react-resizable-panels": "^4.12.3", "react-router": "^7.18.3", "shiki": "^3.23.0",