Skip to content

Commit ae9ce3e

Browse files
committed
feat(app): read and write scheduled jobs and runs through the server
The hooks keep their shape, so the tasks page, the results view, the card and the new tab panel are unchanged apart from where they import from. Both gain an unavailable state, since an empty list and an unreachable server are now the same shape without one. The alarm runner distinguishes them everywhere it reads. Treating a failed load as an empty list would read as nothing being scheduled: alarms would not be rebuilt on startup and schedules would quietly stop firing, with no failed run to show for it. It skips the pass instead and retries on the next startup. Extension storage no longer carries the data, but it still carries the change signal. Runs are written by the background while the side panel and new tab display them, and storage watch is what kept those in step. A revision item is bumped after a write so every mounted view refetches. Run history is imported once, under its own marker. It cannot share the provider and job marker because that import must never run twice: extension storage is frozen now, so a second pass would insert back whatever the user has since deleted. Also removes the scheduled job deletion queue, whose only reader went when sync did, and the mount-time storage read that chose the opening tab, which is now derived so it settles when the history arrives.
1 parent ea29360 commit ae9ce3e

14 files changed

Lines changed: 804 additions & 254 deletions

File tree

packages/browseros-agent/apps/app/entrypoints/background/scheduledJobRuns.ts

Lines changed: 54 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,53 @@
11
import { onScheduleMessage } from '@/lib/messaging/schedules/scheduleMessages'
22
import { createAlarmFromJob } from '@/lib/schedules/createAlarmFromJob'
33
import { getChatServerResponse } from '@/lib/schedules/getChatServerResponse'
4+
import type {
5+
ScheduledJob,
6+
ScheduledJobRun,
7+
} from '@/lib/schedules/scheduleTypes'
48
import {
5-
scheduledJobRunStorage,
6-
scheduledJobStorage,
7-
} from '@/lib/schedules/scheduleStorage'
8-
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
9+
listScheduledJobRunsOrNull,
10+
listScheduledJobsOrNull,
11+
putScheduledJob,
12+
putScheduledJobRun,
13+
} from '@/modules/schedules/schedules.api'
914

10-
const MAX_RUNS_PER_JOB = 15
1115
const STALE_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
1216
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000
1317

1418
const runAbortControllers = new Map<string, AbortController>()
1519

