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
@@ -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')
})
})
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-4 rounded-xl border border-border bg-card p-4 shadow-sm">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-orange)]/10">
<HardDrive className="h-5 w-5 text-[var(--accent-orange)]" />
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm">
Your data now stays on this device
</p>
<p className="text-muted-foreground text-xs">
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.
</p>
</div>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss"
className="shrink-0 rounded-sm p-1 text-muted-foreground opacity-50 transition-opacity hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)
}
26 changes: 3 additions & 23 deletions packages/browseros-agent/apps/app/entrypoints/background/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand All @@ -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 = [
Expand Down Expand Up @@ -59,8 +51,7 @@ export default defineBackground(() => {

Capabilities.initialize().catch(() => null)
setupLlmProvidersBackupToBrowserOS()
setupLlmProvidersSyncToBackend()
setupScheduledJobsSyncToBackend()
startLocalFirstMigration()

scheduledJobRuns()

Expand Down Expand Up @@ -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()
Expand Down
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
@@ -0,0 +1,7 @@
import { storage } from '#imports'

/** One-time announcement, so dismissal has to outlive the session. */
export const cloudSyncNoticeDismissedStorage = storage.defineItem<boolean>(
'local:cloudSyncNoticeDismissed',
{ fallback: false },
)
Loading
Loading