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