1620
export const scheduledJobRuns = async () => {
21+
// Every read below distinguishes an unreachable server from an empty list.
22+
// Treating the two alike would look like "nothing is scheduled": alarms would
23+
// not be rebuilt on startup and schedules would quietly stop firing, with no
24+
// failed run to show for it. Skipping the pass instead leaves the next
25+
// startup to retry.
1726
const cleanupStaleJobRuns = async () => {
18-
const current = (await scheduledJobRunStorage.getValue()) ?? []
27+
const current = await listScheduledJobRunsOrNull()
28+
if (current === null) return
1929
const now = Date.now()
2030

21-
const updated = current.map((run) => {
22-
if (run.status !== 'running') return run
23-
24-
const startedAt = new Date(run.startedAt).getTime()
25-
if (now - startedAt > STALE_TIMEOUT_MS) {
26-
return {
27-
...run,
28-
status: 'failed' as const,
29-
completedAt: new Date().toISOString(),
30-
result: 'Job timed out!',
31-
}
32-
}
33-
return run
34-
})
31+
const stale = current.filter(
32+
(run) =>
33+
run.status === 'running' &&
34+
now - new Date(run.startedAt).getTime() > STALE_TIMEOUT_MS,
35+
)
3536

36-
await scheduledJobRunStorage.setValue(updated)
37+
for (const run of stale) {
38+
await putScheduledJobRun({
39+
...run,
40+
status: 'failed',
41+
completedAt: new Date().toISOString(),
42+
result: 'Job timed out!',
43+
})
44+
}
3745
}
3846

3947
const syncAlarmState = async () => {
40-
const jobs = (await scheduledJobStorage.getValue()).filter(
41-
(each) => each.enabled,
42-
)
48+
const loaded = await listScheduledJobsOrNull()
49+
if (loaded === null) return
50+
const jobs = loaded.filter((each) => each.enabled)
4351

4452
for (let i = 0; i < jobs.length; i++) {
4553
const job = jobs[i]
@@ -56,55 +64,39 @@ export const scheduledJobRuns = async () => {
5664
jobId: string,
5765
status: ScheduledJobRun['status'],
5866
): Promise<ScheduledJobRun> => {
67+
// Trimming to the per-job cap happens on the server now, so creating a run
68+
// no longer has to rewrite the job's whole history to stay bounded.
5969
const jobRun: ScheduledJobRun = {
6070
id: crypto.randomUUID(),
6171
jobId,
6272
startedAt: new Date().toISOString(),
6373
status,
6474
}
6575

66-
const current = (await scheduledJobRunStorage.getValue()) ?? []
67-
const otherJobRuns = current.filter((r) => r.jobId !== jobId)
68-
const thisJobRuns = current
69-
.filter((r) => r.jobId === jobId)
70-
.sort(
71-
(a, b) =>
72-
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
73-
)
74-
.slice(0, MAX_RUNS_PER_JOB - 1)
75-
76-
await scheduledJobRunStorage.setValue([
77-
...otherJobRuns,
78-
...thisJobRuns,
79-
jobRun,
80-
])
76+
await putScheduledJobRun(jobRun)
8177
return jobRun
8278
}
8379

80+
// Takes the run rather than its id: the caller already holds it, and merging
81+
// locally avoids re-reading a list to update one row.
8482
const updateJobRun = async (
85-
runId: string,
83+
run: ScheduledJobRun,
8684
updates: Partial<Omit<ScheduledJobRun, 'id' | 'jobId' | 'startedAt'>>,
8785
) => {
88-
const current = (await scheduledJobRunStorage.getValue()) ?? []
89-
await scheduledJobRunStorage.setValue(
90-
current.map((r) => (r.id === runId ? { ...r, ...updates } : r)),
91-
)
86+
await putScheduledJobRun({ ...run, ...updates })
9287
}
9388

94-
const updateJobLastRunAt = async (jobId: string) => {
95-
const current = (await scheduledJobStorage.getValue()) ?? []
96-
await scheduledJobStorage.setValue(
97-
current.map((j) =>
98-
j.id === jobId ? { ...j, lastRunAt: new Date().toISOString() } : j,
99-
),
100-
)
89+
const updateJobLastRunAt = async (job: ScheduledJob) => {
90+
await putScheduledJob({ ...job, lastRunAt: new Date().toISOString() })
10191
}
10292

10393
const executeScheduledJob = async (jobId: string): Promise<void> => {
104-
const job = (await scheduledJobStorage.getValue()).find(
105-
(each) => each.id === jobId,
106-
)
94+
const jobs = await listScheduledJobsOrNull()
95+
if (jobs === null) {
96+
throw new Error('Cannot reach the BrowserOS server to load the job')
97+
}
10798

99+
const job = jobs.find((each) => each.id === jobId)
108100
if (!job) {
109101
throw new Error(`Job not found: ${jobId}`)
110102
}
@@ -120,7 +112,7 @@ export const scheduledJobRuns = async () => {
120112
providerId: job.providerId,
121113
})
122114

123-
await updateJobRun(jobRun.id, {
115+
await updateJobRun(jobRun, {
124116
status: 'completed',
125117
completedAt: new Date().toISOString(),
126118
result: response.text,
@@ -135,15 +127,15 @@ export const scheduledJobRuns = async () => {
135127
: e instanceof Error
136128
? e.message
137129
: String(e)
138-
await updateJobRun(jobRun.id, {
130+
await updateJobRun(jobRun, {
139131
status: 'failed',
140132
completedAt: new Date().toISOString(),
141133
result: errorMessage,
142134
error: errorMessage,
143135
})
144136
} finally {
145137
runAbortControllers.delete(jobRun.id)
146-
await updateJobLastRunAt(jobId)
138+
await updateJobLastRunAt(job)
147139
}
148140
}
149141

@@ -155,10 +147,11 @@ export const scheduledJobRuns = async () => {
155147
runningMissedJobs = true
156148

157149
try {
158-
const jobs = (await scheduledJobStorage.getValue()).filter(
159-
(j) => j.enabled,
160-
)
161-
const runs = (await scheduledJobRunStorage.getValue()) ?? []
150+
const loadedJobs = await listScheduledJobsOrNull()
151+
const runs = await listScheduledJobRunsOrNull()
152+
if (loadedJobs === null || runs === null) return
153+
154+
const jobs = loadedJobs.filter((j) => j.enabled)
162155
const now = Date.now()
163156
const cutoff = now - TWENTY_FOUR_HOURS_MS
164157

Lines changed: 6 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { storage } from '@wxt-dev/storage'
2-
import { useEffect, useState } from 'react'
3-
import { sendScheduleMessage } from '@/lib/messaging/schedules/scheduleMessages'
4-
import { createAlarmFromJob } from './createAlarmFromJob'
52
import type { ScheduledJob, ScheduledJobRun } from './scheduleTypes'
63

7-
const getAlarmName = (jobId: string) => `scheduled-job-${jobId}`
4+
/**
5+
* Legacy extension storage for scheduled jobs and their runs.
6+
*
7+
* The server owns both now. These items remain only as the source the one-time
8+
* import reads, and nothing writes them any more.
9+
*/
810

911
export const scheduledJobStorage = storage.defineItem<ScheduledJob[]>(
1012
'local:scheduledJobs',
@@ -19,142 +21,3 @@ export const scheduledJobRunStorage = storage.defineItem<ScheduledJobRun[]>(
1921
fallback: [],
2022
},
2123
)
22-
23-
export const pendingDeletionStorage = storage.defineItem<string[]>(
24-
'local:scheduledJobsPendingDeletion',
25-
{
26-
fallback: [],
27-
},
28-
)
29-
30-
export function useScheduledJobs() {
31-
const [jobs, setJobs] = useState<ScheduledJob[]>([])
32-
33-
useEffect(() => {
34-
scheduledJobStorage.getValue().then(setJobs)
35-
const unwatch = scheduledJobStorage.watch((newValue) => {
36-
setJobs(newValue ?? [])
37-
})
38-
return unwatch
39-
}, [])
40-
41-
const addJob = async (
42-
job: Omit<ScheduledJob, 'id' | 'createdAt' | 'updatedAt'>,
43-
) => {
44-
const now = new Date().toISOString()
45-
const newJob: ScheduledJob = {
46-
id: crypto.randomUUID(),
47-
createdAt: now,
48-
updatedAt: now,
49-
...job,
50-
}
51-
const current = (await scheduledJobStorage.getValue()) ?? []
52-
await scheduledJobStorage.setValue([...current, newJob])
53-
54-
if (newJob.enabled) {
55-
await createAlarmFromJob(newJob)
56-
}
57-
}
58-
59-
const removeJob = async (id: string) => {
60-
await chrome.alarms.clear(getAlarmName(id))
61-
62-
const pending = (await pendingDeletionStorage.getValue()) ?? []
63-
if (!pending.includes(id)) {
64-
await pendingDeletionStorage.setValue([...pending, id])
65-
}
66-
67-
const currentJobs = (await scheduledJobStorage.getValue()) ?? []
68-
await scheduledJobStorage.setValue(currentJobs.filter((j) => j.id !== id))
69-
70-
const currentRuns = (await scheduledJobRunStorage.getValue()) ?? []
71-
await scheduledJobRunStorage.setValue(
72-
currentRuns.filter((r) => r.jobId !== id),
73-
)
74-
}
75-
76-
const toggleJob = async (id: string, enabled: boolean) => {
77-
const current = (await scheduledJobStorage.getValue()) ?? []
78-
const job = current.find((j) => j.id === id)
79-
if (!job) return
80-
81-
const updatedAt = new Date().toISOString()
82-
await scheduledJobStorage.setValue(
83-
current.map((j) => (j.id === id ? { ...j, enabled, updatedAt } : j)),
84-
)
85-
86-
if (enabled) {
87-
await createAlarmFromJob({ ...job, enabled })
88-
} else {
89-
await chrome.alarms.clear(getAlarmName(id))
90-
}
91-
}
92-
93-
const editJob = async (
94-
id: string,
95-
updates: Omit<ScheduledJob, 'id' | 'createdAt' | 'updatedAt'>,
96-
) => {
97-
const current = (await scheduledJobStorage.getValue()) ?? []
98-
const existingJob = current.find((j) => j.id === id)
99-
if (!existingJob) return
100-
101-
const updatedJob: ScheduledJob = {
102-
id,
103-
createdAt: existingJob.createdAt,
104-
updatedAt: new Date().toISOString(),
105-
...updates,
106-
}
107-
await scheduledJobStorage.setValue(
108-
current.map((j) => (j.id === id ? updatedJob : j)),
109-
)
110-
111-
await chrome.alarms.clear(getAlarmName(id))
112-
if (updatedJob.enabled) {
113-
await createAlarmFromJob(updatedJob)
114-
}
115-
}
116-
117-
const runJob = async (id: string) => {
118-
return sendScheduleMessage('runScheduledJob', { jobId: id })
119-
}
120-
121-
return { jobs, addJob, removeJob, editJob, toggleJob, runJob }
122-
}
123-
124-
export function useScheduledJobRuns() {
125-
const [jobRuns, setJobRuns] = useState<ScheduledJobRun[]>([])
126-
127-
useEffect(() => {
128-
scheduledJobRunStorage.getValue().then(setJobRuns)
129-
const unwatch = scheduledJobRunStorage.watch((newValue) => {
130-
setJobRuns(newValue ?? [])
131-
})
132-
return unwatch
133-
}, [])
134-
135-
const addJobRun = async (jobRun: ScheduledJobRun) => {
136-
const current = (await scheduledJobRunStorage.getValue()) ?? []
137-
await scheduledJobRunStorage.setValue([...current, jobRun])
138-
}
139-
140-
const removeJobRun = async (id: string) => {
141-
const current = (await scheduledJobRunStorage.getValue()) ?? []
142-
await scheduledJobRunStorage.setValue(current.filter((r) => r.id !== id))
143-
}
144-
145-
const editJobRun = async (
146-
id: string,
147-
updates: Partial<Omit<ScheduledJobRun, 'id'>>,
148-
) => {
149-
const current = (await scheduledJobRunStorage.getValue()) ?? []
150-
await scheduledJobRunStorage.setValue(
151-
current.map((r) => (r.id === id ? { ...r, ...updates } : r)),
152-
)
153-
}
154-
155-
const cancelJobRun = async (runId: string) => {
156-
return sendScheduleMessage('cancelScheduledJobRun', { runId })
157-
}
158-
159-
return { jobRuns, addJobRun, removeJobRun, editJobRun, cancelJobRun }
160-
}

0 commit comments

Comments
 (0)