refactor(api): remove watermark sync surface
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"],
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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.",
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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" }))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user