Skip to content
Merged
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
22 changes: 22 additions & 0 deletions backend/src/db/migrations/202610271800-schedule-show-in-inbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Migration } from '../migration-runner'

interface ColumnInfo {
name: string
}

const migration: Migration = {
id: '202610271800-schedule-show-in-inbox',

up(db) {
const columns = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[]
if (!columns.some((column) => column.name === 'show_in_inbox')) {
db.run('ALTER TABLE schedule_jobs ADD COLUMN show_in_inbox INTEGER NOT NULL DEFAULT 1')
}
},

down(db) {
db.run('ALTER TABLE schedule_jobs DROP COLUMN show_in_inbox')
},
}

export default migration
2 changes: 2 additions & 0 deletions backend/src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { secretsGlobalScopeIndexMigration } from './202610181410-secrets-global-
import migration202610221800 from './202610221800-session-device-name'
import migration202610231200 from './202610231200-push-subscription-session-id'
import migration202610251900 from './202610251900-schedule-sandbox-config'
import migration202610271800 from './202610271800-schedule-show-in-inbox'
import migration202610291200 from './202610291200-drop-session-goals'

export const allMigrations: Migration[] = [
Expand Down Expand Up @@ -88,5 +89,6 @@ export const allMigrations: Migration[] = [
migration202610221800,
migration202610231200,
migration202610251900,
migration202610271800,
migration202610291200,
]
49 changes: 45 additions & 4 deletions backend/src/db/schedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface ScheduleJobRow {
name: string
description: string | null
enabled: number
show_in_inbox: number
schedule_mode: ScheduleMode | null
interval_minutes: number | null
cron_expression: string | null
Expand Down Expand Up @@ -137,6 +138,7 @@ function rowToScheduleJob(row: ScheduleJobRow): ScheduleJob {
name: row.name,
description: row.description,
enabled: Boolean(row.enabled),
showInInbox: row.show_in_inbox !== 0,
scheduleMode: row.schedule_mode ?? 'interval',
intervalMinutes: row.interval_minutes,
cronExpression: row.cron_expression,
Expand Down Expand Up @@ -243,15 +245,15 @@ export function createScheduleJob(
const now = Date.now()
const stmt = db.prepare(`
INSERT INTO schedule_jobs (
repo_id, server_id, directory, name, description, enabled, schedule_mode, interval_minutes, cron_expression, timezone, agent_slug, prompt, model, skill_metadata,
repo_id, server_id, directory, name, description, enabled, show_in_inbox, schedule_mode, interval_minutes, cron_expression, timezone, agent_slug, prompt, model, skill_metadata,
permission_config,
mcp_servers,
branch,
workspace_mode,
sandbox_config,
created_at, updated_at, last_run_at, next_run_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)

const result = stmt.run(
Expand All @@ -261,6 +263,7 @@ export function createScheduleJob(
input.name,
input.description ?? null,
input.enabled ? 1 : 0,
input.showInInbox ? 1 : 0,
input.scheduleMode,
input.intervalMinutes,
input.cronExpression,
Expand Down Expand Up @@ -297,7 +300,7 @@ export function updateScheduleJob(db: Database, repoId: number | null, jobId: nu

const stmt = db.prepare(`
UPDATE schedule_jobs
SET name = ?, description = ?, enabled = ?, schedule_mode = ?, interval_minutes = ?, cron_expression = ?, timezone = ?,
SET name = ?, description = ?, enabled = ?, show_in_inbox = ?, schedule_mode = ?, interval_minutes = ?, cron_expression = ?, timezone = ?,
agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, permission_config = ?, mcp_servers = ?, branch = ?, workspace_mode = ?, sandbox_config = ?, directory = ?, updated_at = ?, next_run_at = ?
WHERE repo_id IS ? AND id = ?
`)
Expand All @@ -306,6 +309,7 @@ export function updateScheduleJob(db: Database, repoId: number | null, jobId: nu
input.name,
input.description,
input.enabled ? 1 : 0,
input.showInInbox ? 1 : 0,
input.scheduleMode,
input.intervalMinutes,
input.cronExpression,
Expand Down Expand Up @@ -449,6 +453,12 @@ export function createScheduleRun(
return run
}

/**
* Finishes a schedule run and stores its session output. A completed run of a
* job that does not announce itself in the inbox is marked viewed at the same
* timestamp, so quiet schedules never surface as unread; failed and cancelled
* runs stay unread, and the job's setting at finish time decides the outcome.
*/
export function updateScheduleRun(
db: Database,
repoId: number | null,
Expand All @@ -466,11 +476,20 @@ export function updateScheduleRun(
): ScheduleRun | null {
const stmt = db.prepare(`
UPDATE schedule_runs
SET status = ?, finished_at = ?, session_id = ?, session_title = ?, log_text = ?, response_text = ?, error_text = ?
SET status = ?, finished_at = ?,
viewed_at = CASE
WHEN ? = 'completed' AND EXISTS (
SELECT 1 FROM schedule_jobs sj WHERE sj.id = schedule_runs.job_id AND sj.show_in_inbox = 0
) THEN ?
ELSE viewed_at
END,
session_id = ?, session_title = ?, log_text = ?, response_text = ?, error_text = ?
WHERE repo_id IS ? AND job_id = ? AND id = ?
`)

stmt.run(
input.status,
input.finishedAt,
input.status,
input.finishedAt,
input.sessionId ?? null,
Expand Down Expand Up @@ -570,6 +589,28 @@ export function getScheduleRunBySessionId(
return row ? rowToScheduleRun(row) : null
}

/**
* Reports whether the latest run for a session belongs to a job that stays out
* of the inbox. Sessions without a run, and sessions of announcing jobs, are
* not quiet.
*/
export function isQuietScheduleRunSession(
db: Database,
sessionId: string,
serverId: string = LOCAL_OPENCODE_SERVER_ID,
): boolean {
const stmt = db.prepare(`
SELECT sj.show_in_inbox
FROM schedule_runs sr
JOIN schedule_jobs sj ON sj.id = sr.job_id
WHERE sr.server_id = ? AND sr.session_id = ?
ORDER BY sr.started_at DESC
LIMIT 1
`)
const row = stmt.get(serverId, sessionId) as { show_in_inbox: number } | undefined
return row?.show_in_inbox === 0
}

export function getRunningScheduleRunByJob(db: Database, repoId: number | null, jobId: number): ScheduleRun | null {
const stmt = db.prepare(`
SELECT * FROM schedule_runs
Expand Down
2 changes: 2 additions & 0 deletions backend/src/services/assistant-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,8 @@ Optional \`workspaceMode\` sets where runs execute: \`worktree\` (default, a fre

Optional \`sandbox: { enabled, resources: { cpus, memoryMib } | null }\` runs the scheduled session and its subagents in a dedicated sandbox microVM with Docker available. It works only for schedules on the local OpenCode server, requires server sandboxing to be enabled in settings, and the VM and its Docker volume are removed after each run. The run fails if the VM cannot start.

Optional \`showInInbox\` (default \`true\`). When \`false\`, successful runs are stored as already viewed and send no completion push; failed runs still appear in the Reports inbox, and a session execution failure still sends the error push. \`PATCH\` accepts it too, and a change applies to runs that finish afterwards.

\`\`\`json
{
"action": "request",
Expand Down
10 changes: 9 additions & 1 deletion backend/src/services/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
import { SettingsService } from "./settings";
import { sseAggregator, type SSEEvent } from "./sse-aggregator";
import { getRepoName } from "../db/queries";
import { getScheduleRunBySessionId } from "../db/schedules";
import { getScheduleRunBySessionId, isQuietScheduleRunSession } from "../db/schedules";
import { ensurePushSubscriptionsTable } from "../db/push-subscriptions";
import { isAuthSessionActive } from "../db/auth-sessions";
import type { Repo } from "../types/repo";
Expand Down Expand Up @@ -373,6 +373,14 @@ export class NotificationService {
if (await suppressor(event, sessionId, serverId)) return;
}

if (
event.type === NotificationEventType.SESSION_IDLE &&
sessionId &&
isQuietScheduleRunSession(this.db, sessionId, serverId)
) {
return;
}

if (serverId !== LOCAL_OPENCODE_SERVER_ID) {
await this.deliverRemoteEvent(
serverId,
Expand Down
3 changes: 3 additions & 0 deletions backend/src/services/schedule-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface ScheduleJobPersistenceInput {
name: string
description: string | null
enabled: boolean
showInInbox: boolean
scheduleMode: ScheduleMode
intervalMinutes: number | null
cronExpression: string | null
Expand Down Expand Up @@ -101,6 +102,7 @@ export function buildCreateSchedulePersistenceInput(input: CreateScheduleJobRequ
name: input.name.trim(),
description: input.description?.trim() || null,
enabled: input.enabled !== false,
showInInbox: input.showInInbox !== false,
agentSlug: input.agentSlug?.trim() || null,
prompt: input.prompt.trim(),
model: input.model?.trim() || null,
Expand Down Expand Up @@ -159,6 +161,7 @@ export function buildUpdatedSchedulePersistenceInput(
name: input.name?.trim() || existing.name,
description: input.description === undefined ? existing.description : (input.description?.trim() || null),
enabled,
showInInbox: input.showInInbox ?? existing.showInInbox,
scheduleMode: scheduleConfig.scheduleMode,
intervalMinutes: scheduleConfig.intervalMinutes,
cronExpression: scheduleConfig.cronExpression,
Expand Down
33 changes: 33 additions & 0 deletions backend/test/db/schedule-migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import migration021 from '../../src/db/migrations/021-drop-schedule-run-workspac
import migration022 from '../../src/db/migrations/022-schedule-runs-session-index'
import migration024 from '../../src/db/migrations/024-schedule-runs-viewed-at'
import migration202610251900 from '../../src/db/migrations/202610251900-schedule-sandbox-config'
import migration202610271800 from '../../src/db/migrations/202610271800-schedule-show-in-inbox'

describe('schedule migrations', () => {
it('creates schedule jobs with nullable interval minutes in v7', () => {
Expand Down Expand Up @@ -317,3 +318,35 @@ describe('migration 202610251900 - schedule sandbox config', () => {
db.close()
})
})

describe('migration 202610271800 - schedule show in inbox', () => {
it('adds the show_in_inbox column defaulting existing rows to 1 and is idempotent', () => {
const db = new Database(':memory:')
db.run('CREATE TABLE schedule_jobs (id INTEGER PRIMARY KEY, name TEXT)')
db.run("INSERT INTO schedule_jobs (id, name) VALUES (1, 'Existing job')")

migration202610271800.up(db)
migration202610271800.up(db)

const columns = (db.prepare('PRAGMA table_info(schedule_jobs)').all() as { name: string }[]).map((column) => column.name)
expect(columns).toContain('show_in_inbox')

const row = db.prepare('SELECT show_in_inbox FROM schedule_jobs WHERE id = 1').get() as { show_in_inbox: number }
expect(row.show_in_inbox).toBe(1)

db.close()
})

it('drops the column on rollback', () => {
const db = new Database(':memory:')
db.run('CREATE TABLE schedule_jobs (id INTEGER PRIMARY KEY, name TEXT)')

migration202610271800.up(db)
migration202610271800.down(db)

const columns = (db.prepare('PRAGMA table_info(schedule_jobs)').all() as { name: string }[]).map((column) => column.name)
expect(columns).not.toContain('show_in_inbox')

db.close()
})
})
Loading