Skip to content

Commit 6fc7ec6

Browse files
fix(schedules): cascade delete schedule runs/jobs and preserve assistant schedules in orphan cleanup
1 parent 6ae3d07 commit 6fc7ec6

7 files changed

Lines changed: 144 additions & 98 deletions

File tree

backend/src/db/queries.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,8 @@ export function updateRepoBranch(db: Database, id: number, branch: string): void
209209
}
210210

211211
export function deleteRepo(db: Database, id: number): void {
212+
db.prepare('DELETE FROM schedule_runs WHERE repo_id = ?').run(id)
213+
db.prepare('DELETE FROM schedule_jobs WHERE repo_id = ?').run(id)
212214
const stmt = db.prepare('DELETE FROM repos WHERE id = ?')
213215
stmt.run(id)
214216
}
215-

backend/src/db/schedules.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,13 @@ export function updateScheduleJob(db: Database, repoId: number, jobId: number, i
200200
}
201201

202202
export function deleteScheduleJob(db: Database, repoId: number, jobId: number): boolean {
203+
db.prepare('DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ?').run(repoId, jobId)
203204
const stmt = db.prepare('DELETE FROM schedule_jobs WHERE repo_id = ? AND id = ?')
204205
const result = stmt.run(repoId, jobId)
205206
return result.changes > 0
206207
}
207208

