Skip to content

Commit ea29360

Browse files
committed
feat(server): carry the per-job run cap across with the runs
The extension kept fifteen runs per job, trimming as it created each one. Now that it no longer owns the history that policy has to live here, or the table grows without bound. It applies on every write rather than only on creation, which is bounded and idempotent, so it holds however the run was written. The import path does not prune, staying purely additive; the next real run trims.
1 parent 959ff4a commit ea29360

4 files changed

Lines changed: 112 additions & 2 deletions

File tree

packages/browseros-agent/apps/server/src/api/routes/scheduled-job-runs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ export function createScheduledJobRunRoutes(
7878
...c.req.valid('json'),
7979
id: c.req.valid('param').runId,
8080
})
81+
// Every write, not just the first: a run is written twice, when it
82+
// starts and when it finishes, and pruning is bounded and idempotent.
83+
// The import path deliberately does not prune, so it stays additive.
84+
await store.prune(run.jobId)
8185
return c.json({ run })
8286
},
8387
)

packages/browseros-agent/apps/server/src/lib/schedules/run-store.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: AGPL-3.0-or-later
55
*/
66

7-
import { desc, eq } from 'drizzle-orm'
7+
import { desc, eq, inArray } from 'drizzle-orm'
88
import { getDb } from '../db'
99
import {
1010
type NewScheduledJobRunRow,
@@ -24,6 +24,14 @@ export type ScheduledJobRunUpsert = Omit<
2424
createdAt?: number
2525
}
2626

