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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/opencode/src/server/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand All @@ -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())
Expand Down
53 changes: 53 additions & 0 deletions packages/opencode/src/server/routes/trigger.ts
Original file line number Diff line number Diff line change
@@ -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")))
},
),
)
151 changes: 151 additions & 0 deletions packages/opencode/src/trigger/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Info>

export const CreateInput = z.object({
interval: z.number().int().min(10).max(86_400_000),
})
export type CreateInput = z.infer<typeof CreateInput>

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<Info>
list: () => Effect.Effect<Info[]>
}

export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Info[]>
}

export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Trigger") {}

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Trigger.state")(function* () {
const data = new Map<string, Info>()

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())
}
}
45 changes: 45 additions & 0 deletions packages/opencode/test/server/trigger.test.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
})
})
42 changes: 42 additions & 0 deletions packages/opencode/test/trigger/trigger.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
},
})
})
})