Files
BrowserOS/packages/browseros-agent/apps/agent/lib/schedules/syncSchedulesToBackend.ts
Nikhil 2b53daf641 fix: prevent deleted scheduled tasks from reappearing after sync (#518)
* fix: prevent deleted scheduled tasks from reappearing after sync

When a scheduled task was deleted, the sync function would see the
remote job missing locally and re-add it, undoing the delete. Fix by
tracking pending deletions in storage so the sync function deletes
them from the backend instead of re-adding them locally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use read-modify-write for pending deletions to prevent concurrent clobber

Re-read pendingDeletionStorage before write-back and only remove
resolved IDs, preserving any new entries added by concurrent
removeJob calls during the sync's network I/O.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:31:57 -07:00

237 lines
7.2 KiB
TypeScript

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 { 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
export async function syncSchedulesToBackend(
localJobs: ScheduledJob[],
userId: string,
): Promise<void> {
const profileResult = await execute(GetProfileIdByUserIdDocument, { userId })
const profileId = profileResult.profileByUserId?.rowId
if (!profileId) return
const remoteResult = await execute(GetScheduledJobsByProfileIdDocument, {
profileId,
})
const remoteJobs = new Map<string, RemoteScheduledJob>()
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<string>()
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,
},
})
}
}
}