Skip to content

Commit a64ecb4

Browse files
feat(schedules): support scheduling jobs for the Assistant workspace
1 parent 9479e1f commit a64ecb4

5 files changed

Lines changed: 286 additions & 18 deletions

File tree

backend/src/db/schedules.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
} from '@opencode-manager/shared/schemas'
1212
import type { ScheduleJobPersistenceInput } from '../services/schedule-config'
1313

14+
const ASSISTANT_REPO_ID = 0
15+
const ASSISTANT_REPO_NAME = 'Assistant'
16+
const ASSISTANT_REPO_PATH = 'assistant'
17+
1418
interface ScheduleJobRow {
1519
id: number
1620
repo_id: number
@@ -376,8 +380,8 @@ export interface ScheduleJobWithRepo extends ScheduleJob {
376380
}
377381

378382
interface ScheduleJobWithRepoRow extends ScheduleJobRow {
379-
repo_url: string
380-
repo_path: string
383+
repo_url: string | null
384+
repo_path: string | null
381385
}
382386

383387
function repoNameFromPath(repoPath: string): string {
@@ -387,20 +391,23 @@ function repoNameFromPath(repoPath: string): string {
387391

388392
function rowToScheduleJobWithRepo(row: ScheduleJobWithRepoRow): ScheduleJobWithRepo {
389393
const job = rowToScheduleJob(row)
394+
if (row.repo_id === ASSISTANT_REPO_ID) {
395+
return { ...job, repoName: ASSISTANT_REPO_NAME, repoPath: ASSISTANT_REPO_PATH, repoUrl: '' }
396+
}
390397
return {
391398
...job,
392-
repoName: repoNameFromPath(row.repo_path),
393-
repoPath: row.repo_path,
394-
repoUrl: row.repo_url,
399+
repoName: repoNameFromPath(row.repo_path ?? ''),
400+
repoPath: row.repo_path ?? '',
401+
repoUrl: row.repo_url ?? '',
395402
}
396403
}
397404

398405
export function listAllScheduleJobsWithRepos(db: Database): ScheduleJobWithRepo[] {
399406
const stmt = db.prepare(`
400407
SELECT sj.*, r.repo_url, r.local_path as repo_path
401408
FROM schedule_jobs sj
402-
JOIN repos r ON sj.repo_id = r.id
403-
ORDER BY r.local_path, sj.name
409+
LEFT JOIN repos r ON sj.repo_id = r.id
410+
ORDER BY COALESCE(r.local_path, ''), sj.name
404411
`)
405412
const rows = stmt.all() as ScheduleJobWithRepoRow[]
406413
return rows.map(rowToScheduleJobWithRepo)
@@ -414,16 +421,19 @@ export interface ScheduleRunWithContext extends ScheduleRun {
414421

415422
interface ScheduleRunWithContextRow extends ScheduleRunRow {
416423
job_name: string
417-
repo_path: string
424+
repo_path: string | null
418425
}
419426

420427
function rowToScheduleRunWithContext(row: ScheduleRunWithContextRow): ScheduleRunWithContext {
421428
const run = rowToScheduleRun(row)
429+
if (row.repo_id === ASSISTANT_REPO_ID) {
430+
return { ...run, jobName: row.job_name, repoName: ASSISTANT_REPO_NAME, repoPath: ASSISTANT_REPO_PATH }
431+
}
422432
return {
423433
...run,
424434
jobName: row.job_name,
425-
repoName: repoNameFromPath(row.repo_path),
426-
repoPath: row.repo_path,
435+
repoName: repoNameFromPath(row.repo_path ?? ''),
436+
repoPath: row.repo_path ?? '',
427437
}
428438
}
429439

@@ -469,7 +479,7 @@ export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions =
469479
sj.name AS job_name, r.local_path AS repo_path
470480
FROM schedule_runs sr
471481
JOIN schedule_jobs sj ON sr.job_id = sj.id
472-
JOIN repos r ON sr.repo_id = r.id
482+
LEFT JOIN repos r ON sr.repo_id = r.id
473483
${whereClause}
474484
ORDER BY sr.started_at DESC
475485
LIMIT ? OFFSET ?
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { Database } from 'bun:sqlite'
3+
import { migrate } from '../../src/db/migration-runner'
4+
import { allMigrations } from '../../src/db/migrations'
5+
import {
6+
listAllScheduleJobsWithRepos,
7+
listAllScheduleRuns,
8+
} from '../../src/db/schedules'
9+
10+
describe('assistant repo (repo_id=0) in global aggregate queries', () => {
11+
let db: Database
12+
13+
beforeEach(() => {
14+
db = new Database(':memory:')
15+
// Disable FK enforcement to match bun:sqlite default behavior (OFF)
16+
db.exec('PRAGMA foreign_keys = OFF')
17+
migrate(db, allMigrations)
18+
19+
const now = Date.now()
20+
21+
// Insert a real repo (id=1)
22+
db.exec(
23+
`INSERT INTO repos (id, repo_url, local_path, branch, default_branch, clone_status, cloned_at)
24+
VALUES (1, 'https://github.com/test/my-repo', 'repos/my-repo', 'main', 'main', 'ready', ${now})`,
25+
)
26+
27+
// Insert a schedule job for the real repo
28+
db.exec(
29+
`INSERT INTO schedule_jobs (id, repo_id, name, enabled, schedule_mode, prompt, created_at, updated_at)
30+
VALUES (1, 1, 'Real repo job', 1, 'interval', 'Run the real repo job', ${now}, ${now})`,
31+
)
32+
33+
// Insert a schedule job for the assistant (repo_id=0)
34+
db.exec(
35+
`INSERT INTO schedule_jobs (id, repo_id, name, enabled, schedule_mode, prompt, created_at, updated_at)
36+
VALUES (2, 0, 'Assistant job', 1, 'interval', 'Run the assistant job', ${now}, ${now})`,
37+
)
38+
39+
// Insert a schedule run for the real repo job
40+
db.exec(
41+
`INSERT INTO schedule_runs (id, job_id, repo_id, trigger_source, status, started_at, created_at)
42+
VALUES (1, 1, 1, 'manual', 'completed', ${now}, ${now})`,
43+
)
44+
45+
// Insert a schedule run for the assistant job
46+
db.exec(
47+
`INSERT INTO schedule_runs (id, job_id, repo_id, trigger_source, status, started_at, created_at)
48+
VALUES (2, 2, 0, 'manual', 'completed', ${now}, ${now})`,
49+
)
50+
})
51+
52+
it('listAllScheduleJobsWithRepos includes assistant jobs with synthetic metadata', () => {
53+
const jobs = listAllScheduleJobsWithRepos(db)
54+
expect(jobs).toHaveLength(2)
55+
56+
const assistantJob = jobs.find(j => j.repoId === 0)
57+
expect(assistantJob).toBeDefined()
58+
if (assistantJob) {
59+
expect(assistantJob.repoName).toBe('Assistant')
60+
expect(assistantJob.repoPath).toBe('assistant')
61+
expect(assistantJob.repoUrl).toBe('')
62+
expect(assistantJob.name).toBe('Assistant job')
63+
}
64+
65+
const realJob = jobs.find(j => j.repoId === 1)
66+
expect(realJob).toBeDefined()
67+
if (realJob) {
68+
expect(realJob.repoName).toBe('my-repo')
69+
expect(realJob.repoPath).toBe('repos/my-repo')
70+
expect(realJob.repoUrl).toBe('https://github.com/test/my-repo')
71+
expect(realJob.name).toBe('Real repo job')
72+
}
73+
})
74+
75+
it('listAllScheduleRuns includes assistant runs with synthetic metadata', () => {
76+
const runs = listAllScheduleRuns(db, {})
77+
expect(runs).toHaveLength(2)
78+
79+
const assistantRun = runs.find(r => r.repoId === 0)
80+
expect(assistantRun).toBeDefined()
81+
if (assistantRun) {
82+
expect(assistantRun.repoName).toBe('Assistant')
83+
expect(assistantRun.repoPath).toBe('assistant')
84+
expect(assistantRun.jobName).toBe('Assistant job')
85+
}
86+
87+
const realRun = runs.find(r => r.repoId === 1)
88+
expect(realRun).toBeDefined()
89+
if (realRun) {
90+
expect(realRun.repoName).toBe('my-repo')
91+
expect(realRun.repoPath).toBe('repos/my-repo')
92+
expect(realRun.jobName).toBe('Real repo job')
93+
}
94+
})
95+
96+
it('listAllScheduleRuns with repoId=0 filter returns only assistant runs', () => {
97+
const runs = listAllScheduleRuns(db, { repoId: 0 })
98+
expect(runs).toHaveLength(1)
99+
const run = runs[0]!
100+
expect(run.repoId).toBe(0)
101+
expect(run.repoName).toBe('Assistant')
102+
expect(run.repoPath).toBe('assistant')
103+
})
104+
})
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { render, screen, waitFor } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5+
import { ScheduleJobDialog } from './ScheduleJobDialog'
6+
7+
// jsdom does not implement scrollIntoView
8+
Element.prototype.scrollIntoView = vi.fn()
9+
10+
const mocks = vi.hoisted(() => ({
11+
templates: [] as Array<{ id: number; title: string; description: string; category: string; cadenceHint: string; suggestedName: string; suggestedDescription: string; prompt: string }>,
12+
useDeletePromptTemplateMutate: vi.fn(),
13+
}))
14+
15+
vi.mock('@/hooks/usePromptTemplates', () => ({
16+
usePromptTemplates: () => ({ data: mocks.templates, isLoading: false }),
17+
useCreatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }),
18+
useUpdatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }),
19+
useDeletePromptTemplate: () => ({ mutate: mocks.useDeletePromptTemplateMutate, isPending: false }),
20+
}))
21+
22+
vi.mock('@/api/providers', () => ({
23+
getProvidersWithModels: () => Promise.resolve([]),
24+
}))
25+
26+
vi.mock('@/api/opencode', () => ({
27+
createOpenCodeClient: () => ({
28+
listAgents: () => Promise.resolve([]),
29+
getConfig: () => Promise.resolve(null),
30+
}),
31+
}))
32+
33+
vi.mock('@/api/settings', () => ({
34+
settingsApi: {
35+
listManagedSkills: () => Promise.resolve([]),
36+
},
37+
}))
38+
39+
vi.mock('@/api/repos', () => ({
40+
listRepos: () => Promise.resolve([]),
41+
}))
42+
43+
function createWrapper() {
44+
const queryClient = new QueryClient({
45+
defaultOptions: {
46+
queries: { retry: false },
47+
mutations: { retry: false },
48+
},
49+
})
50+
return ({ children }: { children: React.ReactNode }) => (
51+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
52+
)
53+
}
54+
55+
describe('ScheduleJobDialog — assistant create guard', () => {
56+
beforeEach(() => {
57+
vi.clearAllMocks()
58+
})
59+
60+
it('renders Assistant as the first repository option', async () => {
61+
const onRepoChange = vi.fn()
62+
const onSubmit = vi.fn()
63+
const onOpenChange = vi.fn()
64+
const user = userEvent.setup()
65+
66+
render(
67+
<ScheduleJobDialog
68+
open
69+
onOpenChange={onOpenChange}
70+
showRepoSelector
71+
repoId={undefined}
72+
onRepoChange={onRepoChange}
73+
onSubmit={onSubmit}
74+
isSaving={false}
75+
/>,
76+
{ wrapper: createWrapper() },
77+
)
78+
79+
// Open the repo combobox by clicking the chevron/input
80+
const repoInput = screen.getByPlaceholderText('Select a repository')
81+
await user.click(repoInput)
82+
83+
// The dropdown should open and show "Assistant" as an option
84+
await waitFor(() => {
85+
expect(screen.getByText('Assistant')).toBeInTheDocument()
86+
})
87+
// Assistant Workspace description should also be visible
88+
expect(screen.getByText('Assistant Workspace')).toBeInTheDocument()
89+
})
90+
91+
it('disables submit when no repo is selected, enables when Assistant repo is selected', async () => {
92+
const onRepoChange = vi.fn()
93+
const onSubmit = vi.fn()
94+
const user = userEvent.setup()
95+
96+
const { rerender } = render(
97+
<ScheduleJobDialog
98+
open
99+
onOpenChange={vi.fn()}
100+
showRepoSelector
101+
repoId={undefined}
102+
onRepoChange={onRepoChange}
103+
onSubmit={onSubmit}
104+
isSaving={false}
105+
/>,
106+
{ wrapper: createWrapper() },
107+
)
108+
109+
// Wait for queries to settle
110+
await waitFor(() => {
111+
expect(screen.getByRole('button', { name: /Create schedule/i })).toBeDisabled()
112+
})
113+
114+
// Fill in required name field
115+
const nameInput = screen.getByLabelText('Name')
116+
await user.type(nameInput, 'Test Assistant Job')
117+
118+
// Switch to Prompt tab to fill prompt
119+
const promptTab = screen.getByRole('tab', { name: 'Prompt' })
120+
await user.click(promptTab)
121+
122+
// Fill in required prompt field
123+
const promptInput = screen.getByRole('textbox', { name: 'Prompt' })
124+
await user.type(promptInput, 'Run a test analysis')
125+
126+
// Submit button should still be disabled (repoId is undefined)
127+
const submitButton = screen.getByRole('button', { name: /Create schedule/i })
128+
expect(submitButton).toBeDisabled()
129+
130+
// Re-render with repoId={0} (Assistant selected) — name and prompt state persists
131+
rerender(
132+
<ScheduleJobDialog
133+
open
134+
onOpenChange={vi.fn()}
135+
showRepoSelector
136+
repoId={0}
137+
onRepoChange={onRepoChange}
138+
onSubmit={onSubmit}
139+
isSaving={false}
140+
/>,
141+
)
142+
143+
// Submit button should now be enabled
144+
await waitFor(() => {
145+
expect(screen.getByRole('button', { name: /Create schedule/i })).not.toBeDisabled()
146+
})
147+
})
148+
})

frontend/src/components/schedules/ScheduleJobDialog.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type SchedulePreset,
1919
} from '@/components/schedules/schedule-utils'
2020
import { getRepoDisplayName } from '@/lib/utils'
21+
import { ASSISTANT_REPO_ID } from '@/lib/schedules/workspace'
2122
import { Loader2 } from 'lucide-react'
2223
import { usePromptTemplates, useDeletePromptTemplate } from '@/hooks/usePromptTemplates'
2324
import { PromptTemplateDialog } from './PromptTemplateDialog'
@@ -107,16 +108,21 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
107108
staleTime: 5 * 60 * 1000,
108109
})
109110

