diff --git a/packages/client/script/generate.ts b/packages/client/script/generate.ts index e70cd5f79d71..d828797d473a 100644 --- a/packages/client/script/generate.ts +++ b/packages/client/script/generate.ts @@ -37,6 +37,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" @@ -92,6 +93,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 6a6ac1c3f6cf..bf114ca1f239 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" @@ -2370,6 +2371,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 @@ -2383,6 +2426,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 1012306cea69..bd3096140ed2 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, @@ -1556,6 +1568,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), @@ -1569,6 +1640,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 8db7a72b9f6c..0c59497f859e 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, @@ -1477,6 +1489,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, + ), + }, migration: { v1: { status: (requestOptions?: RequestOptions) => diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 738fb50180fa..da4477e97b98 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" | "failed" + 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 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 +} + +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 313272efcb57..b27962332d74 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -61,6 +61,7 @@ test("exposes every standard HTTP API group", () => { "file", "persistentPty", "media", + "schedule", "migration", "usage", "config", @@ -80,6 +81,7 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.experimental.integration.wellknown)).toEqual(["add"]) expect(Object.keys(client.experimental.file)).toEqual(["write", "watch", "unwatch"]) 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() }) 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 0b00caa0ccc4..8b64e7949b69 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" @@ -140,6 +142,7 @@ const services = [ Reference.Service, WebSearch.Service, Ripgrep.Service, + Schedule.Service, Session.Service, SessionCompaction.Service, SessionInstructions.Service, @@ -193,6 +196,7 @@ export const requirements = LayerNode.group([ Reference.node, WebSearch.node, Ripgrep.node, + Schedule.node, Session.node, SessionCompaction.node, SessionInstructions.node, @@ -240,6 +244,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..3d0bd0659371 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,10 +21,20 @@ 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, -}) {} +}) { + override get message() { + return `Schedule not found: ${this.scheduleID}` + } +} export class InvalidError extends Schema.TaggedError()("Schedule.InvalidError", { reason: Schema.String, @@ -46,6 +57,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 +470,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..89c5a8780fc5 --- /dev/null +++ b/packages/core/src/tool/plugin/schedule.ts @@ -0,0 +1,212 @@ +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 + + // 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) => + allowed(sessionID).pipe( + Effect.andThen(schedules.get(id)), + Effect.filterOrFail( + (info) => info.sessionID === sessionID, + () => new Schedule.NotFoundError({ scheduleID: id }), + ), + ) + const failure = (error: { readonly message: string }) => new ToolFailure({ message: error.message, 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: `Scheduled: ${summary(view)}` } + }), + Effect.mapError(failure), + ), + }) + 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* () { + 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: [`Now: ${now}`, ...(list.length === 0 ? ["No schedules."] : list.map(summary))].join("\n"), + } + }).pipe(Effect.mapError(failure)), + }) + 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: ${summary(toView(info))}` })), + Effect.mapError(failure), + ), + }) + 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: ${summary(toView(info))}` })), + Effect.mapError(failure), + ), + }) + 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(failure), + ), + }) + }) + .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), + }) + }) + }), +} + +/** 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 + 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..89a3d20331dd --- /dev/null +++ b/packages/core/test/tool-schedule.test.ts @@ -0,0 +1,268 @@ +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 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 })) + } + 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.sync(() => flag.enabled), + tick: Effect.void, + run: Effect.never, + }) + return { rows, flag, 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, flag: schedules.flag, 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 : "")), + ) + +/** 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() + .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("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 })) + 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 cdd955603890..3a59006172fe 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,338 @@ "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", "failed"] + }, + "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" + }, + "at": { + "not": {} + }, + "every": { + "not": {} + }, + "cron": { + "not": {} + }, + "tz": { + "type": "string" + } + }, + "required": ["in"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "at": { + "type": "string" + }, + "in": { + "not": {} + }, + "every": { + "not": {} + }, + "cron": { + "not": {} + }, + "tz": { + "type": "string" + } + }, + "required": ["at"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "every": { + "$ref": "#/components/schemas/Schedule.Duration" + }, + "in": { + "not": {} + }, + "at": { + "not": {} + }, + "cron": { + "not": {} + }, + "tz": { + "type": "string" + } + }, + "required": ["every"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "cron": { + "type": "string" + }, + "in": { + "not": {} + }, + "at": { + "not": {} + }, + "every": { + "not": {} + }, + "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..2b5ea306e9ee 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,46 @@ export const CreateInput = Schema.Struct({ action: Action, }).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, 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 + +/** 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 (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/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..8919b3d8608f --- /dev/null +++ b/packages/server/test/schedule.test.ts @@ -0,0 +1,96 @@ +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 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 } }) + 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" }) + // 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" }) + + // 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), +)