Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/use/execute.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ module-oriented runtime model:
**`codemode.job_schedule(...)`** without creating a saved package
- **`codemode.job_schedule_once(...)`** remains available as a convenience alias
for one-off schedules
- **`codemode.job_update(...)`** updates an existing scheduled job by id for
safe mutable fields such as name, code, params, schedule, timezone,
enabled/disabled state, or kill switch state
- **`codemode.job_delete(...)`** removes an existing scheduled job by id for the
signed-in user
- **`codemode.job_run_now(...)`** runs an existing scheduled job immediately and
returns both the updated job state and the execution result for debugging

Expand Down
5 changes: 3 additions & 2 deletions docs/use/first-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ connector, value, or secret reference, then run work through **execute**.
declare package-owned jobs, and can optionally expose an app/UI surface. For
scheduled work that should not become a saved package, use the built-in
`job_schedule` capability. `job_schedule_once` remains available as a one-off
convenience alias, and `job_run_now` can trigger an existing job immediately
for debugging or catch-up runs.
convenience alias, `job_run_now` can trigger an existing job immediately for
debugging or catch-up runs, and `job_update` / `job_delete` let you correct or
remove an existing scheduled job by id.
- **Ask for natural-language goals**, for example: “Search Kody for GitHub pull
request automation” or “Find a saved package for Cloudflare DNS helpers.”
- **Do not paste secrets in chat.** Use saved secrets, generated UI, or the
Expand Down
8 changes: 5 additions & 3 deletions docs/use/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,11 @@ package.
Jobs are part of the package definition.

For repo-backed jobs that are not part of a saved package, use `job_schedule`
instead. `job_schedule_once` remains available as the one-off shortcut, and
`job_run_now` can trigger an existing scheduled job immediately for debugging or
ad hoc runs.
instead. `job_schedule_once` remains available as the one-off shortcut,
`job_update` can adjust safe mutable fields such as schedule, timezone, enabled
state, params, or code, `job_delete` removes an existing scheduled job by id,
and `job_run_now` can trigger an existing scheduled job immediately for
debugging or ad hoc runs.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Save and edit packages

Expand Down
156 changes: 156 additions & 0 deletions packages/worker/src/jobs/service.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,162 @@ test('deleteJob syncs the job manager alarm after removing a job', async () => {
})
})

test('updateJob rejects another user trying to mutate a job by id', async () => {
const env = {
APP_DB: createDatabase(),
} as Env
mockRepoPersistence()
const ownerCallerContext = createBaseCallerContext()
const created = await createJob({
env,
callerContext: ownerCallerContext,
body: {
name: 'Owner job',
code: 'export default async () => ({ ok: true })',
schedule: {
type: 'interval',
every: '15m',
},
},
})
const otherCallerContext = createMcpCallerContext({
baseUrl: 'https://example.com',
user: {
userId: 'user-999',
email: 'other@example.com',
displayName: 'Other User',
},
storageContext: {
sessionId: null,
appId: 'app-999',
},
}) as PersistedJobCallerContext

await expect(
updateJob({
env,
callerContext: otherCallerContext,
body: {
id: created.id,
enabled: false,
},
}),
).rejects.toThrow(`Job "${created.id}" was not found.`)

const inspection = await getJobInspection({
env,
userId: ownerCallerContext.user.userId,
jobId: created.id,
})
expect(inspection.job.enabled).toBe(true)
})

test('deleteJob rejects another user trying to remove a job by id', async () => {
const env = {
APP_DB: createDatabase(),
} as Env
mockRepoPersistence()
const ownerCallerContext = createBaseCallerContext()
const created = await createJob({
env,
callerContext: ownerCallerContext,
body: {
name: 'Owner job',
code: 'export default async () => ({ ok: true })',
schedule: {
type: 'interval',
every: '15m',
},
},
})

await expect(
deleteJob({
env,
userId: 'user-999',
jobId: created.id,
}),
).rejects.toThrow(`Job "${created.id}" was not found.`)

const inspection = await getJobInspection({
env,
userId: ownerCallerContext.user.userId,
jobId: created.id,
})
expect(inspection.job.id).toBe(created.id)
})

