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
4 changes: 2 additions & 2 deletions packages/opencode/src/server/routes/trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export const TriggerRoutes = lazy(() =>
}),
validator("param", Params),
async (c) => {
return c.json(await Trigger.fire(c.req.valid("param").id))
return c.json(await Trigger.fire(c.req.valid("param").id, "manual"))
},
)
.post(
Expand Down Expand Up @@ -141,7 +141,7 @@ export const TriggerRoutes = lazy(() =>
if (item.webhook_secret && c.req.header("X-Trigger-Secret") !== item.webhook_secret) {
return c.json({ message: "Unauthorized" }, 401)
}
return c.json(await Trigger.fire(id))
return c.json(await Trigger.fire(id, "webhook"))
},
)
.post(
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/session/session.sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Timestamps } from "../storage/schema.sql"

type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
type TriggerLast = NonNullable<Trigger.Info["last"]>

export const SessionTable = sqliteTable(
"session",
Expand Down Expand Up @@ -117,6 +118,9 @@ export const TriggerTable = sqliteTable(
enabled: integer({ mode: "boolean" }).notNull(),
runs: integer().notNull(),
...Timestamps,
last_source: text().$type<TriggerLast["source"]>(),
last_status: text().$type<TriggerLast["status"]>(),
last_error: text(),
time_last: integer(),
time_next: integer().notNull(),
},
Expand Down
100 changes: 79 additions & 21 deletions packages/opencode/src/trigger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ export namespace Trigger {
}),
])

const Source = z.enum(["schedule", "manual", "webhook"])
type Source = z.infer<typeof Source>

const Status = z.enum(["success", "skipped", "failed"])
const Last = z.object({
source: Source,
status: Status,
error: z.string().min(1).optional(),
time: z.number().int().nonnegative(),
})
type Last = z.infer<typeof Last>

