From 65066ab0dc9452acd41746a302ed6c995ac76082 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:11:35 -0400 Subject: [PATCH] feat(schedules): per-schedule switch to keep runs out of the inbox and notifications --- .../202610271800-schedule-show-in-inbox.ts | 22 ++ backend/src/db/migrations/index.ts | 2 + backend/src/db/schedules.ts | 49 ++- backend/src/services/assistant-mode.ts | 2 + backend/src/services/notification.ts | 10 +- backend/src/services/schedule-config.ts | 3 + backend/test/db/schedule-migrations.test.ts | 33 ++ .../test/db/schedule-show-in-inbox.test.ts | 288 ++++++++++++++++++ backend/test/db/schedules.permission.test.ts | 1 + backend/test/db/schedules.test.ts | 12 +- .../test/services/git/GitService.real.test.ts | 1 + .../services/notification-service.test.ts | 150 ++++++++- backend/test/services/schedule-config.test.ts | 82 +++++ .../test/services/schedules-remote.test.ts | 1 + .../services/schedules.permission.test.ts | 1 + backend/test/services/schedules.test.ts | 10 + .../src/components/schedules/GeneralTab.tsx | 14 + .../src/components/schedules/JobDetailTab.tsx | 5 + .../schedules/RepoSchedulesContent.test.tsx | 1 + .../ScheduleJobDialog.inbox.test.tsx | 191 ++++++++++++ .../schedules/ScheduleJobDialog.mcp.test.tsx | 1 + .../ScheduleJobDialog.permissions.test.tsx | 7 +- .../ScheduleJobDialog.sandbox.test.tsx | 1 + .../ScheduleJobDialog.serverTarget.test.tsx | 1 + .../schedules/ScheduleJobDialog.tsx | 5 + .../__tests__/schedule-utils.test.ts | 9 +- .../components/schedules/schedule-utils.ts | 7 + shared/src/schemas/schedule.ts | 18 ++ 28 files changed, 914 insertions(+), 13 deletions(-) create mode 100644 backend/src/db/migrations/202610271800-schedule-show-in-inbox.ts create mode 100644 backend/test/db/schedule-show-in-inbox.test.ts create mode 100644 frontend/src/components/schedules/ScheduleJobDialog.inbox.test.tsx diff --git a/backend/src/db/migrations/202610271800-schedule-show-in-inbox.ts b/backend/src/db/migrations/202610271800-schedule-show-in-inbox.ts new file mode 100644 index 000000000..eddb0cfa7 --- /dev/null +++ b/backend/src/db/migrations/202610271800-schedule-show-in-inbox.ts @@ -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 diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 433dc5899..65fd0e26b 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -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[] = [ @@ -88,5 +89,6 @@ export const allMigrations: Migration[] = [ migration202610221800, migration202610231200, migration202610251900, + migration202610271800, migration202610291200, ] diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index 20f72ba72..20b1da3fc 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -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 @@ -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, @@ -243,7 +245,7 @@ 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, @@ -251,7 +253,7 @@ export function createScheduleJob( sandbox_config, created_at, updated_at, last_run_at, next_run_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) const result = stmt.run( @@ -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, @@ -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 = ? `) @@ -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, @@ -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, @@ -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, @@ -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 diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index 28c229d63..d6195daea 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -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", diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts index c438889ac..b634e840c 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -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"; @@ -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, diff --git a/backend/src/services/schedule-config.ts b/backend/src/services/schedule-config.ts index 034c15f12..a41b39558 100644 --- a/backend/src/services/schedule-config.ts +++ b/backend/src/services/schedule-config.ts @@ -17,6 +17,7 @@ export interface ScheduleJobPersistenceInput { name: string description: string | null enabled: boolean + showInInbox: boolean scheduleMode: ScheduleMode intervalMinutes: number | null cronExpression: string | null @@ -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, @@ -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, diff --git a/backend/test/db/schedule-migrations.test.ts b/backend/test/db/schedule-migrations.test.ts index 94e32cd91..6af512a33 100644 --- a/backend/test/db/schedule-migrations.test.ts +++ b/backend/test/db/schedule-migrations.test.ts @@ -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', () => { @@ -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() + }) +}) diff --git a/backend/test/db/schedule-show-in-inbox.test.ts b/backend/test/db/schedule-show-in-inbox.test.ts new file mode 100644 index 000000000..47af0de7c --- /dev/null +++ b/backend/test/db/schedule-show-in-inbox.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from 'vitest' +import { Database } from 'bun:sqlite' +import { CreateScheduleJobRequestSchema, type ScheduleJob, type ScheduleRun, type ScheduleRunStatus, type ScheduleRunTriggerSource } from '@opencode-manager/shared/schemas' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { createRepo } from '../../src/db/queries' +import { + countUnreadScheduleRuns, + createScheduleJob, + createScheduleRun, + getScheduleJobById, + getScheduleRunById, + listAllScheduleJobsWithRepos, + listAllScheduleRuns, + listScheduleRunsByJob, + isQuietScheduleRunSession, + listUnreadScheduleRuns, + updateScheduleJob, + updateScheduleRun, + updateScheduleRunMetadata, +} from '../../src/db/schedules' +import { + buildCreateSchedulePersistenceInput, + buildUpdatedSchedulePersistenceInput, +} from '../../src/services/schedule-config' + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +function createTestJob(db: Database, overrides: Record = {}, serverId?: string): ScheduleJob { + const repo = createRepo(db, { + localPath: '/abs/repo', + sourcePath: '/abs/repo', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + }) + const request = CreateScheduleJobRequestSchema.parse({ + name: 'Daily health check', + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Review the repository and summarize risks.', + ...overrides, + }) + return createScheduleJob(db, repo.id, buildCreateSchedulePersistenceInput(request), serverId) +} + +describe('schedule job show in inbox', () => { + it('defaults a new job to showInInbox true', () => { + const db = createTestDb() + const created = createTestJob(db) + + const loaded = getScheduleJobById(db, created.repoId, created.id) + + expect(created.showInInbox).toBe(true) + expect(loaded?.showInInbox).toBe(true) + + db.close() + }) + + it('persists showInInbox false and reads it back from both queries', () => { + const db = createTestDb() + const created = createTestJob(db, { showInInbox: false }) + + const loaded = getScheduleJobById(db, created.repoId, created.id) + const listed = listAllScheduleJobsWithRepos(db).find((job) => job.id === created.id) + + expect(created.showInInbox).toBe(false) + expect(loaded?.showInInbox).toBe(false) + expect(listed?.showInInbox).toBe(false) + + db.close() + }) + + it('preserves showInInbox false when an update omits it', () => { + const db = createTestDb() + const created = createTestJob(db, { showInInbox: false }) + + const updated = updateScheduleJob( + db, + created.repoId, + created.id, + buildUpdatedSchedulePersistenceInput(created, { name: 'Renamed job' }), + ) + + expect(updated?.name).toBe('Renamed job') + expect(updated?.showInInbox).toBe(false) + + db.close() + }) + + it('sets showInInbox back to true when an update provides it', () => { + const db = createTestDb() + const created = createTestJob(db, { showInInbox: false }) + + const updated = updateScheduleJob( + db, + created.repoId, + created.id, + buildUpdatedSchedulePersistenceInput(created, { showInInbox: true }), + ) + + expect(updated?.showInInbox).toBe(true) + + db.close() + }) +}) + +function startRun( + db: Database, + job: ScheduleJob, + triggerSource: ScheduleRunTriggerSource = 'schedule', + serverId?: string, +): ScheduleRun { + const startedAt = Date.now() + return createScheduleRun(db, { + jobId: job.id, + repoId: job.repoId, + serverId, + triggerSource, + status: 'running', + startedAt, + createdAt: startedAt, + }) +} + +function finishRun( + db: Database, + job: ScheduleJob, + runId: number, + status: ScheduleRunStatus, + finishedAt: number, +): ScheduleRun | null { + return updateScheduleRun(db, job.repoId, job.id, runId, { status, finishedAt }) +} + +describe('finishing runs', () => { + it('stores a completed quiet run as viewed while keeping it in history', () => { + const db = createTestDb() + const job = createTestJob(db, { showInInbox: false }) + const run = startRun(db, job) + const finishedAt = run.startedAt + 5_000 + + finishRun(db, job, run.id, 'completed', finishedAt) + + const loaded = getScheduleRunById(db, job.repoId, job.id, run.id) + expect(loaded?.viewedAt).toBe(finishedAt) + expect(listUnreadScheduleRuns(db)).toHaveLength(0) + expect(countUnreadScheduleRuns(db)).toEqual({ total: 0, failed: 0 }) + expect(listScheduleRunsByJob(db, job.repoId, job.id).map((entry) => entry.id)).toContain(run.id) + expect(listAllScheduleRuns(db, { jobId: job.id }).map((entry) => entry.id)).toContain(run.id) + + db.close() + }) + + it('leaves a failed quiet run unread', () => { + const db = createTestDb() + const job = createTestJob(db, { showInInbox: false }) + const run = startRun(db, job) + + finishRun(db, job, run.id, 'failed', run.startedAt + 5_000) + + const loaded = getScheduleRunById(db, job.repoId, job.id, run.id) + expect(loaded?.viewedAt).toBeNull() + expect(listUnreadScheduleRuns(db).map((entry) => entry.id)).toEqual([run.id]) + expect(countUnreadScheduleRuns(db)).toEqual({ total: 1, failed: 1 }) + + db.close() + }) + + it('stores a completed manual run of a quiet job as viewed', () => { + const db = createTestDb() + const job = createTestJob(db, { showInInbox: false }) + const run = startRun(db, job, 'manual') + const finishedAt = run.startedAt + 5_000 + + finishRun(db, job, run.id, 'completed', finishedAt) + + expect(getScheduleRunById(db, job.repoId, job.id, run.id)?.viewedAt).toBe(finishedAt) + expect(countUnreadScheduleRuns(db)).toEqual({ total: 0, failed: 0 }) + + db.close() + }) + + it('keeps a completed announcing run unread', () => { + const db = createTestDb() + const job = createTestJob(db, { showInInbox: true }) + const run = startRun(db, job) + + finishRun(db, job, run.id, 'completed', run.startedAt + 5_000) + + expect(getScheduleRunById(db, job.repoId, job.id, run.id)?.viewedAt).toBeNull() + expect(listUnreadScheduleRuns(db).map((entry) => entry.id)).toEqual([run.id]) + expect(countUnreadScheduleRuns(db)).toEqual({ total: 1, failed: 0 }) + + db.close() + }) + + it('does not rewrite runs that finished before the job became quiet', () => { + const db = createTestDb() + const job = createTestJob(db, { showInInbox: true }) + const run = startRun(db, job) + + finishRun(db, job, run.id, 'completed', run.startedAt + 5_000) + updateScheduleJob(db, job.repoId, job.id, buildUpdatedSchedulePersistenceInput(job, { showInInbox: false })) + + expect(getScheduleRunById(db, job.repoId, job.id, run.id)?.viewedAt).toBeNull() + expect(countUnreadScheduleRuns(db)).toEqual({ total: 1, failed: 0 }) + + db.close() + }) + + it('stores a run as viewed when the job became quiet while it was running', () => { + const db = createTestDb() + const job = createTestJob(db, { showInInbox: true }) + const run = startRun(db, job) + + updateScheduleJob(db, job.repoId, job.id, buildUpdatedSchedulePersistenceInput(job, { showInInbox: false })) + const finishedAt = run.startedAt + 5_000 + finishRun(db, job, run.id, 'completed', finishedAt) + + expect(getScheduleRunById(db, job.repoId, job.id, run.id)?.viewedAt).toBe(finishedAt) + expect(countUnreadScheduleRuns(db)).toEqual({ total: 0, failed: 0 }) + + db.close() + }) + + it('leaves a cancelled quiet run unread', () => { + const db = createTestDb() + const job = createTestJob(db, { showInInbox: false }) + const run = startRun(db, job) + + finishRun(db, job, run.id, 'cancelled', run.startedAt + 5_000) + + expect(getScheduleRunById(db, job.repoId, job.id, run.id)?.viewedAt).toBeNull() + expect(countUnreadScheduleRuns(db)).toEqual({ total: 0, failed: 0 }) + + db.close() + }) +}) + +describe('quiet schedule run sessions', () => { + it('reports the session of a quiet job as quiet', () => { + const db = createTestDb() + const job = createTestJob(db, { showInInbox: false }) + const run = startRun(db, job) + updateScheduleRunMetadata(db, job.repoId, job.id, run.id, { sessionId: 'ses_quiet' }) + + expect(isQuietScheduleRunSession(db, 'ses_quiet')).toBe(true) + + db.close() + }) + + it('does not report the session of an announcing job as quiet', () => { + const db = createTestDb() + const job = createTestJob(db, { showInInbox: true }) + const run = startRun(db, job) + updateScheduleRunMetadata(db, job.repoId, job.id, run.id, { sessionId: 'ses_loud' }) + + expect(isQuietScheduleRunSession(db, 'ses_loud')).toBe(false) + + db.close() + }) + + it('does not report an unknown session as quiet', () => { + const db = createTestDb() + + expect(isQuietScheduleRunSession(db, 'ses_unknown')).toBe(false) + + db.close() + }) + + it('scopes the lookup to the run server', () => { + const db = createTestDb() + const job = createTestJob(db, { showInInbox: false }, 'srv_kitchen') + const run = startRun(db, job, 'schedule', 'srv_kitchen') + updateScheduleRunMetadata(db, job.repoId, job.id, run.id, { sessionId: 'ses_remote' }) + + expect(isQuietScheduleRunSession(db, 'ses_remote', 'srv_kitchen')).toBe(true) + expect(isQuietScheduleRunSession(db, 'ses_remote')).toBe(false) + + db.close() + }) +}) diff --git a/backend/test/db/schedules.permission.test.ts b/backend/test/db/schedules.permission.test.ts index 561f0dae7..aa8f9bc71 100644 --- a/backend/test/db/schedules.permission.test.ts +++ b/backend/test/db/schedules.permission.test.ts @@ -24,6 +24,7 @@ describe('schedule permission config persistence', () => { name: 'Permission test job', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, diff --git a/backend/test/db/schedules.test.ts b/backend/test/db/schedules.test.ts index 05c83dcaa..092f64097 100644 --- a/backend/test/db/schedules.test.ts +++ b/backend/test/db/schedules.test.ts @@ -105,6 +105,7 @@ describe('schedule database queries', () => { name: 'Weekly engineering summary', description: 'Summarize repo health and recent changes.', enabled: true, + showInInbox: true, scheduleMode: 'cron', intervalMinutes: null, cronExpression: '0 9 * * 1', @@ -128,6 +129,7 @@ describe('schedule database queries', () => { 'Weekly engineering summary', 'Summarize repo health and recent changes.', 1, + 1, 'cron', null, '0 9 * * 1', @@ -169,6 +171,7 @@ describe('schedule database queries', () => { name: 'Updated summary', description: null, enabled: false, + showInInbox: false, scheduleMode: 'interval', intervalMinutes: 90, cronExpression: null, @@ -189,6 +192,7 @@ describe('schedule database queries', () => { 'Updated summary', null, 0, + 0, 'interval', 90, null, @@ -498,9 +502,10 @@ describe('schedule database queries', () => { mockDb.prepare.mockReturnValueOnce(updateStmt).mockReturnValueOnce(selectStmt) + const finishedAt = Date.now() const run = schedulesDb.updateScheduleRun(mockDb, 42, 7, 5, { status: 'completed', - finishedAt: Date.now(), + finishedAt, sessionId: 'ses-1', sessionTitle: 'Updated title', logText: 'Log output', @@ -508,9 +513,12 @@ describe('schedule database queries', () => { errorText: null, }) + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining('sj.show_in_inbox = 0')) expect(updateStmt.run).toHaveBeenCalledWith( 'completed', - expect.any(Number), + finishedAt, + 'completed', + finishedAt, 'ses-1', 'Updated title', 'Log output', diff --git a/backend/test/services/git/GitService.real.test.ts b/backend/test/services/git/GitService.real.test.ts index ea7a14ba3..56fb6a52e 100644 --- a/backend/test/services/git/GitService.real.test.ts +++ b/backend/test/services/git/GitService.real.test.ts @@ -35,6 +35,7 @@ function scheduleJobInput(name: string, branch: string): ScheduleJobPersistenceI name, description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, diff --git a/backend/test/services/notification-service.test.ts b/backend/test/services/notification-service.test.ts index 5350851c2..d3b73721a 100644 --- a/backend/test/services/notification-service.test.ts +++ b/backend/test/services/notification-service.test.ts @@ -3,13 +3,15 @@ import { Database } from 'bun:sqlite' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import { createRepo } from '../../src/db/queries' -import { createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' +import { createScheduleJob, createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' +import { buildCreateSchedulePersistenceInput } from '../../src/services/schedule-config' import { NotificationService, type NotificationServerRegistry } from '../../src/services/notification' import { SettingsService } from '../../src/services/settings' import { sseAggregator, type SSEEvent } from '../../src/services/sse-aggregator' import type { PushNotificationPayload } from '@opencode-manager/shared/types' import type { GoalState } from '@opencode-manager/shared/opencode' import type { SessionGoalOutcome } from '../../src/services/goal-outcomes' +import { CreateScheduleJobRequestSchema, type ScheduleJob } from '@opencode-manager/shared/schemas' const DIRECTORY = '/abs/repo' const USER_ID = 'user-1' @@ -77,6 +79,32 @@ function createService( return service } +function createScheduledJob(db: Database, showInInbox: boolean, serverId?: string): ScheduleJob { + const request = CreateScheduleJobRequestSchema.parse({ + name: 'Nightly audit', + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Summarize the repository.', + showInInbox, + }) + return createScheduleJob(db, 1, buildCreateSchedulePersistenceInput(request), serverId) +} + +function linkRunSession(db: Database, job: ScheduleJob, sessionId: string, serverId?: string) { + const now = Date.now() + const run = createScheduleRun(db, { + jobId: job.id, + repoId: job.repoId, + serverId, + triggerSource: 'schedule', + status: 'running', + startedAt: now, + createdAt: now, + }) + updateScheduleRunMetadata(db, job.repoId, job.id, run.id, { sessionId }) + return run +} + describe('NotificationService.handleSSEEvent session routing', () => { beforeEach(() => { sseAggregator.shutdown() @@ -187,6 +215,126 @@ describe('NotificationService.handleSSEEvent session routing', () => { }) }) +describe('NotificationService quiet schedule announcements', () => { + const REMOTE_SERVER_ID = 'srv_kitchen' + + function idleEvent(sessionID: string): SSEEvent { + return { + id: 'evt_idle_quiet_1', + created: 1700000000000, + type: 'session.idle', + location: { directory: DIRECTORY }, + data: { sessionID }, + } as SSEEvent + } + + function failedEvent(sessionID: string): SSEEvent { + return { + id: 'evt_failed_quiet_1', + created: 1700000000000, + type: 'session.execution.failed', + location: { directory: DIRECTORY }, + data: { sessionID, error: { type: 'provider', message: 'boom' } }, + } as SSEEvent + } + + function remoteRegistry(): NotificationServerRegistry { + return { + get: (serverId) => (serverId === REMOTE_SERVER_ID ? { name: 'Kitchen', enabled: true } : null), + } + } + + beforeEach(() => { + sseAggregator.shutdown() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('skips the completion push for a quiet schedule run', async () => { + const db = new Database(':memory:') + const service = createService(db) + const job = createScheduledJob(db, false) + linkRunSession(db, job, 'ses_quiet') + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, idleEvent('ses_quiet')) + + expect(send).not.toHaveBeenCalled() + }) + + it('still pushes a failure for a quiet schedule run', async () => { + const db = new Database(':memory:') + const service = createService(db) + const job = createScheduledJob(db, false) + linkRunSession(db, job, 'ses_quiet') + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, failedEvent('ses_quiet')) + + expect(send).toHaveBeenCalledTimes(1) + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.title).toBe('Error') + expect(payload.tag).toBe('session.execution.failed-ses_quiet') + }) + + it('pushes the run report for an announcing schedule run', async () => { + const db = new Database(':memory:') + const service = createService(db) + const job = createScheduledJob(db, true) + const run = linkRunSession(db, job, 'ses_loud') + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, idleEvent('ses_loud')) + + expect(send).toHaveBeenCalledTimes(1) + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.title).toBe('Session complete') + expect(payload.data?.url).toBe(`/schedules?scheduleTab=runs&runId=${run.id}`) + }) + + it('skips the completion push for a quiet remote schedule run', async () => { + const db = new Database(':memory:') + const service = createService(db, remoteRegistry()) + const job = createScheduledJob(db, false, REMOTE_SERVER_ID) + linkRunSession(db, job, 'ses_remote_quiet', REMOTE_SERVER_ID) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, idleEvent('ses_remote_quiet'), REMOTE_SERVER_ID) + + expect(send).not.toHaveBeenCalled() + }) + + it('pushes the completion of an announcing remote schedule run', async () => { + const db = new Database(':memory:') + const service = createService(db, remoteRegistry()) + const job = createScheduledJob(db, true, REMOTE_SERVER_ID) + linkRunSession(db, job, 'ses_remote_loud', REMOTE_SERVER_ID) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, idleEvent('ses_remote_loud'), REMOTE_SERVER_ID) + + expect(send).toHaveBeenCalledTimes(1) + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.data?.serverId).toBe(REMOTE_SERVER_ID) + }) + + it('still pushes a permission prompt for a quiet schedule run', async () => { + const db = new Database(':memory:') + const service = createService(db) + const job = createScheduledJob(db, false) + linkRunSession(db, job, 'ses_quiet') + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_quiet')) + + expect(send).toHaveBeenCalledTimes(1) + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.data?.url).toBe('/repos/1/sessions/ses_quiet') + }) +}) + describe('NotificationService goal outcomes', () => { beforeEach(() => { sseAggregator.shutdown() diff --git a/backend/test/services/schedule-config.test.ts b/backend/test/services/schedule-config.test.ts index 8a7770222..4f7abecfc 100644 --- a/backend/test/services/schedule-config.test.ts +++ b/backend/test/services/schedule-config.test.ts @@ -14,6 +14,7 @@ describe('schedule-config', () => { name: ' Daily health check ', description: ' Summarize repo health ', enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, prompt: ' Review the repository and summarize risks. ', @@ -25,6 +26,7 @@ describe('schedule-config', () => { name: 'Daily health check', description: 'Summarize repo health', enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, @@ -48,6 +50,7 @@ describe('schedule-config', () => { const result = buildCreateSchedulePersistenceInput({ name: 'Morning report', enabled: true, + showInInbox: true, scheduleMode: 'cron', cronExpression: ' 0 9 * * * ', timezone: ' ', @@ -67,6 +70,7 @@ describe('schedule-config', () => { name: 'Weekly engineering summary', description: 'Summarize health', enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, @@ -101,6 +105,7 @@ describe('schedule-config', () => { name: 'Weekly engineering summary', description: 'Existing description', enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, @@ -138,6 +143,7 @@ describe('schedule-config', () => { name: 'Branch job', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, @@ -164,6 +170,74 @@ describe('schedule-config', () => { expect(result.branch).toBe('feature/foo') }) + it('preserves showInInbox false when omitted from an update', () => { + const existing: ScheduleJob = { + id: 15, + repoId: 42, + name: 'Quiet job', + description: null, + enabled: true, + showInInbox: false, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Prompt', + model: null, + skillMetadata: null, + permissionConfig: null, + mcpServers: [], + branch: null, + workspaceMode: 'worktree', + sandbox: null, + nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), + lastRunAt: null, + createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), + updatedAt: Date.UTC(2026, 2, 9, 12, 0, 0), + } + + const result = buildUpdatedSchedulePersistenceInput(existing, { + prompt: 'Updated prompt', + }) + + expect(result.showInInbox).toBe(false) + }) + + it('overrides showInInbox when provided in an update', () => { + const existing: ScheduleJob = { + id: 16, + repoId: 42, + name: 'Quiet job', + description: null, + enabled: true, + showInInbox: false, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Prompt', + model: null, + skillMetadata: null, + permissionConfig: null, + mcpServers: [], + branch: null, + workspaceMode: 'worktree', + sandbox: null, + nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), + lastRunAt: null, + createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), + updatedAt: Date.UTC(2026, 2, 9, 12, 0, 0), + } + + const result = buildUpdatedSchedulePersistenceInput(existing, { + showInInbox: true, + }) + + expect(result.showInInbox).toBe(true) + }) + it('overrides branch when provided in update', () => { const existing: ScheduleJob = { id: 12, @@ -171,6 +245,7 @@ describe('schedule-config', () => { name: 'Branch job', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, @@ -204,6 +279,7 @@ describe('schedule-config', () => { name: 'Branch job', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, @@ -237,6 +313,7 @@ describe('schedule-config', () => { name: 'Paused summary', description: null, enabled: false, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 30, cronExpression: null, @@ -269,6 +346,7 @@ describe('schedule-config', () => { expect(() => buildCreateSchedulePersistenceInput({ name: 'Invalid timezone', enabled: true, + showInInbox: true, scheduleMode: 'cron', cronExpression: '0 9 * * *', timezone: 'Mars/Phobos', @@ -283,6 +361,7 @@ describe('schedule-config', () => { name: 'Disabled summary', description: null, enabled: false, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 15, cronExpression: null, @@ -314,6 +393,7 @@ describe('schedule-config', () => { name: 'Sandbox job', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, @@ -337,6 +417,7 @@ describe('schedule-config', () => { const result = buildCreateSchedulePersistenceInput({ name: 'Sandbox job', enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, prompt: 'Prompt', @@ -349,6 +430,7 @@ describe('schedule-config', () => { const result = buildCreateSchedulePersistenceInput({ name: 'Sandbox job', enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, prompt: 'Prompt', diff --git a/backend/test/services/schedules-remote.test.ts b/backend/test/services/schedules-remote.test.ts index 14c42621a..8352b6326 100644 --- a/backend/test/services/schedules-remote.test.ts +++ b/backend/test/services/schedules-remote.test.ts @@ -77,6 +77,7 @@ function createService(db: Database, localClient: ReturnType { const createResult = await service.createJob(42, { name: 'Daily release summary', enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, prompt: 'Summarize release readiness.', @@ -1011,6 +1013,7 @@ describe('ScheduleService', () => { await expect(service.createJob(42, { name: 'Daily release summary', enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, prompt: 'Summarize release readiness.', @@ -1569,6 +1572,7 @@ describe('ScheduleService', () => { await service.createJob(42, { name: 'Sandboxed job', enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, prompt: 'Audit the project.', @@ -1586,6 +1590,7 @@ describe('ScheduleService', () => { await expect(service.createServerJob('srv_remote', '/srv/audit', { name: 'Remote audit', enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, prompt: 'Audit the project.', @@ -2557,6 +2562,7 @@ describe('ScheduleRunner', () => { name: 'Test Job', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, @@ -2597,6 +2603,7 @@ describe('ScheduleRunner', () => { name: 'Test Cron', description: null, enabled: true, + showInInbox: true, scheduleMode: 'cron', cronExpression: '0 9 * * *', timezone: 'America/New_York', @@ -2635,6 +2642,7 @@ describe('ScheduleRunner', () => { name: 'Disabled Job', description: null, enabled: false, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, @@ -2672,6 +2680,7 @@ describe('ScheduleRunner', () => { name: 'Stop Test', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 30, cronExpression: null, @@ -2709,6 +2718,7 @@ describe('ScheduleRunner', () => { name: 'Update Test', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, diff --git a/frontend/src/components/schedules/GeneralTab.tsx b/frontend/src/components/schedules/GeneralTab.tsx index 14fbdc489..7f894306f 100644 --- a/frontend/src/components/schedules/GeneralTab.tsx +++ b/frontend/src/components/schedules/GeneralTab.tsx @@ -39,6 +39,8 @@ type GeneralTabProps = { modelDirectory?: string enabled: boolean onEnabledChange: (value: boolean) => void + showInInbox: boolean + onShowInInboxChange: (value: boolean) => void branch: string onBranchChange: (value: string) => void branchRepoId: number | undefined @@ -85,6 +87,8 @@ export function GeneralTab({ modelDirectory, enabled, onEnabledChange, + showInInbox, + onShowInInboxChange, branch, onBranchChange, branchRepoId, @@ -182,6 +186,16 @@ export function GeneralTab({ +
+
+
+

Show runs in inbox

+ +
+ +
+
+
{showWorkspaceMode && (
diff --git a/frontend/src/components/schedules/JobDetailTab.tsx b/frontend/src/components/schedules/JobDetailTab.tsx index 1203f2ef9..0e68097ba 100644 --- a/frontend/src/components/schedules/JobDetailTab.tsx +++ b/frontend/src/components/schedules/JobDetailTab.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { formatScheduleSummary, + formatScheduleInbox, formatScheduleSandbox, formatTimestamp, getJobStatusTone, @@ -198,6 +199,10 @@ export function JobDetailTab({

Sandbox

{formatScheduleSandbox(selectedJob.sandbox)}

+
+

Inbox

+

{formatScheduleInbox(selectedJob.showInInbox)}

+

Created

{formatTimestamp(selectedJob.createdAt)}

diff --git a/frontend/src/components/schedules/RepoSchedulesContent.test.tsx b/frontend/src/components/schedules/RepoSchedulesContent.test.tsx index 5faf329d0..ff67feef3 100644 --- a/frontend/src/components/schedules/RepoSchedulesContent.test.tsx +++ b/frontend/src/components/schedules/RepoSchedulesContent.test.tsx @@ -15,6 +15,7 @@ function createJob(id: number, name: string): ScheduleJobWithRepo { name, description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, diff --git a/frontend/src/components/schedules/ScheduleJobDialog.inbox.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.inbox.test.tsx new file mode 100644 index 000000000..27ba4235a --- /dev/null +++ b/frontend/src/components/schedules/ScheduleJobDialog.inbox.test.tsx @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ScheduleJob } from '@opencode-manager/shared/types' +import { ScheduleJobDialog } from './ScheduleJobDialog' +import { useSettings } from '@/hooks/useSettings' +import { useServerHealth } from '@/hooks/useServerHealth' +import { createUseSettingsMock } from '@/test/test-utils' + +Element.prototype.scrollIntoView = vi.fn() + +vi.mock('@/hooks/useSettings') +vi.mock('@/hooks/useServerHealth') + +vi.mock('@/hooks/usePromptTemplates', () => ({ + usePromptTemplates: () => ({ data: [], isLoading: false }), + useCreatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), + useUpdatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), + useDeletePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), +})) + +vi.mock('@/hooks/useScheduleTarget', () => ({ + useScheduleTarget: () => ({ scheduleTarget: undefined, isLoading: false, isError: false }), +})) + +vi.mock('@/hooks/useScheduleModels', () => ({ + useScheduleModels: () => ({ availableModels: null, isLoading: false }), +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useAgents: () => ({ data: [], isSuccess: true }), +})) + +vi.mock('@/api/settings', () => ({ + settingsApi: { + listManagedSkills: () => Promise.resolve([]), + }, +})) + +vi.mock('@/api/mcp', () => ({ + mcpApi: { + getStatus: () => Promise.resolve({}), + }, +})) + +vi.mock('@/api/repos', () => ({ + listRepos: () => Promise.resolve([]), + listBranches: () => Promise.resolve({ branches: [], status: { ahead: 0, behind: 0 } }), +})) + +function mockSandboxPreferences() { + vi.mocked(useSettings).mockReturnValue( + createUseSettingsMock({ preferences: { sandbox: { enabled: true } } }), + ) +} + +function mockSandboxHealth() { + vi.mocked(useServerHealth).mockReturnValue({ + data: { sandbox: { available: true, enabled: true, enforced: false } }, + isLoading: false, + error: null, + refetch: vi.fn(), + } as unknown as ReturnType) +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +function getJob(overrides?: Partial): ScheduleJob { + return { + id: 1, + repoId: 1, + name: 'Nightly health check', + description: null, + enabled: true, + showInInbox: true, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Check the repo.', + model: null, + skillMetadata: null, + permissionConfig: null, + mcpServers: [], + branch: null, + workspaceMode: 'worktree', + sandbox: null, + createdAt: 0, + updatedAt: 0, + lastRunAt: null, + nextRunAt: null, + ...overrides, + } +} + +async function fillRequiredFields(user: ReturnType) { + await user.type(screen.getByLabelText('Name'), 'Nightly health check') + await user.click(screen.getByRole('tab', { name: 'Prompt' })) + await user.type(screen.getByRole('textbox', { name: 'Prompt' }), 'Check the repo.') +} + +async function goToGeneralTab(user: ReturnType) { + await user.click(screen.getByRole('tab', { name: 'General' })) +} + +describe('ScheduleJobDialog — inbox', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSandboxPreferences() + mockSandboxHealth() + }) + + it('submits showInInbox true by default for a new schedule', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + + render( + , + { wrapper: createWrapper() }, + ) + + await fillRequiredFields(user) + await goToGeneralTab(user) + + expect(screen.getByRole('switch', { name: 'Show runs in inbox' })).toBeChecked() + + await user.click(screen.getByRole('button', { name: 'Create schedule' })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + expect(onSubmit.mock.calls[0][0].showInInbox).toBe(true) + }) + + it('submits showInInbox false when the inbox switch is turned off', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + + render( + , + { wrapper: createWrapper() }, + ) + + await fillRequiredFields(user) + await goToGeneralTab(user) + + await user.click(screen.getByRole('switch', { name: 'Show runs in inbox' })) + await user.click(screen.getByRole('button', { name: 'Create schedule' })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + expect(onSubmit.mock.calls[0][0].showInInbox).toBe(false) + }) + + it('keeps showInInbox false from an existing job when the switch is untouched', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + + render( + , + { wrapper: createWrapper() }, + ) + + await goToGeneralTab(user) + + expect(screen.getByRole('switch', { name: 'Show runs in inbox' })).not.toBeChecked() + + await user.click(screen.getByRole('button', { name: 'Save changes' })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + expect(onSubmit.mock.calls[0][0].showInInbox).toBe(false) + }) +}) diff --git a/frontend/src/components/schedules/ScheduleJobDialog.mcp.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.mcp.test.tsx index 7f21cf29c..25a003256 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.mcp.test.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.mcp.test.tsx @@ -58,6 +58,7 @@ const job: ScheduleJob = { name: 'Test Job', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, diff --git a/frontend/src/components/schedules/ScheduleJobDialog.permissions.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.permissions.test.tsx index a45652971..9ba97caab 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.permissions.test.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.permissions.test.tsx @@ -59,6 +59,7 @@ function getDefaultJob(overrides?: Partial): ScheduleJob { name: 'Test Job', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, @@ -91,14 +92,12 @@ async function fillRequiredFields(user: ReturnType) { await user.type(promptInput, 'Run a test') } -/** The external-directory switch is the 2nd switch (index 1) after Enabled */ function getExternalDirSwitch() { - return screen.getAllByRole('switch')[1] + return screen.getByRole('switch', { name: 'Allow access outside the working directory' }) } -/** The allow-questions switch is the 3rd switch (index 2), after Enabled and external directory */ function getAllowQuestionsSwitch() { - return screen.getAllByRole('switch')[2] + return screen.getByRole('switch', { name: 'Allow questions' }) } describe('ScheduleJobDialog — permission config', () => { diff --git a/frontend/src/components/schedules/ScheduleJobDialog.sandbox.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.sandbox.test.tsx index 987e315c4..56b7f0026 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.sandbox.test.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.sandbox.test.tsx @@ -83,6 +83,7 @@ function getJob(overrides?: Partial): ScheduleJob { name: 'Nightly health check', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, diff --git a/frontend/src/components/schedules/ScheduleJobDialog.serverTarget.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.serverTarget.test.tsx index c3257520d..4dd198bbb 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.serverTarget.test.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.serverTarget.test.tsx @@ -49,6 +49,7 @@ const remoteJob: ScheduleJob = { name: 'Nightly audit', description: null, enabled: true, + showInInbox: true, scheduleMode: 'interval', intervalMinutes: 60, cronExpression: null, diff --git a/frontend/src/components/schedules/ScheduleJobDialog.tsx b/frontend/src/components/schedules/ScheduleJobDialog.tsx index 8a5955345..f7702165a 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.tsx @@ -57,6 +57,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, const [name, setName] = useState('') const [description, setDescription] = useState('') const [enabled, setEnabled] = useState(true) + const [showInInbox, setShowInInbox] = useState(true) const [intervalMinutes, setIntervalMinutes] = useState('60') const [timeOfDay, setTimeOfDay] = useState('09:00') const [hourlyMinute, setHourlyMinute] = useState('0') @@ -173,6 +174,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, setName(job?.name ?? '') setDescription(job?.description ?? '') setEnabled(job?.enabled ?? true) + setShowInInbox(job?.showInInbox ?? true) const scheduleDefaults = detectSchedulePreset(job) setSchedulePreset(scheduleDefaults.preset) setIntervalMinutes(scheduleDefaults.intervalMinutes) @@ -249,6 +251,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, name: name.trim(), description: description.trim() || undefined, enabled, + showInInbox, agentSlug: selectedAgentSlug.trim() || undefined, model: resolvedModel ?? undefined, prompt: prompt.trim(), @@ -340,6 +343,8 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, modelDirectory={scheduleDirectory} enabled={enabled} onEnabledChange={setEnabled} + showInInbox={showInInbox} + onShowInInboxChange={setShowInInbox} branch={branch} onBranchChange={setBranch} branchRepoId={!serverTargetDirectory && open && effectiveRepoId !== undefined && effectiveRepoId !== ASSISTANT_REPO_ID ? effectiveRepoId : undefined} diff --git a/frontend/src/components/schedules/__tests__/schedule-utils.test.ts b/frontend/src/components/schedules/__tests__/schedule-utils.test.ts index 6f150efca..6a5baf831 100644 --- a/frontend/src/components/schedules/__tests__/schedule-utils.test.ts +++ b/frontend/src/components/schedules/__tests__/schedule-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { formatRunDuration, matchesScheduleJobSearch } from '@/components/schedules/schedule-utils' +import { formatRunDuration, formatScheduleInbox, matchesScheduleJobSearch } from '@/components/schedules/schedule-utils' import type { ScheduleJob } from '@opencode-manager/shared/types' describe('formatRunDuration', () => { @@ -14,6 +14,13 @@ describe('formatRunDuration', () => { }) }) +describe('formatScheduleInbox', () => { + it('labels every run versus failures only', () => { + expect(formatScheduleInbox(true)).toBe('Every run') + expect(formatScheduleInbox(false)).toBe('Failures only') + }) +}) + describe('matchesScheduleJobSearch', () => { const job = { name: 'Docs drift review', description: 'Check README drift', repoName: 'vllm' } as ScheduleJob & { repoName: string } diff --git a/frontend/src/components/schedules/schedule-utils.ts b/frontend/src/components/schedules/schedule-utils.ts index 200aa36a5..aa92d2515 100644 --- a/frontend/src/components/schedules/schedule-utils.ts +++ b/frontend/src/components/schedules/schedule-utils.ts @@ -77,6 +77,13 @@ export function formatScheduleSandbox(sandbox: ScheduleSandboxConfig | null | un return sandbox.resources ? `On · ${formatSandboxResources(sandbox.resources)}` : 'On · server defaults' } +/** + * Describes which schedule runs are announced in the Reports inbox. + */ +export function formatScheduleInbox(showInInbox: boolean): string { + return showInInbox ? 'Every run' : 'Failures only' +} + export function getLocalTimeZone(): string { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' diff --git a/shared/src/schemas/schedule.ts b/shared/src/schemas/schedule.ts index 2bd5d1048..1a1d8c91d 100644 --- a/shared/src/schemas/schedule.ts +++ b/shared/src/schemas/schedule.ts @@ -190,6 +190,12 @@ export const ScheduleJobSchema = z.object({ name: z.string(), description: z.string().nullable(), enabled: z.boolean(), + /** + * Whether finished runs of this schedule appear in the inbox. `false` stores successful + * runs as already viewed and skips their completion push, while failed runs are still + * announced. + */ + showInInbox: z.boolean(), scheduleMode: ScheduleModeSchema, intervalMinutes: z.number().int().min(5).max(10080).nullable(), cronExpression: z.string().nullable(), @@ -236,6 +242,12 @@ const ScheduleJobBaseRequestSchema = z.object({ name: z.string().min(1).max(120), description: z.string().max(500).optional(), enabled: z.boolean().default(true), + /** + * Whether finished runs of this schedule appear in the inbox. `false` stores successful + * runs as already viewed and skips their completion push, while failed runs are still + * announced. + */ + showInInbox: z.boolean().default(true), agentSlug: z.string().min(1).max(100).optional(), prompt: z.string().min(1).max(20000), model: z.string().min(1).max(200).optional(), @@ -265,6 +277,12 @@ export const UpdateScheduleJobRequestSchema = z.object({ name: z.string().min(1).max(120).optional(), description: z.string().max(500).nullable().optional(), enabled: z.boolean().optional(), + /** + * Whether finished runs of this schedule appear in the inbox. `false` stores successful + * runs as already viewed and skips their completion push, while failed runs are still + * announced. + */ + showInInbox: z.boolean().optional(), scheduleMode: ScheduleModeSchema.optional(), intervalMinutes: z.number().int().min(5).max(10080).nullable().optional(), cronExpression: z.string().min(1).max(200).nullable().optional(),