From d46e188bf80c18e08c7d70ead2f9f5c3e8b25290 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Apr 2026 19:11:59 +0000 Subject: [PATCH 1/3] Add MCP job inspection capabilities Co-authored-by: Kent C. Dodds --- packages/worker/src/jobs/manager-client.ts | 32 +++ packages/worker/src/jobs/manager-do.ts | 50 +++- packages/worker/src/jobs/service.node.test.ts | 178 +++++++++++++ packages/worker/src/jobs/service.ts | 33 +++ .../src/mcp/capabilities/jobs/domain.ts | 22 +- .../src/mcp/capabilities/jobs/job-get.ts | 37 +++ .../src/mcp/capabilities/jobs/job-list.ts | 44 ++++ .../jobs/job-schedule.node.test.ts | 238 +++++++++++++++++- .../src/mcp/capabilities/jobs/shared.ts | 159 ++++++++++-- packages/worker/src/mcp/jobs-embed.ts | 2 +- .../worker/src/mcp/server-instructions.ts | 1 + 11 files changed, 769 insertions(+), 27 deletions(-) create mode 100644 packages/worker/src/mcp/capabilities/jobs/job-get.ts create mode 100644 packages/worker/src/mcp/capabilities/jobs/job-list.ts diff --git a/packages/worker/src/jobs/manager-client.ts b/packages/worker/src/jobs/manager-client.ts index d18569bed2..600ab64e24 100644 --- a/packages/worker/src/jobs/manager-client.ts +++ b/packages/worker/src/jobs/manager-client.ts @@ -1,12 +1,23 @@ import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { type JobRepoCheckPolicy } from './types.ts' +export type JobManagerDebugState = { + bindingAvailable: boolean + status: 'missing_binding' | 'idle' | 'armed' | 'out_of_sync' + storedUserId: string | null + alarmScheduledFor: string | null + nextRunnableJobId: string | null + nextRunnableRunAt: string | null + alarmInSync: boolean | null +} + type JobManagerRpc = { syncAlarm: (payload: { userId: string }) => Promise<{ ok: true userId: string nextRunAt: string | null }> + getDebugState: (payload: { userId: string }) => Promise runNow: (payload: { userId: string jobId: string @@ -37,6 +48,27 @@ export async function syncJobManagerAlarm(input: { env: Env; userId: string }) { }) } +export async function getJobManagerDebugState(input: { + env: Env + userId: string +}) { + const rpc = jobManagerRpc(input.env, input.userId) + if (!rpc) { + return { + bindingAvailable: false, + status: 'missing_binding' as const, + storedUserId: null, + alarmScheduledFor: null, + nextRunnableJobId: null, + nextRunnableRunAt: null, + alarmInSync: null, + } + } + return rpc.getDebugState({ + userId: input.userId, + }) +} + export async function runJobNowViaManager(input: { env: Env userId: string diff --git a/packages/worker/src/jobs/manager-do.ts b/packages/worker/src/jobs/manager-do.ts index 07dda828f2..df798127d6 100644 --- a/packages/worker/src/jobs/manager-do.ts +++ b/packages/worker/src/jobs/manager-do.ts @@ -3,7 +3,11 @@ import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { DurableObject } from 'cloudflare:workers' import { buildSentryOptions } from '#worker/sentry-options.ts' import { getNextRunnableJob, runDueJobsForUser, runJobNow } from './service.ts' -import { type JobRepoCheckPolicy } from './types.ts' +import { + type JobManagerDebugState, + type JobManagerDebugStatus, + type JobRepoCheckPolicy, +} from './types.ts' const userIdStorageKey = 'user-id' @@ -34,6 +38,47 @@ class JobManagerBase extends DurableObject { } } + async getDebugState(input: { userId: string }): Promise { + const userId = input.userId.trim() + if (!userId) { + throw new Error('Job manager requires a non-empty userId.') + } + const [storedUserId, alarmTimestamp, nextJob] = await Promise.all([ + this.ctx.storage.get(userIdStorageKey), + this.ctx.storage.getAlarm(), + getNextRunnableJob({ + env: this.env, + userId, + }), + ]) + const alarmScheduledFor = + typeof alarmTimestamp === 'number' + ? new Date(alarmTimestamp).toISOString() + : null + const nextRunnableRunAt = nextJob?.nextRunAt ?? null + const alarmInSync = + nextRunnableRunAt == null + ? alarmScheduledFor === null + : alarmScheduledFor === nextRunnableRunAt + const status: JobManagerDebugStatus = + nextRunnableRunAt == null + ? alarmScheduledFor == null + ? 'idle' + : 'out_of_sync' + : alarmScheduledFor === nextRunnableRunAt + ? 'armed' + : 'out_of_sync' + return { + bindingAvailable: true, + status, + storedUserId: storedUserId ?? null, + alarmScheduledFor, + nextRunnableJobId: nextJob?.id ?? null, + nextRunnableRunAt, + alarmInSync, + } + } + async alarm(): Promise { const userId = await this.ctx.storage.get(userIdStorageKey) if (!userId) { @@ -94,6 +139,9 @@ export function jobManagerRpc(env: Env, userId: string) { userId: string nextRunAt: string | null }> + getDebugState: (payload: { + userId: string + }) => Promise runNow: (payload: { userId: string jobId: string diff --git a/packages/worker/src/jobs/service.node.test.ts b/packages/worker/src/jobs/service.node.test.ts index 041571b281..e3f57a4b8a 100644 --- a/packages/worker/src/jobs/service.node.test.ts +++ b/packages/worker/src/jobs/service.node.test.ts @@ -7,6 +7,8 @@ import { createJob, deleteJob, executeJobOnce, + getJobInspection, + inspectJobsForUser, runJobNow, updateJob, } from './service.ts' @@ -23,6 +25,7 @@ const repoMockModule = vi.hoisted(() => ({ const jobManagerMockModule = vi.hoisted(() => ({ syncJobManagerAlarm: vi.fn(), + getJobManagerDebugState: vi.fn(), })) vi.mock('#worker/repo/source-service.ts', () => ({ @@ -38,11 +41,23 @@ vi.mock('#worker/repo/source-sync.ts', () => ({ vi.mock('./manager-client.ts', () => ({ syncJobManagerAlarm: (...args: Array) => jobManagerMockModule.syncJobManagerAlarm(...args), + getJobManagerDebugState: (...args: Array) => + jobManagerMockModule.getJobManagerDebugState(...args), })) afterEach(() => { vi.restoreAllMocks() jobManagerMockModule.syncJobManagerAlarm.mockClear() + jobManagerMockModule.getJobManagerDebugState.mockReset() + jobManagerMockModule.getJobManagerDebugState.mockResolvedValue({ + bindingAvailable: false, + status: 'missing_binding', + storedUserId: null, + alarmScheduledFor: null, + nextRunnableJobId: null, + nextRunnableRunAt: null, + alarmInSync: null, + }) }) function mockRepoPersistence() { @@ -703,6 +718,169 @@ test('deleteJob syncs the job manager alarm after removing a job', async () => { }) }) +test('inspectJobsForUser returns persisted job fields with alarm debug state', async () => { + const env = { + APP_DB: createDatabase(), + } as Env + mockRepoPersistence() + const callerContext = createBaseCallerContext() + const created = await createJob({ + env, + callerContext, + body: { + name: 'Inspect recurring job', + code: 'export default async () => ({ ok: true })', + schedule: { + type: 'interval', + every: '15m', + }, + }, + }) + const jobRow = await ( + await import('./repo.ts') + ).getJobRowById(env.APP_DB, callerContext.user.userId, created.id) + if (!jobRow) { + throw new Error('Expected created job row.') + } + jobRow.record.lastRunAt = '2026-04-20T10:05:00.000Z' + jobRow.record.lastRunStatus = 'error' + jobRow.record.lastRunError = 'Worker fetch failed' + jobRow.record.lastDurationMs = 321 + jobRow.record.runCount = 3 + jobRow.record.successCount = 1 + jobRow.record.errorCount = 2 + jobRow.record.runHistory = [ + { + startedAt: '2026-04-20T10:00:00.000Z', + finishedAt: '2026-04-20T10:05:00.000Z', + status: 'error', + durationMs: 321, + error: 'Worker fetch failed', + }, + ] + jobRow.record.nextRunAt = '2026-04-20T10:00:00.000Z' + jobRow.record.updatedAt = '2026-04-20T10:05:00.000Z' + await (await import('./repo.ts')).updateJobRow({ + db: env.APP_DB, + userId: callerContext.user.userId, + job: jobRow.record, + callerContextJson: jobRow.callerContextJson, + }) + jobManagerMockModule.getJobManagerDebugState.mockResolvedValue({ + bindingAvailable: true, + status: 'armed', + storedUserId: callerContext.user.userId, + alarmScheduledFor: '2026-04-20T10:00:00.000Z', + nextRunnableJobId: created.id, + nextRunnableRunAt: '2026-04-20T10:00:00.000Z', + alarmInSync: true, + }) + + const inspected = await inspectJobsForUser({ + env, + userId: callerContext.user.userId, + now: new Date('2026-04-20T10:10:00.000Z'), + }) + + expect(jobManagerMockModule.getJobManagerDebugState).toHaveBeenCalledWith({ + env, + userId: callerContext.user.userId, + }) + expect(inspected.alarm).toEqual({ + bindingAvailable: true, + status: 'armed', + storedUserId: 'user-123', + alarmScheduledFor: '2026-04-20T10:00:00.000Z', + nextRunnableJobId: created.id, + nextRunnableRunAt: '2026-04-20T10:00:00.000Z', + alarmInSync: true, + }) + expect(inspected.jobs).toEqual([ + expect.objectContaining({ + id: created.id, + name: 'Inspect recurring job', + sourceId: created.sourceId, + storageId: created.storageId, + scheduleSummary: 'Runs every 15m', + lastRunAt: '2026-04-20T10:05:00.000Z', + lastRunStatus: 'error', + lastRunError: 'Worker fetch failed', + lastDurationMs: 321, + runCount: 3, + successCount: 1, + errorCount: 2, + runHistory: [ + { + startedAt: '2026-04-20T10:00:00.000Z', + finishedAt: '2026-04-20T10:05:00.000Z', + status: 'error', + durationMs: 321, + error: 'Worker fetch failed', + }, + ], + }), + ]) +}) + +test('getJobInspection returns one job and out-of-sync alarm details', async () => { + const env = { + APP_DB: createDatabase(), + } as Env + mockRepoPersistence() + const callerContext = createBaseCallerContext() + const created = await createJob({ + env, + callerContext, + body: { + name: 'Inspect one job', + code: 'export default async () => ({ ok: true })', + schedule: { + type: 'once', + runAt: '2026-04-20T18:30:00Z', + }, + }, + }) + jobManagerMockModule.getJobManagerDebugState.mockResolvedValue({ + bindingAvailable: true, + status: 'out_of_sync', + storedUserId: callerContext.user.userId, + alarmScheduledFor: '2026-04-20T18:35:00.000Z', + nextRunnableJobId: created.id, + nextRunnableRunAt: '2026-04-20T18:30:00.000Z', + alarmInSync: false, + }) + + const inspected = await getJobInspection({ + env, + userId: callerContext.user.userId, + jobId: created.id, + now: new Date('2026-04-20T18:00:00.000Z'), + }) + + expect(inspected.job).toMatchObject({ + id: created.id, + name: 'Inspect one job', + sourceId: created.sourceId, + storageId: created.storageId, + lastRunAt: undefined, + lastRunStatus: undefined, + lastRunError: undefined, + runCount: 0, + successCount: 0, + errorCount: 0, + runHistory: [], + }) + expect(inspected.alarm).toEqual({ + bindingAvailable: true, + status: 'out_of_sync', + storedUserId: 'user-123', + alarmScheduledFor: '2026-04-20T18:35:00.000Z', + nextRunnableJobId: created.id, + nextRunnableRunAt: '2026-04-20T18:30:00.000Z', + alarmInSync: false, + }) +}) + test('executeJobOnce binds scheduled jobs to writable storage', async () => { const db = createDatabase() const env = { diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index b395b732b8..121d1fe8a0 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -18,6 +18,7 @@ import { insertJobRow, updateJobRow, } from './repo.ts' +import { getJobManagerDebugState } from './manager-client.ts' import { computeNextRunAt, formatJobError, @@ -527,6 +528,38 @@ export async function getJob(input: { return toJobView(row.record) } +export async function inspectJobsForUser(input: { env: Env; userId: string }) { + const [jobs, alarm] = await Promise.all([ + listJobs(input), + getJobManagerDebugState({ + env: input.env, + userId: input.userId, + }), + ]) + return { + jobs, + alarm, + } +} + +export async function getJobInspection(input: { + env: Env + userId: string + jobId: string +}) { + const [job, alarm] = await Promise.all([ + getJob(input), + getJobManagerDebugState({ + env: input.env, + userId: input.userId, + }), + ]) + return { + job, + alarm, + } +} + export async function updateJob(input: { env: Env callerContext: McpCallerContext diff --git a/packages/worker/src/mcp/capabilities/jobs/domain.ts b/packages/worker/src/mcp/capabilities/jobs/domain.ts index 899bb3bf57..6d88e75ddf 100644 --- a/packages/worker/src/mcp/capabilities/jobs/domain.ts +++ b/packages/worker/src/mcp/capabilities/jobs/domain.ts @@ -1,12 +1,28 @@ import { defineDomain } from '../define-domain.ts' import { capabilityDomainNames } from '../domain-metadata.ts' +import { jobGetCapability } from './job-get.ts' +import { jobListCapability } from './job-list.ts' import { jobScheduleCapability } from './job-schedule.ts' import { jobScheduleOnceCapability } from './job-schedule-once.ts' export const jobsDomain = defineDomain({ name: capabilityDomainNames.jobs, description: - 'Schedule repo-backed jobs. Use this for one-off or recurring jobs that should run later without creating a saved package.', - keywords: ['job', 'schedule', 'one-off', 'interval', 'cron', 'background'], - capabilities: [jobScheduleCapability, jobScheduleOnceCapability], + 'Inspect and schedule repo-backed jobs. Use this for one-off or recurring jobs that should run later without creating a saved package.', + keywords: [ + 'job', + 'schedule', + 'one-off', + 'interval', + 'cron', + 'background', + 'debug', + 'inspect', + ], + capabilities: [ + jobListCapability, + jobGetCapability, + jobScheduleCapability, + jobScheduleOnceCapability, + ], }) diff --git a/packages/worker/src/mcp/capabilities/jobs/job-get.ts b/packages/worker/src/mcp/capabilities/jobs/job-get.ts new file mode 100644 index 0000000000..283162c9f2 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/jobs/job-get.ts @@ -0,0 +1,37 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { getJobInspection } from '#worker/jobs/service.ts' +import { + buildJobInspectionOutput, + buildJobManagerDebugOutput, + jobGetOutputSchema, + jobInspectionInputSchema, +} from './shared.ts' + +export const jobGetCapability = defineDomainCapability( + capabilityDomainNames.jobs, + { + name: 'job_get', + description: + 'Load one scheduled job for the signed-in user, including debugging fields such as run counters, last error, recent run history, and current alarm state.', + keywords: ['job', 'inspect', 'debug', 'status', 'scheduled job'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: jobInspectionInputSchema, + outputSchema: jobGetOutputSchema, + async handler(args, ctx) { + const user = requireMcpUser(ctx.callerContext) + const inspection = await getJobInspection({ + env: ctx.env, + userId: user.userId, + jobId: args.id, + }) + return { + job: buildJobInspectionOutput(inspection.job), + alarm: buildJobManagerDebugOutput(inspection.alarm), + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/jobs/job-list.ts b/packages/worker/src/mcp/capabilities/jobs/job-list.ts new file mode 100644 index 0000000000..759eb7030b --- /dev/null +++ b/packages/worker/src/mcp/capabilities/jobs/job-list.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { inspectJobsForUser } from '#worker/jobs/service.ts' +import { + buildJobInspectionOutput, + buildJobManagerDebugOutput, + jobListOutputSchema, +} from './shared.ts' + +export const jobListCapability = defineDomainCapability( + capabilityDomainNames.jobs, + { + name: 'job_list', + description: + 'List scheduled jobs for the signed-in user with status, counters, recent run history, and job-manager alarm state for debugging scheduling issues.', + keywords: [ + 'job', + 'list', + 'inspect', + 'debug', + 'scheduled jobs', + 'alarm', + 'status', + ], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: z.object({}), + outputSchema: jobListOutputSchema, + async handler(_args, ctx) { + const user = requireMcpUser(ctx.callerContext) + const inspection = await inspectJobsForUser({ + env: ctx.env, + userId: user.userId, + }) + return { + jobs: inspection.jobs.map((job) => buildJobInspectionOutput(job)), + alarm: buildJobManagerDebugOutput(inspection.alarm), + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/jobs/job-schedule.node.test.ts b/packages/worker/src/mcp/capabilities/jobs/job-schedule.node.test.ts index 0ac8fa1e17..61d315e196 100644 --- a/packages/worker/src/mcp/capabilities/jobs/job-schedule.node.test.ts +++ b/packages/worker/src/mcp/capabilities/jobs/job-schedule.node.test.ts @@ -4,11 +4,17 @@ import { jobsDomain } from './domain.ts' const mockModule = vi.hoisted(() => ({ createJob: vi.fn(), + getJobInspection: vi.fn(), + inspectJobsForUser: vi.fn(), syncJobManagerAlarm: vi.fn(), })) vi.mock('#worker/jobs/service.ts', () => ({ createJob: (...args: Array) => mockModule.createJob(...args), + getJobInspection: (...args: Array) => + mockModule.getJobInspection(...args), + inspectJobsForUser: (...args: Array) => + mockModule.inspectJobsForUser(...args), })) vi.mock('#worker/jobs/manager-client.ts', () => ({ @@ -18,9 +24,13 @@ vi.mock('#worker/jobs/manager-client.ts', () => ({ const { jobScheduleCapability } = await import('./job-schedule.ts') const { jobScheduleOnceCapability } = await import('./job-schedule-once.ts') +const { jobGetCapability } = await import('./job-get.ts') +const { jobListCapability } = await import('./job-list.ts') function resetMocks() { mockModule.createJob.mockReset() + mockModule.getJobInspection.mockReset() + mockModule.inspectJobsForUser.mockReset() mockModule.syncJobManagerAlarm.mockReset() mockModule.syncJobManagerAlarm.mockResolvedValue(undefined) } @@ -108,9 +118,14 @@ test('job_schedule creates a one-off job and syncs the job manager alarm', async }) }) -test('jobs domain exposes recurring and one-off scheduling capabilities', () => { +test('jobs domain exposes scheduling and inspection capabilities', () => { expect(jobsDomain.capabilities.map((capability) => capability.name)).toEqual( - expect.arrayContaining(['job_schedule', 'job_schedule_once']), + expect.arrayContaining([ + 'job_list', + 'job_get', + 'job_schedule', + 'job_schedule_once', + ]), ) }) @@ -328,3 +343,222 @@ test('job_schedule requires an authenticated user', async () => { expect(mockModule.createJob).not.toHaveBeenCalled() expect(mockModule.syncJobManagerAlarm).not.toHaveBeenCalled() }) + +test('job_list returns inspectable jobs plus alarm state', async () => { + resetMocks() + const env = {} as Env + const callerContext = createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-123', + email: 'user@example.com', + displayName: 'User Example', + }, + }) + mockModule.inspectJobsForUser.mockResolvedValue({ + jobs: [ + { + id: 'job-123', + name: 'Turn lights off', + sourceId: 'source-123', + publishedCommit: 'commit-123', + storageId: 'job:job-123', + schedule: { + type: 'once', + runAt: '2026-04-20T18:30:00.000Z', + }, + scheduleSummary: 'Runs once at 2026-04-20T18:30:00.000Z', + timezone: 'UTC', + enabled: true, + killSwitchEnabled: false, + createdAt: '2026-04-20T10:00:00.000Z', + updatedAt: '2026-04-20T10:05:00.000Z', + nextRunAt: '2026-04-20T18:30:00.000Z', + runCount: 0, + successCount: 0, + errorCount: 0, + runHistory: [], + }, + ], + alarm: { + bindingAvailable: true, + status: 'armed', + storedUserId: 'user-123', + alarmScheduledFor: '2026-04-20T18:30:00.000Z', + nextRunnableJobId: 'job-123', + nextRunnableRunAt: '2026-04-20T18:30:00.000Z', + alarmInSync: true, + }, + }) + + const result = await jobListCapability.handler( + {}, + { + env, + callerContext, + }, + ) + + expect(mockModule.inspectJobsForUser).toHaveBeenCalledWith({ + env, + userId: 'user-123', + }) + expect(result).toEqual({ + jobs: [ + { + id: 'job-123', + name: 'Turn lights off', + source_id: 'source-123', + published_commit: 'commit-123', + storage_id: 'job:job-123', + schedule: { + type: 'once', + run_at: '2026-04-20T18:30:00.000Z', + }, + schedule_summary: 'Runs once at 2026-04-20T18:30:00.000Z', + timezone: 'UTC', + enabled: true, + kill_switch_enabled: false, + created_at: '2026-04-20T10:00:00.000Z', + updated_at: '2026-04-20T10:05:00.000Z', + next_run_at: '2026-04-20T18:30:00.000Z', + due_now: true, + last_run_at: null, + last_run_status: null, + last_run_error: null, + last_duration_ms: null, + run_count: 0, + success_count: 0, + error_count: 0, + recent_runs: [], + }, + ], + alarm: { + binding_available: true, + status: 'armed', + stored_user_id: 'user-123', + alarm_scheduled_for: '2026-04-20T18:30:00.000Z', + next_runnable_job_id: 'job-123', + next_runnable_run_at: '2026-04-20T18:30:00.000Z', + alarm_in_sync: true, + }, + }) +}) + +test('job_get returns one inspectable job plus alarm state', async () => { + resetMocks() + const env = {} as Env + const callerContext = createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-123', + email: 'user@example.com', + displayName: 'User Example', + }, + }) + mockModule.getJobInspection.mockResolvedValue({ + job: { + id: 'job-123', + name: 'Turn lights off', + sourceId: 'source-123', + publishedCommit: null, + storageId: 'job:job-123', + schedule: { + type: 'once', + runAt: '2026-04-20T18:30:00.000Z', + }, + scheduleSummary: 'Runs once at 2026-04-20T18:30:00.000Z', + timezone: 'UTC', + enabled: true, + killSwitchEnabled: false, + createdAt: '2026-04-20T10:00:00.000Z', + updatedAt: '2026-04-20T10:05:00.000Z', + nextRunAt: '2026-04-20T18:30:00.000Z', + lastRunAt: '2026-04-20T09:00:00.000Z', + lastRunStatus: 'error', + lastRunError: 'Timed out', + lastDurationMs: 1200, + runCount: 2, + successCount: 1, + errorCount: 1, + runHistory: [ + { + startedAt: '2026-04-20T08:59:58.000Z', + finishedAt: '2026-04-20T09:00:00.000Z', + status: 'error', + durationMs: 1200, + error: 'Timed out', + }, + ], + }, + alarm: { + bindingAvailable: true, + status: 'out_of_sync', + storedUserId: 'user-123', + alarmScheduledFor: '2026-04-20T19:00:00.000Z', + nextRunnableJobId: 'job-123', + nextRunnableRunAt: '2026-04-20T18:30:00.000Z', + alarmInSync: false, + }, + }) + + const result = await jobGetCapability.handler( + { id: 'job-123' }, + { + env, + callerContext, + }, + ) + + expect(mockModule.getJobInspection).toHaveBeenCalledWith({ + env, + userId: 'user-123', + jobId: 'job-123', + }) + expect(result).toEqual({ + job: { + id: 'job-123', + name: 'Turn lights off', + source_id: 'source-123', + published_commit: null, + storage_id: 'job:job-123', + schedule: { + type: 'once', + run_at: '2026-04-20T18:30:00.000Z', + }, + schedule_summary: 'Runs once at 2026-04-20T18:30:00.000Z', + timezone: 'UTC', + enabled: true, + kill_switch_enabled: false, + created_at: '2026-04-20T10:00:00.000Z', + updated_at: '2026-04-20T10:05:00.000Z', + next_run_at: '2026-04-20T18:30:00.000Z', + due_now: true, + last_run_at: '2026-04-20T09:00:00.000Z', + last_run_status: 'error', + last_run_error: 'Timed out', + last_duration_ms: 1200, + run_count: 2, + success_count: 1, + error_count: 1, + recent_runs: [ + { + started_at: '2026-04-20T08:59:58.000Z', + finished_at: '2026-04-20T09:00:00.000Z', + status: 'error', + duration_ms: 1200, + error: 'Timed out', + }, + ], + }, + alarm: { + binding_available: true, + status: 'out_of_sync', + stored_user_id: 'user-123', + alarm_scheduled_for: '2026-04-20T19:00:00.000Z', + next_runnable_job_id: 'job-123', + next_runnable_run_at: '2026-04-20T18:30:00.000Z', + alarm_in_sync: false, + }, + }) +}) diff --git a/packages/worker/src/mcp/capabilities/jobs/shared.ts b/packages/worker/src/mcp/capabilities/jobs/shared.ts index d7943b5ffb..1dece9ef98 100644 --- a/packages/worker/src/mcp/capabilities/jobs/shared.ts +++ b/packages/worker/src/mcp/capabilities/jobs/shared.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' +import { type JobManagerDebugState } from '#worker/jobs/manager-client.ts' import { type JobCreateInput, type JobSchedule, type JobView } from '#worker/jobs/types.ts' const onceScheduleSchema = z.object({ @@ -66,12 +67,72 @@ export const scheduledJobScheduleSchema = z.discriminatedUnion('type', [ cronScheduleSchema, ]) -const scheduledJobSummarySchema = z.discriminatedUnion('type', [ +export const scheduledJobSummarySchema = z.discriminatedUnion('type', [ onceScheduleSchema, intervalScheduleSchema, cronScheduleSchema, ]) +export const jobInspectionInputSchema = z.object({ + id: z + .string() + .min(1) + .describe('Job id from job_list output or a previous scheduling response.'), +}) + +const jobRunHistoryEntrySchema = z.object({ + started_at: z.string(), + finished_at: z.string(), + status: z.enum(['success', 'error']), + duration_ms: z.number(), + error: z.string().nullable(), +}) + +export const jobInspectionSchema = z.object({ + id: z.string(), + name: z.string(), + source_id: z.string(), + published_commit: z.string().nullable(), + storage_id: z.string(), + schedule: scheduledJobSummarySchema, + schedule_summary: z.string(), + timezone: z.string(), + enabled: z.boolean(), + kill_switch_enabled: z.boolean(), + created_at: z.string(), + updated_at: z.string(), + next_run_at: z.string(), + due_now: z.boolean(), + last_run_at: z.string().nullable(), + last_run_status: z.enum(['success', 'error']).nullable(), + last_run_error: z.string().nullable(), + last_duration_ms: z.number().nullable(), + run_count: z.number(), + success_count: z.number(), + error_count: z.number(), + recent_runs: z.array(jobRunHistoryEntrySchema), +}) + +export const jobManagerDebugSchema = z.object({ + binding_available: z.boolean(), + status: z.enum(['missing_binding', 'idle', 'armed', 'out_of_sync']), + stored_user_id: z.string().nullable(), + alarm_scheduled_for: z.string().nullable(), + next_runnable_job_id: z.string().nullable(), + next_runnable_run_at: z.string().nullable(), + alarm_in_sync: z.boolean().nullable(), +}) + +export const jobListOutputSchema = z.object({ + jobs: z.array(jobInspectionSchema), + alarm: jobManagerDebugSchema, +}) + +export const jobGetOutputSchema = z.object({ + job: jobInspectionSchema, + alarm: jobManagerDebugSchema, +}) + export const jobScheduleInputSchema = z.object({ ...scheduledJobInputBaseSchema, schedule: scheduledJobScheduleSchema, @@ -100,6 +161,26 @@ export const jobScheduleOutputSchema = z.object({ export type JobScheduleCapabilityInput = z.infer +function toScheduleOutput(created: Pick) { + switch (created.schedule.type) { + case 'once': + return { + type: 'once' as const, + run_at: created.schedule.runAt, + } + case 'interval': + return { + type: 'interval' as const, + every: created.schedule.every, + } + case 'cron': + return { + type: 'cron' as const, + expression: created.schedule.expression, + } + } +} + export function toJobSchedule( schedule: z.infer, ): JobSchedule { @@ -141,31 +222,69 @@ export function buildJobScheduleOutput(created: JobView) { name: created.name, source_id: created.sourceId, storage_id: created.storageId, - schedule: (() => { - switch (created.schedule.type) { - case 'once': - return { - type: 'once' as const, - run_at: created.schedule.runAt, - } - case 'interval': - return { - type: 'interval' as const, - every: created.schedule.every, - } - case 'cron': - return { - type: 'cron' as const, - expression: created.schedule.expression, - } - } - })(), + schedule: toScheduleOutput(created), schedule_summary: created.scheduleSummary, created_at: created.createdAt, next_run_at: created.nextRunAt, } } +export function buildJobInspectionOutput( + job: JobView, + input: { now?: Date } = {}, +) { + const now = input.now ?? new Date() + const nextRunAtValue = new Date(job.nextRunAt).valueOf() + const dueNow = + job.enabled && + job.killSwitchEnabled === false && + Number.isFinite(nextRunAtValue) && + nextRunAtValue <= now.valueOf() + + return { + id: job.id, + name: job.name, + source_id: job.sourceId, + published_commit: job.publishedCommit, + storage_id: job.storageId, + schedule: toScheduleOutput(job), + schedule_summary: job.scheduleSummary, + timezone: job.timezone, + enabled: job.enabled, + kill_switch_enabled: job.killSwitchEnabled, + created_at: job.createdAt, + updated_at: job.updatedAt, + next_run_at: job.nextRunAt, + due_now: dueNow, + last_run_at: job.lastRunAt ?? null, + last_run_status: job.lastRunStatus ?? null, + last_run_error: job.lastRunError ?? null, + last_duration_ms: job.lastDurationMs ?? null, + run_count: job.runCount, + success_count: job.successCount, + error_count: job.errorCount, + recent_runs: job.runHistory.map((entry) => ({ + started_at: entry.startedAt, + finished_at: entry.finishedAt, + status: entry.status, + duration_ms: entry.durationMs, + error: entry.error ?? null, + })), + } +} + +export function buildJobManagerDebugOutput(state: JobManagerDebugState) { + return { + binding_available: state.bindingAvailable, + status: state.status, + stored_user_id: state.storedUserId, + alarm_scheduled_for: state.alarmScheduledFor, + next_runnable_job_id: state.nextRunnableJobId, + next_runnable_run_at: state.nextRunnableRunAt, + alarm_in_sync: state.alarmInSync, + } +} + export async function createScheduledJobFromArgs(input: { env: Env callerContext: CapabilityContext['callerContext'] diff --git a/packages/worker/src/mcp/jobs-embed.ts b/packages/worker/src/mcp/jobs-embed.ts index c5ff82058b..b74fc7a5eb 100644 --- a/packages/worker/src/mcp/jobs-embed.ts +++ b/packages/worker/src/mcp/jobs-embed.ts @@ -31,5 +31,5 @@ export function buildJobEmbedText( } export function buildJobUsage(job: Pick) { - return `Inspect with job_get: ${JSON.stringify({ id: job.id })}. Trigger immediately with job_run_now: ${JSON.stringify({ id: job.id })}.` + return `Inspect with job_get: ${JSON.stringify({ id: job.id })}. List jobs with job_list: {}.` } diff --git a/packages/worker/src/mcp/server-instructions.ts b/packages/worker/src/mcp/server-instructions.ts index bd7156a098..a2238900dd 100644 --- a/packages/worker/src/mcp/server-instructions.ts +++ b/packages/worker/src/mcp/server-instructions.ts @@ -23,6 +23,7 @@ Conventions - Do not ask the user to paste secrets in chat; use saved secrets or \`open_generated_ui\`. - \`package_save\`: create or replace a repo-backed saved package rooted at \`package.json\`. Standard package exports define the package surface. \`package.json#kody\` contains Kody-specific metadata such as tags, optional app config, and package-owned jobs. - \`package_get\` / \`package_list\` / \`package_delete\`: inspect or manage saved packages for the signed-in user. +- \`job_list\` / \`job_get\`: inspect the signed-in user's scheduled jobs, recent run outcomes, and current per-user alarm state when debugging scheduling issues. - \`job_schedule\`: schedule a repo-backed job for the signed-in user without creating a saved package first. Supports one-off, interval, and cron schedules. - \`job_schedule_once\`: compatibility wrapper for one-off repo-backed jobs when you only need a single run time. - Package jobs are schedules owned by a package. For ad hoc work that is not tied to a package, use \`job_schedule\`. Package apps are optional UI surfaces declared by the package, not a separate top-level primitive. From 47a9d53d6cd20271f217a43187a66b8b03d7e64e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Apr 2026 22:23:06 +0000 Subject: [PATCH 2/3] Fix jobs capability startup cycle Co-authored-by: Kent C. Dodds --- packages/worker/src/jobs/manager-client.ts | 8 +++++++- packages/worker/src/jobs/manager-do.ts | 6 ++++-- packages/worker/src/mcp/capabilities/jobs/job-get.ts | 2 +- packages/worker/src/mcp/capabilities/jobs/job-list.ts | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/worker/src/jobs/manager-client.ts b/packages/worker/src/jobs/manager-client.ts index 600ab64e24..6144546a4b 100644 --- a/packages/worker/src/jobs/manager-client.ts +++ b/packages/worker/src/jobs/manager-client.ts @@ -1,9 +1,15 @@ import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { type JobRepoCheckPolicy } from './types.ts' +export type JobManagerDebugStatus = + | 'missing_binding' + | 'idle' + | 'armed' + | 'out_of_sync' + export type JobManagerDebugState = { bindingAvailable: boolean - status: 'missing_binding' | 'idle' | 'armed' | 'out_of_sync' + status: JobManagerDebugStatus storedUserId: string | null alarmScheduledFor: string | null nextRunnableJobId: string | null diff --git a/packages/worker/src/jobs/manager-do.ts b/packages/worker/src/jobs/manager-do.ts index df798127d6..58572f23fc 100644 --- a/packages/worker/src/jobs/manager-do.ts +++ b/packages/worker/src/jobs/manager-do.ts @@ -4,10 +4,12 @@ import { DurableObject } from 'cloudflare:workers' import { buildSentryOptions } from '#worker/sentry-options.ts' import { getNextRunnableJob, runDueJobsForUser, runJobNow } from './service.ts' import { - type JobManagerDebugState, - type JobManagerDebugStatus, type JobRepoCheckPolicy, } from './types.ts' +import { + type JobManagerDebugState, + type JobManagerDebugStatus, +} from './manager-client.ts' const userIdStorageKey = 'user-id' diff --git a/packages/worker/src/mcp/capabilities/jobs/job-get.ts b/packages/worker/src/mcp/capabilities/jobs/job-get.ts index 283162c9f2..5b901ba349 100644 --- a/packages/worker/src/mcp/capabilities/jobs/job-get.ts +++ b/packages/worker/src/mcp/capabilities/jobs/job-get.ts @@ -1,7 +1,6 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' -import { getJobInspection } from '#worker/jobs/service.ts' import { buildJobInspectionOutput, buildJobManagerDebugOutput, @@ -23,6 +22,7 @@ export const jobGetCapability = defineDomainCapability( outputSchema: jobGetOutputSchema, async handler(args, ctx) { const user = requireMcpUser(ctx.callerContext) + const { getJobInspection } = await import('#worker/jobs/service.ts') const inspection = await getJobInspection({ env: ctx.env, userId: user.userId, diff --git a/packages/worker/src/mcp/capabilities/jobs/job-list.ts b/packages/worker/src/mcp/capabilities/jobs/job-list.ts index 759eb7030b..c4b1d1ae5f 100644 --- a/packages/worker/src/mcp/capabilities/jobs/job-list.ts +++ b/packages/worker/src/mcp/capabilities/jobs/job-list.ts @@ -2,7 +2,6 @@ import { z } from 'zod' import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' -import { inspectJobsForUser } from '#worker/jobs/service.ts' import { buildJobInspectionOutput, buildJobManagerDebugOutput, @@ -31,6 +30,7 @@ export const jobListCapability = defineDomainCapability( outputSchema: jobListOutputSchema, async handler(_args, ctx) { const user = requireMcpUser(ctx.callerContext) + const { inspectJobsForUser } = await import('#worker/jobs/service.ts') const inspection = await inspectJobsForUser({ env: ctx.env, userId: user.userId, From 3b743fd61085840e31203cfda609d3553976f968 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Apr 2026 22:49:22 +0000 Subject: [PATCH 3/3] Address review feedback on job inspection Co-authored-by: Kent C. Dodds --- .../worker/src/jobs/manager-do.node.test.ts | 41 ++++ packages/worker/src/jobs/manager-do.ts | 23 +- packages/worker/src/jobs/manager-state.ts | 38 +++ packages/worker/src/jobs/service.ts | 6 +- .../jobs/job-schedule.node.test.ts | 224 +++++++++--------- .../src/mcp/capabilities/jobs/shared.ts | 12 +- 6 files changed, 214 insertions(+), 130 deletions(-) create mode 100644 packages/worker/src/jobs/manager-do.node.test.ts create mode 100644 packages/worker/src/jobs/manager-state.ts diff --git a/packages/worker/src/jobs/manager-do.node.test.ts b/packages/worker/src/jobs/manager-do.node.test.ts new file mode 100644 index 0000000000..aff62ed9bd --- /dev/null +++ b/packages/worker/src/jobs/manager-do.node.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from 'vitest' +import { resolveJobManagerAlarmState } from './manager-state.ts' + +test('resolveJobManagerAlarmState treats equivalent UTC formats as in sync', () => { + expect( + resolveJobManagerAlarmState({ + alarmTimestamp: Date.parse('2026-04-20T18:30:00.000Z'), + nextRunnableRunAt: '2026-04-20T18:30:00Z', + }), + ).toEqual({ + alarmScheduledFor: '2026-04-20T18:30:00.000Z', + alarmInSync: true, + status: 'armed', + }) +}) + +test('resolveJobManagerAlarmState marks mismatched alarm times as out of sync', () => { + expect( + resolveJobManagerAlarmState({ + alarmTimestamp: Date.parse('2026-04-20T19:00:00.000Z'), + nextRunnableRunAt: '2026-04-20T18:30:00.000Z', + }), + ).toEqual({ + alarmScheduledFor: '2026-04-20T19:00:00.000Z', + alarmInSync: false, + status: 'out_of_sync', + }) +}) + +test('resolveJobManagerAlarmState reports idle when no alarm or next runnable job exists', () => { + expect( + resolveJobManagerAlarmState({ + alarmTimestamp: null, + nextRunnableRunAt: null, + }), + ).toEqual({ + alarmScheduledFor: null, + alarmInSync: true, + status: 'idle', + }) +}) diff --git a/packages/worker/src/jobs/manager-do.ts b/packages/worker/src/jobs/manager-do.ts index 58572f23fc..2a119ad296 100644 --- a/packages/worker/src/jobs/manager-do.ts +++ b/packages/worker/src/jobs/manager-do.ts @@ -3,12 +3,12 @@ import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { DurableObject } from 'cloudflare:workers' import { buildSentryOptions } from '#worker/sentry-options.ts' import { getNextRunnableJob, runDueJobsForUser, runJobNow } from './service.ts' +import { resolveJobManagerAlarmState } from './manager-state.ts' import { type JobRepoCheckPolicy, } from './types.ts' import { type JobManagerDebugState, - type JobManagerDebugStatus, } from './manager-client.ts' const userIdStorageKey = 'user-id' @@ -53,23 +53,12 @@ class JobManagerBase extends DurableObject { userId, }), ]) - const alarmScheduledFor = - typeof alarmTimestamp === 'number' - ? new Date(alarmTimestamp).toISOString() - : null const nextRunnableRunAt = nextJob?.nextRunAt ?? null - const alarmInSync = - nextRunnableRunAt == null - ? alarmScheduledFor === null - : alarmScheduledFor === nextRunnableRunAt - const status: JobManagerDebugStatus = - nextRunnableRunAt == null - ? alarmScheduledFor == null - ? 'idle' - : 'out_of_sync' - : alarmScheduledFor === nextRunnableRunAt - ? 'armed' - : 'out_of_sync' + const { alarmScheduledFor, alarmInSync, status } = + resolveJobManagerAlarmState({ + alarmTimestamp, + nextRunnableRunAt, + }) return { bindingAvailable: true, status, diff --git a/packages/worker/src/jobs/manager-state.ts b/packages/worker/src/jobs/manager-state.ts new file mode 100644 index 0000000000..19bdb2c78c --- /dev/null +++ b/packages/worker/src/jobs/manager-state.ts @@ -0,0 +1,38 @@ +import { type JobManagerDebugState, type JobManagerDebugStatus } from './manager-client.ts' + +export function resolveJobManagerAlarmState(input: { + alarmTimestamp: number | null + nextRunnableRunAt: string | null +}): Pick { + const alarmScheduledFor = + typeof input.alarmTimestamp === 'number' + ? new Date(input.alarmTimestamp).toISOString() + : null + const nextRunnableMs = + input.nextRunnableRunAt == null + ? null + : new Date(input.nextRunnableRunAt).valueOf() + const alarmMs = + typeof input.alarmTimestamp === 'number' ? input.alarmTimestamp : null + const timestampsMatch = + nextRunnableMs != null && + alarmMs != null && + Number.isFinite(nextRunnableMs) && + alarmMs === nextRunnableMs + const alarmInSync = + nextRunnableMs == null ? alarmMs == null : timestampsMatch + const status: JobManagerDebugStatus = + nextRunnableMs == null + ? alarmMs == null + ? 'idle' + : 'out_of_sync' + : timestampsMatch + ? 'armed' + : 'out_of_sync' + + return { + alarmScheduledFor, + alarmInSync, + status, + } +} diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 121d1fe8a0..fe8c16939c 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -8,7 +8,10 @@ import { deleteJobVector, upsertJobVector } from '#mcp/jobs-vectorize.ts' import { type ExecuteResult } from '@cloudflare/codemode' import { exports as workerExports } from 'cloudflare:workers' import { applyExecutionOutcome, processDueJobs } from './process-due-jobs.ts' -import { syncJobManagerAlarm } from './manager-client.ts' +import { + getJobManagerDebugState, + syncJobManagerAlarm, +} from './manager-client.ts' import { deleteJobRow, getJobRowById, @@ -18,7 +21,6 @@ import { insertJobRow, updateJobRow, } from './repo.ts' -import { getJobManagerDebugState } from './manager-client.ts' import { computeNextRunAt, formatJobError, diff --git a/packages/worker/src/mcp/capabilities/jobs/job-schedule.node.test.ts b/packages/worker/src/mcp/capabilities/jobs/job-schedule.node.test.ts index 61d315e196..c3082c4a26 100644 --- a/packages/worker/src/mcp/capabilities/jobs/job-schedule.node.test.ts +++ b/packages/worker/src/mcp/capabilities/jobs/job-schedule.node.test.ts @@ -346,6 +346,8 @@ test('job_schedule requires an authenticated user', async () => { test('job_list returns inspectable jobs plus alarm state', async () => { resetMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-20T18:30:00.000Z')) const env = {} as Env const callerContext = createMcpCallerContext({ baseUrl: 'https://example.com', @@ -391,62 +393,68 @@ test('job_list returns inspectable jobs plus alarm state', async () => { }, }) - const result = await jobListCapability.handler( - {}, - { - env, - callerContext, - }, - ) - - expect(mockModule.inspectJobsForUser).toHaveBeenCalledWith({ - env, - userId: 'user-123', - }) - expect(result).toEqual({ - jobs: [ + try { + const result = await jobListCapability.handler( + {}, { - id: 'job-123', - name: 'Turn lights off', - source_id: 'source-123', - published_commit: 'commit-123', - storage_id: 'job:job-123', - schedule: { - type: 'once', - run_at: '2026-04-20T18:30:00.000Z', + env, + callerContext, + }, + ) + + expect(mockModule.inspectJobsForUser).toHaveBeenCalledWith({ + env, + userId: 'user-123', + }) + expect(result).toEqual({ + jobs: [ + { + id: 'job-123', + name: 'Turn lights off', + source_id: 'source-123', + published_commit: 'commit-123', + storage_id: 'job:job-123', + schedule: { + type: 'once', + run_at: '2026-04-20T18:30:00.000Z', + }, + schedule_summary: 'Runs once at 2026-04-20T18:30:00.000Z', + timezone: 'UTC', + enabled: true, + kill_switch_enabled: false, + created_at: '2026-04-20T10:00:00.000Z', + updated_at: '2026-04-20T10:05:00.000Z', + next_run_at: '2026-04-20T18:30:00.000Z', + due_now: true, + last_run_at: null, + last_run_status: null, + last_run_error: null, + last_duration_ms: null, + run_count: 0, + success_count: 0, + error_count: 0, + recent_runs: [], }, - schedule_summary: 'Runs once at 2026-04-20T18:30:00.000Z', - timezone: 'UTC', - enabled: true, - kill_switch_enabled: false, - created_at: '2026-04-20T10:00:00.000Z', - updated_at: '2026-04-20T10:05:00.000Z', - next_run_at: '2026-04-20T18:30:00.000Z', - due_now: true, - last_run_at: null, - last_run_status: null, - last_run_error: null, - last_duration_ms: null, - run_count: 0, - success_count: 0, - error_count: 0, - recent_runs: [], + ], + alarm: { + binding_available: true, + status: 'armed', + stored_user_id: 'user-123', + alarm_scheduled_for: '2026-04-20T18:30:00.000Z', + next_runnable_job_id: 'job-123', + next_runnable_run_at: '2026-04-20T18:30:00.000Z', + alarm_in_sync: true, }, - ], - alarm: { - binding_available: true, - status: 'armed', - stored_user_id: 'user-123', - alarm_scheduled_for: '2026-04-20T18:30:00.000Z', - next_runnable_job_id: 'job-123', - next_runnable_run_at: '2026-04-20T18:30:00.000Z', - alarm_in_sync: true, - }, - }) + }) + } finally { + vi.useRealTimers() + } }) test('job_get returns one inspectable job plus alarm state', async () => { resetMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-20T18:30:00.000Z')) const env = {} as Env const callerContext = createMcpCallerContext({ baseUrl: 'https://example.com', @@ -502,63 +510,67 @@ test('job_get returns one inspectable job plus alarm state', async () => { }, }) - const result = await jobGetCapability.handler( - { id: 'job-123' }, - { - env, - callerContext, - }, - ) - - expect(mockModule.getJobInspection).toHaveBeenCalledWith({ - env, - userId: 'user-123', - jobId: 'job-123', - }) - expect(result).toEqual({ - job: { - id: 'job-123', - name: 'Turn lights off', - source_id: 'source-123', - published_commit: null, - storage_id: 'job:job-123', - schedule: { - type: 'once', - run_at: '2026-04-20T18:30:00.000Z', + try { + const result = await jobGetCapability.handler( + { id: 'job-123' }, + { + env, + callerContext, }, - schedule_summary: 'Runs once at 2026-04-20T18:30:00.000Z', - timezone: 'UTC', - enabled: true, - kill_switch_enabled: false, - created_at: '2026-04-20T10:00:00.000Z', - updated_at: '2026-04-20T10:05:00.000Z', - next_run_at: '2026-04-20T18:30:00.000Z', - due_now: true, - last_run_at: '2026-04-20T09:00:00.000Z', - last_run_status: 'error', - last_run_error: 'Timed out', - last_duration_ms: 1200, - run_count: 2, - success_count: 1, - error_count: 1, - recent_runs: [ - { - started_at: '2026-04-20T08:59:58.000Z', - finished_at: '2026-04-20T09:00:00.000Z', - status: 'error', - duration_ms: 1200, - error: 'Timed out', + ) + + expect(mockModule.getJobInspection).toHaveBeenCalledWith({ + env, + userId: 'user-123', + jobId: 'job-123', + }) + expect(result).toEqual({ + job: { + id: 'job-123', + name: 'Turn lights off', + source_id: 'source-123', + published_commit: null, + storage_id: 'job:job-123', + schedule: { + type: 'once', + run_at: '2026-04-20T18:30:00.000Z', }, - ], - }, - alarm: { - binding_available: true, - status: 'out_of_sync', - stored_user_id: 'user-123', - alarm_scheduled_for: '2026-04-20T19:00:00.000Z', - next_runnable_job_id: 'job-123', - next_runnable_run_at: '2026-04-20T18:30:00.000Z', - alarm_in_sync: false, - }, - }) + schedule_summary: 'Runs once at 2026-04-20T18:30:00.000Z', + timezone: 'UTC', + enabled: true, + kill_switch_enabled: false, + created_at: '2026-04-20T10:00:00.000Z', + updated_at: '2026-04-20T10:05:00.000Z', + next_run_at: '2026-04-20T18:30:00.000Z', + due_now: true, + last_run_at: '2026-04-20T09:00:00.000Z', + last_run_status: 'error', + last_run_error: 'Timed out', + last_duration_ms: 1200, + run_count: 2, + success_count: 1, + error_count: 1, + recent_runs: [ + { + started_at: '2026-04-20T08:59:58.000Z', + finished_at: '2026-04-20T09:00:00.000Z', + status: 'error', + duration_ms: 1200, + error: 'Timed out', + }, + ], + }, + alarm: { + binding_available: true, + status: 'out_of_sync', + stored_user_id: 'user-123', + alarm_scheduled_for: '2026-04-20T19:00:00.000Z', + next_runnable_job_id: 'job-123', + next_runnable_run_at: '2026-04-20T18:30:00.000Z', + alarm_in_sync: false, + }, + }) + } finally { + vi.useRealTimers() + } }) diff --git a/packages/worker/src/mcp/capabilities/jobs/shared.ts b/packages/worker/src/mcp/capabilities/jobs/shared.ts index 1dece9ef98..29775c2601 100644 --- a/packages/worker/src/mcp/capabilities/jobs/shared.ts +++ b/packages/worker/src/mcp/capabilities/jobs/shared.ts @@ -80,11 +80,13 @@ export const jobInspectionInputSchema = z.object({ .describe('Job id from job_list output or a previous scheduling response.'), }) +const nonNegativeIntegerSchema = z.number().int().min(0) + const jobRunHistoryEntrySchema = z.object({ started_at: z.string(), finished_at: z.string(), status: z.enum(['success', 'error']), - duration_ms: z.number(), + duration_ms: nonNegativeIntegerSchema, error: z.string().nullable(), }) @@ -106,10 +108,10 @@ export const jobInspectionSchema = z.object({ last_run_at: z.string().nullable(), last_run_status: z.enum(['success', 'error']).nullable(), last_run_error: z.string().nullable(), - last_duration_ms: z.number().nullable(), - run_count: z.number(), - success_count: z.number(), - error_count: z.number(), + last_duration_ms: nonNegativeIntegerSchema.nullable(), + run_count: nonNegativeIntegerSchema, + success_count: nonNegativeIntegerSchema, + error_count: nonNegativeIntegerSchema, recent_runs: z.array(jobRunHistoryEntrySchema), })