Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, AbortController>()

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]
Expand All @@ -56,55 +62,46 @@ export const scheduledJobRuns = async () => {
jobId: string,
status: ScheduledJobRun['status'],
): Promise<ScheduledJobRun> => {
// 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,
startedAt: new Date().toISOString(),
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<Omit<ScheduledJobRun, 'id' | 'jobId' | 'startedAt'>>,
) => {
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<void> => {
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}`)
}
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@ const DEFAULT_BASE_URLS: Record<ProviderType, string> = {
* 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<ProviderType, string>`,
* 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] || ''
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { getAgentServerUrl } from '@/lib/browseros/helpers'
import {
createDefaultBrowserOSProvider,
defaultProviderIdStorage,
providersStorage,
} from '@/lib/llm-providers/storage'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { mcpServerStorage } from '@/lib/mcp/mcpServerStorage'
import { buildChatRequestBody } from '@/lib/messaging/server/buildChatRequestBody'
import type { ChatMode } from '@/modules/chat/chat-types'
import { listProvidersOrNull } from '@/modules/llm-providers/llm-providers.api'
import {
findChatProviderById,
resolveChatProvider,
Expand Down Expand Up @@ -73,23 +73,42 @@ interface StreamParseState {
receivedFinish: boolean
}

const getDefaultProvider = async (): Promise<LlmProviderConfig | null> => {
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<LlmProviderConfig> => {
// One read for both branches: the list is now a request rather than a local
// storage lookup, and the explicit-provider path used to fetch it twice.
const loaded = await listProvidersOrNull()

// Never resolve a provider from a list that failed to load. A job that named
// one must not quietly run on a different one, and a job that named none
// still has a choice behind it: the configured default, whose id lives in
// extension storage but whose credentials and model live in that list. Either
// way, substituting the built-in would spend the wrong credentials on the
// wrong model and still record the run as completed.
//
// An empty list is a different answer and keeps the fallback: the server
// answered, and it really has no providers.
if (loaded === null) {
throw new Error(
'Cannot reach the BrowserOS server to load the selected provider',
)
}

const providers = loaded

if (providerId) {
const providers = await providersStorage.getValue()
const match = findChatProviderById(providers ?? [], providerId)
const match = findChatProviderById(providers, providerId)
if (match) return match
}
return (await getDefaultProvider()) ?? createDefaultBrowserOSProvider()

if (providers.length > 0) {
const defaultProviderId = await defaultProviderIdStorage.getValue()
const provider = resolveChatProvider(providers, defaultProviderId)
if (provider) return provider
}

return createDefaultBrowserOSProvider()
}

export async function getChatServerResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -160,3 +169,73 @@ const providers: LlmProviderConfig[] = [
updatedAt: timestamp,
},
]

describe('provider resolution when the server is unreachable', () => {
// The list being unreachable says nothing about whether the chosen provider
// exists, so running anyway would spend the wrong credentials on the wrong
// model. The job runner turns this into a failed run the user can see.
it('fails a scheduled job that named a provider', async () => {
storageValues.set('unreachable', true)
const { getChatServerResponse } = await import('./getChatServerResponse')

await expect(
getChatServerResponse({
message: 'Run my schedule',
providerId: 'anthropic-sonnet',
}),
).rejects.toThrow('Cannot reach the BrowserOS server')

expect(fetchBodies).toHaveLength(0)
})

it('fails a refine that named a provider', async () => {
storageValues.set('unreachable', true)
const { refinePrompt } = await import('./refine-prompt')

await expect(
refinePrompt({
prompt: 'Check mail',
name: 'Morning brief',
providerId: 'anthropic-sonnet',
}),
).rejects.toThrow('Cannot reach the BrowserOS server')
})

// A job that named nothing still has a choice behind it: the configured
// default. Its id is in extension storage but its model and credentials are
// in the list that failed to load, so the built-in is not a safe stand-in.
it('fails a scheduled job that relies on the configured default', async () => {
storageValues.set('unreachable', true)
const { getChatServerResponse } = await import('./getChatServerResponse')

await expect(
getChatServerResponse({ message: 'Run my schedule' }),
).rejects.toThrow('Cannot reach the BrowserOS server')

expect(fetchBodies).toHaveLength(0)
})

// An empty list is a different answer from an unreachable one: the server
// replied and really has no providers, so the built-in is correct.
it('still falls back to the built-in provider when the server has none', async () => {
storageValues.set('providers', [])
const { getChatServerResponse } = await import('./getChatServerResponse')

await getChatServerResponse({ message: 'Run my schedule' })

expect(fetchBodies[0]).toMatchObject({ provider: 'browseros' })
})

// A provider that was genuinely deleted still falls back, as before. Only
// the unreachable case is treated as unsafe.
it('falls back when the named provider no longer exists', async () => {
const { getChatServerResponse } = await import('./getChatServerResponse')

await getChatServerResponse({
message: 'Run my schedule',
providerId: 'deleted-provider',
})

expect(fetchBodies[0]).toMatchObject({ provider: 'anthropic' })
})
})
16 changes: 13 additions & 3 deletions packages/browseros-agent/apps/app/lib/schedules/refine-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,8 +13,18 @@ import {
const resolveProvider = async (
providerId?: string,
): Promise<LlmProviderConfig> => {
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

Expand Down
Loading
Loading