diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance.ts index 4bb6efaf9b05..1647ce427044 100644 --- a/packages/opencode/src/server/instance.ts +++ b/packages/opencode/src/server/instance.ts @@ -25,6 +25,7 @@ import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" import { ProviderRoutes } from "./routes/provider" import { EventRoutes } from "./routes/event" +import { TriggerRoutes } from "./routes/trigger" import { errorHandler } from "./middleware" const log = Log.create({ service: "server" }) @@ -51,6 +52,7 @@ export const InstanceRoutes = (app?: Hono) => .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) + .route("/trigger", TriggerRoutes()) .route("/", FileRoutes()) .route("/", EventRoutes()) .route("/mcp", McpRoutes()) diff --git a/packages/opencode/src/server/routes/trigger.ts b/packages/opencode/src/server/routes/trigger.ts new file mode 100644 index 000000000000..bfdbf3343d06 --- /dev/null +++ b/packages/opencode/src/server/routes/trigger.ts @@ -0,0 +1,53 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import { Trigger } from "@/trigger" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +export const TriggerRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List triggers", + description: "List lightweight scheduled triggers for the current instance.", + operationId: "trigger.list", + responses: { + 200: { + description: "Triggers", + content: { + "application/json": { + schema: resolver(Trigger.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Trigger.list()) + }, + ) + .post( + "/", + describeRoute({ + summary: "Create trigger", + description: "Register a lightweight scheduled trigger for the current instance.", + operationId: "trigger.create", + responses: { + 200: { + description: "Trigger", + content: { + "application/json": { + schema: resolver(Trigger.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Trigger.CreateInput), + async (c) => { + return c.json(await Trigger.create(c.req.valid("json"))) + }, + ), +) diff --git a/packages/opencode/src/trigger/index.ts b/packages/opencode/src/trigger/index.ts new file mode 100644 index 000000000000..fc54a12f78f6 --- /dev/null +++ b/packages/opencode/src/trigger/index.ts @@ -0,0 +1,151 @@ +import { randomUUID } from "node:crypto" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { InstanceState } from "@/effect/instance-state" +import { makeRuntime } from "@/effect/run-service" +import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect" +import z from "zod" +import { Log } from "../util/log" + +export namespace Trigger { + const log = Log.create({ service: "trigger" }) + + export const Info = z + .object({ + id: z.string(), + schedule: z.object({ + type: z.literal("interval"), + interval: z.number().int().positive(), + }), + runs: z.number().int().nonnegative(), + time: z.object({ + created: z.number().int().nonnegative(), + last: z.number().int().nonnegative().optional(), + next: z.number().int().nonnegative(), + }), + }) + .meta({ + ref: "Trigger", + }) + export type Info = z.infer + + export const CreateInput = z.object({ + interval: z.number().int().min(10).max(86_400_000), + }) + export type CreateInput = z.infer + + export const Event = { + Fired: BusEvent.define( + "trigger.fired", + z.object({ + triggerID: z.string(), + runs: z.number().int().nonnegative(), + at: z.number().int().nonnegative(), + }), + ), + } + + type State = { + create: (input: CreateInput) => Effect.Effect + list: () => Effect.Effect + } + + export interface Interface { + readonly create: (input: CreateInput) => Effect.Effect + readonly list: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Trigger") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const state = yield* InstanceState.make( + Effect.fn("Trigger.state")(function* () { + const data = new Map() + + const tick = Effect.fnUntraced(function* () { + const now = Date.now() + yield* Effect.forEach( + Array.from(data.values()).filter((item) => item.time.next <= now), + (item) => + Effect.gen(function* () { + const at = Date.now() + const next = { + ...item, + runs: item.runs + 1, + time: { + ...item.time, + last: at, + next: at + item.schedule.interval, + }, + } + data.set(item.id, next) + yield* bus.publish(Event.Fired, { + triggerID: item.id, + runs: next.runs, + at, + }) + }), + { discard: true }, + ) + }) + + yield* tick().pipe( + Effect.catchCause((cause) => { + log.error("tick loop failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.millis(10))), + Effect.forkScoped, + ) + + const create = Effect.fn("Trigger.create")(function* (input: CreateInput) { + const now = Date.now() + const item = { + id: `trg_${randomUUID().replaceAll("-", "")}`, + schedule: { + type: "interval" as const, + interval: input.interval, + }, + runs: 0, + time: { + created: now, + next: now + input.interval, + }, + } satisfies Info + data.set(item.id, item) + return item + }) + + const list = Effect.fn("Trigger.list")(() => + Effect.succeed(Array.from(data.values()).sort((a, b) => a.time.created - b.time.created)), + ) + + return { create, list } + }), + ) + + return Service.of({ + create: Effect.fn("Trigger.create")(function* (input: CreateInput) { + return yield* InstanceState.useEffect(state, (svc) => svc.create(input)) + }), + list: Effect.fn("Trigger.list")(function* () { + return yield* InstanceState.useEffect(state, (svc) => svc.list()) + }), + }) + }), + ) + + const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) + const { runPromise } = makeRuntime(Service, defaultLayer) + + export async function create(input: CreateInput) { + return runPromise((svc) => svc.create(input)) + } + + export async function list() { + return runPromise((svc) => svc.list()) + } +} diff --git a/packages/opencode/test/server/trigger.test.ts b/packages/opencode/test/server/trigger.test.ts new file mode 100644 index 000000000000..4a03a4e9a7d1 --- /dev/null +++ b/packages/opencode/test/server/trigger.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("trigger routes", () => { + test("creates and lists triggers", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const app = Server.Default() + + const create = await app.request("/trigger", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ interval: 20 }), + }) + + expect(create.status).toBe(200) + const item = await create.json() + expect(item).toMatchObject({ + schedule: { interval: 20 }, + runs: 0, + }) + + await Bun.sleep(80) + + const list = await app.request("/trigger") + expect(list.status).toBe(200) + const body = await list.json() + expect(body).toHaveLength(1) + expect(body[0]).toMatchObject({ + id: item.id, + schedule: { type: "interval", interval: 20 }, + }) + expect(body[0].runs).toBeGreaterThan(0) + }, + }) + }) +}) diff --git a/packages/opencode/test/trigger/trigger.test.ts b/packages/opencode/test/trigger/trigger.test.ts new file mode 100644 index 000000000000..35d54b6e10b4 --- /dev/null +++ b/packages/opencode/test/trigger/trigger.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Trigger } from "../../src/trigger" +import { tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("trigger service", () => { + test("creates triggers per instance and fires them later", async () => { + await using a = await tmpdir({ git: true }) + await using b = await tmpdir({ git: true }) + + await Instance.provide({ + directory: a.path, + fn: async () => { + const item = await Trigger.create({ interval: 20 }) + const list = await Trigger.list() + expect(list).toHaveLength(1) + expect(list[0]).toMatchObject({ + id: item.id, + schedule: { interval: 20 }, + runs: 0, + }) + + await Bun.sleep(80) + + const next = (await Trigger.list())[0] + expect(next?.runs).toBeGreaterThan(0) + expect(next?.time.last).toBeGreaterThanOrEqual(next!.time.created) + }, + }) + + await Instance.provide({ + directory: b.path, + fn: async () => { + expect(await Trigger.list()).toEqual([]) + }, + }) + }) +})