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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 rename a job and adjust safe mutable fields such as schedule,
timezone, enabled state, kill-switch 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.

## 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