feat(core): session.pending.list API with pending-only session_pending storage (#36126)
This commit is contained in:
parent
99156c10e6
commit
a72992e00f
43 changed files with 986 additions and 649 deletions
|
|
@ -154,8 +154,8 @@ const table = sqliteTable("session", {
|
||||||
## V2 Session Core
|
## V2 Session Core
|
||||||
|
|
||||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
|
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
|
||||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
|
||||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||||
- Preserve one explicit `llm.stream(request)` call per step and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
- Preserve one explicit `llm.stream(request)` call per step and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ _Avoid_: Response envelope
|
||||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose system text, **Instruction Baseline**, tools, and step-local additions remain separate.
|
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose system text, **Instruction Baseline**, tools, and step-local additions remain separate.
|
||||||
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
|
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
|
||||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||||
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||||
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
||||||
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
||||||
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
||||||
|
|
|
||||||
2
bun.lock
2
bun.lock
|
|
@ -790,6 +790,8 @@
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||||
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -226,64 +226,69 @@ export type Endpoint5_20Input = { readonly sessionID: Endpoint5_20Request["param
|
||||||
export type Endpoint5_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
export type Endpoint5_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
||||||
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||||
|
|
||||||
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
|
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.pending.list"]>[0]
|
||||||
export type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
|
export type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
|
||||||
export type Endpoint5_21Output = EffectValue<
|
export type Endpoint5_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.pending.list"]>>["data"]
|
||||||
|
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||||
|
|
||||||
|
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
|
||||||
|
export type Endpoint5_22Input = { readonly sessionID: Endpoint5_22Request["params"]["sessionID"] }
|
||||||
|
export type Endpoint5_22Output = EffectValue<
|
||||||
ReturnType<RawClient["server.session"]["session.instructions.entry.list"]>
|
ReturnType<RawClient["server.session"]["session.instructions.entry.list"]>
|
||||||
>["data"]
|
>["data"]
|
||||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||||
input: Endpoint5_21Input,
|
|
||||||
) => Effect.Effect<Endpoint5_21Output, E>
|
|
||||||
|
|
||||||
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
|
|
||||||
export type Endpoint5_22Input = {
|
|
||||||
readonly sessionID: Endpoint5_22Request["params"]["sessionID"]
|
|
||||||
readonly key: Endpoint5_22Request["params"]["key"]
|
|
||||||
readonly value: Endpoint5_22Request["payload"]["value"]
|
|
||||||
}
|
|
||||||
export type Endpoint5_22Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
|
|
||||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
|
||||||
input: Endpoint5_22Input,
|
input: Endpoint5_22Input,
|
||||||
) => Effect.Effect<Endpoint5_22Output, E>
|
) => Effect.Effect<Endpoint5_22Output, E>
|
||||||
|
|
||||||
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
|
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
|
||||||
export type Endpoint5_23Input = {
|
export type Endpoint5_23Input = {
|
||||||
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
|
||||||
readonly key: Endpoint5_23Request["params"]["key"]
|
readonly key: Endpoint5_23Request["params"]["key"]
|
||||||
|
readonly value: Endpoint5_23Request["payload"]["value"]
|
||||||
}
|
}
|
||||||
export type Endpoint5_23Output = EffectValue<
|
export type Endpoint5_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
|
||||||
ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]>
|
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||||
>
|
|
||||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
|
||||||
input: Endpoint5_23Input,
|
input: Endpoint5_23Input,
|
||||||
) => Effect.Effect<Endpoint5_23Output, E>
|
) => Effect.Effect<Endpoint5_23Output, E>
|
||||||
|
|
||||||
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
|
||||||
export type Endpoint5_24Input = {
|
export type Endpoint5_24Input = {
|
||||||
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
|
||||||
readonly after?: Endpoint5_24Request["query"]["after"]
|
readonly key: Endpoint5_24Request["params"]["key"]
|
||||||
readonly follow?: Endpoint5_24Request["query"]["follow"]
|
|
||||||
}
|
}
|
||||||
export type Endpoint5_24Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
|
export type Endpoint5_24Output = EffectValue<
|
||||||
export type SessionLogOperation<E = never> = (input: Endpoint5_24Input) => Stream.Stream<Endpoint5_24Output, E>
|
ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]>
|
||||||
|
>
|
||||||
|
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||||
|
input: Endpoint5_24Input,
|
||||||
|
) => Effect.Effect<Endpoint5_24Output, E>
|
||||||
|
|
||||||
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||||
export type Endpoint5_25Input = { readonly sessionID: Endpoint5_25Request["params"]["sessionID"] }
|
export type Endpoint5_25Input = {
|
||||||
export type Endpoint5_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
|
||||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
readonly after?: Endpoint5_25Request["query"]["after"]
|
||||||
|
readonly follow?: Endpoint5_25Request["query"]["follow"]
|
||||||
|
}
|
||||||
|
export type Endpoint5_25Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
|
||||||
|
export type SessionLogOperation<E = never> = (input: Endpoint5_25Input) => Stream.Stream<Endpoint5_25Output, E>
|
||||||
|
|
||||||
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
export type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
|
export type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
|
||||||
export type Endpoint5_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
export type Endpoint5_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
||||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
export type SessionInterruptOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||||
|
|
||||||
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||||
export type Endpoint5_27Input = {
|
export type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
|
||||||
readonly sessionID: Endpoint5_27Request["params"]["sessionID"]
|
export type Endpoint5_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
||||||
readonly messageID: Endpoint5_27Request["params"]["messageID"]
|
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||||
|
|
||||||
|
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
|
export type Endpoint5_28Input = {
|
||||||
|
readonly sessionID: Endpoint5_28Request["params"]["sessionID"]
|
||||||
|
readonly messageID: Endpoint5_28Request["params"]["messageID"]
|
||||||
}
|
}
|
||||||
export type Endpoint5_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
export type Endpoint5_28Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
export type SessionMessageOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
|
||||||
|
|
||||||
export interface SessionApi<E = never> {
|
export interface SessionApi<E = never> {
|
||||||
readonly list: SessionListOperation<E>
|
readonly list: SessionListOperation<E>
|
||||||
|
|
@ -309,6 +314,7 @@ export interface SessionApi<E = never> {
|
||||||
readonly commit: SessionRevertCommitOperation<E>
|
readonly commit: SessionRevertCommitOperation<E>
|
||||||
}
|
}
|
||||||
readonly context: SessionContextOperation<E>
|
readonly context: SessionContextOperation<E>
|
||||||
|
readonly pending: { readonly list: SessionPendingListOperation<E> }
|
||||||
readonly instructions: {
|
readonly instructions: {
|
||||||
readonly entry: {
|
readonly entry: {
|
||||||
readonly list: SessionInstructionsEntryListOperation<E>
|
readonly list: SessionInstructionsEntryListOperation<E>
|
||||||
|
|
|
||||||
|
|
@ -318,43 +318,51 @@ const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20I
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
|
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.pending.list"]>[0]
|
||||||
type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
|
type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
|
||||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||||
|
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
|
||||||
|
type Endpoint5_22Input = { readonly sessionID: Endpoint5_22Request["params"]["sessionID"] }
|
||||||
|
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
|
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
|
||||||
type Endpoint5_22Input = {
|
type Endpoint5_23Input = {
|
||||||
readonly sessionID: Endpoint5_22Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
|
||||||
readonly key: Endpoint5_22Request["params"]["key"]
|
readonly key: Endpoint5_23Request["params"]["key"]
|
||||||
readonly value: Endpoint5_22Request["payload"]["value"]
|
readonly value: Endpoint5_23Request["payload"]["value"]
|
||||||
}
|
}
|
||||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||||
raw["session.instructions.entry.put"]({
|
raw["session.instructions.entry.put"]({
|
||||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||||
payload: { value: input["value"] },
|
payload: { value: input["value"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
|
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
|
||||||
type Endpoint5_23Input = {
|
type Endpoint5_24Input = {
|
||||||
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
|
||||||
readonly key: Endpoint5_23Request["params"]["key"]
|
readonly key: Endpoint5_24Request["params"]["key"]
|
||||||
}
|
}
|
||||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||||
type Endpoint5_24Input = {
|
type Endpoint5_25Input = {
|
||||||
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
|
||||||
readonly after?: Endpoint5_24Request["query"]["after"]
|
readonly after?: Endpoint5_25Request["query"]["after"]
|
||||||
readonly follow?: Endpoint5_24Request["query"]["follow"]
|
readonly follow?: Endpoint5_25Request["query"]["follow"]
|
||||||
}
|
}
|
||||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
raw["session.log"]({
|
raw["session.log"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
|
|
@ -365,22 +373,22 @@ const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24I
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
type Endpoint5_25Input = { readonly sessionID: Endpoint5_25Request["params"]["sessionID"] }
|
|
||||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
|
||||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
|
||||||
|
|
||||||
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
|
||||||
type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
|
type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
|
||||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||||
|
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||||
|
type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
|
||||||
|
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
type Endpoint5_27Input = {
|
type Endpoint5_28Input = {
|
||||||
readonly sessionID: Endpoint5_27Request["params"]["sessionID"]
|
readonly sessionID: Endpoint5_28Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint5_27Request["params"]["messageID"]
|
readonly messageID: Endpoint5_28Request["params"]["messageID"]
|
||||||
}
|
}
|
||||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||||
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),
|
||||||
|
|
@ -406,11 +414,12 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||||
wait: Endpoint5_16(raw),
|
wait: Endpoint5_16(raw),
|
||||||
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
|
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
|
||||||
context: Endpoint5_20(raw),
|
context: Endpoint5_20(raw),
|
||||||
instructions: { entry: { list: Endpoint5_21(raw), put: Endpoint5_22(raw), remove: Endpoint5_23(raw) } },
|
pending: { list: Endpoint5_21(raw) },
|
||||||
log: Endpoint5_24(raw),
|
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
|
||||||
interrupt: Endpoint5_25(raw),
|
log: Endpoint5_25(raw),
|
||||||
background: Endpoint5_26(raw),
|
interrupt: Endpoint5_26(raw),
|
||||||
message: Endpoint5_27(raw),
|
background: Endpoint5_27(raw),
|
||||||
|
message: Endpoint5_28(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
type Endpoint6_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
type Endpoint6_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export { Question } from "@opencode-ai/schema/question"
|
||||||
export { Reference } from "@opencode-ai/schema/reference"
|
export { Reference } from "@opencode-ai/schema/reference"
|
||||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||||
export { Session } from "@opencode-ai/schema/session"
|
export { Session } from "@opencode-ai/schema/session"
|
||||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
export { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
export { Skill } from "@opencode-ai/schema/skill"
|
export { Skill } from "@opencode-ai/schema/skill"
|
||||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ import type {
|
||||||
SessionRevertCommitOutput,
|
SessionRevertCommitOutput,
|
||||||
SessionContextInput,
|
SessionContextInput,
|
||||||
SessionContextOutput,
|
SessionContextOutput,
|
||||||
|
SessionPendingListInput,
|
||||||
|
SessionPendingListOutput,
|
||||||
SessionInstructionsEntryListInput,
|
SessionInstructionsEntryListInput,
|
||||||
SessionInstructionsEntryListOutput,
|
SessionInstructionsEntryListOutput,
|
||||||
SessionInstructionsEntryPutInput,
|
SessionInstructionsEntryPutInput,
|
||||||
|
|
@ -666,6 +668,19 @@ export function make(options: ClientOptions) {
|
||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
|
pending: {
|
||||||
|
list: (input: SessionPendingListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<{ readonly data: SessionPendingListOutput }>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
).then((value) => value.data),
|
||||||
|
},
|
||||||
instructions: {
|
instructions: {
|
||||||
entry: {
|
entry: {
|
||||||
list: (input: SessionInstructionsEntryListInput, requestOptions?: RequestOptions) =>
|
list: (input: SessionInstructionsEntryListInput, requestOptions?: RequestOptions) =>
|
||||||
|
|
|
||||||
|
|
@ -624,7 +624,6 @@ export type SessionPromptOutput = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
timeCreated: number
|
timeCreated: number
|
||||||
promotedSeq?: number
|
|
||||||
type: "user"
|
type: "user"
|
||||||
data: {
|
data: {
|
||||||
text: string
|
text: string
|
||||||
|
|
@ -824,7 +823,6 @@ export type SessionCommandOutput = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
timeCreated: number
|
timeCreated: number
|
||||||
promotedSeq?: number
|
|
||||||
type: "user"
|
type: "user"
|
||||||
data: {
|
data: {
|
||||||
text: string
|
text: string
|
||||||
|
|
@ -922,7 +920,6 @@ export type SessionSyntheticOutput = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
timeCreated: number
|
timeCreated: number
|
||||||
promotedSeq?: number
|
|
||||||
type: "synthetic"
|
type: "synthetic"
|
||||||
data: { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
|
data: { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
|
||||||
delivery: "steer" | "queue"
|
delivery: "steer" | "queue"
|
||||||
|
|
@ -943,14 +940,7 @@ export type SessionCompactInput = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionCompactOutput = {
|
export type SessionCompactOutput = {
|
||||||
data: {
|
data: { admittedSeq: number; id: string; sessionID: string; timeCreated: number; type: "compaction" }
|
||||||
admittedSeq: number
|
|
||||||
id: string
|
|
||||||
sessionID: string
|
|
||||||
timeCreated: number
|
|
||||||
type: "compaction"
|
|
||||||
handledSeq?: number
|
|
||||||
}
|
|
||||||
}["data"]
|
}["data"]
|
||||||
|
|
||||||
export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||||
|
|
@ -1145,6 +1135,44 @@ export type SessionContextOutput = {
|
||||||
>
|
>
|
||||||
}["data"]
|
}["data"]
|
||||||
|
|
||||||
|
export type SessionPendingListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||||
|
|
||||||
|
export type SessionPendingListOutput = {
|
||||||
|
data: Array<
|
||||||
|
| {
|
||||||
|
admittedSeq: number
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
timeCreated: number
|
||||||
|
type: "user"
|
||||||
|
data: {
|
||||||
|
text: string
|
||||||
|
files?: Array<{
|
||||||
|
data: string
|
||||||
|
mime: string
|
||||||
|
source: { type: "inline" } | { type: "uri"; uri: string }
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
mention?: { start: number; end: number; text: string }
|
||||||
|
}>
|
||||||
|
agents?: Array<{ name: string; mention?: { start: number; end: number; text: string } }>
|
||||||
|
metadata?: { [x: string]: JsonValue }
|
||||||
|
}
|
||||||
|
delivery: "steer" | "queue"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
admittedSeq: number
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
timeCreated: number
|
||||||
|
type: "synthetic"
|
||||||
|
data: { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
|
||||||
|
delivery: "steer" | "queue"
|
||||||
|
}
|
||||||
|
| { admittedSeq: number; id: string; sessionID: string; timeCreated: number; type: "compaction" }
|
||||||
|
>
|
||||||
|
}["data"]
|
||||||
|
|
||||||
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||||
|
|
||||||
export type SessionInstructionsEntryListOutput = { data: Array<{ key: string; value: JsonValue }> }["data"]
|
export type SessionInstructionsEntryListOutput = { data: Array<{ key: string; value: JsonValue }> }["data"]
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,10 @@
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
|
||||||
import { Location as CoreLocation } from "@opencode-ai/core/location"
|
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
|
||||||
import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input"
|
|
||||||
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
|
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Location } from "@opencode-ai/schema/location"
|
|
||||||
import { Model } from "@opencode-ai/schema/model"
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
import { Project } from "@opencode-ai/schema/project"
|
|
||||||
import { Provider } from "@opencode-ai/schema/provider"
|
|
||||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { SessionInput } from "@opencode-ai/schema/session-input"
|
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
|
||||||
import { Api } from "@opencode-ai/server/api"
|
|
||||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
|
||||||
import { ClientApi, groupNames, promiseOmitEndpoints } from "../src/contract"
|
|
||||||
|
|
||||||
const Client = await import("../src/effect")
|
const Client = await import("../src/effect")
|
||||||
|
|
||||||
|
|
@ -29,34 +14,6 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||||
expect(Client.Session).toBe(Session)
|
expect(Client.Session).toBe(Session)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
|
||||||
expect(AgentV2.ID).toBe(Agent.ID)
|
|
||||||
expect(CoreLocation.Ref).toBe(Location.Ref)
|
|
||||||
expect(ModelV2.Ref).toBe(Model.Ref)
|
|
||||||
expect(SessionV2.Info).toBe(Session.Info)
|
|
||||||
expect(ProjectV2.Current).toBe(Project.Current)
|
|
||||||
expect(ProjectV2.Directory).toBe(Project.Directory)
|
|
||||||
expect(ProjectV2.Directories).toBe(Project.Directories)
|
|
||||||
expect(CoreSessionInput.Message).toBe(SessionInput.Message)
|
|
||||||
expect(CoreSessionInput.User).toBe(SessionInput.User)
|
|
||||||
expect(CoreSessionInput.Synthetic).toBe(SessionInput.Synthetic)
|
|
||||||
expect(CoreSessionMessage.Info).toBe(SessionMessage.Info)
|
|
||||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
|
||||||
expect(Api.groups["server.project"].identifier).toBe("server.project")
|
|
||||||
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
|
||||||
expect(Session.ID.create()).toStartWith("ses_")
|
|
||||||
expect(Project.ID.global).toBe("global")
|
|
||||||
expect(Provider.ID.anthropic).toBe("anthropic")
|
|
||||||
expect(Workspace.ID.create()).toStartWith("wrk_")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("client and Server contracts generate identically", () => {
|
|
||||||
const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints })
|
|
||||||
const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
|
|
||||||
|
|
||||||
expect(emitPromise(client)).toEqual(emitPromise(server))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shared DTO schemas construct and decode plain objects", () => {
|
test("shared DTO schemas construct and decode plain objects", () => {
|
||||||
const made = Prompt.make({ text: "hello" })
|
const made = Prompt.make({ text: "hello" })
|
||||||
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
||||||
|
|
@ -67,5 +24,4 @@ test("shared DTO schemas construct and decode plain objects", () => {
|
||||||
expect(Object.getPrototypeOf(content)).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(content)).toBe(Object.prototype)
|
||||||
expect(Prompt.ast.annotations?.identifier).toBe("Prompt")
|
expect(Prompt.ast.annotations?.identifier).toBe("Prompt")
|
||||||
expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text")
|
expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text")
|
||||||
expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText)
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,34 @@ test("session instructions methods use the public HTTP contract", async () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("session.pending.list uses the public HTTP contract", async () => {
|
||||||
|
const requests: Array<{ method: string; url: string }> = []
|
||||||
|
const pending = [
|
||||||
|
{
|
||||||
|
admittedSeq: 3,
|
||||||
|
id: "msg_pending",
|
||||||
|
sessionID: "ses_test",
|
||||||
|
timeCreated: 1_717_171_717_000,
|
||||||
|
type: "user",
|
||||||
|
data: { text: "Fix the failing tests" },
|
||||||
|
delivery: "steer",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async (input, init) => {
|
||||||
|
const request = input instanceof Request ? input : new Request(input, init)
|
||||||
|
requests.push({ method: request.method, url: request.url })
|
||||||
|
return Response.json({ data: pending })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await client.session.pending.list({ sessionID: "ses_test" })
|
||||||
|
|
||||||
|
expect(result).toEqual(pending)
|
||||||
|
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
|
||||||
|
})
|
||||||
|
|
||||||
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "01451b27-1e51-4657-b2d0-4b457dffa3ec",
|
"id": "2c759c08-79c8-4179-9e15-d2fea45c9dec",
|
||||||
"prevIds": ["8c1748cc-f978-4df4-879d-fe7fda5c4c34"],
|
"prevIds": ["01451b27-1e51-4657-b2d0-4b457dffa3ec"],
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
"name": "workspace",
|
"name": "workspace",
|
||||||
|
|
@ -65,11 +65,11 @@
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "session_input",
|
"name": "session_message",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "session_message",
|
"name": "session_pending",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -980,86 +980,6 @@
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "part"
|
"table": "part"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "session_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "type",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "data",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "delivery",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "admitted_seq",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "promoted_seq",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "time_created",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
|
|
@ -1130,6 +1050,76 @@
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "session_message"
|
"table": "session_message"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "session_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "type",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "data",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "delivery",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "admitted_seq",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "time_created",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
|
|
@ -1616,9 +1606,9 @@
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "fk_session_input_session_id_session_id_fk",
|
"name": "fk_session_message_session_id_session_id_fk",
|
||||||
"entityType": "fks",
|
"entityType": "fks",
|
||||||
"table": "session_input"
|
"table": "session_message"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": ["session_id"],
|
||||||
|
|
@ -1627,9 +1617,9 @@
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "fk_session_message_session_id_session_id_fk",
|
"name": "fk_session_input_session_id_session_id_fk",
|
||||||
"entityType": "fks",
|
"entityType": "fks",
|
||||||
"table": "session_message"
|
"table": "session_pending"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["project_id"],
|
"columns": ["project_id"],
|
||||||
|
|
@ -1761,15 +1751,15 @@
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": ["id"],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_input_pk",
|
"name": "session_message_pk",
|
||||||
"table": "session_input",
|
"table": "session_message",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": ["id"],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_message_pk",
|
"name": "session_input_pk",
|
||||||
"table": "session_message",
|
"table": "session_pending",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1902,82 +1892,6 @@
|
||||||
"entityType": "indexes",
|
"entityType": "indexes",
|
||||||
"table": "part"
|
"table": "part"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"value": "session_id",
|
|
||||||
"isExpression": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "promoted_seq",
|
|
||||||
"isExpression": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "delivery",
|
|
||||||
"isExpression": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "admitted_seq",
|
|
||||||
"isExpression": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"where": null,
|
|
||||||
"origin": "manual",
|
|
||||||
"name": "session_input_session_pending_delivery_seq_idx",
|
|
||||||
"entityType": "indexes",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"value": "session_id",
|
|
||||||
"isExpression": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": true,
|
|
||||||
"where": "\"session_input\".\"type\" = 'compaction' and \"session_input\".\"promoted_seq\" is null",
|
|
||||||
"origin": "manual",
|
|
||||||
"name": "session_input_session_pending_compaction_idx",
|
|
||||||
"entityType": "indexes",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"value": "session_id",
|
|
||||||
"isExpression": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "admitted_seq",
|
|
||||||
"isExpression": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": true,
|
|
||||||
"where": null,
|
|
||||||
"origin": "manual",
|
|
||||||
"name": "session_input_session_admitted_seq_idx",
|
|
||||||
"entityType": "indexes",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"value": "session_id",
|
|
||||||
"isExpression": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "promoted_seq",
|
|
||||||
"isExpression": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": true,
|
|
||||||
"where": null,
|
|
||||||
"origin": "manual",
|
|
||||||
"name": "session_input_session_promoted_seq_idx",
|
|
||||||
"entityType": "indexes",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
@ -2054,6 +1968,60 @@
|
||||||
"entityType": "indexes",
|
"entityType": "indexes",
|
||||||
"table": "session_message"
|
"table": "session_message"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"value": "session_id",
|
||||||
|
"isExpression": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "delivery",
|
||||||
|
"isExpression": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admitted_seq",
|
||||||
|
"isExpression": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"where": null,
|
||||||
|
"origin": "manual",
|
||||||
|
"name": "session_pending_session_delivery_seq_idx",
|
||||||
|
"entityType": "indexes",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"value": "session_id",
|
||||||
|
"isExpression": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"where": "\"session_pending\".\"type\" = 'compaction'",
|
||||||
|
"origin": "manual",
|
||||||
|
"name": "session_pending_session_compaction_idx",
|
||||||
|
"entityType": "indexes",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"value": "session_id",
|
||||||
|
"isExpression": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admitted_seq",
|
||||||
|
"isExpression": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"where": null,
|
||||||
|
"origin": "manual",
|
||||||
|
"name": "session_pending_session_admitted_seq_idx",
|
||||||
|
"entityType": "indexes",
|
||||||
|
"table": "session_pending"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
@ -2111,5 +2079,5 @@
|
||||||
"table": "session"
|
"table": "session"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"renames": []
|
"renames": ["session_input->session_pending"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,4 +54,10 @@ export function path() {
|
||||||
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
|
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] })
|
// Resolve the database path lazily so tests and embedders that set
|
||||||
|
// Flag.OPENCODE_DB after module evaluation still control the storage target.
|
||||||
|
export const node = makeGlobalNode({
|
||||||
|
service: Service,
|
||||||
|
layer: Layer.suspend(() => layerFromPath(path())),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
|
|
||||||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -52,5 +52,6 @@ export const migrations = (
|
||||||
import("./migration/20260709013000_generic_session_input"),
|
import("./migration/20260709013000_generic_session_input"),
|
||||||
import("./migration/20260709025533_drop-todo"),
|
import("./migration/20260709025533_drop-todo"),
|
||||||
import("./migration/20260709163752_time_suspended"),
|
import("./migration/20260709163752_time_suspended"),
|
||||||
|
import("./migration/20260709190621_session_pending_table"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260709190621_session_pending_table",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
// Beta reset: session_input becomes the pending-only session_pending
|
||||||
|
// table. Dropping the old table discards consumed ledger rows and any
|
||||||
|
// in-flight pending work along with every historical index variant.
|
||||||
|
yield* tx.run(`DROP TABLE \`session_input\`;`)
|
||||||
|
yield* tx.run(`
|
||||||
|
CREATE TABLE \`session_pending\` (
|
||||||
|
\`id\` text PRIMARY KEY,
|
||||||
|
\`session_id\` text NOT NULL,
|
||||||
|
\`type\` text NOT NULL,
|
||||||
|
\`data\` text NOT NULL,
|
||||||
|
\`delivery\` text,
|
||||||
|
\`admitted_seq\` integer NOT NULL,
|
||||||
|
\`time_created\` integer NOT NULL,
|
||||||
|
CONSTRAINT \`fk_session_pending_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE INDEX \`session_pending_session_delivery_seq_idx\` ON \`session_pending\` (\`session_id\`,\`delivery\`,\`admitted_seq\`);`,
|
||||||
|
)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE UNIQUE INDEX \`session_pending_session_compaction_idx\` ON \`session_pending\` (\`session_id\`) WHERE "session_pending"."type" = 'compaction';`,
|
||||||
|
)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
|
|
@ -166,19 +166,6 @@ export default {
|
||||||
CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE
|
CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
yield* tx.run(`
|
|
||||||
CREATE TABLE \`session_input\` (
|
|
||||||
\`id\` text PRIMARY KEY,
|
|
||||||
\`session_id\` text NOT NULL,
|
|
||||||
\`type\` text NOT NULL,
|
|
||||||
\`data\` text NOT NULL,
|
|
||||||
\`delivery\` text,
|
|
||||||
\`admitted_seq\` integer NOT NULL,
|
|
||||||
\`promoted_seq\` integer,
|
|
||||||
\`time_created\` integer NOT NULL,
|
|
||||||
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
yield* tx.run(`
|
yield* tx.run(`
|
||||||
CREATE TABLE \`session_message\` (
|
CREATE TABLE \`session_message\` (
|
||||||
\`id\` text PRIMARY KEY,
|
\`id\` text PRIMARY KEY,
|
||||||
|
|
@ -191,6 +178,18 @@ export default {
|
||||||
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
yield* tx.run(`
|
||||||
|
CREATE TABLE \`session_pending\` (
|
||||||
|
\`id\` text PRIMARY KEY,
|
||||||
|
\`session_id\` text NOT NULL,
|
||||||
|
\`type\` text NOT NULL,
|
||||||
|
\`data\` text NOT NULL,
|
||||||
|
\`delivery\` text,
|
||||||
|
\`admitted_seq\` integer NOT NULL,
|
||||||
|
\`time_created\` integer NOT NULL,
|
||||||
|
CONSTRAINT \`fk_session_pending_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`)
|
||||||
yield* tx.run(`
|
yield* tx.run(`
|
||||||
CREATE TABLE \`session\` (
|
CREATE TABLE \`session\` (
|
||||||
\`id\` text PRIMARY KEY,
|
\`id\` text PRIMARY KEY,
|
||||||
|
|
@ -249,18 +248,6 @@ export default {
|
||||||
)
|
)
|
||||||
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
|
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
|
||||||
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
|
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
|
||||||
yield* tx.run(
|
|
||||||
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
|
|
||||||
)
|
|
||||||
yield* tx.run(
|
|
||||||
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
|
|
||||||
)
|
|
||||||
yield* tx.run(
|
|
||||||
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
|
|
||||||
)
|
|
||||||
yield* tx.run(
|
|
||||||
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
|
|
||||||
)
|
|
||||||
yield* tx.run(
|
yield* tx.run(
|
||||||
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
|
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
|
||||||
)
|
)
|
||||||
|
|
@ -271,6 +258,15 @@ export default {
|
||||||
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||||
)
|
)
|
||||||
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
|
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE INDEX \`session_pending_session_delivery_seq_idx\` ON \`session_pending\` (\`session_id\`,\`delivery\`,\`admitted_seq\`);`,
|
||||||
|
)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE UNIQUE INDEX \`session_pending_session_compaction_idx\` ON \`session_pending\` (\`session_id\`) WHERE "session_pending"."type" = 'compaction';`,
|
||||||
|
)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
|
||||||
|
)
|
||||||
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
|
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
|
||||||
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
|
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
|
||||||
yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`)
|
yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import { makeGlobalNode } from "./effect/app-node"
|
||||||
import { LocationServiceMap } from "./location-service-map"
|
import { LocationServiceMap } from "./location-service-map"
|
||||||
import { MessageDecodeError } from "./session/error"
|
import { MessageDecodeError } from "./session/error"
|
||||||
import { SessionEvent } from "./session/event"
|
import { SessionEvent } from "./session/event"
|
||||||
import { SessionInput } from "./session/input"
|
import { SessionPending } from "./session/pending"
|
||||||
import { Snapshot } from "./snapshot"
|
import { Snapshot } from "./snapshot"
|
||||||
import { SessionRevert } from "./session/revert"
|
import { SessionRevert } from "./session/revert"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
|
|
@ -187,6 +187,12 @@ export interface Interface {
|
||||||
readonly context: (
|
readonly context: (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||||
|
/**
|
||||||
|
* Durable admitted session work not yet visible in projected history,
|
||||||
|
* ordered by admission. Includes unpromoted user and synthetic inputs and
|
||||||
|
* unhandled compaction barriers.
|
||||||
|
*/
|
||||||
|
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
||||||
/**
|
/**
|
||||||
* Durable, ordered, gap-free session log read. Replays public durable
|
* Durable, ordered, gap-free session log read. Replays public durable
|
||||||
* session events after the exclusive `after` cursor, emits a `Synced`
|
* session events after the exclusive `after` cursor, emits a `Synced`
|
||||||
|
|
@ -216,9 +222,9 @@ export interface Interface {
|
||||||
files?: PromptInput.Prompt["files"]
|
files?: PromptInput.Prompt["files"]
|
||||||
agents?: PromptInput.Prompt["agents"]
|
agents?: PromptInput.Prompt["agents"]
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
delivery?: SessionInput.Delivery
|
delivery?: SessionPending.Delivery
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<SessionInput.User, NotFoundError | PromptConflictError | AttachmentError>
|
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError>
|
||||||
readonly command: (input: {
|
readonly command: (input: {
|
||||||
id?: SessionMessage.ID
|
id?: SessionMessage.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
|
|
@ -228,10 +234,10 @@ export interface Interface {
|
||||||
model?: ModelV2.Ref
|
model?: ModelV2.Ref
|
||||||
files?: PromptInput.Prompt["files"]
|
files?: PromptInput.Prompt["files"]
|
||||||
agents?: PromptInput.Prompt["agents"]
|
agents?: PromptInput.Prompt["agents"]
|
||||||
delivery?: SessionInput.Delivery
|
delivery?: SessionPending.Delivery
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<
|
}) => Effect.Effect<
|
||||||
SessionInput.User,
|
SessionPending.User,
|
||||||
NotFoundError | PromptConflictError | AttachmentError | CommandV2.NotFoundError | CommandV2.EvaluationError
|
NotFoundError | PromptConflictError | AttachmentError | CommandV2.NotFoundError | CommandV2.EvaluationError
|
||||||
>
|
>
|
||||||
readonly shell: (input: {
|
readonly shell: (input: {
|
||||||
|
|
@ -247,7 +253,7 @@ export interface Interface {
|
||||||
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
||||||
readonly compact: (
|
readonly compact: (
|
||||||
input: CompactInput,
|
input: CompactInput,
|
||||||
) => Effect.Effect<SessionInput.Compaction, NotFoundError | CompactionConflictError>
|
) => Effect.Effect<SessionPending.Compaction, NotFoundError | CompactionConflictError>
|
||||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||||
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||||
|
|
@ -259,9 +265,9 @@ export interface Interface {
|
||||||
text: string
|
text: string
|
||||||
description?: string
|
description?: string
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
delivery?: SessionInput.Delivery
|
delivery?: SessionPending.Delivery
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<SessionInput.Synthetic, NotFoundError | SyntheticConflictError>
|
}) => Effect.Effect<SessionPending.Synthetic, NotFoundError | SyntheticConflictError>
|
||||||
readonly revert: {
|
readonly revert: {
|
||||||
readonly stage: (input: {
|
readonly stage: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
|
|
@ -481,6 +487,10 @@ const layer = Layer.effect(
|
||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
return yield* store.context(sessionID)
|
return yield* store.context(sessionID)
|
||||||
}),
|
}),
|
||||||
|
pending: Effect.fn("V2Session.pending")(function* (sessionID) {
|
||||||
|
yield* result.get(sessionID)
|
||||||
|
return yield* SessionPending.list(db, sessionID)
|
||||||
|
}),
|
||||||
log: (input) =>
|
log: (input) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
result
|
result
|
||||||
|
|
@ -504,25 +514,25 @@ const layer = Layer.effect(
|
||||||
Effect.provideService(FSUtil.Service, fs),
|
Effect.provideService(FSUtil.Service, fs),
|
||||||
)
|
)
|
||||||
const messageID = input.id ?? SessionMessage.ID.create()
|
const messageID = input.id ?? SessionMessage.ID.create()
|
||||||
const admittedInput = SessionInput.Message.make({
|
const admittedInput = SessionPending.Message.make({
|
||||||
type: "user",
|
type: "user",
|
||||||
data: { ...prompt, metadata: input.metadata },
|
data: { ...prompt, metadata: input.metadata },
|
||||||
delivery: input.delivery ?? "steer",
|
delivery: input.delivery ?? "steer",
|
||||||
})
|
})
|
||||||
const admitted = yield* SessionInput.admit(db, events, {
|
const admitted = yield* SessionPending.admit(db, events, {
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
input: admittedInput,
|
input: admittedInput,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
defect instanceof SessionInput.LifecycleConflict
|
defect instanceof SessionPending.LifecycleConflict
|
||||||
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
: Effect.die(defect),
|
: Effect.die(defect),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
admitted.type !== "user" ||
|
admitted.type !== "user" ||
|
||||||
!SessionInput.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
|
!SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
|
||||||
)
|
)
|
||||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
if (input.resume !== false) {
|
if (input.resume !== false) {
|
||||||
|
|
@ -664,12 +674,12 @@ const layer = Layer.effect(
|
||||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
const inputID = input.id ?? SessionMessage.ID.create()
|
const inputID = input.id ?? SessionMessage.ID.create()
|
||||||
const admitted = yield* SessionInput.admitCompaction(db, events, {
|
const admitted = yield* SessionPending.admitCompaction(db, events, {
|
||||||
id: inputID,
|
id: inputID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
defect instanceof SessionInput.LifecycleConflict
|
defect instanceof SessionPending.LifecycleConflict
|
||||||
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
|
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
|
||||||
: Effect.die(defect),
|
: Effect.die(defect),
|
||||||
),
|
),
|
||||||
|
|
@ -709,7 +719,7 @@ const layer = Layer.effect(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
const inputID = input.id ?? SessionMessage.ID.create()
|
const inputID = input.id ?? SessionMessage.ID.create()
|
||||||
const admittedInput = SessionInput.Message.make({
|
const admittedInput = SessionPending.Message.make({
|
||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
data: {
|
data: {
|
||||||
text: input.text,
|
text: input.text,
|
||||||
|
|
@ -718,20 +728,20 @@ const layer = Layer.effect(
|
||||||
},
|
},
|
||||||
delivery: input.delivery ?? "steer",
|
delivery: input.delivery ?? "steer",
|
||||||
})
|
})
|
||||||
const admitted = yield* SessionInput.admit(db, events, {
|
const admitted = yield* SessionPending.admit(db, events, {
|
||||||
id: inputID,
|
id: inputID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
input: admittedInput,
|
input: admittedInput,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
defect instanceof SessionInput.LifecycleConflict
|
defect instanceof SessionPending.LifecycleConflict
|
||||||
? new SyntheticConflictError({ sessionID: input.sessionID, inputID })
|
? new SyntheticConflictError({ sessionID: input.sessionID, inputID })
|
||||||
: Effect.die(defect),
|
: Effect.die(defect),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
admitted.type !== "synthetic" ||
|
admitted.type !== "synthetic" ||
|
||||||
!SessionInput.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
|
!SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
|
||||||
)
|
)
|
||||||
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
|
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
|
||||||
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
|
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export * as SessionInput from "./input"
|
export * as SessionPending from "./pending"
|
||||||
|
|
||||||
import { and, asc, eq, isNull } from "drizzle-orm"
|
import { and, asc, eq } from "drizzle-orm"
|
||||||
import { DateTime, Effect, Schema } from "effect"
|
import { DateTime, Effect, Schema } from "effect"
|
||||||
import {
|
import {
|
||||||
Compaction,
|
Compaction,
|
||||||
|
|
@ -11,14 +11,16 @@ import {
|
||||||
SyntheticData,
|
SyntheticData,
|
||||||
User,
|
User,
|
||||||
UserData,
|
UserData,
|
||||||
} from "@opencode-ai/schema/session-input"
|
} from "@opencode-ai/schema/session-pending"
|
||||||
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type { Database } from "../database/database"
|
import type { Database } from "../database/database"
|
||||||
import type { EventV2 } from "../event"
|
import type { EventV2 } from "../event"
|
||||||
|
import { EventTable } from "../event/sql"
|
||||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionInputTable, SessionMessageTable } from "./sql"
|
import { SessionMessageTable, SessionPendingTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
|
|
@ -28,25 +30,28 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
|
||||||
const encodeUser = Schema.encodeSync(UserData)
|
const encodeUser = Schema.encodeSync(UserData)
|
||||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||||
|
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
|
||||||
|
const admittedEventType = Event.versionedType(
|
||||||
|
SessionEvent.InputAdmitted.type,
|
||||||
|
SessionEvent.InputAdmitted.durable.version,
|
||||||
|
)
|
||||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||||
|
|
||||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
|
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
||||||
id: SessionMessage.ID,
|
"SessionPending.LifecycleConflict",
|
||||||
}) {}
|
{
|
||||||
|
id: SessionMessage.ID,
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => {
|
const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
|
||||||
const base = {
|
const base = {
|
||||||
admittedSeq: row.admitted_seq,
|
admittedSeq: row.admitted_seq,
|
||||||
id: SessionMessage.ID.make(row.id),
|
id: SessionMessage.ID.make(row.id),
|
||||||
sessionID: SessionSchema.ID.make(row.session_id),
|
sessionID: SessionSchema.ID.make(row.session_id),
|
||||||
timeCreated: DateTime.makeUnsafe(row.time_created),
|
timeCreated: DateTime.makeUnsafe(row.time_created),
|
||||||
}
|
}
|
||||||
if (row.type === "compaction")
|
if (row.type === "compaction") return Compaction.make({ ...base, type: "compaction" })
|
||||||
return Compaction.make({
|
|
||||||
...base,
|
|
||||||
type: "compaction",
|
|
||||||
...(row.promoted_seq === null ? {} : { handledSeq: row.promoted_seq }),
|
|
||||||
})
|
|
||||||
if (!row.delivery) throw new LifecycleConflict({ id: base.id })
|
if (!row.delivery) throw new LifecycleConflict({ id: base.id })
|
||||||
if (row.type === "user")
|
if (row.type === "user")
|
||||||
return User.make({
|
return User.make({
|
||||||
|
|
@ -54,7 +59,6 @@ const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => {
|
||||||
type: "user",
|
type: "user",
|
||||||
data: decodeUser(row.data),
|
data: decodeUser(row.data),
|
||||||
delivery: row.delivery,
|
delivery: row.delivery,
|
||||||
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
|
|
||||||
})
|
})
|
||||||
if (row.type === "synthetic")
|
if (row.type === "synthetic")
|
||||||
return Synthetic.make({
|
return Synthetic.make({
|
||||||
|
|
@ -62,31 +66,29 @@ const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => {
|
||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
data: decodeSynthetic(row.data),
|
data: decodeSynthetic(row.data),
|
||||||
delivery: row.delivery,
|
delivery: row.delivery,
|
||||||
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
|
|
||||||
})
|
})
|
||||||
throw new LifecycleConflict({ id: base.id })
|
throw new LifecycleConflict({ id: base.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
|
export const find = Effect.fn("SessionPending.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
|
||||||
const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie)
|
const row = yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionPendingTable)
|
||||||
|
.where(eq(SessionPendingTable.id, id))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
return row === undefined ? undefined : fromRow(row)
|
return row === undefined ? undefined : fromRow(row)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const pendingCompaction = Effect.fn("SessionInput.pendingCompaction")(function* (
|
export const compaction = Effect.fn("SessionPending.compaction")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
const row = yield* db
|
const row = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.where(
|
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.type, "compaction")))
|
||||||
and(
|
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||||
eq(SessionInputTable.session_id, sessionID),
|
|
||||||
eq(SessionInputTable.type, "compaction"),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(SessionInputTable.admitted_seq))
|
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
@ -95,7 +97,51 @@ export const pendingCompaction = Effect.fn("SessionInput.pendingCompaction")(fun
|
||||||
return entry.type === "compaction" ? entry : undefined
|
return entry.type === "compaction" ? entry : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
export const admit = Effect.fn("SessionInput.admit")(function* (
|
/**
|
||||||
|
* Reconstruct the admitted record for a pending row that was already consumed
|
||||||
|
* by promotion. The projected `session_message` row proves promotion happened;
|
||||||
|
* the durable `session.input.admitted` event retains the exact admitted
|
||||||
|
* message, including delivery.
|
||||||
|
*/
|
||||||
|
const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
id: SessionMessage.ID,
|
||||||
|
) {
|
||||||
|
const message = yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionMessageTable)
|
||||||
|
.where(eq(SessionMessageTable.id, id))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
if (message === undefined) return undefined
|
||||||
|
if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic"))
|
||||||
|
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||||
|
const rows = yield* db
|
||||||
|
.select()
|
||||||
|
.from(EventTable)
|
||||||
|
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, admittedEventType)))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
for (const row of rows) {
|
||||||
|
const decoded = decodeAdmittedEvent(row.data)
|
||||||
|
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
|
||||||
|
const base = {
|
||||||
|
admittedSeq: row.seq,
|
||||||
|
id,
|
||||||
|
sessionID,
|
||||||
|
timeCreated: DateTime.makeUnsafe(row.created),
|
||||||
|
}
|
||||||
|
return decoded.value.input.type === "user"
|
||||||
|
? User.make({ ...base, ...decoded.value.input })
|
||||||
|
: Synthetic.make({ ...base, ...decoded.value.input })
|
||||||
|
}
|
||||||
|
// A projected message without an admitted event in this aggregate (for
|
||||||
|
// example fork-copied history) is not a retryable admission.
|
||||||
|
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||||
|
})
|
||||||
|
|
||||||
|
export const admit = Effect.fn("SessionPending.admit")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
request: {
|
request: {
|
||||||
|
|
@ -109,6 +155,8 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
||||||
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
|
||||||
|
if (promoted !== undefined) return promoted
|
||||||
return yield* events
|
return yield* events
|
||||||
.publish(SessionEvent.InputAdmitted, {
|
.publish(SessionEvent.InputAdmitted, {
|
||||||
inputID: request.id,
|
inputID: request.id,
|
||||||
|
|
@ -141,7 +189,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(function* (
|
export const admitCompaction = Effect.fn("SessionPending.admitCompaction")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||||
|
|
@ -153,7 +201,7 @@ export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(functio
|
||||||
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
|
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
|
||||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
}
|
}
|
||||||
const pending = yield* pendingCompaction(db, input.sessionID)
|
const pending = yield* compaction(db, input.sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
return yield* events
|
return yield* events
|
||||||
.publish(SessionEvent.Compaction.Admitted, {
|
.publish(SessionEvent.Compaction.Admitted, {
|
||||||
|
|
@ -164,14 +212,14 @@ export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(functio
|
||||||
Effect.flatMap((event) => {
|
Effect.flatMap((event) => {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return Effect.die(new Error("Compaction admission event is missing aggregate sequence"))
|
return Effect.die(new Error("Compaction admission event is missing aggregate sequence"))
|
||||||
return pendingCompaction(db, input.sessionID).pipe(
|
return compaction(db, input.sessionID).pipe(
|
||||||
Effect.flatMap((stored) =>
|
Effect.flatMap((stored) =>
|
||||||
stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
|
stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
pendingCompaction(db, input.sessionID).pipe(
|
compaction(db, input.sessionID).pipe(
|
||||||
Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
|
Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -180,7 +228,7 @@ export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(functio
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
|
export const projectAdmitted = Effect.fn("SessionPending.projectAdmitted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
request: {
|
request: {
|
||||||
readonly admittedSeq: number
|
readonly admittedSeq: number
|
||||||
|
|
@ -198,7 +246,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||||
const stored = yield* db
|
const stored = yield* db
|
||||||
.insert(SessionInputTable)
|
.insert(SessionPendingTable)
|
||||||
.values({
|
.values({
|
||||||
id: request.id,
|
id: request.id,
|
||||||
session_id: request.sessionID,
|
session_id: request.sessionID,
|
||||||
|
|
@ -209,13 +257,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
||||||
time_created: DateTime.toEpochMillis(request.timeCreated),
|
time_created: DateTime.toEpochMillis(request.timeCreated),
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.returning({ id: SessionInputTable.id })
|
.returning({ id: SessionPendingTable.id })
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompactionAdmitted")(function* (
|
export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompactionAdmitted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: {
|
input: {
|
||||||
readonly admittedSeq: number
|
readonly admittedSeq: number
|
||||||
|
|
@ -232,7 +280,7 @@ export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompacti
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
const stored = yield* db
|
const stored = yield* db
|
||||||
.insert(SessionInputTable)
|
.insert(SessionPendingTable)
|
||||||
.values({
|
.values({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
session_id: input.sessionID,
|
session_id: input.sessionID,
|
||||||
|
|
@ -249,84 +297,74 @@ export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompacti
|
||||||
const entry = fromRow(stored)
|
const entry = fromRow(stored)
|
||||||
return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id }))
|
return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id }))
|
||||||
}
|
}
|
||||||
const pending = yield* pendingCompaction(db, input.sessionID)
|
const pending = yield* compaction(db, input.sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* (
|
/**
|
||||||
|
* Consume one pending row at promotion. The row's content feeds the projected
|
||||||
|
* message insert inside the same event transaction; the deleted row is what
|
||||||
|
* makes the table pending-only.
|
||||||
|
*/
|
||||||
|
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: {
|
input: {
|
||||||
readonly id: SessionMessage.ID
|
readonly id: SessionMessage.ID
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly promotedSeq: number
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (yield* pendingCompaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
const updated = yield* db
|
const deleted = yield* db
|
||||||
.update(SessionInputTable)
|
.delete(SessionPendingTable)
|
||||||
.set({ promoted_seq: input.promotedSeq })
|
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(SessionInputTable.id, input.id),
|
|
||||||
eq(SessionInputTable.session_id, input.sessionID),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const stored = updated ? fromRow(updated) : yield* find(db, input.id)
|
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
if (
|
const stored = fromRow(deleted)
|
||||||
!stored ||
|
if (stored.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
stored.type === "compaction" ||
|
|
||||||
stored.sessionID !== input.sessionID ||
|
|
||||||
stored.promotedSeq !== input.promotedSeq
|
|
||||||
)
|
|
||||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
|
||||||
return stored
|
return stored
|
||||||
})
|
})
|
||||||
|
|
||||||
export const settleCompaction = Effect.fn("SessionInput.settleCompaction")(function* (
|
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: { readonly sessionID: SessionSchema.ID; readonly handledSeq: number },
|
input: { readonly sessionID: SessionSchema.ID },
|
||||||
) {
|
) {
|
||||||
const updated = yield* db
|
const deleted = yield* db
|
||||||
.update(SessionInputTable)
|
.delete(SessionPendingTable)
|
||||||
.set({ promoted_seq: input.handledSeq })
|
.where(and(eq(SessionPendingTable.session_id, input.sessionID), eq(SessionPendingTable.type, "compaction")))
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(SessionInputTable.session_id, input.sessionID),
|
|
||||||
eq(SessionInputTable.type, "compaction"),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (updated) {
|
if (deleted) {
|
||||||
const stored = fromRow(updated)
|
const stored = fromRow(deleted)
|
||||||
return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
|
return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
const rows = yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionPendingTable)
|
||||||
|
.where(eq(SessionPendingTable.session_id, sessionID))
|
||||||
|
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
return rows.map(fromRow)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const has = Effect.fn("SessionPending.has")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
delivery: Delivery,
|
delivery: Delivery,
|
||||||
) {
|
) {
|
||||||
if (yield* pendingCompaction(db, sessionID)) return false
|
if (yield* compaction(db, sessionID)) return false
|
||||||
const row = yield* db
|
const row = yield* db
|
||||||
.select({ id: SessionInputTable.id })
|
.select({ id: SessionPendingTable.id })
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.where(
|
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, delivery)))
|
||||||
and(
|
|
||||||
eq(SessionInputTable.session_id, sessionID),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
eq(SessionInputTable.delivery, delivery),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
@ -350,15 +388,15 @@ export const equivalent = (
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const publish = Effect.fn("SessionInput.publish")(function* (
|
const publish = Effect.fn("SessionPending.publish")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
|
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
|
||||||
) {
|
) {
|
||||||
return yield* inboxLocks.withLock(sessionID)(
|
return yield* inboxLocks.withLock(sessionID)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (yield* pendingCompaction(db, sessionID)) return 0
|
if (yield* compaction(db, sessionID)) return 0
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
rows,
|
rows,
|
||||||
(row) => {
|
(row) => {
|
||||||
|
|
@ -372,12 +410,8 @@ const publish = Effect.fn("SessionInput.publish")(function* (
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
defect instanceof LifecycleConflict
|
defect instanceof LifecycleConflict
|
||||||
? find(db, entry.id).pipe(
|
? promotedFromHistory(db, sessionID, entry.id).pipe(
|
||||||
Effect.flatMap((stored) =>
|
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
|
||||||
stored?.type !== "compaction" && stored?.promotedSeq !== undefined
|
|
||||||
? Effect.void
|
|
||||||
: Effect.die(defect),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: Effect.die(defect),
|
: Effect.die(defect),
|
||||||
),
|
),
|
||||||
|
|
@ -390,45 +424,33 @@ const publish = Effect.fn("SessionInput.publish")(function* (
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
|
export const promoteSteers = Effect.fn("SessionPending.promoteSteers")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
if (yield* pendingCompaction(db, sessionID)) return 0
|
if (yield* compaction(db, sessionID)) return 0
|
||||||
const rows = yield* db
|
const rows = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.where(
|
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
|
||||||
and(
|
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||||
eq(SessionInputTable.session_id, sessionID),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
eq(SessionInputTable.delivery, "steer"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(SessionInputTable.admitted_seq))
|
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
return yield* publish(db, events, sessionID, rows)
|
return yield* publish(db, events, sessionID, rows)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
|
export const promoteNextQueued = Effect.fn("SessionPending.promoteNextQueued")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
if (yield* pendingCompaction(db, sessionID)) return false
|
if (yield* compaction(db, sessionID)) return false
|
||||||
const row = yield* db
|
const row = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.where(
|
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
|
||||||
and(
|
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||||
eq(SessionInputTable.session_id, sessionID),
|
|
||||||
isNull(SessionInputTable.promoted_seq),
|
|
||||||
eq(SessionInputTable.delivery, "queue"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(SessionInputTable.admitted_seq))
|
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export * as SessionProjector from "./projector"
|
export * as SessionProjector from "./projector"
|
||||||
|
|
||||||
import { and, asc, desc, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, gte, inArray, lt, sql } from "drizzle-orm"
|
||||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
|
|
@ -11,14 +11,14 @@ import { SessionV1 } from "../v1/session"
|
||||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionMessageUpdater } from "./message-updater"
|
import { SessionMessageUpdater } from "./message-updater"
|
||||||
import { SessionInput } from "./input"
|
import { SessionPending } from "./pending"
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
import { InstructionCheckpoint } from "./instruction-checkpoint"
|
import { InstructionCheckpoint } from "./instruction-checkpoint"
|
||||||
import {
|
import {
|
||||||
MessageTable,
|
MessageTable,
|
||||||
PartTable,
|
PartTable,
|
||||||
InstructionCheckpointTable,
|
InstructionCheckpointTable,
|
||||||
SessionInputTable,
|
SessionPendingTable,
|
||||||
SessionMessageTable,
|
SessionMessageTable,
|
||||||
SessionTable,
|
SessionTable,
|
||||||
} from "./sql"
|
} from "./sql"
|
||||||
|
|
@ -293,25 +293,25 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
const inputRows = yield* db
|
const pendingRows = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(SessionInputTable.session_id, event.data.parentID),
|
eq(SessionPendingTable.session_id, event.data.parentID),
|
||||||
inArray(
|
inArray(
|
||||||
SessionInputTable.id,
|
SessionPendingTable.id,
|
||||||
rows.map((row) => row.id),
|
rows.map((row) => row.id),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (inputRows.length > 0) {
|
if (pendingRows.length > 0) {
|
||||||
yield* db
|
yield* db
|
||||||
.insert(SessionInputTable)
|
.insert(SessionPendingTable)
|
||||||
.values(
|
.values(
|
||||||
inputRows.flatMap((row) => {
|
pendingRows.flatMap((row) => {
|
||||||
const id = idMap.get(row.id)
|
const id = idMap.get(row.id)
|
||||||
return id && row.type !== "compaction"
|
return id && row.type !== "compaction"
|
||||||
? [
|
? [
|
||||||
|
|
@ -322,7 +322,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||||
data: row.data,
|
data: row.data,
|
||||||
delivery: row.delivery,
|
delivery: row.delivery,
|
||||||
admitted_seq: row.admitted_seq,
|
admitted_seq: row.admitted_seq,
|
||||||
promoted_seq: row.promoted_seq,
|
|
||||||
time_created: row.time_created,
|
time_created: row.time_created,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -633,10 +632,9 @@ const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
const input = yield* SessionInput.projectPromoted(db, {
|
const input = yield* SessionPending.projectPromoted(db, {
|
||||||
id: event.data.inputID,
|
id: event.data.inputID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
promotedSeq: event.durable.seq,
|
|
||||||
})
|
})
|
||||||
yield* insertMessage(
|
yield* insertMessage(
|
||||||
db,
|
db,
|
||||||
|
|
@ -666,7 +664,7 @@ const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
yield* SessionInput.projectAdmitted(db, {
|
yield* SessionPending.projectAdmitted(db, {
|
||||||
admittedSeq: event.durable.seq,
|
admittedSeq: event.durable.seq,
|
||||||
id: event.data.inputID,
|
id: event.data.inputID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
|
|
@ -679,7 +677,7 @@ const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
yield* SessionInput.projectCompactionAdmitted(db, {
|
yield* SessionPending.projectCompactionAdmitted(db, {
|
||||||
admittedSeq: event.durable.seq,
|
admittedSeq: event.durable.seq,
|
||||||
id: event.data.inputID,
|
id: event.data.inputID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
|
|
@ -727,10 +725,7 @@ const layer = Layer.effectDiscard(
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
if (event.data.reason === "manual")
|
if (event.data.reason === "manual")
|
||||||
yield* SessionInput.settleCompaction(db, {
|
yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
|
||||||
sessionID: event.data.sessionID,
|
|
||||||
handledSeq: event.durable.seq,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.Compaction.Failed, (event) =>
|
yield* events.project(SessionEvent.Compaction.Failed, (event) =>
|
||||||
|
|
@ -739,10 +734,7 @@ const layer = Layer.effectDiscard(
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
if (event.data.reason === "manual")
|
if (event.data.reason === "manual")
|
||||||
yield* SessionInput.settleCompaction(db, {
|
yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
|
||||||
sessionID: event.data.sessionID,
|
|
||||||
handledSeq: event.durable.seq,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
||||||
|
|
@ -786,11 +778,11 @@ const layer = Layer.effectDiscard(
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* db
|
yield* db
|
||||||
.delete(SessionInputTable)
|
.delete(SessionPendingTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(SessionInputTable.session_id, event.data.sessionID),
|
eq(SessionPendingTable.session_id, event.data.sessionID),
|
||||||
or(gte(SessionInputTable.admitted_seq, boundary.seq), gte(SessionInputTable.promoted_seq, boundary.seq)),
|
gte(SessionPendingTable.admitted_seq, boundary.seq),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import { InstructionCheckpoint } from "../instruction-checkpoint"
|
||||||
import { SessionCompaction } from "../compaction"
|
import { SessionCompaction } from "../compaction"
|
||||||
import { SessionEvent } from "../event"
|
import { SessionEvent } from "../event"
|
||||||
import { SessionHistory } from "../history"
|
import { SessionHistory } from "../history"
|
||||||
import { SessionInput } from "../input"
|
import { SessionPending } from "../pending"
|
||||||
import { SessionMessage } from "../message"
|
import { SessionMessage } from "../message"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
import { SessionStore } from "../store"
|
import { SessionStore } from "../store"
|
||||||
|
|
@ -203,7 +203,7 @@ const layer = Layer.effect(
|
||||||
|
|
||||||
const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* (
|
const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
promotion: SessionInput.Delivery | undefined,
|
promotion: SessionPending.Delivery | undefined,
|
||||||
step: number,
|
step: number,
|
||||||
recoverOverflow?: typeof compaction.compactAfterOverflow,
|
recoverOverflow?: typeof compaction.compactAfterOverflow,
|
||||||
assistantMessageID?: SessionMessage.ID,
|
assistantMessageID?: SessionMessage.ID,
|
||||||
|
|
@ -226,10 +226,10 @@ const layer = Layer.effect(
|
||||||
let currentStep = step
|
let currentStep = step
|
||||||
if (promotion) {
|
if (promotion) {
|
||||||
let promoted = 0
|
let promoted = 0
|
||||||
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id)
|
if (promotion === "steer") promoted = yield* SessionPending.promoteSteers(db, events, session.id)
|
||||||
if (promotion === "queue") {
|
if (promotion === "queue") {
|
||||||
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
|
promoted += Number(yield* SessionPending.promoteNextQueued(db, events, session.id))
|
||||||
promoted += yield* SessionInput.promoteSteers(db, events, session.id)
|
promoted += yield* SessionPending.promoteSteers(db, events, session.id)
|
||||||
}
|
}
|
||||||
if (promoted > 0) currentStep = 1
|
if (promoted > 0) currentStep = 1
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +285,7 @@ const layer = Layer.effect(
|
||||||
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
|
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
|
||||||
// Automatic compaction completed; rebuild the request from compacted history.
|
// Automatic compaction completed; rebuild the request from compacted history.
|
||||||
if (
|
if (
|
||||||
!(yield* SessionInput.pendingCompaction(db, session.id)) &&
|
!(yield* SessionPending.compaction(db, session.id)) &&
|
||||||
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }))
|
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }))
|
||||||
)
|
)
|
||||||
return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||||
|
|
@ -550,7 +550,7 @@ const layer = Layer.effect(
|
||||||
|
|
||||||
const runStep = Effect.fnUntraced(function* (
|
const runStep = Effect.fnUntraced(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
promotion: SessionInput.Delivery | undefined,
|
promotion: SessionPending.Delivery | undefined,
|
||||||
step: number,
|
step: number,
|
||||||
) {
|
) {
|
||||||
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
||||||
|
|
@ -595,7 +595,7 @@ const layer = Layer.effect(
|
||||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
const pending = yield* SessionInput.pendingCompaction(db, sessionID)
|
const pending = yield* SessionPending.compaction(db, sessionID)
|
||||||
if (!pending) return false
|
if (!pending) return false
|
||||||
const session = yield* getSession(sessionID)
|
const session = yield* getSession(sessionID)
|
||||||
return yield* Effect.uninterruptibleMask((restore) =>
|
return yield* Effect.uninterruptibleMask((restore) =>
|
||||||
|
|
@ -611,7 +611,7 @@ const layer = Layer.effect(
|
||||||
).pipe(Effect.exit)
|
).pipe(Effect.exit)
|
||||||
if (Exit.isSuccess(compacted) && compacted.value) return true
|
if (Exit.isSuccess(compacted) && compacted.value) return true
|
||||||
if (Exit.isFailure(compacted)) {
|
if (Exit.isFailure(compacted)) {
|
||||||
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
|
const unsettled = yield* SessionPending.compaction(db, sessionID)
|
||||||
if (unsettled)
|
if (unsettled)
|
||||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -621,7 +621,7 @@ const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
return yield* Effect.failCause(compacted.cause)
|
return yield* Effect.failCause(compacted.cause)
|
||||||
}
|
}
|
||||||
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
|
const unsettled = yield* SessionPending.compaction(db, sessionID)
|
||||||
if (unsettled)
|
if (unsettled)
|
||||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -640,11 +640,11 @@ const layer = Layer.effect(
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
}) {
|
}) {
|
||||||
yield* runPendingCompaction(input.sessionID)
|
yield* runPendingCompaction(input.sessionID)
|
||||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer")
|
||||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue")
|
||||||
if (!input.force && !hasSteer && !hasQueue) return
|
if (!input.force && !hasSteer && !hasQueue) return
|
||||||
yield* failInterruptedTools(input.sessionID)
|
yield* failInterruptedTools(input.sessionID)
|
||||||
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
let promotion: SessionPending.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||||
let shouldRun = input.force || hasSteer || hasQueue
|
let shouldRun = input.force || hasSteer || hasQueue
|
||||||
while (shouldRun) {
|
while (shouldRun) {
|
||||||
let needsContinuation = true
|
let needsContinuation = true
|
||||||
|
|
@ -664,16 +664,16 @@ const layer = Layer.effect(
|
||||||
needsContinuation = result.needsContinuation
|
needsContinuation = result.needsContinuation
|
||||||
step = result.step + 1
|
step = result.step + 1
|
||||||
if (needsContinuation) {
|
if (needsContinuation) {
|
||||||
promotion = (yield* SessionInput.pendingCompaction(db, input.sessionID)) ? undefined : "steer"
|
promotion = (yield* SessionPending.compaction(db, input.sessionID)) ? undefined : "steer"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
yield* runPendingCompaction(input.sessionID)
|
yield* runPendingCompaction(input.sessionID)
|
||||||
promotion = "steer"
|
promotion = "steer"
|
||||||
needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
needsContinuation = yield* SessionPending.has(db, input.sessionID, "steer")
|
||||||
}
|
}
|
||||||
yield* runPendingCompaction(input.sessionID)
|
yield* runPendingCompaction(input.sessionID)
|
||||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer")
|
||||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue")
|
||||||
shouldRun = hasSteer || hasQueue
|
shouldRun = hasSteer || hasQueue
|
||||||
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { sql } from "drizzle-orm"
|
||||||
import { directoryColumn, pathColumn } from "../database/path"
|
import { directoryColumn, pathColumn } from "../database/path"
|
||||||
import { ProjectTable } from "../project/sql"
|
import { ProjectTable } from "../project/sql"
|
||||||
import type { SessionMessage } from "./message"
|
import type { SessionMessage } from "./message"
|
||||||
import type { SessionInput } from "./input"
|
import type { SessionPending } from "./pending"
|
||||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
import { PermissionV1 } from "../v1/permission"
|
import { PermissionV1 } from "../v1/permission"
|
||||||
import { ProjectV2 } from "../project"
|
import { ProjectV2 } from "../project"
|
||||||
|
|
@ -13,7 +13,7 @@ import { WorkspaceV2 } from "../workspace"
|
||||||
import { Timestamps } from "../database/schema.sql"
|
import { Timestamps } from "../database/schema.sql"
|
||||||
import type { Instructions } from "../instructions/index"
|
import type { Instructions } from "../instructions/index"
|
||||||
import type { Session } from "@opencode-ai/schema/session"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
import type { SyntheticData, UserData } from "@opencode-ai/schema/session-input"
|
import type { SyntheticData, UserData } from "@opencode-ai/schema/session-pending"
|
||||||
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
||||||
import type { Schema } from "effect"
|
import type { Schema } from "effect"
|
||||||
|
|
||||||
|
|
@ -126,35 +126,28 @@ export const SessionMessageTable = sqliteTable(
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
export const SessionInputTable = sqliteTable(
|
export const SessionPendingTable = sqliteTable(
|
||||||
"session_input",
|
"session_pending",
|
||||||
{
|
{
|
||||||
id: text().$type<SessionMessage.ID>().primaryKey(),
|
id: text().$type<SessionMessage.ID>().primaryKey(),
|
||||||
session_id: text()
|
session_id: text()
|
||||||
.$type<SessionSchema.ID>()
|
.$type<SessionSchema.ID>()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||||
type: text().$type<SessionInput.Info["type"]>().notNull(),
|
type: text().$type<SessionPending.Info["type"]>().notNull(),
|
||||||
data: text({ mode: "json" }).$type<UserData | SyntheticData | Record<string, never>>().notNull(),
|
data: text({ mode: "json" }).$type<UserData | SyntheticData | Record<string, never>>().notNull(),
|
||||||
delivery: text().$type<SessionInput.Delivery>(),
|
delivery: text().$type<SessionPending.Delivery>(),
|
||||||
admitted_seq: integer().notNull(),
|
admitted_seq: integer().notNull(),
|
||||||
promoted_seq: integer(),
|
|
||||||
time_created: integer()
|
time_created: integer()
|
||||||
.notNull()
|
.notNull()
|
||||||
.$default(() => Date.now()),
|
.$default(() => Date.now()),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("session_input_session_pending_delivery_seq_idx").on(
|
index("session_pending_session_delivery_seq_idx").on(table.session_id, table.delivery, table.admitted_seq),
|
||||||
table.session_id,
|
uniqueIndex("session_pending_session_compaction_idx")
|
||||||
table.promoted_seq,
|
|
||||||
table.delivery,
|
|
||||||
table.admitted_seq,
|
|
||||||
),
|
|
||||||
uniqueIndex("session_input_session_pending_compaction_idx")
|
|
||||||
.on(table.session_id)
|
.on(table.session_id)
|
||||||
.where(sql`${table.type} = 'compaction' and ${table.promoted_seq} is null`),
|
.where(sql`${table.type} = 'compaction'`),
|
||||||
uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq),
|
uniqueIndex("session_pending_session_admitted_seq_idx").on(table.session_id, table.admitted_seq),
|
||||||
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,15 @@ import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||||
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
||||||
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
||||||
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
|
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
|
||||||
import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
|
import eventSourcedSessionPendingMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
|
||||||
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
||||||
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
||||||
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
import simplifySessionPendingMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||||
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
|
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
|
||||||
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
|
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
|
||||||
import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state"
|
import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state"
|
||||||
import genericSessionInputMigration from "@opencode-ai/core/database/migration/20260709013000_generic_session_input"
|
import genericSessionPendingMigration from "@opencode-ai/core/database/migration/20260709013000_generic_session_input"
|
||||||
|
import sessionPendingTableMigration from "@opencode-ai/core/database/migration/20260709190621_session_pending_table"
|
||||||
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
|
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
|
||||||
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
|
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
|
||||||
import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
|
import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
|
||||||
|
|
@ -352,7 +353,10 @@ describe("DatabaseMigration", () => {
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
||||||
).toEqual({ name: "session_input" })
|
).toBeUndefined()
|
||||||
|
expect(
|
||||||
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_pending'`),
|
||||||
|
).toEqual({ name: "session_pending" })
|
||||||
expect(
|
expect(
|
||||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`),
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`),
|
||||||
).toEqual({ name: "instruction_checkpoint" })
|
).toEqual({ name: "instruction_checkpoint" })
|
||||||
|
|
@ -364,18 +368,17 @@ describe("DatabaseMigration", () => {
|
||||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
|
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
|
||||||
expect(
|
expect(
|
||||||
yield* db.all(
|
yield* db.all(
|
||||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_pending_session_delivery_seq_idx', 'session_pending_session_compaction_idx', 'session_pending_session_admitted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||||
),
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{ name: "event_aggregate_seq_idx" },
|
{ name: "event_aggregate_seq_idx" },
|
||||||
{ name: "event_aggregate_type_seq_idx" },
|
{ name: "event_aggregate_type_seq_idx" },
|
||||||
{ name: "session_input_session_admitted_seq_idx" },
|
|
||||||
{ name: "session_input_session_pending_compaction_idx" },
|
|
||||||
{ name: "session_input_session_pending_delivery_seq_idx" },
|
|
||||||
{ name: "session_input_session_promoted_seq_idx" },
|
|
||||||
{ name: "session_message_session_seq_idx" },
|
{ name: "session_message_session_seq_idx" },
|
||||||
{ name: "session_message_session_time_created_id_idx" },
|
{ name: "session_message_session_time_created_id_idx" },
|
||||||
{ name: "session_message_session_type_seq_idx" },
|
{ name: "session_message_session_type_seq_idx" },
|
||||||
|
{ name: "session_pending_session_admitted_seq_idx" },
|
||||||
|
{ name: "session_pending_session_compaction_idx" },
|
||||||
|
{ name: "session_pending_session_delivery_seq_idx" },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -568,7 +571,7 @@ describe("DatabaseMigration", () => {
|
||||||
sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`,
|
sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionInputMigration])
|
yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionPendingMigration])
|
||||||
|
|
||||||
expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([
|
expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([
|
||||||
{ id: "session", workspace_id: null },
|
{ id: "session", workspace_id: null },
|
||||||
|
|
@ -631,7 +634,7 @@ describe("DatabaseMigration", () => {
|
||||||
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
|
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
|
||||||
)
|
)
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`INSERT INTO session_input (id, session_id, type, data, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'user', '{}', 'steer', 9, 1)`,
|
sql`INSERT INTO session_pending (id, session_id, type, data, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'user', '{}', 'steer', 9, 1)`,
|
||||||
)
|
)
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
|
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
|
||||||
|
|
@ -640,9 +643,17 @@ describe("DatabaseMigration", () => {
|
||||||
sql`INSERT INTO instruction_checkpoint (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`,
|
sql`INSERT INTO instruction_checkpoint (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`,
|
||||||
)
|
)
|
||||||
yield* db.run(sql`ALTER TABLE instruction_checkpoint RENAME TO session_context_epoch`)
|
yield* db.run(sql`ALTER TABLE instruction_checkpoint RENAME TO session_context_epoch`)
|
||||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionInputMigration.id}`)
|
// The partial compaction index embeds the qualified table name, so it
|
||||||
yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration])
|
// must drop before the historical rename dance and recreate after.
|
||||||
|
yield* db.run(sql`DROP INDEX session_pending_session_compaction_idx`)
|
||||||
|
yield* db.run(sql`ALTER TABLE session_pending RENAME TO session_input`)
|
||||||
|
yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionPendingMigration.id}`)
|
||||||
|
yield* DatabaseMigration.applyOnly(db, [simplifySessionPendingMigration])
|
||||||
yield* db.run(sql`ALTER TABLE session_context_epoch RENAME TO instruction_checkpoint`)
|
yield* db.run(sql`ALTER TABLE session_context_epoch RENAME TO instruction_checkpoint`)
|
||||||
|
yield* db.run(sql`ALTER TABLE session_input RENAME TO session_pending`)
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE UNIQUE INDEX session_pending_session_compaction_idx ON session_pending (session_id) WHERE "session_pending"."type" = 'compaction'`,
|
||||||
|
)
|
||||||
|
|
||||||
const database = Layer.succeed(Database.Service, { db })
|
const database = Layer.succeed(Database.Service, { db })
|
||||||
yield* EventV2.Service.use((service) =>
|
yield* EventV2.Service.use((service) =>
|
||||||
|
|
@ -672,7 +683,7 @@ describe("DatabaseMigration", () => {
|
||||||
(SELECT COUNT(*) FROM message WHERE id = 'message') AS messages,
|
(SELECT COUNT(*) FROM message WHERE id = 'message') AS messages,
|
||||||
(SELECT COUNT(*) FROM part WHERE id = 'part') AS parts,
|
(SELECT COUNT(*) FROM part WHERE id = 'part') AS parts,
|
||||||
(SELECT COUNT(*) FROM workspace) AS workspaces,
|
(SELECT COUNT(*) FROM workspace) AS workspaces,
|
||||||
(SELECT COUNT(*) FROM session_input) AS sessionInputs,
|
(SELECT COUNT(*) FROM session_pending) AS sessionInputs,
|
||||||
(SELECT COUNT(*) FROM session_message) AS sessionMessages,
|
(SELECT COUNT(*) FROM session_message) AS sessionMessages,
|
||||||
(SELECT COUNT(*) FROM instruction_checkpoint) AS instructionCheckpoints,
|
(SELECT COUNT(*) FROM instruction_checkpoint) AS instructionCheckpoints,
|
||||||
(SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq,
|
(SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq,
|
||||||
|
|
@ -756,7 +767,7 @@ describe("DatabaseMigration", () => {
|
||||||
sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-promoted', 'session', 7, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"empty"}')`,
|
sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-promoted', 'session', 7, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"empty"}')`,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* DatabaseMigration.applyOnly(db, [genericSessionInputMigration])
|
yield* DatabaseMigration.applyOnly(db, [genericSessionPendingMigration])
|
||||||
|
|
||||||
expect(yield* db.all(sql`SELECT id, type, data, delivery FROM session_input ORDER BY admitted_seq`)).toEqual([
|
expect(yield* db.all(sql`SELECT id, type, data, delivery FROM session_input ORDER BY admitted_seq`)).toEqual([
|
||||||
{ id: "input", type: "user", data: '{"text":"hello"}', delivery: "queue" },
|
{ id: "input", type: "user", data: '{"text":"hello"}', delivery: "queue" },
|
||||||
|
|
@ -775,6 +786,47 @@ describe("DatabaseMigration", () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("replaces the durable inbox with the empty session_pending table", async () => {
|
||||||
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* makeDb
|
||||||
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||||
|
yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE, type text NOT NULL, data text NOT NULL, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
|
||||||
|
)
|
||||||
|
// Interim v2 builds shipped differing index sets on real databases;
|
||||||
|
// dropping the table removes whatever variant exists.
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE INDEX session_input_session_pending_type_delivery_seq_idx ON session_input (session_id, promoted_seq, type, delivery, admitted_seq)`,
|
||||||
|
)
|
||||||
|
yield* db.run(
|
||||||
|
sql`INSERT INTO session_input (id, session_id, type, data, delivery, admitted_seq, promoted_seq, time_created) VALUES ('pending', 'session', 'user', '{"text":"hello"}', 'steer', 4, NULL, 1)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* DatabaseMigration.applyOnly(db, [sessionPendingTableMigration])
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
||||||
|
).toBeUndefined()
|
||||||
|
expect(yield* db.all(sql`SELECT id FROM session_pending`)).toEqual([])
|
||||||
|
expect(
|
||||||
|
(yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_pending)`)).map((column) => column.name),
|
||||||
|
).toEqual(["id", "session_id", "type", "data", "delivery", "admitted_seq", "time_created"])
|
||||||
|
expect(
|
||||||
|
(yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_pending)`))
|
||||||
|
.filter((index) => index.name.startsWith("session_"))
|
||||||
|
.map((index) => ({ name: index.name, unique: index.unique }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
).toEqual([
|
||||||
|
{ name: "session_pending_session_admitted_seq_idx", unique: 1 },
|
||||||
|
{ name: "session_pending_session_compaction_idx", unique: 1 },
|
||||||
|
{ name: "session_pending_session_delivery_seq_idx", unique: 0 },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
||||||
await run(
|
await run(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
|
|
@ -106,7 +106,7 @@ describe("SessionV2.compact", () => {
|
||||||
|
|
||||||
expect(second.id).toBe(first.id)
|
expect(second.id).toBe(first.id)
|
||||||
expect(requests).toHaveLength(0)
|
expect(requests).toHaveLength(0)
|
||||||
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, created.id)).toMatchObject({
|
expect(yield* SessionPending.compaction((yield* Database.Service).db, created.id)).toMatchObject({
|
||||||
id: first.id,
|
id: first.id,
|
||||||
})
|
})
|
||||||
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
|
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
|
|
@ -195,9 +195,9 @@ describe("SessionV2.create", () => {
|
||||||
text: "First",
|
text: "First",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||||
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
|
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
|
||||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -217,30 +217,28 @@ describe("SessionV2.create", () => {
|
||||||
durable: { seq: 0 },
|
durable: { seq: 0 },
|
||||||
data: { sessionID: forked.id, parentID: parent.id },
|
data: { sessionID: forked.id, parentID: parent.id },
|
||||||
})
|
})
|
||||||
expect(yield* SessionInput.find(db, forkContext[0].id)).toMatchObject({
|
expect(yield* SessionPending.find(db, forkContext[0].id)).toBeUndefined()
|
||||||
sessionID: forked.id,
|
expect(yield* SessionPending.find(db, forkContext[1].id)).toBeUndefined()
|
||||||
type: "user",
|
// Fork-copied messages have no admitted event in the fork aggregate, so
|
||||||
data: { text: "First" },
|
// reusing their IDs as prompt IDs is conflicting reuse, not a retry.
|
||||||
promotedSeq: 2,
|
expect(
|
||||||
})
|
yield* session
|
||||||
expect(yield* SessionInput.find(db, forkContext[1].id)).toMatchObject({
|
.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false })
|
||||||
sessionID: forked.id,
|
.pipe(Effect.flip),
|
||||||
type: "synthetic",
|
).toMatchObject({ _tag: "Session.PromptConflictError", messageID: forkContext[0].id })
|
||||||
data: { text: "parent note" },
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* session.prompt({
|
yield* session.prompt({
|
||||||
sessionID: parent.id,
|
sessionID: parent.id,
|
||||||
text: "Parent changed",
|
text: "Parent changed",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||||
yield* session.prompt({
|
yield* session.prompt({
|
||||||
sessionID: forked.id,
|
sessionID: forked.id,
|
||||||
text: "Child continues",
|
text: "Child continues",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, forked.id)
|
yield* SessionPending.promoteSteers(db, events, forked.id)
|
||||||
|
|
||||||
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
expect((yield* session.context(parent.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)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||||
|
|
@ -250,7 +248,7 @@ describe("SessionV2.create", () => {
|
||||||
(event): number | undefined => event.durable?.seq,
|
(event): number | undefined => event.durable?.seq,
|
||||||
),
|
),
|
||||||
).toEqual([0, 5, 6])
|
).toEqual([0, 5, 6])
|
||||||
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id })
|
expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -265,13 +263,13 @@ describe("SessionV2.create", () => {
|
||||||
text: "First",
|
text: "First",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||||
const second = yield* session.prompt({
|
const second = yield* session.prompt({
|
||||||
sessionID: parent.id,
|
sessionID: parent.id,
|
||||||
text: "Second",
|
text: "Second",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||||
const assistantMessageID = SessionMessage.ID.create()
|
const assistantMessageID = SessionMessage.ID.create()
|
||||||
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
|
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
|
|
@ -416,7 +414,7 @@ describe("SessionV2.create", () => {
|
||||||
text: "Hello",
|
text: "Hello",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, created.id)
|
yield* SessionPending.promoteSteers(db, events, created.id)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
|
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
|
||||||
|
|
@ -442,7 +440,7 @@ describe("SessionV2.create", () => {
|
||||||
text: "Replay lifecycle",
|
text: "Replay lifecycle",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id)
|
yield* SessionPending.promoteSteers(sourceDb, sourceEvents, created.id)
|
||||||
const serialized = (yield* sourceDb
|
const serialized = (yield* sourceDb
|
||||||
.select()
|
.select()
|
||||||
.from(EventTable)
|
.from(EventTable)
|
||||||
|
|
@ -480,7 +478,7 @@ describe("SessionV2.create", () => {
|
||||||
|
|
||||||
expect(yield* store.get(created.id)).toBeUndefined()
|
expect(yield* store.get(created.id)).toBeUndefined()
|
||||||
expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id)
|
expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id)
|
||||||
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
|
expect(yield* SessionPending.find(db, admitted.id)).toMatchObject({
|
||||||
id: admitted.id,
|
id: admitted.id,
|
||||||
sessionID: created.id,
|
sessionID: created.id,
|
||||||
type: "user",
|
type: "user",
|
||||||
|
|
@ -491,15 +489,7 @@ describe("SessionV2.create", () => {
|
||||||
expect(yield* store.context(created.id)).toEqual([])
|
expect(yield* store.context(created.id)).toEqual([])
|
||||||
|
|
||||||
expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id)
|
expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id)
|
||||||
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
|
expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
|
||||||
id: admitted.id,
|
|
||||||
sessionID: created.id,
|
|
||||||
type: "user",
|
|
||||||
data: { text: "Replay lifecycle" },
|
|
||||||
delivery: "steer",
|
|
||||||
admittedSeq: 1,
|
|
||||||
promotedSeq: 2,
|
|
||||||
})
|
|
||||||
expect(yield* store.context(created.id)).toMatchObject([
|
expect(yield* store.context(created.id)).toMatchObject([
|
||||||
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
|
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { fromRow } from "@opencode-ai/core/session/info"
|
import { fromRow } from "@opencode-ai/core/session/info"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||||
import { Shell } from "@opencode-ai/schema/shell"
|
import { Shell } from "@opencode-ai/schema/shell"
|
||||||
import {
|
import {
|
||||||
InstructionCheckpointTable,
|
InstructionCheckpointTable,
|
||||||
SessionInputTable,
|
SessionPendingTable,
|
||||||
SessionMessageTable,
|
SessionMessageTable,
|
||||||
SessionTable,
|
SessionTable,
|
||||||
} from "@opencode-ai/core/session/sql"
|
} from "@opencode-ai/core/session/sql"
|
||||||
|
|
@ -77,7 +77,7 @@ describe("SessionProjector", () => {
|
||||||
.run()
|
.run()
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const inputID = SessionMessage.ID.make("msg_manual_compaction")
|
const inputID = SessionMessage.ID.make("msg_manual_compaction")
|
||||||
yield* SessionInput.admitCompaction(db, events, { id: inputID, sessionID })
|
yield* SessionPending.admitCompaction(db, events, { id: inputID, sessionID })
|
||||||
|
|
||||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -85,7 +85,7 @@ describe("SessionProjector", () => {
|
||||||
error: { type: "compaction.failed", message: "Auto compaction failed" },
|
error: { type: "compaction.failed", message: "Auto compaction failed" },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(yield* SessionInput.pendingCompaction(db, sessionID)).toMatchObject({ id: inputID })
|
expect(yield* SessionPending.compaction(db, sessionID)).toMatchObject({ id: inputID })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -316,7 +316,7 @@ describe("SessionProjector", () => {
|
||||||
}).pipe(Effect.provide(sessionsLayer)),
|
}).pipe(Effect.provide(sessionsLayer)),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("marks an inbox row promoted with the PromptPromoted event sequence", () =>
|
it.effect("consumes the pending row and projects the message at promotion", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
yield* db
|
yield* db
|
||||||
|
|
@ -338,7 +338,7 @@ describe("SessionProjector", () => {
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const id = SessionMessage.ID.make("msg_admitted")
|
const id = SessionMessage.ID.make("msg_admitted")
|
||||||
const admitted = yield* SessionInput.admit(db, events, {
|
const admitted = yield* SessionPending.admit(db, events, {
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
input: { type: "user", data: { text: "promote me" }, delivery: "steer" },
|
input: { type: "user", data: { text: "promote me" }, delivery: "steer" },
|
||||||
|
|
@ -351,8 +351,11 @@ describe("SessionProjector", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
yield* db.select().from(SessionPendingTable).where(eq(SessionPendingTable.id, id)).get().pipe(Effect.orDie),
|
||||||
).toMatchObject({ promoted_seq: event.durable?.seq })
|
).toBeUndefined()
|
||||||
|
expect(
|
||||||
|
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
|
||||||
|
).toMatchObject({ session_id: sessionID, type: "user", seq: event.durable?.seq })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
|
@ -81,11 +81,11 @@ const setup = Effect.gen(function* () {
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInput.find(db, id))
|
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionPending.find(db, id))
|
||||||
const admittedCount = Database.Service.use(({ db }) =>
|
const admittedCount = Database.Service.use(({ db }) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.all()
|
.all()
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
|
|
@ -201,7 +201,7 @@ describe("SessionV2.prompt", () => {
|
||||||
text: "boundary",
|
text: "boundary",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
const stale = SessionMessage.ID.make("msg_stale_assistant")
|
const stale = SessionMessage.ID.make("msg_stale_assistant")
|
||||||
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
|
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
|
||||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||||
|
|
@ -218,7 +218,7 @@ describe("SessionV2.prompt", () => {
|
||||||
(row) => row.id,
|
(row) => row.id,
|
||||||
),
|
),
|
||||||
).not.toContainAnyValues([boundary.id, stale])
|
).not.toContainAnyValues([boundary.id, stale])
|
||||||
expect(yield* SessionInput.find(db, boundary.id)).toBeUndefined()
|
expect(yield* SessionPending.find(db, boundary.id)).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -233,7 +233,7 @@ describe("SessionV2.prompt", () => {
|
||||||
text: "boundary",
|
text: "boundary",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||||
sessionID,
|
sessionID,
|
||||||
revert: { messageID: boundary.id, files: [] },
|
revert: { messageID: boundary.id, files: [] },
|
||||||
|
|
@ -243,11 +243,11 @@ describe("SessionV2.prompt", () => {
|
||||||
const completion = yield* session.synthetic({ sessionID, text: "stale completion" })
|
const completion = yield* session.synthetic({ sessionID, text: "stale completion" })
|
||||||
|
|
||||||
expect(wakeCalls).toEqual([])
|
expect(wakeCalls).toEqual([])
|
||||||
expect(yield* SessionInput.find(db, completion.id)).toMatchObject({ type: "synthetic" })
|
expect(yield* SessionPending.find(db, completion.id)).toMatchObject({ type: "synthetic" })
|
||||||
|
|
||||||
yield* session.revert.commit(sessionID)
|
yield* session.revert.commit(sessionID)
|
||||||
|
|
||||||
expect(yield* SessionInput.find(db, completion.id)).toBeUndefined()
|
expect(yield* SessionPending.find(db, completion.id)).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -433,7 +433,7 @@ describe("SessionV2.prompt", () => {
|
||||||
|
|
||||||
yield* session.prompt({ sessionID, text: "First", resume: false })
|
yield* session.prompt({ sessionID, text: "First", resume: false })
|
||||||
yield* session.prompt({ sessionID, text: "Second", resume: false })
|
yield* session.prompt({ sessionID, text: "Second", resume: false })
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||||
|
|
||||||
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
|
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
|
||||||
|
|
@ -610,12 +610,12 @@ describe("SessionV2.prompt", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* Effect.all(
|
yield* Effect.all(
|
||||||
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
|
[SessionPending.promoteSteers(db, events, sessionID), SessionPending.promoteSteers(db, events, sessionID)],
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
|
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
|
||||||
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
|
expect(yield* admitted(messageID)).toBeUndefined()
|
||||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||||
{ id: messageID, type: "user", text: "Promote once" },
|
{ id: messageID, type: "user", text: "Promote once" },
|
||||||
])
|
])
|
||||||
|
|
@ -645,7 +645,11 @@ describe("SessionV2.prompt", () => {
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
yield* events.remove(sessionID)
|
yield* events.remove(sessionID)
|
||||||
yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run().pipe(Effect.orDie)
|
yield* db
|
||||||
|
.delete(SessionPendingTable)
|
||||||
|
.where(eq(SessionPendingTable.session_id, sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
yield* db
|
yield* db
|
||||||
.delete(SessionMessageTable)
|
.delete(SessionMessageTable)
|
||||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||||
|
|
@ -836,7 +840,7 @@ describe("SessionV2.prompt", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
|
|
||||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||||
{
|
{
|
||||||
|
|
@ -861,14 +865,14 @@ describe("SessionV2.prompt", () => {
|
||||||
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
|
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
})
|
})
|
||||||
yield* SessionInput.promoteSteers(database.db, events, sessionID)
|
yield* SessionPending.promoteSteers(database.db, events, sessionID)
|
||||||
const promotedRetry = yield* session.synthetic(input)
|
const promotedRetry = yield* session.synthetic(input)
|
||||||
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
|
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
|
||||||
|
|
||||||
expect(entries[1]).toEqual(entries[0])
|
expect(entries[1]).toEqual(entries[0])
|
||||||
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", promotedSeq: expect.any(Number) })
|
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", data: { text: "Completed" } })
|
||||||
expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
|
expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
|
||||||
expect(yield* admittedCount).toBe(1)
|
expect(yield* admittedCount).toBe(0)
|
||||||
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
|
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -888,9 +892,9 @@ describe("SessionV2.prompt", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(input.delivery).toBe("queue")
|
expect(input.delivery).toBe("queue")
|
||||||
expect(yield* SessionInput.promoteSteers(db, events, sessionID)).toBe(0)
|
expect(yield* SessionPending.promoteSteers(db, events, sessionID)).toBe(0)
|
||||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||||
expect(yield* SessionInput.promoteNextQueued(db, events, sessionID)).toBe(true)
|
expect(yield* SessionPending.promoteNextQueued(db, events, sessionID)).toBe(true)
|
||||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||||
{ id: input.id, type: "synthetic", text: "Queued completion" },
|
{ id: input.id, type: "synthetic", text: "Queued completion" },
|
||||||
])
|
])
|
||||||
|
|
@ -916,7 +920,7 @@ describe("SessionV2.prompt", () => {
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
|
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
|
||||||
|
|
@ -926,3 +930,58 @@ describe("SessionV2.prompt", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("SessionV2.pending", () => {
|
||||||
|
it.effect("fails for an unknown session", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
expect(yield* session.pending(SessionV2.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
|
||||||
|
_tag: "Session.NotFoundError",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lists admitted work in admission order until promotion", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
|
const first = yield* session.prompt({ sessionID, text: "First steer", resume: false })
|
||||||
|
const queued = yield* session.synthetic({
|
||||||
|
sessionID,
|
||||||
|
text: "Queued completion",
|
||||||
|
delivery: "queue",
|
||||||
|
resume: false,
|
||||||
|
})
|
||||||
|
const second = yield* session.prompt({ sessionID, text: "Second steer", resume: false })
|
||||||
|
|
||||||
|
expect(yield* session.pending(sessionID)).toMatchObject([
|
||||||
|
{ id: first.id, type: "user", delivery: "steer" },
|
||||||
|
{ id: queued.id, type: "synthetic", delivery: "queue" },
|
||||||
|
{ id: second.id, type: "user", delivery: "steer" },
|
||||||
|
])
|
||||||
|
|
||||||
|
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||||
|
expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
|
||||||
|
|
||||||
|
yield* SessionPending.promoteNextQueued(db, events, sessionID)
|
||||||
|
expect(yield* session.pending(sessionID)).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lists an unhandled compaction barrier until it settles", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
|
const barrier = yield* session.compact({ sessionID })
|
||||||
|
expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
|
||||||
|
|
||||||
|
yield* SessionPending.settleCompaction(db, { sessionID })
|
||||||
|
expect(yield* session.pending(sessionID)).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
|
|
@ -47,7 +47,7 @@ import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||||
import {
|
import {
|
||||||
InstructionCheckpointTable,
|
InstructionCheckpointTable,
|
||||||
SessionInputTable,
|
SessionPendingTable,
|
||||||
SessionMessageTable,
|
SessionMessageTable,
|
||||||
SessionTable,
|
SessionTable,
|
||||||
} from "@opencode-ai/core/session/sql"
|
} from "@opencode-ai/core/session/sql"
|
||||||
|
|
@ -578,7 +578,7 @@ const replaySessionProjection = (id: SessionV2.ID) =>
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
yield* events.remove(id)
|
yield* events.remove(id)
|
||||||
yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, id)).run().pipe(Effect.orDie)
|
yield* db.delete(SessionPendingTable).where(eq(SessionPendingTable.session_id, id)).run().pipe(Effect.orDie)
|
||||||
yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
|
yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
|
||||||
yield* events.replayAll(
|
yield* events.replayAll(
|
||||||
recorded.map((event) => ({
|
recorded.map((event) => ({
|
||||||
|
|
@ -881,7 +881,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Instructions.InitializationBlocked)
|
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Instructions.InitializationBlocked)
|
||||||
expect(requests).toHaveLength(0)
|
expect(requests).toHaveLength(0)
|
||||||
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
|
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
|
||||||
expect(
|
expect(
|
||||||
yield* db
|
yield* db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -924,7 +924,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
|
|
||||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
|
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1462,7 +1462,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
const first = yield* session.compact({ sessionID })
|
const first = yield* session.compact({ sessionID })
|
||||||
const second = yield* session.compact({ sessionID })
|
const second = yield* session.compact({ sessionID })
|
||||||
expect(second.id).toBe(first.id)
|
expect(second.id).toBe(first.id)
|
||||||
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toMatchObject({
|
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toMatchObject({
|
||||||
id: first.id,
|
id: first.id,
|
||||||
})
|
})
|
||||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined()
|
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined()
|
||||||
|
|
@ -1475,7 +1475,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
delivery: "queue",
|
delivery: "queue",
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
expect(yield* SessionInput.hasPending((yield* Database.Service).db, sessionID, "steer")).toBe(false)
|
expect(yield* SessionPending.has((yield* Database.Service).db, sessionID, "steer")).toBe(false)
|
||||||
|
|
||||||
yield* Deferred.succeed(streamGate, undefined)
|
yield* Deferred.succeed(streamGate, undefined)
|
||||||
yield* Fiber.join(active)
|
yield* Fiber.join(active)
|
||||||
|
|
@ -1485,7 +1485,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
expect(userTexts(requests[2])).toContain("Steer after compaction")
|
expect(userTexts(requests[2])).toContain("Steer after compaction")
|
||||||
expect(userTexts(requests[2])).toContain("Completion after compaction")
|
expect(userTexts(requests[2])).toContain("Completion after compaction")
|
||||||
expect(userTexts(requests[3])).toContain("Queue after compaction")
|
expect(userTexts(requests[3])).toContain("Queue after compaction")
|
||||||
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
|
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
|
||||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
|
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
|
|
@ -1521,7 +1521,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
|
|
||||||
expect(requests).toHaveLength(3)
|
expect(requests).toHaveLength(3)
|
||||||
expect(userTexts(requests[2])).toContain("Continue after failure")
|
expect(userTexts(requests[2])).toContain("Continue after failure")
|
||||||
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
|
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
|
||||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
status: "failed",
|
status: "failed",
|
||||||
|
|
@ -1542,7 +1542,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
|
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
|
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
|
||||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
status: "failed",
|
status: "failed",
|
||||||
|
|
@ -1566,7 +1566,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
|
|
||||||
expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
|
expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
|
||||||
|
|
||||||
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
|
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
|
||||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
status: "failed",
|
status: "failed",
|
||||||
|
|
@ -2317,7 +2317,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* session.interrupt(sessionID)
|
yield* session.interrupt(sessionID)
|
||||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
expect(yield* SessionInput.hasPending(db, sessionID, "queue")).toBe(true)
|
expect(yield* SessionPending.has(db, sessionID, "queue")).toBe(true)
|
||||||
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||||
while (requests.length < 2) yield* Effect.yieldNow
|
while (requests.length < 2) yield* Effect.yieldNow
|
||||||
yield* Deferred.succeed(streamGate, undefined)
|
yield* Deferred.succeed(streamGate, undefined)
|
||||||
|
|
@ -2350,7 +2350,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* session.interrupt(sessionID)
|
yield* session.interrupt(sessionID)
|
||||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
|
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
|
||||||
|
|
||||||
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||||
while (requests.length < 2) yield* Effect.yieldNow
|
while (requests.length < 2) yield* Effect.yieldNow
|
||||||
|
|
@ -2516,7 +2516,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
yield* admit(session, "Recover interrupted tool")
|
yield* admit(session, "Recover interrupted tool")
|
||||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||||
const assistantMessageID = SessionMessage.ID.create()
|
const assistantMessageID = SessionMessage.ID.create()
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -2573,7 +2573,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
yield* admit(session, "Recover interrupted hosted tool")
|
yield* admit(session, "Recover interrupted hosted tool")
|
||||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||||
const assistantMessageID = SessionMessage.ID.create()
|
const assistantMessageID = SessionMessage.ID.create()
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -2624,7 +2624,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
yield* admit(session, "Recover interrupted tool input")
|
yield* admit(session, "Recover interrupted tool input")
|
||||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||||
const assistantMessageID = SessionMessage.ID.create()
|
const assistantMessageID = SessionMessage.ID.create()
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -3056,13 +3056,11 @@ describe("SessionRunnerLLM", () => {
|
||||||
const releaseLateEvent = yield* Deferred.make<void>()
|
const releaseLateEvent = yield* Deferred.make<void>()
|
||||||
yield* registry.register({ permissionfail: permissionFail })
|
yield* registry.register({ permissionfail: permissionFail })
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const permissionFailed = yield* events
|
const permissionFailed = yield* events.subscribe(SessionEvent.Tool.Failed).pipe(
|
||||||
.subscribe(SessionEvent.Tool.Failed)
|
Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-permission"),
|
||||||
.pipe(
|
Stream.runHead,
|
||||||
Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-permission"),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
Stream.runHead,
|
)
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
|
||||||
)
|
|
||||||
yield* admit(session, "Reject permission while another tool input streams")
|
yield* admit(session, "Reject permission while another tool input streams")
|
||||||
responseStream = Stream.concat(
|
responseStream = Stream.concat(
|
||||||
Stream.fromIterable([
|
Stream.fromIterable([
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
||||||
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
|
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
|
||||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { SessionInput } from "@opencode-ai/schema/session-input"
|
import { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
import { Command } from "@opencode-ai/schema/command"
|
import { Command } from "@opencode-ai/schema/command"
|
||||||
|
|
@ -43,7 +43,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||||
corePty,
|
corePty,
|
||||||
coreProject,
|
coreProject,
|
||||||
coreReference,
|
coreReference,
|
||||||
coreSessionInput,
|
coreSessionPending,
|
||||||
coreSessionMessage,
|
coreSessionMessage,
|
||||||
coreSkill,
|
coreSkill,
|
||||||
coreV2Schema,
|
coreV2Schema,
|
||||||
|
|
@ -63,7 +63,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||||
import("@opencode-ai/core/pty"),
|
import("@opencode-ai/core/pty"),
|
||||||
import("@opencode-ai/core/project/schema"),
|
import("@opencode-ai/core/project/schema"),
|
||||||
import("@opencode-ai/core/reference"),
|
import("@opencode-ai/core/reference"),
|
||||||
import("@opencode-ai/core/session/input"),
|
import("@opencode-ai/core/session/pending"),
|
||||||
import("@opencode-ai/core/session/message"),
|
import("@opencode-ai/core/session/message"),
|
||||||
import("@opencode-ai/core/skill"),
|
import("@opencode-ai/core/skill"),
|
||||||
import("@opencode-ai/core/v2-schema"),
|
import("@opencode-ai/core/v2-schema"),
|
||||||
|
|
@ -133,10 +133,10 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||||
[SessionV2.ID, Session.ID],
|
[SessionV2.ID, Session.ID],
|
||||||
[SessionV2.Info, Session.Info],
|
[SessionV2.Info, Session.Info],
|
||||||
[SessionV2.ListAnchor, Session.ListAnchor],
|
[SessionV2.ListAnchor, Session.ListAnchor],
|
||||||
[coreSessionInput.Delivery, SessionInput.Delivery],
|
[coreSessionPending.Delivery, SessionPending.Delivery],
|
||||||
[coreSessionInput.Message, SessionInput.Message],
|
[coreSessionPending.Message, SessionPending.Message],
|
||||||
[coreSessionInput.User, SessionInput.User],
|
[coreSessionPending.User, SessionPending.User],
|
||||||
[coreSessionInput.Synthetic, SessionInput.Synthetic],
|
[coreSessionPending.Synthetic, SessionPending.Synthetic],
|
||||||
[coreSessionMessage.ID, SessionMessage.ID],
|
[coreSessionMessage.ID, SessionMessage.ID],
|
||||||
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
|
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
|
||||||
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
|
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
|
|
@ -288,7 +288,7 @@ describe("SubagentTool", () => {
|
||||||
const admission = Array.from(yield* Fiber.join(admitted))[0]
|
const admission = Array.from(yield* Fiber.join(admitted))[0]
|
||||||
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
|
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||||
const database = yield* Database.Service
|
const database = yield* Database.Service
|
||||||
yield* SessionInput.promoteSteers(database.db, events, parent.id)
|
yield* SessionPending.promoteSteers(database.db, events, parent.id)
|
||||||
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
||||||
expect(synthetic).toHaveLength(1)
|
expect(synthetic).toHaveLength(1)
|
||||||
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
|
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||||
|
|
|
||||||
|
|
@ -1082,6 +1082,14 @@ const scenarios: Scenario[] = [
|
||||||
headers: ctx.headers(),
|
headers: ctx.headers(),
|
||||||
}))
|
}))
|
||||||
.json(404, object, "status"),
|
.json(404, object, "status"),
|
||||||
|
http.protected
|
||||||
|
.get("/api/session/{sessionID}/pending", "v2.session.pending.list")
|
||||||
|
.seeded((ctx) => ctx.session({ title: "Pending list owner" }))
|
||||||
|
.at((ctx) => ({
|
||||||
|
path: route("/api/session/{sessionID}/pending", { sessionID: ctx.state.id }),
|
||||||
|
headers: ctx.headers(),
|
||||||
|
}))
|
||||||
|
.json(200, data(array)),
|
||||||
http.protected
|
http.protected
|
||||||
.post("/api/session/{sessionID}/revert/stage", "v2.session.revert.stage")
|
.post("/api/session/{sessionID}/revert/stage", "v2.session.revert.stage")
|
||||||
.at((ctx) => ({
|
.at((ctx) => ({
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/se
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
|
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
|
|
@ -566,18 +566,18 @@ describe("session HttpApi", () => {
|
||||||
request(`/api/session/${session.id}/prompt`, {
|
request(`/api/session/${session.id}/prompt`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...headers, "content-type": "application/json" },
|
headers: { ...headers, "content-type": "application/json" },
|
||||||
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" }, resume: false }),
|
body: JSON.stringify({ id: "msg_http_prompt", text: "hello", resume: false }),
|
||||||
})
|
})
|
||||||
const first = yield* recordPrompt()
|
const first = yield* recordPrompt()
|
||||||
const retried = yield* recordPrompt()
|
const retried = yield* recordPrompt()
|
||||||
type PromptBody = { id: string; prompt: { text: string }; delivery: string; promotedSeq?: number }
|
type PromptBody = { id: string; data: { text: string }; delivery: string }
|
||||||
const firstBody = yield* json<{ data: PromptBody }>(first)
|
const firstBody = yield* json<{ data: PromptBody }>(first)
|
||||||
const retriedBody = yield* json<{ data: PromptBody }>(retried)
|
const retriedBody = yield* json<{ data: PromptBody }>(retried)
|
||||||
expect(first.status).toBe(200)
|
expect(first.status).toBe(200)
|
||||||
expect(retried.status).toBe(200)
|
expect(retried.status).toBe(200)
|
||||||
expect(retriedBody).toEqual(firstBody)
|
expect(retriedBody).toEqual(firstBody)
|
||||||
expect(firstBody).toMatchObject({
|
expect(firstBody).toMatchObject({
|
||||||
data: { id: "msg_http_prompt", prompt: { text: "hello" }, delivery: "steer" },
|
data: { id: "msg_http_prompt", data: { text: "hello" }, delivery: "steer" },
|
||||||
})
|
})
|
||||||
|
|
||||||
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
|
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
|
||||||
|
|
@ -587,8 +587,8 @@ describe("session HttpApi", () => {
|
||||||
const admitted = yield* Database.Service.use(({ db }) =>
|
const admitted = yield* Database.Service.use(({ db }) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionInputTable)
|
.from(SessionPendingTable)
|
||||||
.where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_http_prompt")))
|
.where(eq(SessionPendingTable.id, SessionMessage.ID.make("msg_http_prompt")))
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
)
|
)
|
||||||
|
|
@ -596,12 +596,11 @@ describe("session HttpApi", () => {
|
||||||
id: "msg_http_prompt",
|
id: "msg_http_prompt",
|
||||||
session_id: session.id,
|
session_id: session.id,
|
||||||
delivery: "steer",
|
delivery: "steer",
|
||||||
promoted_seq: null,
|
|
||||||
})
|
})
|
||||||
const conflict = yield* request(`/api/session/${session.id}/prompt`, {
|
const conflict = yield* request(`/api/session/${session.id}/prompt`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...headers, "content-type": "application/json" },
|
headers: { ...headers, "content-type": "application/json" },
|
||||||
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" } }),
|
body: JSON.stringify({ id: "msg_http_prompt", text: "goodbye" }),
|
||||||
})
|
})
|
||||||
expect(conflict.status).toBe(409)
|
expect(conflict.status).toBe(409)
|
||||||
expect(yield* responseJson(conflict)).toEqual({
|
expect(yield* responseJson(conflict)).toEqual({
|
||||||
|
|
@ -614,7 +613,7 @@ describe("session HttpApi", () => {
|
||||||
const wake = yield* request(`/api/session/${session.id}/prompt`, {
|
const wake = yield* request(`/api/session/${session.id}/prompt`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...headers, "content-type": "application/json" },
|
headers: { ...headers, "content-type": "application/json" },
|
||||||
body: JSON.stringify({ id: wakeID, prompt: { text: "hello again" } }),
|
body: JSON.stringify({ id: wakeID, text: "hello again" }),
|
||||||
})
|
})
|
||||||
expect(wake.status).toBe(200)
|
expect(wake.status).toBe(200)
|
||||||
const message = yield* pollWithTimeout(
|
const message = yield* pollWithTimeout(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { SessionInput } from "@opencode-ai/schema/session-input"
|
import { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||||
|
|
@ -294,11 +294,11 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||||
payload: Schema.Struct({
|
payload: Schema.Struct({
|
||||||
id: SessionMessage.ID.pipe(Schema.optional),
|
id: SessionMessage.ID.pipe(Schema.optional),
|
||||||
...PromptInput.Prompt.fields,
|
...PromptInput.Prompt.fields,
|
||||||
metadata: SessionInput.UserData.fields.metadata,
|
metadata: SessionPending.UserData.fields.metadata,
|
||||||
delivery: SessionInput.Delivery.pipe(Schema.optional),
|
delivery: SessionPending.Delivery.pipe(Schema.optional),
|
||||||
resume: Schema.Boolean.pipe(Schema.optional),
|
resume: Schema.Boolean.pipe(Schema.optional),
|
||||||
}),
|
}),
|
||||||
success: Schema.Struct({ data: SessionInput.User }),
|
success: Schema.Struct({ data: SessionPending.User }),
|
||||||
error: [ConflictError, InvalidRequestError, SessionNotFoundError],
|
error: [ConflictError, InvalidRequestError, SessionNotFoundError],
|
||||||
})
|
})
|
||||||
.middleware(sessionLocationMiddleware)
|
.middleware(sessionLocationMiddleware)
|
||||||
|
|
@ -321,10 +321,10 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||||
model: Model.Ref.pipe(Schema.optional),
|
model: Model.Ref.pipe(Schema.optional),
|
||||||
files: PromptInput.Prompt.fields.files,
|
files: PromptInput.Prompt.fields.files,
|
||||||
agents: PromptInput.Prompt.fields.agents,
|
agents: PromptInput.Prompt.fields.agents,
|
||||||
delivery: SessionInput.Delivery.pipe(Schema.optional),
|
delivery: SessionPending.Delivery.pipe(Schema.optional),
|
||||||
resume: Schema.Boolean.pipe(Schema.optional),
|
resume: Schema.Boolean.pipe(Schema.optional),
|
||||||
}),
|
}),
|
||||||
success: Schema.Struct({ data: SessionInput.User }),
|
success: Schema.Struct({ data: SessionPending.User }),
|
||||||
error: [ConflictError, InvalidRequestError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError],
|
error: [ConflictError, InvalidRequestError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError],
|
||||||
})
|
})
|
||||||
.middleware(sessionLocationMiddleware)
|
.middleware(sessionLocationMiddleware)
|
||||||
|
|
@ -365,10 +365,10 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
description: Schema.String.pipe(Schema.optional),
|
description: Schema.String.pipe(Schema.optional),
|
||||||
metadata: SessionMessage.Synthetic.fields.metadata,
|
metadata: SessionMessage.Synthetic.fields.metadata,
|
||||||
delivery: SessionInput.Delivery.pipe(Schema.optional),
|
delivery: SessionPending.Delivery.pipe(Schema.optional),
|
||||||
resume: Schema.Boolean.pipe(Schema.optional),
|
resume: Schema.Boolean.pipe(Schema.optional),
|
||||||
}),
|
}),
|
||||||
success: Schema.Struct({ data: SessionInput.Synthetic }),
|
success: Schema.Struct({ data: SessionPending.Synthetic }),
|
||||||
error: [ConflictError, SessionNotFoundError],
|
error: [ConflictError, SessionNotFoundError],
|
||||||
})
|
})
|
||||||
.middleware(sessionLocationMiddleware)
|
.middleware(sessionLocationMiddleware)
|
||||||
|
|
@ -404,7 +404,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||||
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
|
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional) }),
|
payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional) }),
|
||||||
success: Schema.Struct({ data: SessionInput.Compaction }),
|
success: Schema.Struct({ data: SessionPending.Compaction }),
|
||||||
error: [ConflictError, SessionNotFoundError],
|
error: [ConflictError, SessionNotFoundError],
|
||||||
})
|
})
|
||||||
.middleware(sessionLocationMiddleware)
|
.middleware(sessionLocationMiddleware)
|
||||||
|
|
@ -482,6 +482,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.get("session.pending.list", "/api/session/:sessionID/pending", {
|
||||||
|
params: { sessionID: Session.ID },
|
||||||
|
success: Schema.Struct({ data: Schema.Array(SessionPending.Info) }),
|
||||||
|
error: SessionNotFoundError,
|
||||||
|
})
|
||||||
|
.middleware(sessionLocationMiddleware)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.session.pending.list",
|
||||||
|
summary: "List pending session work",
|
||||||
|
description:
|
||||||
|
"List durable admitted session work not yet visible in projected history, ordered by admission. Includes unpromoted user and synthetic inputs and unhandled compaction barriers. The runner owns consumption; items disappear once promoted or handled.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
|
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export { Provider } from "./provider.js"
|
||||||
export { Reference } from "./reference.js"
|
export { Reference } from "./reference.js"
|
||||||
export { Session } from "./session.js"
|
export { Session } from "./session.js"
|
||||||
export { Vcs } from "./vcs.js"
|
export { Vcs } from "./vcs.js"
|
||||||
export { SessionInput } from "./session-input.js"
|
export { SessionPending } from "./session-pending.js"
|
||||||
export { SessionError } from "./session-error.js"
|
export { SessionError } from "./session-error.js"
|
||||||
export { SessionMessage } from "./session-message.js"
|
export { SessionMessage } from "./session-message.js"
|
||||||
export { Snapshot } from "./snapshot.js"
|
export { Snapshot } from "./snapshot.js"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { Skill as SkillSchema } from "./skill.js"
|
||||||
import { Money } from "./money.js"
|
import { Money } from "./money.js"
|
||||||
import { Snapshot } from "./snapshot.js"
|
import { Snapshot } from "./snapshot.js"
|
||||||
import { TokenUsage } from "./token-usage.js"
|
import { TokenUsage } from "./token-usage.js"
|
||||||
import { SessionInput } from "./session-input.js"
|
import { SessionPending } from "./session-pending.js"
|
||||||
|
|
||||||
export { FileAttachment }
|
export { FileAttachment }
|
||||||
|
|
||||||
|
|
@ -130,7 +130,7 @@ export const InputAdmitted = Event.durable({
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
inputID: SessionMessage.ID,
|
inputID: SessionMessage.ID,
|
||||||
input: SessionInput.Message,
|
input: SessionPending.Message,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type InputAdmitted = typeof InputAdmitted.Type
|
export type InputAdmitted = typeof InputAdmitted.Type
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export * as SessionInput from "./session-input.js"
|
export * as SessionPending from "./session-pending.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional } from "./schema.js"
|
import { optional } from "./schema.js"
|
||||||
|
|
@ -15,32 +15,32 @@ export interface UserData extends Schema.Schema.Type<typeof UserData> {}
|
||||||
export const UserData = Schema.Struct({
|
export const UserData = Schema.Struct({
|
||||||
...Prompt.fields,
|
...Prompt.fields,
|
||||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||||
}).annotate({ identifier: "SessionInput.UserData" })
|
}).annotate({ identifier: "SessionPending.UserData" })
|
||||||
|
|
||||||
export interface SyntheticData extends Schema.Schema.Type<typeof SyntheticData> {}
|
export interface SyntheticData extends Schema.Schema.Type<typeof SyntheticData> {}
|
||||||
export const SyntheticData = Schema.Struct({
|
export const SyntheticData = Schema.Struct({
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
description: Schema.String.pipe(optional),
|
description: Schema.String.pipe(optional),
|
||||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||||
}).annotate({ identifier: "SessionInput.SyntheticData" })
|
}).annotate({ identifier: "SessionPending.SyntheticData" })
|
||||||
|
|
||||||
export interface UserMessage extends Schema.Schema.Type<typeof UserMessage> {}
|
export interface UserMessage extends Schema.Schema.Type<typeof UserMessage> {}
|
||||||
export const UserMessage = Schema.Struct({
|
export const UserMessage = Schema.Struct({
|
||||||
type: Schema.tag("user"),
|
type: Schema.tag("user"),
|
||||||
data: UserData,
|
data: UserData,
|
||||||
delivery: Delivery,
|
delivery: Delivery,
|
||||||
}).annotate({ identifier: "SessionInput.UserMessage" })
|
}).annotate({ identifier: "SessionPending.UserMessage" })
|
||||||
|
|
||||||
export interface SyntheticMessage extends Schema.Schema.Type<typeof SyntheticMessage> {}
|
export interface SyntheticMessage extends Schema.Schema.Type<typeof SyntheticMessage> {}
|
||||||
export const SyntheticMessage = Schema.Struct({
|
export const SyntheticMessage = Schema.Struct({
|
||||||
type: Schema.tag("synthetic"),
|
type: Schema.tag("synthetic"),
|
||||||
data: SyntheticData,
|
data: SyntheticData,
|
||||||
delivery: Delivery,
|
delivery: Delivery,
|
||||||
}).annotate({ identifier: "SessionInput.SyntheticMessage" })
|
}).annotate({ identifier: "SessionPending.SyntheticMessage" })
|
||||||
|
|
||||||
export const Message = Schema.Union([UserMessage, SyntheticMessage]).pipe(
|
export const Message = Schema.Union([UserMessage, SyntheticMessage]).pipe(
|
||||||
Schema.toTaggedUnion("type"),
|
Schema.toTaggedUnion("type"),
|
||||||
Schema.annotate({ identifier: "SessionInput.Message" }),
|
Schema.annotate({ identifier: "SessionPending.Message" }),
|
||||||
)
|
)
|
||||||
export type Message = typeof Message.Type
|
export type Message = typeof Message.Type
|
||||||
|
|
||||||
|
|
@ -50,32 +50,27 @@ const Admitted = {
|
||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
timeCreated: DateTimeUtcFromMillis,
|
timeCreated: DateTimeUtcFromMillis,
|
||||||
}
|
}
|
||||||
const MessageLifecycle = {
|
|
||||||
...Admitted,
|
|
||||||
promotedSeq: NonNegativeInt.pipe(optional),
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User extends Schema.Schema.Type<typeof User> {}
|
export interface User extends Schema.Schema.Type<typeof User> {}
|
||||||
export const User = Schema.Struct({
|
export const User = Schema.Struct({
|
||||||
...MessageLifecycle,
|
...Admitted,
|
||||||
...UserMessage.fields,
|
...UserMessage.fields,
|
||||||
}).annotate({ identifier: "SessionInput.User" })
|
}).annotate({ identifier: "SessionPending.User" })
|
||||||
|
|
||||||
export interface Synthetic extends Schema.Schema.Type<typeof Synthetic> {}
|
export interface Synthetic extends Schema.Schema.Type<typeof Synthetic> {}
|
||||||
export const Synthetic = Schema.Struct({
|
export const Synthetic = Schema.Struct({
|
||||||
...MessageLifecycle,
|
...Admitted,
|
||||||
...SyntheticMessage.fields,
|
...SyntheticMessage.fields,
|
||||||
}).annotate({ identifier: "SessionInput.Synthetic" })
|
}).annotate({ identifier: "SessionPending.Synthetic" })
|
||||||
|
|
||||||
export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
|
export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
|
||||||
export const Compaction = Schema.Struct({
|
export const Compaction = Schema.Struct({
|
||||||
...Admitted,
|
...Admitted,
|
||||||
type: Schema.tag("compaction"),
|
type: Schema.tag("compaction"),
|
||||||
handledSeq: NonNegativeInt.pipe(optional),
|
}).annotate({ identifier: "SessionPending.Compaction" })
|
||||||
}).annotate({ identifier: "SessionInput.Compaction" })
|
|
||||||
|
|
||||||
export const Info = Schema.Union([User, Synthetic, Compaction]).pipe(
|
export const Info = Schema.Union([User, Synthetic, Compaction]).pipe(
|
||||||
Schema.toTaggedUnion("type"),
|
Schema.toTaggedUnion("type"),
|
||||||
Schema.annotate({ identifier: "SessionInput.Info" }),
|
Schema.annotate({ identifier: "SessionPending.Info" }),
|
||||||
)
|
)
|
||||||
export type Info = typeof Info.Type
|
export type Info = typeof Info.Type
|
||||||
|
|
@ -10,7 +10,7 @@ import { Pty } from "../src/pty.js"
|
||||||
import { Question } from "../src/question.js"
|
import { Question } from "../src/question.js"
|
||||||
import { Session } from "../src/session.js"
|
import { Session } from "../src/session.js"
|
||||||
import { SessionMessage } from "../src/session-message.js"
|
import { SessionMessage } from "../src/session-message.js"
|
||||||
import { SessionInput } from "../src/session-input.js"
|
import { SessionPending } from "../src/session-pending.js"
|
||||||
import { FileDiff } from "../src/file-diff.js"
|
import { FileDiff } from "../src/file-diff.js"
|
||||||
import { Money } from "../src/money.js"
|
import { Money } from "../src/money.js"
|
||||||
import { Skill } from "../src/skill.js"
|
import { Skill } from "../src/skill.js"
|
||||||
|
|
@ -39,7 +39,7 @@ describe("contract hygiene", () => {
|
||||||
expect(Schema.encodeSync(Value)({ value: 1 })).toEqual({ value: "1" })
|
expect(Schema.encodeSync(Value)({ value: 1 })).toEqual({ value: "1" })
|
||||||
expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({})
|
expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({})
|
||||||
expect(
|
expect(
|
||||||
Schema.encodeSync(SessionInput.SyntheticData)({
|
Schema.encodeSync(SessionPending.SyntheticData)({
|
||||||
text: "completed",
|
text: "completed",
|
||||||
description: undefined,
|
description: undefined,
|
||||||
metadata: undefined,
|
metadata: undefined,
|
||||||
|
|
@ -89,10 +89,10 @@ describe("contract hygiene", () => {
|
||||||
Pty.Info,
|
Pty.Info,
|
||||||
Session.ListAnchor,
|
Session.ListAnchor,
|
||||||
Session.Revert,
|
Session.Revert,
|
||||||
SessionInput.UserData,
|
SessionPending.UserData,
|
||||||
SessionInput.SyntheticData,
|
SessionPending.SyntheticData,
|
||||||
SessionInput.User,
|
SessionPending.User,
|
||||||
SessionInput.Synthetic,
|
SessionPending.Synthetic,
|
||||||
].map((schema) => schema.ast.annotations?.identifier)
|
].map((schema) => schema.ast.annotations?.identifier)
|
||||||
|
|
||||||
expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true)
|
expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true)
|
||||||
|
|
@ -134,7 +134,7 @@ describe("contract hygiene", () => {
|
||||||
|
|
||||||
test("reviewed session contracts use their canonical current shapes", () => {
|
test("reviewed session contracts use their canonical current shapes", () => {
|
||||||
expect(SessionMessage.Info.ast.annotations?.identifier).toBe("Session.Message.Info")
|
expect(SessionMessage.Info.ast.annotations?.identifier).toBe("Session.Message.Info")
|
||||||
expect(SessionInput.Info.ast.annotations?.identifier).toBe("SessionInput.Info")
|
expect(SessionPending.Info.ast.annotations?.identifier).toBe("SessionPending.Info")
|
||||||
expect(Money.USD).not.toBe(Money.USDPerMillionTokens)
|
expect(Money.USD).not.toBe(Money.USDPerMillionTokens)
|
||||||
expect(
|
expect(
|
||||||
FileDiff.Info.make({ file: "src/index.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }),
|
FileDiff.Info.make({ file: "src/index.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@
|
||||||
"effect": "catalog:"
|
"effect": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||||
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:"
|
"@typescript/native-preview": "catalog:"
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,6 @@ export { Question } from "@opencode-ai/schema/question"
|
||||||
export { Reference } from "@opencode-ai/schema/reference"
|
export { Reference } from "@opencode-ai/schema/reference"
|
||||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||||
export { Session } from "@opencode-ai/schema/session"
|
export { Session } from "@opencode-ai/schema/session"
|
||||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
export { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
export { Skill } from "@opencode-ai/schema/skill"
|
export { Skill } from "@opencode-ai/schema/skill"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,23 @@
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
|
import { Location as CoreLocation } from "@opencode-ai/core/location"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
|
import { SessionPending as CoreSessionPending } from "@opencode-ai/core/session/pending"
|
||||||
|
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
|
import { Location } from "@opencode-ai/schema/location"
|
||||||
import { Model } from "@opencode-ai/schema/model"
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
|
import { Project } from "@opencode-ai/schema/project"
|
||||||
|
import { Provider } from "@opencode-ai/schema/provider"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
|
import { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
|
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
|
import { Api } from "@opencode-ai/server/api"
|
||||||
|
import { ClientApi, groupNames, promiseOmitEndpoints } from "@opencode-ai/protocol/client"
|
||||||
|
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
||||||
|
|
||||||
const SDK = await import("../src/index")
|
const SDK = await import("../src/index")
|
||||||
|
|
||||||
|
|
@ -32,9 +48,38 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||||
"Reference",
|
"Reference",
|
||||||
"RelativePath",
|
"RelativePath",
|
||||||
"Session",
|
"Session",
|
||||||
"SessionInput",
|
|
||||||
"SessionMessage",
|
"SessionMessage",
|
||||||
|
"SessionPending",
|
||||||
"Skill",
|
"Skill",
|
||||||
"Tool",
|
"Tool",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
||||||
|
expect(AgentV2.ID).toBe(Agent.ID)
|
||||||
|
expect(CoreLocation.Ref).toBe(Location.Ref)
|
||||||
|
expect(ModelV2.Ref).toBe(Model.Ref)
|
||||||
|
expect(SessionV2.Info).toBe(Session.Info)
|
||||||
|
expect(ProjectV2.Current).toBe(Project.Current)
|
||||||
|
expect(ProjectV2.Directory).toBe(Project.Directory)
|
||||||
|
expect(ProjectV2.Directories).toBe(Project.Directories)
|
||||||
|
expect(CoreSessionPending.Message).toBe(SessionPending.Message)
|
||||||
|
expect(CoreSessionPending.User).toBe(SessionPending.User)
|
||||||
|
expect(CoreSessionPending.Synthetic).toBe(SessionPending.Synthetic)
|
||||||
|
expect(CoreSessionMessage.Info).toBe(SessionMessage.Info)
|
||||||
|
expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText)
|
||||||
|
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||||
|
expect(Api.groups["server.project"].identifier).toBe("server.project")
|
||||||
|
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
||||||
|
expect(Session.ID.create()).toStartWith("ses_")
|
||||||
|
expect(String(Project.ID.global)).toBe("global")
|
||||||
|
expect(String(Provider.ID.anthropic)).toBe("anthropic")
|
||||||
|
expect(Workspace.ID.create()).toStartWith("wrk_")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("client and Server contracts generate identically", () => {
|
||||||
|
const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints })
|
||||||
|
const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
|
||||||
|
|
||||||
|
expect(emitPromise(client)).toEqual(emitPromise(server))
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,7 @@ it.live(
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
const context = yield* opencode.sessions.context({ sessionID: id })
|
const context = yield* opencode.sessions.context({ sessionID: id })
|
||||||
|
const pendingAfterAdmit = yield* opencode.sessions.pending.list({ sessionID: id })
|
||||||
yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "deploy-target", value: "production" })
|
yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "deploy-target", value: "production" })
|
||||||
yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "flags", value: { beta: true } })
|
yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "flags", value: { beta: true } })
|
||||||
const contextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
|
const contextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
|
||||||
|
|
@ -245,6 +246,7 @@ it.live(
|
||||||
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 pendingAfterPromote = yield* opencode.sessions.pending.list({ sessionID: id })
|
||||||
const event = yield* opencode.sessions.log({ sessionID: id }).pipe(
|
const event = yield* opencode.sessions.log({ sessionID: id }).pipe(
|
||||||
Stream.filter((item) => item.type !== "log.synced"),
|
Stream.filter((item) => item.type !== "log.synced"),
|
||||||
Stream.take(1),
|
Stream.take(1),
|
||||||
|
|
@ -264,6 +266,7 @@ it.live(
|
||||||
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.instructions.entry.list({ sessionID: missingSessionID }).pipe(Effect.flip),
|
opencode.sessions.instructions.entry.list({ sessionID: missingSessionID }).pipe(Effect.flip),
|
||||||
|
opencode.sessions.pending.list({ sessionID: missingSessionID }).pipe(Effect.flip),
|
||||||
],
|
],
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
)
|
)
|
||||||
|
|
@ -280,7 +283,11 @@ it.live(
|
||||||
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({})
|
||||||
expect(admitted.sessionID).toBe(id)
|
expect(admitted.sessionID).toBe(id)
|
||||||
|
expect(pendingAfterAdmit).toContainEqual(
|
||||||
|
expect.objectContaining({ id: admitted.id, type: "user", delivery: "steer" }),
|
||||||
|
)
|
||||||
expect(prompted.type).toBe("session.input.promoted")
|
expect(prompted.type).toBe("session.input.promoted")
|
||||||
|
expect(pendingAfterPromote.map((item) => item.id)).not.toContainAnyValues([admitted.id, wake.id])
|
||||||
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
|
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
|
||||||
expect(contextEntries).toEqual([
|
expect(contextEntries).toEqual([
|
||||||
{ key: "deploy-target", value: "production" },
|
{ key: "deploy-target", value: "production" },
|
||||||
|
|
@ -295,6 +302,7 @@ it.live(
|
||||||
"SessionNotFoundError",
|
"SessionNotFoundError",
|
||||||
"SessionNotFoundError",
|
"SessionNotFoundError",
|
||||||
"SessionNotFoundError",
|
"SessionNotFoundError",
|
||||||
|
"SessionNotFoundError",
|
||||||
])
|
])
|
||||||
expect(missingMessage._tag).toBe("MessageNotFoundError")
|
expect(missingMessage._tag).toBe("MessageNotFoundError")
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,8 @@ import type {
|
||||||
V2SessionMessageResponses,
|
V2SessionMessageResponses,
|
||||||
V2SessionMoveErrors,
|
V2SessionMoveErrors,
|
||||||
V2SessionMoveResponses,
|
V2SessionMoveResponses,
|
||||||
|
V2SessionPendingListErrors,
|
||||||
|
V2SessionPendingListResponses,
|
||||||
V2SessionPermissionCreateErrors,
|
V2SessionPermissionCreateErrors,
|
||||||
V2SessionPermissionCreateResponses,
|
V2SessionPermissionCreateResponses,
|
||||||
V2SessionPermissionGetErrors,
|
V2SessionPermissionGetErrors,
|
||||||
|
|
@ -5251,6 +5253,31 @@ export class Revert extends HeyApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Pending extends HeyApiClient {
|
||||||
|
/**
|
||||||
|
* List pending session work
|
||||||
|
*
|
||||||
|
* List durable admitted session work not yet visible in projected history, ordered by admission. Includes unpromoted user and synthetic inputs and unhandled compaction barriers. The runner owns consumption; items disappear once promoted or handled.
|
||||||
|
*/
|
||||||
|
public list<ThrowOnError extends boolean = false>(
|
||||||
|
parameters: {
|
||||||
|
sessionID: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
|
||||||
|
return (options?.client ?? this.client).get<
|
||||||
|
V2SessionPendingListResponses,
|
||||||
|
V2SessionPendingListErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
|
url: "/api/session/{sessionID}/pending",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Entry extends HeyApiClient {
|
export class Entry extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* List instruction entries
|
* List instruction entries
|
||||||
|
|
@ -6524,6 +6551,11 @@ export class Session3 extends HeyApiClient {
|
||||||
return (this._revert ??= new Revert({ client: this.client }))
|
return (this._revert ??= new Revert({ client: this.client }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _pending?: Pending
|
||||||
|
get pending(): Pending {
|
||||||
|
return (this._pending ??= new Pending({ client: this.client }))
|
||||||
|
}
|
||||||
|
|
||||||
private _instructions?: Instructions
|
private _instructions?: Instructions
|
||||||
get instructions(): Instructions {
|
get instructions(): Instructions {
|
||||||
return (this._instructions ??= new Instructions({ client: this.client }))
|
return (this._instructions ??= new Instructions({ client: this.client }))
|
||||||
|
|
|
||||||
|
|
@ -846,7 +846,7 @@ export type GlobalEvent = {
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
inputID: string
|
inputID: string
|
||||||
input: SessionInputMessage
|
input: SessionPendingMessage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -3352,7 +3352,7 @@ export type PromptAgentAttachment = {
|
||||||
mention?: PromptMention
|
mention?: PromptMention
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputUserData = {
|
export type SessionPendingUserData = {
|
||||||
text: string
|
text: string
|
||||||
files?: Array<PromptFileAttachment>
|
files?: Array<PromptFileAttachment>
|
||||||
agents?: Array<PromptAgentAttachment>
|
agents?: Array<PromptAgentAttachment>
|
||||||
|
|
@ -3361,13 +3361,13 @@ export type SessionInputUserData = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputUserMessage = {
|
export type SessionPendingUserMessage = {
|
||||||
type: "user"
|
type: "user"
|
||||||
data: SessionInputUserData
|
data: SessionPendingUserData
|
||||||
delivery: "steer" | "queue"
|
delivery: "steer" | "queue"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputSyntheticData = {
|
export type SessionPendingSyntheticData = {
|
||||||
text: string
|
text: string
|
||||||
description?: string
|
description?: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
|
|
@ -3375,13 +3375,13 @@ export type SessionInputSyntheticData = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputSyntheticMessage = {
|
export type SessionPendingSyntheticMessage = {
|
||||||
type: "synthetic"
|
type: "synthetic"
|
||||||
data: SessionInputSyntheticData
|
data: SessionPendingSyntheticData
|
||||||
delivery: "steer" | "queue"
|
delivery: "steer" | "queue"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputMessage = SessionInputUserMessage | SessionInputSyntheticMessage
|
export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSyntheticMessage
|
||||||
|
|
||||||
export type SessionStructuredError = {
|
export type SessionStructuredError = {
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -3817,7 +3817,7 @@ export type SyncEventSessionInputAdmitted = {
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
inputID: string
|
inputID: string
|
||||||
input: SessionInputMessage
|
input: SessionPendingMessage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4450,35 +4450,32 @@ export type PromptInputFileAttachment = {
|
||||||
mention?: PromptMention
|
mention?: PromptMention
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputUser = {
|
export type SessionPendingUser = {
|
||||||
admittedSeq: number
|
admittedSeq: number
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
timeCreated: number
|
timeCreated: number
|
||||||
promotedSeq?: number
|
|
||||||
type: "user"
|
type: "user"
|
||||||
data: SessionInputUserData
|
data: SessionPendingUserData
|
||||||
delivery: "steer" | "queue"
|
delivery: "steer" | "queue"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputSynthetic = {
|
export type SessionPendingSynthetic = {
|
||||||
admittedSeq: number
|
admittedSeq: number
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
timeCreated: number
|
timeCreated: number
|
||||||
promotedSeq?: number
|
|
||||||
type: "synthetic"
|
type: "synthetic"
|
||||||
data: SessionInputSyntheticData
|
data: SessionPendingSyntheticData
|
||||||
delivery: "steer" | "queue"
|
delivery: "steer" | "queue"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputCompaction = {
|
export type SessionPendingCompaction = {
|
||||||
admittedSeq: number
|
admittedSeq: number
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
timeCreated: number
|
timeCreated: number
|
||||||
type: "compaction"
|
type: "compaction"
|
||||||
handledSeq?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMessageAgentSelected = {
|
export type SessionMessageAgentSelected = {
|
||||||
|
|
@ -4747,6 +4744,8 @@ export type SessionMessageInfo =
|
||||||
| SessionMessageAssistant
|
| SessionMessageAssistant
|
||||||
| SessionMessageCompaction
|
| SessionMessageCompaction
|
||||||
|
|
||||||
|
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
|
||||||
|
|
||||||
export type InstructionEntryKey = string
|
export type InstructionEntryKey = string
|
||||||
|
|
||||||
export type InstructionEntryInfo = {
|
export type InstructionEntryInfo = {
|
||||||
|
|
@ -4904,7 +4903,7 @@ export type SessionInputAdmitted = {
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
inputID: string
|
inputID: string
|
||||||
input: SessionInputMessage
|
input: SessionPendingMessage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7185,7 +7184,7 @@ export type EventSessionInputAdmitted = {
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
inputID: string
|
inputID: string
|
||||||
input: SessionInputMessage
|
input: SessionPendingMessage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -8778,35 +8777,32 @@ export type SessionInfoV2 = {
|
||||||
|
|
||||||
export type PromptBase64V2 = string
|
export type PromptBase64V2 = string
|
||||||
|
|
||||||
export type SessionInputUserV2 = {
|
export type SessionPendingUserV2 = {
|
||||||
admittedSeq: number
|
admittedSeq: number
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
timeCreated: number
|
timeCreated: number
|
||||||
promotedSeq?: number
|
|
||||||
type: "user"
|
type: "user"
|
||||||
data: SessionInputUserData
|
data: SessionPendingUserData
|
||||||
delivery: "steer" | "queue"
|
delivery: "steer" | "queue"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputSyntheticV2 = {
|
export type SessionPendingSyntheticV2 = {
|
||||||
admittedSeq: number
|
admittedSeq: number
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
timeCreated: number
|
timeCreated: number
|
||||||
promotedSeq?: number
|
|
||||||
type: "synthetic"
|
type: "synthetic"
|
||||||
data: SessionInputSyntheticData
|
data: SessionPendingSyntheticData
|
||||||
delivery: "steer" | "queue"
|
delivery: "steer" | "queue"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputCompactionV2 = {
|
export type SessionPendingCompactionV2 = {
|
||||||
admittedSeq: number
|
admittedSeq: number
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
timeCreated: number
|
timeCreated: number
|
||||||
type: "compaction"
|
type: "compaction"
|
||||||
handledSeq?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMessageAgentSelectedV2 = {
|
export type SessionMessageAgentSelectedV2 = {
|
||||||
|
|
@ -9123,7 +9119,7 @@ export type SessionInputPromotedV2 = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputUserData1 = {
|
export type SessionPendingUserData1 = {
|
||||||
text: string
|
text: string
|
||||||
files?: Array<PromptFileAttachment>
|
files?: Array<PromptFileAttachment>
|
||||||
agents?: Array<PromptAgentAttachment>
|
agents?: Array<PromptAgentAttachment>
|
||||||
|
|
@ -9132,13 +9128,13 @@ export type SessionInputUserData1 = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputUserMessageV2 = {
|
export type SessionPendingUserMessageV2 = {
|
||||||
type: "user"
|
type: "user"
|
||||||
data: SessionInputUserData1
|
data: SessionPendingUserData1
|
||||||
delivery: "steer" | "queue"
|
delivery: "steer" | "queue"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputSyntheticData1 = {
|
export type SessionPendingSyntheticData1 = {
|
||||||
text: string
|
text: string
|
||||||
description?: string
|
description?: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
|
|
@ -9146,9 +9142,9 @@ export type SessionInputSyntheticData1 = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInputSyntheticMessageV2 = {
|
export type SessionPendingSyntheticMessageV2 = {
|
||||||
type: "synthetic"
|
type: "synthetic"
|
||||||
data: SessionInputSyntheticData1
|
data: SessionPendingSyntheticData1
|
||||||
delivery: "steer" | "queue"
|
delivery: "steer" | "queue"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -9168,7 +9164,7 @@ export type SessionInputAdmittedV2 = {
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
inputID: string
|
inputID: string
|
||||||
input: SessionInputMessage
|
input: SessionPendingMessage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15683,7 +15679,7 @@ export type V2SessionPromptResponses = {
|
||||||
* Success
|
* Success
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
data: SessionInputUserV2
|
data: SessionPendingUserV2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15738,7 +15734,7 @@ export type V2SessionCommandResponses = {
|
||||||
* Success
|
* Success
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
data: SessionInputUserV2
|
data: SessionPendingUserV2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15827,7 +15823,7 @@ export type V2SessionSyntheticResponses = {
|
||||||
* Success
|
* Success
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
data: SessionInputSyntheticV2
|
data: SessionPendingSyntheticV2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15908,7 +15904,7 @@ export type V2SessionCompactResponses = {
|
||||||
* Success
|
* Success
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
data: SessionInputCompactionV2
|
data: SessionPendingCompactionV2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16124,6 +16120,43 @@ export type V2SessionContextResponses = {
|
||||||
|
|
||||||
export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses]
|
export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses]
|
||||||
|
|
||||||
|
export type V2SessionPendingListData = {
|
||||||
|
body?: never
|
||||||
|
path: {
|
||||||
|
sessionID: string
|
||||||
|
}
|
||||||
|
query?: never
|
||||||
|
url: "/api/session/{sessionID}/pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2SessionPendingListErrors = {
|
||||||
|
/**
|
||||||
|
* InvalidRequestError
|
||||||
|
*/
|
||||||
|
400: InvalidRequestErrorV2
|
||||||
|
/**
|
||||||
|
* UnauthorizedError
|
||||||
|
*/
|
||||||
|
401: UnauthorizedError
|
||||||
|
/**
|
||||||
|
* SessionNotFoundError
|
||||||
|
*/
|
||||||
|
404: SessionNotFoundError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2SessionPendingListError = V2SessionPendingListErrors[keyof V2SessionPendingListErrors]
|
||||||
|
|
||||||
|
export type V2SessionPendingListResponses = {
|
||||||
|
/**
|
||||||
|
* Success
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
data: Array<SessionPendingInfo>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2SessionPendingListResponse = V2SessionPendingListResponses[keyof V2SessionPendingListResponses]
|
||||||
|
|
||||||
export type V2SessionInstructionsEntryListData = {
|
export type V2SessionInstructionsEntryListData = {
|
||||||
body?: never
|
body?: never
|
||||||
path: {
|
path: {
|
||||||
|
|
|
||||||
|
|
@ -600,6 +600,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.handle(
|
||||||
|
"session.pending.list",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
return {
|
||||||
|
data: yield* session.pending(ctx.params.sessionID).pipe(
|
||||||
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||||
|
Effect.fail(
|
||||||
|
new SessionNotFoundError({
|
||||||
|
sessionID: error.sessionID,
|
||||||
|
message: `Session not found: ${error.sessionID}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
.handle(
|
.handle(
|
||||||
"session.instructions.entry.list",
|
"session.instructions.entry.list",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,18 @@
|
||||||
# V2 Schema Changelog
|
# V2 Schema Changelog
|
||||||
|
|
||||||
|
## 2026-07-09: Make Session Input Storage Pending-Only And Rename It To Session Pending
|
||||||
|
|
||||||
|
- Rename the `SessionInput` schema namespace to `SessionPending` and the `session_input` table to `session_pending`.
|
||||||
|
- Make the table pending-only: promotion consumes the user or synthetic row in the same event transaction that projects the message, and compaction settlement deletes the barrier row. Drop the `promoted_seq` column and the retained `promotedSeq`/`handledSeq` wire fields.
|
||||||
|
- Add `GET /api/session/:sessionID/pending` (`v2.session.pending.list`) returning durable admitted work not yet visible in projected history, ordered by admission.
|
||||||
|
- Reconcile exact retry of an already-promoted input against the projected `session_message` row plus the durable `session.input.admitted` event instead of a retained row. A projected message without an admitted event in the aggregate (for example fork-copied history) is conflicting reuse. Reusing a settled compaction ID admits a fresh barrier instead of reconciling; the worst case is one redundant compaction on a retried request.
|
||||||
|
|
||||||
|
Compatibility:
|
||||||
|
|
||||||
|
- `20260709190621_session_pending_table` drops `session_input` (including consumed ledger rows, any in-flight pending work, and whatever historical index variant the database carried) and creates the empty `session_pending` table. V2 storage is beta; no compatibility or data retention is attempted.
|
||||||
|
- Durable event names and payloads are unchanged; `session.input.admitted` still records the full admitted message including delivery.
|
||||||
|
- Promise, Effect, and legacy JavaScript SDK surfaces are regenerated; `SessionInput*` generated schema names become `SessionPending*` while event-derived names keep their `session.input.*` vocabulary.
|
||||||
|
|
||||||
## 2026-07-05: Rename Session Context Contracts To Instructions
|
## 2026-07-05: Rename Session Context Contracts To Instructions
|
||||||
|
|
||||||
- Rename the System Context algebra to `Instructions`, API-managed `SessionContextEntry` records to `InstructionEntry`, and the session-owned context checkpoint to `InstructionCheckpoint`.
|
- Rename the System Context algebra to `Instructions`, API-managed `SessionContextEntry` records to `InstructionEntry`, and the session-owned context checkpoint to `InstructionCheckpoint`.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue