Skip to content

Commit 70cb62b

Browse files
feat(schedules): support scheduling jobs for the Assistant workspace (#254)
* feat(schedules): support scheduling jobs for the Assistant workspace * refactor(schedules): extract assistant repo constants, rename workspace to schedule-target * feat(navigation): add schedules link to session drawer menu * fix(schedules): document Assistant repoId=0 in generated schedule-management skill
1 parent 9479e1f commit 70cb62b

18 files changed

Lines changed: 402 additions & 115 deletions

backend/src/db/schedules.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type ScheduleRunStatus,
1010
type ScheduleRunTriggerSource,
1111
} from '@opencode-manager/shared/schemas'
12+
import { ASSISTANT_REPO_ID, ASSISTANT_REPO_NAME, ASSISTANT_REPO_PATH } from '@opencode-manager/shared/utils'
1213
import type { ScheduleJobPersistenceInput } from '../services/schedule-config'
1314

1415
interface ScheduleJobRow {
@@ -376,31 +377,36 @@ export interface ScheduleJobWithRepo extends ScheduleJob {
376377
}
377378

378379
interface ScheduleJobWithRepoRow extends ScheduleJobRow {
379-
repo_url: string
380-
repo_path: string
380+
repo_url: string | null
381+
repo_path: string | null
381382
}
382383

383384
function repoNameFromPath(repoPath: string): string {
384385
if (!repoPath || repoPath === '/') return 'Unknown'
385386
return repoPath.split(/[\\/]/).pop() ?? repoPath
386387
}
387388

389+
function resolveRepoDisplay(repoId: number, repoPath: string | null): { repoName: string; repoPath: string } {
390+
if (repoId === ASSISTANT_REPO_ID) {
391+
return { repoName: ASSISTANT_REPO_NAME, repoPath: ASSISTANT_REPO_PATH }
392+
}
393+
return { repoName: repoNameFromPath(repoPath ?? ''), repoPath: repoPath ?? '' }
394+
}
395+
388396
function rowToScheduleJobWithRepo(row: ScheduleJobWithRepoRow): ScheduleJobWithRepo {
389-
const job = rowToScheduleJob(row)
390397
return {
391-
...job,
392-
repoName: repoNameFromPath(row.repo_path),
393-
repoPath: row.repo_path,
394-
repoUrl: row.repo_url,
398+
...rowToScheduleJob(row),
399+
...resolveRepoDisplay(row.repo_id, row.repo_path),
400+
repoUrl: row.repo_url ?? '',
395401
}
396402
}
397403

398404
export function listAllScheduleJobsWithRepos(db: Database): ScheduleJobWithRepo[] {
399405
const stmt = db.prepare(`
400406
SELECT sj.*, r.repo_url, r.local_path as repo_path
401407
FROM schedule_jobs sj
402-
JOIN repos r ON sj.repo_id = r.id
403-
ORDER BY r.local_path, sj.name
408+
LEFT JOIN repos r ON sj.repo_id = r.id
409+
ORDER BY COALESCE(r.local_path, ''), sj.name
404410
`)
405411
const rows = stmt.all() as ScheduleJobWithRepoRow[]
406412
return rows.map(rowToScheduleJobWithRepo)
@@ -414,16 +420,14 @@ export interface ScheduleRunWithContext extends ScheduleRun {
414420

415421
interface ScheduleRunWithContextRow extends ScheduleRunRow {
416422
job_name: string
417-
repo_path: string
423+
repo_path: string | null
418424
}
419425

420426
function rowToScheduleRunWithContext(row: ScheduleRunWithContextRow): ScheduleRunWithContext {
421-
const run = rowToScheduleRun(row)
422427
return {
423-
...run,
428+
...rowToScheduleRun(row),
424429
jobName: row.job_name,
425-
repoName: repoNameFromPath(row.repo_path),
426-
repoPath: row.repo_path,
430+
...resolveRepoDisplay(row.repo_id, row.repo_path),
427431
}
428432
}
429433

@@ -469,7 +473,7 @@ export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions =
469473
sj.name AS job_name, r.local_path AS repo_path
470474
FROM schedule_runs sr
471475
JOIN schedule_jobs sj ON sr.job_id = sj.id
472-
JOIN repos r ON sr.repo_id = r.id
476+
LEFT JOIN repos r ON sr.repo_id = r.id
473477
${whereClause}
474478
ORDER BY sr.started_at DESC
475479
LIMIT ? OFFSET ?

backend/src/services/assistant-mode.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ensureDirectoryExists,
1414
} from './file-operations'
1515
import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas'
16+
import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH } from '@opencode-manager/shared/utils'
1617
import { getReposPath, ENV } from '@opencode-manager/shared/config/env'
1718
import type { Database } from 'bun:sqlite'
1819
import { getOrCreateInternalToken } from './internal-token'
@@ -21,7 +22,7 @@ import { logger } from '../utils/logger'
2122

2223
const ASSISTANT_WARMUP_OPENCODE_TIMEOUT_MS = 90000
2324

24-
const ASSISTANT_MODE_DIR = 'assistant'
25+
const ASSISTANT_MODE_DIR = ASSISTANT_REPO_PATH
2526
const ASSISTANT_MODE_RELATIVE_PATH = 'repos/assistant'
2627
const ASSISTANT_AGENTS_MD_FILENAME = 'AGENTS.md'
2728
const ASSISTANT_OPENCODE_CONFIG_FILENAME = 'opencode.json'
@@ -52,7 +53,7 @@ export function getAssistantModeDirectory(): string {
5253

5354
export function buildAssistantRepo(): Repo {
5455
return {
55-
id: 0,
56+
id: ASSISTANT_REPO_ID,
5657
localPath: ASSISTANT_MODE_DIR,
5758
fullPath: getAssistantModeDirectory(),
5859
defaultBranch: 'main',
@@ -339,6 +340,10 @@ Authorization: Bearer <token>
339340
340341
\`${internalBaseUrl}\`
341342
343+
## Assistant Schedules
344+
345+
Use repo ID \`0\` for the built-in Assistant. For example, use \`/repos/0/schedules\` to list or create schedule jobs that run in the Assistant workspace.
346+
342347
## Endpoints
343348
344349
### GET /schedules/all

backend/src/services/schedules.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { sseAggregator, type SSEEvent } from './sse-aggregator'
3939
import { getErrorMessage } from '../utils/error-utils'
4040
import { logger } from '../utils/logger'
4141
import { buildAssistantRepo } from './assistant-mode'
42+
import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils'
4243

4344
class ScheduleServiceError extends Error {
4445
status: number
@@ -1056,7 +1057,7 @@ export class ScheduleService {
10561057
}
10571058

10581059
private assertRepo(repoId: number) {
1059-
if (repoId === 0) {
1060+
if (repoId === ASSISTANT_REPO_ID) {
10601061
return { ...buildAssistantRepo(), lastAccessedAt: Date.now(), isLocal: true, currentBranch: undefined }
10611062
}
10621063
const repo = getRepoById(this.db, repoId)
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+
})

backend/test/services/assistant-mode.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ describe('buildSchedulesSkill', () => {
1818
expect(skill).toContain(`http://localhost:${ENV.SERVER.PORT}/api/internal`)
1919
expect(skill).not.toContain(':443')
2020
})
21+
22+
it('documents repoId 0 for Assistant schedules', () => {
23+
const skill = buildSchedulesSkill('http://localhost:5003/api/internal')
24+
expect(skill).toContain('Use repo ID `0` for the built-in Assistant')
25+
expect(skill).toContain('/repos/0/schedules')
26+
})
2127
})
2228

