mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 07:38:28 +00:00
feat(server): durable log reads, changes feed, and watermarked snapshots (#34962)
This commit is contained in:
parent
33705e632a
commit
bc2e270f82
28 changed files with 1352 additions and 1555 deletions
|
|
@ -4,6 +4,8 @@ export * from "./generated-effect/index"
|
|||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Command } from "@opencode-ai/schema/command"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { Event } from "@opencode-ai/schema/event"
|
||||
export { EventLog } from "@opencode-ai/schema/event-log"
|
||||
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
|
|
|
|||
|
|
@ -80,10 +80,7 @@ const Endpoint4_1 = (raw: RawClient["server.session"]) => (input?: Endpoint4_1In
|
|||
)
|
||||
|
||||
const Endpoint4_2 = (raw: RawClient["server.session"]) => () =>
|
||||
raw["session.active"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
raw["session.active"]({}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"]["sessionID"] }
|
||||
|
|
@ -282,47 +279,39 @@ const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20I
|
|||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||
type Endpoint4_21Input = {
|
||||
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint4_21Request["query"]["limit"]
|
||||
readonly after?: Endpoint4_21Request["query"]["after"]
|
||||
readonly follow?: Endpoint4_21Request["query"]["follow"]
|
||||
}
|
||||
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
|
||||
raw["session.history"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], after: input["after"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint4_22Input = {
|
||||
readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_22Request["query"]["after"]
|
||||
}
|
||||
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { after: input["after"], follow: input["follow"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||
),
|
||||
)
|
||||
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
|
||||
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] }
|
||||
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
|
||||
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
|
||||
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_25Input = {
|
||||
readonly sessionID: Endpoint4_25Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_25Request["params"]["messageID"]
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_24Input = {
|
||||
readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_24Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) =>
|
||||
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
|
|
@ -350,11 +339,10 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
|
|||
listContextEntries: Endpoint4_18(raw),
|
||||
putContextEntry: Endpoint4_19(raw),
|
||||
removeContextEntry: Endpoint4_20(raw),
|
||||
history: Endpoint4_21(raw),
|
||||
events: Endpoint4_22(raw),
|
||||
interrupt: Endpoint4_23(raw),
|
||||
background: Endpoint4_24(raw),
|
||||
message: Endpoint4_25(raw),
|
||||
log: Endpoint4_21(raw),
|
||||
interrupt: Endpoint4_22(raw),
|
||||
background: Endpoint4_23(raw),
|
||||
message: Endpoint4_24(raw),
|
||||
})
|
||||
|
||||
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
|
|
@ -696,7 +684,15 @@ const Endpoint17_0 = (raw: RawClient["server.event"]) => () =>
|
|||
),
|
||||
)
|
||||
|
||||
const adaptGroup17 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint17_0(raw) })
|
||||
const Endpoint17_1 = (raw: RawClient["server.event"]) => () =>
|
||||
Stream.unwrap(
|
||||
raw["event.changes"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup17 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint17_0(raw), changes: Endpoint17_1(raw) })
|
||||
|
||||
type Endpoint18_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
||||
type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
|
||||
|
|
|
|||
|
|
@ -47,10 +47,8 @@ import type {
|
|||
SessionPutContextEntryOutput,
|
||||
SessionRemoveContextEntryInput,
|
||||
SessionRemoveContextEntryOutput,
|
||||
SessionHistoryInput,
|
||||
SessionHistoryOutput,
|
||||
SessionEventsInput,
|
||||
SessionEventsOutput,
|
||||
SessionLogInput,
|
||||
SessionLogOutput,
|
||||
SessionInterruptInput,
|
||||
SessionInterruptOutput,
|
||||
SessionBackgroundInput,
|
||||
|
|
@ -118,6 +116,7 @@ import type {
|
|||
SkillListInput,
|
||||
SkillListOutput,
|
||||
EventSubscribeOutput,
|
||||
EventChangesOutput,
|
||||
PtyListInput,
|
||||
PtyListOutput,
|
||||
PtyCreateInput,
|
||||
|
|
@ -380,7 +379,7 @@ export function make(options: ClientOptions) {
|
|||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
active: (requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionActiveOutput }>(
|
||||
request<SessionActiveOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/active`,
|
||||
|
|
@ -389,7 +388,7 @@ export function make(options: ClientOptions) {
|
|||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
),
|
||||
get: (input: SessionGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionGetOutput }>(
|
||||
{
|
||||
|
|
@ -608,24 +607,12 @@ export function make(options: ClientOptions) {
|
|||
},
|
||||
requestOptions,
|
||||
),
|
||||
history: (input: SessionHistoryInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionHistoryOutput>(
|
||||
log: (input: SessionLogInput, requestOptions?: RequestOptions): AsyncIterable<SessionLogOutput> =>
|
||||
sse<SessionLogOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
|
||||
query: { limit: input["limit"], after: input["after"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
events: (input: SessionEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionEventsOutput> =>
|
||||
sse<SessionEventsOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
|
||||
query: { after: input["after"] },
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/log`,
|
||||
query: { after: input["after"], follow: input["follow"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
|
|
@ -1067,6 +1054,11 @@ export function make(options: ClientOptions) {
|
|||
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
changes: (requestOptions?: RequestOptions): AsyncIterable<EventChangesOutput> =>
|
||||
sse<EventChangesOutput>(
|
||||
{ method: "GET", path: `/api/event/changes`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
pty: {
|
||||
list: (input?: PtyListInput, requestOptions?: RequestOptions) =>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,9 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
|
||||
import { AbsolutePath, Agent, Event, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
|
||||
|
||||
const caughtUp = { type: "log.caught_up" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
|
||||
|
||||
test("session.get returns the decoded Effect projection", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
|
|
@ -60,32 +62,20 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
|
|||
})
|
||||
|
||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const historyQueries: Array<Record<string, string>> = []
|
||||
let historyPage = 0
|
||||
const logQueries: Array<Record<string, string>> = []
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const url = request.url
|
||||
if (url.includes("/event")) {
|
||||
if (url.includes("/log")) {
|
||||
logQueries.push(Object.fromEntries(request.urlParams.params))
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
||||
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(caughtUp)}\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (url.includes("/history")) {
|
||||
historyPage++
|
||||
historyQueries.push(Object.fromEntries(request.urlParams.params))
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (url.includes("/prompt")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
||||
}
|
||||
|
|
@ -97,7 +87,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||
}
|
||||
if (url.endsWith("/api/session/active")) {
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } }),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (request.method === "POST" && url.endsWith("/api/session")) {
|
||||
|
|
@ -107,7 +100,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
|
||||
}
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json({ data: [session.data], watermarks: { ses_test: 3 }, cursor: { next: "next" } }),
|
||||
),
|
||||
)
|
||||
})
|
||||
const result = await Effect.gen(function* () {
|
||||
|
|
@ -130,31 +126,20 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||
yield* client.session.compact({ sessionID: Session.ID.make("ses_test") })
|
||||
yield* client.session.wait({ sessionID: Session.ID.make("ses_test") })
|
||||
const context = yield* client.session.context({ sessionID: Session.ID.make("ses_test") })
|
||||
const history = yield* client.session.history({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
after: 0,
|
||||
limit: 1,
|
||||
})
|
||||
const historyNext = history.hasMore
|
||||
? yield* client.session.history({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
after: history.data.at(-1)?.durable?.seq,
|
||||
limit: 2,
|
||||
})
|
||||
: undefined
|
||||
const events = yield* client.session
|
||||
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||
const log = yield* client.session
|
||||
.log({ sessionID: Session.ID.make("ses_test"), after: Event.Seq.make(0) })
|
||||
.pipe(Stream.runCollect)
|
||||
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||
const message = yield* client.session.message({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_model"),
|
||||
})
|
||||
return { page, active, created, admitted, context, history, historyNext, events, message }
|
||||
return { page, active, created, admitted, context, log, message }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||
expect(result.active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(result.active).toEqual({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } })
|
||||
expect(result.page.watermarks).toEqual({ ses_test: 3 })
|
||||
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
||||
expect(result.created.id).toBe("ses_test")
|
||||
|
|
@ -162,16 +147,17 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||
expect(result.context).toEqual([])
|
||||
expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
|
||||
expect(result.historyNext).toEqual({ data: [], hasMore: false })
|
||||
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
|
||||
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
|
||||
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||
expect(logQueries[0]).toEqual({ after: "0" })
|
||||
const logged = Array.from(result.log)
|
||||
expect(logged.map((item) => item.type)).toEqual(["session.next.model.switched", "log.caught_up"])
|
||||
expect(logged[0]?.type === "session.next.model.switched" && DateTime.toEpochMillis(logged[0].data.timestamp)).toBe(
|
||||
1_717_171_717_000,
|
||||
)
|
||||
expect(logged.at(-1)).toEqual(caughtUp)
|
||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||
})
|
||||
|
||||
test("session.history retains the typed SessionNotFoundError", async () => {
|
||||
test("session.log retains the typed SessionNotFoundError", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
|
|
@ -185,11 +171,7 @@ test("session.history retains the typed SessionNotFoundError", async () => {
|
|||
)
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.session
|
||||
.history({
|
||||
sessionID: Session.ID.make("ses_missing"),
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
return yield* client.session.log({ sessionID: Session.ID.make("ses_missing") }).pipe(Stream.runCollect, Effect.flip)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(error._tag).toBe("SessionNotFoundError")
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ test("exposes every standard HTTP API group", () => {
|
|||
"health",
|
||||
"location",
|
||||
"agent",
|
||||
"plugin",
|
||||
"session",
|
||||
"message",
|
||||
"model",
|
||||
"generate",
|
||||
"provider",
|
||||
"integration",
|
||||
"server.mcp",
|
||||
"credential",
|
||||
"project",
|
||||
"permission",
|
||||
|
|
@ -172,7 +174,6 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
|||
|
||||
test("session methods use the public HTTP contract", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
let historyPage = 0
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
|
|
@ -183,16 +184,16 @@ test("session methods use the public HTTP contract", async () => {
|
|||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
if (url.includes("/history")) {
|
||||
historyPage++
|
||||
return Response.json(
|
||||
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||
)
|
||||
if (url.includes("/log")) {
|
||||
return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(caughtUp)}\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
if (url.includes("/prompt")) return Response.json(admission)
|
||||
if (url.includes("/context")) return Response.json({ data: [] })
|
||||
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||
if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
|
||||
if (url.endsWith("/api/session/active"))
|
||||
return Response.json({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } })
|
||||
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
|
||||
if (init?.method === "POST") return new Response(null, { status: 204 })
|
||||
return Response.json({ data: [session.data], cursor: { next: "next" } })
|
||||
|
|
@ -215,24 +216,17 @@ test("session methods use the public HTTP contract", async () => {
|
|||
await client.session.compact({ sessionID: "ses_test" })
|
||||
await client.session.wait({ sessionID: "ses_test" })
|
||||
const context = await client.session.context({ sessionID: "ses_test" })
|
||||
const history = await client.session.history({ sessionID: "ses_test", after: 0, limit: 1 })
|
||||
const historyAfter = history.data.at(-1)?.durable?.seq
|
||||
const historyNext = history.hasMore
|
||||
? await client.session.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
|
||||
: undefined
|
||||
const events = []
|
||||
for await (const event of client.session.events({ sessionID: "ses_test", after: 0 })) events.push(event)
|
||||
const log = []
|
||||
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
|
||||
await client.session.interrupt({ sessionID: "ses_test" })
|
||||
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(active).toEqual({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } })
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(context).toEqual([])
|
||||
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
|
||||
expect(historyNext).toEqual({ data: [], hasMore: false })
|
||||
expect(events).toEqual([modelSwitchedEvent])
|
||||
expect(log).toEqual([modelSwitchedEvent, caughtUp])
|
||||
expect(message).toEqual(modelSwitchedMessage)
|
||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
|
||||
|
|
@ -244,9 +238,7 @@ test("session methods use the public HTTP contract", async () => {
|
|||
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/log?after=0"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||
])
|
||||
|
|
@ -273,7 +265,7 @@ test("middleware errors remain declared client errors", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("session.history decodes SessionNotFoundError", async () => {
|
||||
test("session.log decodes SessionNotFoundError", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
|
|
@ -284,7 +276,7 @@ test("session.history decodes SessionNotFoundError", async () => {
|
|||
})
|
||||
|
||||
try {
|
||||
await client.session.history({ sessionID: "ses_missing" })
|
||||
await client.session.log({ sessionID: "ses_missing" })[Symbol.asyncIterator]().next()
|
||||
throw new Error("Expected request to fail")
|
||||
} catch (error) {
|
||||
expect(isSessionNotFoundError(error)).toBe(true)
|
||||
|
|
@ -329,6 +321,8 @@ const modelSwitchedMessage = {
|
|||
model: { id: "claude", providerID: "anthropic" },
|
||||
}
|
||||
|
||||
const caughtUp = { type: "log.caught_up", aggregateID: "ses_test", seq: 1 }
|
||||
|
||||
const modelSwitchedEvent = {
|
||||
id: "evt_model",
|
||||
type: "session.next.model.switched",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * as EventV2 from "./event"
|
|||
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
|
|
@ -13,6 +14,10 @@ import { Durable } from "@opencode-ai/schema/durable-event-manifest"
|
|||
|
||||
export const ID = Event.ID
|
||||
export type ID = import("@opencode-ai/schema/event").ID
|
||||
export const Seq = Event.Seq
|
||||
export type Seq = import("@opencode-ai/schema/event").Seq
|
||||
export const Version = Event.Version
|
||||
export type Version = import("@opencode-ai/schema/event").Version
|
||||
export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||
|
||||
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||
|
|
@ -63,6 +68,12 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
|
|||
},
|
||||
) {}
|
||||
|
||||
const envelope = (aggregateID: string, seq: number, version: number) => ({
|
||||
aggregateID,
|
||||
seq: Seq.make(seq),
|
||||
version: Version.make(version),
|
||||
})
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||
const definition = Durable.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
|
|
@ -71,58 +82,11 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
|||
return {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||
durable: envelope(event.aggregateID, event.seq, definition.durable.version),
|
||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const readAggregate = Effect.fn("EventV2.readAggregate")(function* <A>(
|
||||
db: Database.Interface["db"],
|
||||
input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly limit: number
|
||||
readonly manifest: {
|
||||
readonly definitions: ReadonlyMap<string, Definition>
|
||||
readonly schema: Schema.Decoder<A, never>
|
||||
}
|
||||
},
|
||||
) {
|
||||
const after = input.after ?? -1
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, input.aggregateID),
|
||||
gt(EventTable.seq, after),
|
||||
inArray(EventTable.type, Array.from(input.manifest.definitions.keys())),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.limit(input.limit + 1)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const page = rows.slice(0, input.limit)
|
||||
const decode = Schema.decodeUnknownSync(input.manifest.schema)
|
||||
const events = page.map((event) =>
|
||||
decode({
|
||||
id: event.id,
|
||||
type: input.manifest.definitions.get(event.type)?.type ?? event.type,
|
||||
durable: {
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
version: input.manifest.definitions.get(event.type)?.durable?.version,
|
||||
},
|
||||
data: event.data,
|
||||
}),
|
||||
)
|
||||
return {
|
||||
events,
|
||||
hasMore: rows.length > input.limit,
|
||||
}
|
||||
})
|
||||
|
||||
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
|
||||
"EventV2.SubscriberOverflow",
|
||||
{ capacity: Schema.Int },
|
||||
|
|
@ -139,6 +103,11 @@ export interface PublishOptions {
|
|||
readonly commit?: (seq: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** Marker/event union emitted by `log`. Markers carry no event `id`. */
|
||||
export type LogItem = Payload | EventLog.CaughtUp
|
||||
|
||||
export const isCaughtUp = (item: LogItem): item is EventLog.CaughtUp => !("id" in item)
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends Definition>(
|
||||
definition: D,
|
||||
|
|
@ -146,8 +115,31 @@ export interface Interface {
|
|||
options?: PublishOptions,
|
||||
) => Effect.Effect<Payload<D>>
|
||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||
readonly all: () => Stream.Stream<Payload>
|
||||
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
|
||||
/**
|
||||
* Volatile live channel: every event published from now on, nothing before,
|
||||
* nothing across a disconnect. The only channel that carries non-durable
|
||||
* events; consumers that need reliability combine `changes` with `log`.
|
||||
*/
|
||||
readonly live: () => Stream.Stream<Payload>
|
||||
/**
|
||||
* Durable, ordered, gap-free per-aggregate log read. `follow: false`
|
||||
* completes at the end of the log; `follow: true` replays then transitions
|
||||
* to live. Both modes emit a `CaughtUp` marker at the replay boundary; the
|
||||
* marker may be re-emitted after internal re-attaches.
|
||||
*/
|
||||
readonly log: (input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly follow?: boolean
|
||||
}) => Stream.Stream<LogItem>
|
||||
/**
|
||||
* Coalescing hint channel: latest committed seq per aggregate, never a
|
||||
* delivery guarantee. Emits `SweepRequired` first on every subscribe and
|
||||
* whenever per-key retention is exceeded. Never fails under backpressure.
|
||||
*/
|
||||
readonly changes: () => Stream.Stream<EventLog.Change>
|
||||
/** Latest committed seq per aggregate. Aggregates without events are absent. */
|
||||
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Seq>>
|
||||
/** @deprecated Use `all()` and consume the returned stream. */
|
||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||
|
|
@ -165,7 +157,7 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||
|
||||
export const allBounded = (events: Interface, capacity: number) =>
|
||||
export const liveBounded = (events: Interface, capacity: number) =>
|
||||
Effect.gen(function* () {
|
||||
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
|
|
@ -181,6 +173,11 @@ export const allBounded = (events: Interface, capacity: number) =>
|
|||
|
||||
export interface LayerOptions {
|
||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||
/**
|
||||
* Maximum distinct aggregates buffered per changes subscriber before the
|
||||
* buffer is abandoned and the subscriber is told to sweep.
|
||||
*/
|
||||
readonly changesKeyCapacity?: number
|
||||
}
|
||||
|
||||
export const layerWith = (options?: LayerOptions) =>
|
||||
|
|
@ -188,13 +185,19 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const pubsub = {
|
||||
all: yield* PubSub.unbounded<Payload>(),
|
||||
live: yield* PubSub.unbounded<Payload>(),
|
||||
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
|
||||
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
||||
}
|
||||
const projectors = new Map<string, Subscriber[]>()
|
||||
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
|
||||
const listeners = new Array<Subscriber>()
|
||||
const changesKeyCapacity = options?.changesKeyCapacity ?? 4096
|
||||
const changesSubscribers = new Set<{
|
||||
readonly hints: Map<string, number>
|
||||
sweepRequired: boolean
|
||||
readonly wake: PubSub.PubSub<void>
|
||||
}>()
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
const getOrCreate = (definition: Definition) =>
|
||||
|
|
@ -208,13 +211,16 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* PubSub.shutdown(pubsub.all)
|
||||
yield* PubSub.shutdown(pubsub.live)
|
||||
yield* Effect.forEach(
|
||||
pubsub.durable.values(),
|
||||
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
|
||||
yield* Effect.forEach(changesSubscribers, (subscriber) => PubSub.shutdown(subscriber.wake), {
|
||||
discard: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -373,6 +379,27 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
(wake) => PubSub.publish(wake, undefined),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
changesSubscribers,
|
||||
(subscriber) =>
|
||||
Effect.sync(() => {
|
||||
// Coalesce to the latest seq per aggregate. Overflowing key
|
||||
// cardinality abandons the buffer instead of dropping hints silently.
|
||||
if (
|
||||
subscriber.hints.size >= changesKeyCapacity &&
|
||||
!subscriber.hints.has(committed.aggregateID)
|
||||
) {
|
||||
subscriber.hints.clear()
|
||||
subscriber.sweepRequired = true
|
||||
} else if (!subscriber.sweepRequired) {
|
||||
subscriber.hints.set(
|
||||
committed.aggregateID,
|
||||
Math.max(subscriber.hints.get(committed.aggregateID) ?? -1, committed.seq),
|
||||
)
|
||||
}
|
||||
}).pipe(Effect.andThen(PubSub.publish(subscriber.wake, undefined)), Effect.asVoid),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
return committed
|
||||
}),
|
||||
|
|
@ -396,11 +423,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
if (committed) {
|
||||
event = {
|
||||
...event,
|
||||
durable: {
|
||||
aggregateID: committed.aggregateID,
|
||||
seq: committed.seq,
|
||||
version: definition.durable.version,
|
||||
},
|
||||
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
|
||||
}
|
||||
yield* notify(event as Payload, true)
|
||||
return event
|
||||
|
|
@ -428,7 +451,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
)
|
||||
const typed = pubsub.typed.get(event.type)
|
||||
if (typed) yield* PubSub.publish(typed, event)
|
||||
yield* PubSub.publish(pubsub.all, event)
|
||||
yield* PubSub.publish(pubsub.live, event)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -480,11 +503,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
yield* notify(
|
||||
{
|
||||
...payload,
|
||||
durable: {
|
||||
aggregateID: committed.aggregateID,
|
||||
seq: committed.seq,
|
||||
version: definition.durable.version,
|
||||
},
|
||||
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
|
@ -552,7 +571,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
Stream.map((event) => event as Payload<D>),
|
||||
)
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
||||
const streamLive = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.live)
|
||||
|
||||
const readAfter = (aggregateID: string, after: number) =>
|
||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||
|
|
@ -565,17 +584,24 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
.all(),
|
||||
),
|
||||
Effect.orDie,
|
||||
Effect.map((rows) =>
|
||||
rows.map((event) =>
|
||||
decodeSerializedEvent({
|
||||
id: event.id,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
}),
|
||||
),
|
||||
),
|
||||
// Skip types missing from the durable manifest instead of failing the
|
||||
// read: the aggregate may hold events this process cannot decode. The
|
||||
// raw tail seq keeps cursors advancing across the resulting gaps.
|
||||
Effect.map((rows) => ({
|
||||
seq: rows.at(-1)?.seq,
|
||||
events: rows.flatMap((event) => {
|
||||
if (!Durable.get(event.type)?.durable) return []
|
||||
return [
|
||||
decodeSerializedEvent({
|
||||
id: event.id,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
}),
|
||||
]
|
||||
}),
|
||||
})),
|
||||
)
|
||||
|
||||
const subscribeDurable = (aggregateID: string) =>
|
||||
|
|
@ -598,27 +624,95 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
return subscription
|
||||
})
|
||||
|
||||
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
|
||||
const log = (input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly follow?: boolean
|
||||
}): Stream.Stream<LogItem> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const wakes = yield* subscribeDurable(input.aggregateID)
|
||||
let sequence = input.after ?? -1
|
||||
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
|
||||
Effect.tap((events) =>
|
||||
Effect.tap((page) =>
|
||||
Effect.sync(() => {
|
||||
sequence = events.at(-1)?.durable?.seq ?? sequence
|
||||
sequence = page.seq ?? sequence
|
||||
}),
|
||||
),
|
||||
Effect.map((page) => page.events),
|
||||
)
|
||||
// Subscribing before the historical read means events committed during
|
||||
// replay either appear in the read or arrive through a post-marker wake.
|
||||
const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
|
||||
const historical = yield* read
|
||||
const marker: EventLog.CaughtUp = {
|
||||
type: "log.caught_up",
|
||||
aggregateID: input.aggregateID,
|
||||
...(sequence >= 0 ? { seq: Seq.make(sequence) } : {}),
|
||||
}
|
||||
const replay = Stream.fromIterable<LogItem>(historical).pipe(Stream.concat(Stream.make(marker)))
|
||||
if (!wakes) return replay
|
||||
const live = Stream.fromSubscription(wakes).pipe(
|
||||
Stream.mapEffect(() => read),
|
||||
Stream.flattenIterable,
|
||||
)
|
||||
return Stream.concat(Stream.fromIterable(historical), live)
|
||||
return Stream.concat(replay, live)
|
||||
}),
|
||||
)
|
||||
|
||||
const changes = (): Stream.Stream<EventLog.Change> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* PubSub.sliding<void>(1)
|
||||
const subscription = yield* PubSub.subscribe(wake)
|
||||
const subscriber = { hints: new Map<string, number>(), sweepRequired: false, wake }
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => changesSubscribers.add(subscriber)),
|
||||
() =>
|
||||
Effect.sync(() => changesSubscribers.delete(subscriber)).pipe(
|
||||
Effect.andThen(PubSub.shutdown(wake)),
|
||||
Effect.asVoid,
|
||||
),
|
||||
)
|
||||
const drain = Effect.sync((): ReadonlyArray<EventLog.Change> => {
|
||||
if (subscriber.sweepRequired) {
|
||||
subscriber.sweepRequired = false
|
||||
subscriber.hints.clear()
|
||||
return [{ type: "log.sweep_required" }]
|
||||
}
|
||||
const hints = Array.from(
|
||||
subscriber.hints,
|
||||
([aggregateID, seq]): EventLog.Change => ({ type: "log.hint", aggregateID, seq: Seq.make(seq) }),
|
||||
)
|
||||
subscriber.hints.clear()
|
||||
return hints
|
||||
})
|
||||
// Hints missed while unsubscribed were never buffered, so every
|
||||
// (re)subscribe starts from the sweep contract.
|
||||
const initial: EventLog.Change = { type: "log.sweep_required" }
|
||||
return Stream.make(initial).pipe(
|
||||
Stream.concat(
|
||||
Stream.fromSubscription(subscription).pipe(
|
||||
Stream.mapEffect(() => drain),
|
||||
Stream.flattenIterable,
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Seq>> => {
|
||||
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
|
||||
return db
|
||||
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(inArray(EventSequenceTable.aggregate_id, Array.from(aggregateIDs)))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Seq.make(row.seq)]))),
|
||||
)
|
||||
}
|
||||
|
||||
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
listeners.push(listener)
|
||||
|
|
@ -638,8 +732,10 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
return Service.of({
|
||||
publish,
|
||||
subscribe,
|
||||
all: streamAll,
|
||||
durable,
|
||||
live: streamLive,
|
||||
log,
|
||||
changes,
|
||||
sequences,
|
||||
listen,
|
||||
project,
|
||||
replay,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import { SessionCompaction } from "./session/compaction"
|
|||
import { SessionRevert } from "./session/revert"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { Job } from "./job"
|
||||
import { CommandV2 } from "./command"
|
||||
|
|
@ -136,7 +136,11 @@ export type Error =
|
|||
| MessageNotFoundError
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||
readonly data: SessionSchema.Info[]
|
||||
/** Per-session durable log watermark, read in the same transaction as the snapshot. Sessions without events are absent. */
|
||||
readonly watermarks: ReadonlyMap<string, EventV2.Seq>
|
||||
}>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError>
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
|
|
@ -156,15 +160,20 @@ export interface Interface {
|
|||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
readonly events: (input: {
|
||||
/**
|
||||
* Durable, ordered, gap-free session log read. Replays public durable
|
||||
* session events after the exclusive `after` cursor, emits a `CaughtUp`
|
||||
* marker at the replay boundary, then continues live when `follow` is set.
|
||||
* The marker's seq may exceed the last emitted event because non-public
|
||||
* durable events share the aggregate's sequence space.
|
||||
*/
|
||||
readonly log: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||
readonly history: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
limit: number
|
||||
}) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
|
||||
follow?: boolean
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.CaughtUp, NotFoundError>
|
||||
/** Latest durable log seq per session. Sessions without events are absent. */
|
||||
readonly watermarks: (sessionIDs: ReadonlyArray<SessionSchema.ID>) => Effect.Effect<ReadonlyMap<string, EventV2.Seq>>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
|
|
@ -189,7 +198,10 @@ export interface Interface {
|
|||
agents?: PromptInput.Prompt["agents"]
|
||||
delivery?: SessionInput.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError | CommandV2.NotFoundError | CommandV2.EvaluationError>
|
||||
}) => Effect.Effect<
|
||||
SessionInput.Admitted,
|
||||
NotFoundError | PromptConflictError | CommandV2.NotFoundError | CommandV2.EvaluationError
|
||||
>
|
||||
readonly shell: (input: {
|
||||
id?: EventV2.ID
|
||||
sessionID: SessionSchema.ID
|
||||
|
|
@ -373,10 +385,21 @@ const layer = Layer.effect(
|
|||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||
// Watermarks must pair with the snapshot exactly, so both reads share a transaction:
|
||||
// a higher watermark would let an attached tail skip events missing from the snapshot.
|
||||
const snapshot = yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
const watermarks = yield* events.sequences(rows.map((row) => row.id))
|
||||
return { rows, watermarks }
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const rows = direction === "previous" ? snapshot.rows.toReversed() : snapshot.rows
|
||||
return { data: rows.map((row) => fromRow(row)), watermarks: snapshot.watermarks }
|
||||
}),
|
||||
messages: Effect.fn("V2Session.messages")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
|
|
@ -420,19 +443,19 @@ const layer = Layer.effect(
|
|||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
events: (input) =>
|
||||
log: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||
history: Effect.fn("V2Session.history")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* EventV2.readAggregate(db, {
|
||||
...input,
|
||||
aggregateID: input.sessionID,
|
||||
manifest: SessionDurable,
|
||||
})
|
||||
.pipe(Effect.as(events.log({ aggregateID: input.sessionID, after: input.after, follow: input.follow }))),
|
||||
).pipe(
|
||||
Stream.filter(
|
||||
(item): item is SessionEvent.DurableEvent | EventLog.CaughtUp =>
|
||||
EventV2.isCaughtUp(item) || isDurableSessionEvent(item),
|
||||
),
|
||||
),
|
||||
watermarks: Effect.fn("V2Session.watermarks")(function* (sessionIDs) {
|
||||
return yield* events.sequences(sessionIDs)
|
||||
}),
|
||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
|
|
|
|||
|
|
@ -78,6 +78,12 @@ const durableData = (sessionID: Session.ID, text: string) => ({
|
|||
messageID: SessionV1.MessageID.ascending(`msg_${text}`),
|
||||
})
|
||||
|
||||
/** Followed log read without markers: the old `durable` stream shape. */
|
||||
const tail = (events: EventV2.Interface, input: { aggregateID: string; after?: number }) =>
|
||||
events
|
||||
.log({ ...input, follow: true })
|
||||
.pipe(Stream.filter((item): item is EventV2.Payload => !EventV2.isCaughtUp(item)))
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [[Location.node, locationLayer]]),
|
||||
)
|
||||
|
|
@ -119,7 +125,7 @@ describe("EventV2", () => {
|
|||
const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" })
|
||||
|
||||
expect(event.type).toBe("test.versioned")
|
||||
expect(event.durable?.version).toBe(2)
|
||||
expect(event.durable?.version).toBe(EventV2.Version.make(2))
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -145,7 +151,7 @@ describe("EventV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const typed = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
const wildcard = yield* events.all().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
const wildcard = yield* events.live().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const event = yield* events.publish(Message, { text: "hello" })
|
||||
|
||||
|
|
@ -226,7 +232,7 @@ describe("EventV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<string>()
|
||||
const fiber = yield* events.all().pipe(
|
||||
const fiber = yield* events.live().pipe(
|
||||
Stream.take(1),
|
||||
Stream.runForEach(() => Effect.sync(() => received.push("stream"))),
|
||||
Effect.forkScoped,
|
||||
|
|
@ -325,8 +331,8 @@ describe("EventV2", () => {
|
|||
const events = yield* EventV2.Service
|
||||
const consuming = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const slowStream = yield* EventV2.allBounded(events, 1)
|
||||
const fastStream = yield* EventV2.allBounded(events, 8)
|
||||
const slowStream = yield* EventV2.liveBounded(events, 1)
|
||||
const fastStream = yield* EventV2.liveBounded(events, 8)
|
||||
const slow = yield* slowStream.pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
||||
Effect.forkScoped,
|
||||
|
|
@ -425,9 +431,11 @@ describe("EventV2", () => {
|
|||
const aggregateID = Session.ID.create()
|
||||
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
const fiber = yield* events
|
||||
.durable({ aggregateID, after: 0 })
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
const fiber = yield* tail(events, { aggregateID, after: 0 }).pipe(
|
||||
Stream.take(2),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* events.publish(DurableMessage, durableData(aggregateID, "two"))
|
||||
|
|
@ -444,7 +452,7 @@ describe("EventV2", () => {
|
|||
const events = yield* EventV2.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
const fiber = yield* tail(events, { aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
|
||||
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
|
||||
|
|
@ -470,7 +478,7 @@ describe("EventV2", () => {
|
|||
yield* Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
const fiber = yield* tail(events, { aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Deferred.await(readStarted)
|
||||
|
||||
pause = false
|
||||
|
|
@ -489,9 +497,7 @@ describe("EventV2", () => {
|
|||
const events = yield* EventV2.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
const count = 64
|
||||
const fiber = yield* events
|
||||
.durable({ aggregateID })
|
||||
.pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
|
||||
const fiber = yield* tail(events, { aggregateID }).pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
|
|
@ -508,7 +514,7 @@ describe("EventV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
const fiber = yield* tail(events, { aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* events.publish(Message, { text: "live only" })
|
||||
|
|
@ -1121,4 +1127,125 @@ describe("EventV2", () => {
|
|||
expect(received[0]?.data).toEqual(durableData(aggregateID, "replayed"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("log without follow replays events and completes with a caught-up marker", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(events.log({ aggregateID })))
|
||||
|
||||
expect(items.map((item) => (EventV2.isCaughtUp(item) ? item.type : item.durable?.seq))).toEqual([
|
||||
EventV2.Seq.make(0),
|
||||
EventV2.Seq.make(1),
|
||||
"log.caught_up",
|
||||
])
|
||||
expect(items.at(-1)).toEqual({ type: "log.caught_up", aggregateID, seq: EventV2.Seq.make(1) })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("log caught-up marker omits seq for an empty log and keeps the cursor otherwise", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
|
||||
const empty = Array.from(yield* Stream.runCollect(events.log({ aggregateID })))
|
||||
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||
const drained = Array.from(yield* Stream.runCollect(events.log({ aggregateID, after: 0 })))
|
||||
|
||||
expect(empty).toEqual([{ type: "log.caught_up", aggregateID }])
|
||||
expect(empty[0]).not.toHaveProperty("seq")
|
||||
expect(drained).toEqual([{ type: "log.caught_up", aggregateID, seq: EventV2.Seq.make(0) }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("log with follow emits the caught-up marker at the replay-to-live boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||
const fiber = yield* events
|
||||
.log({ aggregateID, follow: true })
|
||||
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
|
||||
const items = Array.from(yield* Fiber.join(fiber))
|
||||
expect(items.map((item) => (EventV2.isCaughtUp(item) ? item : item.durable?.seq))).toEqual([
|
||||
EventV2.Seq.make(0),
|
||||
{ type: "log.caught_up", aggregateID, seq: EventV2.Seq.make(0) },
|
||||
EventV2.Seq.make(1),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("changes emits sweep-required on subscribe then coalesced hints per aggregate", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const first = Session.ID.create()
|
||||
const second = Session.ID.create()
|
||||
const pull = yield* Stream.toPull(events.changes())
|
||||
|
||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.sweep_required" }])
|
||||
|
||||
yield* events.publish(DurableMessage, durableData(first, "zero"))
|
||||
yield* events.publish(DurableMessage, durableData(first, "one"))
|
||||
yield* events.publish(DurableMessage, durableData(first, "two"))
|
||||
yield* events.publish(DurableMessage, durableData(second, "zero"))
|
||||
|
||||
expect(Array.from(yield* pull)).toEqual([
|
||||
{ type: "log.hint", aggregateID: first, seq: EventV2.Seq.make(2) },
|
||||
{ type: "log.hint", aggregateID: second, seq: EventV2.Seq.make(0) },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("changes abandons the hint buffer for a sweep when key retention is exceeded", () =>
|
||||
Effect.gen(function* () {
|
||||
const eventLayer = EventV2.layerWith({ changesKeyCapacity: 2 }).pipe(
|
||||
Layer.provide(LayerNode.compile(Database.node)),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const pull = yield* Stream.toPull(events.changes())
|
||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.sweep_required" }])
|
||||
|
||||
yield* events.publish(DurableMessage, durableData(Session.ID.create(), "a"))
|
||||
yield* events.publish(DurableMessage, durableData(Session.ID.create(), "b"))
|
||||
yield* events.publish(DurableMessage, durableData(Session.ID.create(), "c"))
|
||||
|
||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.sweep_required" }])
|
||||
|
||||
const late = Session.ID.create()
|
||||
yield* events.publish(DurableMessage, durableData(late, "d"))
|
||||
|
||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.hint", aggregateID: late, seq: EventV2.Seq.make(0) }])
|
||||
}).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer)))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sequences returns the latest committed seq per aggregate and omits unknown aggregates", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const first = Session.ID.create()
|
||||
const second = Session.ID.create()
|
||||
yield* events.publish(DurableMessage, durableData(first, "zero"))
|
||||
yield* events.publish(DurableMessage, durableData(first, "one"))
|
||||
yield* events.publish(DurableMessage, durableData(second, "zero"))
|
||||
|
||||
const sequences = yield* events.sequences([first, second, Session.ID.create()])
|
||||
|
||||
expect(sequences).toEqual(
|
||||
new Map([
|
||||
[first, EventV2.Seq.make(1)],
|
||||
[second, EventV2.Seq.make(0)],
|
||||
]),
|
||||
)
|
||||
expect(yield* events.sequences([])).toEqual(new Map())
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -49,6 +49,12 @@ const it = testEffect(
|
|||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const id = SessionV2.ID.create()
|
||||
|
||||
/** Public session events from a `log` read, without caught-up markers. */
|
||||
const logEvents = (session: SessionV2.Interface, sessionID: SessionV2.ID, follow?: boolean) =>
|
||||
session
|
||||
.log({ sessionID, follow })
|
||||
.pipe(Stream.filter((item): item is SessionEvent.DurableEvent => !EventV2.isCaughtUp(item)))
|
||||
|
||||
const assertCreateInputTypes = (session: SessionV2.Interface) => {
|
||||
// @ts-expect-error location or parentID is required.
|
||||
session.create({})
|
||||
|
|
@ -66,7 +72,7 @@ describe("SessionV2.create", () => {
|
|||
const second = yield* session.create({ location })
|
||||
|
||||
expect(second.id).not.toBe(first.id)
|
||||
expect(yield* session.list()).toHaveLength(2)
|
||||
expect((yield* session.list()).data).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -79,7 +85,7 @@ describe("SessionV2.create", () => {
|
|||
const retried = yield* session.create(input)
|
||||
|
||||
expect(retried).toEqual(first)
|
||||
expect(yield* session.list()).toEqual([first])
|
||||
expect((yield* session.list()).data).toEqual([first])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -146,7 +152,7 @@ describe("SessionV2.create", () => {
|
|||
const forked = yield* session.fork({ sessionID: parent.id })
|
||||
const parentContext = yield* session.context(parent.id)
|
||||
const forkContext = yield* session.context(forked.id)
|
||||
const history = yield* session.history({ sessionID: forked.id, limit: 10 })
|
||||
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
|
||||
|
||||
expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" })
|
||||
expect(forkContext).toMatchObject([
|
||||
|
|
@ -154,8 +160,8 @@ describe("SessionV2.create", () => {
|
|||
{ type: "synthetic", text: "parent note", sessionID: forked.id },
|
||||
])
|
||||
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
|
||||
expect(history.events).toHaveLength(1)
|
||||
expect(history.events[0]).toMatchObject({
|
||||
expect(history).toHaveLength(1)
|
||||
expect(history[0]).toMatchObject({
|
||||
type: "session.next.forked",
|
||||
durable: { seq: 0 },
|
||||
data: { sessionID: forked.id, parentID: parent.id },
|
||||
|
|
@ -175,7 +181,9 @@ describe("SessionV2.create", () => {
|
|||
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||
expect((yield* session.context(forked.id)).at(-1)).toMatchObject({ text: "Child continues" })
|
||||
expect(
|
||||
(yield* session.history({ sessionID: forked.id, limit: 10 })).events.map((event) => event.durable?.seq),
|
||||
Array.from(yield* Stream.runCollect(logEvents(session, forked.id))).map(
|
||||
(event): number | undefined => event.durable?.seq,
|
||||
),
|
||||
).toEqual([0, 4, 5])
|
||||
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id })
|
||||
}),
|
||||
|
|
@ -203,10 +211,10 @@ describe("SessionV2.create", () => {
|
|||
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
|
||||
|
||||
const context = yield* session.context(forked.id)
|
||||
const history = yield* session.history({ sessionID: forked.id, limit: 10 })
|
||||
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
|
||||
expect(context).toMatchObject([{ text: "First" }])
|
||||
expect(context[0]?.id).not.toBe(first.id)
|
||||
expect(history.events[0]).toMatchObject({ data: { messageID: second.id } })
|
||||
expect(history[0]).toMatchObject({ data: { messageID: second.id } })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -227,7 +235,7 @@ describe("SessionV2.create", () => {
|
|||
for (const input of changed) {
|
||||
expect(yield* session.create(input)).toEqual(created)
|
||||
}
|
||||
expect(yield* session.list()).toHaveLength(1)
|
||||
expect((yield* session.list()).data).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -239,7 +247,7 @@ describe("SessionV2.create", () => {
|
|||
const created = yield* Effect.all([session.create(input), session.create(input)], { concurrency: "unbounded" })
|
||||
|
||||
expect(created[1]).toEqual(created[0])
|
||||
expect(yield* session.list()).toEqual([created[0]])
|
||||
expect((yield* session.list()).data).toEqual([created[0]])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -317,7 +325,7 @@ describe("SessionV2.create", () => {
|
|||
yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
|
||||
|
||||
expect(
|
||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
|
||||
).toMatchObject([
|
||||
{ durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } },
|
||||
{ durable: { seq: 2 }, type: "session.next.prompted" },
|
||||
|
|
@ -447,7 +455,7 @@ describe("SessionV2.create", () => {
|
|||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||
expect(
|
||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }])
|
||||
}),
|
||||
)
|
||||
|
|
@ -480,7 +488,7 @@ describe("SessionV2.create", () => {
|
|||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||
expect(
|
||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.next.model.switched", data: { model } }])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,166 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
|
||||
[
|
||||
[ProjectV2.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
const GapEvent = EventV2.define({
|
||||
type: "test.session.history.gap",
|
||||
durable: { aggregate: "sessionID", version: 1 },
|
||||
schema: { sessionID: SessionV2.ID, value: Schema.String },
|
||||
})
|
||||
|
||||
describe("SessionV2.history", () => {
|
||||
it.effect("returns an exhausted page for a migrated Session with no event sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const session = yield* SessionV2.Service
|
||||
const sessionID = SessionV2.ID.make("ses_empty_history")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: ProjectV2.ID.global,
|
||||
slug: "empty-history",
|
||||
directory: "/project",
|
||||
title: "Empty history",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
|
||||
const first = yield* session.history({ sessionID, limit: 10 })
|
||||
|
||||
expect(first).toEqual({ events: [], hasMore: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats after as an exclusive aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
|
||||
const page = yield* session.history({ sessionID: created.id, after: 1, limit: 10 })
|
||||
|
||||
expect(page.events.map((event) => event.durable?.seq)).toEqual([2])
|
||||
expect(page.hasMore).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("paginates public events in aggregate order across filtered gaps without duplicates", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "three" })
|
||||
|
||||
const first = yield* session.history({ sessionID: created.id, limit: 2 })
|
||||
const after = first.events.at(-1)?.durable?.seq
|
||||
const second = yield* session.history({
|
||||
sessionID: created.id,
|
||||
after,
|
||||
limit: 2,
|
||||
})
|
||||
const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq)
|
||||
|
||||
expect(first.hasMore).toBe(true)
|
||||
expect(second.hasMore).toBe(false)
|
||||
expect(sequence).toEqual([1, 3, 4])
|
||||
expect(new Set(sequence).size).toBe(sequence.length)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("includes events committed between pages", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
|
||||
const first = yield* session.history({ sessionID: created.id, limit: 1 })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "later" })
|
||||
const second = yield* session.history({
|
||||
sessionID: created.id,
|
||||
after: first.events.at(-1)?.durable?.seq,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
expect(first.hasMore).toBe(true)
|
||||
expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2, 3])
|
||||
expect(second.hasMore).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports exhaustion for exact-limit and limit-plus-one pages", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
|
||||
const exact = yield* session.history({ sessionID: created.id, limit: 2 })
|
||||
const oneMore = yield* session.history({ sessionID: created.id, limit: 1 })
|
||||
const exhausted = yield* session.history({
|
||||
sessionID: created.id,
|
||||
after: oneMore.events.at(-1)?.durable?.seq,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
expect(exact.events).toHaveLength(2)
|
||||
expect(exact.hasMore).toBe(false)
|
||||
expect(oneMore.events).toHaveLength(1)
|
||||
expect(oneMore.hasMore).toBe(true)
|
||||
expect(exhausted.events).toHaveLength(1)
|
||||
expect(exhausted.hasMore).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with NotFoundError for a missing Session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const error = yield* session.history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }).pipe(Effect.flip)
|
||||
|
||||
expect(error._tag).toBe("Session.NotFoundError")
|
||||
}),
|
||||
)
|
||||
})
|
||||
161
packages/core/test/session-log.test.ts
Normal file
161
packages/core/test/session-log.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
|
||||
[
|
||||
[ProjectV2.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
describe("SessionV2.log", () => {
|
||||
it.effect("replays public session events and marks caught-up at the aggregate watermark", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "renamed" })
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
||||
const watermark = (yield* events.sequences([created.id])).get(created.id)
|
||||
|
||||
// Session creation commits a non-public durable event, so the marker's
|
||||
// seq covers more of the aggregate than the public events emitted.
|
||||
expect(items.map((item) => item.type)).toEqual(["session.next.renamed", "log.caught_up"])
|
||||
expect(items.at(-1)).toEqual({ type: "log.caught_up", aggregateID: created.id, seq: watermark })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues with live public events when following", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
const fiber = yield* session
|
||||
.log({ sessionID: created.id, follow: true })
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* session.rename({ sessionID: created.id, title: "renamed live" })
|
||||
|
||||
const items = Array.from(yield* Fiber.join(fiber))
|
||||
expect(items.map((item) => item.type)).toEqual(["log.caught_up", "session.next.renamed"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with NotFound for an unknown session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const error = yield* Effect.flip(Stream.runCollect(session.log({ sessionID: SessionV2.ID.create() })))
|
||||
expect(error._tag).toBe("Session.NotFoundError")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () =>
|
||||
Effect.gen(function* () {
|
||||
const GapEvent = EventV2.define({
|
||||
type: "test.session.log.gap",
|
||||
durable: { aggregate: "sessionID", version: 1 },
|
||||
schema: { sessionID: SessionV2.ID, value: Schema.String },
|
||||
})
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
// Not in the durable manifest, so reads must skip it without failing.
|
||||
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "three" })
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))
|
||||
|
||||
expect(
|
||||
items.map((item): number | string | undefined => (EventV2.isCaughtUp(item) ? item.type : item.durable?.seq)),
|
||||
).toEqual([3, 4, "log.caught_up"])
|
||||
expect(items.at(-1)).toEqual({ type: "log.caught_up", aggregateID: created.id, seq: EventV2.Seq.make(4) })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("completes with a bare caught-up marker for a migrated Session with no event sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const session = yield* SessionV2.Service
|
||||
const sessionID = SessionV2.ID.make("ses_empty_log")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: ProjectV2.ID.global,
|
||||
slug: "empty-log",
|
||||
directory: "/project",
|
||||
title: "Empty log",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID })))
|
||||
|
||||
expect(items).toEqual([{ type: "log.caught_up", aggregateID: sessionID }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionV2 watermarks", () => {
|
||||
it.effect("list pairs each session snapshot with its durable log watermark", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const first = yield* session.create({ location })
|
||||
const second = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: first.id, title: "renamed" })
|
||||
|
||||
const page = yield* session.list()
|
||||
const sequences = yield* events.sequences([first.id, second.id])
|
||||
|
||||
expect(page.data.map((info) => info.id).toSorted()).toEqual([first.id, second.id].toSorted())
|
||||
expect(page.watermarks).toEqual(sequences)
|
||||
expect(page.watermarks.get(first.id)).toBeGreaterThan(page.watermarks.get(second.id)!)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("watermarks omits sessions without durable events", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
const watermarks = yield* session.watermarks([created.id, SessionV2.ID.create()])
|
||||
|
||||
expect(Array.from(watermarks.keys())).toEqual([created.id])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -245,7 +245,11 @@ describe("SessionV2.prompt", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||
const publicEvents = (input: { sessionID: SessionV2.ID; after?: number }) =>
|
||||
session
|
||||
.log({ ...input, follow: true })
|
||||
.pipe(Stream.filter((item): item is SessionEvent.DurableEvent => !EventV2.isCaughtUp(item)))
|
||||
const fiber = yield* publicEvents({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
|
|
@ -253,7 +257,7 @@ describe("SessionV2.prompt", () => {
|
|||
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
|
||||
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
|
||||
[0, "session.next.prompt.admitted"],
|
||||
[1, "session.next.prompt.admitted"],
|
||||
[2, "session.next.prompted"],
|
||||
|
|
@ -261,10 +265,8 @@ describe("SessionV2.prompt", () => {
|
|||
])
|
||||
expect(
|
||||
Array.from(
|
||||
yield* session
|
||||
.events({ sessionID, after: streamed[0]!.durable?.seq })
|
||||
.pipe(Stream.take(1), Stream.runCollect),
|
||||
).map((event) => [event.durable?.seq, event.type]),
|
||||
yield* publicEvents({ sessionID, after: streamed[0]!.durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
|
||||
).map((event): [number | undefined, string] => [event.durable?.seq, event.type]),
|
||||
).toEqual([[1, "session.next.prompt.admitted"]])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,8 +27,10 @@ const capture = () => {
|
|||
return event
|
||||
}),
|
||||
subscribe: () => Stream.empty,
|
||||
all: () => Stream.empty,
|
||||
durable: () => Stream.empty,
|
||||
live: () => Stream.empty,
|
||||
log: () => Stream.empty,
|
||||
changes: () => Stream.empty,
|
||||
sequences: () => Effect.succeed(new Map()),
|
||||
listen: () => Effect.succeed(Effect.void),
|
||||
project: () => Effect.void,
|
||||
replay: () => Effect.void,
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export type Endpoint4_1Input = {
|
|||
export type Endpoint4_1Output = EffectValue<ReturnType<RawClient["server.session"]["session.create"]>>["data"]
|
||||
export type SessionCreateOperation<E = never> = (input?: Endpoint4_1Input) => Effect.Effect<Endpoint4_1Output, E>
|
||||
|
||||
export type Endpoint4_2Output = EffectValue<ReturnType<RawClient["server.session"]["session.active"]>>["data"]
|
||||
export type Endpoint4_2Output = EffectValue<ReturnType<RawClient["server.session"]["session.active"]>>
|
||||
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint4_2Output, E>
|
||||
|
||||
type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
|
|
@ -216,40 +216,32 @@ export type SessionRemoveContextEntryOperation<E = never> = (
|
|||
input: Endpoint4_20Input,
|
||||
) => Effect.Effect<Endpoint4_20Output, E>
|
||||
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||
export type Endpoint4_21Input = {
|
||||
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint4_21Request["query"]["limit"]
|
||||
readonly after?: Endpoint4_21Request["query"]["after"]
|
||||
readonly follow?: Endpoint4_21Request["query"]["follow"]
|
||||
}
|
||||
export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.history"]>>
|
||||
export type SessionHistoryOperation<E = never> = (input: Endpoint4_21Input) => Effect.Effect<Endpoint4_21Output, E>
|
||||
export type Endpoint4_21Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint4_21Input) => Stream.Stream<Endpoint4_21Output, E>
|
||||
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
export type Endpoint4_22Input = {
|
||||
readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_22Request["query"]["after"]
|
||||
}
|
||||
export type Endpoint4_22Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.events"]>>>
|
||||
export type SessionEventsOperation<E = never> = (input: Endpoint4_22Input) => Stream.Stream<Endpoint4_22Output, E>
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] }
|
||||
export type Endpoint4_22Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint4_22Input) => Effect.Effect<Endpoint4_22Output, E>
|
||||
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
|
||||
export type Endpoint4_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint4_23Input) => Effect.Effect<Endpoint4_23Output, E>
|
||||
export type Endpoint4_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_23Input) => Effect.Effect<Endpoint4_23Output, E>
|
||||
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
|
||||
export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E>
|
||||
|
||||
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
export type Endpoint4_25Input = {
|
||||
readonly sessionID: Endpoint4_25Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_25Request["params"]["messageID"]
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
export type Endpoint4_24Input = {
|
||||
readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_24Request["params"]["messageID"]
|
||||
}
|
||||
export type Endpoint4_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint4_25Input) => Effect.Effect<Endpoint4_25Output, E>
|
||||
export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
|
|
@ -273,8 +265,7 @@ export interface SessionApi<E = never> {
|
|||
readonly listContextEntries: SessionListContextEntriesOperation<E>
|
||||
readonly putContextEntry: SessionPutContextEntryOperation<E>
|
||||
readonly removeContextEntry: SessionRemoveContextEntryOperation<E>
|
||||
readonly history: SessionHistoryOperation<E>
|
||||
readonly events: SessionEventsOperation<E>
|
||||
readonly log: SessionLogOperation<E>
|
||||
readonly interrupt: SessionInterruptOperation<E>
|
||||
readonly background: SessionBackgroundOperation<E>
|
||||
readonly message: SessionMessageOperation<E>
|
||||
|
|
@ -586,8 +577,12 @@ export interface SkillApi<E = never> {
|
|||
export type Endpoint17_0Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.subscribe"]>>>
|
||||
export type EventSubscribeOperation<E = never> = () => Stream.Stream<Endpoint17_0Output, E>
|
||||
|
||||
export type Endpoint17_1Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.changes"]>>>
|
||||
export type EventChangesOperation<E = never> = () => Stream.Stream<Endpoint17_1Output, E>
|
||||
|
||||
export interface EventApi<E = never> {
|
||||
readonly subscribe: EventSubscribeOperation<E>
|
||||
readonly changes: EventChangesOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint18_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import type { Definition } from "@opencode-ai/schema/event"
|
||||
|
|
@ -8,7 +9,7 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/un
|
|||
const fields = {
|
||||
id: Event.ID,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
|
||||
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Event.Seq, version: Event.Version })),
|
||||
location: Schema.optional(Location.Ref),
|
||||
}
|
||||
|
||||
|
|
@ -38,11 +39,24 @@ const make = <const Definitions extends ReadonlyArray<Definition>>(definitions:
|
|||
OpenApi.annotations({
|
||||
identifier: "v2.event.subscribe",
|
||||
summary: "Subscribe to events",
|
||||
description: "Subscribe to native event payloads for the server.",
|
||||
description:
|
||||
"Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed. Consumers that need reliability should combine the changes feed with durable session log reads.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." })),
|
||||
.add(
|
||||
HttpApiEndpoint.get("event.changes", "/api/event/changes", {
|
||||
success: HttpApiSchema.StreamSse({ data: EventLog.Change }),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.event.changes",
|
||||
summary: "Subscribe to change hints",
|
||||
description:
|
||||
"Payload-free hint channel: after an event commits, a subscriber eventually receives a hint for that aggregate with seq at or beyond the event, or a sweep-required marker. Hints coalesce to the latest seq per aggregate under backpressure and the stream never fails from overflow. No consumer may derive correctness from receiving a hint; correctness always comes from durable log reads plus the consumer's own checkpoint. A sweep-required marker is emitted first on every (re)subscribe and whenever hint retention is exceeded: treat every aggregate as potentially dirty and recover via bounded sweep plus log reads.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream routes." })),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { optional } from "@opencode-ai/schema/schema"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors.js"
|
||||
|
|
@ -28,6 +30,10 @@ export const MessageGroup = HttpApiGroup.make("server.message")
|
|||
query: SessionMessagesQuery,
|
||||
success: Schema.Struct({
|
||||
data: Schema.Array(SessionMessage.Message),
|
||||
watermark: optional(Event.Seq).annotate({
|
||||
description:
|
||||
"Durable log seq this snapshot was computed at, read before the snapshot. Attach a live log read after the watermark to compose fetch and stream gap-free; events between the watermark and the snapshot read may be redelivered by the tail and are safe to re-apply. Absent when the session has no durable events.",
|
||||
}),
|
||||
cursor: Schema.Struct({
|
||||
previous: Schema.String.pipe(Schema.optional),
|
||||
next: Schema.String.pipe(Schema.optional),
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
|||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Context, Effect, Encoding, Result, Schema, Struct } from "effect"
|
||||
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import {
|
||||
ConflictError,
|
||||
|
|
@ -26,6 +27,7 @@ import { Model } from "@opencode-ai/schema/model"
|
|||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||
|
||||
const SessionsQueryFields = {
|
||||
workspace: Workspace.ID.pipe(Schema.optional),
|
||||
|
|
@ -89,13 +91,19 @@ const SessionActive = Schema.Struct({
|
|||
type: Schema.Literal("running"),
|
||||
}).annotate({ identifier: "SessionActive" })
|
||||
|
||||
const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100))
|
||||
|
||||
export const SessionHistoryQuery = Schema.Struct({
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional),
|
||||
after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
|
||||
const SessionWatermarks = Schema.Record(Session.ID, Event.Seq).annotate({
|
||||
identifier: "SessionWatermarks",
|
||||
description:
|
||||
"Durable log seq each session's snapshot was computed at. Attach a live log read after the watermark to compose fetch and stream gap-free; apply a snapshot only where its watermark is at or beyond already-applied events. Sessions without durable events are absent.",
|
||||
})
|
||||
|
||||
const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value): "true" | "false" => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
|
||||
const SessionsQueryCursor = SessionsCursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
})
|
||||
|
|
@ -115,6 +123,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||
query: SessionsQuery,
|
||||
success: Schema.Struct({
|
||||
data: Schema.Array(Session.Info),
|
||||
watermarks: SessionWatermarks,
|
||||
cursor: Schema.Struct({
|
||||
previous: SessionsCursor.pipe(Schema.optional),
|
||||
next: SessionsCursor.pipe(Schema.optional),
|
||||
|
|
@ -149,13 +158,13 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.active", "/api/session/active", {
|
||||
success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }),
|
||||
success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive), watermarks: SessionWatermarks }),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.active",
|
||||
summary: "List active sessions",
|
||||
description:
|
||||
"Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.",
|
||||
"Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. Watermarks are the durable log positions read alongside the activity snapshot; activity itself is process state, so the pairing is advisory rather than transactional.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -282,7 +291,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||
OpenApi.annotations({
|
||||
identifier: "v2.session.command",
|
||||
summary: "Run command",
|
||||
description: "Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.",
|
||||
description:
|
||||
"Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -455,40 +465,24 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: SessionHistoryQuery,
|
||||
success: Schema.Struct({
|
||||
data: Schema.Array(SessionEvent.Durable),
|
||||
hasMore: Schema.Boolean,
|
||||
}).annotate({ identifier: "SessionHistory" }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.history",
|
||||
summary: "Get session history",
|
||||
description:
|
||||
"Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", {
|
||||
HttpApiEndpoint.get("session.log", "/api/session/:sessionID/log", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: {
|
||||
after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
|
||||
after: Schema.NumberFromString.pipe(Schema.decodeTo(Event.Seq), Schema.optional),
|
||||
follow: BooleanFromString.pipe(Schema.optional),
|
||||
},
|
||||
success: HttpApiSchema.StreamSse({ data: SessionEvent.Durable }),
|
||||
success: HttpApiSchema.StreamSse({
|
||||
data: Schema.Union([SessionEvent.Durable, EventLog.CaughtUp]).annotate({ identifier: "SessionLogItem" }),
|
||||
}),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.events",
|
||||
summary: "Subscribe to session events",
|
||||
description: "Replay durable events after an aggregate sequence, then continue with new durable events.",
|
||||
identifier: "v2.session.log",
|
||||
summary: "Read the session log",
|
||||
description:
|
||||
"Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a caught-up marker once the replay reaches the end of the log, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { SessionHistoryQuery, SessionsCursor } from "../src/groups/session.js"
|
||||
import { Effect } from "effect"
|
||||
import { SessionsCursor } from "../src/groups/session.js"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
describe("SessionsCursor", () => {
|
||||
|
|
@ -16,11 +16,3 @@ describe("SessionsCursor", () => {
|
|||
expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionHistoryQuery", () => {
|
||||
test("decodes numeric paging inputs", async () => {
|
||||
const query = await Effect.runPromise(Schema.decodeUnknownEffect(SessionHistoryQuery)({ after: "3", limit: "10" }))
|
||||
|
||||
expect(query).toEqual({ after: 3, limit: 10 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
56
packages/schema/src/event-log.ts
Normal file
56
packages/schema/src/event-log.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
export * as EventLog from "./event-log.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Event } from "./event.js"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
/**
|
||||
* Replay-to-live boundary marker for a durable log read. The reader now holds
|
||||
* every event committed at or below `seq`; `seq` is absent when the log holds
|
||||
* no events at or before the read position. May be re-emitted after the
|
||||
* reader internally re-attaches.
|
||||
*/
|
||||
export const CaughtUp = Schema.Struct({
|
||||
type: Schema.Literal("log.caught_up"),
|
||||
aggregateID: Schema.String,
|
||||
seq: optional(Event.Seq),
|
||||
}).annotate({
|
||||
identifier: "EventLog.CaughtUp",
|
||||
description:
|
||||
"Marker emitted when a log read transitions from replay to live (or completes a finite read). The reader holds every event committed at or below seq.",
|
||||
})
|
||||
export interface CaughtUp extends Schema.Schema.Type<typeof CaughtUp> {}
|
||||
|
||||
/**
|
||||
* A payload-free doorbell: the aggregate's log advanced to at least `seq`.
|
||||
* Hints are a latency optimization only; no consumer may derive correctness
|
||||
* from receiving one. Correctness always comes from a durable log read plus
|
||||
* the consumer's own checkpoint.
|
||||
*/
|
||||
export const Hint = Schema.Struct({
|
||||
type: Schema.Literal("log.hint"),
|
||||
aggregateID: Schema.String,
|
||||
seq: Event.Seq,
|
||||
}).annotate({
|
||||
identifier: "EventLog.Hint",
|
||||
description:
|
||||
"Payload-free change hint: the aggregate's durable log advanced to at least seq. Hints coalesce under backpressure (latest per aggregate) and are never a delivery guarantee.",
|
||||
})
|
||||
export interface Hint extends Schema.Schema.Type<typeof Hint> {}
|
||||
|
||||
/**
|
||||
* Hints may have been lost. Treat every aggregate as potentially dirty and
|
||||
* recover via bounded sweep plus durable log reads. Also emitted first on
|
||||
* every (re)subscribe, since hints during disconnection were never buffered.
|
||||
*/
|
||||
export const SweepRequired = Schema.Struct({
|
||||
type: Schema.Literal("log.sweep_required"),
|
||||
}).annotate({
|
||||
identifier: "EventLog.SweepRequired",
|
||||
description:
|
||||
"Hints may have been lost; treat every aggregate as potentially dirty and recover via bounded sweep plus durable log reads. Emitted first on every (re)subscribe.",
|
||||
})
|
||||
export interface SweepRequired extends Schema.Schema.Type<typeof SweepRequired> {}
|
||||
|
||||
export const Change = Schema.Union([Hint, SweepRequired]).annotate({ identifier: "EventLog.Change" })
|
||||
export type Change = typeof Change.Type
|
||||
|
|
@ -12,6 +12,18 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
|
|||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
/**
|
||||
* Position in one aggregate's durable log. Values originate from the durable
|
||||
* event envelope, caught-up markers, change hints, and snapshot watermarks;
|
||||
* `after` cursors accept only values that came from those sources.
|
||||
*/
|
||||
export const Seq = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.brand("Event.Seq"))
|
||||
export type Seq = typeof Seq.Type
|
||||
|
||||
/** Durable schema version of one event type, from the event definition that committed it. */
|
||||
export const Version = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).pipe(Schema.brand("Event.Version"))
|
||||
export type Version = typeof Version.Type
|
||||
|
||||
export type Definition<
|
||||
Type extends string = string,
|
||||
DataSchema extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>,
|
||||
|
|
@ -32,8 +44,8 @@ export type Payload<D extends Definition = Definition> = {
|
|||
readonly data: Data<D>
|
||||
readonly durable?: {
|
||||
readonly aggregateID: string
|
||||
readonly seq: number
|
||||
readonly version: number
|
||||
readonly seq: Seq
|
||||
readonly version: Version
|
||||
}
|
||||
readonly location?: Location.Ref
|
||||
readonly metadata?: Record<string, unknown>
|
||||
|
|
@ -55,7 +67,7 @@ export function define<
|
|||
id: ID,
|
||||
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
type: Schema.Literal(input.type),
|
||||
durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
|
||||
durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Seq, version: Version })),
|
||||
location: optional(Location.Ref),
|
||||
data,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -77,16 +77,19 @@ it.live(
|
|||
sessionID: id,
|
||||
prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }),
|
||||
})
|
||||
const prompted = yield* opencode.sessions.events({ sessionID: id }).pipe(
|
||||
const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe(
|
||||
Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id),
|
||||
Stream.runHead,
|
||||
Effect.timeout("10 seconds"),
|
||||
Effect.map(Option.getOrThrow),
|
||||
)
|
||||
const wakeContext = yield* opencode.sessions.context({ sessionID: id })
|
||||
const event = yield* opencode.sessions
|
||||
.events({ sessionID: id })
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined))
|
||||
const event = yield* opencode.sessions.log({ sessionID: id }).pipe(
|
||||
Stream.filter((item) => item.type !== "log.caught_up"),
|
||||
Stream.take(1),
|
||||
Stream.runHead,
|
||||
Effect.map(Option.getOrUndefined),
|
||||
)
|
||||
const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe(
|
||||
Option.getOrThrow,
|
||||
)
|
||||
|
|
@ -96,7 +99,7 @@ it.live(
|
|||
const missingSessionID = fixture.sdk.Session.ID.create()
|
||||
const missing = yield* Effect.all(
|
||||
[
|
||||
opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
|
||||
opencode.sessions.log({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
|
||||
opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
|
||||
opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
|
||||
opencode.sessions.listContextEntries({ sessionID: missingSessionID }).pipe(Effect.flip),
|
||||
|
|
@ -114,7 +117,7 @@ it.live(
|
|||
expect(selected.model?.id).toBe(model.id)
|
||||
expect(selected.model?.providerID).toBe(model.providerID)
|
||||
expect(page.data.some((session) => session.id === id)).toBe(true)
|
||||
expect(active).toEqual({})
|
||||
expect(active).toEqual({ data: {}, watermarks: {} })
|
||||
expect(admitted.sessionID).toBe(id)
|
||||
expect(prompted.type).toBe("session.next.prompted")
|
||||
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
|
||||
|
|
|
|||
|
|
@ -20,33 +20,38 @@ function eventData(data: unknown): Sse.Event {
|
|||
export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
return handlers.handleRaw("event.subscribe", () =>
|
||||
Effect.gen(function* () {
|
||||
const connected = {
|
||||
id: EventV2.ID.create(),
|
||||
type: "server.connected",
|
||||
data: {},
|
||||
}
|
||||
const output = Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
// Acquiring the bounded stream installs its listener before readiness is observable.
|
||||
const live = yield* EventV2.allBounded(events, subscriberCapacity)
|
||||
return Stream.make(connected).pipe(Stream.concat(live))
|
||||
}),
|
||||
).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()))
|
||||
const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n"))
|
||||
return HttpServerResponse.stream(
|
||||
output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),
|
||||
{
|
||||
contentType: "text/event-stream",
|
||||
headers: {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
return handlers
|
||||
.handle(
|
||||
"event.changes",
|
||||
Effect.fn(() => Effect.succeed(events.changes())),
|
||||
)
|
||||
.handleRaw("event.subscribe", () =>
|
||||
Effect.gen(function* () {
|
||||
const connected = {
|
||||
id: EventV2.ID.create(),
|
||||
type: "server.connected",
|
||||
data: {},
|
||||
}
|
||||
const output = Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
// Acquiring the bounded stream installs its listener before readiness is observable.
|
||||
const live = yield* EventV2.liveBounded(events, subscriberCapacity)
|
||||
return Stream.make(connected).pipe(Stream.concat(live))
|
||||
}),
|
||||
).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()))
|
||||
const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n"))
|
||||
return HttpServerResponse.stream(
|
||||
output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),
|
||||
{
|
||||
contentType: "text/event-stream",
|
||||
headers: {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
|||
catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
|
||||
})
|
||||
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
||||
// Read the watermark before the snapshot: an understated watermark only
|
||||
// redelivers already-reflected events, while an overstated one would let
|
||||
// an attached tail skip events missing from the snapshot.
|
||||
const watermark = (yield* session.watermarks([ctx.params.sessionID])).get(ctx.params.sessionID)
|
||||
const messages = yield* session
|
||||
.messages({
|
||||
sessionID: ctx.params.sessionID,
|
||||
|
|
@ -70,6 +74,7 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
|||
const last = messages.at(-1)
|
||||
return {
|
||||
data: messages,
|
||||
watermark,
|
||||
cursor: {
|
||||
previous: first ? cursor.encode(first, order, "previous") : undefined,
|
||||
next: last ? cursor.encode(last, order, "next") : undefined,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import {
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
const DefaultSessionHistoryLimit = 50
|
||||
|
||||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -35,15 +34,17 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
||||
)
|
||||
: ctx.query
|
||||
const sessions = yield* session.list({
|
||||
const page = yield* session.list({
|
||||
...query,
|
||||
workspaceID: query.workspace,
|
||||
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
||||
})
|
||||
const sessions = page.data
|
||||
const first = sessions[0]
|
||||
const last = sessions.at(-1)
|
||||
return {
|
||||
data: sessions,
|
||||
watermarks: Object.fromEntries(page.watermarks),
|
||||
cursor: {
|
||||
previous: first
|
||||
? SessionsCursor.make({
|
||||
|
|
@ -87,10 +88,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||
.handle(
|
||||
"session.active",
|
||||
Effect.fn(function* () {
|
||||
const active = yield* session.active
|
||||
const watermarks = yield* session.watermarks(Array.from(active))
|
||||
return {
|
||||
data: Object.fromEntries(
|
||||
Array.from(yield* session.active, (sessionID) => [sessionID, { type: "running" as const }]),
|
||||
),
|
||||
data: Object.fromEntries(Array.from(active, (sessionID) => [sessionID, { type: "running" as const }])),
|
||||
watermarks: Object.fromEntries(watermarks),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
|
@ -547,35 +549,12 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.history",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* session
|
||||
.history({
|
||||
sessionID: ctx.params.sessionID,
|
||||
after: ctx.query.after,
|
||||
limit: ctx.query.limit ?? DefaultSessionHistoryLimit,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((page) => ({
|
||||
data: page.events,
|
||||
hasMore: page.hasMore,
|
||||
})),
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
(error) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.events",
|
||||
"session.log",
|
||||
Effect.fn((ctx) =>
|
||||
Effect.succeed(
|
||||
session.events({ sessionID: ctx.params.sessionID, after: ctx.query.after }).pipe(Stream.orDie),
|
||||
session
|
||||
.log({ sessionID: ctx.params.sessionID, after: ctx.query.after, follow: ctx.query.follow })
|
||||
.pipe(Stream.orDie),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -869,7 +869,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
setStore(
|
||||
"session",
|
||||
"status",
|
||||
Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const])),
|
||||
Object.fromEntries(Object.keys(active.data).map((sessionID) => [sessionID, "running" as const])),
|
||||
),
|
||||
),
|
||||
result.location.refresh(),
|
||||
|
|
|
|||
|
|
@ -232,7 +232,8 @@ test("connectedOnce is false until first connect and persists across disconnect"
|
|||
test("tracks session status from active sessions and execution events", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json({ data: { "session-active": { type: "running" } }, watermarks: {} })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||
if (url.pathname === "/api/shell") return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json({ data: {} })
|
||||
if (url.pathname === "/api/session/active") return json({ data: {}, watermarks: {} })
|
||||
if (
|
||||
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(
|
||||
url.pathname,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue