Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ For local development setup, see the [Development Guide](https://chriswritescode
- **Git** — Multi-repo support, SSH authentication, worktrees, unified diffs with line numbers, PR creation
- **Files** — Directory browser with tree view, syntax highlighting, create/rename/delete, ZIP download
- **Chat** — Real-time streaming (SSE), slash commands, `@file` mentions, Plan/Build modes, Mermaid diagrams
- **Schedules** — Recurring repo jobs with reusable prompts, run history, linked sessions, and markdown-rendered output
- **Audio** — Text-to-speech (browser + OpenAI-compatible), speech-to-text (browser + OpenAI-compatible)
- **AI** — Model selection, provider config, OAuth for Anthropic/GitHub Copilot, custom agents with system prompts
- **MCP** — Local and remote MCP server support with pre-built templates
Expand Down
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@opencode-manager/shared": "workspace:*",
"archiver": "^7.0.1",
"better-auth": "^1.4.17",
"cron-parser": "^5.5.0",
"dotenv": "^17.2.3",
"eventsource": "^4.1.0",
"hono": "^4.11.7",
Expand All @@ -34,6 +35,7 @@
"@types/bun": "latest",
"@types/eventsource": "^3.0.0",
"@types/web-push": "^3.6.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.39.1",
"typescript-eslint": "^8.45.0",
Expand Down
58 changes: 58 additions & 0 deletions backend/src/db/migrations/007-schedules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Migration } from '../migration-runner'

const migration: Migration = {
version: 7,
name: 'schedules',

up(db) {
db.run(`
CREATE TABLE IF NOT EXISTS schedule_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_minutes INTEGER,
agent_slug TEXT,
prompt TEXT NOT NULL,
model TEXT,
skill_metadata TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_run_at INTEGER,
next_run_at INTEGER
)
`)

db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_repo ON schedule_jobs(repo_id)')
db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_next_run ON schedule_jobs(enabled, next_run_at)')

db.run(`
CREATE TABLE IF NOT EXISTS schedule_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES schedule_jobs(id) ON DELETE CASCADE,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
trigger_source TEXT NOT NULL,
status TEXT NOT NULL,
started_at INTEGER NOT NULL,
finished_at INTEGER,
created_at INTEGER NOT NULL,
session_id TEXT,
session_title TEXT,
log_text TEXT,
response_text TEXT,
error_text TEXT
)
`)

db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_job ON schedule_runs(job_id, started_at DESC)')
db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_repo ON schedule_runs(repo_id, started_at DESC)')
},

down(db) {
db.run('DROP TABLE IF EXISTS schedule_runs')
db.run('DROP TABLE IF EXISTS schedule_jobs')
},
}

export default migration
129 changes: 129 additions & 0 deletions backend/src/db/migrations/008-schedule-cron-support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { Migration } from '../migration-runner'

interface ColumnInfo {
name: string
notnull: number
dflt_value: string | null
}

const migration: Migration = {
version: 8,
name: 'schedule-cron-support',

up(db) {
const tableInfo = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[]
const existingColumns = new Set(tableInfo.map((column) => column.name))
const intervalMinutesColumn = tableInfo.find((column) => column.name === 'interval_minutes')
const scheduleModeColumn = tableInfo.find((column) => column.name === 'schedule_mode')
const hasCronColumns = existingColumns.has('schedule_mode') && existingColumns.has('cron_expression') && existingColumns.has('timezone')
const scheduleModeDefault = scheduleModeColumn?.dflt_value?.replaceAll("'", '')

if (intervalMinutesColumn?.notnull === 0 && hasCronColumns && scheduleModeDefault === 'interval') {
return
}

db.run(`
CREATE TABLE schedule_jobs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_minutes INTEGER,
schedule_mode TEXT NOT NULL DEFAULT 'interval',
cron_expression TEXT,
timezone TEXT,
agent_slug TEXT,
prompt TEXT NOT NULL,
model TEXT,
skill_metadata TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_run_at INTEGER,
next_run_at INTEGER
)
`)

db.run(`
INSERT INTO schedule_jobs_new (
id, repo_id, name, description, enabled, interval_minutes, schedule_mode, cron_expression, timezone,
agent_slug, prompt, model, skill_metadata, created_at, updated_at, last_run_at, next_run_at
)
SELECT
id,
repo_id,
name,
description,
enabled,
interval_minutes,
${existingColumns.has('schedule_mode') ? "COALESCE(schedule_mode, 'interval')" : "'interval'"},
${existingColumns.has('cron_expression') ? 'cron_expression' : 'NULL'},
${existingColumns.has('timezone') ? 'timezone' : 'NULL'},
agent_slug,
prompt,
model,
skill_metadata,
created_at,
updated_at,
last_run_at,
next_run_at
FROM schedule_jobs
`)

db.run('DROP TABLE schedule_jobs')
db.run('ALTER TABLE schedule_jobs_new RENAME TO schedule_jobs')
db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_repo ON schedule_jobs(repo_id)')
db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_next_run ON schedule_jobs(enabled, next_run_at)')
},

down(db) {
db.run(`
CREATE TABLE schedule_jobs_old (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_minutes INTEGER NOT NULL,
agent_slug TEXT,
prompt TEXT NOT NULL,
model TEXT,
skill_metadata TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_run_at INTEGER,
next_run_at INTEGER
)
`)

db.run(`
INSERT INTO schedule_jobs_old (
id, repo_id, name, description, enabled, interval_minutes, agent_slug, prompt, model, skill_metadata,
created_at, updated_at, last_run_at, next_run_at
)
SELECT
id,
repo_id,
name,
description,
enabled,
COALESCE(interval_minutes, 60),
agent_slug,
prompt,
model,
skill_metadata,
created_at,
updated_at,
last_run_at,
next_run_at
FROM schedule_jobs
`)

db.run('DROP TABLE schedule_jobs')
db.run('ALTER TABLE schedule_jobs_old RENAME TO schedule_jobs')
db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_repo ON schedule_jobs(repo_id)')
db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_next_run ON schedule_jobs(enabled, next_run_at)')
},
}

export default migration
4 changes: 4 additions & 0 deletions backend/src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import migration003 from './003-repos-add-columns'
import migration004 from './004-repos-indexes'
import migration005 from './005-repos-local-path-prefix'
import migration006 from './006-git-token-to-credentials'
import migration007 from './007-schedules'
import migration008 from './008-schedule-cron-support'

export const allMigrations: Migration[] = [
migration001,
Expand All @@ -13,4 +15,6 @@ export const allMigrations: Migration[] = [
migration004,
migration005,
migration006,
migration007,
migration008,
]
Loading