feat(server): durable log reads, changes feed, and watermarked snapshots (#34962)

This commit is contained in:
Kit Langton 2026-07-02 16:42:30 -04:00 committed by GitHub
commit bc2e270f82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1402 additions and 1605 deletions

View file

@ -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",
},
},
},
)
}),
)
)
}),
)
}),
)

View file

@ -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,

View file

@ -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),
),
),
)