2329
describe('buildReposSkill', () => {
@@ -224,6 +230,8 @@ describe('ensureAssistantMode', () => {
224230
const schedulesSkillContent = await readFile(schedulesSkillPath, 'utf8')
225231
expect(schedulesSkillContent).toContain('name: schedule-management')
226232
expect(schedulesSkillContent).toContain('Manage schedule jobs')
233+
expect(schedulesSkillContent).toContain('Use repo ID `0` for the built-in Assistant')
234+
expect(schedulesSkillContent).toContain('/repos/0/schedules')
227235

228236
const notificationsSkillContent = await readFile(notificationsSkillPath, 'utf8')
229237
expect(notificationsSkillContent).toContain('name: notifications')

frontend/src/components/navigation/moreDrawerItems.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,18 @@ describe('buildMoreItems', () => {
3333

3434
it('returns session-specific items for /repos/:id/sessions/:sid', () => {
3535
const items = buildMoreItems('/repos/42/sessions/abc')
36-
expect(items).toHaveLength(8)
36+
expect(items).toHaveLength(9)
3737
expect(items[0].key).toBe('files')
3838
expect(items[1].key).toBe('mcp')
3939
expect(items[2].key).toBe('skills')
4040
expect(items[3].key).toBe('lsp')
4141
expect(items[3].dialog).toBe('lsp')
4242
expect(items[4].key).toBe('reset-permissions')
43-
expect(items[5].key).toBe('source-control')
44-
expect(items[6].key).toBe('settings')
45-
expect(items[7].key).toBe('logout')
43+
expect(items[5].key).toBe('schedules')
44+
expect(items[5].to).toBe('/repos/42/schedules')
45+
expect(items[6].key).toBe('source-control')
46+
expect(items[7].key).toBe('settings')
47+
expect(items[8].key).toBe('logout')
4648
})
4749

4850
it('returns assistant workspace items for /repos/:id/assistant', () => {

frontend/src/components/navigation/moreDrawerItems.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export function buildNavModel(pathname: string): NavModel {
7575
{ key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' },
7676
{ key: 'lsp', label: 'LSP', icon: Code2, dialog: 'lsp' },
7777
{ key: 'reset-permissions', label: 'Reset Permissions', icon: ShieldOff, dialog: 'resetPermissions', danger: true },
78+
{ key: 'schedules', label: 'Schedules', icon: CalendarClock, to: `/repos/${sessionDetailMatch[1]}/schedules` },
7879
{ key: 'source-control', label: 'Source Control', icon: GitCommitHorizontal, dialog: 'sourceControl' },
7980
...baseItems,
8081
]

0 commit comments

Comments
 (0)