Files
opencode/packages/server/test/schedule.test.ts
Dax Raad da87c475a7 fix(core): keep schedules off unless experimental.schedules is enabled
Restores the opt-in default from before #60478. The schedule loop still runs in every server process and keeps its leader election; it only fires schedules in locations that enable the flag.
2026-10-30 11:35:33 -04:00

247 lines
11 KiB
TypeScript

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"
import { ServerProcess } from "../src/process"
import { startServer } from "./fixture/server"
const client = (handler: (request: Request) => Promise<Response>) =>
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 when enabled and honors experimental.schedules: false", () =>
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 } }))
await fs.writeFile(path.join(disabled, "opencode.json"), JSON.stringify({ experimental: { schedules: 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 } })
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",
})
// Shell until check that already passes returns done immediately with its output.
const matched = await api.experimental.schedule.create({
sessionID: session.id,
until: { run: "printf ready" },
prompt: "ready now",
})
expect(matched).toMatchObject({
status: "done",
lastOutcome: "fired",
lastOutput: "ready",
until: { type: "shell", run: "printf ready" },
})
// Stateless HTTP create settles an "ask" command as 403 ForbiddenError instead of hanging.
const askDir = path.join(dir, "ask")
await fs.mkdir(askDir, { recursive: true })
await fs.writeFile(
path.join(askDir, "opencode.json"),
JSON.stringify({ experimental: { schedules: true }, permission: { shell: "ask" } }),
)
const askSession = await api.session.create({ location: { directory: askDir } })
await expect(
api.experimental.schedule.create({
sessionID: askSession.id,
until: { run: "printf blocked" },
prompt: "never",
}),
).rejects.toMatchObject({ _tag: "ForbiddenError" })
// Code Mode until check works end-to-end with no allow field, and { run, code } together is 400.
const codeMatched = await api.experimental.schedule.create({
sessionID: session.id,
until: { code: 'return { wake: true, message: "models=" + (await tools.opencode.models({})).total }' },
prompt: "models checked",
})
expect(codeMatched).toMatchObject({
status: "done",
lastOutcome: "fired",
lastOutput: expect.stringContaining("models="),
until: { type: "code" },
})
const mixed = await handlerRequest("/api/experimental/schedule", {
sessionID: session.id,
until: { run: "true", code: "return true" },
prompt: "ping",
})
expect(mixed.status).toBe(400)
})
}).pipe(Effect.scoped),
)
it.live("ticks due schedules in standalone ServerProcess.start without lifecycle", () =>
Effect.gen(function* () {
const dir = (yield* tmpdirScoped("opencode-schedule-serve-")).path
const server = yield* startServer(dir)
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
yield* Effect.promise(async () => {
await fs.writeFile(path.join(dir, "opencode.json"), JSON.stringify({ experimental: { schedules: true }, permission: { shell: "allow" } }))
const session = await api.session.create({ location: { directory: dir } })
const created = await api.experimental.schedule.create({
sessionID: session.id,
when: { in: "150 millis" },
if: { run: "false" },
prompt: "wake from serve",
})
expect(created.status).toBe("active")
const deadline = Date.now() + 5_000
let current = created
while (current.status === "active" && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50))
current = await api.experimental.schedule.get({ scheduleID: created.id })
}
expect(current).toMatchObject({
status: "done",
lastOutcome: "checked",
})
})
}).pipe(Effect.scoped),
)
it.live("elects one scheduler leader when two server processes boot against a due schedule", () =>
Effect.gen(function* () {
const dir = (yield* tmpdirScoped("opencode-schedule-leader-")).path
const dbPath = path.join(dir, "shared.db")
const marker = path.join(dir, "runs.txt")
yield* Effect.promise(() =>
fs.writeFile(path.join(dir, "opencode.json"), JSON.stringify({ experimental: { schedules: true }, permission: { shell: "allow" } })),
)
// Seed an active schedule in a short-lived scope so it falls due while no server is running.
const createdID = yield* Effect.scoped(
Effect.gen(function* () {
const handler = yield* ServerFetch.make({
app: { version: "test" },
database: { path: dbPath },
config: { directory: dir, project: true, claude: false },
models: { fetch: false },
fs: { filewatcher: false },
})
const seededApi = client(handler)
return yield* Effect.promise(async () => {
const session = await seededApi.session.create({ location: { directory: dir } })
const created = await seededApi.experimental.schedule.create({
sessionID: session.id,
when: { in: "80 millis" },
if: { run: `printf 'x\\n' >> ${JSON.stringify(marker)} && false` },
prompt: "single leader check",
})
return created.id
})
}),
)
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 120)))
const boot = () =>
ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: dbPath },
config: { directory: dir },
fs: { filewatcher: false },
models: { fetch: false },
})
// Both servers start against the same already-due row at t=0; without leader election both run the check.
const [first] = yield* Effect.all([boot(), boot()], { concurrency: 2 })
const base = `http://127.0.0.1:${"port" in first.address ? first.address.port : 0}`
const api = OpenCode.make({
baseUrl: base,
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
})
yield* Effect.promise(async () => {
const deadline = Date.now() + 5_000
let current = await api.experimental.schedule.get({ scheduleID: createdID })
while (current.status === "active" && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50))
current = await api.experimental.schedule.get({ scheduleID: createdID })
}
await new Promise((resolve) => setTimeout(resolve, 150))
const lines = (await fs.readFile(marker, "utf8")).trim().split("\n").filter(Boolean)
expect(current.status).toBe("done")
expect(lines).toEqual(["x"])
})
}).pipe(Effect.scoped),
)