test('updateJob clears params, updates timezone, and disables a job', async () => {
const env = {
APP_DB: createDatabase(),
} as Env
mockRepoPersistence()
const callerContext = createBaseCallerContext()
const created = await createJob({
env,
callerContext,
body: {
name: 'Mutable job',
code: 'export default async () => ({ ok: true })',
params: {
room: 'office',
},
schedule: {
type: 'cron',
expression: '0 9 * * 1',
},
timezone: 'UTC',
},
})

const updated = await updateJob({
env,
callerContext,
body: {
id: created.id,
params: null,
timezone: 'America/Denver',
enabled: false,
},
})

expect(updated.params).toBeUndefined()
expect(updated.timezone).toBe('America/Denver')
expect(updated.enabled).toBe(false)
expect(updated.scheduleSummary).toContain('America/Denver')
})

test('updateJob rejects empty replacement code', async () => {
const env = {
APP_DB: createDatabase(),
} as Env
mockRepoPersistence()
const callerContext = createBaseCallerContext()
const created = await createJob({
env,
callerContext,
body: {
name: 'Code validation job',
code: 'export default async () => ({ ok: true })',
schedule: {
type: 'interval',
every: '15m',
},
},
})

await expect(
updateJob({
env,
callerContext,
body: {
id: created.id,
code: ' ',
},
}),
).rejects.toThrow('Jobs require non-empty code.')
})

test('inspectJobsForUser returns persisted job fields with alarm debug state', async () => {
const env = {
APP_DB: createDatabase(),
Expand Down
8 changes: 8 additions & 0 deletions packages/worker/src/mcp/capabilities/jobs/domain.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { defineDomain } from '../define-domain.ts'
import { capabilityDomainNames } from '../domain-metadata.ts'
import { jobDeleteCapability } from './job-delete.ts'
import { jobGetCapability } from './job-get.ts'
import { jobListCapability } from './job-list.ts'
import { jobRunNowCapability } from './job-run-now.ts'
import { jobScheduleCapability } from './job-schedule.ts'
import { jobScheduleOnceCapability } from './job-schedule-once.ts'
import { jobUpdateCapability } from './job-update.ts'

export const jobsDomain = defineDomain({
name: capabilityDomainNames.jobs,
Expand All @@ -16,6 +18,10 @@ export const jobsDomain = defineDomain({
'one-off',
'interval',
'cron',
'update',
'delete',
'disable',
'enable',
'background',
'debug',
'inspect',
Expand All @@ -27,6 +33,8 @@ export const jobsDomain = defineDomain({
jobGetCapability,
jobScheduleCapability,
jobScheduleOnceCapability,
jobUpdateCapability,
jobDeleteCapability,
jobRunNowCapability,
],
})
30 changes: 30 additions & 0 deletions packages/worker/src/mcp/capabilities/jobs/job-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts'
import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
import {
deleteJobFromArgs,
type JobDeleteCapabilityInput,
jobDeleteInputSchema,
jobDeleteOutputSchema,
} from './shared.ts'

export const jobDeleteCapability = defineDomainCapability(
capabilityDomainNames.jobs,
{
name: 'job_delete',
description:
'Delete an existing scheduled job owned by the signed-in user by id. Use this to remove a mistaken or obsolete schedule entirely.',
keywords: ['job', 'delete', 'remove', 'cancel', 'unschedule', 'cleanup'],
readOnly: false,
idempotent: false,
destructive: true,
inputSchema: jobDeleteInputSchema,
outputSchema: jobDeleteOutputSchema,
async handler(args: JobDeleteCapabilityInput, ctx) {
return deleteJobFromArgs({
env: ctx.env,
callerContext: ctx.callerContext,
args,
})
},
},
)
Loading
Loading