27+
/**
28+
* Runs kept per job. The extension applied this cap when it owned the history,
29+
* trimming as it created each run; keeping the number here means it holds
30+
* however the run was written rather than only on the path that happened to
31+
* enforce it.
32+
*/
33+
export const MAX_RUNS_PER_JOB = 15
34+
2735
export interface ScheduledJobRunStore {
2836
list(): Promise<ScheduledJobRunRow[]>
2937
get(id: string): Promise<ScheduledJobRunRow | null>
@@ -34,6 +42,8 @@ export interface ScheduledJobRunStore {
3442
* exists. Used by the one-time import for the reason on the provider store. */
3543
insertIfAbsent(row: ScheduledJobRunUpsert): Promise<ScheduledJobRunRow | null>
3644
remove(id: string): Promise<boolean>
45+
/** Drops all but the newest `keep` runs of a job. Returns how many went. */
46+
prune(jobId: string, keep?: number): Promise<number>
3747
}
3848

3949
async function list(): Promise<ScheduledJobRunRow[]> {
@@ -78,6 +88,26 @@ async function insertIfAbsent(
7888
return saved ?? null
7989
}
8090

91+
async function prune(
92+
jobId: string,
93+
keep: number = MAX_RUNS_PER_JOB,
94+
): Promise<number> {
95+
const rows = await getDb()
96+
.select({ id: scheduledJobRuns.id })
97+
.from(scheduledJobRuns)
98+
.where(eq(scheduledJobRuns.jobId, jobId))
99+
.orderBy(desc(scheduledJobRuns.startedAt))
100+
.all()
101+
102+
const stale = rows.slice(keep).map((row) => row.id)
103+
if (stale.length === 0) return 0
104+
105+
await getDb()
106+
.delete(scheduledJobRuns)
107+
.where(inArray(scheduledJobRuns.id, stale))
108+
return stale.length
109+
}
110+
81111
async function remove(id: string): Promise<boolean> {
82112
const deleted = await getDb()
83113
.delete(scheduledJobRuns)
@@ -92,4 +122,5 @@ export const dbScheduledJobRunStore: ScheduledJobRunStore = {
92122
upsert,
93123
insertIfAbsent,
94124
remove,
125+
prune,
95126
}

packages/browseros-agent/apps/server/tests/api/routes/scheduled-job-runs.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ function memoryStore(initial: ScheduledJobRunRow[] = []) {
4848
return store.upsert(input)
4949
},
5050
remove: async (id) => rows.delete(id),
51+
prune: async (jobId, keep = 15) => {
52+
const ofJob = [...rows.values()]
53+
.filter((r) => r.jobId === jobId)
54+
.sort((a, b) => b.startedAt - a.startedAt)
55+
const stale = ofJob.slice(keep)
56+
for (const run of stale) rows.delete(run.id)
57+
return stale.length
58+
},
5159
}
5260
return { store, rows }
5361
}
@@ -103,6 +111,25 @@ describe('scheduled job run routes', () => {
103111
expect(rows.get(RUN_ID)?.toolCalls).toEqual(toolCalls)
104112
})
105113

114+
// The cap moved here from the extension, so a write has to apply it or the
115+
// history grows without bound now that nothing else trims it.
116+
it('trims a job past the run cap on write', async () => {
117+
const existing = Array.from({ length: 15 }, (_, i) =>
118+
row({ id: `run-${i}`, startedAt: 1000 + i }),
119+
)
120+
const { store, rows } = memoryStore(existing)
121+
122+
await put(
123+
createScheduledJobRunRoutes({ store }),
124+
{ ...body, startedAt: 9999 },
125+
'run-new',
126+
)
127+
128+
expect(rows.size).toBe(15)
129+
expect(rows.has('run-0')).toBe(false)
130+
expect(rows.has('run-new')).toBe(true)
131+
})
132+
106133
it('rejects a status the schema does not know', async () => {
107134
const routes = createScheduledJobRunRoutes(memoryStore())
108135
const response = await put(routes, { ...body, status: 'cancelled' })

packages/browseros-agent/apps/server/tests/lib/schedules/run-store.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { rm } from 'node:fs/promises'
44
import { tmpdir } from 'node:os'
55
import { join } from 'node:path'
66
import { closeDb, initializeDb } from '../../../src/lib/db'
7-
import { dbScheduledJobRunStore } from '../../../src/lib/schedules/run-store'
7+
import {
8+
dbScheduledJobRunStore,
9+
MAX_RUNS_PER_JOB,
10+
} from '../../../src/lib/schedules/run-store'
811
import { dbScheduledJobStore } from '../../../src/lib/schedules/schedule-store'
912

1013
const JOB_ID = 'job-1'
@@ -119,4 +122,49 @@ describe('dbScheduledJobRunStore', () => {
119122
'old',
120123
])
121124
})
125+
126+
// The extension applied this cap while it owned the history, so keeping it
127+
// is preserving behaviour rather than adding a policy.
128+
test('prune keeps the newest runs of a job and drops the rest', async () => {
129+
await useTempDbWithJob()
130+
for (let i = 0; i < MAX_RUNS_PER_JOB + 5; i += 1) {
131+
await dbScheduledJobRunStore.upsert(
132+
baseRun({ id: `run-${i}`, startedAt: 1000 + i }),
133+
)
134+
}
135+
136+
const dropped = await dbScheduledJobRunStore.prune(JOB_ID)
137+
138+
expect(dropped).toBe(5)
139+
const remaining = await dbScheduledJobRunStore.list()
140+
expect(remaining).toHaveLength(MAX_RUNS_PER_JOB)
141+
expect(remaining[0].startedAt).toBe(1000 + MAX_RUNS_PER_JOB + 4)
142+
})
143+
144+
test('prune leaves a job under the cap alone', async () => {
145+
await useTempDbWithJob()
146+
await dbScheduledJobRunStore.upsert(baseRun())
147+
148+
expect(await dbScheduledJobRunStore.prune(JOB_ID)).toBe(0)
149+
expect(await dbScheduledJobRunStore.list()).toHaveLength(1)
150+
})
151+
152+
test('prune only touches the job it was given', async () => {
153+
await useTempDbWithJob()
154+
await dbScheduledJobStore.upsert({ ...baseJob(), id: 'job-2' })
155+
await dbScheduledJobRunStore.upsert(
156+
baseRun({ id: 'other', jobId: 'job-2' }),
157+
)
158+
for (let i = 0; i < MAX_RUNS_PER_JOB + 2; i += 1) {
159+
await dbScheduledJobRunStore.upsert(
160+
baseRun({ id: `run-${i}`, startedAt: 1000 + i }),
161+
)
162+
}
163+
164+
await dbScheduledJobRunStore.prune(JOB_ID)
165+
166+
const ids = (await dbScheduledJobRunStore.list()).map((r) => r.id)
167+
expect(ids).toContain('other')
168+
expect(ids).toHaveLength(MAX_RUNS_PER_JOB + 1)
169+
})
122170
})

0 commit comments

Comments
 (0)