Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 32e2e3f2f7 feat(tui): surface plugin failures 2026-08-12 02:43:52 +00:00
711 changed files with 6542 additions and 158815 deletions
-1
View File
@@ -10,7 +10,6 @@ kitlangton
kommander kommander
ludvigrask ludvigrask
MrMushrooooom MrMushrooooom
neriousy
nexxeln nexxeln
R44VC0RP R44VC0RP
rekram1-node rekram1-node
-49
View File
@@ -1,49 +0,0 @@
name: deploy-lab-catalog
on:
push:
branches: [v2]
paths:
- ".github/workflows/deploy-lab-catalog.yml"
- "bun.lock"
- "package.json"
- "packages/drive/**"
- "packages/protocol/src/simulation.ts"
- "packages/simulation/**"
- "packages/lab/catalog/**"
workflow_dispatch:
concurrency:
group: deploy-lab-catalog-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: ./.github/actions/setup-bun
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Validate
run: |
bun --cwd packages/protocol typecheck
bun --cwd packages/simulation typecheck
bun --cwd packages/drive run check
bun --cwd packages/drive run test
bun --cwd packages/lab/catalog run check
- name: Deploy
working-directory: packages/lab/catalog
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -7
View File
@@ -55,12 +55,6 @@ jobs:
git config --global user.email "bot@opencode.ai" git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode" git config --global user.name "opencode"
- name: Install ffmpeg
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Cache Turbo - name: Cache Turbo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with: with:
@@ -72,7 +66,7 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }} run: GITHUB_ACTIONS=false bun turbo test
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
+4 -4
View File
@@ -51,7 +51,7 @@ files. If the script is unsuccessful, automatically fix the script and run it ag
Scripts use one typed definition object. `setup` runs before OpenCode starts, Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project. and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
```ts ```ts
import { defineScript } from "opencode-drive" import { defineScript } from "opencode-drive"
@@ -83,7 +83,7 @@ itself (this is extremely rare, do not use this unless explicitly asked). In thi
mode `ui` is typed as `null`; call `server.launch()` exactly mode `ui` is typed as `null`; call `server.launch()` exactly
once before launching clients. Each `clients.launch(name)` result provides the once before launching clients. Each `clients.launch(name)` result provides the
same UI methods as the automatic client. You can see an example of this API same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
Use the exported `wait(milliseconds)` utility for an unconditional delay. Use the exported `wait(milliseconds)` utility for an unconditional delay.
@@ -114,8 +114,8 @@ completion are automatic.
You can see some example scripts here: You can see some example scripts here:
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts - https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts - https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
## Prune ## Prune
+1 -1
View File
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = { const TEAM = {
tui: ["kommander", "simonklee"], tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"], desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"], core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom", "starptech"], inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"], windows: ["Hona"],
} as const } as const
+2 -3
View File
@@ -1,9 +1,8 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. - After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required. - Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `v2`. - The default branch in this repo is `dev`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing ## Live V2 TUI Testing
+517 -1073
View File
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -33,7 +33,6 @@
"packages": [ "packages": [
"packages/*", "packages/*",
"packages/console/*", "packages/console/*",
"packages/lab/*",
"packages/stats/*", "packages/stats/*",
"packages/slack" "packages/slack"
], ],
@@ -47,9 +46,9 @@
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.2", "@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.5.2", "@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.5.2", "@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0", "@standard-schema/spec": "1.1.0",
@@ -121,8 +120,7 @@
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "catalog:", "sst": "catalog:",
"turbo": "2.10.2", "turbo": "2.10.2"
"vitest": "4.1.10"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
@@ -152,9 +150,9 @@
"electron" "electron"
], ],
"overrides": { "overrides": {
"@opentui/core": "catalog:", "@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "catalog:", "@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "catalog:", "@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"effect": "catalog:" "effect": "catalog:"
@@ -174,7 +172,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch" "@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
} }
} }
@@ -573,10 +573,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
cache_control: cacheControl(breakpoints, part.cache), cache_control: cacheControl(breakpoints, part.cache),
}) })
} }
const previous = messages.at(-1) messages.push({ role: "user", content })
if (previous?.role === "user" && previous.content.every((block) => block.type === "tool_result"))
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
} }
return messages return messages
+30 -73
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect" import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
import { import {
FetchHttpClient, FetchHttpClient,
Headers, Headers,
@@ -297,86 +297,44 @@ export const classifyHttpFailure = (input: {
}) })
} }
type HttpOperation = "request" | "read" const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const transportError = (input: {
const NativeTransportFailure = Schema.Struct({ readonly message: string
message: Schema.String, readonly kind?: string | undefined
code: Schema.optionalKey(Schema.String), readonly request?: HttpClientRequest.HttpClientRequest | undefined
cause: Schema.optionalKey(Schema.Unknown), }) =>
})
const decodeNativeTransportFailure = Schema.decodeUnknownOption(NativeTransportFailure)
const nativeTransportFailure = (error: unknown) => {
const failure = Option.getOrUndefined(decodeNativeTransportFailure(error))
if (!failure) return undefined
if (failure.code !== undefined) return failure
const cause = Option.getOrUndefined(decodeNativeTransportFailure(failure.cause))
if (cause?.code !== undefined) return cause
return failure
}
const httpError = (input: {
readonly error: unknown
readonly request: HttpClientRequest.HttpClientRequest
readonly operation: HttpOperation
readonly redactedNames: ReadonlyArray<string | RegExp>
}) => {
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
new AIError({ new AIError({
module: "RequestExecutor", module: "RequestExecutor",
method: input.operation, method: "execute",
reason: new TransportReason({ reason: new TransportReason({
message: failure.message, message: input.message,
transport: "http", kind: input.kind,
operation: input.operation, url: input.request ? redactUrl(input.request.url) : undefined,
code: failure.code, http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
url: redactUrl(request.url),
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
}), }),
}) })
const source = if (Cause.isTimeoutError(error)) {
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason return transportError({ message: error.message, kind: "Timeout" })
? input.error.reason.cause }
: input.error if (!HttpClientError.isHttpClientError(error)) {
const native = nativeTransportFailure(source) return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
const code = native?.code }
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined) const request = "request" in error ? error.request : undefined
const detail = raw ? redactBody(raw, secretValues(request)) : undefined if (error.reason._tag === "TransportError") {
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
return transportError({ message: message ?? "HTTP transport timed out", code: code ?? "Timeout" })
if (!HttpClientError.isHttpClientError(input.error))
return transportError({ message: message ?? "HTTP transport failed", code })
if (input.error.reason._tag === "TransportError") {
return transportError({ return transportError({
message: message ?? input.error.reason.description ?? "HTTP transport failed", message: error.reason.description ?? "HTTP transport failed",
code: code ?? input.error.reason._tag, kind: error.reason._tag,
request,
}) })
} }
return transportError({ return transportError({
message: message ?? `HTTP transport failed: ${input.error.reason._tag}`, message: `HTTP transport failed: ${error.reason._tag}`,
code: code ?? input.error.reason._tag, kind: error.reason._tag,
request,
}) })
} }
export const stream = (
executor: Interface,
request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware,
): Stream.Stream<Uint8Array, AIError> =>
Stream.unwrap(
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
const response = yield* executor.execute(request, middleware)
return response.stream.pipe(
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })),
)
}),
)
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect( export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -385,16 +343,15 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Effect.gen(function* () { Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware) if (!middleware)
return yield* http.execute(request).pipe( return yield* http
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })), .execute(request)
Effect.flatMap(statusError(request, redactedNames)), .pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
)
const response = yield* middleware(request, (input) => const response = yield* middleware(request, (input) =>
http http
.execute(input) .execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))), .pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames }))) ).pipe(Effect.mapError(toHttpError(redactedNames)))
return yield* statusError(response.request, redactedNames)(response) return yield* statusError(response.request, redactedNames)(response)
}) })
return Service.of({ return Service.of({
+21 -4
View File
@@ -1,4 +1,4 @@
import { Effect } from "effect" import { Effect, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth.js" import { Auth } from "../auth.js"
import { render as renderEndpoint } from "../endpoint.js" import { render as renderEndpoint } from "../endpoint.js"
@@ -6,7 +6,6 @@ import { Framing } from "../framing.js"
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js" import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js"
import * as ProviderShared from "../../protocols/shared.js" import * as ProviderShared from "../../protocols/shared.js"
import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js" import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js"
import { RequestExecutor } from "../executor.js"
export type JsonRequestInput<Body> = TransportPrepareInput<Body> export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@@ -87,8 +86,26 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware, middleware: prepareInput.middleware,
} }
}), }),
frames: (prepared, _request, runtime) => frames: (prepared, request, runtime) =>
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)), Stream.unwrap(
runtime.http
.execute(prepared.request, prepared.middleware)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
),
),
),
),
),
),
}) })
export const sseJson = { export const sseJson = {
+13 -36
View File
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js" import { AIError, TransportReason } from "../../schema/index.js"
import * as HttpTransport from "./http.js" import * as HttpTransport from "./http.js"
import type { Transport } from "./index.js" import type { Transport } from "./index.js"
@@ -29,18 +29,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
const transportError = ( const transportError = (
method: string, method: string,
message: string, message: string,
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string }, input: { readonly url?: string; readonly kind?: string } = {},
) => ) =>
new AIError({ new AIError({
module: "WebSocketExecutor", module: "WebSocketExecutor",
method, method,
reason: new TransportReason({ reason: new TransportReason({ message, url: input.url, kind: input.kind }),
message,
transport: "websocket",
operation: input.operation,
url: input.url,
code: input.code,
}),
}) })
const eventMessage = (event: Event) => { const eventMessage = (event: Event) => {
@@ -61,8 +55,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
return Effect.fail( return Effect.fail(
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, { transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
url: input.url, url: input.url,
operation: "request", kind: "open",
code: "closed",
}), }),
) )
} }
@@ -86,10 +79,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
cleanup() cleanup()
resume( resume(
Effect.fail( Effect.fail(
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
url: input.url,
operation: "request",
}),
), ),
) )
} }
@@ -99,8 +89,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
Effect.fail( Effect.fail(
transportError("open", `WebSocket closed before opening with code ${event.code}`, { transportError("open", `WebSocket closed before opening with code ${event.code}`, {
url: input.url, url: input.url,
operation: "request", kind: "open",
code: String(event.code),
}), }),
), ),
) )
@@ -129,8 +118,7 @@ const webSocketUrl = (value: string) =>
catch: (error) => catch: (error) =>
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", { transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
url: value, url: value,
operation: "request", kind: "websocket",
code: "invalid-url",
}), }),
}) })
@@ -141,7 +129,7 @@ export const open = (input: WebSocketRequest) =>
catch: (error) => catch: (error) =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", { transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url, url: input.url,
operation: "request", kind: "open",
}), }),
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input))) }).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
@@ -162,10 +150,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
transportError("message", "Unsupported WebSocket message payload", { transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
url: input.url,
operation: "read",
}),
), ),
) )
} }
@@ -173,10 +158,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
transportError("message", `WebSocket error: ${eventMessage(event)}`, { transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
url: input.url,
operation: "read",
}),
), ),
) )
} }
@@ -185,11 +167,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
transportError("message", `WebSocket closed with code ${event.code}`, { transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
url: input.url,
operation: "read",
code: String(event.code),
}),
), ),
) )
} }
@@ -210,7 +188,7 @@ export const fromWebSocket = (
catch: (error) => catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", { transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url, url: input.url,
operation: "write", kind: "write",
}), }),
}), }),
messages: Stream.fromQueue(messages), messages: Stream.fromQueue(messages),
@@ -265,8 +243,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
return Stream.fail( return Stream.fail(
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", { transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
url: prepared.url, url: prepared.url,
operation: "request", kind: "websocket",
code: "unavailable",
}), }),
) )
} }
+1 -9
View File
@@ -92,18 +92,10 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export const TransportType = Schema.Literals(["http", "websocket"])
export type TransportType = typeof TransportType.Type
export const TransportOperation = Schema.Literals(["request", "read", "write"])
export type TransportOperation = typeof TransportOperation.Type
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
_tag: Schema.tag("Transport"), _tag: Schema.tag("Transport"),
message: Schema.String, message: Schema.String,
transport: TransportType, kind: Schema.optional(Schema.String),
operation: TransportOperation,
code: Schema.optional(Schema.String),
url: Schema.optional(Schema.String), url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
+3 -101
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Ref, Stream } from "effect" import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, AIError } from "../src/index.js" import { LLM, AIError } from "../src/index.js"
import { LLMClient, RequestExecutor } from "../src/route.js" import { LLMClient, RequestExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js" import * as OpenAIChat from "../src/protocols/openai-chat.js"
import { dynamicResponse, systemError } from "./lib/http.js" import { dynamicResponse } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js" import { deltaChunk } from "./lib/openai-chunks.js"
import { sseRaw } from "./lib/sse.js" import { sseRaw } from "./lib/sse.js"
import { it } from "./lib/effect.js" import { it } from "./lib/effect.js"
@@ -67,62 +67,6 @@ const expectAIError = (error: unknown) => {
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined) const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => { describe("RequestExecutor", () => {
it.effect("parses response body failures at the executor seam", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: disconnected <redacted> <redacted>",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
),
),
)
it.effect("unwraps native transport failure causes", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed",
operation: "read",
code: "ECONNRESET",
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
),
),
)
it.effect("preserves middleware error messages", () => it.effect("preserves middleware error messages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
@@ -135,48 +79,6 @@ describe("RequestExecutor", () => {
}).pipe(Effect.provide(responsesLayer([]))), }).pipe(Effect.provide(responsesLayer([]))),
) )
it.effect("reports the request sent by middleware", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor
.execute(request, (original, handler) =>
handler(
original.pipe(
HttpClientRequest.setUrl("https://proxy.test/v1/chat?api_key=proxy-secret"),
HttpClientRequest.setHeader("authorization", "Bearer proxy-secret"),
),
),
)
.pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: proxy disconnected <redacted>",
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
http: {
request: {
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
headers: { authorization: "<redacted>" },
},
},
})
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: input.request,
cause: systemError("ECONNRESET", "proxy disconnected proxy-secret"),
}),
}),
),
),
),
),
)
it.effect("classifies context overflow responses", () => it.effect("classifies context overflow responses", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
+6 -20
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Ref } from "effect" import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js" import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
import type { Service as LLMClientService } from "../../src/route/client.js" import type { Service as LLMClientService } from "../../src/route/client.js"
import type { Service as RequestExecutorService } from "../../src/route/executor.js" import type { Service as RequestExecutorService } from "../../src/route/executor.js"
@@ -14,9 +14,7 @@ export type HandlerInput = {
) => HttpClientResponse.HttpClientResponse ) => HttpClientResponse.HttpClientResponse
} }
export type Handler = ( export type Handler = (input: HandlerInput) => Effect.Effect<HttpClientResponse.HttpClientResponse>
input: HandlerInput,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> => const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
Layer.succeed( Layer.succeed(
@@ -36,12 +34,6 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
export interface SystemError extends Error {
readonly code: string
}
export const systemError = (code: string, message: string): SystemError => Object.assign(new Error(message), { code })
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => { export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer)) const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
@@ -71,20 +63,14 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to * Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
* exercise transport errors that surface during parsing. * exercise transport errors that surface during parsing.
*/ */
export const truncatedStream = (chunks: ReadonlyArray<string>, error: Error = new Error("connection reset")) => export const truncatedStream = (chunks: ReadonlyArray<string>) =>
dynamicResponse((input) => dynamicResponse((input) =>
Effect.sync(() => { Effect.sync(() => {
const encoder = new TextEncoder() const encoder = new TextEncoder()
let index = 0
const stream = new ReadableStream({ const stream = new ReadableStream({
pull(controller) { start(controller) {
const chunk = chunks[index] for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
if (chunk !== undefined) { controller.error(new Error("connection reset"))
index++
controller.enqueue(encoder.encode(chunk))
return
}
controller.error(error)
}, },
}) })
return input.respond(stream, { headers: SSE_HEADERS }) return input.respond(stream, { headers: SSE_HEADERS })
@@ -271,47 +271,6 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("batches parallel tool results into one Anthropic user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Check both cities."),
Message.assistant([
{ type: "text", text: "I'll check both." },
ToolCallPart.make({ id: "call_paris", name: "weather", input: { city: "Paris" } }),
ToolCallPart.make({ id: "call_london", name: "weather", input: { city: "London" } }),
]),
Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }),
Message.tool({ id: "call_london", name: "weather", result: { temperature: 18 } }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: [{ type: "text", text: "Check both cities." }] },
{
role: "assistant",
content: [
{ type: "text", text: "I'll check both." },
{ type: "tool_use", id: "call_paris", name: "weather", input: { city: "Paris" } },
{ type: "tool_use", id: "call_london", name: "weather", input: { city: "London" } },
],
},
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' },
{ type: "tool_result", tool_use_id: "call_london", content: '{"temperature":18}' },
],
},
])
expect(prepared.body.messages).toHaveLength(3)
}),
)
it.effect("keeps tools and sends tool_choice none", () => it.effect("keeps tools and sends tool_choice none", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -956,54 +915,6 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_paris", name: "weather", input: {} },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
},
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "call_london", name: "weather", input: {} },
},
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"city":"London"}' },
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
{ id: "call_london", name: "weather", input: { city: "London" } },
])
expect(response.message.content).toMatchObject([
{ type: "tool-call", id: "call_paris", name: "weather", input: { city: "Paris" } },
{ type: "tool-call", id: "call_london", name: "weather", input: { city: "London" } },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
}),
)
it.effect("keeps malformed server tool input terminal", () => it.effect("keeps malformed server tool input terminal", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents( const body = sseEvents(
+7 -39
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Ref, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { import {
HttpOptions, HttpOptions,
@@ -22,7 +22,7 @@ import { ProviderShared } from "../../src/protocols/shared.js"
import { Auth, LLMClient } from "../../src/route.js" import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js" import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js" import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse, systemError, truncatedStream } from "../lib/http.js" import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http.js"
import { deltaChunk, usageChunk } from "../lib/openai-chunks.js" import { deltaChunk, usageChunk } from "../lib/openai-chunks.js"
import { sseEvents } from "../lib/sse.js" import { sseEvents } from "../lib/sse.js"
@@ -1221,44 +1221,12 @@ describe("OpenAI Chat route", () => {
it.effect("surfaces transport errors that occur mid-stream", () => it.effect("surfaces transport errors that occur mid-stream", () =>
Effect.gen(function* () { Effect.gen(function* () {
const layer = truncatedStream( const layer = truncatedStream([
[`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`], `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
systemError("ECONNRESET", "socket closed unexpectedly"), ])
) const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
const error = yield* LLMClient.stream(request).pipe(
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
Stream.runDrain,
Effect.provide(layer),
Effect.flip,
)
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue() expect(error.message).toContain("Failed to read openai/openai-chat stream")
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed unexpectedly",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://api.openai.test/v1/chat/completions",
})
}),
)
it.effect("surfaces transport errors before the first stream frame", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(truncatedStream([], systemError("ECONNRESET", "socket closed before output"))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed before output",
transport: "http",
operation: "read",
code: "ECONNRESET",
})
}), }),
) )
@@ -189,12 +189,7 @@ test.describe("smoke: session timeline", () => {
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]') .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect() ?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
if ( if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
!firstPaint &&
visible.includes(last) &&
Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1 &&
!root.querySelector('[data-markdown-key="initial"]')
) {
firstPaint = true firstPaint = true
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => { root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
const rect = row.getBoundingClientRect() const rect = row.getBoundingClientRect()
@@ -209,16 +204,10 @@ test.describe("smoke: session timeline", () => {
} }
;( ;(
window as Window & { window as Window & {
__sessionTabPaint?: { __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
samples: typeof samples
painted: () => boolean
removed: () => number
stop: () => void
}
} }
).__sessionTabPaint = { ).__sessionTabPaint = {
samples, samples,
painted: () => firstPaint,
removed: () => removedFirstPaintNodes, removed: () => removedFirstPaintNodes,
stop: () => { stop: () => {
running = false running = false
@@ -230,19 +219,17 @@ test.describe("smoke: session timeline", () => {
) )
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() => { await page.waitForFunction(() =>
const probe = ( (
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }>; painted: () => boolean } } window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
).__sessionTabPaint ).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
return probe?.painted() && probe.samples.some((sample) => sample.ids.length > 0) )
})
await page.waitForTimeout(200) await page.waitForTimeout(200)
const first = await page.evaluate(() => { const first = await page.evaluate(() => {
const probe = ( const probe = (
window as Window & { window as Window & {
__sessionTabPaint?: { __sessionTabPaint?: {
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
painted: () => boolean
removed: () => number removed: () => number
stop: () => void stop: () => void
} }
@@ -1,5 +1,4 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types" import type { Message, Part, Project, Todo } from "@/types"
import type { import type {
@@ -188,18 +187,6 @@ export function applyDirectoryEvent(input: {
input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break break
} }
case "project.directory.resolved": {
const properties = event.properties as { projectID: string; directory: string; previous: string }
input.store.session.forEach((session, index) => {
const adopted = ProjectDirectories.adopt(
{ projectID: session.projectID, directory: session.location.directory },
properties,
)
if (!adopted) return
input.setStore("session", index, (current) => ({ ...current, ...adopted }))
})
break
}
case "session.renamed": { case "session.renamed": {
const properties = event.properties as { sessionID: string; title: string } const properties = event.properties as { sessionID: string; title: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
@@ -18,18 +18,18 @@ describe("v2 session reducer", () => {
apply({ apply({
...base, ...base,
id: "evt_admitted", id: "evt_admitted",
type: "session.inbox.enqueued", type: "session.input.admitted",
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
inboxID: "msg_user", inputID: "msg_user",
item: { type: "user", delivery: "steer", payload: { text: "hello" } }, input: { type: "user", delivery: "steer", data: { text: "hello" } },
}, },
}) })
apply({ apply({
...base, ...base,
id: "evt_promoted", id: "evt_promoted",
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_1", inboxID: "msg_user" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}) })
apply({ apply({
...base, ...base,
@@ -203,8 +203,8 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_promoted", id: "evt_promoted",
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_1", inboxID: "msg_user" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
@@ -218,11 +218,11 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_admitted", id: "evt_admitted",
type: "session.inbox.enqueued", type: "session.input.admitted",
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
inboxID: "msg_user", inputID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "cancel me" } }, input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
}, },
}), }),
) )
@@ -231,8 +231,8 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_cancelled", id: "evt_cancelled",
type: "session.inbox.cancelled", type: "session.input.cancelled",
data: { sessionID: "ses_1", inboxID: "msg_user" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
const result = reducer.reduce( const result = reducer.reduce(
@@ -240,8 +240,8 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_promoted", id: "evt_promoted",
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_1", inboxID: "msg_user" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
@@ -255,11 +255,11 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_admitted", id: "evt_admitted",
type: "session.inbox.enqueued", type: "session.input.admitted",
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
inboxID: "msg_user", inputID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "steer me" } }, input: { type: "user", delivery: "queue", data: { text: "steer me" } },
}, },
}), }),
) )
@@ -268,8 +268,8 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_steered", id: "evt_steered",
type: "session.inbox.delivery.changed", type: "session.input.steered",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "steer" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
reducer.reduce( reducer.reduce(
@@ -277,8 +277,8 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_queued", id: "evt_queued",
type: "session.inbox.delivery.changed", type: "session.input.queued",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "queue" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
@@ -287,8 +287,8 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_promoted", id: "evt_promoted",
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_1", inboxID: "msg_user" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
@@ -1,4 +1,4 @@
import type { OpenCodeEvent, SessionInboxItem, SessionMessageInfo } from "@opencode-ai/client/promise" import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }> type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }> type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -12,7 +12,7 @@ export type V2SessionReduction = {
} }
export function createV2SessionReducer() { export function createV2SessionReducer() {
const pending = new Map<string, SessionInboxItem>() const pending = new Map<string, SessionPendingMessage>()
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => { const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
@@ -26,33 +26,32 @@ export function createV2SessionReducer() {
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id]) result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
switch (event.type) { switch (event.type) {
case "session.inbox.enqueued": case "session.input.admitted":
pending.set(key(sessionID, event.data.inboxID), event.data.item) pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source]) return result([...source])
case "session.inbox.cancelled": case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inboxID)) pending.delete(key(sessionID, event.data.inputID))
return return
case "session.inbox.delivered": { case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inboxID)) const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inboxID)) pending.delete(key(sessionID, event.data.inputID))
if (!input) return { ...result([...source]), missing: event.data.inboxID } if (!input) return { ...result([...source]), missing: event.data.inputID }
if (input.type === "user") if (input.type === "user")
return append({ return append({
id: event.data.inboxID, id: event.data.inputID,
type: "user", type: "user",
metadata: input.payload.metadata, metadata: input.data.metadata,
text: input.payload.text, text: input.data.text,
files: input.payload.files, files: input.data.files,
agents: input.payload.agents, agents: input.data.agents,
time: { created: event.created }, time: { created: event.created },
}) })
if (input.type !== "synthetic") return result([...source])
return append({ return append({
id: event.data.inboxID, id: event.data.inputID,
type: "synthetic", type: "synthetic",
metadata: input.payload.metadata, metadata: input.data.metadata,
text: input.payload.text, text: input.data.text,
description: input.payload.description, description: input.data.description,
time: { created: event.created }, time: { created: event.created },
}) })
} }
@@ -352,7 +351,7 @@ export function createV2SessionReducer() {
metadata: event.metadata, metadata: event.metadata,
reason: event.data.reason, reason: event.data.reason,
summary: "", summary: "",
recent: event.data.recent ?? "", recent: event.data.recent,
time: { created: event.created }, time: { created: event.created },
}) })
case "session.compaction.delta": case "session.compaction.delta":
@@ -1,5 +1,4 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Message, Part, Todo } from "@/types" import type { Message, Part, Todo } from "@/types"
@@ -895,17 +894,6 @@ export function createServerSession(
} }
const applyV2 = (event: OpenCodeEvent) => { const applyV2 = (event: OpenCodeEvent) => {
if (event.type === "project.directory.resolved") {
Object.values(data.info).forEach((info) => {
if (!info) return
const adopted = ProjectDirectories.adopt(
{ projectID: info.projectID, directory: info.location.directory },
event.data,
)
if (adopted) remember({ ...info, ...adopted })
})
return
}
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID const sessionID = event.data.sessionID
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event) const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
+3 -2
View File
@@ -538,8 +538,9 @@ async function replayMessage(
} }
function matchesStart(event: EventSubscribeOutput, start: TurnStart) { function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
if (start.type === "input") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id if (start.type === "input") return event.type === "session.input.promoted" && event.data.inputID === start.id
if (start.type === "compaction") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id if (start.type === "compaction")
return event.type === "session.compaction.admitted" && event.data.inputID === start.id
return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_") return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
} }
+31 -53
View File
@@ -1,8 +1,7 @@
export * as Config from "./config" export * as Config from "./config"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Flock } from "@opencode-ai/util/flock" import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect"
import { produce, type Draft } from "immer" import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser" import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path" import path from "path"
@@ -29,6 +28,7 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service const global = yield* Global.Service
const file = path.join(global.config, "cli.json") const file = path.join(global.config, "cli.json")
const lock = yield* Semaphore.make(1)
const readJson = Effect.fnUntraced(function* () { const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined))) const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
@@ -49,60 +49,38 @@ export const layer = Layer.effect(
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe( const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(FileSystem.FileSystem, fs),
) )
const withLock = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.scoped(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const lock = yield* restore(
Effect.promise((signal) => Flock.acquire(file, { dir: path.join(global.state, "locks"), signal })),
)
yield* Effect.addFinalizer(() => Effect.promise(() => lock.release()))
return yield* restore(effect)
}),
),
)
const get = Effect.fn("cli.config.get")(() => const get = Effect.fn("cli.config.get")(function* () {
withLock( yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
Effect.gen(function* () { return Option.getOrElse(decode(yield* readJson()), () => empty)
const migration = yield* migrate.pipe( })
Effect.catchCause((cause) =>
Effect.logWarning("failed to migrate cli config", { cause }).pipe(Effect.as(undefined)),
),
)
if (migration?.cause)
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
if (migration?.info) return migration.info
return Option.getOrElse(decode(yield* readJson()), () => empty)
}),
),
)
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) => const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
withLock( lock
Effect.gen(function* () { .withPermits(1)(
const migration = yield* migrate Effect.gen(function* () {
if (migration?.cause) return yield* Effect.failCause(migration.cause) yield* migrate
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty) const current = Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update) const next = produce(current, update)
const edits = changes(current, next) const edits = changes(current, next)
if (!edits.length) return current if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}"))) const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce( const updated = edits.reduce(
(text, edit) => (text, edit) =>
applyEdits( applyEdits(
text, text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }), modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
), ),
text, text,
) )
const errors: ParseError[] = [] const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true }))) const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update")) if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n") yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config return config
}), }),
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))), )
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
) )
return Service.of({ path: file, get, update }) return Service.of({ path: file, get, update })
+15 -123
View File
@@ -1,18 +1,13 @@
export * as ConfigMigration from "./migrate" export * as ConfigMigration from "./migrate"
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1" import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
import { Definitions } from "@opencode-ai/tui/config/keybind"
import { Effect, FileSystem, Option, Schema } from "effect" import { Effect, FileSystem, Option, Schema } from "effect"
import { randomUUID } from "crypto" import { parse, type ParseError } from "jsonc-parser"
import { createScanner, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import path from "path" import path from "path"
import { Info } from "./schema" import type { Info } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info) const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeInfo = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any)) const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const LegacyKeybindTargets = new Set<string>(Object.values(TuiKeybind.CommandMap))
export const run = Effect.fn("cli.config.migrate")(function* (input: { export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly file: string readonly file: string
@@ -20,60 +15,7 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly state: string readonly state: string
}) { }) {
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
const persist = Effect.fnUntraced(function* (text: string, info: Info) { if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
const temp = `${input.file}.${process.pid}.${randomUUID()}.tmp`
const cause = yield* Effect.gen(function* () {
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, text, { mode: 0o600 })
yield* fs.rename(temp, input.file)
}).pipe(
Effect.as(undefined),
Effect.catchCause((cause) => Effect.succeed(cause)),
Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)),
)
return cause === undefined ? { info } : { info, cause }
})
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) {
const text = yield* fs.readFileString(input.file)
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return
const config = Option.getOrUndefined(decodeRecord(value))
if (config === undefined) return
const keybinds = Option.getOrUndefined(decodeRecord(config.keybinds))
if (keybinds === undefined) return
const deduped = findKeybindObjects(text)
.slice(0, -1)
.reduce((text) => {
const property = findKeybindObjects(text)[0]
return property === undefined ? text : removeProperty(text, property)
}, text)
const updated = Object.keys(keybinds).reduce((text, name) => {
const target =
TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ??
(name in Definitions || LegacyKeybindTargets.has(name) ? name : undefined)
if (target === undefined) return text
const properties = findKeybindProperties(text, name)
if (!properties.length) return text
const remove = !(target in Definitions) || (target !== name && target in keybinds)
// The parser gives the final duplicate precedence, so remove earlier properties before renaming it.
const updated = properties.slice(0, remove ? properties.length : -1).reduce((text) => {
const property = findKeybindProperties(text, name)[0]
return property === undefined ? text : removeProperty(text, property)
}, text)
if (remove) return updated
if (target === name) return updated
const key = findKeybindProperties(updated, name)[0]?.children?.[0]
if (key === undefined) return text
return updated.slice(0, key.offset) + JSON.stringify(target) + updated.slice(key.offset + key.length)
}, deduped)
if (updated === text) return
const updatedErrors: ParseError[] = []
const info = Option.getOrUndefined(decodeInfo(parse(updated, updatedErrors, { allowTrailingComma: true })))
if (updatedErrors.length || info === undefined) return
return yield* persist(updated, info)
}
const legacyValue = yield* readJson(path.join(input.config, "tui.json")) const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue)) const legacy = Option.getOrUndefined(decodeV1(legacyValue))
@@ -81,59 +23,19 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
const migrated = migrateV1(legacy, kv ?? {}) const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return if (!Object.keys(migrated).length) return
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated) const temp = input.file + ".tmp"
if (result.cause === undefined) yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* Effect.logInfo("migrated cli config", { yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
from: [ yield* fs.rename(temp, input.file)
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"), yield* Effect.logInfo("migrated cli config", {
kv === undefined ? undefined : path.join(input.state, "kv.json"), from: [
].filter(Boolean), legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
to: input.file, kv === undefined ? undefined : path.join(input.state, "kv.json"),
}) ].filter(Boolean),
return result to: input.file,
})
}) })
function findKeybindProperties(text: string, name: string) {
const keybinds = findKeybindObjects(text).at(-1)?.children?.[1]
return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? []
}
function findKeybindObjects(text: string) {
const tree = parseTree(text)
if (tree === undefined) return []
return tree.children?.filter((property) => property.children?.[0]?.value === "keybinds") ?? []
}
function removeProperty(text: string, property: Node) {
const properties = property.parent?.children ?? []
const index = properties.indexOf(property)
const end = property.offset + property.length
const next = properties[index + 1]
if (next) {
const comma = findComma(text, end, next.offset)
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
}
const previous = properties[index - 1]
if (previous) {
const comma = findComma(text, previous.offset + previous.length, property.offset)
if (comma !== undefined) return text.slice(0, comma) + text.slice(comma + 1, property.offset) + text.slice(end)
}
const comma = findComma(text, end, (property.parent?.offset ?? 0) + (property.parent?.length ?? 0))
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
return text.slice(0, property.offset) + text.slice(end)
}
function findComma(text: string, start: number, end: number) {
const scanner = createScanner(text, false)
scanner.setPosition(start)
while (true) {
scanner.scan()
const offset = scanner.getTokenOffset()
if (scanner.getTokenLength() === 0 || offset >= end) return
if (text[offset] === ",") return offset
}
}
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info { export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
const plugins = [ const plugins = [
...(legacy?.plugin?.map((plugin) => ...(legacy?.plugin?.map((plugin) =>
@@ -147,16 +49,6 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined) const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
const thinking = const thinking =
kv.thinking_mode ?? (kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide") kv.thinking_mode ?? (kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
const keybinds =
legacy?.keybinds === undefined
? undefined
: Object.fromEntries(
Object.entries(legacy.keybinds).flatMap(([name, value]) => {
const target = TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? name
if (!(target in Definitions)) return []
return [[target, value]]
}),
)
return { return {
...(themeName !== undefined || themeMode !== undefined ...(themeName !== undefined || themeMode !== undefined
@@ -167,7 +59,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
}, },
} }
: {}), : {}),
...(keybinds === undefined ? {} : { keybinds }), ...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
...(plugins.length ? { plugins } : {}), ...(plugins.length ? { plugins } : {}),
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }), ...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined ...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
+2 -2
View File
@@ -195,8 +195,8 @@ export async function runNonInteractivePrompt(input: Input) {
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
const time = toMillis("created" in event ? event.created : undefined) const time = toMillis("created" in event ? event.created : undefined)
if (event.type === "session.inbox.delivered") { if (event.type === "session.input.promoted") {
if (event.data.inboxID === messageID) { if (event.data.inputID === messageID) {
promoted = true promoted = true
prePromotionError = undefined prePromotionError = undefined
continue continue
+18 -18
View File
@@ -22,8 +22,8 @@ describe("acp event behavior", () => {
delta: "before admission", delta: "before admission",
}), }),
) )
send(durableEvent("session.inbox.delivered", { sessionID: "ses_b", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_b", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: "input_other" })) send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: "input_other" }))
send( send(
ephemeralEvent("session.text.delta", { ephemeralEvent("session.text.delta", {
sessionID: "ses_a", sessionID: "ses_a",
@@ -32,7 +32,7 @@ describe("acp event behavior", () => {
delta: "wrong input", delta: "wrong input",
}), }),
) )
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: id }))
send( send(
ephemeralEvent("session.text.delta", { ephemeralEvent("session.text.delta", {
sessionID: "ses_b", sessionID: "ses_b",
@@ -68,7 +68,7 @@ describe("acp event behavior", () => {
fixture, fixture,
connection: recordingConnection(updates), connection: recordingConnection(updates),
sessionID: "ses_a", sessionID: "ses_a",
inboxID: "input_a", inputID: "input_a",
}) })
expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([ expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([
@@ -99,7 +99,7 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = [] const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({ const fixture = createSseFixture({
async onPrompt({ id, send }) { async onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_order", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_order", inputID: id }))
send( send(
ephemeralEvent("session.reasoning.delta", { ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order", sessionID: "ses_order",
@@ -148,7 +148,7 @@ describe("acp event behavior", () => {
}, },
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection } satisfies Connection
const result = turn({ fixture, connection, sessionID: "ses_order", inboxID: "input_order" }) const result = turn({ fixture, connection, sessionID: "ses_order", inputID: "input_order" })
try { try {
await withTimeout(firstUpdate.promise, "first ordered update was not delivered") await withTimeout(firstUpdate.promise, "first ordered update was not delivered")
@@ -195,7 +195,7 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = [] const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send( send(
durableEvent("session.created", { durableEvent("session.created", {
sessionID: "ses_child", sessionID: "ses_child",
@@ -240,7 +240,7 @@ describe("acp event behavior", () => {
fixture, fixture,
connection: recordingConnection(updates), connection: recordingConnection(updates),
sessionID: "ses_parent", sessionID: "ses_parent",
inboxID: "input_parent", inputID: "input_parent",
}) })
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([ expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
@@ -276,7 +276,7 @@ describe("acp event behavior", () => {
const completed = Promise.withResolvers<void>() const completed = Promise.withResolvers<void>()
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send( send(
durableEvent("session.created", { durableEvent("session.created", {
sessionID: "ses_background", sessionID: "ses_background",
@@ -292,7 +292,7 @@ describe("acp event behavior", () => {
fixture, fixture,
connection: recordingConnection(updates), connection: recordingConnection(updates),
sessionID: "ses_parent", sessionID: "ses_parent",
inboxID: "input_parent", inputID: "input_parent",
childSessionUpdate: async (update) => { childSessionUpdate: async (update) => {
childUpdates.push(update) childUpdates.push(update)
if (update.type === "status" && update.status === "completed") completed.resolve() if (update.type === "status" && update.status === "completed") completed.resolve()
@@ -370,7 +370,7 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = [] const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_tools", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_tools", inputID: id }))
send( send(
durableEvent("session.tool.input.started", { durableEvent("session.tool.input.started", {
sessionID: "ses_tools", sessionID: "ses_tools",
@@ -460,7 +460,7 @@ describe("acp event behavior", () => {
fixture, fixture,
connection: recordingConnection(updates), connection: recordingConnection(updates),
sessionID: "ses_tools", sessionID: "ses_tools",
inboxID: "input_tools", inputID: "input_tools",
}) })
expect( expect(
@@ -597,7 +597,7 @@ describe("acp event behavior", () => {
const control: TurnControl = { cancelled: false, admission: new AbortController() } const control: TurnControl = { cancelled: false, admission: new AbortController() }
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_cancel", inputID: id }))
}, },
onInterrupt({ sessionID, send }) { onInterrupt({ sessionID, send }) {
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" })) send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
@@ -680,7 +680,7 @@ describe("acp event behavior", () => {
test("cancels unsupported session forms so execution can continue", async () => { test("cancels unsupported session forms so execution can continue", async () => {
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_form", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id }))
send( send(
ephemeralEvent("form.created", { ephemeralEvent("form.created", {
form: { form: {
@@ -704,7 +704,7 @@ describe("acp event behavior", () => {
fixture, fixture,
connection: recordingConnection([]), connection: recordingConnection([]),
sessionID: "ses_form", sessionID: "ses_form",
inboxID: "input_form", inputID: "input_form",
}) })
expect(response.stopReason).toBe("end_turn") expect(response.stopReason).toBe("end_turn")
@@ -730,7 +730,7 @@ function turn(input: {
readonly fixture: Fixture readonly fixture: Fixture
readonly connection: Connection readonly connection: Connection
readonly sessionID: string readonly sessionID: string
readonly inboxID: string readonly inputID: string
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void> readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
}) { }) {
return streamTurn({ return streamTurn({
@@ -738,12 +738,12 @@ function turn(input: {
connection: input.connection, connection: input.connection,
sessionID: input.sessionID, sessionID: input.sessionID,
cwd: "/workspace", cwd: "/workspace",
start: { type: "input", id: input.inboxID }, start: { type: "input", id: input.inputID },
writeTextFile: false, writeTextFile: false,
control: { cancelled: false, admission: new AbortController() }, control: { cancelled: false, admission: new AbortController() },
childSessionUpdate: input.childSessionUpdate, childSessionUpdate: input.childSessionUpdate,
submit: (signal) => submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }), input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
}) })
} }
+2 -2
View File
@@ -34,8 +34,8 @@ test("acp prompt resolves after ordered turn updates", async () => {
send(events, { send(events, {
id: "evt_promoted", id: "evt_promoted",
created: 1, created: 1,
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_test", inboxID: id }, data: { sessionID: "ses_test", inputID: id },
}) })
send(events, { send(events, {
id: "evt_text", id: "evt_text",
@@ -38,7 +38,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = [] const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_allow", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_allow", inputID: id }))
send( send(
permissionAsked("ses_allow", "perm_once", { permissionAsked("ses_allow", "perm_once", {
action: "shell", action: "shell",
@@ -112,7 +112,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = [] const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_external", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_external", inputID: id }))
send( send(
permissionAsked("ses_external", "perm_external", { permissionAsked("ses_external", "perm_external", {
action: "external_directory", action: "external_directory",
@@ -157,7 +157,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = [] const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send( send(
durableEvent("session.created", { durableEvent("session.created", {
sessionID: "ses_child", sessionID: "ses_child",
@@ -219,7 +219,7 @@ describe("acp permission behavior", () => {
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = [] const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_edit", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_edit", inputID: id }))
send( send(
durableEvent("session.tool.input.started", { durableEvent("session.tool.input.started", {
sessionID: "ses_edit", sessionID: "ses_edit",
@@ -309,7 +309,7 @@ describe("acp permission behavior", () => {
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = [] const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_patch", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_patch", inputID: id }))
send( send(
durableEvent("session.tool.input.started", { durableEvent("session.tool.input.started", {
sessionID: "ses_patch", sessionID: "ses_patch",
@@ -389,7 +389,7 @@ describe("acp permission behavior", () => {
test("rejects explicit rejection, cancellation, and permission UI failure", async () => { test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_reject", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_reject", inputID: id }))
send(permissionAsked("ses_reject", "perm_selected_reject")) send(permissionAsked("ses_reject", "perm_selected_reject"))
send(permissionAsked("ses_reject", "perm_cancelled")) send(permissionAsked("ses_reject", "perm_cancelled"))
send(permissionAsked("ses_reject", "perm_failed")) send(permissionAsked("ses_reject", "perm_failed"))
@@ -426,7 +426,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = [] const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({ const fixture = createSseFixture({
onPrompt({ id, send }) { onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_serial", inboxID: id })) send(durableEvent("session.input.promoted", { sessionID: "ses_serial", inputID: id }))
send(permissionAsked("ses_serial", "perm_1")) send(permissionAsked("ses_serial", "perm_1"))
send(permissionAsked("ses_serial", "perm_2")) send(permissionAsked("ses_serial", "perm_2"))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" })) send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
@@ -477,8 +477,8 @@ describe("acp permission behavior", () => {
const blockedID = promptIDs.get("ses_blocked") const blockedID = promptIDs.get("ses_blocked")
const freeID = promptIDs.get("ses_free") const freeID = promptIDs.get("ses_free")
if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered") if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
send(durableEvent("session.inbox.delivered", { sessionID: "ses_blocked", inboxID: blockedID })) send(durableEvent("session.input.promoted", { sessionID: "ses_blocked", inputID: blockedID }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_free", inboxID: freeID })) send(durableEvent("session.input.promoted", { sessionID: "ses_free", inputID: freeID }))
send(permissionAsked("ses_blocked", "perm_blocked")) send(permissionAsked("ses_blocked", "perm_blocked"))
send( send(
ephemeralEvent("session.text.delta", { ephemeralEvent("session.text.delta", {
@@ -538,16 +538,16 @@ describe("acp permission behavior", () => {
}) })
}) })
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inboxID: string, cwd = "/workspace") { function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inputID: string, cwd = "/workspace") {
return streamTurn({ return streamTurn({
client: fixture.client, client: fixture.client,
connection, connection,
sessionID, sessionID,
cwd, cwd,
start: { type: "input", id: inboxID }, start: { type: "input", id: inputID },
writeTextFile: true, writeTextFile: true,
control: { cancelled: false, admission: new AbortController() }, control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }), submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
}) })
} }
+8 -8
View File
@@ -15,8 +15,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request) const id = requestID(request)
completeTurn(context, "ses_routes", { completeTurn(context, "ses_routes", {
id: `evt_${id}`, id: `evt_${id}`,
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_routes", inboxID: id }, data: { sessionID: "ses_routes", inputID: id },
}) })
return Response.json({ data: {} }) return Response.json({ data: {} })
} }
@@ -33,8 +33,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request) const id = requestID(request)
completeTurn(context, "ses_routes", { completeTurn(context, "ses_routes", {
id: `evt_${id}`, id: `evt_${id}`,
type: "session.inbox.delivered", type: "session.compaction.admitted",
data: { sessionID: "ses_routes", inboxID: id }, data: { sessionID: "ses_routes", inputID: id },
}) })
return Response.json({ data: {} }) return Response.json({ data: {} })
} }
@@ -95,8 +95,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request) const id = requestID(request)
context.send({ context.send({
id: `evt_${id}`, id: `evt_${id}`,
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_usage", inboxID: id }, data: { sessionID: "ses_usage", inputID: id },
}) })
context.send({ context.send({
id: "evt_step", id: "evt_step",
@@ -189,8 +189,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request) const id = requestID(request)
context.send({ context.send({
id: `evt_${id}`, id: `evt_${id}`,
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_usage_failure", inboxID: id }, data: { sessionID: "ses_usage_failure", inputID: id },
}) })
context.send({ context.send({
id: "evt_step_failure", id: "evt_step_failure",
+4 -288
View File
@@ -1,9 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Flock } from "@opencode-ai/util/flock"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Option } from "effect" import { Effect } from "effect"
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path" import path from "path"
import { Config } from "../src/config" import { Config } from "../src/config"
@@ -23,14 +21,7 @@ test("migrates tui and kv config into cli.json", async () => {
path.join(directory, "tui.json"), path.join(directory, "tui.json"),
JSON.stringify({ JSON.stringify({
theme: "legacy", theme: "legacy",
keybinds: { keybinds: { leader: "ctrl+o" },
leader: "ctrl+o",
app_exit: "ctrl+q",
app_heap_snapshot: "ctrl+h",
input_paste: { key: "ctrl+v", preventDefault: false },
session_delete: false,
"dialog.select.next": "ctrl+n",
},
plugin: [["example", { mode: "safe" }]], plugin: [["example", { mode: "safe" }]],
plugin_enabled: { disabled: false }, plugin_enabled: { disabled: false },
leader_timeout: 500, leader_timeout: 500,
@@ -74,13 +65,7 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).toMatchObject({ expect(config).toMatchObject({
theme: { name: "legacy", mode: "light" }, theme: { name: "legacy", mode: "light" },
keybinds: { keybinds: { leader: "ctrl+o" },
leader: "ctrl+o",
"app.exit": "ctrl+q",
"prompt.paste": { key: "ctrl+v", preventDefault: false },
"session.delete": false,
"dialog.select.next": "ctrl+n",
},
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"], plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
leader: { timeout: 500 }, leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true }, scroll: { speed: 2, acceleration: true },
@@ -95,13 +80,7 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).not.toHaveProperty("skipped_version") expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key") expect(config).not.toHaveProperty("which_key")
expect(config).not.toHaveProperty("hints") expect(config).not.toHaveProperty("hints")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
leader: "ctrl+o",
"app.exit": "ctrl+q",
"prompt.paste": { key: "ctrl+v", preventDefault: false },
"session.delete": false,
"dialog.select.next": "ctrl+n",
})
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true) expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true) expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true) expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
@@ -162,257 +141,6 @@ test("preserves legacy cursor settings", async () => {
} }
}) })
test("migrates legacy keybind names in an existing cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{
// Preserve this comment
"keybinds": {
// Session list shortcut
"session_list": "ctrl+l",
"app_heap_snapshot": "ctrl+h",
// Legacy delete shortcut
"session_delete": "ctrl+d",
// Canonical delete shortcut
"session.delete": "ctrl+x",
"app.heap_snapshot": "ctrl+shift+h"
}
}
`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({
"session.list": "ctrl+l",
"session.delete": "ctrl+x",
})
const text = await Bun.file(file).text()
expect(text).toContain("// Preserve this comment")
expect(text).toContain("// Session list shortcut")
expect(text).toContain("// Legacy delete shortcut")
expect(text).toContain("// Canonical delete shortcut")
expect(parse(text).keybinds).toEqual({
"session.list": "ctrl+l",
"session.delete": "ctrl+x",
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("uses migrated keybinds when persistence fails", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_list":"ctrl+l"}}`)
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "rename") return () => Effect.die(new Error("read-only config"))
return Reflect.get(target, property, receiver)
},
})
try {
const config = await Effect.runPromise(
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
expect(config.keybinds).toEqual({ "session.list": "ctrl+l" })
expect(await Bun.file(file).json()).toEqual({ keybinds: { session_list: "ctrl+l" } })
expect(await Array.fromAsync(new Bun.Glob("*.tmp").scan(directory))).toEqual([])
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("preserves the effective value when migrating duplicate legacy keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+a","session_delete":"ctrl+b"}}`)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({ "session.delete": "ctrl+b" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "ctrl+b" })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates and updates the effective duplicate top-level keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last" })
return yield* service.update((draft) => {
draft.keybinds = { ...draft.keybinds, "session.delete": "changed" }
})
}),
)
expect(config.keybinds).toEqual({ "session.delete": "changed" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("serializes migration and updates across processes", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const started = path.join(directory, "started")
const release = path.join(directory, "release")
const migrateReady = path.join(directory, "migrate-ready")
const updateReady = path.join(directory, "update-ready")
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`)
const worker = path.join(import.meta.dir, "fixture/config-concurrency.ts")
const migrate = Bun.spawn([process.execPath, worker, "migrate", directory, started, release, migrateReady], {
stdout: "ignore",
stderr: "pipe",
})
try {
await waitForFile(started, migrate.exited)
const update = Bun.spawn([process.execPath, worker, "update", directory, started, release, updateReady], {
stdout: "ignore",
stderr: "pipe",
})
try {
await waitForFile(updateReady, update.exited)
expect(await Promise.race([update.exited.then(() => true), Bun.sleep(500).then(() => false)])).toBe(false)
await Bun.write(release, "")
const [migrateCode, updateCode] = await Promise.all([migrate.exited, update.exited])
expect(await new Response(migrate.stderr).text()).toBe("")
expect(await new Response(update.stderr).text()).toBe("")
expect([migrateCode, updateCode]).toEqual([0, 0])
expect(await Bun.file(file).json()).toEqual({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
} finally {
update.kill()
await update.exited
}
} finally {
await Bun.write(release, "")
migrate.kill()
await migrate.exited
await Bun.$`rm -rf ${directory}`
}
})
test("config reads remain interruptible while waiting for the file lock", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const locks = path.join(directory, "locks")
const held = await Flock.acquire(file, { dir: locks })
try {
const service = await Effect.runPromise(
Config.Service.pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provide(NodeFileSystem.layer),
),
)
const result = Effect.runPromise(service.get().pipe(Effect.timeoutOption("50 millis")))
expect(await Promise.race([result, Bun.sleep(250).then(() => "blocked" as const)])).toEqual(Option.none())
} finally {
await held.release()
await Bun.$`rm -rf ${directory}`
}
})
test("updates effective duplicate canonical keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{"keybinds":{"session.delete":"first","session.delete":"last","permission.mode":"off","permission.mode":"on"}}`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last", "permission.mode": "on" })
return yield* service.update((draft) => {
draft.keybinds = { ...draft.keybinds, "session.delete": "changed", "permission.mode": "changed" }
})
}),
)
expect(config.keybinds).toEqual({ "session.delete": "changed", "permission.mode": "changed" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({
"session.delete": "changed",
"permission.mode": "changed",
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("removes orphaned keybinds without deleting trailing comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{
"keybinds": {
"app_heap_snapshot": "ctrl+h" /* Keep legacy explanation */,
"app.heap_snapshot": "ctrl+shift+h" /* Keep canonical explanation */,
},
}
`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({})
const text = await Bun.file(file).text()
expect(text).toContain("/* Keep legacy explanation */")
expect(text).toContain("/* Keep canonical explanation */")
expect(parse(text).keybinds).toEqual({})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("updates a config draft while preserving JSONC comments", async () => { test("updates a config draft while preserving JSONC comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n') await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n')
@@ -439,15 +167,3 @@ test("updates a config draft while preserving JSONC comments", async () => {
await Bun.$`rm -rf ${directory}` await Bun.$`rm -rf ${directory}`
} }
}) })
async function waitForFile(file: string, exited: Promise<number>) {
const found = await Promise.race([
(async () => {
while (!(await Bun.file(file).exists())) await Bun.sleep(10)
return true
})(),
exited.then(() => false),
Bun.sleep(5000).then(() => false),
])
if (!found) throw new Error(`timed out waiting for ${file}`)
}
@@ -1,42 +0,0 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { Config } from "../../src/config"
const [mode, directory, started, release, ready] = process.argv.slice(2)
if (!mode || !directory || !started || !release || !ready) throw new Error("missing config concurrency arguments")
if (mode !== "migrate" && mode !== "update") throw new Error(`unknown mode: ${mode}`)
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
const state = { writes: 0 }
const writeFileString: FileSystem.FileSystem["writeFileString"] = (target, data, options) => {
state.writes++
if (mode !== "migrate" || state.writes !== 1) return node.writeFileString(target, data, options)
return Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(started, ""))
while (!(yield* Effect.promise(() => Bun.file(release).exists()))) yield* Effect.sleep("10 millis")
yield* node.writeFileString(target, data, options)
})
}
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "writeFileString") return writeFileString
return Reflect.get(target, property, receiver)
},
})
const service = await Effect.runPromise(
Config.Service.pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
await Bun.write(ready, "")
if (mode === "migrate") await Effect.runPromise(service.get())
if (mode === "update")
await Effect.runPromise(
service.update((draft) => {
draft.mouse = false
}),
)
-69
View File
@@ -1,69 +0,0 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { Effect, Option } from "effect"
import { expect, mock, test } from "bun:test"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { Config } from "../src/config"
import type { MiniCommandInput } from "../src/mini"
import { OPENCODE_VERSION } from "../src/version"
test("mini handler passes resolved CLI keybinds to the runtime", async () => {
const root = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const configDirectory = path.join(root, "config")
const stateDirectory = path.join(root, "state")
await mkdir(configDirectory, { recursive: true })
await Bun.write(
path.join(configDirectory, "cli.json"),
JSON.stringify({
keybinds: { "composer.subagent.interrupt": "ctrl+i" },
leader: { timeout: 321 },
}),
)
let received: MiniCommandInput["tuiConfig"]
const mini = await import("../src/mini")
mock.module("../src/mini", () => ({
...mini,
validateMiniTerminal() {},
runMini(input: Pick<MiniCommandInput, "tuiConfig">) {
received = input.tuiConfig
return Promise.resolve()
},
}))
const handler = (await import("../src/commands/handlers/mini")).default
const server = Bun.serve({
port: 0,
fetch: () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }),
})
try {
await Effect.runPromise(
handler({
server: Option.some(server.url.toString()),
standalone: false,
continue: false,
session: Option.none(),
fork: false,
replay: true as never,
replayLimit: Option.none(),
model: Option.none(),
agent: Option.none(),
prompt: Option.none(),
demo: false,
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: configDirectory, state: stateDirectory })),
Effect.provide(NodeFileSystem.layer),
Effect.scoped,
),
)
const config = await received
expect(config?.leader.timeout).toBe(321)
expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }])
} finally {
server.stop(true)
mock.restore()
await rm(root, { recursive: true, force: true })
}
})
+10 -10
View File
@@ -28,13 +28,13 @@ function formCreated(info: FormInfo, eventLocation = location): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } } return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
} }
function prompted(inboxID: string): V2Event { function prompted(inputID: string): V2Event {
return { return {
id: "evt_prompted", id: "evt_prompted",
created: 0, created: 0,
type: "session.inbox.delivered", type: "session.input.promoted",
durable: { aggregateID: "ses_1", seq: 0, version: 1 }, durable: { aggregateID: "ses_1", seq: 0, version: 1 },
data: { sessionID: "ses_1", inboxID }, data: { sessionID: "ses_1", inputID },
} }
} }
@@ -98,9 +98,9 @@ function executionFailed(message: string): V2Event {
} }
} }
function failedTool(inboxID: string): V2Event[] { function failedTool(inputID: string): V2Event[] {
return [ return [
prompted(inboxID), prompted(inputID),
{ {
id: "evt_failed_tool_input", id: "evt_failed_tool_input",
created: 1, created: 1,
@@ -156,10 +156,10 @@ function failedTool(inboxID: string): V2Event[] {
] ]
} }
function successfulGrep(inboxID: string): V2Event[] { function successfulGrep(inputID: string): V2Event[] {
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle" const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
return [ return [
prompted(inboxID), prompted(inputID),
{ {
id: "evt_grep_input", id: "evt_grep_input",
created: 1, created: 1,
@@ -206,7 +206,7 @@ function successfulGrep(inboxID: string): V2Event[] {
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the // Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID. // live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: { async function run(input: {
turn: (inboxID: string) => V2Event[] turn: (inputID: string) => V2Event[]
pendingForms?: FormInfo[] pendingForms?: FormInfo[]
attached?: boolean attached?: boolean
format?: "default" | "json" format?: "default" | "json"
@@ -214,7 +214,7 @@ async function run(input: {
cancel?: (input: { sessionID: string; formID: string }) => Promise<void> cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void> renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void> renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inboxID: string) => SessionMessageInfo[] messages?: (inputID: string) => SessionMessageInfo[]
wait?: () => Promise<void> wait?: () => Promise<void>
terminalDelay?: number terminalDelay?: number
}) { }) {
@@ -515,7 +515,7 @@ describe("runNonInteractivePrompt", () => {
const rendered: SessionMessageAssistantTool[] = [] const rendered: SessionMessageAssistantTool[] = []
const failed: SessionMessageAssistantTool[] = [] const failed: SessionMessageAssistantTool[] = []
await capture({ await capture({
turn: (inboxID) => failedTool(inboxID).filter((event) => event.type !== "session.tool.progress"), turn: (inputID) => failedTool(inputID).filter((event) => event.type !== "session.tool.progress"),
renderTool: (part) => { renderTool: (part) => {
rendered.push(part) rendered.push(part)
return Promise.resolve() return Promise.resolve()
+2 -2
View File
@@ -30,7 +30,7 @@ import { Reference } from "@opencode-ai/schema/reference"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema" import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionInbox } from "@opencode-ai/schema/session-inbox" import { SessionPending } from "@opencode-ai/schema/session-pending"
import { Shell } from "@opencode-ai/schema/shell" import { Shell } from "@opencode-ai/schema/shell"
import { Skill } from "@opencode-ai/schema/skill" import { Skill } from "@opencode-ai/schema/skill"
import { Vcs } from "@opencode-ai/schema/vcs" import { Vcs } from "@opencode-ai/schema/vcs"
@@ -69,7 +69,7 @@ const effectTypeReferences = [
...namespaceTypes("Reference", "@opencode-ai/schema/reference", Reference), ...namespaceTypes("Reference", "@opencode-ai/schema/reference", Reference),
...namespaceTypes("Session", "@opencode-ai/schema/session", Session), ...namespaceTypes("Session", "@opencode-ai/schema/session", Session),
...namespaceTypes("SessionMessage", "@opencode-ai/schema/session-message", SessionMessage), ...namespaceTypes("SessionMessage", "@opencode-ai/schema/session-message", SessionMessage),
...namespaceTypes("SessionInbox", "@opencode-ai/schema/session-inbox", SessionInbox), ...namespaceTypes("SessionPending", "@opencode-ai/schema/session-pending", SessionPending),
...namespaceTypes("Shell", "@opencode-ai/schema/shell", Shell), ...namespaceTypes("Shell", "@opencode-ai/schema/shell", Shell),
...namespaceTypes("Skill", "@opencode-ai/schema/skill", Skill), ...namespaceTypes("Skill", "@opencode-ai/schema/skill", Skill),
...namespaceTypes("Vcs", "@opencode-ai/schema/vcs", Vcs), ...namespaceTypes("Vcs", "@opencode-ai/schema/vcs", Vcs),
+52 -41
View File
@@ -11,9 +11,9 @@ import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Brand } from "effect" import type { Brand } from "effect"
import type { Model } from "@opencode-ai/schema/model" import type { Model } from "@opencode-ai/schema/model"
import type { SessionMessage } from "@opencode-ai/schema/session-message" import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { PromptInput } from "@opencode-ai/schema/prompt-input" import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt" import type { AgentAttachment } from "@opencode-ai/schema/prompt"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import type { Skill } from "@opencode-ai/schema/skill" import type { Skill } from "@opencode-ai/schema/skill"
import type { Event } from "@opencode-ai/schema/event" import type { Event } from "@opencode-ai/schema/event"
import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry" import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
@@ -170,7 +170,6 @@ export type Endpoint5_11Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly directory: AbsolutePath readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined readonly workspaceID?: Workspace.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
} }
export type Endpoint5_11Output = void export type Endpoint5_11Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E> export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
@@ -183,10 +182,10 @@ export type Endpoint5_12Input = {
readonly agents?: ReadonlyArray<AgentAttachment> | undefined readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly delivery?: SessionInbox.Delivery | undefined readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined readonly resume?: boolean | undefined
} }
export type Endpoint5_12Output = SessionInbox.User export type Endpoint5_12Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E> export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_13Input = { export type Endpoint5_13Input = {
@@ -199,10 +198,10 @@ export type Endpoint5_13Input = {
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
readonly agents?: ReadonlyArray<AgentAttachment> | undefined readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly delivery?: SessionInbox.Delivery | undefined readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined readonly resume?: boolean | undefined
} }
export type Endpoint5_13Output = SessionInbox.User export type Endpoint5_13Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E> export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_14Input = { export type Endpoint5_14Input = {
@@ -220,10 +219,10 @@ export type Endpoint5_15Input = {
readonly text: string readonly text: string
readonly description?: string | undefined readonly description?: string | undefined
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly delivery?: SessionInbox.Delivery | undefined readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined readonly resume?: boolean | undefined
} }
export type Endpoint5_15Output = SessionInbox.Synthetic export type Endpoint5_15Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E> export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Input = { export type Endpoint5_16Input = {
@@ -234,12 +233,8 @@ export type Endpoint5_16Input = {
export type Endpoint5_16Output = void export type Endpoint5_16Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E> export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Input = { export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
readonly sessionID: Session.ID export type Endpoint5_17Output = SessionPending.Compaction
readonly id?: SessionMessage.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_17Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E> export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID } export type Endpoint5_18Input = { readonly sessionID: Session.ID }
@@ -267,20 +262,22 @@ export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E> export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID } export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info> export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E> export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_24Output = void export type Endpoint5_24Output = void
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E> export type SessionPendingCancelOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_25Output = void export type Endpoint5_25Output = void
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E> export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_26Output = void export type Endpoint5_26Output = void
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E> export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID } export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info> export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
@@ -371,7 +368,7 @@ export type Endpoint5_31Output =
readonly data: { readonly data: {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly location: Location.Ref readonly location: Location.Ref
readonly projectID: Project.ID readonly projectID?: Project.ID | undefined
readonly subpath?: RelativePath | undefined readonly subpath?: RelativePath | undefined
} }
} }
@@ -413,45 +410,50 @@ export type Endpoint5_31Output =
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivered" readonly type: "session.input.promoted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.enqueued" readonly type: "session.input.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
readonly data: { readonly data: {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly inboxID: SessionMessage.ID readonly inputID: SessionMessage.ID
readonly item: SessionInbox.Item readonly input: SessionPending.Message
} }
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.cancelled" readonly type: "session.input.cancelled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivery.changed" readonly type: "session.input.steered"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
readonly data: { readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
readonly sessionID: Session.ID }
readonly inboxID: SessionMessage.ID | {
readonly delivery: SessionInbox.Delivery readonly id: Event.ID
} readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.queued"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
@@ -812,6 +814,15 @@ export type Endpoint5_31Output =
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined } readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
} }
} }
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: DateTime.Utc
@@ -942,11 +953,11 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E> readonly commit: SessionRevertCommitOperation<E>
} }
readonly context: SessionContextOperation<E> readonly context: SessionContextOperation<E>
readonly inbox: { readonly pending: {
readonly list: SessionInboxListOperation<E> readonly list: SessionPendingListOperation<E>
readonly cancel: SessionInboxCancelOperation<E> readonly cancel: SessionPendingCancelOperation<E>
readonly steer: SessionInboxSteerOperation<E> readonly steer: SessionPendingSteerOperation<E>
readonly queue: SessionInboxQueueOperation<E> readonly queue: SessionPendingQueueOperation<E>
} }
readonly instructions: { readonly instructions: {
readonly entry: { readonly entry: {
+7 -10
View File
@@ -399,7 +399,7 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
preserveEffect<Endpoint5_11Output>()( preserveEffect<Endpoint5_11Output>()(
raw["session.move"]({ raw["session.move"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] }, payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
@@ -481,10 +481,7 @@ const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16I
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) => const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()( preserveEffect<Endpoint5_17Output>()(
raw["session.compact"]({ raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], delivery: input["delivery"] },
}).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
@@ -526,7 +523,7 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) => const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()( preserveEffect<Endpoint5_23Output>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
@@ -534,21 +531,21 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) => const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()( preserveEffect<Endpoint5_24Output>()(
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
), ),
) )
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) => const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()( preserveEffect<Endpoint5_25Output>()(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
), ),
) )
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()( preserveEffect<Endpoint5_26Output>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
), ),
) )
@@ -640,7 +637,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_18(raw), wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) }, revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw), context: Endpoint5_22(raw),
inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) }, pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } }, instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
generate: Endpoint5_30(raw), generate: Endpoint5_30(raw),
log: Endpoint5_31(raw), log: Endpoint5_31(raw),
+1 -1
View File
@@ -41,7 +41,7 @@ export { Reference } from "@opencode-ai/schema/reference"
export { WebSearch } from "@opencode-ai/schema/websearch" export { WebSearch } from "@opencode-ai/schema/websearch"
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 { SessionInbox } from "@opencode-ai/schema/session-inbox" 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"
+3 -4
View File
@@ -10,7 +10,6 @@ import {
spawnServiceContender, spawnServiceContender,
} from "../service-contender.js" } from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js" import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
export * from "../service.js" export * from "../service.js"
/** Contents of the local service registration file. */ /** Contents of the local service registration file. */
@@ -38,14 +37,14 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
const info = yield* read(options.file) const info = yield* read(options.file)
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url }) const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
if (found === undefined || found.legacy) return undefined if (found === undefined || found.legacy) return undefined
if (!matchesVersion(found.version, options)) return undefined if (options.version !== undefined && found.version !== options.version) return undefined
return { endpoint: found.endpoint, state: found.state } return { endpoint: found.endpoint, state: found.state }
}) })
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) { const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
const found = (yield* registered(options.file)).service const found = (yield* registered(options.file)).service
if (found?.state !== "ready") return undefined if (found?.state !== "ready") return undefined
if (!matchesVersion(found.version, options)) return undefined if (options.version !== undefined && found.version !== options.version) return undefined
return found return found
}) })
@@ -94,7 +93,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
} else timeouts = undefined } else timeouts = undefined
if (service !== undefined) { if (service !== undefined) {
spawnDelay = timing.spawnDelay spawnDelay = timing.spawnDelay
const compatible = !service.legacy && matchesVersion(service.version, options) const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.state === "ready") return Option.some(service) if (compatible && service.state === "ready") return Option.some(service)
if (compatible && service.state === "failed") if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start")) return yield* Effect.fail(new Error("Background service failed to start"))
+23 -23
View File
@@ -56,14 +56,14 @@ import type {
SessionRevertCommitOutput, SessionRevertCommitOutput,
SessionContextInput, SessionContextInput,
SessionContextOutput, SessionContextOutput,
SessionInboxListInput, SessionPendingListInput,
SessionInboxListOutput, SessionPendingListOutput,
SessionInboxCancelInput, SessionPendingCancelInput,
SessionInboxCancelOutput, SessionPendingCancelOutput,
SessionInboxSteerInput, SessionPendingSteerInput,
SessionInboxSteerOutput, SessionPendingSteerOutput,
SessionInboxQueueInput, SessionPendingQueueInput,
SessionInboxQueueOutput, SessionPendingQueueOutput,
SessionInstructionsEntryListInput, SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput, SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput, SessionInstructionsEntryPutInput,
@@ -598,7 +598,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/move`, path: `/api/session/${encodeURIComponent(input.sessionID)}/move`,
body: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] }, body: { directory: input["directory"], workspaceID: input["workspaceID"] },
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
@@ -697,7 +697,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
body: { id: input["id"], delivery: input["delivery"] }, body: { id: input["id"] },
successStatus: 200, successStatus: 200,
declaredStatuses: [409, 404, 400, 401], declaredStatuses: [409, 404, 400, 401],
empty: false, empty: false,
@@ -762,45 +762,45 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
inbox: { pending: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) => list: (input: SessionPendingListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>( request<{ readonly data: SessionPendingListOutput }>(
{ {
method: "GET", method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox`, path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 401, 400], declaredStatuses: [404, 401, 400],
empty: false, empty: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
cancel: (input: SessionInboxCancelInput, requestOptions?: RequestOptions) => cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
request<SessionInboxCancelOutput>( request<SessionPendingCancelOutput>(
{ {
method: "DELETE", method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`, path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
successStatus: 204, successStatus: 204,
declaredStatuses: [409, 404, 401, 400], declaredStatuses: [409, 404, 401, 400],
empty: true, empty: true,
}, },
requestOptions, requestOptions,
), ),
steer: (input: SessionInboxSteerInput, requestOptions?: RequestOptions) => steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
request<SessionInboxSteerOutput>( request<SessionPendingSteerOutput>(
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/steer`, path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
successStatus: 204, successStatus: 204,
declaredStatuses: [409, 404, 401, 400], declaredStatuses: [409, 404, 401, 400],
empty: true, empty: true,
}, },
requestOptions, requestOptions,
), ),
queue: (input: SessionInboxQueueInput, requestOptions?: RequestOptions) => queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
request<SessionInboxQueueOutput>( request<SessionPendingQueueOutput>(
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/queue`, path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
successStatus: 204, successStatus: 204,
declaredStatuses: [409, 404, 401, 400], declaredStatuses: [409, 404, 401, 400],
empty: true, empty: true,
+162 -184
View File
@@ -130,17 +130,15 @@ export type SessionMessageCompactionCompleted = {
export type SessionActive = { type: "running" } export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue" export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } } export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
export type SessionInboxCompactionPayload = {}
export type InstructionEntryKey = string export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } } export type SessionGenerateResponse = { data: { text: string } }
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } } export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type ShellInfo = { export type ShellInfo = {
id: string id: string
@@ -422,8 +420,6 @@ export type SessionMessageLocationSwitched = {
previous?: { location: LocationRef; projectID?: string; subpath?: string } previous?: { location: LocationRef; projectID?: string; subpath?: string }
} }
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
export type SessionCreated = { export type SessionCreated = {
id: string id: string
created: number created: number
@@ -472,7 +468,7 @@ export type SessionMoved = {
type: "session.moved" type: "session.moved"
durable: { aggregateID: string; seq: number; version: 1 } durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef location?: LocationRef
data: { sessionID: string; location: LocationRef; projectID: string; subpath?: string } data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string }
} }
export type SessionRenamed = { export type SessionRenamed = {
@@ -505,24 +501,44 @@ export type SessionForked = {
data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } } data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } }
} }
export type SessionInboxDelivered = { export type SessionInputPromoted = {
id: string id: string
created: number created: number
metadata?: { [x: string]: any } metadata?: { [x: string]: any }
type: "session.inbox.delivered" type: "session.input.promoted"
durable: { aggregateID: string; seq: number; version: 1 } durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef location?: LocationRef
data: { sessionID: string; inboxID: string } data: { sessionID: string; inputID: string }
} }
export type SessionInboxCancelled = { export type SessionInputCancelled = {
id: string id: string
created: number created: number
metadata?: { [x: string]: any } metadata?: { [x: string]: any }
type: "session.inbox.cancelled" type: "session.input.cancelled"
durable: { aggregateID: string; seq: number; version: 1 } durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef location?: LocationRef
data: { sessionID: string; inboxID: string } data: { sessionID: string; inputID: string }
}
export type SessionInputSteered = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.steered"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputQueued = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.queued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
} }
export type SessionExecutionStarted = { export type SessionExecutionStarted = {
@@ -643,6 +659,16 @@ export type SessionToolInputEnded = {
data: { sessionID: string; assistantMessageID: string; id: string; text: string } data: { sessionID: string; assistantMessageID: string; id: string; text: string }
} }
export type SessionCompactionAdmitted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.admitted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionCompactionStarted = { export type SessionCompactionStarted = {
id: string id: string
created: number created: number
@@ -837,16 +863,6 @@ export type ProjectDirectoriesUpdated = {
data: { projectID: string } data: { projectID: string }
} }
export type ProjectDirectoryResolved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.directory.resolved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { projectID: string; directory: string; previous: string }
}
export type CommandUpdated = { export type CommandUpdated = {
id: string id: string
created: number created: number
@@ -1146,36 +1162,23 @@ export type SessionCompactionFailed = {
data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string } data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string }
} }
export type SessionInboxDeliveryChanged = { export type SessionPendingSynthetic = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.inbox.delivery.changed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inboxID: string; delivery: SessionInboxDelivery }
}
export type SessionInboxSynthetic = {
id: string id: string
sessionID: string sessionID: string
timeCreated: number timeCreated: number
type: "synthetic" type: "synthetic"
payload: SessionInboxSyntheticPayload data: SessionPendingSyntheticData
delivery: SessionInboxDelivery delivery: "steer" | "queue"
}
export type SessionInboxCompaction = {
id: string
sessionID: string
timeCreated: number
type: "compaction"
payload: SessionInboxCompactionPayload
delivery: SessionInboxDelivery
} }
export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue } export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue }
export type SessionPendingSyntheticMessage = {
type: "synthetic"
data: SessionPendingSyntheticData1
delivery: "steer" | "queue"
}
export type SessionShellStarted = { export type SessionShellStarted = {
id: string id: string
created: number created: number
@@ -1524,15 +1527,6 @@ export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule> export type PermissionRuleset = Array<PermissionRule>
export type SessionInboxMove = {
id: string
sessionID: string
timeCreated: number
type: "move"
payload: SessionInboxMovePayload
delivery: SessionInboxDelivery
}
export type SessionInfo = { export type SessionInfo = {
id: string id: string
parentID?: string parentID?: string
@@ -1570,7 +1564,7 @@ export type SessionMessageUser = {
type: "user" type: "user"
} }
export type SessionInboxUserPayload = { export type SessionPendingUserData = {
text: string text: string
files?: Array<PromptFileAttachment> files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment> agents?: Array<PromptAgentAttachment>
@@ -1578,7 +1572,7 @@ export type SessionInboxUserPayload = {
metadata?: { [x: string]: JsonValue } metadata?: { [x: string]: JsonValue }
} }
export type SessionInboxUserPayload1 = { export type SessionPendingUserData1 = {
text: string text: string
files?: Array<PromptFileAttachment> files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment> agents?: Array<PromptAgentAttachment>
@@ -1890,20 +1884,16 @@ export type ConfigEntry =
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } } export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxUser = { export type SessionPendingUser = {
id: string id: string
sessionID: string sessionID: string
timeCreated: number timeCreated: number
type: "user" type: "user"
payload: SessionInboxUserPayload data: SessionPendingUserData
delivery: SessionInboxDelivery delivery: "steer" | "queue"
} }
export type SessionInboxItem = export type SessionPendingUserMessage = { type: "user"; data: SessionPendingUserData1; delivery: "steer" | "queue" }
| { type: "user"; payload: SessionInboxUserPayload1; delivery: SessionInboxDelivery }
| { type: "synthetic"; payload: SessionInboxSyntheticPayload1; delivery: SessionInboxDelivery }
| { type: "compaction"; payload: SessionInboxCompactionPayload; delivery: SessionInboxDelivery }
| { type: "move"; payload: SessionInboxMovePayload; delivery: SessionInboxDelivery }
export type SessionMessageAssistantTool = { export type SessionMessageAssistantTool = {
type: "tool" type: "tool"
@@ -1924,17 +1914,9 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields3 = [FormField1, ...Array<FormField1>] export type FormFields3 = [FormField1, ...Array<FormField1>]
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
export type SessionInboxEnqueued = { export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSyntheticMessage
id: string
created: number
metadata?: { [x: string]: any }
type: "session.inbox.enqueued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inboxID: string; item: SessionInboxItem }
}
export type SessionMessageAssistant = { export type SessionMessageAssistant = {
id: string id: string
@@ -1960,47 +1942,15 @@ export type FormInfo = { id: string; sessionID: string; title: string; metadata?
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields3 } export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields3 }
export type SessionEventDurable = export type SessionInputAdmitted = {
| SessionCreated id: string
| SessionAgentSelected created: number
| SessionModelSelected metadata?: { [x: string]: any }
| SessionMoved type: "session.input.admitted"
| SessionRenamed durable: { aggregateID: string; seq: number; version: 1 }
| SessionDeleted location?: LocationRef
| SessionForked data: { sessionID: string; inputID: string; input: SessionPendingMessage }
| SessionInboxDelivered }
| SessionInboxEnqueued
| SessionInboxCancelled
| SessionInboxDeliveryChanged
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
| SessionExecutionInterrupted
| SessionInstructionsUpdated
| SessionSynthetic
| SessionSkillActivated
| SessionShellStarted
| SessionShellEnded
| SessionStepStarted
| SessionStepEnded
| SessionStepFailed
| SessionTextStarted
| SessionTextEnded
| SessionReasoningStarted
| SessionReasoningEnded
| SessionToolInputStarted
| SessionToolInputEnded
| SessionToolCalled
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
| SessionCompactionStarted
| SessionCompactionEnded
| SessionCompactionFailed
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionUsageRecorded
export type SessionMessageInfo = export type SessionMessageInfo =
| SessionMessageAgentSelected | SessionMessageAgentSelected
@@ -2029,7 +1979,49 @@ export type FormCreated = {
data: { form: FormInfo1 } data: { form: FormInfo1 }
} }
export type SessionLogItem = SessionEventDurable | EventLogSynced export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionDeleted
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
| SessionExecutionInterrupted
| SessionInstructionsUpdated
| SessionSynthetic
| SessionSkillActivated
| SessionShellStarted
| SessionShellEnded
| SessionStepStarted
| SessionStepEnded
| SessionStepFailed
| SessionTextStarted
| SessionTextEnded
| SessionReasoningStarted
| SessionReasoningEnded
| SessionToolInputStarted
| SessionToolInputEnded
| SessionToolCalled
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
| SessionCompactionAdmitted
| SessionCompactionStarted
| SessionCompactionEnded
| SessionCompactionFailed
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionUsageRecorded
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> } export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
@@ -2059,10 +2051,11 @@ export type V2Event =
| SessionUsageUpdated | SessionUsageUpdated
| SessionDeleted | SessionDeleted
| SessionForked | SessionForked
| SessionInboxDelivered | SessionInputPromoted
| SessionInboxEnqueued | SessionInputAdmitted
| SessionInboxCancelled | SessionInputCancelled
| SessionInboxDeliveryChanged | SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted | SessionExecutionStarted
| SessionExecutionSucceeded | SessionExecutionSucceeded
| SessionExecutionFailed | SessionExecutionFailed
@@ -2089,6 +2082,7 @@ export type V2Event =
| SessionToolSuccess | SessionToolSuccess
| SessionToolFailed | SessionToolFailed
| SessionRetryScheduled | SessionRetryScheduled
| SessionCompactionAdmitted
| SessionCompactionStarted | SessionCompactionStarted
| SessionCompactionDelta | SessionCompactionDelta
| SessionCompactionEnded | SessionCompactionEnded
@@ -2103,7 +2097,6 @@ export type V2Event =
| PluginAdded | PluginAdded
| PluginUpdated | PluginUpdated
| ProjectDirectoriesUpdated | ProjectDirectoriesUpdated
| ProjectDirectoryResolved
| CommandUpdated | CommandUpdated
| ConfigUpdated | ConfigUpdated
| SkillUpdated | SkillUpdated
@@ -2134,6 +2127,8 @@ export type V2Event =
| McpResourcesChanged | McpResourcesChanged
| V2EventServerConnected | V2EventServerConnected
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string } export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError => export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
@@ -3366,21 +3361,8 @@ export type SessionRenameOutput = void
export type SessionMoveInput = { export type SessionMoveInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly directory: { readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
readonly directory: string readonly workspaceID?: { readonly directory: string; readonly workspaceID?: string }["workspaceID"]
readonly workspaceID?: string
readonly delivery?: ("steer" | "queue") | null
}["directory"]
readonly workspaceID?: {
readonly directory: string
readonly workspaceID?: string
readonly delivery?: ("steer" | "queue") | null
}["workspaceID"]
readonly delivery?: {
readonly directory: string
readonly workspaceID?: string
readonly delivery?: ("steer" | "queue") | null
}["delivery"]
} }
export type SessionMoveOutput = void export type SessionMoveOutput = void
@@ -3405,7 +3387,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["id"] }["id"]
readonly text: { readonly text: {
@@ -3426,7 +3408,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["text"] }["text"]
readonly files?: { readonly files?: {
@@ -3447,7 +3429,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["files"] }["files"]
readonly agents?: { readonly agents?: {
@@ -3468,7 +3450,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["agents"] }["agents"]
readonly skills?: { readonly skills?: {
@@ -3489,7 +3471,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["skills"] }["skills"]
readonly metadata?: { readonly metadata?: {
@@ -3510,7 +3492,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["metadata"] }["metadata"]
readonly delivery?: { readonly delivery?: {
@@ -3531,7 +3513,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["delivery"] }["delivery"]
readonly resume?: { readonly resume?: {
@@ -3552,12 +3534,12 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["resume"] }["resume"]
} }
export type SessionPromptOutput = { data: SessionInboxUser }["data"] export type SessionPromptOutput = { data: SessionPendingUser }["data"]
export type SessionCommandInput = { export type SessionCommandInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3581,7 +3563,7 @@ export type SessionCommandInput = {
readonly id: string readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["id"] }["id"]
readonly command: { readonly command: {
@@ -3604,7 +3586,7 @@ export type SessionCommandInput = {
readonly id: string readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["command"] }["command"]
readonly arguments?: { readonly arguments?: {
@@ -3627,7 +3609,7 @@ export type SessionCommandInput = {
readonly id: string readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["arguments"] }["arguments"]
readonly agent?: { readonly agent?: {
@@ -3650,7 +3632,7 @@ export type SessionCommandInput = {
readonly id: string readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["agent"] }["agent"]
readonly model?: { readonly model?: {
@@ -3673,7 +3655,7 @@ export type SessionCommandInput = {
readonly id: string readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["model"] }["model"]
readonly files?: { readonly files?: {
@@ -3696,7 +3678,7 @@ export type SessionCommandInput = {
readonly id: string readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["files"] }["files"]
readonly agents?: { readonly agents?: {
@@ -3719,7 +3701,7 @@ export type SessionCommandInput = {
readonly id: string readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["agents"] }["agents"]
readonly skills?: { readonly skills?: {
@@ -3742,7 +3724,7 @@ export type SessionCommandInput = {
readonly id: string readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["skills"] }["skills"]
readonly delivery?: { readonly delivery?: {
@@ -3765,7 +3747,7 @@ export type SessionCommandInput = {
readonly id: string readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["delivery"] }["delivery"]
readonly resume?: { readonly resume?: {
@@ -3788,12 +3770,12 @@ export type SessionCommandInput = {
readonly id: string readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["resume"] }["resume"]
} }
export type SessionCommandOutput = { data: SessionInboxUser }["data"] export type SessionCommandOutput = { data: SessionPendingUser }["data"]
export type SessionSkillInput = { export type SessionSkillInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3823,7 +3805,7 @@ export type SessionSyntheticInput = {
readonly text: string readonly text: string
readonly description?: string | null readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["id"] }["id"]
readonly text: { readonly text: {
@@ -3831,7 +3813,7 @@ export type SessionSyntheticInput = {
readonly text: string readonly text: string
readonly description?: string | null readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["text"] }["text"]
readonly description?: { readonly description?: {
@@ -3839,7 +3821,7 @@ export type SessionSyntheticInput = {
readonly text: string readonly text: string
readonly description?: string | null readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["description"] }["description"]
readonly metadata?: { readonly metadata?: {
@@ -3847,7 +3829,7 @@ export type SessionSyntheticInput = {
readonly text: string readonly text: string
readonly description?: string | null readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["metadata"] }["metadata"]
readonly delivery?: { readonly delivery?: {
@@ -3855,7 +3837,7 @@ export type SessionSyntheticInput = {
readonly text: string readonly text: string
readonly description?: string | null readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["delivery"] }["delivery"]
readonly resume?: { readonly resume?: {
@@ -3863,12 +3845,12 @@ export type SessionSyntheticInput = {
readonly text: string readonly text: string
readonly description?: string | null readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null readonly resume?: boolean | null
}["resume"] }["resume"]
} }
export type SessionSyntheticOutput = { data: SessionInboxSynthetic }["data"] export type SessionSyntheticOutput = { data: SessionPendingSynthetic }["data"]
export type SessionShellInput = { export type SessionShellInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3880,14 +3862,10 @@ export type SessionShellOutput = void
export type SessionCompactInput = { export type SessionCompactInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: { readonly id?: string | undefined; readonly delivery?: ("steer" | "queue") | undefined }["id"] readonly id?: { readonly id?: string | undefined }["id"]
readonly delivery?: {
readonly id?: string | undefined
readonly delivery?: ("steer" | "queue") | undefined
}["delivery"]
} }
export type SessionCompactOutput = { data: SessionInboxCompaction }["data"] export type SessionCompactOutput = { data: SessionPendingCompaction }["data"]
export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -3913,30 +3891,30 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"] export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionPendingListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"] export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
export type SessionInboxCancelInput = { export type SessionPendingCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"] readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"] readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
} }
export type SessionInboxCancelOutput = void export type SessionPendingCancelOutput = void
export type SessionInboxSteerInput = { export type SessionPendingSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"] readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"] readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
} }
export type SessionInboxSteerOutput = void export type SessionPendingSteerOutput = void
export type SessionInboxQueueInput = { export type SessionPendingQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"] readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"] readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
} }
export type SessionInboxQueueOutput = void export type SessionPendingQueueOutput = void
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
+2 -3
View File
@@ -9,7 +9,6 @@ import {
spawnServiceContender, spawnServiceContender,
} from "../service-contender.js" } from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js" import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js" import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js" export * from "../service.js"
@@ -28,7 +27,7 @@ export async function discover(options: DiscoverOptions = {}) {
async function discoverLocal(options: DiscoverOptions) { async function discoverLocal(options: DiscoverOptions) {
const found = (await registered(options.file)).service const found = (await registered(options.file)).service
if (found?.state !== "ready") return undefined if (found?.state !== "ready") return undefined
if (!matchesVersion(found.version, options)) return undefined if (options.version !== undefined && found.version !== options.version) return undefined
return found return found
} }
@@ -77,7 +76,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (registration.service !== undefined) { if (registration.service !== undefined) {
spawnDelay = timing.spawnDelay spawnDelay = timing.spawnDelay
const service = registration.service const service = registration.service
const compatible = !service.legacy && matchesVersion(service.version, options) const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.state === "ready") return service.endpoint if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "failed") throw new Error("Background service failed to start") if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) { if (!compatible) {
-8
View File
@@ -1,8 +0,0 @@
import type { DiscoverOptions } from "./service.js"
export function matchesVersion(version: string | undefined, options: DiscoverOptions) {
if (options.version === undefined) return true
if (version === undefined) return false
if (typeof options.version === "function") return options.version(version)
return version === options.version
}
+2 -2
View File
@@ -17,8 +17,8 @@ export type Endpoint = {
export type DiscoverOptions = { export type DiscoverOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */ /** Absolute registration file path. Defaults to the XDG state directory. */
readonly file?: string readonly file?: string
/** Required exact service version or compatibility predicate. */ /** Required service version. */
readonly version?: string | ((version: string) => boolean) readonly version?: string
} }
/** Reason ensuring the service requires a new process. */ /** Reason ensuring the service requires a new process. */
+1 -15
View File
@@ -1,7 +1,6 @@
import { Effect } from "effect" import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect" import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
import type { Session } from "@opencode-ai/schema/session" import type { Session } from "@opencode-ai/schema/session"
import type { DiscoverOptions } from "../src/service"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>> type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make> type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
@@ -9,9 +8,6 @@ type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
declare const effectClient: EffectClient declare const effectClient: EffectClient
declare const promiseClient: PromiseClient declare const promiseClient: PromiseClient
const exactVersion: DiscoverOptions = { version: "2.0.0" }
const compatibleVersion: DiscoverOptions = { version: (version) => version.startsWith("2.") }
const effectApi: EffectApi<unknown> = effectClient const effectApi: EffectApi<unknown> = effectClient
const effectSession: Effect.Effect<Session.Info, unknown> = effectClient.session.get({ const effectSession: Effect.Effect<Session.Info, unknown> = effectClient.session.get({
@@ -46,14 +42,4 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
key: "review-notes", key: "review-notes",
}) })
void [ void [effectSession, effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
effectSession,
effectList,
effectPut,
effectRemove,
promiseList,
promisePut,
promiseRemove,
exactVersion,
compatibleVersion,
]
+2 -4
View File
@@ -213,7 +213,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test") expect(result.created.id).toBe("ses_test")
expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.admitted.payload)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.admitted.data)).toBe(Object.prototype)
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([]) expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" }) expect(logQueries[0]).toEqual({ after: "0" })
@@ -271,7 +271,7 @@ const admission = {
id: "msg_test", id: "msg_test",
sessionID: "ses_test", sessionID: "ses_test",
type: "user", type: "user",
payload: { text: "Hello" }, data: { text: "Hello" },
delivery: "steer", delivery: "steer",
timeCreated: 1_717_171_717_000, timeCreated: 1_717_171_717_000,
}, },
@@ -280,8 +280,6 @@ const admission = {
const compactionAdmission = { const compactionAdmission = {
data: { data: {
type: "compaction", type: "compaction",
payload: {},
delivery: "queue",
id: "msg_compaction", id: "msg_compaction",
sessionID: "ses_test", sessionID: "ses_test",
timeCreated: 1_717_171_717_000, timeCreated: 1_717_171_717_000,
+1 -4
View File
@@ -27,10 +27,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
} }
let requests = 0 let requests = 0
let version = "test" const version = mode === "old" || mode === "reject-stop" ? "old" : "test"
if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID() const id = crypto.randomUUID()
const server = Bun.serve({ const server = Bun.serve({
port: 0, port: 0,
@@ -25,19 +25,6 @@ test("discovers a registered service", async () => {
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined() expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
}) })
test("discovers a compatible registered service", async () => {
const registration = await setup("compatible")
expect(await Service.discover({ file: registration, version: "2.1.0" })).toBeUndefined()
expect(await Service.discover({ file: registration, version: "2.1.0-next.1" })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("2.") })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined()
})
test("ensures a missing service with native promises", async () => { test("ensures a missing service with native promises", async () => {
const directory = await temp() const directory = await temp()
const registration = join(directory, "service.json") const registration = join(directory, "service.json")
+11 -11
View File
@@ -373,7 +373,7 @@ test("session instructions methods use the public HTTP contract", async () => {
]) ])
}) })
test("session.inbox.list uses the public HTTP contract", async () => { test("session.pending.list uses the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = [] const requests: Array<{ method: string; url: string }> = []
const pending = [ const pending = [
{ {
@@ -381,7 +381,7 @@ test("session.inbox.list uses the public HTTP contract", async () => {
sessionID: "ses_test", sessionID: "ses_test",
timeCreated: 1_717_171_717_000, timeCreated: 1_717_171_717_000,
type: "user", type: "user",
payload: { text: "Fix the failing tests" }, data: { text: "Fix the failing tests" },
delivery: "steer", delivery: "steer",
}, },
] ]
@@ -394,13 +394,13 @@ test("session.inbox.list uses the public HTTP contract", async () => {
}, },
}) })
const result = await client.session.inbox.list({ sessionID: "ses_test" }) const result = await client.session.pending.list({ sessionID: "ses_test" })
expect(result).toEqual(pending) expect(result).toEqual(pending)
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/inbox" }]) expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
}) })
test("session.inbox mutations use the public HTTP contract", async () => { test("session.pending mutations use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = [] const requests: Array<{ method: string; url: string }> = []
const client = OpenCode.make({ const client = OpenCode.make({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
@@ -411,14 +411,14 @@ test("session.inbox mutations use the public HTTP contract", async () => {
}, },
}) })
await client.session.inbox.cancel({ sessionID: "ses_test", inboxID: "msg_cancel" }) await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
await client.session.inbox.steer({ sessionID: "ses_test", inboxID: "msg_steer" }) await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
await client.session.inbox.queue({ sessionID: "ses_test", inboxID: "msg_queue" }) await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
expect(requests).toEqual([ expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/inbox/msg_cancel" }, { method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer/steer" }, { method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue/queue" }, { method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
]) ])
}) })
-46
View File
@@ -47,52 +47,6 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid }) expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
}) })
test("reuses a compatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "compatible")
await waitForFile(registration)
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => version.startsWith("2."),
command: [],
onStart: (reason) => starts.push(reason),
}),
)
expect(endpoint.url).toBe((await Bun.file(registration).json()).url)
expect(starts).toEqual([])
expect(existing.exitCode).toBe(null)
})
test("replaces an incompatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "incompatible")
await waitForFile(registration)
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => version.startsWith("2."),
command: [process.execPath, fixture, registration, "delayed-compatible", "10"],
onStart: (reason) => starts.push(reason),
}),
)
const replacement = await Bun.file(registration).json()
expect(await existing.exited).toBe(0)
expect(replacement.version).toBe("2.1.0-next.1")
expect(endpoint.url).toBe(replacement.url)
expect(starts).toEqual(["version-mismatch"])
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
})
test("waits for a registered service to finish starting", async () => { test("waits for a registered service to finish starting", async () => {
const directory = await temp() const directory = await temp()
const registration = join(directory, "service.json") const registration = join(directory, "service.json")
-6
View File
@@ -19,12 +19,6 @@
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },
"imports": {
"#transpile": {
"workerd": "./src/interpreter/transpile.workerd.ts",
"default": "./src/interpreter/transpile.node.ts"
}
},
"scripts": { "scripts": {
"build": "bun run script/build.ts", "build": "bun run script/build.ts",
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
+15 -6
View File
@@ -1,8 +1,6 @@
import { parse } from "acorn" import { parse } from "acorn"
import { Cause, Effect, Scope } from "effect" import { Cause, Effect, Scope } from "effect"
// #transpile: conditional import — full typescript on node/bun, an identity import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
import { transpile } from "#transpile"
import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js" import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js"
import { copyIn, copyOut, ToolRuntime, type Services } from "../tool-runtime.js" import { copyIn, copyOut, ToolRuntime, type Services } from "../tool-runtime.js"
import type { Tools } from "../tools.js" import type { Tools } from "../tools.js"
@@ -121,10 +119,21 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
} }
const parseProgram = (code: string): ProgramNode => { const parseProgram = (code: string): ProgramNode => {
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`) const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, {
reportDiagnostics: true,
compilerOptions: {
target: ScriptTarget.ESNext,
module: ModuleKind.ESNext,
},
})
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
if (transpiled.error !== undefined) { if (diagnostic) {
throw new InterpreterRuntimeError(`Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError") throw new InterpreterRuntimeError(
`Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
undefined,
"ParseError",
)
} }
const bodyStart = transpiled.outputText.indexOf("{") + 1 const bodyStart = transpiled.outputText.indexOf("{") + 1
@@ -1,25 +0,0 @@
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// Full TypeScript transpilation on node/bun runtimes.
export const transpile = (source: string): TranspileResult => {
const transpiled = transpileModule(source, {
reportDiagnostics: true,
compilerOptions: {
target: ScriptTarget.ESNext,
module: ModuleKind.ESNext,
},
})
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
if (diagnostic) {
return {
outputText: transpiled.outputText,
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
}
}
return { outputText: transpiled.outputText }
}
@@ -1,11 +0,0 @@
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// workerd profile: the typescript compiler is ~11 MiB and probes node
// internals at module init, so codemode programs are passed through
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
// error from the interpreter instead of a transpile diagnostic.
export const transpile = (source: string): TranspileResult => ({ outputText: source })
-3
View File
@@ -1,3 +0,0 @@
# @opencode-ai/core
Core runtime services for OpenCode.
+13 -26
View File
@@ -4,28 +4,24 @@
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"repository": { "private": true,
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/core"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": { "scripts": {
"db": "bun drizzle-kit", "db": "bun drizzle-kit",
"migration": "bun run script/migration.ts", "migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts", "fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts", "benchmark:location": "bun run script/benchmark-location.ts",
"build": "bun run script/build.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts", "update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun test --only-failures", "test": "bun test --only-failures",
"typecheck": "tsgo -b tsconfig.json tsconfig.tests.json" "typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
}, },
"bin": {
"opencode": "./bin/opencode"
},
"exports": { "exports": {
"./environment": "./src/environment/index.ts",
"./testing/environment-conformance": "./test/lib/environment-conformance.ts",
"./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
"imports": { "imports": {
@@ -36,46 +32,32 @@
"default": "./src/database/sqlite.node.ts" "default": "./src/database/sqlite.node.ts"
}, },
"#pty": { "#pty": {
"workerd": "./src/pty/pty.workerd.ts",
"bun": "./src/pty/pty.bun.ts", "bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts", "node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts" "default": "./src/pty/pty.bun.ts"
}, },
"#fff": { "#fff": {
"workerd": "./src/filesystem/fff.workerd.ts",
"bun": "./src/filesystem/fff.bun.ts", "bun": "./src/filesystem/fff.bun.ts",
"node": "./src/filesystem/fff.node.ts", "node": "./src/filesystem/fff.node.ts",
"default": "./src/filesystem/fff.bun.ts" "default": "./src/filesystem/fff.bun.ts"
}, },
"#photon-wasm": { "#photon-wasm": {
"workerd": "./src/image/photon-wasm.workerd.ts",
"bun": "./src/image/photon-wasm.bun.ts", "bun": "./src/image/photon-wasm.bun.ts",
"node": "./src/image/photon-wasm.node.ts", "node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts" "default": "./src/image/photon-wasm.bun.ts"
}, },
"#shell-parser-wasm": { "#shell-parser-wasm": {
"workerd": "./src/shell/parser-wasm.workerd.ts",
"bun": "./src/shell/parser-wasm.bun.ts", "bun": "./src/shell/parser-wasm.bun.ts",
"node": "./src/shell/parser-wasm.node.ts", "node": "./src/shell/parser-wasm.node.ts",
"default": "./src/shell/parser-wasm.bun.ts" "default": "./src/shell/parser-wasm.bun.ts"
}, },
"#process-lock-ffi": { "#process-lock-ffi": {
"workerd": "./src/util/process-lock-ffi.workerd.ts",
"bun": "./src/util/process-lock-ffi.bun.ts", "bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts", "node": "./src/util/process-lock-ffi.node.ts",
"default": "./src/util/process-lock-ffi.bun.ts" "default": "./src/util/process-lock-ffi.bun.ts"
},
"#v1-migration": {
"types": "./src/database/v1-migration.bun.ts",
"bun": "./src/database/v1-migration.bun.ts",
"node": "./src/database/v1-migration.noop.ts",
"workerd": "./src/database/v1-migration.noop.ts",
"default": "./src/database/v1-migration.noop.ts"
} }
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
@@ -111,6 +93,8 @@
"@ai-sdk/togetherai": "2.0.41", "@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39", "@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0", "@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:", "@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@ff-labs/fff-bun": "0.10.1", "@ff-labs/fff-bun": "0.10.1",
@@ -144,5 +128,8 @@
"web-tree-sitter": "0.25.10", "web-tree-sitter": "0.25.10",
"which": "6.0.1", "which": "6.0.1",
"zod": "catalog:" "zod": "catalog:"
},
"overrides": {
"drizzle-orm": "catalog:"
} }
} }
+2 -134
View File
@@ -1,8 +1,8 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "5c1aa56b-c3ee-4283-9a84-c0bf626dc604", "id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
"prevIds": ["00924d88-1842-4d71-ac74-5682ddc47e1c"], "prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
"ddl": [ "ddl": [
{ {
"name": "account_state", "name": "account_state",
@@ -56,10 +56,6 @@
"name": "instruction_state", "name": "instruction_state",
"entityType": "tables" "entityType": "tables"
}, },
{
"name": "session_inbox",
"entityType": "tables"
},
{ {
"name": "session_message", "name": "session_message",
"entityType": "tables" "entityType": "tables"
@@ -846,76 +842,6 @@
"entityType": "columns", "entityType": "columns",
"table": "instruction_state" "table": "instruction_state"
}, },
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "payload",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "delivery",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "enqueued_seq",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_inbox"
},
{ {
"type": "text", "type": "text",
"notNull": false, "notNull": false,
@@ -1502,17 +1428,6 @@
"entityType": "fks", "entityType": "fks",
"table": "instruction_state" "table": "instruction_state"
}, },
{
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_inbox_session_id_session_v2_id_fk",
"entityType": "fks",
"table": "session_inbox"
},
{ {
"columns": ["session_id"], "columns": ["session_id"],
"tableTo": "session_v2", "tableTo": "session_v2",
@@ -1637,13 +1552,6 @@
"table": "instruction_state", "table": "instruction_state",
"entityType": "pks" "entityType": "pks"
}, },
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_inbox_pk",
"table": "session_inbox",
"entityType": "pks"
},
{ {
"columns": ["id"], "columns": ["id"],
"nameExplicit": false, "nameExplicit": false,
@@ -1734,46 +1642,6 @@
"entityType": "indexes", "entityType": "indexes",
"table": "permission" "table": "permission"
}, },
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "delivery",
"isExpression": false
},
{
"value": "enqueued_seq",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_inbox_session_delivery_seq_idx",
"entityType": "indexes",
"table": "session_inbox"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "enqueued_seq",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "session_inbox_session_enqueued_seq_idx",
"entityType": "indexes",
"table": "session_inbox"
},
{ {
"columns": [ "columns": [
{ {
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await rm("dist", { recursive: true, force: true })
await $`bun tsc -p tsconfig.build.json`
const root = path.resolve("src")
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true }))
const result = await Bun.build({
entrypoints: files.filter((file) => !file.endsWith(".d.ts")),
root,
outdir: "dist",
target: "node",
format: "esm",
packages: "external",
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
splitting: true,
loader: {
".txt": "text",
".md": "text",
},
naming: {
entry: "[dir]/[name].[ext]",
chunk: "chunks/[name]-[hash].[ext]",
asset: "assets/[name]-[hash].[ext]",
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build Core")
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
imports: Record<string, Record<string, string>>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
value.replace("./src/", types ? "./dist/types/" : "./dist/").replace(/\.ts$/, types ? ".d.ts" : ".js")
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [key, { import: output(value), types: output(value, true) }]
}),
)
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(
Object.entries(conditions).map(([condition, value]) => [condition, output(value, condition === "types")]),
),
]),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+1 -3
View File
@@ -763,9 +763,7 @@ function apiCallErrorReason(error: APICallError) {
if (error.statusCode !== undefined || !error.isRetryable) return reason if (error.statusCode !== undefined || !error.isRetryable) return reason
return new TransportReason({ return new TransportReason({
message: reason.message, message: reason.message,
transport: "http", kind: error.name,
operation: "request",
code: error.name,
url: error.url, url: error.url,
http: "http" in reason ? reason.http : undefined, http: "http" in reason ? reason.http : undefined,
}) })
+31 -200
View File
@@ -6,8 +6,7 @@ import type { EventLog } from "@opencode-ai/schema/event-log"
import { and, asc, eq, gt, lte, sql } from "drizzle-orm" import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
import { Database } from "./database/database.js" import { Database } from "./database/database.js"
import { EventSequenceTable, EventTable } from "./event/sql.js" import { EventSequenceTable, EventTable } from "./event/sql.js"
import type { Location } from "@opencode-ai/schema/location" import { Location } from "./location.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { isDeepStrictEqual } from "node:util" import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest" import { Durable } from "@opencode-ai/schema/durable-event-manifest"
@@ -93,16 +92,6 @@ export interface PublishOptions {
readonly commit?: (seq: number) => Effect.Effect<void> readonly commit?: (seq: number) => Effect.Effect<void>
} }
export type PublishInput<D extends Event.DurableDefinition = Event.DurableDefinition> = readonly [
definition: D,
data: Event.Data<D>,
options?: PublishOptions,
]
export type PublishResult<I extends readonly PublishInput[]> = {
readonly [K in keyof I]: I[K] extends PublishInput<infer D> ? Event.Payload<D> : never
}
/** Marker/event union emitted by `log`. */ /** Marker/event union emitted by `log`. */
export type LogItem = Event.Payload | EventLog.Synced export type LogItem = Event.Payload | EventLog.Synced
@@ -135,9 +124,6 @@ export interface Interface {
data: Event.Data<D>, data: Event.Data<D>,
options?: PublishOptions, options?: PublishOptions,
) => Effect.Effect<Event.Payload<D>> ) => Effect.Effect<Event.Payload<D>>
readonly publishAll: <const I extends readonly [PublishInput, ...PublishInput[]]>(
events: I,
) => Effect.Effect<PublishResult<I>>
readonly subscribe: Subscribe readonly subscribe: Subscribe
/** /**
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an * Durable, ordered per-aggregate log read. Forked aggregates may reserve an
@@ -183,9 +169,6 @@ export function configured(options?: Options) {
layer: Layer.effect( layer: Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
// Deferred import: a static one would close the module cycle
// bus → location → project → bus and hit the node bindings in TDZ.
const { Location } = yield* Effect.promise(() => import("./location.js"))
const pubsub = { const pubsub = {
live: yield* PubSub.unbounded<Event.Payload>(), live: yield* PubSub.unbounded<Event.Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(), durable: new Map<string, Set<PubSub.PubSub<void>>>(),
@@ -193,7 +176,6 @@ export function configured(options?: Options) {
} }
const projectors = new Map<string, Subscriber[]>() const projectors = new Map<string, Subscriber[]>()
const listeners = new Array<Subscriber>() const listeners = new Array<Subscriber>()
const durableLocks = KeyedMutex.makeUnsafe<string>()
const { db } = yield* Database.Service const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512 const logReadPageSize = options?.logReadPageSize ?? 512
const persist = options?.persist ?? false const persist = options?.persist ?? false
@@ -403,23 +385,15 @@ export function configured(options?: Options) {
}), }),
) )
if (definition?.durable) { if (definition?.durable) {
const aggregateID = (event.data as Record<string, unknown>)[definition.durable.aggregate] const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
if (typeof aggregateID !== "string") if (committed) {
return yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit).pipe( event = {
Effect.as(event), ...event,
) durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
return yield* durableLocks.withLock(aggregateID)( }
Effect.gen(function* () { yield* notify(event as Event.Payload, true)
const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit) return event
if (!committed) return event }
event = {
...event,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
}
yield* notify(event as Event.Payload, true)
return event
}),
)
} }
yield* notify(event as Event.Payload, false) yield* notify(event as Event.Payload, false)
return event return event
@@ -470,144 +444,6 @@ export function configured(options?: Options) {
}) })
} }
function publishAll<const I extends readonly [PublishInput, ...PublishInput[]]>(events: I) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const payloads = yield* Effect.forEach(events, ([definition, data, options]) =>
Effect.gen(function* () {
const aggregateID = (data as Record<string, unknown>)[definition.durable.aggregate]
if (typeof aggregateID !== "string") {
return yield* Effect.die(
new InvalidDurableEventError({
type: definition.type,
message: `Expected string aggregate field ${definition.durable.aggregate}`,
}),
)
}
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return {
definition,
aggregateID,
commit: options?.commit,
event: {
id: options?.id ?? Event.ID.create(),
created: yield* DateTime.now,
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(location ? { location } : {}),
data,
} as Event.Payload,
}
}),
)
const aggregateID = payloads[0].aggregateID
if (payloads.some((item) => item.aggregateID !== aggregateID)) {
return yield* Effect.die(
new InvalidDurableEventError({
type: payloads[0].definition.type,
message: "Published events must belong to the same aggregate",
}),
)
}
return yield* durableLocks.withLock(aggregateID)(
Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const firstSeq = (row?.seq ?? -1) + 1
const finalSeq = firstSeq + payloads.length - 1
const result = new Array<Event.Payload>()
const rows = new Array<typeof EventTable.$inferInsert>()
const ids = new Set<Event.ID>()
for (const [index, item] of payloads.entries()) {
const seq = firstSeq + index
const encoded = Schema.encodeUnknownSync(item.definition.data)(item.event.data) as Record<
string,
unknown
>
if (persist) {
if (ids.has(item.event.id))
yield* Effect.die(
new InvalidDurableEventError({
type: item.event.type,
message: `Event ${item.event.id} appears more than once in the batch`,
}),
)
ids.add(item.event.id)
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, item.event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: item.event.type,
message: `Event ${item.event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const event = {
...item.event,
durable: envelope(aggregateID, seq, item.definition.durable.version),
} as Event.Payload
for (const projector of projectors.get(
versionedType(item.definition.type, item.definition.durable.version),
) ?? []) {
yield* projector(event)
}
if (item.commit) yield* item.commit(seq)
if (persist)
rows.push({
id: event.id,
aggregate_id: aggregateID,
seq,
created: DateTime.toEpochMillis(event.created),
type: versionedType(item.definition.type, item.definition.durable.version),
data: encoded,
})
result.push(event)
}
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq: finalSeq }])
.onConflictDoUpdate({ target: EventSequenceTable.aggregate_id, set: { seq: finalSeq } })
.run()
.pipe(Effect.orDie)
if (persist) yield* db.insert(EventTable).values(rows).run().pipe(Effect.orDie)
return result
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
yield* Effect.forEach(
pubsub.durable.get(aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{
discard: true,
},
)
yield* Effect.forEach(committed, (event) => notify(event, true), { discard: true })
return committed as PublishResult<I>
}),
),
)
})
}
function replay( function replay(
event: SerializedEvent, event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@@ -619,31 +455,27 @@ export function configured(options?: Options) {
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }), new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
) )
} else { } else {
yield* durableLocks.withLock(event.aggregateID)( const payload = {
Effect.gen(function* () { id: event.id,
const payload = { created: event.created ?? DateTime.makeUnsafe(0),
id: event.id, type: definition.type,
created: event.created ?? DateTime.makeUnsafe(0), data: Schema.decodeUnknownSync(definition.data)(event.data),
type: definition.type, } as Event.Payload
data: Schema.decodeUnknownSync(definition.data)(event.data), const committed = yield* commitDurableEvent(definition, payload, {
} as Event.Payload seq: event.seq,
const committed = yield* commitDurableEvent(definition, payload, { aggregateID: event.aggregateID,
seq: event.seq, ownerID: options?.ownerID,
aggregateID: event.aggregateID, strictOwner: options?.strictOwner,
ownerID: options?.ownerID, })
strictOwner: options?.strictOwner, if (committed && options?.publish) {
}) yield* notify(
if (committed && options?.publish) { {
yield* notify( ...payload,
{ durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
...payload, },
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version), true,
}, )
true, }
)
}
}),
)
} }
}) })
} }
@@ -860,7 +692,6 @@ export function configured(options?: Options) {
return Service.of({ return Service.of({
publish, publish,
publishAll,
subscribe, subscribe,
log, log,
listen, listen,
+2 -4
View File
@@ -75,9 +75,7 @@ export const create = (
const outputFileParts = outputFiles(content) const outputFileParts = outputFiles(content)
if (outputFileParts.length > 0) if (outputFileParts.length > 0)
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }]) yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
if (executed.output !== undefined) return executed.output return executed.output
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return text === "" ? null : text
}), }),
{ {
onToolCallStart: ({ index, name, input }) => { onToolCallStart: ({ index, name, input }) => {
@@ -157,7 +155,7 @@ function runtime(
tools[path] = Tool.make({ tools[path] = Tool.make({
description: child.description, description: child.description,
input: child.inputSchema, input: child.inputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String), output: child.outputSchema,
execute: (input) => executeTool(name, registration, input), execute: (input) => executeTool(name, registration, input),
}) })
} }
-9
View File
@@ -65,13 +65,4 @@ export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] }) return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
} }
/** `configured`, but over an injected SqlClient layer instead of a filesystem path. */
export function configuredClient(client: Layer.Layer<SqlClient.SqlClient>) {
return makeGlobalNode({
service: Service,
layer: layerFromClient.pipe(Layer.provide(client)),
deps: [Global.node],
})
}
export const node = configured({ path: ":memory:" }) export const node = configured({ path: ":memory:" })
@@ -28,20 +28,6 @@ export interface EffectSQLiteQueryEffectHKT extends QueryEffectHKTBase {
readonly context: never readonly context: never
} }
/**
* A SqlClient whose runtime rejects SQL transaction statements (BEGIN/COMMIT/SAVEPOINT) and
* manages transactions natively instead Cloudflare Durable Object SQLite. Producers set
* `transactionStatements: false` (see `database/sqlite.workerd.ts`) and the session delegates
* to the client's `withTransaction` rather than issuing transaction statements.
*/
export interface NativeTransactionSqlClient extends SqlClient {
readonly transactionStatements: false
}
export function managesTransactionsNatively(client: SqlClient): client is NativeTransactionSqlClient {
return (client as SqlClient & { readonly transactionStatements?: boolean }).transactionStatements === false
}
export type EffectSQLiteRunResult = readonly never[] export type EffectSQLiteRunResult = readonly never[]
export interface EffectSQLiteSessionOptions { export interface EffectSQLiteSessionOptions {
@@ -134,7 +120,6 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
} }
private withTransaction<A, E, R>(effect: Effect.Effect<A, E, R>, config: SQLiteTransactionConfig | undefined) { private withTransaction<A, E, R>(effect: Effect.Effect<A, E, R>, config: SQLiteTransactionConfig | undefined) {
if (managesTransactionsNatively(this.client)) return this.client.withTransaction(effect)
return Effect.uninterruptibleMask((restore) => return Effect.uninterruptibleMask((restore) =>
Effect.withFiber<A, E | SqlError, R>((fiber) => { Effect.withFiber<A, E | SqlError, R>((fiber) => {
const services = fiber.context const services = fiber.context
-2
View File
@@ -41,7 +41,6 @@ import m38 from "./migration/20260804233008_loose_psylocke.js"
import m39 from "./migration/20260805200742_import_legacy_credentials.js" import m39 from "./migration/20260805200742_import_legacy_credentials.js"
import m40 from "./migration/20260808023530_workspace_domain.js" import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js" import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
export const migrations = [ export const migrations = [
m00, m00,
@@ -86,5 +85,4 @@ export const migrations = [
m39, m39,
m40, m40,
m41, m41,
m42,
] satisfies DatabaseMigration.Migration[] ] satisfies DatabaseMigration.Migration[]
@@ -1,30 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260812181746_session_inbox",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_inbox\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`payload\` text NOT NULL,
\`delivery\` text NOT NULL,
\`enqueued_seq\` integer NOT NULL,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_inbox_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`CREATE INDEX \`session_inbox_session_delivery_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`delivery\`,\`enqueued_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_seq\`);`,
)
})
},
}
export default migration
-18
View File
@@ -142,18 +142,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
); );
`) `)
yield* tx.run(`
CREATE TABLE \`session_inbox\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`payload\` text NOT NULL,
\`delivery\` text NOT NULL,
\`enqueued_seq\` integer NOT NULL,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_inbox_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`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,
@@ -230,12 +218,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
yield* tx.run( yield* tx.run(
`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`, `CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
) )
yield* tx.run(
`CREATE INDEX \`session_inbox_session_delivery_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`delivery\`,\`enqueued_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_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\`);`,
) )
+11 -1
View File
@@ -1,4 +1,5 @@
import { Database, type SQLQueryBindings } from "bun:sqlite" import { Database, type SQLQueryBindings } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity" import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql" import { SqlClient } from "effect/unstable/sql"
@@ -97,7 +98,16 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
return drizzle({ client: (yield* Sqlite.Native) as Database })
}),
)
export const sqliteLayer = (config: Config) => { export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config) const native = nativeLayer(config)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer)) return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
} }
+11 -1
View File
@@ -1,4 +1,5 @@
import { DatabaseSync, type SQLInputValue } from "node:sqlite" import { DatabaseSync, type SQLInputValue } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity" import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql" import { SqlClient } from "effect/unstable/sql"
@@ -94,7 +95,16 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
return drizzle({ client: (yield* Sqlite.Native) as DatabaseSync }) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => { export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config) const native = nativeLayer(config)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer)) return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
} }
+3
View File
@@ -5,8 +5,11 @@ import { identity } from "effect/Function"
import { SqlClient, Statement } from "effect/unstable/sql" import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection" import type { Connection } from "effect/unstable/sql/SqlConnection"
import type { SqlError } from "effect/unstable/sql/SqlError" import type { SqlError } from "effect/unstable/sql/SqlError"
import type { drizzle } from "drizzle-orm/bun-sqlite"
export type DrizzleClient = ReturnType<typeof drizzle>
export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {} export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}
export interface ClientConfig { export interface ClientConfig {
readonly spanAttributes?: Record<string, unknown> readonly spanAttributes?: Record<string, unknown>
+15 -5
View File
@@ -1,10 +1,10 @@
import { drizzle } from "drizzle-orm/durable-sqlite"
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect" import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function" import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity" import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient, Statement } from "effect/unstable/sql" import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection" import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError" import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
import type { NativeTransactionSqlClient } from "./drizzle/effect-sqlite/session.js"
import { Sqlite } from "./sqlite.js" import { Sqlite } from "./sqlite.js"
const ATTR_DB_SYSTEM_NAME = "db.system.name" const ATTR_DB_SYSTEM_NAME = "db.system.name"
@@ -216,10 +216,10 @@ const make = (options: Config) =>
config: options, config: options,
withTransaction: makeWithTransaction(native, connection, semaphore), withTransaction: makeWithTransaction(native, connection, semaphore),
// Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such // Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
// as the drizzle session detect this and route through withTransaction. // as the drizzle session must route through withTransaction instead.
transactionStatements: false, transactionStatements: false,
} as const, },
) satisfies NativeTransactionSqlClient )
return client return client
}) })
@@ -238,7 +238,17 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
return drizzle(native) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => { export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config) const native = nativeLayer(config)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer)) return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
} }
@@ -1,997 +0,0 @@
export * as V1Migration from "./v1-migration.js"
import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect"
import { Database } from "./database.js"
import { SessionMessageTable, SessionTable } from "../session/sql.js"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message.js"
import { SessionSchema } from "../session/schema.js"
import { KVTable } from "../kv/sql.js"
import { EventSequenceTable } from "../event/sql.js"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
import path from "node:path"
import type { Database as SQLiteDatabase } from "bun:sqlite"
import { Project } from "@opencode-ai/schema/project"
export type SourceMessage = {
readonly id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type SourcePart = {
readonly id: string
readonly message_id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type TransformInput = {
readonly session: typeof SessionTable.$inferSelect
readonly messages: ReadonlyArray<SourceMessage>
readonly parts: ReadonlyArray<SourcePart>
}
export type Warning = {
readonly reason: string
readonly sessionID: string
readonly messageID?: string
readonly partID?: string
readonly observedType?: string
}
export type TransformResult = {
readonly messages: ReadonlyArray<{
readonly id: string
readonly session_id: string
readonly type: SessionMessage.Type
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: Record<string, unknown>
}>
readonly session: Pick<
typeof SessionTable.$inferInsert,
| "agent"
| "model"
| "cost"
| "tokens_input"
| "tokens_output"
| "tokens_reasoning"
| "tokens_cache_read"
| "tokens_cache_write"
| "revert"
| "time_compacting"
>
readonly watermark: number
readonly warnings: ReadonlyArray<Warning>
}
type Progress = {
readonly label: string
readonly numerator?: number
readonly denominator?: number
}
export type Status =
| { readonly status: "required" | "completed" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type RunResult = {
readonly status: "completed"
}
type Options = {
readonly nextDatabasePath?: string
}
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
type RuntimeState =
| { readonly status: "idle" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type NextProject = {
readonly id: string
readonly worktree: string
readonly vcs: string | null
readonly name: string | null
readonly icon_url: string | null
readonly icon_url_override: string | null
readonly icon_color: string | null
readonly time_created: number
readonly time_updated: number
readonly time_initialized: number | null
readonly sandboxes: string
readonly commands: string | null
}
type NextSession = {
readonly id: string
readonly project_id: string
readonly workspace_id: string | null
readonly parent_id: string | null
readonly fork_session_id: string | null
readonly fork_boundary: string | null
readonly slug: string
readonly directory: string
readonly path: string | null
readonly title: string | null
readonly version: string
readonly share_url: string | null
readonly summary_additions: number | null
readonly summary_deletions: number | null
readonly summary_files: number | null
readonly summary_diffs: string | null
readonly metadata: string | null
readonly cost: number
readonly tokens_input: number
readonly tokens_output: number
readonly tokens_reasoning: number
readonly tokens_cache_read: number
readonly tokens_cache_write: number
readonly revert: string | null
readonly permission: string | null
readonly agent: string | null
readonly model: string | null
readonly time_created: number
readonly time_updated: number
readonly time_compacting: number | null
readonly time_archived: number | null
readonly time_suspended: number | null
}
type NextMessage = {
readonly id: string
readonly session_id: string
readonly type: string
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: string
}
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
let runtimeState: RuntimeState = { status: "idle" }
export function transformSession(input: TransformInput): TransformResult {
const warnings: Warning[] = []
const messages = input.messages
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id }))
: undefined
if (decoded) return { row, value: decoded }
warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id })
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id))
const messageIDs = new Set(input.messages.map((row) => row.id))
const parts = input.parts
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined
if (!messageIDs.has(row.message_id)) {
warnings.push({
reason: "orphan-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
}
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(
decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }),
)
: undefined
if (decoded) return { row, value: decoded }
warnings.push({
reason: "invalid-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.id.localeCompare(b.row.id))
const byMessage = Map.groupBy(parts, (item) => item.row.message_id)
const paired = new Set<string>()
const used = new Set(messages.map((item) => item.row.id))
const projected = messages
.flatMap((item) => {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
: -1
const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id)
const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex)
return [
row(
{ ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) },
{
id: item.row.id,
type: "compaction",
status: "completed",
reason: compaction.auto ? "auto" : "manual",
summary: summaryText,
recent: serializeRecent(tail, byMessage),
time: { created: item.row.time_created },
},
),
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)]
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
synthetic.length > 0 &&
attachments.length === 0 &&
agentAttachments.length === 0
)
return [
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
const user = row(item.row, {
id: item.row.id,
type: "user",
text,
...(attachments.length ? { files: attachments } : {}),
...(agentAttachments.length ? { agents: agentAttachments } : {}),
time: { created: item.row.time_created },
})
if (synthetic.length === 0) return [user]
return [
user,
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
}
if (item.value.role !== "assistant") return []
const assistant = item.value
const parent = messages.find((candidate) => candidate.row.id === assistant.parentID)
const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : []
if (
parentParts.some((part) => part.type === "subtask") &&
owned.some((part) => part.type === "tool" && part.tool === "task")
)
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
if (part.type !== "tool") return []
return [migrateTool(part, item.row.time_created)]
})
const start =
owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0]
const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1)
const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : []))))
const finish = normalizeFinish(assistant.finish)
return [
row(item.row, {
id: item.row.id,
type: "assistant",
agent: assistant.agent,
model: {
providerID: assistant.providerID,
id: assistant.modelID,
variant: assistant.variant ?? "default",
},
content,
...(start || end || snapshotFiles.length
? {
snapshot: {
...(start ? { start } : {}),
...(end ? { end } : {}),
...(snapshotFiles.length ? { files: snapshotFiles } : {}),
},
}
: {}),
...(finish ? { finish } : {}),
cost: assistant.cost,
tokens: {
input: assistant.tokens.input,
output: assistant.tokens.output,
reasoning: assistant.tokens.reasoning,
cache: assistant.tokens.cache,
},
...(assistant.error ? { error: migrateError(assistant.error) } : {}),
time: {
created: item.row.time_created,
...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }),
},
}),
]
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
if (item.value.role !== "user") return false
const owned = byMessage.get(item.row.id) ?? []
if (owned.some((part) => part.value.type === "compaction")) return false
return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask")
})
return {
messages: projected,
session: {
agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null),
model:
input.session.model ??
(latestUser?.value.role === "user"
? {
id: latestUser.value.model.modelID,
providerID: latestUser.value.model.providerID,
variant: latestUser.value.model.variant ?? "default",
}
: null),
cost: assistants.reduce((total, item) => total + item.cost, 0),
tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0),
tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0),
tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0),
tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0),
tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0),
revert: null,
time_compacting: null,
},
watermark: projected.length - 1,
warnings,
}
}
export function status(): Effect.Effect<Status, never, Database.Service> {
return Effect.gen(function* () {
const { db } = yield* Database.Service
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const state = yield* readState(db)
if (runtimeState.status === "running") return runtimeState
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
runtimeState = { status: "running", progress: { label: "Clearing old events" } }
yield* run().pipe(
Effect.matchCauseEffect({
onFailure: (cause) =>
Effect.sync(() => {
runtimeState = { status: "error", error: errorText(Cause.squash(cause)) }
}).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))),
onSuccess: () =>
Effect.sync(() => {
runtimeState = { status: "idle" }
}),
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
function errorText(input: unknown): string {
if (!(input instanceof Error)) return String(input)
const cause = input.cause
return cause === undefined ? input.message : `${input.message}\nCaused by: ${errorText(cause)}`
}
function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
)
SELECT
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
}).pipe(Effect.orDie),
)
}
function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
return Effect.acquireRelease(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
}),
(source) => Effect.sync(() => source.close()),
)
}
function countNextSessions(sourcePath: string | undefined) {
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
}
function importNextDatabase(
db: Database.Interface["db"],
sourcePath: string | undefined,
onProgress: (completed: number) => void,
): Effect.Effect<void, unknown> {
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) {
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
return
}
source.run("BEGIN")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (source.inTransaction) source.run("ROLLBACK")
}),
)
const projects = new Map(
source
.query<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
for (const [index, session] of sessions.entries()) {
const project = projects.get(session.project_id)
const projectID = project ? session.project_id : Project.ID.global
if (!project) {
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
sessionID: session.id,
projectID: session.project_id,
})
}
const messages = source
.query<
NextMessage,
[string]
>("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
.all(session.id)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
if (project)
yield* tx.run(sql`
INSERT OR IGNORE INTO project (
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
time_created, time_updated, time_initialized, sandboxes, commands
) VALUES (
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
)
`)
const existing = yield* tx
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
.get()
if (existing) return
yield* tx.run(sql`
INSERT INTO session_v2 (
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, slug, directory,
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
time_archived, time_suspended
) VALUES (
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
${session.time_archived}, ${session.time_suspended}
)
`)
yield* Effect.forEach(messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type as SessionMessage.Type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${message.data}`,
})
.run(),
)
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
onProgress(index + 1)
yield* Effect.yieldNow
}
source.run("COMMIT")
}),
)
}
function isNextDatabase(source: SQLiteDatabase) {
const tables = new Set(
source
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((table) => table.name),
)
return tables.has("project") && tables.has("session") && tables.has("session_message")
}
function row(
source: SourceMessage,
message: {
readonly id: string
readonly type: SessionMessage.Type
readonly time: { readonly created: number }
readonly [key: string]: unknown
},
): TransformResult["messages"][number] {
const { id, type, ...data } = message
return {
id,
session_id: source.session_id,
type,
seq: 0,
time_created: source.time_created,
time_updated: source.time_updated,
data,
}
}
function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) {
const base = {
type: "tool" as const,
id: part.callID,
name: part.tool,
...(part.metadata ? { providerState: part.metadata } : {}),
}
if (part.state.status === "completed")
return {
...base,
state: {
status: "completed",
input: part.state.input,
content:
part.state.time.compacted === undefined
? [
{ type: "text", text: part.state.output },
...(part.state.attachments ?? []).map((file) => ({
type: "file" as const,
uri: file.url,
mime: file.mime,
...(file.filename ? { name: file.filename } : {}),
})),
]
: [{ type: "text", text: "[Old tool result content cleared]" }],
metadata: part.state.metadata,
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
if (part.state.status === "error")
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.execution", message: part.state.error },
...(typeof part.state.metadata?.output === "string"
? { content: [{ type: "text", text: part.state.metadata.output }] }
: {}),
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" },
...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.status === "running" ? part.state.time.start : fallback },
}
}
function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) {
const message =
"message" in error.data
? error.data.message
: error.name === "MessageOutputLengthError"
? "The model exceeded its output limit"
: error.name
const type =
error.name === "ProviderAuthError"
? "provider.auth"
: error.name === "ContentFilterError"
? "provider.content-filter"
: error.name === "ContextOverflowError"
? "provider.invalid-request"
: error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError"
? "provider.invalid-output"
: error.name === "MessageAbortedError"
? "aborted"
: error.name === "APIError"
? "provider.error"
: "unknown"
return { type, message }
}
function normalizeFinish(finish: string | undefined) {
if (!finish) return undefined
return (
(["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find(
(value) => value === finish,
) ?? "unknown"
)
}
function migrateFile(part: SessionV1.FilePart) {
if (!part.url.startsWith("data:")) return []
const comma = part.url.indexOf(",")
if (comma < 0) return []
const header = part.url.slice(0, comma)
const payload = part.url.slice(comma + 1)
const data = header.endsWith(";base64")
? Buffer.from(payload, "base64").toString("base64")
: Buffer.from(decodeURIComponent(payload)).toString("base64")
return [
{
data,
mime: part.mime,
source:
part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const },
...(part.filename ? { name: part.filename } : {}),
...(part.source
? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } }
: {}),
},
]
}
function unavailableFile(part: SessionV1.FilePart) {
const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url)
return `[Attachment unavailable after migration: ${label} (${part.mime})]`
}
function syntheticID(source: string, used: Set<string>) {
const prefix = source.slice(0, 16)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for (let salt = 0; ; salt++) {
const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex")
let value = BigInt(`0x${hex}`)
let suffix = ""
while (suffix.length < 14) {
suffix = alphabet[Number(value % 62n)] + suffix
value /= 62n
}
const id = prefix + suffix
if (used.has(id)) continue
used.add(id)
return id
}
}
function serializeRecent(
messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>,
parts: Map<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
) {
return messages
.flatMap((message) => {
const owned = parts.get(message.row.id)?.map((part) => part.value) ?? []
if (message.value.role === "user")
return [
`[User]: ${owned
.filter((part) => part.type === "text" && !part.ignored)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")}`,
]
return owned.flatMap((part) =>
part.type === "text"
? [`[Assistant]: ${part.text}`]
: part.type === "reasoning" && part.text
? [`[Assistant reasoning]: ${part.text}`]
: [],
)
})
.join("\n\n")
}
function readState(db: Database.Interface["db"]): Effect.Effect<MigrationState | undefined> {
return db
.select({ value: KVTable.value })
.from(KVTable)
.where(eq(KVTable.key, MIGRATION_STATE_KEY))
.get()
.pipe(
Effect.map((row) => parseState(row?.value)),
Effect.orDie,
)
}
function parseState(input: unknown): MigrationState | undefined {
if (!input || typeof input !== "object" || !("phase" in input)) return
if (input.phase === "completed") return { phase: "completed" }
if (input.phase !== "sessions") return
if (!("cursor" in input) || input.cursor === undefined) return { phase: "sessions" }
if (typeof input.cursor === "string") return { phase: "sessions", cursor: input.cursor }
}
function hasLegacySessions(db: Database.Interface["db"]) {
return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe(
Effect.map((row) => row !== undefined),
Effect.orDie,
)
}
@@ -1,11 +0,0 @@
import { Effect, Layer } from "effect"
export type Status = { readonly status: "completed" }
export const layer = Layer.empty
export function status() {
return Effect.succeed({ status: "completed" } as const)
}
export function run() {
return Effect.succeed({ status: "completed" } as const)
}
+997 -2
View File
@@ -1,2 +1,997 @@
export * as V1Migration from "#v1-migration" export * as V1Migration from "./v1-migration.js"
export * from "#v1-migration"
import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect"
import { Database } from "./database.js"
import { SessionMessageTable, SessionTable } from "../session/sql.js"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message.js"
import { SessionSchema } from "../session/schema.js"
import { KVTable } from "../kv/sql.js"
import { EventSequenceTable } from "../event/sql.js"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
import path from "node:path"
import type { Database as SQLiteDatabase } from "bun:sqlite"
import { Project } from "@opencode-ai/schema/project"
export type SourceMessage = {
readonly id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type SourcePart = {
readonly id: string
readonly message_id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type TransformInput = {
readonly session: typeof SessionTable.$inferSelect
readonly messages: ReadonlyArray<SourceMessage>
readonly parts: ReadonlyArray<SourcePart>
}
export type Warning = {
readonly reason: string
readonly sessionID: string
readonly messageID?: string
readonly partID?: string
readonly observedType?: string
}
export type TransformResult = {
readonly messages: ReadonlyArray<{
readonly id: string
readonly session_id: string
readonly type: SessionMessage.Type
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: Record<string, unknown>
}>
readonly session: Pick<
typeof SessionTable.$inferInsert,
| "agent"
| "model"
| "cost"
| "tokens_input"
| "tokens_output"
| "tokens_reasoning"
| "tokens_cache_read"
| "tokens_cache_write"
| "revert"
| "time_compacting"
>
readonly watermark: number
readonly warnings: ReadonlyArray<Warning>
}
type Progress = {
readonly label: string
readonly numerator?: number
readonly denominator?: number
}
export type Status =
| { readonly status: "required" | "completed" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type RunResult = {
readonly status: "completed"
}
type Options = {
readonly nextDatabasePath?: string
}
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
type RuntimeState =
| { readonly status: "idle" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type NextProject = {
readonly id: string
readonly worktree: string
readonly vcs: string | null
readonly name: string | null
readonly icon_url: string | null
readonly icon_url_override: string | null
readonly icon_color: string | null
readonly time_created: number
readonly time_updated: number
readonly time_initialized: number | null
readonly sandboxes: string
readonly commands: string | null
}
type NextSession = {
readonly id: string
readonly project_id: string
readonly workspace_id: string | null
readonly parent_id: string | null
readonly fork_session_id: string | null
readonly fork_boundary: string | null
readonly slug: string
readonly directory: string
readonly path: string | null
readonly title: string | null
readonly version: string
readonly share_url: string | null
readonly summary_additions: number | null
readonly summary_deletions: number | null
readonly summary_files: number | null
readonly summary_diffs: string | null
readonly metadata: string | null
readonly cost: number
readonly tokens_input: number
readonly tokens_output: number
readonly tokens_reasoning: number
readonly tokens_cache_read: number
readonly tokens_cache_write: number
readonly revert: string | null
readonly permission: string | null
readonly agent: string | null
readonly model: string | null
readonly time_created: number
readonly time_updated: number
readonly time_compacting: number | null
readonly time_archived: number | null
readonly time_suspended: number | null
}
type NextMessage = {
readonly id: string
readonly session_id: string
readonly type: string
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: string
}
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
let runtimeState: RuntimeState = { status: "idle" }
export function transformSession(input: TransformInput): TransformResult {
const warnings: Warning[] = []
const messages = input.messages
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id }))
: undefined
if (decoded) return { row, value: decoded }
warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id })
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id))
const messageIDs = new Set(input.messages.map((row) => row.id))
const parts = input.parts
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined
if (!messageIDs.has(row.message_id)) {
warnings.push({
reason: "orphan-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
}
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(
decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }),
)
: undefined
if (decoded) return { row, value: decoded }
warnings.push({
reason: "invalid-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.id.localeCompare(b.row.id))
const byMessage = Map.groupBy(parts, (item) => item.row.message_id)
const paired = new Set<string>()
const used = new Set(messages.map((item) => item.row.id))
const projected = messages
.flatMap((item) => {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
: -1
const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id)
const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex)
return [
row(
{ ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) },
{
id: item.row.id,
type: "compaction",
status: "completed",
reason: compaction.auto ? "auto" : "manual",
summary: summaryText,
recent: serializeRecent(tail, byMessage),
time: { created: item.row.time_created },
},
),
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)]
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
synthetic.length > 0 &&
attachments.length === 0 &&
agentAttachments.length === 0
)
return [
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
const user = row(item.row, {
id: item.row.id,
type: "user",
text,
...(attachments.length ? { files: attachments } : {}),
...(agentAttachments.length ? { agents: agentAttachments } : {}),
time: { created: item.row.time_created },
})
if (synthetic.length === 0) return [user]
return [
user,
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
}
if (item.value.role !== "assistant") return []
const assistant = item.value
const parent = messages.find((candidate) => candidate.row.id === assistant.parentID)
const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : []
if (
parentParts.some((part) => part.type === "subtask") &&
owned.some((part) => part.type === "tool" && part.tool === "task")
)
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
if (part.type !== "tool") return []
return [migrateTool(part, item.row.time_created)]
})
const start =
owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0]
const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1)
const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : []))))
const finish = normalizeFinish(assistant.finish)
return [
row(item.row, {
id: item.row.id,
type: "assistant",
agent: assistant.agent,
model: {
providerID: assistant.providerID,
id: assistant.modelID,
variant: assistant.variant ?? "default",
},
content,
...(start || end || snapshotFiles.length
? {
snapshot: {
...(start ? { start } : {}),
...(end ? { end } : {}),
...(snapshotFiles.length ? { files: snapshotFiles } : {}),
},
}
: {}),
...(finish ? { finish } : {}),
cost: assistant.cost,
tokens: {
input: assistant.tokens.input,
output: assistant.tokens.output,
reasoning: assistant.tokens.reasoning,
cache: assistant.tokens.cache,
},
...(assistant.error ? { error: migrateError(assistant.error) } : {}),
time: {
created: item.row.time_created,
...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }),
},
}),
]
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
if (item.value.role !== "user") return false
const owned = byMessage.get(item.row.id) ?? []
if (owned.some((part) => part.value.type === "compaction")) return false
return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask")
})
return {
messages: projected,
session: {
agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null),
model:
input.session.model ??
(latestUser?.value.role === "user"
? {
id: latestUser.value.model.modelID,
providerID: latestUser.value.model.providerID,
variant: latestUser.value.model.variant ?? "default",
}
: null),
cost: assistants.reduce((total, item) => total + item.cost, 0),
tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0),
tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0),
tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0),
tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0),
tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0),
revert: null,
time_compacting: null,
},
watermark: projected.length - 1,
warnings,
}
}
export function status(): Effect.Effect<Status, never, Database.Service> {
return Effect.gen(function* () {
const { db } = yield* Database.Service
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const state = yield* readState(db)
if (runtimeState.status === "running") return runtimeState
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
runtimeState = { status: "running", progress: { label: "Clearing old events" } }
yield* run().pipe(
Effect.matchCauseEffect({
onFailure: (cause) =>
Effect.sync(() => {
runtimeState = { status: "error", error: errorText(Cause.squash(cause)) }
}).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))),
onSuccess: () =>
Effect.sync(() => {
runtimeState = { status: "idle" }
}),
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
function errorText(input: unknown): string {
if (!(input instanceof Error)) return String(input)
const cause = input.cause
return cause === undefined ? input.message : `${input.message}\nCaused by: ${errorText(cause)}`
}
function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
)
SELECT
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
}).pipe(Effect.orDie),
)
}
function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
return Effect.acquireRelease(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
}),
(source) => Effect.sync(() => source.close()),
)
}
function countNextSessions(sourcePath: string | undefined) {
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
}
function importNextDatabase(
db: Database.Interface["db"],
sourcePath: string | undefined,
onProgress: (completed: number) => void,
): Effect.Effect<void, unknown> {
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) {
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
return
}
source.run("BEGIN")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (source.inTransaction) source.run("ROLLBACK")
}),
)
const projects = new Map(
source
.query<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
for (const [index, session] of sessions.entries()) {
const project = projects.get(session.project_id)
const projectID = project ? session.project_id : Project.ID.global
if (!project) {
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
sessionID: session.id,
projectID: session.project_id,
})
}
const messages = source
.query<
NextMessage,
[string]
>("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
.all(session.id)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
if (project)
yield* tx.run(sql`
INSERT OR IGNORE INTO project (
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
time_created, time_updated, time_initialized, sandboxes, commands
) VALUES (
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
)
`)
const existing = yield* tx
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
.get()
if (existing) return
yield* tx.run(sql`
INSERT INTO session_v2 (
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, slug, directory,
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
time_archived, time_suspended
) VALUES (
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
${session.time_archived}, ${session.time_suspended}
)
`)
yield* Effect.forEach(messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type as SessionMessage.Type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${message.data}`,
})
.run(),
)
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
onProgress(index + 1)
yield* Effect.yieldNow
}
source.run("COMMIT")
}),
)
}
function isNextDatabase(source: SQLiteDatabase) {
const tables = new Set(
source
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((table) => table.name),
)
return tables.has("project") && tables.has("session") && tables.has("session_message")
}
function row(
source: SourceMessage,
message: {
readonly id: string
readonly type: SessionMessage.Type
readonly time: { readonly created: number }
readonly [key: string]: unknown
},
): TransformResult["messages"][number] {
const { id, type, ...data } = message
return {
id,
session_id: source.session_id,
type,
seq: 0,
time_created: source.time_created,
time_updated: source.time_updated,
data,
}
}
function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) {
const base = {
type: "tool" as const,
id: part.callID,
name: part.tool,
...(part.metadata ? { providerState: part.metadata } : {}),
}
if (part.state.status === "completed")
return {
...base,
state: {
status: "completed",
input: part.state.input,
content:
part.state.time.compacted === undefined
? [
{ type: "text", text: part.state.output },
...(part.state.attachments ?? []).map((file) => ({
type: "file" as const,
uri: file.url,
mime: file.mime,
...(file.filename ? { name: file.filename } : {}),
})),
]
: [{ type: "text", text: "[Old tool result content cleared]" }],
metadata: part.state.metadata,
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
if (part.state.status === "error")
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.execution", message: part.state.error },
...(typeof part.state.metadata?.output === "string"
? { content: [{ type: "text", text: part.state.metadata.output }] }
: {}),
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" },
...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.status === "running" ? part.state.time.start : fallback },
}
}
function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) {
const message =
"message" in error.data
? error.data.message
: error.name === "MessageOutputLengthError"
? "The model exceeded its output limit"
: error.name
const type =
error.name === "ProviderAuthError"
? "provider.auth"
: error.name === "ContentFilterError"
? "provider.content-filter"
: error.name === "ContextOverflowError"
? "provider.invalid-request"
: error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError"
? "provider.invalid-output"
: error.name === "MessageAbortedError"
? "aborted"
: error.name === "APIError"
? "provider.error"
: "unknown"
return { type, message }
}
function normalizeFinish(finish: string | undefined) {
if (!finish) return undefined
return (
(["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find(
(value) => value === finish,
) ?? "unknown"
)
}
function migrateFile(part: SessionV1.FilePart) {
if (!part.url.startsWith("data:")) return []
const comma = part.url.indexOf(",")
if (comma < 0) return []
const header = part.url.slice(0, comma)
const payload = part.url.slice(comma + 1)
const data = header.endsWith(";base64")
? Buffer.from(payload, "base64").toString("base64")
: Buffer.from(decodeURIComponent(payload)).toString("base64")
return [
{
data,
mime: part.mime,
source:
part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const },
...(part.filename ? { name: part.filename } : {}),
...(part.source
? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } }
: {}),
},
]
}
function unavailableFile(part: SessionV1.FilePart) {
const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url)
return `[Attachment unavailable after migration: ${label} (${part.mime})]`
}
function syntheticID(source: string, used: Set<string>) {
const prefix = source.slice(0, 16)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for (let salt = 0; ; salt++) {
const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex")
let value = BigInt(`0x${hex}`)
let suffix = ""
while (suffix.length < 14) {
suffix = alphabet[Number(value % 62n)] + suffix
value /= 62n
}
const id = prefix + suffix
if (used.has(id)) continue
used.add(id)
return id
}
}
function serializeRecent(
messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>,
parts: Map<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
) {
return messages
.flatMap((message) => {
const owned = parts.get(message.row.id)?.map((part) => part.value) ?? []
if (message.value.role === "user")
return [
`[User]: ${owned
.filter((part) => part.type === "text" && !part.ignored)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")}`,
]
return owned.flatMap((part) =>
part.type === "text"
? [`[Assistant]: ${part.text}`]
: part.type === "reasoning" && part.text
? [`[Assistant reasoning]: ${part.text}`]
: [],
)
})
.join("\n\n")
}
function readState(db: Database.Interface["db"]): Effect.Effect<MigrationState | undefined> {
return db
.select({ value: KVTable.value })
.from(KVTable)
.where(eq(KVTable.key, MIGRATION_STATE_KEY))
.get()
.pipe(
Effect.map((row) => parseState(row?.value)),
Effect.orDie,
)
}
function parseState(input: unknown): MigrationState | undefined {
if (!input || typeof input !== "object" || !("phase" in input)) return
if (input.phase === "completed") return { phase: "completed" }
if (input.phase !== "sessions") return
if (!("cursor" in input) || input.cursor === undefined) return { phase: "sessions" }
if (typeof input.cursor === "string") return { phase: "sessions", cursor: input.cursor }
}
function hasLegacySessions(db: Database.Interface["db"]) {
return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe(
Effect.map((row) => row !== undefined),
Effect.orDie,
)
}
@@ -1,17 +0,0 @@
import { Effect, Layer, PlatformError } from "effect"
import { ChildProcessSpawner, make } from "effect/unstable/process/ChildProcessSpawner"
export const spawner = make(() =>
Effect.fail(
PlatformError.systemError({
_tag: "Unknown",
module: "Environment",
method: "spawn",
description: "This location has no execution plane: no workspace is attached and the host cannot spawn processes",
}),
),
)
export const layer = Layer.succeed(ChildProcessSpawner, spawner)
export * as EnvironmentUnavailable from "./unavailable.js"
@@ -1,12 +0,0 @@
import { bind } from "./fff.js"
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff.js"
// No fff backend exists on workerd; every create reports unavailability and
// FileSystemSearch degrades the same way it does on a runtime without the native module.
const adapter = bind(undefined, "fff unavailable on workerd runtime")
export const available = adapter.available
export const create = adapter.create
export * as Fff from "./fff.workerd.js"
@@ -2,10 +2,9 @@ import { createRequire } from "node:module"
declare const OPENCODE_LIBC: string | undefined declare const OPENCODE_LIBC: string | undefined
// Lazy: on workerd import.meta.url is undefined and the watcher is never const require = createRequire(import.meta.url)
// loaded, so createRequire must not run at module scope.
export default function load() { export default function load() {
const require = createRequire(import.meta.url)
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
return require( return require(
process.env.OPENCODE_PARCEL_WATCHER_PATH ?? process.env.OPENCODE_PARCEL_WATCHER_PATH ??
@@ -1,4 +0,0 @@
// workerd has no filesystem path to a photon wasm artifact. Image.Photon only
// reads this lazily and surfaces a typed ResizerUnavailableError when loading
// fails, so an empty path degrades cleanly instead of breaking module load.
export default ""
+59 -15
View File
@@ -1,10 +1,11 @@
export * as MCPClient from "./client.js" export * as MCPClient from "./client.js"
import path from "node:path" import path from "node:path"
import { execFile } from "node:child_process"
import { pathToFileURL } from "node:url" import { pathToFileURL } from "node:url"
import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import { import {
CallToolResultSchema, CallToolResultSchema,
@@ -29,12 +30,13 @@ import {
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect" import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp" import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { MCPStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000 const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000 const DEFAULT_CATALOG_TIMEOUT = 30_000
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
type Transport = StdioClientTransport | StreamableHTTPClientTransport
// Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops // Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops
// only that field so a single bad schema doesn't blank out the whole tool list. // only that field so a single bad schema doesn't blank out the whole tool list.
const TolerantListToolsResult = ListToolsResultSchema.extend({ const TolerantListToolsResult = ListToolsResultSchema.extend({
@@ -174,12 +176,7 @@ export interface Connection {
readonly onResourcesChanged: (callback: () => void) => void readonly onResourcesChanged: (callback: () => void) => void
} }
/** /** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
* Connects an MCP server; closing the calling scope tears down the transport and any spawned process.
*
* A stdio server is spawned through the location's `Environment`, so it runs on the same execution
* plane as the location's shell commands rather than always on the host.
*/
export const connect = Effect.fnUntraced(function* ( export const connect = Effect.fnUntraced(function* (
server: string, server: string,
config: typeof ConfigMCP.Server.Type, config: typeof ConfigMCP.Server.Type,
@@ -193,12 +190,13 @@ export const connect = Effect.fnUntraced(function* (
const transport: Transport = yield* Effect.gen(function* () { const transport: Transport = yield* Effect.gen(function* () {
if (config.type === "local") { if (config.type === "local") {
const [command, ...args] = config.command const [command, ...args] = config.command
return yield* MCPStdio.make({ return new StdioClientTransport({
server,
command, command,
args, args,
cwd: config.cwd ? path.resolve(directory, config.cwd) : directory, cwd: config.cwd ? path.resolve(directory, config.cwd) : directory,
environment: { stderr: "pipe",
env: {
...(process.env as Record<string, string>),
...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}), ...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...config.environment, ...config.environment,
}, },
@@ -235,9 +233,9 @@ export const connect = Effect.fnUntraced(function* (
catch: (error) => error, catch: (error) => error,
}).pipe(Effect.exit) }).pipe(Effect.exit)
if (Exit.isSuccess(exit)) { if (Exit.isSuccess(exit)) {
// Closing the client closes the transport, which ends stdin and then kills through the spawner yield* Effect.addFinalizer(() =>
// handle if the server does not exit cleanly. The process scope remains a final backstop. cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => client.close())), Effect.ignore),
yield* Effect.addFinalizer(() => Effect.promise(() => client.close()).pipe(Effect.ignore)) )
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
return { return {
@@ -436,12 +434,58 @@ export const connect = Effect.fnUntraced(function* (
} satisfies Connection } satisfies Connection
} }
yield* Effect.promise(() => transport.close()).pipe(Effect.ignore) yield* cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => transport.close())), Effect.ignore)
const error = Cause.squash(exit.cause) const error = Cause.squash(exit.cause)
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server }) if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) }) return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
}) })
// SDK close stops the MCP process, but not child processes it spawned.
const cleanupStdioDescendants = (transport: Transport) =>
Effect.gen(function* () {
if (!(transport instanceof StdioClientTransport)) return
const pid = transport.pid
if (typeof pid !== "number") return
yield* Effect.forEach(
yield* descendantPids(pid),
(pid) =>
Effect.try({
try: () => process.kill(pid, "SIGTERM"),
catch: () => undefined,
}).pipe(Effect.ignore),
{ discard: true },
)
})
const descendantPids = Effect.fnUntraced(function* (root: number) {
if (process.platform === "win32") return []
const result: number[] = []
const queue = [root]
for (let index = 0; index < queue.length; index++) {
const parent = queue[index]
if (parent === undefined) return result
const children = (yield* childPids(parent)).filter((pid) => !result.includes(pid))
result.push(...children)
queue.push(...children)
}
return result
})
const childPids = (pid: number) =>
Effect.promise(
() =>
new Promise<number[]>((resolve) => {
execFile("pgrep", ["-P", String(pid)], { encoding: "utf8" }, (_error, stdout) => {
resolve(
stdout
.split("\n")
.map((line) => Number.parseInt(line, 10))
.filter((pid) => Number.isInteger(pid)),
)
})
}),
)
async function paginate<R extends { nextCursor?: string }, T>( async function paginate<R extends { nextCursor?: string }, T>(
list: (cursor: string | undefined) => Promise<R>, list: (cursor: string | undefined) => Promise<R>,
items: (result: R) => T[], items: (result: R) => T[],
+9 -7
View File
@@ -12,7 +12,6 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Config } from "../config.js" import { Config } from "../config.js"
import { Credential } from "../credential.js" import { Credential } from "../credential.js"
import { Bus } from "../bus.js" import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
import { Form } from "../form.js" import { Form } from "../form.js"
import { Integration } from "../integration.js" import { Integration } from "../integration.js"
import { KeyedMutex } from "../effect/keyed-mutex.js" import { KeyedMutex } from "../effect/keyed-mutex.js"
@@ -174,13 +173,13 @@ export const layer = (options?: Options) =>
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const location = yield* Location.Service const location = yield* Location.Service
const environment = yield* Environment.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const forms = yield* Form.Service const forms = yield* Form.Service
const integration = yield* Integration.Service const integration = yield* Integration.Service
const credentials = yield* Credential.Service const credentials = yield* Credential.Service
const root = yield* Effect.scope const root = yield* Scope.make()
const fork = yield* FiberSet.makeRuntime<never, void, never>() const fork = yield* FiberSet.makeRuntime<never, void, never>()
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
const loadConfig = (entries: readonly Entry[]) => { const loadConfig = (entries: readonly Entry[]) => {
const documents = entries.filter((entry): entry is Document => entry.type === "document") const documents = entries.filter((entry): entry is Document => entry.type === "document")
@@ -460,8 +459,13 @@ export const layer = (options?: Options) =>
connection.onClose(() => connection.onClose(() =>
live( live(
Effect.gen(function* () { Effect.gen(function* () {
entry.client = undefined
entry.tools = undefined
entry.prompts = undefined
entry.status = { status: "failed", error: "Connection closed" } entry.status = { status: "failed", error: "Connection closed" }
yield* stopServer(name, entry) yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
}), }),
), ),
@@ -516,8 +520,6 @@ export const layer = (options?: Options) =>
options?.clientInfo, options?.clientInfo,
).pipe( ).pipe(
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))), Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
// A stdio server is spawned on this location's execution plane, not the host's.
Effect.provideService(Environment.Service, environment),
Scope.provide(scope), Scope.provide(scope),
Effect.exit, Effect.exit,
) )
@@ -826,7 +828,7 @@ export function configured(options?: Options) {
return makeLocationNode({ return makeLocationNode({
service: Service, service: Service,
layer: layer(options), layer: layer(options),
deps: [Config.node, Location.node, Environment.node, Bus.node, Form.node, Integration.node, Credential.node], deps: [Config.node, Location.node, Bus.node, Form.node, Integration.node, Credential.node],
}) })
} }
+1 -2
View File
@@ -2,6 +2,7 @@ export * as MCPOAuth from "./oauth.js"
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js" import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import { createServer } from "node:http"
import { Deferred, Effect } from "effect" import { Deferred, Effect } from "effect"
import { Credential } from "@opencode-ai/schema/credential" import { Credential } from "@opencode-ai/schema/credential"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp" import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
@@ -151,8 +152,6 @@ export const authorize = (input: {
const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback" const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback"
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url") const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => { const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1") const url = new URL(request.url ?? "/", "http://127.0.0.1")
if (url.pathname !== redirectPath) { if (url.pathname !== redirectPath) {
-181
View File
@@ -1,181 +0,0 @@
export * as MCPStdio from "./stdio.js"
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Duration, Effect, Queue, Scope, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
import { Environment } from "../environment/index.js"
/** Mirrors StdioClientTransport: wait this long for a graceful exit after stdin closes. */
const CLOSE_GRACE = Duration.seconds(2)
/** Mirrors StdioClientTransport: escalate SIGTERM to SIGKILL after this long. */
const FORCE_KILL_AFTER = Duration.seconds(2)
const OUTGOING_CAPACITY = 64
const MAX_FRAME_BYTES = 16 * 1024 * 1024
export interface Options {
/** Server name; only used to attribute logs. */
readonly server: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
/**
* Environment declared by the server config, and nothing else.
*
* The host environment is merged in by the spawner via `extendEnv`, which keeps the merge on the
* side that actually runs the process: the local driver extends with the host's `process.env`
* (what the MCP SDK's transport did), while a workspace driver extends with the sandbox's own
* environment. Host variables therefore never cross the seam into a remote workspace.
*/
readonly environment: Record<string, string>
}
/**
* MCP stdio transport that spawns its server through the location's `Environment` instead of the
* SDK's host-bound `StdioClientTransport`, so a workspace-backed location runs its MCP servers
* wherever the rest of its execution happens.
*
* The process is acquired in the calling scope: closing the scope kills it (the spawner kills the
* whole process group, so descendants go too) regardless of whether the transport was closed.
*/
export const make = Effect.fnUntraced(function* (options: Options) {
const environment = yield* Environment.Service
const scope = yield* Effect.scope
// Outgoing frames are queued rather than written to `handle.stdin` directly: the sink closes the
// stream it is run with, and stdin must stay open across the whole session.
const outgoing = yield* Queue.bounded<string, Cause.Done>(OUTGOING_CAPACITY)
const buffer = new ReadBuffer()
const state: { phase: "ready" | "starting" | "open" | "closed"; handle?: ChildProcessHandle } = { phase: "ready" }
let startup: Promise<void> | undefined
let closing: Promise<void> | undefined
let trailingBytes = 0
const stop = (handle: ChildProcessHandle) =>
Effect.gen(function* () {
const exit = yield* Effect.timeoutOption(handle.exitCode, CLOSE_GRACE)
if (exit._tag === "Some") return
const terminated = yield* Effect.timeoutOption(handle.kill({ killSignal: "SIGTERM" }), FORCE_KILL_AFTER)
if (terminated._tag === "None") yield* handle.kill({ killSignal: "SIGKILL" })
}).pipe(Effect.ignore)
const close = () =>
(closing ??= Effect.runPromise(
Effect.gen(function* () {
state.phase = "closed"
Queue.endUnsafe(outgoing)
if (startup) yield* Effect.promise(() => startup!.catch(() => undefined))
const handle = state.handle
if (!handle) return
state.handle = undefined
yield* stop(handle)
}).pipe(Effect.ensuring(Queue.shutdown(outgoing)), Effect.ensuring(Effect.sync(() => buffer.clear()))),
))
const transport: Transport = {
start: () => {
if (state.phase !== "ready") return Promise.reject(new Error("Stdio transport already started"))
state.phase = "starting"
startup = Effect.runPromise(
Effect.gen(function* () {
const handle = yield* environment.spawner.spawn(
ChildProcess.make(options.command, [...options.args], {
cwd: options.cwd,
env: options.environment,
extendEnv: true,
stdin: { stream: Stream.encodeText(Stream.fromQueue(outgoing)), endOnDone: true },
stdout: "pipe",
stderr: "pipe",
forceKillAfter: FORCE_KILL_AFTER,
}),
)
state.handle = handle
if (state.phase === "closed") {
state.handle = undefined
return yield* stop(handle)
}
state.phase = "open"
yield* startOutput(handle)
}).pipe(Scope.provide(scope)),
)
return startup
},
send: (message: JSONRPCMessage) =>
state.phase !== "open"
? Promise.reject(new Error("Not connected"))
: Effect.runPromise(
Queue.offer(outgoing, serializeMessage(message)).pipe(
Effect.flatMap((offered) => (offered ? Effect.void : Effect.fail(new Error("Not connected")))),
),
),
close,
}
const deliver = (chunk: Uint8Array) =>
Effect.gen(function* () {
for (const byte of chunk) {
trailingBytes = byte === 10 ? 0 : trailingBytes + 1
if (trailingBytes > MAX_FRAME_BYTES) return yield* Effect.fail(new Error("MCP stdio frame exceeded 16 MiB"))
}
buffer.append(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength))
while (true) {
// `undefined` means the frame failed to parse: the buffer has already advanced past it, so
// keep draining. `null` means the buffer holds no complete frame yet.
const message = yield* Effect.try({
try: () => buffer.readMessage(),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}).pipe(
Effect.catch((error) =>
Effect.sync(() => {
transport.onerror?.(error)
return undefined
}),
),
)
if (message === undefined) continue
if (message === null) return
transport.onmessage?.(message)
}
})
const startOutput = (handle: ChildProcessHandle) =>
Effect.gen(function* () {
yield* Effect.forkScoped(
Stream.runForEach(handle.stdout, deliver).pipe(
Effect.tapCause((cause) =>
Effect.sync(() => {
const error = Cause.squash(cause)
transport.onerror?.(error instanceof Error ? error : new Error(String(error)))
}),
),
Effect.ignore,
// stdout ending means the server is gone; the SDK transport reports that the same way.
Effect.ensuring(
Effect.gen(function* () {
const unexpected = state.phase !== "closed"
if (unexpected) yield* Effect.promise(close)
transport.onclose?.()
}),
),
),
)
// StdioClientTransport pipes stderr into a stream nobody reads. Drain chunks into the debug
// log so chatty servers cannot stall and newline-free output is not buffered without bound.
yield* Effect.forkScoped(
handle.stderr.pipe(
Stream.decodeText(),
Stream.runForEach((output) =>
output.trim() === ""
? Effect.void
: Effect.logDebug("mcp server stderr", { server: options.server, output }),
),
Effect.ignore,
),
)
})
return transport
})
+6 -22
View File
@@ -544,24 +544,6 @@ const Cache = Schema.Struct({
}) })
const defaultSource = "https://models.opencode.ai" const defaultSource = "https://models.opencode.ai"
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
// packages/core/src/models-dev/snapshot.txt and refreshed via
// `bun run script/update-models-snapshot.ts`. Decoded and normalized once per
// isolate: the snapshot is a multi-MB module-level constant and one isolate can
// host many runtimes (Cloudflare colocates Durable Object instances), so
// per-runtime decoding would multiply the cost.
let bundledCache: readonly Snapshot[] | undefined
const bundledSnapshot = Effect.suspend(() =>
bundledCache
? Effect.succeed(bundledCache)
: decodeCatalog(snapshotText).pipe(
Effect.map((catalog) => {
bundledCache = normalize(catalog)
return bundledCache
}),
),
)
function cacheKey(source: string) { function cacheKey(source: string) {
if (source === defaultSource) return "models-dev:catalog" if (source === defaultSource) return "models-dev:catalog"
return `models-dev:catalog:${Hash.fast(source)}` return `models-dev:catalog:${Hash.fast(source)}`
@@ -625,9 +607,11 @@ export const layer = (options?: Options) =>
) )
: Effect.succeed(undefined) : Effect.succeed(undefined)
// The bundled snapshot is the boot-time floor for the catalog; the // Bundled snapshot of https://models.opencode.ai/api.json, committed at
// periodic fetch below still refreshes on top. // packages/core/src/models-dev/snapshot.txt and refreshed via
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : bundledSnapshot // `bun run script/update-models-snapshot.ts`. It is the boot-time floor
// for the catalog; the periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : decodeCatalog(snapshotText)
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi() const text = yield* fetchApi()
@@ -651,7 +635,7 @@ export const layer = (options?: Options) =>
const cached = options?.file ? undefined : yield* loadFromCache() const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog) if (cached) return normalize(cached.catalog)
const bundled = yield* loadSnapshot const bundled = yield* loadSnapshot
if (bundled) return bundled if (bundled) return normalize(bundled)
if (!fetch) return [] if (!fetch) return []
const catalog = yield* lock.withPermit( const catalog = yield* lock.withPermit(
Effect.gen(function* () { Effect.gen(function* () {
@@ -204,11 +204,10 @@ export const GithubCopilotPlugin = define({
for (const [id, model] of loaded.models) { for (const [id, model] of loaded.models) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model))) evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
} }
} else { } else if (loaded.baseURL) {
for (const id of item.models.keys()) { for (const id of item.models.keys()) {
evt.model.update(item.provider.id, id, (model) => { evt.model.update(item.provider.id, id, (model) => {
model.package = "@ai-sdk/github-copilot" model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
if (loaded.baseURL) model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
}) })
} }
} }
+1 -2
View File
@@ -1,3 +1,4 @@
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin" import { define } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
@@ -57,8 +58,6 @@ const browser = (app: App.Info) =>
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const code = yield* Deferred.make<string, Error>() const code = yield* Deferred.make<string, Error>()
const redirect = `http://localhost:${callbackPort}/auth/callback` const redirect = `http://localhost:${callbackPort}/auth/callback`
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => { const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (url.pathname !== "/auth/callback") { if (url.pathname !== "/auth/callback") {
+1 -5
View File
@@ -23,11 +23,7 @@ export const GooglePlugin = make("google", (id) => (id.includes("gemini-") ? PRO
export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined)) export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined))
export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined)) export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined))
export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined)) export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined))
export const MetaPlugin = make("meta", (id) => { export const MetaPlugin = make("meta", (id) => (id.includes("muse-spark") ? PROMPT_META : undefined))
if (!id.includes("muse")) return
const name = id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
return PROMPT_META.replaceAll("{{MODEL_NAME}}", name)
})
export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
@@ -1,4 +1,4 @@
You are OpenCode, a coding agent that helps users with software engineering tasks. You are powered by {{MODEL_NAME}}, a large language model trained by Meta MSL. You are OpenCode, a coding agent that helps users with software engineering tasks. You are powered by Muse Spark, a large language model trained by Meta MSL.
Use the instructions below and the tools available to assist the user. Use the instructions below and the tools available to assist the user.
@@ -55,5 +55,5 @@ Use the instructions below and the tools available to assist the user.
- NEVER use comments as a place for long-winded chain-of-thought. Long thinking texts must be generated as private reasoning. Comments in code must be appropriately concise. - NEVER use comments as a place for long-winded chain-of-thought. Long thinking texts must be generated as private reasoning. Comments in code must be appropriately concise.
# User Help & Feedback # User Help & Feedback
- Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta {{MODEL_NAME}}. - Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta Muse Spark.
- When users ask directly about OpenCode (eg. "can OpenCode do...", "are you able to do...") or its features (eg. implement a hook, write a slash command, or install an MCP server), use the `webfetch` tool to gather information to answer the question from the V2 OpenCode docs at https://opencode.ai/v2/docs/. - When users ask directly about OpenCode (eg. "can OpenCode do...", "are you able to do...") or its features (eg. implement a hook, write a slash command, or install an MCP server), use the `webfetch` tool to gather information to answer the question from the V2 OpenCode docs at https://opencode.ai/v2/docs/.
+19 -33
View File
@@ -5,9 +5,7 @@ import { ChildProcess } from "effect/unstable/process"
import { asc, desc } from "drizzle-orm" import { asc, desc } from "drizzle-orm"
import path from "path" import path from "path"
import { AbsolutePath } from "./schema.js" import { AbsolutePath } from "./schema.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js" import { Database } from "./database/database.js"
import { Event } from "@opencode-ai/schema/project-directories"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js" import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
@@ -93,40 +91,28 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const git = yield* Git.Service const git = yield* Git.Service
const proc = yield* AppProcess.Service const proc = yield* AppProcess.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db const db = (yield* Database.Service).db
const projectDirectories = yield* ProjectDirectories.Service const projectDirectories = yield* ProjectDirectories.Service
const announcing = new Set<string>()
const persist = Effect.fnUntraced(function* (project: Resolved) { const persist = Effect.fnUntraced(function* (project: Resolved) {
yield* upsertProject(db, project).pipe(Effect.orDie) yield* db
if (!project.vcs) return project .transaction((tx) =>
const directories: ProjectDirectories.CreateInput[] = [{ projectID: project.id, directory: project.canonical }] Effect.gen(function* () {
if (project.directory !== project.canonical) yield* upsertProject(tx, project)
directories.push({ if (!project.vcs) return
projectID: project.id, yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
directory: project.directory, if (project.directory === project.canonical) return
strategy: project.vcs.type === "git" ? "git_worktree" : undefined, yield* projectDirectories.create(
}) {
// A missing directory row means this directory's resolution is a new durable projectID: project.id,
// fact (copy.ts registers copy directories directly; those never strand directory: project.directory,
// sessions and never announce). The row insert commits atomically with the strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
// event, so a crash between checks retries on the next resolve instead of },
// stranding the announcement. The in-flight set keeps concurrent resolves tx,
// from publishing the same fact twice. )
for (const item of directories) { }),
const key = item.projectID + "\u0000" + item.directory )
if (announcing.has(key)) continue .pipe(Effect.orDie)
announcing.add(key)
yield* Effect.gen(function* () {
if (yield* projectDirectories.get({ projectID: item.projectID, directory: item.directory })) return
yield* bus.publish(
Event.Resolved,
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
{ commit: () => Effect.asVoid(projectDirectories.create(item)) },
)
}).pipe(Effect.ensuring(Effect.sync(() => announcing.delete(key))))
}
return project return project
}) })
@@ -264,5 +250,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({ export const node = makeGlobalNode({
service: Service, service: Service,
layer: layer, layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node], deps: [Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
}) })
-9
View File
@@ -1,9 +0,0 @@
import type { Proc } from "./pty.js"
export type { Disp, Exit, Opts, Proc } from "./pty.js"
// workerd cannot spawn processes; the Pty service surfaces this as a defect if
// a terminal is ever requested on this runtime.
export function spawn(): Proc {
throw new Error("Pseudo-terminals are unavailable on the workerd runtime")
}
+57 -60
View File
@@ -31,7 +31,7 @@ import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/err
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map.js" import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js" import { SessionEvent } from "./session/event.js"
import { SessionInbox } from "./session/inbox.js" import { SessionPending } from "./session/pending.js"
import { InstructionState } from "./session/instruction-state.js" import { InstructionState } from "./session/instruction-state.js"
import { SessionGenerate } from "./session/generate.js" import { SessionGenerate } from "./session/generate.js"
import { Snapshot } from "./snapshot.js" import { Snapshot } from "./snapshot.js"
@@ -99,7 +99,6 @@ type CreateInput = CreateBaseInput &
type CompactInput = { type CompactInput = {
id?: SessionMessage.ID id?: SessionMessage.ID
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
delivery?: SessionInbox.Delivery
} }
type ForkInput = { type ForkInput = {
@@ -134,11 +133,14 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", { export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
}) {} }) {}
export class InboxConflictError extends Schema.TaggedErrorClass<InboxConflictError>()("Session.InboxConflictError", { export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
sessionID: SessionSchema.ID, "Session.PendingInputConflictError",
inboxID: SessionMessage.ID, {
}) {} sessionID: SessionSchema.ID,
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID } inputID: SessionMessage.ID,
},
) {}
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", { export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID, skill: Skill.ID,
}) {} }) {}
@@ -186,10 +188,10 @@ export interface Interface {
* ordered by admission. Includes unpromoted user and synthetic inputs and * ordered by admission. Includes unpromoted user and synthetic inputs and
* unhandled compaction barriers. * unhandled compaction barriers.
*/ */
readonly inbox: (sessionID: SessionSchema.ID) => Effect.Effect<SessionInbox.Info[], NotFoundError> readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly cancelInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError> readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly steerInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError> readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly queueInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError> readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
/** /**
* Durable, ordered session log read. Replays durable session bus after * Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured * the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -209,7 +211,6 @@ export interface Interface {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
directory: AbsolutePath directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"] workspaceID?: Location.Ref["workspaceID"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError> }) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
readonly prompt: (input: { readonly prompt: (input: {
id?: SessionMessage.ID id?: SessionMessage.ID
@@ -219,9 +220,9 @@ export interface Interface {
agents?: PromptInput.Prompt["agents"] agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"] skills?: PromptInput.Prompt["skills"]
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery delivery?: SessionPending.Delivery
resume?: boolean resume?: boolean
}) => Effect.Effect<SessionInbox.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError> }) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
/** Generates text from current Session context without admitting input or mutating history. */ /** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: { readonly generate: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
@@ -237,10 +238,10 @@ export interface Interface {
files?: PromptInput.Prompt["files"] files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"] agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"] skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery delivery?: SessionPending.Delivery
resume?: boolean resume?: boolean
}) => Effect.Effect< }) => Effect.Effect<
SessionInbox.User, SessionPending.User,
| NotFoundError | NotFoundError
| PromptConflictError | PromptConflictError
| AttachmentError | AttachmentError
@@ -261,7 +262,7 @@ export interface Interface {
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError> }) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: ( readonly compact: (
input: CompactInput, input: CompactInput,
) => Effect.Effect<SessionInbox.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>
@@ -273,9 +274,9 @@ export interface Interface {
text: string text: string
description?: string description?: string
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery delivery?: SessionPending.Delivery
resume?: boolean resume?: boolean
}) => Effect.Effect<SessionInbox.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
@@ -320,12 +321,12 @@ const layer = Layer.effect(
), ),
) )
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: InboxItemRef) { const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
return yield* new InboxConflictError(input) return yield* new PendingInputConflictError(input)
}) })
const mutatePending = ( const mutatePending = (
input: InboxItemRef, input: PendingInputRef,
mutation: ( mutation: (
bus: Bus.Interface, bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }, input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
@@ -334,9 +335,9 @@ const layer = Layer.effect(
) => ) =>
Effect.uninterruptible( Effect.uninterruptible(
Effect.gen(function* () { Effect.gen(function* () {
yield* mutation(bus, { sessionID: input.sessionID, id: input.inboxID }).pipe( yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
Effect.catchDefect((defect) => Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict ? pendingConflict(input) : Effect.die(defect), defect instanceof SessionPending.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
), ),
) )
if (wake) yield* execution.wake(input.sessionID) if (wake) yield* execution.wake(input.sessionID)
@@ -528,13 +529,13 @@ const layer = Layer.effect(
yield* result.get(sessionID) yield* result.get(sessionID)
return yield* store.context(sessionID) return yield* store.context(sessionID)
}), }),
inbox: Effect.fn("Session.inbox")(function* (sessionID) { pending: Effect.fn("Session.pending")(function* (sessionID) {
yield* result.get(sessionID) yield* result.get(sessionID)
return yield* SessionInbox.list(db, sessionID) return yield* SessionPending.list(db, sessionID)
}), }),
cancelInbox: Effect.fn("Session.cancelInbox")((input) => mutatePending(input, SessionInbox.cancel)), cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
steerInbox: Effect.fn("Session.steerInbox")((input) => mutatePending(input, SessionInbox.steer, true)), steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
queueInbox: Effect.fn("Session.queueInbox")((input) => mutatePending(input, SessionInbox.queue)), queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
log: (input) => log: (input) =>
Stream.unwrap( Stream.unwrap(
result result
@@ -563,25 +564,25 @@ const layer = Layer.effect(
skills, skills,
).pipe(Effect.provideService(FSUtil.Service, fs)) ).pipe(Effect.provideService(FSUtil.Service, fs))
const messageID = input.id ?? SessionMessage.ID.create() const messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionInbox.Item.make({ const admittedInput = SessionPending.Message.make({
type: "user", type: "user",
payload: { ...prompt, metadata: input.metadata }, data: { ...prompt, metadata: input.metadata },
delivery: input.delivery ?? "steer", delivery: input.delivery ?? "steer",
}) })
const admitted = yield* SessionInbox.admit(db, bus, { const admitted = yield* SessionPending.admit(db, bus, {
id: messageID, id: messageID,
sessionID: input.sessionID, sessionID: input.sessionID,
item: admittedInput, input: admittedInput,
}).pipe( }).pipe(
Effect.catchDefect((defect) => Effect.catchDefect((defect) =>
defect instanceof SessionInbox.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" ||
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: 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) {
@@ -639,9 +640,7 @@ const layer = Layer.effect(
yield* execution.awaitIdle(input.sessionID) yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () { const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service const shell = yield* Shell.Service
return yield* shell return yield* shell.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
.pipe(Effect.orDie)
}).pipe(Effect.provide(locations.get(session.location))) }).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish( yield* bus.publish(
SessionEvent.Shell.Started, SessionEvent.Shell.Started,
@@ -739,35 +738,33 @@ const layer = Layer.effect(
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined))) const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) return yield* new DestinationNotFoundError({ directory }) if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory }) if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
const project = yield* projects.resolve(directory) const project = yield* projects.resolve(directory)
yield* persistProject(project) yield* persistProject(project)
const item = SessionInbox.Item.make({ if ((yield* execution.active).has(input.sessionID)) {
type: "move", yield* execution.interrupt(input.sessionID)
payload: { yield* execution.awaitIdle(input.sessionID)
}
yield* bus.publish(
SessionEvent.Moved,
{
sessionID: input.sessionID,
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }), location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id, projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")), subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
}, },
delivery: input.delivery ?? "steer", { location: current.location },
}) )
const inboxID = SessionMessage.ID.create()
yield* SessionInbox.admit(db, bus, {
id: inboxID,
sessionID: input.sessionID,
item,
})
yield* execution.wake(input.sessionID)
}), }),
compact: Effect.fn("Session.compact")(function* (input) { compact: Effect.fn("Session.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* SessionInbox.admitCompaction(db, bus, { const admitted = yield* SessionPending.admitCompaction(db, bus, {
id: inputID, id: inputID,
sessionID: input.sessionID, sessionID: input.sessionID,
delivery: input.delivery ?? "queue",
}).pipe( }).pipe(
Effect.catchDefect((defect) => Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict defect instanceof SessionPending.LifecycleConflict
? new CompactionConflictError({ sessionID: input.sessionID, inputID }) ? new CompactionConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect), : Effect.die(defect),
), ),
@@ -807,29 +804,29 @@ 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 = SessionInbox.Item.make({ const admittedInput = SessionPending.Message.make({
type: "synthetic", type: "synthetic",
payload: { data: {
text: input.text, text: input.text,
description: input.description, description: input.description,
metadata: input.metadata, metadata: input.metadata,
}, },
delivery: input.delivery ?? "steer", delivery: input.delivery ?? "steer",
}) })
const admitted = yield* SessionInbox.admit(db, bus, { const admitted = yield* SessionPending.admit(db, bus, {
id: inputID, id: inputID,
sessionID: input.sessionID, sessionID: input.sessionID,
item: admittedInput, input: admittedInput,
}).pipe( }).pipe(
Effect.catchDefect((defect) => Effect.catchDefect((defect) =>
defect instanceof SessionInbox.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" ||
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: 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)
@@ -842,7 +839,7 @@ const layer = Layer.effect(
Effect.uninterruptible( Effect.uninterruptible(
Effect.gen(function* () { Effect.gen(function* () {
yield* execution.interrupt(sessionID) yield* execution.interrupt(sessionID)
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID) if (options?.continue && (yield* SessionPending.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
}), }),
), ),
), ),
+6 -10
View File
@@ -89,7 +89,6 @@ export type ManualInput = {
readonly session: SessionSchema.Info readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[] readonly messages: readonly SessionMessage.Info[]
readonly inputID: SessionMessage.ID readonly inputID: SessionMessage.ID
readonly started?: boolean
} }
type RequiredInput = Omit<AutoInput, "ref"> type RequiredInput = Omit<AutoInput, "ref">
@@ -103,7 +102,6 @@ type Plan = {
readonly prompt: string readonly prompt: string
readonly recent: string readonly recent: string
readonly inputID?: SessionMessage.ID readonly inputID?: SessionMessage.ID
readonly started?: boolean
} }
export type Outcome = export type Outcome =
@@ -250,13 +248,12 @@ const make = (dependencies: Dependencies) => {
return { status: "failed" as const, error: input.error } return { status: "failed" as const, error: input.error }
}) })
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) { const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
if (!plan.started) yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, { sessionID: plan.session.id,
sessionID: plan.session.id, reason: plan.reason,
reason: plan.reason, recent: plan.recent,
recent: plan.recent, inputID: plan.inputID,
inputID: plan.inputID, })
})
const chunks: string[] = [] const chunks: string[] = []
let failure: SessionError.Error | undefined let failure: SessionError.Error | undefined
@@ -411,7 +408,6 @@ const make = (dependencies: Dependencies) => {
cost: resolved.cost, cost: resolved.cost,
reason: "manual", reason: "manual",
inputID: input.inputID, inputID: input.inputID,
started: input.started,
...content, ...content,
}) })
}) })
+10 -25
View File
@@ -1,6 +1,6 @@
export * as SessionExecution from "./execution.js" export * as SessionExecution from "./execution.js"
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect" import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Bus } from "../bus.js" import { Bus } from "../bus.js"
import { LocationServiceMap } from "../location-service-map.js" import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -67,17 +67,16 @@ export const layer = Layer.effect(
const releaseOnCommit = (sessionID: SessionSchema.ID) => ({ const releaseOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => store.release(sessionID), commit: () => store.release(sessionID),
}) })
function drain( const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
sessionID: SessionSchema.ID, started: (sessionID) =>
force: boolean, reportLifecycle(
continuation?: SessionRunner.Continuation, sessionID,
): Effect.Effect<void, SessionRunner.RunError> { bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
return Effect.gen(function* () { ),
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID) const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) => return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe(
runner.drain({ sessionID, force, continuation }),
).pipe(
Effect.provide(locations.get(session.location)), Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) => Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause) Cause.hasInterruptsOnly(cause)
@@ -85,17 +84,7 @@ export const layer = Layer.effect(
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })), : Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
), ),
) )
if (result.type === "complete") return }),
return yield* drain(sessionID, false, result.continuation)
})
}
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
started: (sessionID) =>
reportLifecycle(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
),
drain: (sessionID, force) => drain(sessionID, force),
// One terminal observation per busy period, covering every coalesced drain. // One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) => settled: (sessionID, exit, reason) =>
reportLifecycle( reportLifecycle(
@@ -127,10 +116,6 @@ export const layer = Layer.effect(
}), }),
), ),
}) })
yield* bus.subscribe(SessionEvent.Moved).pipe(
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
Effect.forkScoped,
)
return Service.of({ return Service.of({
active: coordinator.active, active: coordinator.active,
-505
View File
@@ -1,505 +0,0 @@
export * as SessionInbox from "./inbox.js"
import { and, asc, eq, or } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
CompactionPayload,
Delivery,
Info,
Item,
Move,
MovePayload,
Synthetic,
SyntheticPayload,
User,
UserPayload,
} from "@opencode-ai/schema/session-inbox"
import type { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { KeyedMutex } from "../effect/keyed-mutex.js"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionInboxTable, SessionMessageTable } from "./sql.js"
type DatabaseService = Database.Interface["db"]
export {
Compaction,
CompactionPayload,
Delivery,
Info,
Item,
Move,
MovePayload,
Synthetic,
SyntheticPayload,
User,
UserPayload,
}
/**
* Which pending input `promote` may consume: "steer" promotes steers only (a step
* boundary mid-work), while "input" also allows one queued input when no steers are
* waiting (the idle boundary, where the Session picks up fresh work).
*/
export type Promotable = "input" | "steer"
const decodeUser = Schema.decodeUnknownSync(UserPayload)
const encodeUser = Schema.encodeSync(UserPayload)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticPayload)
const encodeSynthetic = Schema.encodeSync(SyntheticPayload)
const decodeCompaction = Schema.decodeUnknownSync(CompactionPayload)
const encodeCompaction = Schema.encodeSync(CompactionPayload)
const decodeMove = Schema.decodeUnknownSync(MovePayload)
const encodeMove = Schema.encodeSync(MovePayload)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
export const serialized = <A, E, R>(sessionID: SessionSchema.ID, effect: Effect.Effect<A, E, R>) =>
inboxLocks.withLock(sessionID)(effect)
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInbox.LifecycleConflict", {
id: SessionMessage.ID,
}) {}
const fromRow = (row: typeof SessionInboxTable.$inferSelect): Info => {
const base = {
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "compaction")
return Compaction.make({
...base,
type: "compaction",
payload: decodeCompaction(row.payload),
delivery: row.delivery,
})
if (row.type === "move")
return Move.make({ ...base, type: "move", payload: decodeMove(row.payload), delivery: row.delivery })
if (row.type === "user")
return User.make({
...base,
type: "user",
payload: decodeUser(row.payload),
delivery: row.delivery,
})
if (row.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
payload: decodeSynthetic(row.payload),
delivery: row.delivery,
})
throw new LifecycleConflict({ id: base.id })
}
export const find = Effect.fn("SessionInbox.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
const row = yield* db.select().from(SessionInboxTable).where(eq(SessionInboxTable.id, id)).get().pipe(Effect.orDie)
return row === undefined ? undefined : fromRow(row)
})
const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
delivery: Delivery,
) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, id))
.get()
.pipe(Effect.orDie)
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, timeCreated: message.time.created, delivery }
if (message.type === "user")
return User.make({
...base,
type: "user",
payload: decodeUser(message),
})
if (message.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
payload: decodeSynthetic(message),
})
return yield* Effect.die(new LifecycleConflict({ id }))
})
export const admit = Effect.fn("SessionInbox.admit")(function* (
db: DatabaseService,
bus: Bus.Interface,
request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly item: Item
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
}
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.item.delivery)
if (promoted !== undefined) return promoted
return yield* bus
.publish(SessionEvent.InboxEnqueued, {
inboxID: request.id,
sessionID: request.sessionID,
item: request.item,
})
.pipe(
Effect.flatMap((event) => {
const base = {
id: request.id,
sessionID: request.sessionID,
timeCreated: event.created,
}
return Effect.succeed(Info.make({ ...base, ...request.item }))
}),
Effect.catchDefect((defect) =>
find(db, request.id).pipe(
Effect.flatMap((stored) =>
stored?.type === request.item.type ? Effect.succeed(stored) : Effect.die(defect),
),
),
),
)
})
export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
) {
const admitted = yield* admit(db, bus, {
id: input.id,
sessionID: input.sessionID,
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
})
if (admitted.type === "compaction") return admitted
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
db: DatabaseService,
request: {
readonly enqueuedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly item: Item
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, request.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
const stored = yield* db
.insert(SessionInboxTable)
.values({
id: request.id,
session_id: request.sessionID,
type: request.item.type,
payload:
request.item.type === "user"
? encodeUser(request.item.payload)
: request.item.type === "synthetic"
? encodeSynthetic(request.item.payload)
: request.item.type === "compaction"
? encodeCompaction(request.item.payload)
: encodeMove(request.item.payload),
delivery: request.item.delivery,
enqueued_seq: request.enqueuedSeq,
time_created: DateTime.toEpochMillis(request.timeCreated),
})
.onConflictDoNothing()
.returning({ id: SessionInboxTable.id })
.get()
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
})
/**
* 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 projectDelivered = Effect.fn("SessionInbox.projectDelivered")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionInboxTable)
.where(and(eq(SessionInboxTable.id, input.id), eq(SessionInboxTable.session_id, input.sessionID)))
.returning()
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return fromRow(deleted)
})
export const projectCancelled = Effect.fn("SessionInbox.projectCancelled")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionInboxTable)
.where(
and(
eq(SessionInboxTable.id, input.id),
eq(SessionInboxTable.session_id, input.sessionID),
or(eq(SessionInboxTable.delivery, "queue"), eq(SessionInboxTable.delivery, "steer")),
),
)
.returning({ id: SessionInboxTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionInbox.projectDelivery")(function* (
db: DatabaseService,
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
) {
const updated = yield* db
.update(SessionInboxTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionInboxTable.id, input.id),
eq(SessionInboxTable.session_id, input.sessionID),
eq(SessionInboxTable.delivery, input.from),
),
)
.returning({ id: SessionInboxTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectDeliveryChanged = Effect.fn("SessionInbox.projectDeliveryChanged")(
(db: DatabaseService, input: PendingRef & { readonly delivery: Delivery }) =>
projectDelivery(db, {
...input,
from: input.delivery === "steer" ? "queue" : "steer",
to: input.delivery,
}),
)
export const list = Effect.fn("SessionInbox.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
.from(SessionInboxTable)
.where(eq(SessionInboxTable.session_id, sessionID))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
return rows.map(fromRow)
})
export const nextQueued = Effect.fn("SessionInbox.nextQueued")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return row ? fromRow(row) : undefined
})
export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return row ? fromRow(row) : undefined
})
/**
* Which pending rows count: "any" counts every row, while "input" means any
* item in either delivery mode.
*/
export type Scope = "any" | "input" | Delivery
export const has = Effect.fn("SessionInbox.has")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
scope: Scope,
) {
const row = yield* db
.select({ id: SessionInboxTable.id })
.from(SessionInboxTable)
.where(
and(
eq(SessionInboxTable.session_id, sessionID),
scope === "any"
? undefined
: scope === "input"
? or(eq(SessionInboxTable.delivery, "steer"), eq(SessionInboxTable.delivery, "queue"))
: eq(SessionInboxTable.delivery, scope),
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
return row !== undefined
})
export const equivalent = (input: Info, expected: { readonly sessionID: SessionSchema.ID; readonly item: Item }) => {
if (
input.type !== expected.item.type ||
input.delivery !== expected.item.delivery ||
input.sessionID !== expected.sessionID
)
return false
if (input.type === "user" && expected.item.type === "user")
return JSON.stringify(encodeUser(input.payload)) === JSON.stringify(encodeUser(expected.item.payload))
if (input.type === "synthetic" && expected.item.type === "synthetic")
return JSON.stringify(encodeSynthetic(input.payload)) === JSON.stringify(encodeSynthetic(expected.item.payload))
if (input.type === "compaction" && expected.item.type === "compaction") return true
if (input.type === "move" && expected.item.type === "move")
return JSON.stringify(encodeMove(input.payload)) === JSON.stringify(encodeMove(expected.item.payload))
return false
}
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
serialized(input.sessionID, effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionInbox.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxCancelled, {
sessionID: input.sessionID,
inboxID: input.id,
}),
),
)
export const steer = Effect.fn("SessionInbox.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "steer",
}),
),
)
export const queue = Effect.fn("SessionInbox.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "queue",
}),
),
)
const publish = Effect.fn("SessionInbox.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionInboxTable.$inferSelect>,
) {
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return bus
.publish(SessionEvent.InboxDelivered, {
sessionID,
inboxID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
return rows.length
})
/**
* Promotes pending input into visible messages and returns the promoted count.
* Steers always go first; only the "input" scope may fall through to one queued
* input, and it then collects steers that arrived during promotion.
*/
export const promote = Effect.fn("SessionInbox.promote")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
scope: Promotable,
) {
return yield* serialized(
sessionID,
Effect.gen(function* () {
const steers = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
if (steers.length > 0 || scope === "steer") {
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
}
const queued = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!queued) return 0
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
return (
promoted +
(yield* publish(db, bus, sessionID, control === -1 ? arrivedSteers : arrivedSteers.slice(0, control)))
)
}),
)
})
+6 -4
View File
@@ -109,10 +109,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.renamed": () => Effect.void, "session.renamed": () => Effect.void,
"session.deleted": () => Effect.void, "session.deleted": () => Effect.void,
"session.forked": () => Effect.void, "session.forked": () => Effect.void,
"session.inbox.delivered": () => Effect.void, "session.input.promoted": () => Effect.void,
"session.inbox.enqueued": () => Effect.void, "session.input.admitted": () => Effect.void,
"session.inbox.cancelled": () => Effect.void, "session.input.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void, "session.input.steered": () => Effect.void,
"session.input.queued": () => Effect.void,
"session.execution.started": () => Effect.void, "session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry, "session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry, "session.execution.failed": () => clearCurrentRetry,
@@ -379,6 +380,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
} }
}) })
}, },
"session.compaction.admitted": () => Effect.void,
"session.compaction.started": (event) => "session.compaction.started": (event) =>
adapter.appendMessage( adapter.appendMessage(
SessionMessage.CompactionRunning.make({ SessionMessage.CompactionRunning.make({
+545
View File
@@ -0,0 +1,545 @@
export * as SessionPending from "./pending.js"
import { and, asc, eq, or } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
Delivery,
Info,
Message,
Synthetic,
SyntheticData,
User,
UserData,
} from "@opencode-ai/schema/session-pending"
import type { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { KeyedMutex } from "../effect/keyed-mutex.js"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable, SessionPendingTable } from "./sql.js"
type DatabaseService = Database.Interface["db"]
export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData }
/**
* Which pending input `promote` may consume: "steer" promotes steers only (a step
* boundary mid-work), while "input" also allows one queued input when no steers are
* waiting (the idle boundary, where the Session picks up fresh work).
*/
export type Promotable = "input" | "steer"
const decodeUser = Schema.decodeUnknownSync(UserData)
const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
"SessionPending.LifecycleConflict",
{
id: SessionMessage.ID,
},
) {}
const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
const base = {
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "compaction") return Compaction.make({ ...base, type: "compaction" })
if (!row.delivery) throw new LifecycleConflict({ id: base.id })
if (row.type === "user")
return User.make({
...base,
type: "user",
data: decodeUser(row.data),
delivery: row.delivery,
})
if (row.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
data: decodeSynthetic(row.data),
delivery: row.delivery,
})
throw new LifecycleConflict({ id: base.id })
}
export const find = Effect.fn("SessionPending.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
const row = yield* db
.select()
.from(SessionPendingTable)
.where(eq(SessionPendingTable.id, id))
.get()
.pipe(Effect.orDie)
return row === undefined ? undefined : fromRow(row)
})
export const compaction = Effect.fn("SessionPending.compaction")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.type, "compaction")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const entry = fromRow(row)
return entry.type === "compaction" ? entry : undefined
})
const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
delivery: Delivery,
) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, id))
.get()
.pipe(Effect.orDie)
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, timeCreated: message.time.created, delivery }
if (message.type === "user")
return User.make({
...base,
type: "user",
data: decodeUser(message),
})
if (message.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
data: decodeSynthetic(message),
})
return yield* Effect.die(new LifecycleConflict({ id }))
})
export const admit = Effect.fn("SessionPending.admit")(function* (
db: DatabaseService,
bus: Bus.Interface,
request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly input: Message
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
}
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.input.delivery)
if (promoted !== undefined) return promoted
return yield* bus
.publish(SessionEvent.InputAdmitted, {
inputID: request.id,
sessionID: request.sessionID,
input: request.input,
})
.pipe(
Effect.flatMap((event) => {
const base = {
id: request.id,
sessionID: request.sessionID,
timeCreated: event.created,
}
return Effect.succeed(
request.input.type === "user"
? User.make({ ...base, ...request.input })
: Synthetic.make({ ...base, ...request.input }),
)
}),
Effect.catchDefect((defect) =>
find(db, request.id).pipe(
Effect.flatMap((stored) =>
stored?.type === request.input.type ? Effect.succeed(stored) : Effect.die(defect),
),
),
),
)
})
export const admitCompaction = Effect.fn("SessionPending.admitCompaction")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
return yield* inboxLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}
const pending = yield* compaction(db, input.sessionID)
if (pending) return pending
return yield* bus
.publish(SessionEvent.Compaction.Admitted, {
inputID: input.id,
sessionID: input.sessionID,
})
.pipe(
Effect.flatMap((event) => {
if (event.durable === undefined)
return Effect.die(new Error("Compaction admission event is missing aggregate sequence"))
return compaction(db, input.sessionID).pipe(
Effect.flatMap((stored) =>
stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
),
)
}),
Effect.catchDefect((defect) =>
compaction(db, input.sessionID).pipe(
Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
),
),
)
}),
)
})
export const projectAdmitted = Effect.fn("SessionPending.projectAdmitted")(function* (
db: DatabaseService,
request: {
readonly admittedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly input: Message
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, request.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
const stored = yield* db
.insert(SessionPendingTable)
.values({
id: request.id,
session_id: request.sessionID,
type: request.input.type,
data: request.input.type === "user" ? encodeUser(request.input.data) : encodeSynthetic(request.input.data),
delivery: request.input.delivery,
admitted_seq: request.admittedSeq,
time_created: DateTime.toEpochMillis(request.timeCreated),
})
.onConflictDoNothing()
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
})
export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompactionAdmitted")(function* (
db: DatabaseService,
input: {
readonly admittedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = yield* db
.insert(SessionPendingTable)
.values({
id: input.id,
session_id: input.sessionID,
type: "compaction",
data: {},
admitted_seq: input.admittedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.onConflictDoNothing()
.returning()
.get()
.pipe(Effect.orDie)
if (stored) {
const entry = fromRow(stored)
return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id }))
}
const pending = yield* compaction(db, input.sessionID)
if (pending) return pending
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
/**
* 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,
input: PendingRef,
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
.returning()
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = fromRow(deleted)
if (stored.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored
})
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
db: DatabaseService,
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
) {
const updated = yield* db
.update(SessionPendingTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
eq(SessionPendingTable.delivery, input.from),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectSteered = Effect.fn("SessionPending.projectSteered")((db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
)
export const projectQueued = Effect.fn("SessionPending.projectQueued")((db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
)
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID },
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, input.sessionID), eq(SessionPendingTable.type, "compaction")))
.returning()
.get()
.pipe(Effect.orDie)
if (deleted) {
const stored = fromRow(deleted)
return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
}
return undefined
})
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)
})
/**
* Which pending rows count: "any" counts every row including compaction, while
* delivery scopes are blocked behind a pending compaction barrier. "input" means
* any model-facing input, steered or queued.
*/
export type Scope = "any" | "input" | Delivery
export const has = Effect.fn("SessionPending.has")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
scope: Scope,
) {
if (scope !== "any" && (yield* compaction(db, sessionID))) return false
const row = yield* db
.select({ id: SessionPendingTable.id })
.from(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.session_id, sessionID),
scope === "any"
? undefined
: scope === "input"
? or(eq(SessionPendingTable.delivery, "steer"), eq(SessionPendingTable.delivery, "queue"))
: eq(SessionPendingTable.delivery, scope),
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
return row !== undefined
})
export const equivalent = (
input: User | Synthetic,
expected: { readonly sessionID: SessionSchema.ID; readonly input: Message },
) => {
if (
input.type !== expected.input.type ||
input.delivery !== expected.input.delivery ||
input.sessionID !== expected.sessionID
)
return false
if (input.type === "user" && expected.input.type === "user")
return JSON.stringify(encodeUser(input.data)) === JSON.stringify(encodeUser(expected.input.data))
if (input.type === "synthetic" && expected.input.type === "synthetic")
return JSON.stringify(encodeSynthetic(input.data)) === JSON.stringify(encodeSynthetic(expected.input.data))
return false
}
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputCancelled, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputSteered, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputQueued, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
) {
if (yield* compaction(db, sessionID)) return 0
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return bus
.publish(SessionEvent.InputPromoted, {
sessionID,
inputID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
return rows.length
})
/**
* Promotes pending input into visible messages and returns the promoted count.
* Steers always go first; only the "input" scope may fall through to one queued
* input, and it then collects steers that arrived during promotion.
*/
export const promote = Effect.fn("SessionPending.promote")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
scope: Promotable,
) {
return yield* inboxLocks.withLock(sessionID)(
Effect.gen(function* () {
if (yield* compaction(db, sessionID)) return 0
const steers = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
if (steers.length > 0 || scope === "steer") return yield* publish(db, bus, sessionID, steers)
const queued = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!queued) return 0
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return promoted + (yield* publish(db, bus, sessionID, arrivedSteers))
}),
)
})
+50 -75
View File
@@ -1,8 +1,7 @@
export * as SessionProjector from "./projector.js" export * as SessionProjector from "./projector.js"
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm" import { and, asc, desc, eq, gt, gte, lt, lte, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema, Stream } from "effect" import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { Database } from "../database/database.js" import { Database } from "../database/database.js"
import { Bus } from "../bus.js" import { Bus } from "../bus.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -11,15 +10,12 @@ import { Model } from "../model.js"
import { SessionEvent } from "./event.js" import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js" import { SessionMessage } from "./message.js"
import { SessionMessageUpdater } from "./message-updater.js" import { SessionMessageUpdater } from "./message-updater.js"
import { SessionInbox } from "./inbox.js" import { SessionPending } from "./pending.js"
import { Workspace } from "../workspace.js" import { Workspace } from "../workspace.js"
import { InstructionState } from "./instruction-state.js" import { InstructionState } from "./instruction-state.js"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js" import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql.js"
import { Slug } from "../util/slug.js" import { Slug } from "../util/slug.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { Event } from "@opencode-ai/schema/project-directories"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, RelativePath } from "../schema.js" import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js" import type { SessionSchema } from "./schema.js"
@@ -439,47 +435,6 @@ const layer = Layer.effectDiscard(
yield* InstructionState.reset(db, event.data.sessionID) yield* InstructionState.reset(db, event.data.sessionID)
}), }),
) )
// Sessions whose ownership came from the directory's previous resolution
// follow its new identity. Location, transcript, instructions, and recency
// are untouched: the session did not move, its directory got identified.
yield* bus.project(Event.Resolved, (event) =>
Effect.gen(function* () {
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
if (stale.length === 0) return
const rows = yield* db
.select({ id: SessionTable.id, directory: SessionTable.directory })
.from(SessionTable)
.where(
and(
inArray(SessionTable.project_id, stale),
// Lexicographic range narrows the scan to prefix neighbors without
// LIKE escaping; FSUtil.contains below decides containment exactly.
gte(SessionTable.directory, event.data.directory),
lte(SessionTable.directory, AbsolutePath.make(event.data.directory + "\uffff")),
),
)
.all()
.pipe(Effect.orDie)
yield* Effect.forEach(
rows,
(row) => {
if (!FSUtil.contains(event.data.directory, row.directory)) return Effect.void
return db
.update(SessionTable)
.set({
project_id: event.data.projectID,
path: RelativePath.make(path.relative(event.data.directory, row.directory).replaceAll("\\", "/")),
// Self-assignment suppresses the column's $onUpdate: adoption is not activity.
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, row.id))
.run()
.pipe(Effect.orDie)
},
{ discard: true },
)
}),
)
yield* bus.project(SessionEvent.Deleted, (event) => yield* bus.project(SessionEvent.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
) )
@@ -515,15 +470,14 @@ const layer = Layer.effectDiscard(
) )
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data)) yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event)) yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* bus.project(SessionEvent.InboxDelivered, (event) => yield* bus.project(SessionEvent.InputPromoted, (event) =>
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* SessionInbox.projectDelivered(db, { const input = yield* SessionPending.projectPromoted(db, {
id: event.data.inboxID, id: event.data.inputID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
}) })
if (input.type === "compaction" || input.type === "move") return
yield* insertMessage( yield* insertMessage(
db, db,
event, event,
@@ -531,33 +485,33 @@ const layer = Layer.effectDiscard(
? { ? {
id: input.id, id: input.id,
type: "user", type: "user",
metadata: input.payload.metadata, metadata: input.data.metadata,
text: input.payload.text, text: input.data.text,
files: input.payload.files, files: input.data.files,
agents: input.payload.agents, agents: input.data.agents,
skills: input.payload.skills, skills: input.data.skills,
time: { created: event.created }, time: { created: event.created },
} }
: { : {
id: input.id, id: input.id,
type: "synthetic", type: "synthetic",
text: input.payload.text, text: input.data.text,
description: input.payload.description, description: input.data.description,
metadata: input.payload.metadata, metadata: input.data.metadata,
time: { created: event.created }, time: { created: event.created },
}, },
) )
}), }),
) )
yield* bus.project(SessionEvent.InboxEnqueued, (event) => yield* bus.project(SessionEvent.InputAdmitted, (event) =>
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* SessionInbox.projectAdmitted(db, { yield* SessionPending.projectAdmitted(db, {
enqueuedSeq: event.durable.seq, admittedSeq: event.durable.seq,
id: event.data.inboxID, id: event.data.inputID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
item: event.data.item, input: event.data.input,
timeCreated: event.created, timeCreated: event.created,
}) })
yield* db yield* db
@@ -568,17 +522,34 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
yield* bus.project(SessionEvent.InboxCancelled, (event) => yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionInbox.projectCancelled(db, { SessionPending.projectCancelled(db, {
id: event.data.inboxID, id: event.data.inputID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
}), }),
) )
yield* bus.project(SessionEvent.InboxDeliveryChanged, (event) => yield* bus.project(SessionEvent.InputSteered, (event) =>
SessionInbox.projectDeliveryChanged(db, { SessionPending.projectSteered(db, {
id: event.data.inboxID, id: event.data.inputID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
delivery: event.data.delivery, }),
)
yield* bus.project(SessionEvent.InputQueued, (event) =>
SessionPending.projectQueued(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionPending.projectCompactionAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
timeCreated: event.created,
})
}), }),
) )
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event)) yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
@@ -625,6 +596,8 @@ const layer = Layer.effectDiscard(
yield* InstructionState.advanceEpoch(db, event.data.sessionID, event.durable.seq) yield* InstructionState.advanceEpoch(db, event.data.sessionID, event.durable.seq)
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")
yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
}), }),
) )
yield* bus.project(SessionEvent.Compaction.Failed, (event) => yield* bus.project(SessionEvent.Compaction.Failed, (event) =>
@@ -632,6 +605,8 @@ const layer = Layer.effectDiscard(
yield* run(db, event) yield* run(db, event)
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")
yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
}), }),
) )
yield* bus.project(SessionEvent.RevertEvent.Staged, (event) => yield* bus.project(SessionEvent.RevertEvent.Staged, (event) =>
@@ -675,11 +650,11 @@ const layer = Layer.effectDiscard(
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* db yield* db
.delete(SessionInboxTable) .delete(SessionPendingTable)
.where( .where(
and( and(
eq(SessionInboxTable.session_id, event.data.sessionID), eq(SessionPendingTable.session_id, event.data.sessionID),
gte(SessionInboxTable.enqueued_seq, boundary.seq), gte(SessionPendingTable.admitted_seq, boundary.seq),
), ),
) )
.run() .run()
+2 -9
View File
@@ -16,20 +16,13 @@ export type RunError =
| UserInterruptedError | UserInterruptedError
| Instructions.InitializationBlocked | Instructions.InitializationBlocked
export type Continuation = { readonly step: number }
export type DrainResult =
| { readonly type: "complete" }
| { readonly type: "moved"; readonly continuation?: Continuation }
/** Runs one local continuation from already-recorded Session history. */ /** Runs one local continuation from already-recorded Session history. */
export interface Interface { export interface Interface {
/** Drains eligible durable work, returning transient state when execution must continue at a new Location. */ /** Drains eligible durable work. Explicit runs make one model call even when no work is eligible. */
readonly drain: (input: { readonly drain: (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean
readonly continuation?: Continuation }) => Effect.Effect<void, RunError>
}) => Effect.Effect<DrainResult, RunError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunner") {} export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunner") {}
+35 -93
View File
@@ -17,13 +17,13 @@ import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js" import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js" import { SessionContext } from "../context.js"
import { SessionEvent } from "../event.js" import { SessionEvent } from "../event.js"
import { SessionInbox } from "../inbox.js" import { SessionPending } from "../pending.js"
import { SessionModelRequest } from "../model-request.js" import { SessionModelRequest } from "../model-request.js"
import { SessionMessage } from "../message.js" import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js" import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js" import { SessionStore } from "../store.js"
import { SessionTitle } from "../title.js" import { SessionTitle } from "../title.js"
import { Service, type Continuation } from "./index.js" import { Service } from "./index.js"
import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event.js" import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event.js"
import { Snapshot } from "../../snapshot.js" import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -113,8 +113,8 @@ const layer = Layer.effect(
const compaction = yield* SessionCompaction.Service const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service const title = yield* SessionTitle.Service
const toolOutput = yield* ToolOutput.Service const toolOutput = yield* ToolOutput.Service
// Title generation starts once input is visible and must not delay model execution. // Title generation is a side effect of a successful step; it must not delay continuation.
// The in-flight set coalesces overlapping prompts while title presence records success durably. // The in-flight set coalesces overlapping steps while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>() const titlesRunning = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>() const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
/** /**
@@ -124,47 +124,29 @@ const layer = Layer.effect(
const drain = Effect.fn("SessionRunner.drain")(function* (input: { const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean
readonly continuation?: Continuation
}) { }) {
let force = input.force if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "any"))) return
let continuation = input.continuation
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
return { type: "complete" as const }
yield* settleStaleToolCalls(input.sessionID) yield* settleStaleToolCalls(input.sessionID)
while (true) { yield* runPendingCompaction(input.sessionID)
if (yield* runPendingCompaction(input.sessionID)) { if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "input"))) return
force = false do {
continue yield* runSteps(input.sessionID)
} } while (yield* SessionPending.has(db, input.sessionID, "input"))
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
return { type: "complete" as const }
const result = yield* runSteps(input.sessionID, continuation)
if (result.type === "moved") return result
force = false
continuation = undefined
}
}) })
/** /**
* Runs logical steps until no tool result or newly admitted steer requires another * Runs logical steps until no tool result or newly admitted steer requires another
* model call. Queued inputs remain pending until the current model work reaches idle. * model call. Queued inputs remain pending until the current model work reaches idle.
*/ */
const runSteps = Effect.fn("SessionRunner.runSteps")(function* ( const runSteps = Effect.fn("SessionRunner.runSteps")(function* (sessionID: SessionSchema.ID) {
sessionID: SessionSchema.ID,
continuation?: Continuation,
) {
// Fresh work may promote queued input; later steps absorb steers only. // Fresh work may promote queued input; later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input" let promotable: SessionPending.Promotable = "input"
let step = continuation?.step ?? 1 let step = 1
let next = continuation
while (true) { while (true) {
if (yield* runPendingCompaction(sessionID)) continue
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
const result = yield* runStep(sessionID, promotable, step) const result = yield* runStep(sessionID, promotable, step)
next = result.needsContinuation ? { step: result.step + 1 } : undefined if (step === 1) yield* startTitle(sessionID)
if (!result.needsContinuation && !(yield* SessionInbox.has(db, sessionID, "steer"))) yield* runPendingCompaction(sessionID)
return { type: "complete" as const } if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer" promotable = "steer"
step = result.step + 1 step = result.step + 1
} }
@@ -173,7 +155,7 @@ const layer = Layer.effect(
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */ /** Completes one logical model step, transparently retrying or rebuilding after compaction. */
const runStep = Effect.fnUntraced(function* ( const runStep = Effect.fnUntraced(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable, promotable: SessionPending.Promotable,
step: number, step: number,
) { ) {
// Minting message identity before any attempt lets retries resume the same durable // Minting message identity before any attempt lets retries resume the same durable
@@ -201,7 +183,7 @@ const layer = Layer.effect(
.pipe(Effect.andThen(Effect.fail(failure.cause))), .pipe(Effect.andThen(Effect.fail(failure.cause))),
), ),
) )
let currentPromotable: SessionInbox.Promotable | undefined = promotable let currentPromotable: SessionPending.Promotable | undefined = promotable
let currentStep = step let currentStep = step
// Overflow recovery is one-shot: a call after recovery must not recover another overflow. // Overflow recovery is one-shot: a call after recovery must not recover another overflow.
let recoverOverflow = true let recoverOverflow = true
@@ -244,7 +226,7 @@ const layer = Layer.effect(
*/ */
const callModel = Effect.fn("SessionRunner.callModel")(function* ( const callModel = Effect.fn("SessionRunner.callModel")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable | undefined, promotable: SessionPending.Promotable | undefined,
step: number, step: number,
recoverOverflow: boolean, recoverOverflow: boolean,
assistantMessageID: SessionMessage.ID, assistantMessageID: SessionMessage.ID,
@@ -253,8 +235,7 @@ const layer = Layer.effect(
// Establish what the model knows before admitting what the user said, so // Establish what the model knows before admitting what the user said, so
// a blocked first step leaves pending inputs untouched. // a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id) yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0 const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
if (promoted > 0) yield* startTitle(sessionID)
// Promoted input opens a fresh step allowance. // Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step const currentStep = promoted > 0 ? 1 : step
const loaded = yield* context.load(selected) const loaded = yield* context.load(selected)
@@ -264,7 +245,7 @@ const layer = Layer.effect(
// Make room: history must fit the context window before the call. A pending manual // Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps. // compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost } const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost }
if (compaction.required(compactionInput)) { if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput) const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed") if (compacted.status === "completed")
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false }) return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
@@ -501,75 +482,36 @@ 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* SessionPending.compaction(db, sessionID)
if (!pending) return
const session = yield* getSession(sessionID)
return yield* Effect.uninterruptibleMask((restore) => return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () { Effect.gen(function* () {
const pending = yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const selected =
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
if (selected?.type !== "compaction") return
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
[SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: selected.id }],
])
return selected
}),
)
if (pending?.type !== "compaction") return false
const session = yield* getSession(sessionID)
const compacted = yield* restore( const compacted = yield* restore(
Effect.gen(function* () { Effect.gen(function* () {
return yield* compaction.compactManual({ return yield* compaction.compactManual({
session, session,
messages: yield* store.context(sessionID), messages: yield* store.context(sessionID),
inputID: pending.id, inputID: pending.id,
started: true,
}) })
}), }),
).pipe(Effect.exit) ).pipe(Effect.exit)
if (Exit.isSuccess(compacted)) return true if (Exit.isSuccess(compacted)) return
yield* bus.publish(SessionEvent.Compaction.Failed, { const unsettled = yield* SessionPending.compaction(db, sessionID)
sessionID, if (unsettled)
reason: "manual", yield* bus.publish(SessionEvent.Compaction.Failed, {
error: Cause.hasInterruptsOnly(compacted.cause) sessionID,
? { type: "aborted", message: "Compaction cancelled" } reason: "manual",
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) }, error: Cause.hasInterruptsOnly(compacted.cause)
inputID: pending.id, ? { type: "aborted", message: "Compaction cancelled" }
}) : { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: unsettled.id,
})
return yield* Effect.failCause(compacted.cause) return yield* Effect.failCause(compacted.cause)
}), }),
) )
}) })
const runPendingMove = Effect.fn("SessionRunner.runPendingMove")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
) {
return yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const pending =
(yield* SessionInbox.nextSteer(db, sessionID)) ??
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
if (pending?.type !== "move") return false
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
[
SessionEvent.Moved,
{
sessionID,
location: pending.payload.location,
projectID: pending.payload.projectID,
subpath: pending.payload.subpath,
},
],
])
return true
}),
)
})
/** Closes stale tool calls left active by an earlier interrupted drain. */ /** Closes stale tool calls left active by an earlier interrupted drain. */
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* ( const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
+1 -3
View File
@@ -48,12 +48,10 @@ export const schedule = (
assistantMessageID: () => SessionMessage.ID, assistantMessageID: () => SessionMessage.ID,
) => ) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe( Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<RetryableFailure>(), Schedule.setInputType<RetryableFailure>(),
Schedule.modifyDelay(({ input: failure, duration: delay }) => { Schedule.modifyDelay(({ input: failure, duration: delay }) => {
const minimum = retryAfter(failure) const minimum = retryAfter(failure)
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)) return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
}), }),
Schedule.tap((metadata) => Schedule.tap((metadata) =>
bus.publish(SessionEvent.RetryScheduled, { bus.publish(SessionEvent.RetryScheduled, {
@@ -1,6 +1,5 @@
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai" import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import { Option, Schema } from "effect" import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
import type { Model } from "../../model.js" import type { Model } from "../../model.js"
import { SessionMessage } from "../message.js" import { SessionMessage } from "../message.js"
import type { FileAttachment } from "@opencode-ai/schema/prompt" import type { FileAttachment } from "@opencode-ai/schema/prompt"
@@ -15,17 +14,6 @@ const media = (file: FileAttachment): ContentPart => ({
metadata: file.description === undefined ? undefined : { description: file.description }, metadata: file.description === undefined ? undefined : { description: file.description },
}) })
const attachmentLocation = (file: FileAttachment) => {
if (file.source.type !== "uri") return undefined
const url = URL.parse(file.source.uri)
if (url?.protocol !== "file:") return undefined
try {
return fileURLToPath(url)
} catch {
return undefined
}
}
const textAttachment = (file: FileAttachment): ContentPart => ({ const textAttachment = (file: FileAttachment): ContentPart => ({
type: "text", type: "text",
text: `\n\n${[ text: `\n\n${[
@@ -48,7 +36,7 @@ const textAttachment = (file: FileAttachment): ContentPart => ({
const directoryAttachment = (file: FileAttachment): ContentPart => ({ const directoryAttachment = (file: FileAttachment): ContentPart => ({
type: "text", type: "text",
text: `\n\n${[ text: `\n\n${[
`Attached directory: ${attachmentLocation(file) ?? file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`, `Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
file.description === undefined ? undefined : `Description: ${file.description}`, file.description === undefined ? undefined : `Description: ${file.description}`,
file.data.length === 0 ? undefined : "", file.data.length === 0 ? undefined : "",
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"), file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
@@ -67,10 +55,7 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
const attachmentContent = (file: FileAttachment): ContentPart[] => { const attachmentContent = (file: FileAttachment): ContentPart[] => {
if (file.mime === "text/plain") return [textAttachment(file)] if (file.mime === "text/plain") return [textAttachment(file)]
if (file.mime === "application/x-directory") return [directoryAttachment(file)] if (file.mime === "application/x-directory") return [directoryAttachment(file)]
if (imageMimes.has(file.mime)) { if (imageMimes.has(file.mime)) return [media(file)]
const location = attachmentLocation(file)
return [...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]), media(file)]
}
return [] return []
} }

Some files were not shown because too many files have changed in this diff Show More