208209
export function deleteScheduleJobsByRepo(db: Database, repoId: number): number {
209-
// Delete runs first in case FK enforcement is off (e.g. during migration)
210210
db.prepare('DELETE FROM schedule_runs WHERE repo_id = ?').run(repoId)
211211
const stmt = db.prepare('DELETE FROM schedule_jobs WHERE repo_id = ?')
212212
const result = stmt.run(repoId)
@@ -216,16 +216,16 @@ export function deleteScheduleJobsByRepo(db: Database, repoId: number): number {
216216
export function cleanupOrphanedSchedules(db: Database): { orphanedJobs: number; orphanedRuns: number } {
217217
const runStmt = db.prepare(`
218218
DELETE FROM schedule_runs
219-
WHERE repo_id NOT IN (SELECT id FROM repos)
219+
WHERE (repo_id != ? AND repo_id NOT IN (SELECT id FROM repos))
220220
OR job_id NOT IN (SELECT id FROM schedule_jobs)
221221
`)
222-
const orphanedRuns = runStmt.run().changes
222+
const orphanedRuns = runStmt.run(ASSISTANT_REPO_ID).changes
223223

224224
const jobStmt = db.prepare(`
225225
DELETE FROM schedule_jobs
226-
WHERE repo_id NOT IN (SELECT id FROM repos)
226+
WHERE repo_id != ? AND repo_id NOT IN (SELECT id FROM repos)
227227
`)
228-
const orphanedJobs = jobStmt.run().changes
228+
const orphanedJobs = jobStmt.run(ASSISTANT_REPO_ID).changes
229229

230230
return { orphanedJobs, orphanedRuns }
231231
}

backend/test/db/queries.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,18 +258,31 @@ describe('Database Queries', () => {
258258
})
259259

260260
describe('deleteRepo', () => {
261-
it('should delete repo by ID', () => {
262-
const stmt = {
261+
it('should delete repo schedules before deleting repo by ID', () => {
262+
const deleteRunsStmt = {
263+
run: vi.fn().mockReturnValue({ changes: 2 })
264+
}
265+
const deleteJobsStmt = {
263266
run: vi.fn().mockReturnValue({ changes: 1 })
264267
}
265-
mockDb.prepare.mockReturnValue(stmt)
268+
const deleteRepoStmt = {
269+
run: vi.fn().mockReturnValue({ changes: 1 })
270+
}
271+
mockDb.prepare
272+
.mockReturnValueOnce(deleteRunsStmt)
273+
.mockReturnValueOnce(deleteJobsStmt)
274+
.mockReturnValueOnce(deleteRepoStmt)
266275

267276
db.deleteRepo(mockDb, 1)
268277

269-
expect(mockDb.prepare).toHaveBeenCalledWith(
278+
expect(mockDb.prepare).toHaveBeenNthCalledWith(1, 'DELETE FROM schedule_runs WHERE repo_id = ?')
279+
expect(deleteRunsStmt.run).toHaveBeenCalledWith(1)
280+
expect(mockDb.prepare).toHaveBeenNthCalledWith(2, 'DELETE FROM schedule_jobs WHERE repo_id = ?')
281+
expect(deleteJobsStmt.run).toHaveBeenCalledWith(1)
282+
expect(mockDb.prepare).toHaveBeenNthCalledWith(3,
270283
'DELETE FROM repos WHERE id = ?'
271284
)
272-
expect(stmt.run).toHaveBeenCalledWith(1)
285+
expect(deleteRepoStmt.run).toHaveBeenCalledWith(1)
273286
})
274287
})
275288

backend/test/db/schedules-assistant.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Database } from 'bun:sqlite'
33
import { migrate } from '../../src/db/migration-runner'
44
import { allMigrations } from '../../src/db/migrations'
55
import {
6+
cleanupOrphanedSchedules,
67
listAllScheduleJobsWithRepos,
78
listAllScheduleRuns,
89
} from '../../src/db/schedules'
@@ -101,4 +102,14 @@ describe('assistant repo (repo_id=0) in global aggregate queries', () => {
101102
expect(run.repoName).toBe('Assistant')
102103
expect(run.repoPath).toBe('assistant')
103104
})
105+
106+
it('cleanupOrphanedSchedules keeps assistant schedules while deleting real repo orphans', () => {
107+
db.exec('DELETE FROM repos WHERE id = 1')
108+
109+
const result = cleanupOrphanedSchedules(db)
110+
111+
expect(result).toEqual({ orphanedJobs: 1, orphanedRuns: 1 })
112+
expect(listAllScheduleJobsWithRepos(db).map((job) => job.repoId)).toEqual([0])
113+
expect(listAllScheduleRuns(db, {}).map((run) => run.repoId)).toEqual([0])
114+
})
104115
})

backend/test/db/schedules.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,27 @@ describe('schedule database queries', () => {
173173
})
174174
})
175175

176+
it('deletes schedule runs before deleting a schedule job', () => {
177+
const deleteRunsStmt = {
178+
run: vi.fn().mockReturnValue({ changes: 2 }),
179+
}
180+
const deleteJobStmt = {
181+
run: vi.fn().mockReturnValue({ changes: 1 }),
182+
}
183+
184+
mockDb.prepare
185+
.mockReturnValueOnce(deleteRunsStmt)
186+
.mockReturnValueOnce(deleteJobStmt)
187+
188+
const deleted = schedulesDb.deleteScheduleJob(mockDb, 42, 7)
189+
190+
expect(mockDb.prepare).toHaveBeenNthCalledWith(1, 'DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ?')
191+
expect(deleteRunsStmt.run).toHaveBeenCalledWith(42, 7)
192+
expect(mockDb.prepare).toHaveBeenNthCalledWith(2, 'DELETE FROM schedule_jobs WHERE repo_id = ? AND id = ?')
193+
expect(deleteJobStmt.run).toHaveBeenCalledWith(42, 7)
194+
expect(deleted).toBe(true)
195+
})
196+
176197
it('returns null when updating metadata for a missing run', () => {
177198
const selectStmt = {
178199
get: vi.fn().mockReturnValue(undefined),

frontend/src/components/schedules/PromptTab.tsx

Lines changed: 79 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,77 @@ type PromptTabProps = {
1717
onNewTemplate: () => void
1818
}
1919

20+
type PromptTemplateCardProps = {
21+
template: PromptTemplate
22+
selected?: boolean
23+
onApply?: (template: PromptTemplate) => void
24+
onEdit: (template: PromptTemplate) => void
25+
onDelete: (templateId: number) => void
26+
}
27+
28+
export function PromptTemplateCard({ template, selected = false, onApply, onEdit, onDelete }: PromptTemplateCardProps) {
29+
const cardClassName = `w-full rounded-xl border-2 p-4 text-left transition-all ${
30+
selected
31+
? 'border-primary bg-primary/10 ring-2 ring-primary/30'
32+
: 'border-border bg-card hover:bg-accent/40'
33+
}`
34+
35+
const content = (
36+
<>
37+
<div className="flex flex-wrap items-center gap-2">
38+
<Badge className="border-transparent bg-orange-500 text-[10px] uppercase tracking-wide text-white">
39+
{template.category}
40+
</Badge>
41+
<Badge className="border-transparent bg-slate-600 text-[10px] uppercase tracking-wide text-white">
42+
{template.cadenceHint}
43+
</Badge>
44+
</div>
45+
<div className="mt-3">
46+
<p className="text-sm font-semibold">{template.title}</p>
47+
<p className="mt-1 text-xs text-muted-foreground">{template.description}</p>
48+
</div>
49+
<p className="mt-3 line-clamp-3 text-xs text-muted-foreground">{template.suggestedDescription}</p>
50+
{selected && (
51+
<div className="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary">
52+
<Check className="h-3 w-3 text-primary-foreground" />
53+
</div>
54+
)}
55+
</>
56+
)
57+
58+
return (
59+
<div className="group relative">
60+
{onApply ? (
61+
<button type="button" onClick={() => onApply(template)} className={cardClassName}>
62+
{content}
63+
</button>
64+
) : (
65+
<div className={cardClassName}>{content}</div>
66+
)}
67+
<div className={`absolute top-2 flex gap-1 transition-opacity ${selected || onApply ? 'right-10' : 'right-2'} ${selected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
68+
<Button
69+
type="button"
70+
variant="ghost"
71+
size="icon"
72+
className="h-6 w-6"
73+
onClick={(e) => { e.stopPropagation(); onEdit(template) }}
74+
>
75+
<Pencil className="h-3 w-3" />
76+
</Button>
77+
<Button
78+
type="button"
79+
variant="ghost"
80+
size="icon"
81+
className="h-6 w-6 text-destructive hover:text-destructive"
82+
onClick={(e) => { e.stopPropagation(); onDelete(template.id) }}
83+
>
84+
<Trash2 className="h-3 w-3" />
85+
</Button>
86+
</div>
87+
</div>
88+
)
89+
}
90+
2091
export function PromptTab({
2192
prompt,
2293
onPromptChange,
@@ -50,56 +121,14 @@ export function PromptTab({
50121
const isSelected = selectedPromptTemplateId === template.id
51122

52123
return (
53-
<div key={template.id} className="relative group">
54-
<button
55-
type="button"
56-
onClick={() => onApplyTemplate(template)}
57-
className={`w-full rounded-xl border-2 p-4 text-left transition-all ${
58-
isSelected
59-
? 'border-primary bg-primary/10 ring-2 ring-primary/30'
60-
: 'border-border bg-card hover:bg-accent/40'
61-
}`}
62-
>
63-
<div className="flex flex-wrap items-center gap-2">
64-
<Badge className="text-[10px] uppercase tracking-wide border-transparent bg-orange-500 text-white">
65-
{template.category}
66-
</Badge>
67-
<Badge className="text-[10px] uppercase tracking-wide border-transparent bg-slate-600 text-white">
68-
{template.cadenceHint}
69-
</Badge>
70-
</div>
71-
<div className="mt-3">
72-
<p className="text-sm font-semibold">{template.title}</p>
73-
<p className="mt-1 text-xs text-muted-foreground">{template.description}</p>
74-
</div>
75-
<p className="mt-3 text-xs text-muted-foreground line-clamp-3">{template.suggestedDescription}</p>
76-
{isSelected && (
77-
<div className="absolute top-2 right-2 h-5 w-5 rounded-full bg-primary flex items-center justify-center">
78-
<Check className="h-3 w-3 text-primary-foreground" />
79-
</div>
80-
)}
81-
</button>
82-
<div className={`absolute top-2 right-10 flex gap-1 transition-opacity ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
83-
<Button
84-
type="button"
85-
variant="ghost"
86-
size="icon"
87-
className="h-6 w-6"
88-
onClick={(e) => { e.stopPropagation(); onEditTemplate(template) }}
89-
>
90-
<Pencil className="h-3 w-3" />
91-
</Button>
92-
<Button
93-
type="button"
94-
variant="ghost"
95-
size="icon"
96-
className="h-6 w-6 text-destructive hover:text-destructive"
97-
onClick={(e) => { e.stopPropagation(); onDeleteTemplate(template.id) }}
98-
>
99-
<Trash2 className="h-3 w-3" />
100-
</Button>
101-
</div>
102-
</div>
124+
<PromptTemplateCard
125+
key={template.id}
126+
template={template}
127+
selected={isSelected}
128+
onApply={onApplyTemplate}
129+
onEdit={onEditTemplate}
130+
onDelete={onDeleteTemplate}
131+
/>
103132
)
104133
})}
105134
</div>

frontend/src/components/schedules/PromptsTab.tsx

Lines changed: 8 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import type { CreatePromptTemplateRequest } from '@opencode-manager/shared/types
33
import type { PromptDialog } from '@/hooks/useScheduleUrlState'
44
import { Button } from '@/components/ui/button'
55
import { Card, CardContent } from '@/components/ui/card'
6-
import { Badge } from '@/components/ui/badge'
7-
import { Loader2, FileText, Plus, Upload, Pencil, Trash2 } from 'lucide-react'
6+
import { Loader2, FileText, Plus, Upload } from 'lucide-react'
87
import { usePromptTemplates, useDeletePromptTemplate } from '@/hooks/usePromptTemplates'
98
import { parseMarkdownTemplate } from '@/lib/schedules/markdownTemplate'
109
import { PromptTemplateDialog } from './PromptTemplateDialog'
1110
import { DeleteDialog } from '@/components/ui/delete-dialog'
11+
import { PromptTemplateCard } from './PromptTab'
1212

1313
interface PromptsTabProps {
1414
promptDialog: PromptDialog
@@ -130,41 +130,12 @@ export function PromptsTab({ promptDialog, templateId, onNew, onEdit, onDelete,
130130
) : (
131131
<div className="grid gap-3 lg:grid-cols-2">
132132
{templates.map((template) => (
133-
<div key={template.id} className="group relative rounded-xl border-2 border-border bg-card p-4 text-left transition-all hover:bg-accent/40">
134-
<div className="flex flex-wrap items-center gap-2">
135-
<Badge className="border-transparent bg-orange-500 text-[10px] uppercase tracking-wide text-white">
136-
{template.category}
137-
</Badge>
138-
<Badge className="border-transparent bg-slate-600 text-[10px] uppercase tracking-wide text-white">
139-
{template.cadenceHint}
140-
</Badge>
141-
</div>
142-
<div className="mt-3">
143-
<p className="text-sm font-semibold">{template.title}</p>
144-
<p className="mt-1 text-xs text-muted-foreground">{template.description}</p>
145-
</div>
146-
<p className="mt-3 line-clamp-3 text-xs text-muted-foreground">{template.suggestedDescription}</p>
147-
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
148-
<Button
149-
type="button"
150-
variant="ghost"
151-
size="icon"
152-
className="h-6 w-6"
153-
onClick={() => onEdit(template.id)}
154-
>
155-
<Pencil className="h-3 w-3" />
156-
</Button>
157-
<Button
158-
type="button"
159-
variant="ghost"
160-
size="icon"
161-
className="h-6 w-6 text-destructive hover:text-destructive"
162-
onClick={() => onDelete(template.id)}
163-
>
164-
<Trash2 className="h-3 w-3" />
165-
</Button>
166-
</div>
167-
</div>
133+
<PromptTemplateCard
134+
key={template.id}
135+
template={template}
136+
onEdit={(item) => onEdit(item.id)}
137+
onDelete={onDelete}
138+
/>
168139
))}
169140
</div>
170141
)}

0 commit comments

Comments
 (0)