refactor(api): remove watermark sync surface

This commit is contained in:
Dax Raad
2026-07-06 13:01:36 -04:00
parent 4cee5bd824
commit ff499c3603
25 changed files with 141 additions and 392 deletions
+2 -2
View File
@@ -544,8 +544,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.permissions = permissions.map(permission) state.permissions = permissions.map(permission)
state.questions = questions.map(question) state.questions = questions.map(question)
syncBlockers() syncBlockers()
await subagents.hydrate({ messages: [...projected], active: active.data }) await subagents.hydrate({ messages: [...projected], active })
const running = input.sessionID in active.data const running = input.sessionID in active
write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" }) write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" })
if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) { if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) {
const current = state.wait const current = state.wait
+1 -5
View File
@@ -66,7 +66,7 @@ export type Endpoint4_1Input = {
export type Endpoint4_1Output = EffectValue<ReturnType<RawClient["server.session"]["session.create"]>>["data"] 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 SessionCreateOperation<E = never> = (input?: Endpoint4_1Input) => Effect.Effect<Endpoint4_1Output, E>
export type Endpoint4_2Output = EffectValue<ReturnType<RawClient["server.session"]["session.active"]>> export type Endpoint4_2Output = EffectValue<ReturnType<RawClient["server.session"]["session.active"]>>["data"]
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint4_2Output, E> export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint4_2Output, E>
type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0] type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
@@ -658,12 +658,8 @@ export interface SkillApi<E = never> {
export type Endpoint18_0Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.subscribe"]>>> export type Endpoint18_0Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.subscribe"]>>>
export type EventSubscribeOperation<E = never> = () => Stream.Stream<Endpoint18_0Output, E> export type EventSubscribeOperation<E = never> = () => Stream.Stream<Endpoint18_0Output, E>
export type Endpoint18_1Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.changes"]>>>
export type EventChangesOperation<E = never> = () => Stream.Stream<Endpoint18_1Output, E>
export interface EventApi<E = never> { export interface EventApi<E = never> {
readonly subscribe: EventSubscribeOperation<E> readonly subscribe: EventSubscribeOperation<E>
readonly changes: EventChangesOperation<E>
} }
type Endpoint19_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0] type Endpoint19_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
+5 -10
View File
@@ -82,7 +82,10 @@ const Endpoint4_1 = (raw: RawClient["server.session"]) => (input?: Endpoint4_1In
) )
const Endpoint4_2 = (raw: RawClient["server.session"]) => () => const Endpoint4_2 = (raw: RawClient["server.session"]) => () =>
raw["session.active"]({}).pipe(Effect.mapError(mapClientError)) raw["session.active"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0] type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"]["sessionID"] } type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"]["sessionID"] }
@@ -796,15 +799,7 @@ const Endpoint18_0 = (raw: RawClient["server.event"]) => () =>
), ),
) )
const Endpoint18_1 = (raw: RawClient["server.event"]) => () => const adaptGroup18 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint18_0(raw) })
Stream.unwrap(
raw["event.changes"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
),
)
const adaptGroup18 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint18_0(raw), changes: Endpoint18_1(raw) })
type Endpoint19_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0] type Endpoint19_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] } type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
@@ -133,7 +133,6 @@ import type {
SkillListInput, SkillListInput,
SkillListOutput, SkillListOutput,
EventSubscribeOutput, EventSubscribeOutput,
EventChangesOutput,
PtyListInput, PtyListInput,
PtyListOutput, PtyListOutput,
PtyCreateInput, PtyCreateInput,
@@ -402,7 +401,7 @@ export function make(options: ClientOptions) {
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
active: (requestOptions?: RequestOptions) => active: (requestOptions?: RequestOptions) =>
request<SessionActiveOutput>( request<{ readonly data: SessionActiveOutput }>(
{ {
method: "GET", method: "GET",
path: `/api/session/active`, path: `/api/session/active`,
@@ -411,7 +410,7 @@ export function make(options: ClientOptions) {
empty: false, empty: false,
}, },
requestOptions, requestOptions,
), ).then((value) => value.data),
get: (input: SessionGetInput, requestOptions?: RequestOptions) => get: (input: SessionGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionGetOutput }>( request<{ readonly data: SessionGetOutput }>(
{ {
@@ -646,7 +645,7 @@ export function make(options: ClientOptions) {
sse<SessionLogOutput>( sse<SessionLogOutput>(
{ {
method: "GET", method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/log`, path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
query: { after: input["after"], follow: input["follow"] }, query: { after: input["after"], follow: input["follow"] },
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
@@ -1183,11 +1182,6 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, { method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions, requestOptions,
), ),
changes: (requestOptions?: RequestOptions): AsyncIterable<EventChangesOutput> =>
sse<EventChangesOutput>(
{ method: "GET", path: `/api/event/changes`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
}, },
pty: { pty: {
list: (input?: PtyListInput, requestOptions?: RequestOptions) => list: (input?: PtyListInput, requestOptions?: RequestOptions) =>
+1 -10
View File
@@ -354,7 +354,6 @@ export type SessionListOutput = {
}> }>
} }
}> }>
readonly watermarks: { readonly [x: string]: number }
readonly cursor: { readonly previous?: string | null; readonly next?: string | null } readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
} }
@@ -419,10 +418,7 @@ export type SessionCreateOutput = {
} }
}["data"] }["data"]
export type SessionActiveOutput = { export type SessionActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"]
readonly data: { readonly [x: string]: { readonly type: "running" } }
readonly watermarks: { readonly [x: string]: number }
}
export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -1987,7 +1983,6 @@ export type MessageListOutput = {
readonly time: { readonly created: number } readonly time: { readonly created: number }
} }
> >
readonly watermark?: number
readonly cursor: { readonly previous?: string | null; readonly next?: string | null } readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
} }
@@ -5546,10 +5541,6 @@ export type EventSubscribeOutput =
readonly data: {} readonly data: {}
} }
export type EventChangesOutput =
| { readonly type: "log.hint"; readonly aggregateID: string; readonly seq: number }
| { readonly type: "log.sweep_required" }
export type PtyListInput = { export type PtyListInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+3 -4
View File
@@ -99,7 +99,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return Effect.succeed( return Effect.succeed(
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(
request, request,
Response.json({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } }), Response.json({ data: { ses_test: { type: "running" } } }),
), ),
) )
} }
@@ -112,7 +112,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return Effect.succeed( return Effect.succeed(
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(
request, request,
Response.json({ data: [session.data], watermarks: { ses_test: 3 }, cursor: { next: "next" } }), Response.json({ data: [session.data], cursor: { next: "next" } }),
), ),
) )
}) })
@@ -148,8 +148,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
expect(result.active).toEqual({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } }) expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(result.page.watermarks).toEqual({ ses_test: 3 })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype) expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test") expect(result.created.id).toBe("ses_test")
+4 -4
View File
@@ -47,7 +47,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"]) expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["current", "directories"]) expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
}) })
test("file.read returns binary content from the public HTTP contract", async () => { test("file.read returns binary content from the public HTTP contract", async () => {
@@ -198,7 +198,7 @@ test("session methods use the public HTTP contract", async () => {
if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
if (url.endsWith("/api/session/active")) if (url.endsWith("/api/session/active"))
return Response.json({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } }) return Response.json({ data: { ses_test: { type: "running" } } })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (init?.method === "POST") return new Response(null, { status: 204 }) if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } }) return Response.json({ data: [session.data], cursor: { next: "next" } })
@@ -227,7 +227,7 @@ test("session methods use the public HTTP contract", async () => {
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" }) const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next") expect(page.cursor.next).toBe("next")
expect(active).toEqual({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } }) expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test") expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test") expect(admitted.id).toBe("msg_test")
expect(context).toEqual([]) expect(context).toEqual([])
@@ -243,7 +243,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/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"], ["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/context"],
["GET", "http://localhost:3000/api/session/ses_test/log?after=0"], ["GET", "http://localhost:3000/api/experimental/session/ses_test/log?after=0"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt"], ["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
]) ])
-1
View File
@@ -204,7 +204,6 @@ describe("OpenAPI.fromSpec", () => {
expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined() expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined()
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined() expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined() expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.changes")).toBeUndefined()
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined() expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined() expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined()
}) })
-83
View File
@@ -135,12 +135,6 @@ export interface Interface {
readonly after?: number readonly after?: number
readonly follow?: boolean readonly follow?: boolean
}) => Stream.Stream<LogItem> }) => 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. */ /** Latest committed seq per aggregate. Aggregates without events are absent. */
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Seq>> readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Seq>>
/** @deprecated Use `all()` and consume the returned stream. */ /** @deprecated Use `all()` and consume the returned stream. */
@@ -183,11 +177,6 @@ export const liveBounded = (
export interface LayerOptions { export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void> 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
/** Maximum durable rows read per page while replaying or tailing an aggregate log. */ /** Maximum durable rows read per page while replaying or tailing an aggregate log. */
readonly logReadPageSize?: number readonly logReadPageSize?: number
} }
@@ -204,12 +193,6 @@ export const layerWith = (options?: LayerOptions) =>
const projectors = new Map<string, Subscriber[]>() const projectors = new Map<string, Subscriber[]>()
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads. // TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
const listeners = new Array<Subscriber>() 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 { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512 const logReadPageSize = options?.logReadPageSize ?? 512
@@ -231,9 +214,6 @@ export const layerWith = (options?: LayerOptions) =>
{ discard: true }, { discard: true },
) )
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true }) yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
yield* Effect.forEach(changesSubscribers, (subscriber) => PubSub.shutdown(subscriber.wake), {
discard: true,
})
}), }),
) )
@@ -394,27 +374,6 @@ export const layerWith = (options?: LayerOptions) =>
(wake) => PubSub.publish(wake, undefined), (wake) => PubSub.publish(wake, undefined),
{ discard: true }, { 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 return committed
}), }),
@@ -723,47 +682,6 @@ export const layerWith = (options?: LayerOptions) =>
}), }),
) )
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>> => { const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Seq>> => {
if (aggregateIDs.length === 0) return Effect.succeed(new Map()) if (aggregateIDs.length === 0) return Effect.succeed(new Map())
return db return db
@@ -798,7 +716,6 @@ export const layerWith = (options?: LayerOptions) =>
subscribe, subscribe,
live: streamLive, live: streamLive,
log, log,
changes,
sequences, sequences,
listen, listen,
project, project,
-46
View File
@@ -1289,52 +1289,6 @@ describe("EventV2", () => {
}), }),
) )
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", () => it.effect("sequences returns the latest committed seq per aggregate and omits unknown aggregates", () =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
@@ -29,7 +29,6 @@ const capture = () => {
subscribe: () => Stream.empty, subscribe: () => Stream.empty,
live: () => Stream.empty, live: () => Stream.empty,
log: () => Stream.empty, log: () => Stream.empty,
changes: () => Stream.empty,
sequences: () => Effect.succeed(new Map()), sequences: () => Effect.succeed(new Map()),
listen: () => Effect.succeed(Effect.void), listen: () => Effect.succeed(Effect.void),
project: () => Effect.void, project: () => Effect.void,
+1 -14
View File
@@ -1,5 +1,4 @@
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { EventLog } from "@opencode-ai/schema/event-log"
import { EventManifest } from "@opencode-ai/schema/event-manifest" import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Location } from "@opencode-ai/schema/location" import { Location } from "@opencode-ai/schema/location"
import type { Definition } from "@opencode-ai/schema/event" import type { Definition } from "@opencode-ai/schema/event"
@@ -39,19 +38,7 @@ const make = <const Definitions extends ReadonlyArray<Definition>>(definitions:
identifier: "v2.event.subscribe", identifier: "v2.event.subscribe",
summary: "Subscribe to events", summary: "Subscribe to events",
description: 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.", "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.",
}),
),
)
.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.",
}), }),
), ),
) )
-6
View File
@@ -1,7 +1,5 @@
import { Session } from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message" 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 { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors.js" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors.js"
@@ -30,10 +28,6 @@ export const MessageGroup = HttpApiGroup.make("server.message")
query: SessionMessagesQuery, query: SessionMessagesQuery,
success: Schema.Struct({ success: Schema.Struct({
data: Schema.Array(SessionMessage.Message), 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({ cursor: Schema.Struct({
previous: Schema.String.pipe(Schema.optional), previous: Schema.String.pipe(Schema.optional),
next: Schema.String.pipe(Schema.optional), next: Schema.String.pipe(Schema.optional),
+4 -11
View File
@@ -104,12 +104,6 @@ const SessionActive = Schema.Struct({
type: Schema.Literal("running"), type: Schema.Literal("running"),
}).annotate({ identifier: "SessionActive" }) }).annotate({ identifier: "SessionActive" })
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( const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, { Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"), decode: SchemaGetter.transform((value) => value === "true"),
@@ -136,7 +130,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
query: SessionsQuery, query: SessionsQuery,
success: Schema.Struct({ success: Schema.Struct({
data: Schema.Array(Session.Info), data: Schema.Array(Session.Info),
watermarks: SessionWatermarks,
cursor: Schema.Struct({ cursor: Schema.Struct({
previous: SessionsCursor.pipe(Schema.optional), previous: SessionsCursor.pipe(Schema.optional),
next: SessionsCursor.pipe(Schema.optional), next: SessionsCursor.pipe(Schema.optional),
@@ -171,13 +164,13 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
) )
.add( .add(
HttpApiEndpoint.get("session.active", "/api/session/active", { HttpApiEndpoint.get("session.active", "/api/session/active", {
success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive), watermarks: SessionWatermarks }), success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.active", identifier: "v2.session.active",
summary: "List active sessions", summary: "List active sessions",
description: description:
"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.", "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.",
}), }),
), ),
) )
@@ -498,7 +491,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
), ),
) )
.add( .add(
HttpApiEndpoint.get("session.log", "/api/session/:sessionID/log", { HttpApiEndpoint.get("session.log", "/api/experimental/session/:sessionID/log", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
query: { query: {
after: Schema.NumberFromString.pipe(Schema.decodeTo(Event.Seq), Schema.optional), after: Schema.NumberFromString.pipe(Schema.decodeTo(Event.Seq), Schema.optional),
@@ -515,7 +508,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
identifier: "v2.session.log", identifier: "v2.session.log",
summary: "Read the session log", summary: "Read the session log",
description: description:
"Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, 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.", "Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.",
}), }),
), ),
) )
-34
View File
@@ -19,37 +19,3 @@ export const Synced = Schema.Struct({
"Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq.", "Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq.",
}) })
export interface Synced extends Schema.Schema.Type<typeof Synced> {} export interface Synced extends Schema.Schema.Type<typeof Synced> {}
/**
* 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
+1 -1
View File
@@ -14,7 +14,7 @@ export type ID = typeof ID.Type
/** /**
* Position in one aggregate's durable log. Values originate from the durable * Position in one aggregate's durable log. Values originate from the durable
* event envelope, synced markers, change hints, and snapshot watermarks; * event envelope and synced markers;
* `after` cursors accept only values that came from those sources. * `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 const Seq = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.brand("Event.Seq"))
+1 -1
View File
@@ -117,7 +117,7 @@ it.live(
expect(selected.model?.id).toBe(model.id) expect(selected.model?.id).toBe(model.id)
expect(selected.model?.providerID).toBe(model.providerID) expect(selected.model?.providerID).toBe(model.providerID)
expect(page.data.some((session) => session.id === id)).toBe(true) expect(page.data.some((session) => session.id === id)).toBe(true)
expect(active).toEqual({ data: {}, watermarks: {} }) expect(active).toEqual({})
expect(admitted.sessionID).toBe(id) expect(admitted.sessionID).toBe(id)
expect(prompted.type).toBe("session.prompt.promoted") expect(prompted.type).toBe("session.prompt.promoted")
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
+18 -18
View File
@@ -278,8 +278,6 @@ import type {
V2CredentialUpdateResponses, V2CredentialUpdateResponses,
V2DebugLocationErrors, V2DebugLocationErrors,
V2DebugLocationResponses, V2DebugLocationResponses,
V2EventChangesErrors,
V2EventChangesResponses,
V2EventSubscribeErrors, V2EventSubscribeErrors,
V2EventSubscribeResponses, V2EventSubscribeResponses,
V2FormRequestListErrors, V2FormRequestListErrors,
@@ -334,6 +332,8 @@ import type {
V2ProjectCurrentResponses, V2ProjectCurrentResponses,
V2ProjectDirectoriesErrors, V2ProjectDirectoriesErrors,
V2ProjectDirectoriesResponses, V2ProjectDirectoriesResponses,
V2ProjectListErrors,
V2ProjectListResponses,
V2ProviderGetErrors, V2ProviderGetErrors,
V2ProviderGetResponses, V2ProviderGetResponses,
V2ProviderListErrors, V2ProviderListErrors,
@@ -5896,7 +5896,7 @@ export class Session3 extends HeyApiClient {
/** /**
* List active sessions * List active sessions
* *
* 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. * Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.
*/ */
public active<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) { public active<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<V2SessionActiveResponses, V2SessionActiveErrors, ThrowOnError>({ return (options?.client ?? this.client).get<V2SessionActiveResponses, V2SessionActiveErrors, ThrowOnError>({
@@ -6341,7 +6341,7 @@ export class Session3 extends HeyApiClient {
/** /**
* Read the session log * Read the session log
* *
* Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, 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. * Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.
*/ */
public log<ThrowOnError extends boolean = false>( public log<ThrowOnError extends boolean = false>(
parameters: { parameters: {
@@ -6364,7 +6364,7 @@ export class Session3 extends HeyApiClient {
], ],
) )
return (options?.client ?? this.client).sse.get<V2SessionLogResponses, V2SessionLogErrors, ThrowOnError>({ return (options?.client ?? this.client).sse.get<V2SessionLogResponses, V2SessionLogErrors, ThrowOnError>({
url: "/api/session/{sessionID}/log", url: "/api/experimental/session/{sessionID}/log",
...options, ...options,
...params, ...params,
}) })
@@ -7032,6 +7032,18 @@ export class Credential extends HeyApiClient {
} }
export class Project2 extends HeyApiClient { export class Project2 extends HeyApiClient {
/**
* List projects
*
* List known projects.
*/
public list<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<V2ProjectListResponses, V2ProjectListErrors, ThrowOnError>({
url: "/api/project",
...options,
})
}
/** /**
* Get current project * Get current project
* *
@@ -7357,7 +7369,7 @@ export class Event2 extends HeyApiClient {
/** /**
* Subscribe to events * Subscribe to events
* *
* 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. * 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.
*/ */
public subscribe<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) { public subscribe<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).sse.get<V2EventSubscribeResponses, V2EventSubscribeErrors, ThrowOnError>({ return (options?.client ?? this.client).sse.get<V2EventSubscribeResponses, V2EventSubscribeErrors, ThrowOnError>({
@@ -7365,18 +7377,6 @@ export class Event2 extends HeyApiClient {
...options, ...options,
}) })
} }
/**
* Subscribe to change hints
*
* 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.
*/
public changes<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).sse.get<V2EventChangesResponses, V2EventChangesErrors, ThrowOnError>({
url: "/api/event/changes",
...options,
})
}
} }
export class Pty2 extends HeyApiClient { export class Pty2 extends HeyApiClient {
+62 -84
View File
@@ -2796,13 +2796,8 @@ export type UnauthorizedError = {
message: string message: string
} }
export type SessionWatermarks = {
[key: string]: unknown | number
}
export type SessionsResponse = { export type SessionsResponse = {
data: Array<SessionV2Info> data: Array<SessionV2Info>
watermarks: SessionWatermarks
cursor: { cursor: {
previous?: string previous?: string
next?: string next?: string
@@ -2930,7 +2925,6 @@ export type SessionLogItemStream = string
export type SessionMessagesResponse = { export type SessionMessagesResponse = {
data: Array<SessionMessage> data: Array<SessionMessage>
watermark?: number
cursor: { cursor: {
previous?: string previous?: string
next?: string next?: string
@@ -6615,20 +6609,6 @@ export type GlobalDisposed = {
} }
} }
export type EventLogHint = {
type: "log.hint"
aggregateID: string
seq: number
}
export type EventLogSweepRequired = {
type: "log.sweep_required"
}
export type EventLogChange = EventLogHint | EventLogSweepRequired
export type EventLogChangeStream = string
export type QuestionV2Request = { export type QuestionV2Request = {
id: string id: string
sessionID: string sessionID: string
@@ -7859,16 +7839,8 @@ export type SessionV2Info2 = {
revert?: RevertState2 revert?: RevertState2
} }
/**
* 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.
*/
export type SessionWatermarksV2 = {
[key: string]: unknown | number
}
export type SessionsResponseV2 = { export type SessionsResponseV2 = {
data: Array<SessionV2Info2> data: Array<SessionV2Info2>
watermarks: SessionWatermarksV2
cursor: { cursor: {
previous?: string | null previous?: string | null
next?: string | null next?: string | null
@@ -9059,7 +9031,6 @@ export type SessionLogItemStreamV2 = string
export type SessionMessagesResponseV2 = { export type SessionMessagesResponseV2 = {
data: Array<SessionMessage2> data: Array<SessionMessage2>
watermark?: number
cursor: { cursor: {
previous?: string | null previous?: string | null
next?: string | null next?: string | null
@@ -9329,6 +9300,38 @@ export type McpServer2 = {
integrationID?: string integrationID?: string
} }
export type ProjectVcs2 = "git" | "hg"
export type ProjectIcon2 = {
url?: string
override?: string
color?: string
}
export type ProjectCommands2 = {
/**
* Startup script to run when creating a new workspace (worktree)
*/
start?: string
}
export type ProjectTime2 = {
created: number
updated: number
initialized?: number
}
export type ProjectV2 = {
id: string
worktree: string
vcs?: ProjectVcs2
name?: string
icon?: ProjectIcon2
commands?: ProjectCommands2
time: ProjectTime2
sandboxes: Array<string>
}
export type ProjectCurrent2 = { export type ProjectCurrent2 = {
id: string id: string
directory: string directory: string
@@ -11276,26 +11279,6 @@ export type V2EventV2 =
export type V2EventStreamV2 = string export type V2EventStreamV2 = string
/**
* 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 type EventLogHint2 = {
type: "log.hint"
aggregateID: string
seq: number
}
/**
* 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 type EventLogSweepRequired2 = {
type: "log.sweep_required"
}
export type EventLogChange2 = EventLogHint2 | EventLogSweepRequired2
export type EventLogChangeStream2 = string
export type PtyNotFoundErrorV2 = { export type PtyNotFoundErrorV2 = {
_tag: "PtyNotFoundError" _tag: "PtyNotFoundError"
ptyID: string ptyID: string
@@ -15770,7 +15753,6 @@ export type V2SessionActiveResponses = {
data: { data: {
[key: string]: unknown | SessionActiveV2 [key: string]: unknown | SessionActiveV2
} }
watermarks: SessionWatermarksV2
} }
} }
@@ -16563,7 +16545,7 @@ export type V2SessionLogData = {
after?: number | null after?: number | null
follow?: "true" | "false" | null follow?: "true" | "false" | null
} }
url: "/api/session/{sessionID}/log" url: "/api/experimental/session/{sessionID}/log"
} }
export type V2SessionLogErrors = { export type V2SessionLogErrors = {
@@ -17353,6 +17335,35 @@ export type V2CredentialUpdateResponses = {
export type V2CredentialUpdateResponse = V2CredentialUpdateResponses[keyof V2CredentialUpdateResponses] export type V2CredentialUpdateResponse = V2CredentialUpdateResponses[keyof V2CredentialUpdateResponses]
export type V2ProjectListData = {
body?: never
path?: never
query?: never
url: "/api/project"
}
export type V2ProjectListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
}
export type V2ProjectListError = V2ProjectListErrors[keyof V2ProjectListErrors]
export type V2ProjectListResponses = {
/**
* Success
*/
200: Array<ProjectV2>
}
export type V2ProjectListResponse = V2ProjectListResponses[keyof V2ProjectListResponses]
export type V2ProjectCurrentData = { export type V2ProjectCurrentData = {
body?: never body?: never
path?: never path?: never
@@ -18176,39 +18187,6 @@ export type V2EventSubscribeResponses = {
export type V2EventSubscribeResponse = V2EventSubscribeResponses[keyof V2EventSubscribeResponses] export type V2EventSubscribeResponse = V2EventSubscribeResponses[keyof V2EventSubscribeResponses]
export type V2EventChangesData = {
body?: never
path?: never
query?: never
url: "/api/event/changes"
}
export type V2EventChangesErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
}
export type V2EventChangesError = V2EventChangesErrors[keyof V2EventChangesErrors]
export type V2EventChangesResponses = {
/**
* Success
*/
200: {
id: string | null
event: string
data: EventLogChangeStream2
}
}
export type V2EventChangesResponse = V2EventChangesResponses[keyof V2EventChangesResponses]
export type V2PtyListData = { export type V2PtyListData = {
body?: never body?: never
path?: never path?: never
+1 -6
View File
@@ -20,12 +20,7 @@ function eventData(data: unknown): Sse.Event {
export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) => export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
return handlers return handlers.handleRaw("event.subscribe", () =>
.handle(
"event.changes",
Effect.fn(() => Effect.succeed(events.changes())),
)
.handleRaw("event.subscribe", () =>
Effect.gen(function* () { Effect.gen(function* () {
const connected = { const connected = {
id: EventV2.ID.create(), id: EventV2.ID.create(),
-5
View File
@@ -38,10 +38,6 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
catch: () => new InvalidCursorError({ message: "Invalid cursor" }), catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
}) })
const order = decoded?.order ?? ctx.query.order ?? "desc" 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 const messages = yield* session
.messages({ .messages({
sessionID: ctx.params.sessionID, sessionID: ctx.params.sessionID,
@@ -74,7 +70,6 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
const last = messages.at(-1) const last = messages.at(-1)
return { return {
data: messages, data: messages,
watermark,
cursor: { cursor: {
previous: first ? cursor.encode(first, order, "previous") : undefined, previous: first ? cursor.encode(first, order, "previous") : undefined,
next: last ? cursor.encode(last, order, "next") : undefined, next: last ? cursor.encode(last, order, "next") : undefined,
-3
View File
@@ -44,7 +44,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
const last = sessions.at(-1) const last = sessions.at(-1)
return { return {
data: sessions, data: sessions,
watermarks: Object.fromEntries(page.watermarks),
cursor: { cursor: {
previous: first previous: first
? SessionsCursor.make({ ? SessionsCursor.make({
@@ -89,10 +88,8 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.active", "session.active",
Effect.fn(function* () { Effect.fn(function* () {
const active = yield* session.active const active = yield* session.active
const watermarks = yield* session.watermarks(Array.from(active))
return { return {
data: Object.fromEntries(Array.from(active, (sessionID) => [sessionID, { type: "running" as const }])), data: Object.fromEntries(Array.from(active, (sessionID) => [sessionID, { type: "running" as const }])),
watermarks: Object.fromEntries(watermarks),
} }
}), }),
) )
+1 -1
View File
@@ -911,7 +911,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
.then((active) => { .then((active) => {
if (generation !== connectionGeneration) return if (generation !== connectionGeneration) return
const status: Record<string, DataSessionStatus> = Object.fromEntries( const status: Record<string, DataSessionStatus> = Object.fromEntries(
Object.keys(active.data).map((sessionID) => [sessionID, "running" as const]), Object.keys(active).map((sessionID) => [sessionID, "running" as const]),
) )
for (const sessionID of changed) status[sessionID] = store.session.status[sessionID] for (const sessionID of changed) status[sessionID] = store.session.status[sessionID]
setStore("session", "status", reconcile(status)) setStore("session", "status", reconcile(status))
+3 -3
View File
@@ -119,7 +119,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
} }
if (url.pathname === "/api/session/active") { if (url.pathname === "/api/session/active") {
requests.active++ requests.active++
if (requests.active === 1) return json({ data: { "session-stale": { type: "running" } }, watermarks: {} }) if (requests.active === 1) return json({ data: { "session-stale": { type: "running" } } })
return new Promise<Response>((resolve) => { return new Promise<Response>((resolve) => {
resolveActive = resolve resolveActive = resolve
}) })
@@ -189,7 +189,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
}, },
}) })
await wait(() => data.session.status("session-new") === "running") await wait(() => data.session.status("session-new") === "running")
resolveActive(json({ data: {}, watermarks: {} })) resolveActive(json({ data: {} }))
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000) await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
await wait(() => data.session.status("session-stale") === "idle") await wait(() => data.session.status("session-stale") === "idle")
@@ -350,7 +350,7 @@ test("tracks session status from active sessions and execution events", async ()
const events = createEventStream() const events = createEventStream()
const calls = createFetch((url) => { const calls = createFetch((url) => {
if (url.pathname === "/api/session/active") if (url.pathname === "/api/session/active")
return json({ data: { "session-active": { type: "running" } }, watermarks: {} }) return json({ data: { "session-active": { type: "running" } } })
}, events) }, events)
let data!: ReturnType<typeof useData> let data!: ReturnType<typeof useData>
+1 -1
View File
@@ -100,7 +100,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/api/mcp") if (url.pathname === "/api/mcp")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] }) 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") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {}, watermarks: {} }) if (url.pathname === "/api/session/active") return json({ data: {} })
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] }) if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
if ( if (
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes( ["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(