mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-28 12:39:43 +00:00
feat(httpapi): bridge session lifecycle routes (#24486)
This commit is contained in:
parent
daff119fe4
commit
daaa2e5911
4 changed files with 214 additions and 7 deletions
|
|
@ -291,12 +291,12 @@ This checklist tracks bridge parity only. Checked routes are available through t
|
||||||
- [x] `GET /session/:sessionID` - get session.
|
- [x] `GET /session/:sessionID` - get session.
|
||||||
- [x] `GET /session/:sessionID/children` - get child sessions.
|
- [x] `GET /session/:sessionID/children` - get child sessions.
|
||||||
- [x] `GET /session/:sessionID/todo` - get session todos.
|
- [x] `GET /session/:sessionID/todo` - get session todos.
|
||||||
- [ ] `POST /session` - create session.
|
- [x] `POST /session` - create session.
|
||||||
- [ ] `DELETE /session/:sessionID` - delete session.
|
- [x] `DELETE /session/:sessionID` - delete session.
|
||||||
- [ ] `PATCH /session/:sessionID` - update session metadata.
|
- [x] `PATCH /session/:sessionID` - update session metadata.
|
||||||
- [ ] `POST /session/:sessionID/init` - run project init command.
|
- [ ] `POST /session/:sessionID/init` - run project init command.
|
||||||
- [ ] `POST /session/:sessionID/fork` - fork session.
|
- [x] `POST /session/:sessionID/fork` - fork session.
|
||||||
- [ ] `POST /session/:sessionID/abort` - abort session.
|
- [x] `POST /session/:sessionID/abort` - abort session.
|
||||||
- [ ] `POST /session/:sessionID/share` - share session.
|
- [ ] `POST /session/:sessionID/share` - share session.
|
||||||
- [x] `GET /session/:sessionID/diff` - session diff.
|
- [x] `GET /session/:sessionID/diff` - session diff.
|
||||||
- [ ] `DELETE /session/:sessionID/share` - unshare session.
|
- [ ] `DELETE /session/:sessionID/share` - unshare session.
|
||||||
|
|
@ -355,7 +355,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
|
||||||
6. [x] Bridge workspace create/remove/session-restore routes.
|
6. [x] Bridge workspace create/remove/session-restore routes.
|
||||||
7. [x] Bridge sync start/replay/history routes.
|
7. [x] Bridge sync start/replay/history routes.
|
||||||
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
|
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
|
||||||
9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
|
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
|
||||||
10. [ ] Bridge session share/summary/message/part mutation routes.
|
10. [ ] Bridge session share/summary/message/part mutation routes.
|
||||||
11. [ ] Replace event SSE with non-Hono Effect HTTP.
|
11. [ ] Replace event SSE with non-Hono Effect HTTP.
|
||||||
12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP.
|
12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
import { Permission } from "@/permission"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
|
import { SessionShare } from "@/share"
|
||||||
import { Session } from "@/session"
|
import { Session } from "@/session"
|
||||||
import { MessageV2 } from "@/session/message-v2"
|
import { MessageV2 } from "@/session/message-v2"
|
||||||
|
import { SessionPrompt } from "@/session/prompt"
|
||||||
import { SessionStatus } from "@/session/status"
|
import { SessionStatus } from "@/session/status"
|
||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
import { Todo } from "@/session/todo"
|
import { Todo } from "@/session/todo"
|
||||||
|
|
@ -26,6 +30,18 @@ const MessagesQuery = Schema.Struct({
|
||||||
before: Schema.optional(Schema.String),
|
before: Schema.optional(Schema.String),
|
||||||
})
|
})
|
||||||
const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
|
const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
|
||||||
|
const UpdatePayload = Schema.Struct({
|
||||||
|
title: Schema.optional(Schema.String),
|
||||||
|
permission: Schema.optional(Permission.Ruleset),
|
||||||
|
time: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
archived: Schema.optional(Schema.Number),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}).annotate({ identifier: "SessionUpdateInput" })
|
||||||
|
const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({
|
||||||
|
identifier: "SessionForkInput",
|
||||||
|
})
|
||||||
|
|
||||||
export const SessionPaths = {
|
export const SessionPaths = {
|
||||||
list: root,
|
list: root,
|
||||||
|
|
@ -36,6 +52,11 @@ export const SessionPaths = {
|
||||||
diff: `${root}/:sessionID/diff`,
|
diff: `${root}/:sessionID/diff`,
|
||||||
messages: `${root}/:sessionID/message`,
|
messages: `${root}/:sessionID/message`,
|
||||||
message: `${root}/:sessionID/message/:messageID`,
|
message: `${root}/:sessionID/message/:messageID`,
|
||||||
|
create: root,
|
||||||
|
remove: `${root}/:sessionID`,
|
||||||
|
update: `${root}/:sessionID`,
|
||||||
|
fork: `${root}/:sessionID/fork`,
|
||||||
|
abort: `${root}/:sessionID/abort`,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const SessionApi = HttpApi.make("session")
|
export const SessionApi = HttpApi.make("session")
|
||||||
|
|
@ -123,6 +144,58 @@ export const SessionApi = HttpApi.make("session")
|
||||||
description: "Retrieve a specific message from a session by its message ID.",
|
description: "Retrieve a specific message from a session by its message ID.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
HttpApiEndpoint.post("create", SessionPaths.create, {
|
||||||
|
payload: Session.CreateInput,
|
||||||
|
success: Session.Info,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "session.create",
|
||||||
|
summary: "Create session",
|
||||||
|
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
|
||||||
|
params: { sessionID: SessionID },
|
||||||
|
success: Schema.Boolean,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "session.delete",
|
||||||
|
summary: "Delete session",
|
||||||
|
description: "Delete a session and permanently remove all associated data, including messages and history.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HttpApiEndpoint.patch("update", SessionPaths.update, {
|
||||||
|
params: { sessionID: SessionID },
|
||||||
|
payload: UpdatePayload,
|
||||||
|
success: Session.Info,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "session.update",
|
||||||
|
summary: "Update session",
|
||||||
|
description: "Update properties of an existing session, such as title or other metadata.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HttpApiEndpoint.post("fork", SessionPaths.fork, {
|
||||||
|
params: { sessionID: SessionID },
|
||||||
|
payload: ForkPayload,
|
||||||
|
success: Session.Info,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "session.fork",
|
||||||
|
summary: "Fork session",
|
||||||
|
description: "Create a new session by forking an existing session at a specific message point.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HttpApiEndpoint.post("abort", SessionPaths.abort, {
|
||||||
|
params: { sessionID: SessionID },
|
||||||
|
success: Schema.Boolean,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "session.abort",
|
||||||
|
summary: "Abort session",
|
||||||
|
description: "Abort an active session and stop any ongoing AI processing or command execution.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
|
|
@ -222,6 +295,86 @@ export const sessionHandlers = Layer.unwrap(
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload: Session.CreateInput }) {
|
||||||
|
const instance = yield* InstanceState.context
|
||||||
|
return yield* Effect.promise(() =>
|
||||||
|
Instance.restore(instance, () =>
|
||||||
|
AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||||
|
const instance = yield* InstanceState.context
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Instance.restore(instance, () =>
|
||||||
|
AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = Effect.fn("SessionHttpApi.update")(function* (ctx: {
|
||||||
|
params: { sessionID: SessionID }
|
||||||
|
payload: typeof UpdatePayload.Type
|
||||||
|
}) {
|
||||||
|
const instance = yield* InstanceState.context
|
||||||
|
return yield* Effect.promise(() =>
|
||||||
|
Instance.restore(instance, () =>
|
||||||
|
AppRuntime.runPromise(
|
||||||
|
Session.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const current = yield* svc.get(ctx.params.sessionID)
|
||||||
|
if (ctx.payload.title !== undefined) {
|
||||||
|
yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
|
||||||
|
}
|
||||||
|
if (ctx.payload.permission !== undefined) {
|
||||||
|
yield* svc.setPermission({
|
||||||
|
sessionID: ctx.params.sessionID,
|
||||||
|
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (ctx.payload.time?.archived !== undefined) {
|
||||||
|
yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
|
||||||
|
}
|
||||||
|
return yield* svc.get(ctx.params.sessionID)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(Session.defaultLayer)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
|
||||||
|
params: { sessionID: SessionID }
|
||||||
|
payload: typeof ForkPayload.Type
|
||||||
|
}) {
|
||||||
|
const instance = yield* InstanceState.context
|
||||||
|
return yield* Effect.promise(() =>
|
||||||
|
Instance.restore(instance, () =>
|
||||||
|
AppRuntime.runPromise(
|
||||||
|
Session.Service.use((svc) => svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })).pipe(
|
||||||
|
Effect.provide(Session.defaultLayer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||||
|
const instance = yield* InstanceState.context
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Instance.restore(instance, () =>
|
||||||
|
AppRuntime.runPromise(
|
||||||
|
SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe(
|
||||||
|
Effect.provide(SessionPrompt.defaultLayer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
|
return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
|
||||||
handlers
|
handlers
|
||||||
.handle("list", list)
|
.handle("list", list)
|
||||||
|
|
@ -231,7 +384,12 @@ export const sessionHandlers = Layer.unwrap(
|
||||||
.handle("todo", todo)
|
.handle("todo", todo)
|
||||||
.handle("diff", diff)
|
.handle("diff", diff)
|
||||||
.handle("messages", messages)
|
.handle("messages", messages)
|
||||||
.handle("message", message),
|
.handle("message", message)
|
||||||
|
.handle("create", create)
|
||||||
|
.handle("remove", remove)
|
||||||
|
.handle("update", update)
|
||||||
|
.handle("fork", fork)
|
||||||
|
.handle("abort", abort),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||||
app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
|
app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
|
||||||
app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
|
app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
|
||||||
app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
|
app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
|
||||||
|
app.post(SessionPaths.create, (c) => handler(c.req.raw, context))
|
||||||
|
app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context))
|
||||||
|
app.patch(SessionPaths.update, (c) => handler(c.req.raw, context))
|
||||||
|
app.post(SessionPaths.fork, (c) => handler(c.req.raw, context))
|
||||||
|
app.post(SessionPaths.abort, (c) => handler(c.req.raw, context))
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
|
|
@ -129,4 +129,48 @@ describe("session HttpApi", () => {
|
||||||
),
|
),
|
||||||
).toMatchObject({ info: { id: message.id } })
|
).toMatchObject({ info: { id: message.id } })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("serves lifecycle mutation routes through Hono bridge", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false, share: "disabled" } })
|
||||||
|
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||||
|
|
||||||
|
const created = await json<Session.Info>(
|
||||||
|
await app().request(SessionPaths.create, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ title: "created" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(created.title).toBe("created")
|
||||||
|
|
||||||
|
const updated = await json<Session.Info>(
|
||||||
|
await app().request(pathFor(SessionPaths.update, { sessionID: created.id }), {
|
||||||
|
method: "PATCH",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ title: "updated", time: { archived: 1 } }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } })
|
||||||
|
|
||||||
|
const forked = await json<Session.Info>(
|
||||||
|
await app().request(pathFor(SessionPaths.fork, { sessionID: created.id }), {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(forked.id).not.toBe(created.id)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await json<boolean>(
|
||||||
|
await app().request(pathFor(SessionPaths.abort, { sessionID: created.id }), { method: "POST", headers }),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await json<boolean>(
|
||||||
|
await app().request(pathFor(SessionPaths.remove, { sessionID: created.id }), { method: "DELETE", headers }),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue