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

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

View file

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

View file

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