From 24ab18d5d474765a1dd7938b7ecc1d885eb98248 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 26 Oct 2026 17:40:35 -0700 Subject: [PATCH 1/3] feat(core): schedule tools, /schedule command and experimental API --- packages/client/script/generate.ts | 2 + packages/client/src/effect/api/api.ts | 51 ++ .../client/src/effect/generated/client.ts | 79 ++ .../client/src/promise/generated/client.ts | 88 ++ .../client/src/promise/generated/types.ts | 81 ++ packages/client/test/promise.test.ts | 3 +- packages/core/src/plugin/command/schedule.txt | 11 + packages/core/src/plugin/internal.ts | 5 + packages/core/src/schedule.ts | 16 +- packages/core/src/tool/plugin/schedule.ts | 191 +++++ packages/core/test/tool-schedule.test.ts | 203 +++++ packages/protocol/openapi.json | 750 ++++++++++++++++++ packages/protocol/src/errors.ts | 9 + packages/protocol/src/groups/experimental.ts | 3 +- packages/protocol/src/groups/schedule.ts | 68 ++ packages/schema/src/schedule.ts | 54 +- .../server/src/handlers/persistent-pty.ts | 8 + packages/server/src/handlers/schedule.ts | 71 ++ packages/server/test/schedule.test.ts | 81 ++ 19 files changed, 1761 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/plugin/command/schedule.txt create mode 100644 packages/core/src/tool/plugin/schedule.ts create mode 100644 packages/core/test/tool-schedule.test.ts create mode 100644 packages/protocol/src/groups/schedule.ts create mode 100644 packages/server/src/handlers/schedule.ts create mode 100644 packages/server/test/schedule.test.ts diff --git a/packages/client/script/generate.ts b/packages/client/script/generate.ts index 1b3395cc7e23..dc7ae04fc4d5 100644 --- a/packages/client/script/generate.ts +++ b/packages/client/script/generate.ts @@ -31,6 +31,7 @@ import { PtyTicket } from "@opencode/schema/pty-ticket" import { Question } from "@opencode/schema/question" import { Reference } from "@opencode/schema/reference" import { AbsolutePath, RelativePath } from "@opencode/schema/schema" +import { Schedule } from "@opencode/schema/schedule" import { Session } from "@opencode/schema/session" import { SessionMessage } from "@opencode/schema/session-message" import { SessionInbox } from "@opencode/schema/session-inbox" @@ -76,6 +77,7 @@ const effectTypeReferences = [ ...namespaceTypes("PtyTicket", "@opencode/schema/pty-ticket", PtyTicket), ...namespaceTypes("Question", "@opencode/schema/question", Question), ...namespaceTypes("Reference", "@opencode/schema/reference", Reference), + ...namespaceTypes("Schedule", "@opencode/schema/schedule", Schedule), ...namespaceTypes("Session", "@opencode/schema/session", Session), ...namespaceTypes("SessionMessage", "@opencode/schema/session-message", SessionMessage), ...namespaceTypes("SessionInbox", "@opencode/schema/session-inbox", SessionInbox), diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 3c80f44505d3..4e4cd4c2dc3f 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -37,6 +37,7 @@ import type { FileSystem } from "@opencode/schema/filesystem" import type { Command } from "@opencode/schema/command" import type { OpenCodeEvent } from "@opencode/protocol/groups/event" import type { Pty } from "@opencode/schema/pty" +import type { Schedule } from "@opencode/schema/schedule" import type { Reference } from "@opencode/schema/reference" import type { Worktree } from "@opencode/schema/worktree" import type { Vcs } from "@opencode/schema/vcs" @@ -2254,6 +2255,48 @@ export type ExperimentalPersistentPtyConnectTokenOperation = ( input: ExperimentalPersistentPtyConnectTokenInput, ) => Effect.Effect +export type ExperimentalScheduleCreateInput = { + readonly sessionID: Session.ID + readonly name?: string | undefined + readonly when: Schedule.WhenInput + readonly ends?: Schedule.Ends | undefined + readonly prompt: string +} +export type ExperimentalScheduleCreateOutput = Schedule.Info +export type ExperimentalScheduleCreateOperation = ( + input: ExperimentalScheduleCreateInput, +) => Effect.Effect + +export type ExperimentalScheduleListInput = { readonly sessionID: Session.ID } +export type ExperimentalScheduleListOutput = ReadonlyArray +export type ExperimentalScheduleListOperation = ( + input: ExperimentalScheduleListInput, +) => Effect.Effect + +export type ExperimentalScheduleGetInput = { readonly scheduleID: Schedule.ID } +export type ExperimentalScheduleGetOutput = Schedule.Info +export type ExperimentalScheduleGetOperation = ( + input: ExperimentalScheduleGetInput, +) => Effect.Effect + +export type ExperimentalSchedulePauseInput = { readonly scheduleID: Schedule.ID } +export type ExperimentalSchedulePauseOutput = Schedule.Info +export type ExperimentalSchedulePauseOperation = ( + input: ExperimentalSchedulePauseInput, +) => Effect.Effect + +export type ExperimentalScheduleResumeInput = { readonly scheduleID: Schedule.ID } +export type ExperimentalScheduleResumeOutput = Schedule.Info +export type ExperimentalScheduleResumeOperation = ( + input: ExperimentalScheduleResumeInput, +) => Effect.Effect + +export type ExperimentalScheduleDeleteInput = { readonly scheduleID: Schedule.ID } +export type ExperimentalScheduleDeleteOutput = void +export type ExperimentalScheduleDeleteOperation = ( + input: ExperimentalScheduleDeleteInput, +) => Effect.Effect + export interface ExperimentalApi { readonly persistentPty: { readonly read: ExperimentalPersistentPtyReadOperation @@ -2267,6 +2310,14 @@ export interface ExperimentalApi { readonly remove: ExperimentalPersistentPtyRemoveOperation readonly connectToken: ExperimentalPersistentPtyConnectTokenOperation } + readonly schedule: { + readonly create: ExperimentalScheduleCreateOperation + readonly list: ExperimentalScheduleListOperation + readonly get: ExperimentalScheduleGetOperation + readonly pause: ExperimentalSchedulePauseOperation + readonly resume: ExperimentalScheduleResumeOperation + readonly delete: ExperimentalScheduleDeleteOperation + } } export type ShellListInput = { readonly location?: { readonly directory?: string | undefined } | undefined } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index a7d53fd2b025..ef4cb39af2db 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -243,6 +243,18 @@ import type { ExperimentalPersistentPtyRemoveOutput, ExperimentalPersistentPtyConnectTokenInput, ExperimentalPersistentPtyConnectTokenOutput, + ExperimentalScheduleCreateInput, + ExperimentalScheduleCreateOutput, + ExperimentalScheduleListInput, + ExperimentalScheduleListOutput, + ExperimentalScheduleGetInput, + ExperimentalScheduleGetOutput, + ExperimentalSchedulePauseInput, + ExperimentalSchedulePauseOutput, + ExperimentalScheduleResumeInput, + ExperimentalScheduleResumeOutput, + ExperimentalScheduleDeleteInput, + ExperimentalScheduleDeleteOutput, ShellListInput, ShellListOutput, ShellCreateInput, @@ -1541,6 +1553,65 @@ const EndpointExperimentalPersistentPtyConnectToken = ), ) +const EndpointExperimentalScheduleCreate = + (raw: RawClient["server.experimental"]) => (input: ExperimentalScheduleCreateInput) => + preserveEffect()( + raw["schedule.create"]({ + payload: { + sessionID: input["sessionID"], + name: input["name"], + when: input["when"], + ends: input["ends"], + prompt: input["prompt"], + }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const EndpointExperimentalScheduleList = + (raw: RawClient["server.experimental"]) => (input: ExperimentalScheduleListInput) => + preserveEffect()( + raw["schedule.list"]({ query: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const EndpointExperimentalScheduleGet = + (raw: RawClient["server.experimental"]) => (input: ExperimentalScheduleGetInput) => + preserveEffect()( + raw["schedule.get"]({ params: { scheduleID: input["scheduleID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const EndpointExperimentalSchedulePause = + (raw: RawClient["server.experimental"]) => (input: ExperimentalSchedulePauseInput) => + preserveEffect()( + raw["schedule.pause"]({ params: { scheduleID: input["scheduleID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const EndpointExperimentalScheduleResume = + (raw: RawClient["server.experimental"]) => (input: ExperimentalScheduleResumeInput) => + preserveEffect()( + raw["schedule.resume"]({ params: { scheduleID: input["scheduleID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const EndpointExperimentalScheduleDelete = + (raw: RawClient["server.experimental"]) => (input: ExperimentalScheduleDeleteInput) => + preserveEffect()( + raw["schedule.delete"]({ params: { scheduleID: input["scheduleID"] } }).pipe(Effect.mapError(mapClientError)), + ) + const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({ persistentPty: { read: EndpointExperimentalPersistentPtyRead(raw), @@ -1554,6 +1625,14 @@ const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({ remove: EndpointExperimentalPersistentPtyRemove(raw), connectToken: EndpointExperimentalPersistentPtyConnectToken(raw), }, + schedule: { + create: EndpointExperimentalScheduleCreate(raw), + list: EndpointExperimentalScheduleList(raw), + get: EndpointExperimentalScheduleGet(raw), + pause: EndpointExperimentalSchedulePause(raw), + resume: EndpointExperimentalScheduleResume(raw), + delete: EndpointExperimentalScheduleDelete(raw), + }, }) const EndpointShellList = (raw: RawClient["server.shell"]) => (input?: ShellListInput) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 32aec4b71f19..02cf6ed4a4c1 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -243,6 +243,18 @@ import type { ExperimentalPersistentPtyConnectTokenOutput, ExperimentalMediaGetInput, ExperimentalMediaGetOutput, + ExperimentalScheduleCreateInput, + ExperimentalScheduleCreateOutput, + ExperimentalScheduleListInput, + ExperimentalScheduleListOutput, + ExperimentalScheduleGetInput, + ExperimentalScheduleGetOutput, + ExperimentalSchedulePauseInput, + ExperimentalSchedulePauseOutput, + ExperimentalScheduleResumeInput, + ExperimentalScheduleResumeOutput, + ExperimentalScheduleDeleteInput, + ExperimentalScheduleDeleteOutput, ShellListInput, ShellListOutput, ShellCreateInput, @@ -2097,6 +2109,82 @@ export function make(options: ClientOptions) { requestOptions, ), }, + schedule: { + create: (input: ExperimentalScheduleCreateInput, requestOptions?: RequestOptions) => + request<{ readonly data: ExperimentalScheduleCreateOutput }>( + { + method: "POST", + path: `/api/experimental/schedule`, + body: { + sessionID: input["sessionID"], + name: input["name"], + when: input["when"], + ends: input["ends"], + prompt: input["prompt"], + }, + successStatus: 200, + declaredStatuses: [400, 401, 403, 404], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + list: (input: ExperimentalScheduleListInput, requestOptions?: RequestOptions) => + request<{ readonly data: ExperimentalScheduleListOutput }>( + { + method: "GET", + path: `/api/experimental/schedule`, + query: { sessionID: input["sessionID"] }, + successStatus: 200, + declaredStatuses: [400, 401, 403, 404], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + get: (input: ExperimentalScheduleGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: ExperimentalScheduleGetOutput }>( + { + method: "GET", + path: `/api/experimental/schedule/${encodeURIComponent(input.scheduleID)}`, + successStatus: 200, + declaredStatuses: [400, 401, 403, 404], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + pause: (input: ExperimentalSchedulePauseInput, requestOptions?: RequestOptions) => + request<{ readonly data: ExperimentalSchedulePauseOutput }>( + { + method: "POST", + path: `/api/experimental/schedule/${encodeURIComponent(input.scheduleID)}/pause`, + successStatus: 200, + declaredStatuses: [400, 401, 403, 404], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + resume: (input: ExperimentalScheduleResumeInput, requestOptions?: RequestOptions) => + request<{ readonly data: ExperimentalScheduleResumeOutput }>( + { + method: "POST", + path: `/api/experimental/schedule/${encodeURIComponent(input.scheduleID)}/resume`, + successStatus: 200, + declaredStatuses: [400, 401, 403, 404], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + delete: (input: ExperimentalScheduleDeleteInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/experimental/schedule/${encodeURIComponent(input.scheduleID)}`, + successStatus: 204, + declaredStatuses: [400, 401, 403, 404], + empty: true, + }, + requestOptions, + ), + }, }, shell: { list: (input?: ShellListInput, requestOptions?: RequestOptions) => diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 21c1f0d2a031..d933b8505bb5 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -319,6 +319,20 @@ export type SessionStatus = export type PersistentPtyHandoff = { directory: string; instanceID: string; ticket: string; expiresAt: number } +export type ScheduleID = string + +export type ScheduleDuration = string + +export type ScheduleWhenAt = { type: "at"; at: string; tz?: string } + +export type ScheduleWhenCron = { type: "cron"; cron: string; tz?: string } + +export type ScheduleActionPrompt = { type: "prompt"; prompt: string } + +export type ScheduleStatus = "active" | "paused" | "done" + +export type ScheduleOutcome = "fired" | "late" | "skipped" | "disabled" | "expired" + export type ReferenceGitSource = { type: "git"; repository: string; branch?: string } export type VcsBranch = { current?: string; default?: string } @@ -887,6 +901,12 @@ export type FormMultiselectField1 = { export type FormAnswer1 = { [x: string]: FormValue1 } +export type ScheduleWhenIn = { type: "in"; in: ScheduleDuration; tz?: string } + +export type ScheduleWhenEvery = { type: "every"; every: ScheduleDuration; tz?: string } + +export type ScheduleAction = ScheduleActionPrompt + export type VcsInfo = { provider?: string; branch: VcsBranch } export type WebSearchProvider = { id: WebSearchID; name: string } @@ -2122,6 +2142,8 @@ export type FormReplied = { data: { id: FormID; sessionID: string; answer: FormAnswer1 } } +export type ScheduleWhen = ScheduleWhenIn | ScheduleWhenAt | ScheduleWhenEvery | ScheduleWhenCron + export type SessionInboxMove = { id: SessionMessageID sessionID: SessionID @@ -2509,6 +2531,23 @@ export type CredentialEntry = { export type FormFields2 = [FormField1, ...Array] +export type ScheduleInfo = { + id: ScheduleID + sessionID?: SessionID + name?: string + when: ScheduleWhen + tz: string + action: ScheduleAction + status: ScheduleStatus + nextDueAt?: number + endsAt?: number + remainingRuns?: number + runCount: number + lastFiredAt?: number + lastOutcome?: ScheduleOutcome + time: { created: number; updated: number } +} + export type AgentInfo = { id: AgentID name: string @@ -3059,6 +3098,14 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError" +export type ScheduleNotFoundError = { + readonly _tag: "ScheduleNotFoundError" + readonly scheduleID: string + readonly message: string +} +export const isScheduleNotFoundError = (value: unknown): value is ScheduleNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ScheduleNotFoundError" + export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly id: string; readonly message: string } export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError" @@ -4351,6 +4398,40 @@ export type ExperimentalMediaGetInput = { readonly hash: string } export type ExperimentalMediaGetOutput = globalThis.Uint8Array +export type ExperimentalScheduleCreateInput = { + readonly sessionID: string + readonly name?: string | undefined + readonly when: + | { readonly in: string; readonly tz?: string } + | { readonly at: string; readonly tz?: string } + | { readonly every: string; readonly tz?: string } + | { readonly cron: string; readonly tz?: string } + readonly ends?: { readonly after?: string; readonly at?: string; readonly times?: number } | undefined + readonly prompt: string +} + +export type ExperimentalScheduleCreateOutput = { data: ScheduleInfo }["data"] + +export type ExperimentalScheduleListInput = { readonly sessionID: string } + +export type ExperimentalScheduleListOutput = { data: Array }["data"] + +export type ExperimentalScheduleGetInput = { readonly scheduleID: string } + +export type ExperimentalScheduleGetOutput = { data: ScheduleInfo }["data"] + +export type ExperimentalSchedulePauseInput = { readonly scheduleID: string } + +export type ExperimentalSchedulePauseOutput = { data: ScheduleInfo }["data"] + +export type ExperimentalScheduleResumeInput = { readonly scheduleID: string } + +export type ExperimentalScheduleResumeOutput = { data: ScheduleInfo }["data"] + +export type ExperimentalScheduleDeleteInput = { readonly scheduleID: string } + +export type ExperimentalScheduleDeleteOutput = void + export type ShellListInput = { readonly location?: { readonly directory?: string | undefined } | undefined } export type ShellListOutput = { location: LocationPublicRef; data: Array } diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index f2f9255da01a..9eca8368d8c1 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -53,8 +53,9 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.vcs)).toEqual(["init", "get", "base", "status", "branch", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"]) expect(Object.keys(client.pty.connect)).toEqual(["token"]) - expect(Object.keys(client.experimental).sort()).toEqual(["media", "persistentPty"]) + expect(Object.keys(client.experimental).sort()).toEqual(["media", "persistentPty", "schedule"]) expect(Object.keys(client.experimental.media)).toEqual(["get"]) + expect(Object.keys(client.experimental.schedule)).toEqual(["create", "list", "get", "pause", "resume", "delete"]) expect(client.experimental.persistentPty.read).toBeFunction() expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"]) expect(Object.keys(client.project)).toEqual(["list", "update"]) diff --git a/packages/core/src/plugin/command/schedule.txt b/packages/core/src/plugin/command/schedule.txt new file mode 100644 index 000000000000..b003c0fb12ea --- /dev/null +++ b/packages/core/src/plugin/command/schedule.txt @@ -0,0 +1,11 @@ +Manage scheduled prompts for this session. + +Request: $ARGUMENTS + +Use the schedule tools in the `opencode` namespace. They only see this session's schedules. + +- To schedule something, call `schedule_create`. Choose exactly one `when`: `{ in: "20 minutes" }`, `{ at: "2026-10-27T09:00" }`, `{ every: "1 hour" }` or `{ cron: "0 9 * * 1-5" }`. Wall-clock times use the configured time zone unless you pass `tz`. To stop a repeating schedule, add `ends`: `{ after: "8 hours" }`, `{ at: "..." }` or `{ times: 3 }`. Write `prompt` as the instruction you will receive when it fires, with enough context to act on it without this conversation. +- To pause, resume or delete one, find its id with `schedule_list`, then call `schedule_pause`, `schedule_resume` or `schedule_delete`. +- If the request is empty, call `schedule_list` and show this session's schedules: name, when, status and next run. + +Reply in one line: what changed and when it next fires. diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index c9fa35cf0ba2..1219550d5352 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -57,6 +57,7 @@ import { Permission } from "../permission.js" import { Reference } from "../reference.js" import { WebSearch } from "../websearch.js" import { Ripgrep } from "../ripgrep.js" +import { Schedule } from "../schedule.js" import { Session } from "../session.js" import { SessionCompaction } from "../session/compaction.js" import { SessionInstructions } from "../session/instructions.js" @@ -76,6 +77,7 @@ import { OpenCodeTools } from "../tool/plugin/opencode.js" import { QuestionTool } from "../tool/plugin/question.js" import { ReadToolFileSystem } from "../tool/read-filesystem.js" import { ReadTool } from "../tool/plugin/read.js" +import { ScheduleTools } from "../tool/plugin/schedule.js" import { ShellTool } from "../tool/plugin/shell.js" import { SkillTool } from "../tool/plugin/skill.js" import { SubagentTool } from "../tool/plugin/subagent.js" @@ -139,6 +141,7 @@ const services = [ Reference.Service, WebSearch.Service, Ripgrep.Service, + Schedule.Service, Session.Service, SessionCompaction.Service, SessionInstructions.Service, @@ -192,6 +195,7 @@ export const requirements = LayerNode.group([ Reference.node, WebSearch.node, Ripgrep.node, + Schedule.node, Session.node, SessionCompaction.node, SessionInstructions.node, @@ -238,6 +242,7 @@ const pre = [ McpResourceTools.Plugin, QuestionTool.Plugin, ReadTool.Plugin, + ScheduleTools.Plugin, ShellTool.Plugin, SkillTool.Plugin, SubagentTool.Plugin, diff --git a/packages/core/src/schedule.ts b/packages/core/src/schedule.ts index 1c5a8aae48f2..820049478148 100644 --- a/packages/core/src/schedule.ts +++ b/packages/core/src/schedule.ts @@ -2,7 +2,8 @@ export * as Schedule from "./schedule.js" import { and, asc, eq, gt, isNull, lte, min, or } from "drizzle-orm" import { Cause, Clock, Context, Cron, DateTime, Effect, Latch, Layer, Option, Result, Schema } from "effect" -import { makeGlobalNode } from "@opencode/util/effect/app-node" +import { Node, makeGlobalNode } from "@opencode/util/effect/app-node" +import { LayerNode } from "@opencode/util/effect/layer-node" import { Schedule as ScheduleSchema } from "@opencode/schema/schedule" import { Config } from "./config.js" import { Database } from "./database/database.js" @@ -20,6 +21,12 @@ export const Info = ScheduleSchema.Info export type Info = ScheduleSchema.Info export const CreateInput = ScheduleSchema.CreateInput export type CreateInput = ScheduleSchema.CreateInput +export const Request = ScheduleSchema.Request +export type Request = ScheduleSchema.Request +export const When = ScheduleSchema.When +export const Status = ScheduleSchema.Status +export const Outcome = ScheduleSchema.Outcome +export const toCreateInput = ScheduleSchema.toCreateInput export class NotFoundError extends Schema.TaggedError()("Schedule.NotFoundError", { scheduleID: ID, @@ -46,6 +53,8 @@ export interface Interface { readonly pause: (id: ID) => Effect.Effect readonly resume: (id: ID) => Effect.Effect readonly remove: (id: ID) => Effect.Effect + /** Whether experimental.schedules is on in the config where the Session lives. */ + readonly enabled: (sessionID: SessionSchema.ID) => Effect.Effect /** Fires every schedule that is due now, at most once each. */ readonly tick: Effect.Effect /** @@ -457,13 +466,16 @@ const layer = Layer.effect( yield* find(id) yield* db.delete(ScheduleTable).where(eq(ScheduleTable.id, id)).run().pipe(Effect.orDie) }), + enabled: Effect.fn("Schedule.enabled")(function* (sessionID) { + return (yield* settings(yield* sessions.get(sessionID)).pipe(Effect.orDie)).enabled + }), tick, run, }) }), ) -export const node = makeGlobalNode({ +export const node: LayerNode.Provider = makeGlobalNode({ service: Service, layer, deps: [Database.node, Session.node, SessionExecution.node, Instance.node], diff --git a/packages/core/src/tool/plugin/schedule.ts b/packages/core/src/tool/plugin/schedule.ts new file mode 100644 index 000000000000..80a87812d233 --- /dev/null +++ b/packages/core/src/tool/plugin/schedule.ts @@ -0,0 +1,191 @@ +export * as ScheduleTools from "./schedule.js" + +import { ToolFailure } from "@opencode/ai/effect" +import type { Context } from "@opencode/plugin/effect/plugin" +import { optional } from "@opencode/schema/schema" +import type { Session } from "@opencode/schema/session" +import { DateTime, Effect, Option, Schema } from "effect" +import { Config } from "../../config.js" +import { ConfigEntryObserver } from "../../config/plugin/entry-observer.js" +import PROMPT_SCHEDULE from "../../plugin/command/schedule.txt" +import { Schedule } from "../../schedule.js" + +/** A schedule as the model reads it, with times in the schedule's own zone. */ +export const View = Schema.Struct({ + id: Schedule.ID, + name: Schema.String.pipe(optional), + when: Schedule.When, + tz: Schema.String, + prompt: Schema.String, + status: Schedule.Status, + nextDueAt: Schema.String.pipe(optional), + endsAt: Schema.String.pipe(optional), + remainingRuns: Schema.Number.pipe(optional), + runCount: Schema.Number, + lastFiredAt: Schema.String.pipe(optional), + lastOutcome: Schedule.Outcome.pipe(optional), +}) +export type View = typeof View.Type + +const ListOutput = Schema.Struct({ + now: Schema.String.annotate({ description: "The current time, in UTC." }), + schedules: Schema.Array(View), +}) + +export const IDInput = Schema.Struct({ + id: Schedule.ID.annotate({ description: "Schedule id from schedule_list." }), +}) + +const RemoveOutput = Schema.Struct({ id: Schedule.ID, removed: Schema.Boolean }) + +export const Plugin = { + id: "opencode.tool.schedule", + effect: Effect.fn("ScheduleTools.Plugin")(function* (ctx: Context) { + const config = yield* Config.Service + const schedules = yield* Schedule.Service + // Internal plugins are not re-run on config changes, so reload the tools and the command instead. + const loaded = yield* ConfigEntryObserver.observe( + config, + ctx.event, + Effect.andThen(ctx.tool.reload(), ctx.command.reload()), + ) + const enabled = () => Config.latest(loaded.entries, "experimental")?.schedules === true + + // Tools act only on the calling Session's schedules. + const owned = (id: Schedule.ID, sessionID: Session.ID) => + schedules.get(id).pipe( + Effect.filterOrFail( + (info) => info.sessionID === sessionID, + () => new Schedule.NotFoundError({ scheduleID: id }), + ), + ) + const notFound = (error: Schedule.NotFoundError) => + new ToolFailure({ message: `Schedule not found: ${error.scheduleID}`, error }) + + yield* ctx.tool + .transform((editor) => { + if (!enabled()) return + editor.add({ + name: "schedule_create", + description: + "Schedule a prompt that wakes this session later: once after a delay, at a time, every interval, or on a cron. Returns the schedule with its next run.", + input: Schedule.Request, + output: View, + options: { namespace: "opencode", codemode: true }, + execute: (input, context) => + schedules.create(Schedule.toCreateInput(context.sessionID, input)).pipe( + Effect.map((info) => { + const view = toView(info) + return { + output: view, + content: view.nextDueAt + ? `Scheduled ${view.name ?? view.id}; next run ${view.nextDueAt}.` + : `Scheduled ${view.name ?? view.id}.`, + } + }), + Effect.mapError( + (error) => new ToolFailure({ message: error.message || "Unable to create schedule", error }), + ), + ), + }) + editor.add({ + name: "schedule_list", + description: "List this session's schedules with their status and next run, and the current time.", + input: Schema.Struct({}), + output: ListOutput, + options: { namespace: "opencode", codemode: true }, + execute: (_, context) => + Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now) + const list = (yield* schedules.list({ sessionID: context.sessionID })).map(toView) + return { + output: { now, schedules: list }, + content: list.length === 0 ? "No schedules." : `${list.length} schedule(s).`, + } + }), + }) + editor.add({ + name: "schedule_pause", + description: "Pause a schedule. It keeps its settings and does not fire until resumed.", + input: IDInput, + output: View, + options: { namespace: "opencode", codemode: true }, + execute: (input, context) => + owned(input.id, context.sessionID).pipe( + Effect.andThen(schedules.pause(input.id)), + Effect.map((info) => ({ output: toView(info), content: `Paused ${info.name ?? info.id}.` })), + Effect.mapError(notFound), + ), + }) + editor.add({ + name: "schedule_resume", + description: + "Resume a paused schedule. A one-shot that fell due while paused fires once now; a repeating one resumes at its next slot.", + input: IDInput, + output: View, + options: { namespace: "opencode", codemode: true }, + execute: (input, context) => + owned(input.id, context.sessionID).pipe( + Effect.andThen(schedules.resume(input.id)), + Effect.map((info) => ({ output: toView(info), content: `Resumed ${info.name ?? info.id}.` })), + Effect.mapError(notFound), + ), + }) + editor.add({ + name: "schedule_delete", + description: "Delete a schedule for good.", + input: IDInput, + output: RemoveOutput, + options: { namespace: "opencode", codemode: true }, + execute: (input, context) => + owned(input.id, context.sessionID).pipe( + Effect.andThen(schedules.remove(input.id)), + Effect.as({ output: { id: input.id, removed: true }, content: `Deleted schedule ${input.id}.` }), + Effect.mapError(notFound), + ), + }) + }) + .pipe(Effect.orDie) + + yield* ctx.command.transform((editor) => { + if (!enabled()) return + editor.add({ + name: "schedule", + description: "schedule a prompt for later, or list, pause and delete this session's schedules", + execute: (input) => + ctx.session + .prompt({ + ...input.prompt, + sessionID: input.sessionID, + text: PROMPT_SCHEDULE.replaceAll("$ARGUMENTS", () => input.prompt.text.trim()), + delivery: input.delivery, + }) + .pipe(Effect.asVoid), + }) + }) + }), +} + +function toView(info: Schedule.Info): View { + const local = (time: DateTime.Utc | undefined) => { + if (time === undefined) return undefined + return Option.match(DateTime.zoneMakeNamed(info.tz), { + onNone: () => DateTime.formatIso(time), + onSome: (zone) => DateTime.formatIsoOffset(DateTime.setZone(time, zone)), + }) + } + return { + id: info.id, + name: info.name, + when: info.when, + tz: info.tz, + prompt: info.action.prompt, + status: info.status, + nextDueAt: local(info.nextDueAt), + endsAt: local(info.endsAt), + remainingRuns: info.remainingRuns, + runCount: info.runCount, + lastFiredAt: local(info.lastFiredAt), + lastOutcome: info.lastOutcome, + } +} diff --git a/packages/core/test/tool-schedule.test.ts b/packages/core/test/tool-schedule.test.ts new file mode 100644 index 000000000000..2badac46f913 --- /dev/null +++ b/packages/core/test/tool-schedule.test.ts @@ -0,0 +1,203 @@ +import { describe, expect } from "bun:test" +import { DateTime, Effect, Layer, Stream } from "effect" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { Config } from "@opencode/core/config" +import { Image } from "@opencode/core/image" +import { Schedule } from "@opencode/core/schedule" +import { Session } from "@opencode/core/session" +import { Tool } from "@opencode/core/tool" +import { ScheduleTools } from "@opencode/core/tool/plugin/schedule" +import { Document, Info, type Entry } from "@opencode/schema/config" +import { ConfigExperimental } from "@opencode/schema/config/experimental" +import type { CommandDefinition } from "@opencode/plugin/effect/command" +import { testEffect } from "./lib/effect" +import { imagePassthrough } from "./lib/image" +import { permissionLayer } from "./lib/permission" +import { codeModeListings, executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" + +const sessionID = Session.ID.make("ses_schedule_tool_test") +const otherSessionID = Session.ID.make("ses_schedule_tool_other") +const now = DateTime.makeUnsafe("2026-10-27T13:00:00Z") +const names = ["schedule_create", "schedule_list", "schedule_pause", "schedule_resume", "schedule_delete"].map( + (name) => `opencode.${name}`, +) + +const it = testEffect(AppNodeBuilder.build(Tool.node, [Image.node.replace(imagePassthrough)])) + +/** An in-memory Schedule service. The real one is covered in schedule.test.ts; this exercises the tool layer. */ +const memory = () => { + const rows = new Map() + const find = (id: Schedule.ID): Effect.Effect => { + const info = rows.get(id) + return info ? Effect.succeed(info) : Effect.fail(new Schedule.NotFoundError({ scheduleID: id })) + } + const update = (id: Schedule.ID, status: Schedule.Info["status"]) => + find(id).pipe( + Effect.map((info) => { + const next = { ...info, status } + rows.set(id, next) + return next + }), + ) + const service = Schedule.Service.of({ + create: (input) => + Effect.sync(() => { + const info: Schedule.Info = { + id: Schedule.ID.make(`sch_${rows.size + 1}`), + sessionID: input.sessionID, + name: input.name, + when: input.when, + tz: "America/New_York", + action: input.action, + status: "active", + nextDueAt: DateTime.add(now, { minutes: 20 }), + runCount: 0, + time: { created: now, updated: now }, + } + rows.set(info.id, info) + return info + }), + list: (input) => Effect.sync(() => [...rows.values()].filter((info) => info.sessionID === input?.sessionID)), + get: find, + pause: (id) => update(id, "paused"), + resume: (id) => update(id, "active"), + remove: (id) => find(id).pipe(Effect.map(() => void rows.delete(id))), + enabled: () => Effect.succeed(true), + tick: Effect.void, + run: Effect.never, + }) + return { rows, service } +} + +const setup = (experimental: ConfigExperimental.Info | undefined) => + Effect.gen(function* () { + const schedules = memory() + const commands: CommandDefinition[] = [] + const prompts: unknown[] = [] + yield* registerToolPlugin(ScheduleTools.Plugin, { + event: { subscribe: () => Stream.never }, + command: { + list: () => Effect.die("unused command.list"), + reload: () => Effect.void, + transform: (callback) => + Effect.sync(() => { + callback({ add: (definition) => commands.push(definition) }) + return { dispose: Effect.void } + }), + }, + session: { prompt: (input) => Effect.sync(() => prompts.push(input)).pipe(Effect.as(undefined as never)) }, + }).pipe( + Effect.provide(Layer.mock(Config.Service)({ entries: () => Effect.succeed([document(experimental)]) })), + Effect.provide(Layer.succeed(Schedule.Service, schedules.service)), + Effect.provide(permissionLayer({ assert: () => Effect.void })), + ) + return { registry: yield* Tool.Service, rows: schedules.rows, commands, prompts } + }) + +/** Runs one Code Mode call, the way the model reaches these tools. */ +const call = (registry: Tool.Interface, name: string, input: unknown, session = sessionID) => + executeTool(registry, { + sessionID: session, + ...toolIdentity, + call: { + type: "tool-call", + id: `call-${name}`, + name: "execute", + input: { code: `return await tools.opencode.${name}(${JSON.stringify(input)})` }, + }, + }) +const run = (registry: Tool.Interface, name: string, input: unknown, session = sessionID) => + call(registry, name, input, session).pipe( + Effect.map((result) => JSON.parse(result.content?.[0]?.type === "text" ? result.content[0].text : "")), + ) + +const paths = (registry: Tool.Interface) => + registry + .snapshot() + .pipe( + Effect.map((snapshot) => + snapshot.codeModeCatalog ? codeModeListings(snapshot.codeModeCatalog).map((tool) => tool.path) : [], + ), + ) + +describe("ScheduleTools", () => { + it.effect("are absent unless experimental.schedules is enabled", () => + Effect.gen(function* () { + expect(yield* paths((yield* setup(undefined)).registry)).not.toContain(names[0]) + const off = yield* setup(new ConfigExperimental.Info({ schedules: false })) + expect(yield* paths(off.registry)).not.toContain(names[0]) + expect(off.commands).toEqual([]) + const on = yield* setup(new ConfigExperimental.Info({ schedules: true })) + expect(yield* paths(on.registry)).toEqual(expect.arrayContaining(names)) + expect(on.commands.map((command) => command.name)).toEqual(["schedule"]) + }), + ) + + it.effect("creates, lists, pauses, resumes and deletes this session's schedules", () => + Effect.gen(function* () { + const { registry, rows } = yield* setup(new ConfigExperimental.Info({ schedules: true })) + + expect( + yield* run(registry, "schedule_create", { + name: "build", + when: { in: "20 minutes" }, + prompt: "check the build", + }), + ).toMatchObject({ + id: "sch_1", + name: "build", + when: { type: "in", in: "20 minutes" }, + prompt: "check the build", + status: "active", + nextDueAt: "2026-10-27T09:20:00.000-04:00", + }) + expect([...rows.values()][0]).toMatchObject({ sessionID, action: { type: "prompt", prompt: "check the build" } }) + + expect(yield* run(registry, "schedule_list", {})).toMatchObject({ + schedules: [{ id: "sch_1", status: "active" }], + }) + expect(yield* run(registry, "schedule_pause", { id: "sch_1" })).toMatchObject({ status: "paused" }) + expect(yield* run(registry, "schedule_resume", { id: "sch_1" })).toMatchObject({ status: "active" }) + expect(yield* run(registry, "schedule_delete", { id: "sch_1" })).toEqual({ id: "sch_1", removed: true }) + expect(rows.size).toBe(0) + }), + ) + + it.effect("/schedule hands the request to the model with the schedule instructions", () => + Effect.gen(function* () { + const { commands, prompts } = yield* setup(new ConfigExperimental.Info({ schedules: true })) + yield* commands[0]! + .execute({ + sessionID, + prompt: { text: " every morning at 9 check CI " }, + delivery: "steer", + }) + .pipe(Effect.orDie) + expect(prompts).toHaveLength(1) + expect(prompts[0]).toMatchObject({ sessionID, delivery: "steer" }) + const text = (prompts[0] as { text: string }).text + expect(text).toContain("every morning at 9 check CI") + expect(text).not.toContain("$ARGUMENTS") + }), + ) + + it.effect("treats another session's schedule as missing", () => + Effect.gen(function* () { + const { registry, rows } = yield* setup(new ConfigExperimental.Info({ schedules: true })) + yield* run(registry, "schedule_create", { when: { every: "1 hour" }, prompt: "ping" }, otherSessionID) + + expect(yield* run(registry, "schedule_list", {})).toMatchObject({ schedules: [] }) + for (const name of ["schedule_pause", "schedule_resume", "schedule_delete"]) { + expect(yield* call(registry, name, { id: "sch_1" })).toMatchObject({ + content: [{ type: "text", text: expect.stringContaining("Schedule not found: sch_1") }], + metadata: { error: true }, + }) + } + expect(rows.get(Schedule.ID.make("sch_1"))).toMatchObject({ status: "active", sessionID: otherSessionID }) + }), + ) +}) + +function document(experimental: ConfigExperimental.Info | undefined): Entry { + return new Document({ type: "document", info: new Info(experimental ? { experimental } : {}) }) +} diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index fe4e1bd06630..707222484bf3 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -12652,6 +12652,460 @@ "summary": "Read media blob" } }, + "/api/experimental/schedule": { + "post": { + "tags": ["experimental"], + "operationId": "experimental.schedule.create", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Schedule.Info" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorEncoded" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionNotFoundErrorEncoded" + } + } + } + } + }, + "description": "Schedule a prompt that wakes the Session once after a delay, at a time, every interval, or on a cron. Experimental: needs experimental.schedules in the Session's config and may change without notice.", + "summary": "Create schedule", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Schedule.CreatePayload" + } + } + }, + "required": true + } + }, + "get": { + "tags": ["experimental"], + "operationId": "experimental.schedule.list", + "parameters": [ + { + "name": "sessionID", + "in": "query", + "schema": { + "$ref": "#/components/schemas/Session.ID" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Schedule.Info" + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorEncoded" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionNotFoundErrorEncoded" + } + } + } + } + }, + "description": "List a Session's schedules, oldest first. Experimental: needs experimental.schedules in the Session's config and may change without notice.", + "summary": "List schedules" + } + }, + "/api/experimental/schedule/{scheduleID}": { + "get": { + "tags": ["experimental"], + "operationId": "experimental.schedule.get", + "parameters": [ + { + "name": "scheduleID", + "in": "path", + "schema": { + "$ref": "#/components/schemas/Schedule.ID" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Schedule.Info" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorEncoded" + } + } + } + }, + "404": { + "description": "ScheduleNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleNotFoundErrorEncoded" + } + } + } + } + }, + "description": "Read one schedule. Experimental: needs experimental.schedules in the Session's config and may change without notice.", + "summary": "Get schedule" + }, + "delete": { + "tags": ["experimental"], + "operationId": "experimental.schedule.delete", + "parameters": [ + { + "name": "scheduleID", + "in": "path", + "schema": { + "$ref": "#/components/schemas/Schedule.ID" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorEncoded" + } + } + } + }, + "404": { + "description": "ScheduleNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleNotFoundErrorEncoded" + } + } + } + } + }, + "description": "Delete a schedule. Experimental: needs experimental.schedules in the Session's config and may change without notice.", + "summary": "Delete schedule" + } + }, + "/api/experimental/schedule/{scheduleID}/pause": { + "post": { + "tags": ["experimental"], + "operationId": "experimental.schedule.pause", + "parameters": [ + { + "name": "scheduleID", + "in": "path", + "schema": { + "$ref": "#/components/schemas/Schedule.ID" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Schedule.Info" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorEncoded" + } + } + } + }, + "404": { + "description": "ScheduleNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleNotFoundErrorEncoded" + } + } + } + } + }, + "description": "Stop a schedule from firing until it is resumed. Experimental: needs experimental.schedules in the Session's config and may change without notice.", + "summary": "Pause schedule" + } + }, + "/api/experimental/schedule/{scheduleID}/resume": { + "post": { + "tags": ["experimental"], + "operationId": "experimental.schedule.resume", + "parameters": [ + { + "name": "scheduleID", + "in": "path", + "schema": { + "$ref": "#/components/schemas/Schedule.ID" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Schedule.Info" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorEncoded" + } + } + } + }, + "404": { + "description": "ScheduleNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleNotFoundErrorEncoded" + } + } + } + } + }, + "description": "Resume a paused schedule. A one-shot that fell due while paused fires once; a repeating schedule resumes at its next slot. Experimental: needs experimental.schedules in the Session's config and may change without notice.", + "summary": "Resume schedule" + } + }, "/api/shell": { "get": { "tags": ["shell"], @@ -20637,6 +21091,302 @@ "required": ["_tag", "type", "message"], "additionalProperties": false }, + "Schedule.Action": { + "anyOf": [ + { + "$ref": "#/components/schemas/Schedule.Action.Prompt" + } + ] + }, + "Schedule.Action.Prompt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["prompt"] + }, + "prompt": { + "type": "string" + } + }, + "required": ["type", "prompt"], + "additionalProperties": false + }, + "Schedule.CreatePayload": { + "type": "object", + "properties": { + "sessionID": { + "$ref": "#/components/schemas/Session.ID" + }, + "name": { + "type": "string" + }, + "when": { + "$ref": "#/components/schemas/Schedule.WhenInput" + }, + "ends": { + "$ref": "#/components/schemas/Schedule.Ends" + }, + "prompt": { + "type": "string", + "minLength": 1, + "description": "The message the Session receives each time the schedule fires." + } + }, + "required": ["sessionID", "when", "prompt"], + "additionalProperties": false + }, + "Schedule.Duration": { + "type": "string", + "description": "A duration such as \"90 seconds\", \"5 minutes\" or \"2 hours\"." + }, + "Schedule.Ends": { + "type": "object", + "properties": { + "after": { + "$ref": "#/components/schemas/Schedule.Duration" + }, + "at": { + "type": "string" + }, + "times": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false + }, + "Schedule.ID": { + "type": "string", + "pattern": "^sch_" + }, + "Schedule.Info": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/Schedule.ID" + }, + "sessionID": { + "$ref": "#/components/schemas/Session.ID" + }, + "name": { + "type": "string" + }, + "when": { + "$ref": "#/components/schemas/Schedule.When" + }, + "tz": { + "type": "string" + }, + "action": { + "$ref": "#/components/schemas/Schedule.Action" + }, + "status": { + "$ref": "#/components/schemas/Schedule.Status" + }, + "nextDueAt": { + "type": "number" + }, + "endsAt": { + "type": "number" + }, + "remainingRuns": { + "type": "integer", + "minimum": 0 + }, + "runCount": { + "type": "integer", + "minimum": 0 + }, + "lastFiredAt": { + "type": "number" + }, + "lastOutcome": { + "$ref": "#/components/schemas/Schedule.Outcome" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + } + }, + "required": ["id", "when", "tz", "action", "status", "runCount", "time"], + "additionalProperties": false + }, + "Schedule.Outcome": { + "type": "string", + "enum": ["fired", "late", "skipped", "disabled", "expired"] + }, + "Schedule.Status": { + "type": "string", + "enum": ["active", "paused", "done"] + }, + "Schedule.When": { + "anyOf": [ + { + "$ref": "#/components/schemas/Schedule.When.In" + }, + { + "$ref": "#/components/schemas/Schedule.When.At" + }, + { + "$ref": "#/components/schemas/Schedule.When.Every" + }, + { + "$ref": "#/components/schemas/Schedule.When.Cron" + } + ] + }, + "Schedule.When.At": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["at"] + }, + "at": { + "type": "string" + }, + "tz": { + "type": "string" + } + }, + "required": ["type", "at"], + "additionalProperties": false + }, + "Schedule.When.Cron": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["cron"] + }, + "cron": { + "type": "string" + }, + "tz": { + "type": "string" + } + }, + "required": ["type", "cron"], + "additionalProperties": false + }, + "Schedule.When.Every": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["every"] + }, + "every": { + "$ref": "#/components/schemas/Schedule.Duration" + }, + "tz": { + "type": "string" + } + }, + "required": ["type", "every"], + "additionalProperties": false + }, + "Schedule.When.In": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["in"] + }, + "in": { + "$ref": "#/components/schemas/Schedule.Duration" + }, + "tz": { + "type": "string" + } + }, + "required": ["type", "in"], + "additionalProperties": false + }, + "Schedule.WhenInput": { + "anyOf": [ + { + "type": "object", + "properties": { + "in": { + "$ref": "#/components/schemas/Schedule.Duration" + }, + "tz": { + "type": "string" + } + }, + "required": ["in"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "at": { + "type": "string" + }, + "tz": { + "type": "string" + } + }, + "required": ["at"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "every": { + "$ref": "#/components/schemas/Schedule.Duration" + }, + "tz": { + "type": "string" + } + }, + "required": ["every"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "cron": { + "type": "string" + }, + "tz": { + "type": "string" + } + }, + "required": ["cron"], + "additionalProperties": false + } + ], + "description": "When it fires: { in: \"20 minutes\" }, { at: \"2026-10-27T09:00\" }, { every: \"1 hour\" } or { cron: \"0 9 * * 1-5\" }. Wall-clock times use tz." + }, + "ScheduleNotFoundErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ScheduleNotFoundError"] + }, + "scheduleID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "scheduleID", "message"], + "additionalProperties": false + }, "ServerInfo": { "type": "object", "properties": { diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 97438c5cd0fc..951db21b3d9c 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -324,3 +324,12 @@ export class ProviderRequestError extends Schema.TaggedError()( + "ScheduleNotFoundError", + { + scheduleID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} diff --git a/packages/protocol/src/groups/experimental.ts b/packages/protocol/src/groups/experimental.ts index c282c7917e74..c1513517a6c4 100644 --- a/packages/protocol/src/groups/experimental.ts +++ b/packages/protocol/src/groups/experimental.ts @@ -1,5 +1,6 @@ import { MediaGetEndpoint } from "./media.js" import { PersistentPtyGroup } from "./persistent-pty.js" +import { ScheduleEndpoints } from "./schedule.js" /** `server.experimental`: routes under /api/experimental, without compatibility guarantees. */ -export const ExperimentalGroup = PersistentPtyGroup.add(MediaGetEndpoint) +export const ExperimentalGroup = PersistentPtyGroup.add(MediaGetEndpoint, ...ScheduleEndpoints) diff --git a/packages/protocol/src/groups/schedule.ts b/packages/protocol/src/groups/schedule.ts new file mode 100644 index 000000000000..fec9a101ac8c --- /dev/null +++ b/packages/protocol/src/groups/schedule.ts @@ -0,0 +1,68 @@ +import { Schedule } from "@opencode/schema/schedule" +import { Session } from "@opencode/schema/session" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiSchema, OpenApi } from "effect/http-api" +import { ForbiddenError, InvalidRequestError, ScheduleNotFoundError, SessionNotFoundError } from "../errors.js" + +const EXPERIMENTAL = "Experimental: needs experimental.schedules in the Session's config and may change without notice." +const errors = [ScheduleNotFoundError, ForbiddenError] as const +const params = { scheduleID: Schedule.ID } +const annotations = (identifier: string, summary: string, description: string) => + OpenApi.annotations({ + identifier: `experimental.schedule.${identifier}`, + summary, + description: `${description} ${EXPERIMENTAL}`, + // The routes share the `server.experimental` group with the persistent PTY prototype; tag them on their own. + transform: (operation) => ({ ...operation, tags: ["experimental"] }), + }) + +export const CreatePayload = Schema.Struct({ sessionID: Session.ID, ...Schedule.Request.fields }).annotate({ + identifier: "Schedule.CreatePayload", +}) +export type CreatePayload = typeof CreatePayload.Type + +/** Lives in the `server.experimental` group (see `groups/experimental.ts`). */ +export const ScheduleEndpoints = [ + HttpApiEndpoint.post("schedule.create", "/api/experimental/schedule", { + payload: CreatePayload, + success: Schema.Struct({ data: Schedule.Info }), + error: [InvalidRequestError, SessionNotFoundError, ForbiddenError], + }).annotateMerge( + annotations( + "create", + "Create schedule", + "Schedule a prompt that wakes the Session once after a delay, at a time, every interval, or on a cron.", + ), + ), + HttpApiEndpoint.get("schedule.list", "/api/experimental/schedule", { + query: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(Schedule.Info) }), + error: [SessionNotFoundError, ForbiddenError], + }).annotateMerge(annotations("list", "List schedules", "List a Session's schedules, oldest first.")), + HttpApiEndpoint.get("schedule.get", "/api/experimental/schedule/:scheduleID", { + params, + success: Schema.Struct({ data: Schedule.Info }), + error: errors, + }).annotateMerge(annotations("get", "Get schedule", "Read one schedule.")), + HttpApiEndpoint.post("schedule.pause", "/api/experimental/schedule/:scheduleID/pause", { + params, + success: Schema.Struct({ data: Schedule.Info }), + error: errors, + }).annotateMerge(annotations("pause", "Pause schedule", "Stop a schedule from firing until it is resumed.")), + HttpApiEndpoint.post("schedule.resume", "/api/experimental/schedule/:scheduleID/resume", { + params, + success: Schema.Struct({ data: Schedule.Info }), + error: errors, + }).annotateMerge( + annotations( + "resume", + "Resume schedule", + "Resume a paused schedule. A one-shot that fell due while paused fires once; a repeating schedule resumes at its next slot.", + ), + ), + HttpApiEndpoint.delete("schedule.delete", "/api/experimental/schedule/:scheduleID", { + params, + success: HttpApiSchema.NoContent, + error: errors, + }).annotateMerge(annotations("delete", "Delete schedule", "Delete a schedule.")), +] as const diff --git a/packages/schema/src/schedule.ts b/packages/schema/src/schedule.ts index b962b85a095e..b89a1245b647 100644 --- a/packages/schema/src/schedule.ts +++ b/packages/schema/src/schedule.ts @@ -19,15 +19,11 @@ const decodeDuration = Schema.decodeUnknownOption(Schema.DurationFromString) /** Milliseconds in a duration string, or `None` when it is not one. */ export const durationMillis = (value: string) => decodeDuration(value).pipe(Option.map(Duration.toMillis)) -/** A duration such as "90 seconds", "5 minutes" or "2 hours". */ -export const DurationString = Schema.String.check( - Schema.makeFilter((value: string) => - Option.match(durationMillis(value), { - onNone: () => `expected a duration such as "5 minutes", got ${JSON.stringify(value)}`, - onSome: (millis) => millis > 0 || "expected a positive duration", - }), - ), -).annotate({ identifier: "Schedule.Duration" }) +/** A duration such as "90 seconds", "5 minutes" or "2 hours". The service validates it, since clients cannot run a custom check. */ +export const DurationString = Schema.String.annotate({ + identifier: "Schedule.Duration", + description: 'A duration such as "90 seconds", "5 minutes" or "2 hours".', +}) const zone = { tz: Schema.String.pipe(optional).annotate({ @@ -107,3 +103,43 @@ export const CreateInput = Schema.Struct({ action: Action, }).annotate({ identifier: "Schedule.CreateInput" }) export type CreateInput = typeof CreateInput.Type + +/** The `when` callers write: exactly one of `in`, `at`, `every` or `cron`, with an optional `tz`. */ +export const WhenInput = Schema.Union([ + Schema.Struct({ in: In.fields.in, ...zone }), + Schema.Struct({ at: At.fields.at, ...zone }), + Schema.Struct({ every: Every.fields.every, ...zone }), + Schema.Struct({ cron: Cron.fields.cron, ...zone }), +]).annotate({ identifier: "Schedule.WhenInput" }) +export type WhenInput = typeof WhenInput.Type + +/** A schedule as callers describe it: when it fires, when it stops, and the prompt it wakes the Session with. */ +export const Request = Schema.Struct({ + name: Schema.String.pipe(optional).annotate({ description: "A short label for the schedule." }), + when: WhenInput.annotate({ + description: + 'When it fires: { in: "20 minutes" }, { at: "2026-10-27T09:00" }, { every: "1 hour" } or { cron: "0 9 * * 1-5" }. Wall-clock times use tz.', + }), + ends: Ends.pipe(optional).annotate({ + description: 'Stops a repeating schedule at the first limit reached: { after: "8 hours" }, { at }, { times: 3 }.', + }), + prompt: Schema.String.check(Schema.isMinLength(1)).annotate({ + description: "The message the Session receives each time the schedule fires.", + }), +}).annotate({ identifier: "Schedule.Request" }) +export type Request = typeof Request.Type + +export const toCreateInput = (sessionID: SessionID, request: Request): CreateInput => ({ + sessionID, + name: request.name, + when: toWhen(request.when), + ends: request.ends, + action: { type: "prompt", prompt: request.prompt }, +}) + +const toWhen = (when: WhenInput): When => { + if ("in" in when) return { type: "in", ...when } + if ("at" in when) return { type: "at", ...when } + if ("every" in when) return { type: "every", ...when } + return { type: "cron", ...when } +} diff --git a/packages/server/src/handlers/persistent-pty.ts b/packages/server/src/handlers/persistent-pty.ts index 7f874bfd9c5f..0b40b2cd0130 100644 --- a/packages/server/src/handlers/persistent-pty.ts +++ b/packages/server/src/handlers/persistent-pty.ts @@ -14,6 +14,7 @@ import { Api } from "../api" import { CorsConfig, isAllowedRequestOrigin } from "../cors" import { makeMediaGet } from "./media" import { type Outbound, runPtySocket } from "./pty-socket" +import { makeSchedule } from "./schedule" export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experimental", (handlers) => Effect.gen(function* () { @@ -21,9 +22,16 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment const cors = yield* CorsConfig const pty = yield* PersistentPty.Service const mediaGet = yield* makeMediaGet + const schedule = yield* makeSchedule return handlers .handleRaw("media.get", (ctx) => mediaGet(ctx.params.hash, ctx.request)) + .handle("schedule.create", (ctx) => schedule.create(ctx.payload)) + .handle("schedule.list", (ctx) => schedule.list(ctx.query.sessionID)) + .handle("schedule.get", (ctx) => schedule.get(ctx.params.scheduleID)) + .handle("schedule.pause", (ctx) => schedule.pause(ctx.params.scheduleID)) + .handle("schedule.resume", (ctx) => schedule.resume(ctx.params.scheduleID)) + .handle("schedule.delete", (ctx) => schedule.remove(ctx.params.scheduleID)) .handle( "persistentPty.read", Effect.fn(function* (ctx) { diff --git a/packages/server/src/handlers/schedule.ts b/packages/server/src/handlers/schedule.ts new file mode 100644 index 000000000000..1d65d9ec8cf6 --- /dev/null +++ b/packages/server/src/handlers/schedule.ts @@ -0,0 +1,71 @@ +import { Schedule } from "@opencode/core/schedule" +import type { Session } from "@opencode/core/session" +import { ForbiddenError, InvalidRequestError, ScheduleNotFoundError } from "@opencode/protocol/errors" +import type { CreatePayload } from "@opencode/protocol/groups/schedule" +import { Effect } from "effect" +import { HttpApiSchema } from "effect/http-api" +import { missingSession } from "./session-error" + +/** `schedule.*` in the `server.experimental` group. Every route needs experimental.schedules where the Session lives. */ +export const makeSchedule = Effect.gen(function* () { + const schedules = yield* Schedule.Service + + const allowed = (sessionID: Session.ID) => + schedules.enabled(sessionID).pipe( + Effect.mapError(missingSession), + Effect.flatMap((enabled) => (enabled ? Effect.void : Effect.fail(disabled()))), + ) + + // A schedule whose Session is gone, or whose location has the flag off, reads as missing or forbidden like the rest. + const find = (id: Schedule.ID) => + Effect.gen(function* () { + const info = yield* schedules.get(id).pipe(Effect.mapError(missingSchedule)) + if (info.sessionID === undefined) return yield* missingSchedule({ scheduleID: id }) + yield* allowed(info.sessionID).pipe( + Effect.catchTag("SessionNotFoundError", () => Effect.fail(missingSchedule({ scheduleID: id }))), + ) + return info + }) + + return { + create: (payload: CreatePayload) => + schedules.create(Schedule.toCreateInput(payload.sessionID, payload)).pipe( + Effect.map((data) => ({ data })), + Effect.catchTags({ + "Schedule.InvalidError": (error) => + Effect.fail(new InvalidRequestError({ message: error.message, kind: "schedule" })), + "Schedule.DisabledError": () => Effect.fail(disabled()), + "Session.NotFoundError": (error) => Effect.fail(missingSession(error)), + }), + ), + list: (sessionID: Session.ID) => + allowed(sessionID).pipe( + Effect.andThen(schedules.list({ sessionID })), + Effect.map((data) => ({ data })), + ), + get: (id: Schedule.ID) => find(id).pipe(Effect.map((data) => ({ data }))), + pause: (id: Schedule.ID) => + find(id).pipe( + Effect.andThen(schedules.pause(id).pipe(Effect.mapError(missingSchedule))), + Effect.map((data) => ({ data })), + ), + resume: (id: Schedule.ID) => + find(id).pipe( + Effect.andThen(schedules.resume(id).pipe(Effect.mapError(missingSchedule))), + Effect.map((data) => ({ data })), + ), + remove: (id: Schedule.ID) => + find(id).pipe( + Effect.andThen(schedules.remove(id).pipe(Effect.mapError(missingSchedule))), + Effect.as(HttpApiSchema.NoContent.make()), + ), + } +}) + +function missingSchedule(error: { readonly scheduleID: Schedule.ID }) { + return new ScheduleNotFoundError({ scheduleID: error.scheduleID, message: `Schedule not found: ${error.scheduleID}` }) +} + +function disabled() { + return new ForbiddenError({ message: new Schedule.DisabledError().message }) +} diff --git a/packages/server/test/schedule.test.ts b/packages/server/test/schedule.test.ts new file mode 100644 index 000000000000..2f53412d50b7 --- /dev/null +++ b/packages/server/test/schedule.test.ts @@ -0,0 +1,81 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { expect } from "bun:test" +import { OpenCode } from "@opencode/client" +import { Effect } from "effect" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" +import { it } from "../../core/test/lib/effect" +import { ServerFetch } from "../src/fetch" + +const client = (handler: (request: Request) => Promise) => + OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: Object.assign((input: string | URL | Request, init?: RequestInit) => handler(new Request(input, init)), { + preconnect: fetch.preconnect, + }), + }) + +it.live("manages schedules through the experimental API where the session's config enables them", () => + Effect.gen(function* () { + const dir = (yield* tmpdirScoped("opencode-schedule-api-")).path + const global = path.join(dir, "global") + const enabled = path.join(dir, "enabled") + const disabled = path.join(dir, "disabled") + yield* Effect.promise(async () => { + await Promise.all([global, enabled, disabled].map((item) => fs.mkdir(item, { recursive: true }))) + await fs.writeFile(path.join(enabled, "opencode.json"), JSON.stringify({ experimental: { schedules: true } })) + }) + const api = client( + yield* ServerFetch.make({ + app: { version: "test" }, + database: { path: path.join(dir, "opencode.db") }, + config: { directory: global, project: true, claude: false }, + models: { fetch: false }, + fs: { filewatcher: false }, + }), + ) + + yield* Effect.promise(async () => { + const session = await api.session.create({ location: { directory: enabled } }) + const created = await api.experimental.schedule.create({ + sessionID: session.id, + name: "build", + when: { every: "1 hour" }, + ends: { times: 3 }, + prompt: "check the build", + }) + expect(created).toMatchObject({ + sessionID: session.id, + name: "build", + when: { type: "every", every: "1 hour" }, + action: { type: "prompt", prompt: "check the build" }, + status: "active", + remainingRuns: 3, + }) + expect(await api.experimental.schedule.list({ sessionID: session.id })).toMatchObject([{ id: created.id }]) + expect(await api.experimental.schedule.get({ scheduleID: created.id })).toMatchObject({ id: created.id }) + expect(await api.experimental.schedule.pause({ scheduleID: created.id })).toMatchObject({ status: "paused" }) + expect(await api.experimental.schedule.resume({ scheduleID: created.id })).toMatchObject({ status: "active" }) + await api.experimental.schedule.delete({ scheduleID: created.id }) + await expect(api.experimental.schedule.get({ scheduleID: created.id })).rejects.toMatchObject({ + _tag: "ScheduleNotFoundError", + }) + + await expect( + api.experimental.schedule.create({ sessionID: session.id, when: { every: "soon" }, prompt: "ping" }), + ).rejects.toMatchObject({ _tag: "InvalidRequestError" }) + await expect( + api.experimental.schedule.create({ sessionID: session.id, when: { in: "0 seconds" }, prompt: "ping" }), + ).rejects.toMatchObject({ _tag: "InvalidRequestError", message: "Duration must be positive: 0 seconds" }) + + // Off where the Session lives: nothing can be created or read. + const off = await api.session.create({ location: { directory: disabled } }) + await expect( + api.experimental.schedule.create({ sessionID: off.id, when: { in: "5 minutes" }, prompt: "ping" }), + ).rejects.toMatchObject({ _tag: "ForbiddenError" }) + await expect(api.experimental.schedule.list({ sessionID: off.id })).rejects.toMatchObject({ + _tag: "ForbiddenError", + }) + }) + }).pipe(Effect.scoped), +) From fb5fe888e32ea4baceca3b7b0709b2d40ad0f19e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 26 Oct 2026 19:45:47 -0700 Subject: [PATCH 2/3] fix(core): recheck the schedules flag per call, reject two triggers, list schedules in tool text --- .../client/src/promise/generated/types.ts | 8 +-- packages/core/src/schedule.ts | 6 +- packages/core/src/tool/plugin/schedule.ts | 59 ++++++++++----- packages/core/test/tool-schedule.test.ts | 71 ++++++++++++++++++- packages/protocol/openapi.json | 36 ++++++++++ packages/schema/src/schedule.ts | 19 ++--- packages/server/test/schedule.test.ts | 33 ++++++--- 7 files changed, 188 insertions(+), 44 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index d933b8505bb5..005ac750e4f3 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -4402,10 +4402,10 @@ export type ExperimentalScheduleCreateInput = { readonly sessionID: string readonly name?: string | undefined readonly when: - | { readonly in: string; readonly tz?: string } - | { readonly at: string; readonly tz?: string } - | { readonly every: string; readonly tz?: string } - | { readonly cron: string; readonly tz?: string } + | { readonly in: string; readonly at?: never; readonly every?: never; readonly cron?: never; readonly tz?: string } + | { readonly at: string; readonly in?: never; readonly every?: never; readonly cron?: never; readonly tz?: string } + | { readonly every: string; readonly in?: never; readonly at?: never; readonly cron?: never; readonly tz?: string } + | { readonly cron: string; readonly in?: never; readonly at?: never; readonly every?: never; readonly tz?: string } readonly ends?: { readonly after?: string; readonly at?: string; readonly times?: number } | undefined readonly prompt: string } diff --git a/packages/core/src/schedule.ts b/packages/core/src/schedule.ts index 820049478148..3d0bd0659371 100644 --- a/packages/core/src/schedule.ts +++ b/packages/core/src/schedule.ts @@ -30,7 +30,11 @@ export const toCreateInput = ScheduleSchema.toCreateInput export class NotFoundError extends Schema.TaggedError()("Schedule.NotFoundError", { scheduleID: ID, -}) {} +}) { + override get message() { + return `Schedule not found: ${this.scheduleID}` + } +} export class InvalidError extends Schema.TaggedError()("Schedule.InvalidError", { reason: Schema.String, diff --git a/packages/core/src/tool/plugin/schedule.ts b/packages/core/src/tool/plugin/schedule.ts index 80a87812d233..89c5a8780fc5 100644 --- a/packages/core/src/tool/plugin/schedule.ts +++ b/packages/core/src/tool/plugin/schedule.ts @@ -51,16 +51,21 @@ export const Plugin = { ) const enabled = () => Config.latest(loaded.entries, "experimental")?.schedules === true + // A request keeps the tools it started with after config reloads, so each call rechecks the flag. + const allowed = (sessionID: Session.ID) => + schedules + .enabled(sessionID) + .pipe(Effect.flatMap((on) => (on ? Effect.void : Effect.fail(new Schedule.DisabledError())))) // Tools act only on the calling Session's schedules. const owned = (id: Schedule.ID, sessionID: Session.ID) => - schedules.get(id).pipe( + allowed(sessionID).pipe( + Effect.andThen(schedules.get(id)), Effect.filterOrFail( (info) => info.sessionID === sessionID, () => new Schedule.NotFoundError({ scheduleID: id }), ), ) - const notFound = (error: Schedule.NotFoundError) => - new ToolFailure({ message: `Schedule not found: ${error.scheduleID}`, error }) + const failure = (error: { readonly message: string }) => new ToolFailure({ message: error.message, error }) yield* ctx.tool .transform((editor) => { @@ -76,16 +81,9 @@ export const Plugin = { schedules.create(Schedule.toCreateInput(context.sessionID, input)).pipe( Effect.map((info) => { const view = toView(info) - return { - output: view, - content: view.nextDueAt - ? `Scheduled ${view.name ?? view.id}; next run ${view.nextDueAt}.` - : `Scheduled ${view.name ?? view.id}.`, - } + return { output: view, content: `Scheduled: ${summary(view)}` } }), - Effect.mapError( - (error) => new ToolFailure({ message: error.message || "Unable to create schedule", error }), - ), + Effect.mapError(failure), ), }) editor.add({ @@ -96,13 +94,14 @@ export const Plugin = { options: { namespace: "opencode", codemode: true }, execute: (_, context) => Effect.gen(function* () { + yield* allowed(context.sessionID) const now = DateTime.formatIso(yield* DateTime.now) const list = (yield* schedules.list({ sessionID: context.sessionID })).map(toView) return { output: { now, schedules: list }, - content: list.length === 0 ? "No schedules." : `${list.length} schedule(s).`, + content: [`Now: ${now}`, ...(list.length === 0 ? ["No schedules."] : list.map(summary))].join("\n"), } - }), + }).pipe(Effect.mapError(failure)), }) editor.add({ name: "schedule_pause", @@ -113,8 +112,8 @@ export const Plugin = { execute: (input, context) => owned(input.id, context.sessionID).pipe( Effect.andThen(schedules.pause(input.id)), - Effect.map((info) => ({ output: toView(info), content: `Paused ${info.name ?? info.id}.` })), - Effect.mapError(notFound), + Effect.map((info) => ({ output: toView(info), content: `Paused: ${summary(toView(info))}` })), + Effect.mapError(failure), ), }) editor.add({ @@ -127,8 +126,8 @@ export const Plugin = { execute: (input, context) => owned(input.id, context.sessionID).pipe( Effect.andThen(schedules.resume(input.id)), - Effect.map((info) => ({ output: toView(info), content: `Resumed ${info.name ?? info.id}.` })), - Effect.mapError(notFound), + Effect.map((info) => ({ output: toView(info), content: `Resumed: ${summary(toView(info))}` })), + Effect.mapError(failure), ), }) editor.add({ @@ -141,7 +140,7 @@ export const Plugin = { owned(input.id, context.sessionID).pipe( Effect.andThen(schedules.remove(input.id)), Effect.as({ output: { id: input.id, removed: true }, content: `Deleted schedule ${input.id}.` }), - Effect.mapError(notFound), + Effect.mapError(failure), ), }) }) @@ -166,6 +165,28 @@ export const Plugin = { }), } +/** One line per schedule, for models that read tool content rather than output (Code Mode off). */ +function summary(view: View) { + const when = view.when + const trigger = + when.type === "in" + ? `in ${when.in}` + : when.type === "at" + ? `at ${when.at}` + : when.type === "every" + ? `every ${when.every}` + : `cron "${when.cron}"` + const prompt = view.prompt.length > 80 ? `${view.prompt.slice(0, 79)}…` : view.prompt + return [ + `${view.id}${view.name ? ` "${view.name}"` : ""}: ${trigger} (${view.tz})`, + view.status, + view.nextDueAt && `next ${view.nextDueAt}`, + `prompt: ${JSON.stringify(prompt)}`, + ] + .filter(Boolean) + .join(", ") +} + function toView(info: Schedule.Info): View { const local = (time: DateTime.Utc | undefined) => { if (time === undefined) return undefined diff --git a/packages/core/test/tool-schedule.test.ts b/packages/core/test/tool-schedule.test.ts index 2badac46f913..89a3d20331dd 100644 --- a/packages/core/test/tool-schedule.test.ts +++ b/packages/core/test/tool-schedule.test.ts @@ -27,6 +27,7 @@ const it = testEffect(AppNodeBuilder.build(Tool.node, [Image.node.replace(imageP /** An in-memory Schedule service. The real one is covered in schedule.test.ts; this exercises the tool layer. */ const memory = () => { const rows = new Map() + const flag = { enabled: true } const find = (id: Schedule.ID): Effect.Effect => { const info = rows.get(id) return info ? Effect.succeed(info) : Effect.fail(new Schedule.NotFoundError({ scheduleID: id })) @@ -62,11 +63,11 @@ const memory = () => { pause: (id) => update(id, "paused"), resume: (id) => update(id, "active"), remove: (id) => find(id).pipe(Effect.map(() => void rows.delete(id))), - enabled: () => Effect.succeed(true), + enabled: () => Effect.sync(() => flag.enabled), tick: Effect.void, run: Effect.never, }) - return { rows, service } + return { rows, flag, service } } const setup = (experimental: ConfigExperimental.Info | undefined) => @@ -91,7 +92,7 @@ const setup = (experimental: ConfigExperimental.Info | undefined) => Effect.provide(Layer.succeed(Schedule.Service, schedules.service)), Effect.provide(permissionLayer({ assert: () => Effect.void })), ) - return { registry: yield* Tool.Service, rows: schedules.rows, commands, prompts } + return { registry: yield* Tool.Service, rows: schedules.rows, flag: schedules.flag, commands, prompts } }) /** Runs one Code Mode call, the way the model reaches these tools. */ @@ -111,6 +112,20 @@ const run = (registry: Tool.Interface, name: string, input: unknown, session = s Effect.map((result) => JSON.parse(result.content?.[0]?.type === "text" ? result.content[0].text : "")), ) +/** Calls a tool directly, as a model does when Code Mode is denied, and returns the text it reads. */ +const direct = (registry: Tool.Interface, name: string, input: unknown) => + registry.snapshot([{ action: "execute", resource: "*", effect: "deny" }]).pipe( + Effect.flatMap((tools) => + tools.execute({ + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: `call-direct-${name}`, name: `opencode_${name}`, input }, + }), + ), + Effect.map((result) => result.content?.map((part) => (part.type === "text" ? part.text : "")).join("")), + Effect.orDie, + ) + const paths = (registry: Tool.Interface) => registry .snapshot() @@ -181,6 +196,56 @@ describe("ScheduleTools", () => { }), ) + it.effect("rechecks the flag on each call, since a request keeps its tools after config reloads", () => + Effect.gen(function* () { + const { registry, rows, flag } = yield* setup(new ConfigExperimental.Info({ schedules: true })) + yield* run(registry, "schedule_create", { when: { every: "1 hour" }, prompt: "ping" }) + flag.enabled = false + + for (const [name, input] of [ + ["schedule_list", {}], + ["schedule_pause", { id: "sch_1" }], + ["schedule_resume", { id: "sch_1" }], + ["schedule_delete", { id: "sch_1" }], + ] as const) { + expect(yield* call(registry, name, input)).toMatchObject({ + content: [{ type: "text", text: expect.stringContaining("Scheduled tasks are disabled") }], + metadata: { error: true }, + }) + } + expect(rows.get(Schedule.ID.make("sch_1"))).toMatchObject({ status: "active" }) + }), + ) + + it.effect("rejects a when with more than one trigger instead of keeping the first", () => + Effect.gen(function* () { + const { registry, rows } = yield* setup(new ConfigExperimental.Info({ schedules: true })) + expect( + yield* call(registry, "schedule_create", { when: { in: "5 minutes", every: "1 hour" }, prompt: "ping" }), + ).toMatchObject({ metadata: { error: true } }) + expect(rows.size).toBe(0) + }), + ) + + it.effect("puts each schedule's id, trigger, status, next run and prompt in the text without Code Mode", () => + Effect.gen(function* () { + const { registry } = yield* setup(new ConfigExperimental.Info({ schedules: true })) + expect( + yield* direct(registry, "schedule_create", { name: "ci", when: { every: "1 hour" }, prompt: "check CI" }), + ).toBe( + 'Scheduled: sch_1 "ci": every 1 hour (America/New_York), active, next 2026-10-27T09:20:00.000-04:00, prompt: "check CI"', + ) + yield* direct(registry, "schedule_create", { when: { cron: "0 9 * * 1-5" }, prompt: "x".repeat(100) }) + yield* direct(registry, "schedule_pause", { id: "sch_2" }) + const list = yield* direct(registry, "schedule_list", {}) + expect(list?.split("\n").slice(1)).toEqual([ + 'sch_1 "ci": every 1 hour (America/New_York), active, next 2026-10-27T09:20:00.000-04:00, prompt: "check CI"', + `sch_2: cron "0 9 * * 1-5" (America/New_York), paused, next 2026-10-27T09:20:00.000-04:00, prompt: "${"x".repeat(79)}…"`, + ]) + expect(list).toStartWith("Now: ") + }), + ) + it.effect("treats another session's schedule as missing", () => Effect.gen(function* () { const { registry, rows } = yield* setup(new ConfigExperimental.Info({ schedules: true })) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 707222484bf3..1a7a7418320b 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -21321,6 +21321,15 @@ "in": { "$ref": "#/components/schemas/Schedule.Duration" }, + "at": { + "not": {} + }, + "every": { + "not": {} + }, + "cron": { + "not": {} + }, "tz": { "type": "string" } @@ -21334,6 +21343,15 @@ "at": { "type": "string" }, + "in": { + "not": {} + }, + "every": { + "not": {} + }, + "cron": { + "not": {} + }, "tz": { "type": "string" } @@ -21347,6 +21365,15 @@ "every": { "$ref": "#/components/schemas/Schedule.Duration" }, + "in": { + "not": {} + }, + "at": { + "not": {} + }, + "cron": { + "not": {} + }, "tz": { "type": "string" } @@ -21360,6 +21387,15 @@ "cron": { "type": "string" }, + "in": { + "not": {} + }, + "at": { + "not": {} + }, + "every": { + "not": {} + }, "tz": { "type": "string" } diff --git a/packages/schema/src/schedule.ts b/packages/schema/src/schedule.ts index b89a1245b647..2b5ea306e9ee 100644 --- a/packages/schema/src/schedule.ts +++ b/packages/schema/src/schedule.ts @@ -104,12 +104,15 @@ export const CreateInput = Schema.Struct({ }).annotate({ identifier: "Schedule.CreateInput" }) export type CreateInput = typeof CreateInput.Type +/** Declares a trigger absent, so `{ in, every }` fails instead of decoding as a one-shot. */ +const absent = Schema.optionalKey(Schema.Never) + /** The `when` callers write: exactly one of `in`, `at`, `every` or `cron`, with an optional `tz`. */ export const WhenInput = Schema.Union([ - Schema.Struct({ in: In.fields.in, ...zone }), - Schema.Struct({ at: At.fields.at, ...zone }), - Schema.Struct({ every: Every.fields.every, ...zone }), - Schema.Struct({ cron: Cron.fields.cron, ...zone }), + Schema.Struct({ in: In.fields.in, at: absent, every: absent, cron: absent, ...zone }), + Schema.Struct({ at: At.fields.at, in: absent, every: absent, cron: absent, ...zone }), + Schema.Struct({ every: Every.fields.every, in: absent, at: absent, cron: absent, ...zone }), + Schema.Struct({ cron: Cron.fields.cron, in: absent, at: absent, every: absent, ...zone }), ]).annotate({ identifier: "Schedule.WhenInput" }) export type WhenInput = typeof WhenInput.Type @@ -138,8 +141,8 @@ export const toCreateInput = (sessionID: SessionID, request: Request): CreateInp }) const toWhen = (when: WhenInput): When => { - if ("in" in when) return { type: "in", ...when } - if ("at" in when) return { type: "at", ...when } - if ("every" in when) return { type: "every", ...when } - return { type: "cron", ...when } + if (when.in !== undefined) return { type: "in", in: when.in, tz: when.tz } + if (when.at !== undefined) return { type: "at", at: when.at, tz: when.tz } + if (when.every !== undefined) return { type: "every", every: when.every, tz: when.tz } + return { type: "cron", cron: when.cron, tz: when.tz } } diff --git a/packages/server/test/schedule.test.ts b/packages/server/test/schedule.test.ts index 2f53412d50b7..8919b3d8608f 100644 --- a/packages/server/test/schedule.test.ts +++ b/packages/server/test/schedule.test.ts @@ -25,15 +25,22 @@ it.live("manages schedules through the experimental API where the session's conf await Promise.all([global, enabled, disabled].map((item) => fs.mkdir(item, { recursive: true }))) await fs.writeFile(path.join(enabled, "opencode.json"), JSON.stringify({ experimental: { schedules: true } })) }) - const api = client( - yield* ServerFetch.make({ - app: { version: "test" }, - database: { path: path.join(dir, "opencode.db") }, - config: { directory: global, project: true, claude: false }, - models: { fetch: false }, - fs: { filewatcher: false }, - }), - ) + const handler = yield* ServerFetch.make({ + app: { version: "test" }, + database: { path: path.join(dir, "opencode.db") }, + config: { directory: global, project: true, claude: false }, + models: { fetch: false }, + fs: { filewatcher: false }, + }) + const api = client(handler) + const handlerRequest = (route: string, body: unknown) => + handler( + new Request(`http://opencode.local${route}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ) yield* Effect.promise(async () => { const session = await api.session.create({ location: { directory: enabled } }) @@ -64,6 +71,14 @@ it.live("manages schedules through the experimental API where the session's conf await expect( api.experimental.schedule.create({ sessionID: session.id, when: { every: "soon" }, prompt: "ping" }), ).rejects.toMatchObject({ _tag: "InvalidRequestError" }) + // Two triggers would otherwise decode as the first one, silently turning a repeat into a one-shot. + const twoTriggers = await handlerRequest("/api/experimental/schedule", { + sessionID: session.id, + when: { in: "5 minutes", every: "1 hour" }, + prompt: "ping", + }) + expect(twoTriggers.status).toBe(400) + expect(await api.experimental.schedule.list({ sessionID: session.id })).toEqual([]) await expect( api.experimental.schedule.create({ sessionID: session.id, when: { in: "0 seconds" }, prompt: "ping" }), ).rejects.toMatchObject({ _tag: "InvalidRequestError", message: "Duration must be positive: 0 seconds" }) From 23757156b3ead368177713121a3eae372b298060 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 26 Oct 2026 22:13:14 -0700 Subject: [PATCH 3/3] chore(sdk): regenerate for the failed schedule outcome --- packages/client/src/promise/generated/types.ts | 2 +- packages/protocol/openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 005ac750e4f3..2bd7d95ac5ff 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -331,7 +331,7 @@ export type ScheduleActionPrompt = { type: "prompt"; prompt: string } export type ScheduleStatus = "active" | "paused" | "done" -export type ScheduleOutcome = "fired" | "late" | "skipped" | "disabled" | "expired" +export type ScheduleOutcome = "fired" | "late" | "skipped" | "disabled" | "failed" export type ReferenceGitSource = { type: "git"; repository: string; branch?: string } diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 1a7a7418320b..40bb407cc9c4 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -21223,7 +21223,7 @@ }, "Schedule.Outcome": { "type": "string", - "enum": ["fired", "late", "skipped", "disabled", "expired"] + "enum": ["fired", "late", "skipped", "disabled", "failed"] }, "Schedule.Status": { "type": "string",