110-
const repoOptions = useMemo<ComboboxOption[]>(() =>
111-
repos
111+
const repoOptions = useMemo<ComboboxOption[]>(() => {
112+
const assistantOption: ComboboxOption = {
113+
value: ASSISTANT_REPO_ID.toString(),
114+
label: 'Assistant',
115+
description: 'Assistant Workspace',
116+
}
117+
const repoEntries = repos
112118
.filter((repo) => repo.cloneStatus === 'ready')
113119
.map((repo) => ({
114120
value: repo.id.toString(),
115121
label: getRepoDisplayName(repo.repoUrl, repo.localPath, repo.sourcePath),
116122
description: repo.localPath,
117-
})),
118-
[repos]
119-
)
123+
}))
124+
return [assistantOption, ...repoEntries]
125+
}, [repos])
120126

121127
const modelOptions = useMemo<ComboboxOption[]>(() => {
122128
const configuredModels: ComboboxOption[] = []
@@ -335,7 +341,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
335341

336342
<div className="mt-0 shrink-0 border-t border-border px-3 sm:px-6 py-4 flex flex-row gap-2 sm:justify-end">
337343
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving} className="flex-1 sm:flex-none">Cancel</Button>
338-
<Button onClick={handleSubmit} disabled={isSaving || !name.trim() || !prompt.trim() || isScheduleConfigInvalid || (!!showRepoSelector && !job && !selectedRepoId)} className="flex-1 sm:flex-none">
344+
<Button onClick={handleSubmit} disabled={isSaving || !name.trim() || !prompt.trim() || isScheduleConfigInvalid || (!!showRepoSelector && !job && selectedRepoId === undefined)} className="flex-1 sm:flex-none">
339345
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
340346
{isSaving ? 'Saving...' : job ? 'Save changes' : 'Create schedule'}
341347
</Button>

frontend/src/pages/GlobalSchedules.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ export function GlobalSchedules() {
254254
}
255255

256256
const handleCreate = (data: CreateScheduleJobRequest) => {
257-
if (!selectedRepoId) return
257+
if (selectedRepoId === undefined) return
258258
createMutation.mutate(
259259
{ repoId: selectedRepoId, data },
260260
{

0 commit comments

Comments
 (0)