export const Info = z
.object({
id: z.string(),
Expand All @@ -36,6 +48,7 @@ export namespace Trigger {
webhook_secret: z.string().min(1).optional(),
enabled: z.boolean(),
runs: z.number().int().nonnegative(),
last: Last.optional(),
time: z.object({
created: z.number().int().nonnegative(),
last: z.number().int().nonnegative().optional(),
Expand Down Expand Up @@ -71,7 +84,7 @@ export namespace Trigger {
create: (input: CreateInput) => Effect.Effect<Info>
get: (id: string) => Effect.Effect<Info, Err>
list: () => Effect.Effect<Info[]>
fire: (id: string) => Effect.Effect<Info, Err>
fire: (id: string, source: Source) => Effect.Effect<Info, Err>
enable: (id: string) => Effect.Effect<Info, Err>
disable: (id: string) => Effect.Effect<Info, Err>
delete: (id: string) => Effect.Effect<void, Err>
Expand All @@ -81,7 +94,7 @@ export namespace Trigger {
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly get: (id: string) => Effect.Effect<Info, Err>
readonly list: () => Effect.Effect<Info[]>
readonly fire: (id: string) => Effect.Effect<Info, Err>
readonly fire: (id: string, source?: Source) => Effect.Effect<Info, Err>
readonly enable: (id: string) => Effect.Effect<Info, Err>
readonly disable: (id: string) => Effect.Effect<Info, Err>
readonly delete: (id: string) => Effect.Effect<void, Err>
Expand All @@ -97,9 +110,12 @@ export namespace Trigger {
webhook_secret: item.webhook_secret ?? null,
enabled: item.enabled,
runs: item.runs,
last_source: item.last?.source ?? null,
last_status: item.last?.status ?? null,
last_error: item.last?.error ?? null,
time_created: item.time.created,
time_updated,
time_last: item.time.last ?? null,
time_last: item.last?.time ?? item.time.last ?? null,
time_next: item.time.next,
})

Expand All @@ -110,6 +126,16 @@ export namespace Trigger {
...(row.webhook_secret ? { webhook_secret: row.webhook_secret } : {}),
enabled: row.enabled,
runs: row.runs,
...(row.last_source && row.last_status && row.time_last !== null
? {
last: {
source: row.last_source,
status: row.last_status,
...(row.last_error ? { error: row.last_error } : {}),
time: row.time_last,
},
}
: {}),
time: {
created: row.time_created,
...(row.time_last === null ? {} : { last: row.time_last }),
Expand Down Expand Up @@ -141,6 +167,15 @@ export namespace Trigger {
if (!cols.some((col) => col.name === "webhook_secret")) {
Database.Client().$client.query(`ALTER TABLE trigger ADD COLUMN webhook_secret text`).run()
}
if (!cols.some((col) => col.name === "last_source")) {
Database.Client().$client.query(`ALTER TABLE trigger ADD COLUMN last_source text`).run()
}
if (!cols.some((col) => col.name === "last_status")) {
Database.Client().$client.query(`ALTER TABLE trigger ADD COLUMN last_status text`).run()
}
if (!cols.some((col) => col.name === "last_error")) {
Database.Client().$client.query(`ALTER TABLE trigger ADD COLUMN last_error text`).run()
}
Database.Client().$client.query(`CREATE INDEX IF NOT EXISTS trigger_project_idx ON trigger (project_id)`).run()
})

Expand Down Expand Up @@ -190,7 +225,21 @@ export namespace Trigger {
}),
)

const run = Effect.fnUntraced(function* (item: Info) {
const last = Effect.fnUntraced(function* (item: Info, next: Last) {
const out = {
...item,
last: next,
time: {
...item.time,
last: next.time,
},
}
data.set(item.id, out)
yield* save(out)
return out
})

const run = Effect.fnUntraced(function* (item: Info, source: Source) {
const at = Date.now()
const next = {
...item,
Expand All @@ -209,32 +258,41 @@ export namespace Trigger {
at,
})
const action = item.action
if (!action) return next
if (!action) return yield* last(next, { source, status: "success", time: at })
const st = yield* Effect.promise(() => SessionStatus.get(action.sessionID))
if (st.type !== "idle") return next
yield* Effect.promise(() =>
if (st.type !== "idle") return yield* last(next, { source, status: "skipped", time: at })
return yield* Effect.promise(() =>
SessionPrompt.command({
sessionID: action.sessionID,
command: action.command,
arguments: action.arguments ?? "",
}),
).pipe(
Effect.flatMap(() => last(next, { source, status: "success", time: at })),
Effect.catchCause((cause) =>
Effect.sync(() =>
log.error("trigger action failed", {
triggerID: item.id,
cause: Cause.pretty(cause),
}),
),
Effect.gen(function* () {
const err = Cause.squash(cause)
yield* Effect.sync(() =>
log.error("trigger action failed", {
triggerID: item.id,
cause: Cause.pretty(cause),
}),
)
return yield* last(next, {
source,
status: "failed",
error: err instanceof Error ? err.message : String(err),
time: at,
})
}),
),
)
return next
})

const tick = Effect.fnUntraced(function* () {
yield* Effect.forEach(
Array.from(data.values()).filter((item) => item.enabled && item.time.next <= Date.now()),
(item) => run(item),
(item) => run(item, "schedule"),
{ discard: true },
)
})
Expand Down Expand Up @@ -282,8 +340,8 @@ export namespace Trigger {
Effect.succeed(Array.from(data.values()).sort((a, b) => a.time.created - b.time.created)),
)

const fire = Effect.fn("Trigger.fire")(function* (id: string) {
return yield* run(yield* get(id))
const fire = Effect.fn("Trigger.fire")(function* (id: string, source: Source) {
return yield* run(yield* get(id), source)
})

const enable = Effect.fn("Trigger.enable")((id: string) => update(id, true))
Expand All @@ -310,8 +368,8 @@ export namespace Trigger {
list: Effect.fn("Trigger.list")(function* () {
return yield* InstanceState.useEffect(state, (svc) => svc.list())
}),
fire: Effect.fn("Trigger.fire")(function* (id: string) {
return yield* InstanceState.useEffect(state, (svc) => svc.fire(id))
fire: Effect.fn("Trigger.fire")(function* (id: string, source = "manual") {
return yield* InstanceState.useEffect(state, (svc) => svc.fire(id, source))
}),
enable: Effect.fn("Trigger.enable")(function* (id: string) {
return yield* InstanceState.useEffect(state, (svc) => svc.enable(id))
Expand Down Expand Up @@ -345,8 +403,8 @@ export namespace Trigger {
return runPromise((svc) => svc.enable(id))
}

export async function fire(id: string) {
return runPromise((svc) => svc.fire(id))
export async function fire(id: string, source: Source = "manual") {
return runPromise((svc) => svc.fire(id, source))
}

export async function disable(id: string) {
Expand Down
10 changes: 10 additions & 0 deletions packages/opencode/test/server/trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ describe("trigger routes", () => {
expect(await fire.json()).toMatchObject({
id: item.id,
runs: 1,
last: {
source: "manual",
status: "success",
time: expect.any(Number),
},
time: {
created: item.time.created,
last: expect.any(Number),
Expand All @@ -166,6 +171,11 @@ describe("trigger routes", () => {
expect(await fire.json()).toMatchObject({
id: item.id,
runs: 1,
last: {
source: "webhook",
status: "success",
time: expect.any(Number),
},
time: {
created: item.time.created,
last: expect.any(Number),
Expand Down
49 changes: 49 additions & 0 deletions packages/opencode/test/trigger/trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,18 @@ describe("trigger service", () => {

await Bun.sleep(80)

const next = (await Trigger.list())[0]

expect(command).toHaveBeenCalledWith({
sessionID: session.id,
command: "init",
arguments: "--help",
})
expect(next?.last).toMatchObject({
source: "schedule",
status: "success",
time: expect.any(Number),
})
},
})
})
Expand Down Expand Up @@ -214,7 +221,44 @@ describe("trigger service", () => {

await Bun.sleep(80)

const next = (await Trigger.list())[0]
expect(command).not.toHaveBeenCalled()
expect(next?.last).toMatchObject({
source: "schedule",
status: "skipped",
time: expect.any(Number),
})
},
})
})

test("records failed action error", async () => {
await using tmp = await tmpdir({ git: true })

await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = new Error("boom")
spyOn(SessionPrompt, "command").mockRejectedValue(err)

const item = await Trigger.create({
interval: 5_000,
action: {
type: "command",
sessionID: session.id,
command: "init",
},
})

const next = await Trigger.fire(item.id)

expect(next.last).toMatchObject({
source: "manual",
status: "failed",
error: "boom",
time: expect.any(Number),
})
},
})
})
Expand Down Expand Up @@ -266,6 +310,11 @@ describe("trigger service", () => {
at: last,
},
])
expect(next.last).toMatchObject({
source: "manual",
status: "success",
time: expect.any(Number),
})
},
})
})
Expand Down