Apply PR #40845: feat(app): redesign non-modal settings

This commit is contained in:
opencode-agent[bot] 2026-08-07 13:12:27 +00:00
commit cd44746a5e
109 changed files with 6872 additions and 4666 deletions

1
.gitattributes vendored
View file

@ -1,2 +1,3 @@
packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
packages/core/src/**/*.txt text eol=lf

37
.github/workflows/deploy-www.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: deploy-www
on:
push:
branches:
- dev
- v2
workflow_dispatch:
concurrency:
group: deploy-www-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/www
run: bun run build
env:
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
- name: Deploy
working-directory: packages/www
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View file

@ -6,6 +6,7 @@ on:
branches:
- ci
- dev
- v2
- beta
- fix/npm-native-binary-install
- snapshot-*
@ -31,6 +32,9 @@ permissions:
contents: write
packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
jobs:
version:
runs-on: blacksmith-4vcpu-ubuntu-2404
@ -86,11 +90,18 @@ jobs:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build
- name: Build legacy CLI
if: github.ref_name != 'v2'
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
- name: Build preview CLI
id: build
run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@ -98,6 +109,7 @@ jobs:
GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
with:
name: opencode-cli
path: |
@ -105,6 +117,7 @@ jobs:
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
@ -117,12 +130,61 @@ jobs:
outputs:
version: ${{ needs.version.outputs.version }}
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode'
strategy:
fail-fast: false
matrix:
settings:
- target: linux-arm64
host: blacksmith-4vcpu-ubuntu-2404-arm
- target: linux-x64
host: blacksmith-4vcpu-ubuntu-2404
- target: darwin-arm64
host: macos-26
- target: windows-arm64
host: blacksmith-4vcpu-windows-2025
- target: windows-x64
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
with:
install-flags: --os=* --cpu=*
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Build
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
- name: Verify service lifecycle
if: matrix.settings.target != 'windows-arm64'
working-directory: packages/cli
run: bun run script/service-smoke.ts --node
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-node-cli-${{ matrix.settings.target }}
path: packages/cli/dist/node/cli-node-*
if-no-files-found: error
sign-cli-windows:
needs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@ -220,7 +282,7 @@ jobs:
build-electron:
needs:
- version
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@ -405,6 +467,7 @@ jobs:
needs:
- version
- build-cli
- build-node-cli
- sign-cli-windows
- build-electron
if: always() && !failure() && !cancelled()
@ -433,16 +496,19 @@ jobs:
registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
@ -452,6 +518,12 @@ jobs:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: opencode-node-cli-*
path: packages/cli/dist/node
merge-multiple: true
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.version.outputs.release
with:

View file

@ -4,6 +4,7 @@ on:
push:
branches:
- dev
- v2
pull_request:
workflow_dispatch:
@ -69,18 +70,41 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
working-directory: packages/cli
run: |
bun run script/build.ts --single --skip-install
bun run script/service-smoke.ts
- name: Setup Node build runtime
if: always()
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Verify Node build
if: always()
timeout-minutes: 15
working-directory: packages/cli
run: |
bun run script/build-node.ts --single --skip-install --outdir=dist/node
bun run script/service-smoke.ts --node
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
run: bun run check:generated
- name: Run HttpApi exerciser gates
- name: Check generated documentation
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
working-directory: packages/www
run: bun run check:generated
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
strategy:
fail-fast: false
matrix:

View file

@ -2,9 +2,9 @@ name: typecheck
on:
push:
branches: [dev]
branches: [dev, v2]
pull_request:
branches: [dev]
branches: [dev, v2]
workflow_dispatch:
jobs:

3
.gitignore vendored
View file

@ -11,8 +11,10 @@ node_modules
playground
tmp
dist
dist-node
ts-dist
.turbo
.typecheck-profiles
**/.serena
.serena/
**/.omo
@ -24,6 +26,7 @@ Session.vim
a.out
target
.scripts
.cache
.direnv/
# Local dev files

View file

@ -0,0 +1,13 @@
import type { Context } from "../../../packages/plugin/src/tui/context"
export default {
id: "test.tui-discovery-smoke",
setup(_context: Context) {
// context.ui.toast.show({
// title: "TUI plugin discovery works",
// message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
// variant: "success",
// duration: 30_000,
// })
},
}

View file

@ -0,0 +1,68 @@
---
name: ideal-pseudocode
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
---
# Ideal Pseudocode
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
## Loop
One function per round. Never touch code before the user picks a direction.
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
8. **Offer the next target** and stop.
## Style
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
- Prefer early return and `if` chains for control flow; matchers are for producing values.
- Compare names against the question each caller is actually asking; rename toward the domain noun.
## Example round
Every round should read like this (abridged from a real one, on an Effect step-runner):
> `runStep` it is. The ideal pseudocode:
>
> ```ts
> // One logical step: one settled model call, however many attempts it takes.
> // An attempt can end without settling in two ways:
> // transient provider failure -> retry the same call, same assistant message
> // compaction rewrote history -> rebuild the request and call again
> function runStep(session, promotable, step) {
> while (true) {
> const result = callModel(session, promotable, step)
>
> if (result.completed) return result
>
> if (result.retryable) {
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
> promotable = none // never re-promote on a second attempt
> continue
> }
>
> // compaction restarted the step: fresh request from rewritten history
> promotable = none
> step = result.step
> }
> }
> ```
>
> Comparing against the real thing, three deltas:
>
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
>
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
>
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?

View file

@ -0,0 +1,254 @@
---
name: opencode-drive
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
---
# OpenCode Drive
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
on changes).
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
stops all processes, and cleans up all artifacts.
# Prepare The Environment
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
```bash
artifacts=$(opencode-drive init --name demo)
cp -R ./fixtures/home/. "$artifacts/"
cp -R ./fixtures/project/. "$artifacts/files/"
opencode-drive start --name demo --dev ~/projects/opencode
```
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
# Scripted usage
You can write scripts that walk through entire flows, and gives you full access to controlling
the backend too. See examples of the script API at the bottom of this file.
After creating or editing a script, always typecheck it before running. Never skip this step:
```bash
opencode-drive check ./reproduce-stale-exploring-empty.ts
```
Run it by passing `--script` to start:
```bash
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
```
It will output information about the run, including paths to log files which you can read
to inspect what happened. If you need to dig into failures that aren't clear, read those log
files. If the script is unsuccessful, automatically fix the script and run it again.
Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
```ts
import { defineScript } from "opencode-drive"
export default defineScript({
async setup({ fs, config }) {
config.autoupdate = false
await fs.writeFile("src/example.ts", "export const value = 1\n")
},
async run({ ui, llm }) {
await ui.submit("Open src/example.ts")
await llm.send(llm.text("The file exports `value`."))
await ui.waitFor("The file exports `value`.")
},
})
```
`setup` receives the current OpenCode config object, which starts from the
default drive config unless the prepared instance already has one. When a script
needs custom config, mutate this `config` parameter instead of generating and
writing a new config object from scratch, so the script keeps the default
provider/model settings unless it intentionally changes them.
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
Use `launch: "manual"` when the script needs to launch the server and every TUI
itself (this is extremely rare, do not use this unless explicitly asked). In this
mode `ui` is typed as `null`; call `server.launch()` exactly
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
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.
`await llm.send(...)` waits for the next request and resolves after OpenCode
acknowledges its complete response. `llm.queue(...)` declares responses in
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
`finish`, and `disconnect`. A normal response receives `finish("stop")`
automatically unless it yields or queues an explicit terminal event.
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
15-character target varied by plus or minus 5 per chunk.
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
delay between any two outputs.
Use `llm.serve` for an ongoing typed response generator:
```ts
llm.serve(async function* (request, index) {
yield llm.reasoning(`Handling request ${index + 1}`)
yield llm.text(`Received ${request.id}`)
yield llm.finish("stop")
})
```
The backend connection, response cleanup, cancellation, and recording
completion are automatic.
You can see some example scripts here:
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
## Prune
- `prune` removes artifact directories. These are always cleaned up after running a script
successfully, but leftover on failed runs. Always call this if a script fails.
```bash
opencode-drive prune --name demo
// --force cleans up all artifcat directories
opencode-dirve prune --force
```
# Live interaction usage
- Always give headless instances a unique `--name`. Visible instances may omit it.
- A normal headless `start` detaches automatically and returns after the instance is ready.
- Do not add `&`; the long-running owner already runs in the background.
- Configure simulated model responses after startup when needed.
- Send ordered UI commands with `send`.
- Always stop the instance when finished.
```bash
opencode-drive start --name demo
opencode-drive send --name demo \
--command.ui.type '{"text":"Explain this project"}' \
--command.ui.enter
opencode-drive stop --name demo
```
## Send UI Commands
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
- Combine typing and Enter in one command when submitting a prompt.
- JSON-valued commands require one JSON argument.
- Multiple command flags execute from left to right.
Commands:
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
- `--command.ui.enter` presses Enter. Arguments: none.
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
```bash
opencode-drive send --name demo \
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
--command.ui.enter
opencode-drive send --name demo \
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
opencode-drive send --name demo \
--command.ui.arrow '{"direction":"down"}'
opencode-drive send --name demo \
--command.ui.focus '{"target":12}'
opencode-drive send --name demo \
--command.ui.click '{"target":12,"x":4,"y":1}'
opencode-drive send --name demo \
--command.ui.matches '{"text":"OpenCode"}'
```
To read the UI state and see information about interactable elements, use the `ui.state` command:
```bash
opencode-drive send --name demo --command.ui.state
```
## Configure LLM Responses
- `responses` controls what the LLM responds with
- Only use this if you are wanting to reproduce an exact type of response
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
- `--tools` limits generated tool calls to names offered by OpenCode.
```bash
opencode-drive responses --name demo \
--types text,reasoning,diff,tool \
--tools write,apply_patch
opencode-drive responses --name demo \
--types tool \
--tools read,glob,grep
```
## Inspect The UI
- `ui.state` prints focus and interactive element metadata as JSON.
- `ui.matches` checks for literal, case-sensitive screen text.
- `screenshot` prints the generated image path.
```bash
opencode-drive screenshot --name demo
```
## Lifecycle
- `stop` waits for recording export and owner cleanup before returning.
```bash
opencode-drive stop --name demo
```
# Record The UI
- Start with `--record` to capture a headless instance from its first rendered frame.
- `stop` finishes the recording, exports an MP4, and prints its path.
```bash
opencode-drive start --name demo --record
opencode-drive send --name demo \
--command.ui.type '{"text":"Show me the current architecture"}' \
--command.ui.enter
opencode-drive stop --name demo
```
# Artifacts dir
- `dir` prints the artifact directory for the instance.
```bash
opencode-drive dir --name demo
```

View file

@ -0,0 +1,31 @@
---
name: sample-skill
description: Use when the user says sample skill, skill demo, or asks how an opencode SKILL.md should be structured; demonstrates a tiny project-local skill with practical assistant workflow guidance.
---
# Sample Skill
This is a minimal project-local opencode skill. It exists as a reference for how a skill is structured and as a tiny reusable workflow the assistant can load when the user asks for a skill example.
## When To Use
- Use when the user asks for a sample skill or skill template.
- Use when demonstrating the required `SKILL.md` frontmatter and body format.
- Do not use for unrelated coding tasks just because a skill exists.
## Workflow
- Confirm the specific outcome the user wants if the request is ambiguous.
- Inspect the relevant files before changing anything.
- Make the smallest correct change.
- Verify the result with a focused read, typecheck, test, or other lightweight check when available.
- Summarize the changed files and any required restart or reload step.
## Example Response Style
When this skill is relevant, keep responses direct and actionable:
```text
I created a project-local skill at .opencode/skills/sample-skill/SKILL.md.
Restart opencode for the new skill to be discovered by future sessions.
```

View file

@ -1,9 +1,16 @@
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- 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.
- 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 `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
## Live V2 TUI Testing
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
@ -24,6 +31,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
- Keep things in one function unless composable or reusable
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Use Bun APIs when possible, like `Bun.file()`
@ -59,6 +67,7 @@ const { a, b } = obj
### Imports
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
- Never use type-position `import("...")` references such as `Schema.declare<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>`. Only when two imports genuinely collide on a name and no other option exists, an aliased type import (`import type { Plugin as PluginDefinition } from "..."`) is permitted as a last resort — still strongly preferred not to.
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
- Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading.
@ -150,12 +159,15 @@ const table = sqliteTable("session", {
## V2 Session Core
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.

View file

@ -1,225 +0,0 @@
# OpenCode Session Runtime
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
## Language
**System Context**:
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
_Avoid_: System prompt
**Session History**:
The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs.
_Avoid_: Session Context
**Context Source**:
One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources.
_Avoid_: Prompt fragment
**System Context Registry**:
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
**Mid-Conversation System Message**:
A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**.
_Avoid_: System update, system notification, raw text diff
**Context Epoch**:
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
**Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**.
_Avoid_: Live system prompt
**Context Snapshot**:
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
**Unavailable Context**:
An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
**Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
**Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Provider Turn**:
One request to a model provider and the response projected from that request.
**Session Drain**:
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
**Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**Native Continuation Metadata**:
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
**OpenCode Client**:
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
_Avoid_: Remote client
**SDK Contract IR**:
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
**Embedded OpenCode**:
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
_Avoid_: Local implementation
**Page**:
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
_Avoid_: Response envelope
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
Before stabilizing the client API:
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
## Flagged ambiguities
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics.

1332
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
[test]
root = "./do-not-run-tests-from-root"

View file

@ -0,0 +1,685 @@
# Service Lifecycle: Election, Restart, and Reconnect
Status: in progress
Incident: [#36688](https://github.com/anomalyco/opencode/issues/36688)
## Summary
The managed V2 service keeps its current update policy: the background updater
may install a new package, but only a freshly launched TUI activates that update
after finding an older running service. Existing TUIs never replace a service;
they only reconnect.
The restart path changes in three places:
1. A process-held OS lock, not the HTTP port or registration file, elects
exactly one server owner for its lifetime.
2. The elected process binds and registers a minimal lifecycle surface before
it initializes the application, so clients can distinguish a slow winner
from an absent server.
3. TUIs rediscover and reconnect indefinitely. Transport loss is never a
terminal error by itself.
Several clients may spawn small contenders during a restart. This is safe and
intentional: one contender acquires the lock and initializes, while every loser
exits before expensive server boot. The design does not require clients to
agree on a single initiator.
This proposal does not introduce a supervisor process, warm candidate server,
protocol negotiation, idle background restart, or general execution-recovery
framework.
## Architecture at a Glance
```text
╭───────────────────╮
│ CLI ServiceConfig │
╰─────────┬─────────╯
╭──────────────────────╮
│ CLI ServerConnection │
╰───────────┬──────────╯
╭──────────────────╰───────────────────╮
▼ ▼
╭──────────────────────────╮ ╭─────────────────────────╮
│ Client Service lifecycle │ │ CLI runPromiseWith seam │
╰─────────────┬────────────╯ ╰─────────────┬───────────╯
╰─────╮ │
▼ ▼
╭────────────────────────────╮ ╭─────────────╮
│ Background service process │ │ TUI / Solid │
╰──────────────┬─────────────╯ ╰──────┬──────╯
│ │
╰────────────◀────────────────────╯
╭───────────────────────╮
│ Server HTTP transport │
╰───────────┬───────────╯
╭──────────────────╮
│ Core application │
╰──────────────────╯
```
| Owner | Responsibility |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `packages/client/src/effect/service.ts` | Effect-native discovery, start, and stop lifecycle operations |
| `packages/cli/src/services/service-config.ts` | CLI registration path, installed version, and daemon command |
| `packages/cli/src/services/server-connection.ts` | Resolve an endpoint and, only for the shared service, grouped reconnect and restart Effects |
| `packages/cli/src/server-process.ts` | Daemon election, registration, and server process boot |
| `packages/server/src/process.ts` | HTTP lifecycle shell and application transport |
| `packages/core` | Application behavior behind the transport |
| CLI default handler | Convert lifecycle Effects with the outer `FileSystem` context and pass grouped Promise capabilities |
| `packages/tui` Solid client context | Own event-stream reconnect, endpoint replacement, status, and user-triggered restart UI |
## Implementation Status
| Area | State |
| ------------------------- | --------------------------------------------------------------------- |
| Lifetime ownership | Implemented on this branch with a scoped OS lock |
| Contender behavior | Implemented; losers exit before the server module is imported |
| Registration repair | Implemented; the owner reasserts deleted or corrupt discovery |
| Channel isolation | Implemented with no-clobber migration for legacy preview discovery |
| Client startup waiting | Implemented; slow winners are not killed and waiting is indefinite |
| Lifecycle shell | Implemented; the owner binds and registers before application boot |
| Failed-state latching | Implemented; deterministic boot failure stays bound and actionable |
| Recovery diagnostics | Implemented; the TUI shows status instead of transport internals |
| Cross-platform validation | macOS runtime verified; Linux and Windows run in the unit-test matrix |
## Context
The V2 CLI runs a shared managed service that owns Sessions, location graphs,
plugins, permissions, and tool execution. The service updater can replace the
installed package while the current process continues running the old image.
A later TUI launch then detects the version mismatch and replaces the service.
Incident #36688 showed four failures in that replacement path:
- Multiple TUIs spawned heavyweight server contenders.
- A winner remained unobservable while it cold-booted, so another wave treated
it as absent and displaced it.
- A fresh TUI exhausted its reconnect budget and crashed with an unhandled
transport defect.
- A losing contender remained alive and consumed about 1 GB of RSS.
The `origin/v2` baseline serializes service startup with `EffectFlock`. A
contender acquires a three-second heartbeat lease, checks whether another
service became discoverable, and only the winner crosses the application-boot
boundary. This already prevents simultaneous heavy boots and makes startup
losers exit.
The lease is released immediately after registration, however, so it is not
lifetime ownership. Registration then reverts to last-writer-wins authority: a
deleted or corrupt registration can admit a second boot, a displaced server
terminates itself through its 10-second registration self-check, and a stalled
lease holder can be displaced after the three-second service staleness timeout.
`Flock` and `EffectFlock` live in `packages/core/src/util` and are also used for
config writes, MCP auth, npm installs, and repository caching. Despite the
name, the primitive is an atomic-mkdir lease with heartbeat and staleness
takeover, not an OS-held lock. It remains appropriate for bounded critical
sections, including today's startup fence, but is not lifetime service
ownership.
The current implementation also mixes three different concepts:
- **Ownership:** which process is allowed to be the managed server.
- **Discovery:** where clients can reach that process.
- **Lifecycle:** whether that process is starting, ready, stopping, or failed.
This design gives each concept one authority.
```definitions
[
{
"term": "Owner",
"definition": "The one process holding the process-held OS service lock."
},
{
"term": "Contender",
"definition": "A small serve process attempting to acquire the service lock. It must not initialize the application before winning."
},
{
"term": "Registration",
"definition": "An atomic discovery record containing the elected owner's identity and endpoint. Registration never grants ownership."
},
{
"term": "Lifecycle shell",
"definition": "The minimal HTTP surface bound by the elected process before application initialization. It serves health and retryable startup responses."
},
{
"term": "Application",
"definition": "The full server routes and global or location-scoped modules used for normal OpenCode work."
}
]
```
## Goals
- At most one process initializes and serves the managed application.
- Losing contenders exit before database, route, plugin, MCP, or location boot.
- A slow winner becomes observable before expensive initialization.
- Existing and freshly launched TUIs survive retryable service unavailability.
- Reconnect follows service state instead of displaying retry counts or raw
transport failures.
- Version-mismatch replacement remains triggered by a fresh TUI launch.
- A stale or malformed registration cannot create a second owner.
- An unresponsive owner is never killed automatically by an arbitrary TUI.
- Every spawned contender has a bounded path to ownership or exit.
## Non-goals
- Restarting automatically when a background update finds an idle window.
- Running old and candidate application servers concurrently.
- Adding a permanent steward, proxy, or supervisor process.
- Zero-downtime worker handoff or automatic rollback.
- Application protocol negotiation or automatic TUI self-restart.
- General hard-crash recovery for active Sessions.
- Defining recovery semantics for provider attempts, tools, shells, sub-agents,
permissions, questions, or background jobs.
- Automatically killing a frozen owner.
- Bounding concurrent location cold boots after clients reconnect.
- Multi-machine or clustered service placement.
## Invariants
1. **The service lock is ownership.** Exactly one process may hold the OS lock
for one installation channel and service profile.
2. **Ownership precedes boot.** A contender performs no expensive application
initialization before it acquires the lock.
3. **Ownership lasts for the process lifetime.** The owner holds an open lock
handle until the managed server exits. The OS releases it on process death
without a cleanup callback.
4. **The port is transport, not election.** The owner may select a dynamic port
after acquiring the lock.
5. **Registration is discovery, not election.** Deleting, corrupting, or
replacing registration does not invalidate a live owner's lock.
6. **Only a fresh launch enforces package version.** Existing TUIs reconnect to
the current owner without initiating version replacement.
7. **Transport loss is retryable.** It never terminates a TUI without a separate
diagnosed, non-retryable cause.
8. **Clients do not kill an unresponsive owner automatically.** Destructive
recovery requires the explicit `service restart` command.
9. **Lifecycle does not promise execution semantics.** Graceful replacement
invokes Session suspension and resumption hooks, but tool-level continuity
belongs to a separate design.
## System Model
```text
╭───────────────────────╮ ╭──────────────────────────────╮
│ Fresh or existing TUI │ │ Process-held OS service lock │
╰───────────┬───────────╯ ╰───────────────┬──────────────╯
╰─────┬ normal requests observe ───────────────────────╮ │
│ discover │ ├──╯ authorizes one owner
▼ │ ▼
╭───────────────────╮ │ ╭─────────────────╮
│ Registration file │ │ │ Lifecycle shell │
╰───────────────────╯ │ ╰────────┬────────╯
│ │
├────────────────────────╯
╭──────────────────────╮
│ OpenCode application │
╰──────────────────────╯
```
The lifecycle shell and application run in the same process. The distinction is
initialization order and responsibility, not process topology.
## Service Status
The server reports one small status value:
```typescript
type ServiceStatus =
| {
type: "starting"
}
| {
type: "ready"
}
| {
type: "stopping"
targetVersion?: string
}
| {
type: "failed"
message: string
action: string
}
```
The client adds only the discovery states needed by callers:
```typescript
type Status = { type: "missing" } | { type: "unreachable" } | { type: "unresponsive" } | ServiceStatus
```
The health response retains the existing fields for old clients and adds the
status discriminant:
```typescript
type ServiceHealth = {
healthy: true
version: string
pid: number
instanceID: string
status: ServiceStatus
}
```
`healthy: true` means the registered lifecycle shell is responding and its
identity matches registration. New clients use `status.type === "ready"` as
the application-readiness signal.
During `starting` or `stopping`, application requests are not held in memory.
They receive an immediate retryable response:
```http
HTTP/1.1 503 Service Unavailable
Retry-After: 1
Content-Type: application/json
{"code":"service_starting"}
```
`stopping` uses `service_stopping`. A failed application boot uses
`service_failed` and includes a safe diagnostic message.
A failed owner remains bound and keeps holding the service lock. Exiting on
failure would let every waiting client's `ensureRunning` loop elect a new
contender that repeats the same heavy failing boot, so staying bound turns a
deterministic boot failure into one observable `failed` state instead of a
client-driven respawn loop. Recovery still works: a fresh launch observes the
failed instance through the stop path, and explicit `service restart` replaces
it.
## Registration Contract
Registration contains only discovery identity:
```typescript
type ServiceRegistration = {
schema: 1
instanceID: string
version: string
url: string
pid: number
}
```
Authentication continues to use the existing private service credential
storage. The registration schema does not change that policy.
The owner writes registration only after the lifecycle shell has bound:
1. Bind the lifecycle shell.
2. Write a temporary registration file with mode `0600`.
3. Atomically rename it over the old registration.
4. Serve lifecycle health as `starting`.
On shutdown, the owner removes registration only if the current file still has
its `instanceID`. An old finalizer can never remove a successor's registration.
While running, the owner periodically asserts its registration. Because the
lock guarantees exactly one live owner, any registration that does not name the
owner is stale or corrupt, and the owner rewrites it. A deleted or clobbered
registration therefore heals within one assertion interval instead of leaving
clients waiting on absent discovery. This inverts today's self-check loop,
which terminates the displaced process instead of repairing discovery.
Legacy registration shapes are decoded by a compatibility adapter. The new
domain type does not make fields optional to represent old formats.
## Election
This design promotes today's startup fence into lifetime ownership.
Last-writer-wins registration is replaced by a process-held OS lock that is
acquired before any expensive boot work and held for the entire service
lifetime.
A heartbeat-and-staleness lease, including the existing `Flock` utility, is not
sufficient for service ownership: the service configures a three-second stale
timeout, after which its lock can be broken and recreated. An event-loop stall,
a suspended machine, or a debugger pause can therefore make a live owner appear
stale and allow a contender to displace it. Service ownership requires a
process-held OS lock: `flock` on Unix and an exclusively bound named pipe on
Windows. It cannot be broken because a heartbeat exceeded a timeout. Process
death releases the lock through the OS.
Neither Bun nor Node exposes `flock` directly, the existing `Flock` utility is
an mkdir-plus-heartbeat lease rather than an OS-held lock, and the common
lockfile packages are staleness-based leases as well. The platform layer uses
`bun:ffi` to call `flock` on POSIX and Node's named-pipe server support on
Windows, where Bun FFI is not available on every shipped architecture. It lives
alongside the existing utility in `packages/core/src/util`. This primitive is
the foundation of the design, so the delivery sequence spikes it first.
```text
Contender Lock Lifecycle Application
│ │ │ │
├─ try acquire ───▶ │ │
│ │ │ │
╭─ alt: lock held ────────────────────────────────────────────────╮
│ │ │ │ │ │
│ ◀─ busy ──────────┤ │ │ │
│ │ │ │ │ │
│ ├─────────╮ │ │ │ │
│ │ exit │ │ │ │ │
│ ◀─────────╯ │ │ │ │
│ │ │ │ │ │
├─ else: lock acquired ───────────────────────────────────────────┤
│ │ │ │ │ │
│ ◀─ owner ─────────┤ │ │ │
│ │ │ │ │ │
│ ├─ bind, register, starting ────────▶ │ │
│ │ │ │ │ │
│ ├─ initialize ──────────────────────────────────────────────▶ │
│ │ │ │ │ │
│╭─ alt: boot succeeds ──────────────────────────────────────────╮│
││ │ │ │ │ ││
││ │ │ ◀─ ready ───────────────┤ ││
││ │ │ │ │ ││
│├─ else: boot fails ────────────────────────────────────────────┤│
││ │ │ │ │ ││
││ │ │ ◀─ failed, stay bound ──┤ ││
││ │ │ │ │ ││
│╰───────────────────────────────────────────────────────────────╯│
│ │ │ │ │ │
╰─────────────────────────────────────────────────────────────────╯
│ │ │ │
```
Lock acquisition by a contender is nonblocking or tightly bounded. A loser
must exit before constructing application routes or importing startup-heavy
modules.
Several clients may spawn contenders concurrently. The design guarantees one
heavy winner, not one process spawn. If the winner crashes during startup, the
OS releases the lock and a later client retry starts another election.
The lock is scoped by installation channel and service profile. Local, preview,
and stable installations cannot displace one another.
## Update Activation
Background update behavior remains unchanged:
1. The running service checks for an update.
2. The updater installs the package in the background.
3. The running process continues using its existing process image.
4. No idle check or automatic restart occurs.
A fresh TUI launch activates the installed update:
1. Read registration and authenticate the responding service.
2. If its package version matches the fresh client, attach normally.
3. If the version differs, request graceful stop of that exact registered
instance using the existing authenticated stop path.
4. Re-check instance identity before every signal or escalation in that path.
5. Wait for the old process to exit and release the service lock.
6. Call `ensureRunning` until a compatible service becomes ready.
Concurrent fresh launchers may all observe the same old instance. Stopping that
exact instance must be idempotent. Once registration names a different instance,
a stale launcher stops signaling and returns to discovery.
No durable restart-transition record is introduced. The initiating fresh TUI
already knows the source and target versions and can display its update
preflight. Existing TUIs may display `Updating...` if they observed `stopping`;
otherwise `Waiting for background service...` is the honest fallback.
## Fresh Launch Versus Reconnect
Fresh launch and reconnect deliberately have different version policies:
```typescript
type ManagedConnection =
| {
type: "launch"
requiredVersion: string
}
| {
type: "reconnect"
}
```
- `launch` requires the installed package version and may activate replacement.
- `reconnect` accepts the current owner and never activates replacement.
This preserves today's permissive reconnect behavior. Explicit application
protocol negotiation and automatic TUI re-exec remain follow-ups.
## Client Reconnect
Fresh and existing TUIs use the same status loop after startup:
1. Read registration on every attempt. Do not retry a stale URL indefinitely.
2. If registration is absent, call `ensureRunning` and continue waiting.
3. If registration is unreachable, call `ensureRunning`. A live owner prevents
contenders from acquiring the lock; a dead owner does not.
4. If status is `starting` or `stopping`, wait.
5. If status is `failed`, show its actionable message.
6. If status is `ready`, rebuild HTTP and event-stream clients for the new
endpoint and perform authoritative state reconciliation.
Retry cadence is internal policy. Retry counts are telemetry, not user-facing
state. The TUI waits until the service is ready or the user exits.
Transport failures are handled at the TUI run boundary. A raw client transport
error or Effect defect must not escape to the terminal. Hard exit is reserved
for diagnosed causes such as invalid local configuration, failed authentication,
or a foreign process occupying an explicitly configured port.
The UI derives text from status:
| Status | User-facing state |
| ------------------------ | ----------------------------------- |
| No registration | `Starting background service...` |
| Registration unreachable | `Waiting for background service...` |
| `starting` | `Starting OpenCode vX...` |
| `stopping` | `Updating to vX...` |
| `failed` | Actionable failure message |
| `ready` | Normal TUI |
## Graceful Session Continuity
Version-mismatch replacement uses the existing graceful Session suspension and
resumption hooks:
1. The old server snapshots active Session IDs during graceful teardown.
2. The successor schedules those Sessions for continuation.
3. The runner reloads durable Session history before continuing.
This lifecycle design does not define what an interrupted physical provider
attempt or tool invocation means. It does not promise that external side effects
did not occur, replay the exact interrupted tool, preserve an in-memory form, or
recover process-local background work.
Those concerns require a separate execution-continuity design covering tools,
shells, sub-agents, permissions, questions, provider attempts, and hard-crash
recovery.
## Unresponsive Owner
An unreachable registration does not prove that the owner is dead. A contender
attempts the service lock:
- If the lock is free, the contender starts a replacement.
- If the lock is held, the contender exits and the client keeps waiting.
After a bounded diagnostic threshold, the client may show:
```text
The background service owns the service lock but is not responding.
Run `opencode service restart` to recover it.
```
Only explicit `service restart` may perform destructive recovery. It verifies
the complete registration and process instance before signaling, waits for
graceful exit, re-checks identity before escalation, and refuses to kill a
process it cannot positively identify.
Automatic frozen-owner recovery is deferred.
## Failure Walkthroughs
### Update with open TUIs
1. The old service installs vNext but keeps running.
2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop.
3. The old service reports `stopping`, suspends active Sessions, and exits.
4. Open TUIs enter their indefinite status loops.
5. One or more clients spawn contenders.
6. One contender acquires the service lock. Losers exit before heavy boot.
7. The winner binds and registers the lifecycle shell as `starting`.
8. Clients stop spawning and wait on the observable winner.
9. The winner initializes the application and reports `ready`.
10. TUIs rebuild clients, reconcile state, and resume.
### Server crashes while ready
1. The endpoint becomes unreachable and registration may remain stale.
2. Clients call `ensureRunning`.
3. Process death has released the service lock.
4. One contender wins, replaces registration, and starts normally.
5. Detailed active-execution recovery is outside this design.
### Winner crashes during startup
1. Clients observed `starting` and remain alive.
2. Process death releases the service lock.
3. A later reconnect attempt starts another election.
4. One new contender wins; all other contenders exit.
### Registration is deleted while the owner is healthy
1. Clients may call `ensureRunning` because discovery is absent.
2. Every contender fails to acquire the owner's lock and exits.
3. No second application initializes.
4. The owner's next registration assertion republishes discovery.
### Owner is alive but unresponsive
1. Health fails, but the process still holds the service lock.
2. Contenders fail lock acquisition and exit.
3. Clients wait and eventually show explicit recovery guidance.
4. No TUI kills the owner automatically.
## TDD Verification
Implementation should proceed test-first with real subprocesses and real locks.
Mocks cannot establish process death, lock release, loser cleanup, or port
behavior.
### Election tests
| Scenario | Required result |
| ----------------------------------------------------- | ------------------------------------------------------- |
| Ten contenders start simultaneously | Exactly one crosses the application-boot boundary |
| Winner pauses after lock acquisition | No loser initializes or remains alive |
| Winner event loop pauses beyond the old stale timeout | Ownership is not displaced |
| Winner crashes before bind | Lock releases; a later attempt wins |
| Winner crashes after bind but before registration | Lock releases; a later attempt replaces stale discovery |
| Registration is deleted while owner runs | No second owner initializes |
| Registration is malformed | Lock still prevents a second owner |
| Registration names a dead PID | New contender can acquire the released lock |
| Two installation channels start | Each elects an independent owner |
| Explicit configured port is foreign-owned | Fail diagnostically; do not kill the foreign process |
The fixture records a marker immediately before application initialization. The
tests assert that only one process writes that marker and that every loser exits
within a bounded interval. The harness should also assert that a loser's peak
RSS stays an order of magnitude below an application boot, since import weight
was the observed incident cost.
### Lifecycle tests
| Scenario | Required result |
| ----------------------------------------------- | ---------------------------------------------------------------- |
| Winner owns lock but application boot is paused | Health reports `starting` |
| Application request arrives during startup | Immediate retryable `503` |
| Application becomes ready | Status changes once from `starting` to `ready` |
| Graceful replacement begins | Status reports `stopping` before disconnect |
| Application initialization fails | Actionable `failed` status; owner stays bound and holds the lock |
| Registration is deleted while owner runs | Owner republishes it within one assertion interval |
| Owner exits | Registration is removed only if it still names that owner |
### Update tests
| Scenario | Required result |
| -------------------------------------- | -------------------------------------------------------- |
| Background update installs vNext | Running vOld service does not restart |
| Fresh vNext launch finds vOld | Exact old instance stops; vNext eventually becomes ready |
| Two fresh vNext launches race | One heavy successor; both clients attach |
| Existing vOld TUI reconnects to vNext | It never requests replacement |
| Stale launcher observes a new instance | It does not signal the new instance |
### Reconnect tests
| Scenario | Required result |
| --------------------------------------------------- | -------------------------------------------------- |
| Endpoint disappears and changes port | TUI rediscovers and rebuilds clients |
| Service remains unavailable beyond old retry budget | TUI remains alive |
| Event stream reconnects | Client performs authoritative state reconciliation |
| Transport returns an unexpected defect | TUI formats it; no raw stack escapes |
| Owner remains unresponsive | TUI waits and shows explicit restart guidance |
## Delivery Sequence
1. **Spike the lock primitive.** Prove a nonblocking, process-held OS lock
under Bun on macOS, Linux, and Windows (`bun:ffi` to `flock` on POSIX and a
named pipe on Windows), including release on hard kill and behavior across
containers and network filesystems used in CI.
2. **Expand the subprocess test harness.** Begin from the baseline
two-contender test and cover ten contenders, lock release on crash, a paused
winner, deleted or corrupt registration, and bounded loser exit before
changing ownership.
3. **Contain client failure.** Make transport loss nonterminal, rediscover on
every cycle, and format unexpected failures at the TUI boundary.
4. **Promote the startup fence to process-held ownership.** Preserve the
existing pre-boot acquisition seam, replace its lease with the OS lock, hold
it until process exit, and invert the registration self-check from
self-termination to reassertion.
5. **Bind the lifecycle shell first.** Publish registration and `starting`,
return retryable `503` for application requests, then initialize the app.
The health contract change is public API: regenerate clients from
`packages/client` with `bun run generate`.
6. **Codify launch versus reconnect.** Fresh launch enforces installed version;
reconnect never activates replacement.
7. **Integrate graceful replacement.** Preserve current background-install and
fresh-launch activation behavior while invoking Session continuity hooks.
8. **Harden explicit recovery.** Verify exact process identity during explicit
`service restart`; never automatically kill an unresponsive owner.
9. **Run the full multi-process suite.** Include repeated restart cycles and
assert that no contender or child process remains afterward.
## Acceptance Criteria
- Ten concurrent restart observers produce one application initialization.
- No losing contender survives or builds a location graph.
- A 30-second application boot remains continuously observable as `starting`.
- A TUI remains alive through a service outage longer than the previous retry
budget.
- A service endpoint change does not require restarting an existing TUI.
- Background installation alone does not restart the service.
- A fresh mismatched TUI eventually attaches to the installed service version.
- Existing reconnecting TUIs never replace the current owner.
- Registration corruption cannot produce two owners.
- A deleted registration heals without restarting the owner or any client.
- An unresponsive owner is not killed without an explicit recovery command.
- Raw transport defects never escape to the terminal.
## Follow-ups
- Idle background update activation with an admission fence.
- Application protocol compatibility and automatic local TUI re-exec.
- Durable execution recovery for provider attempts and tools.
- Shell, sub-agent, permission, question, and background-job continuity.
- Automatic recovery for a positively identified frozen owner.
- Cold-boot concurrency limits and interaction-prioritized location loading.
- A steward or socket-handoff architecture if zero-downtime replacement becomes
a real requirement.

View file

@ -0,0 +1,298 @@
# V1 to V2 Database Migration
## Approach
- Use the `dev` branch database schema and migration registry as the V1 baseline.
- Remove migrations that exist only on the V2 branch.
- Generate one canonical migration from the `dev` schema to the final V2 schema.
- Keep the canonical migration focused on schema changes and dropping obsolete tables.
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI.
- Show committed session progress while the endpoint runs.
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
and help flows do not trigger the backfill.
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
the status check and spinner presentation.
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
for the current single elected server process.
## Preserve
The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows.
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
workspace relationships.
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider
ID, model ID, and variant, normalizing an absent variant to `default`.
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
cache-write token totals with those sums.
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
parts, and file history.
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
unmanaged legacy storage.
## Per-Session Replacement
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
hold SQLite's writer lock long enough to block the running TUI.
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
remain untouched.
## Message Backfill
Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
V2 session APIs, which read `session_message`.
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
untouched in the V1 tables.
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
leave skipped source rows unchanged.
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
avoid rewriting other persisted state that may refer to a message.
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
contiguous `session_message.seq` values starting at `0`.
Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
payload.
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
stable. Omit only explicitly dropped internal concepts and undecodable messages.
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
part mappings must be decided explicitly before implementing the backfill.
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
Keep all source rows unchanged in the V1 `message` and `part` tables.
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
state has no start time. Set the error to type `tool.interrupted` with message
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
`[Old tool result content cleared]` as the only output and omit attachments.
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
`tokens.total` because it is derivable and V2 does not persist it.
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
`message` row.
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
finish values in metadata.
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
preserve the message, and discard V1-only retryability and raw provider details.
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
useful enough to preserve. The original retry rows remain in the V1 `part` table.
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
markers without snapshots.
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
removed.
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
a blocker for the V1 migration, which should target the current storage contract.
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
metadata. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
must not affect future V2 execution. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
format only in the V1 `message` row.
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
row.
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
Omit `agents` when there are no agent parts.
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
For a non-embedded V1 file, do not create a V2 file attachment. Append
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
the preserved V1 `part` row.
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
source user row. Entirely synthetic messages continue to reuse their original message ID.
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
admitted compaction input ID and preserves references to the initiating message.
For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1
compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and
serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
assistant row.
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables.
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
history. The migrated session's prior `event` rows are removed in the same transaction.
## Drop
Drop these pre-launch V2 tables without preserving or transforming their rows:
- `session_input`
- `session_context_epoch`
- `data_migration`
Do not transfer `session_input` rows into `session_pending`.
## Create Empty
Let the generated migration create these tables empty:
- `instruction_blob`
- `instruction_entry`
- `instruction_state`
- `session_pending`
- `kv`
V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs.
## Fork Storage
V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in
`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of:
- `before`: copy messages before the identified message.
- `through`: copy messages through the identified message.
Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2
schema.
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
## Execution
Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the
current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions.
Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its
maximum message sequence. Commit each imported Session independently and leave the source database untouched.
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor:
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed.
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key
exists.
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
seed migration state specially.
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
cursor. Mark the migration complete after the final session and return immediately on later calls.
Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session`
row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal.
Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each
successfully committed session advances the cursor.
## Testing
Detailed migration test design is deferred until after the canonical migration is implemented.

View file

@ -495,7 +495,6 @@ async function subscribeSessionEvents() {
console.log("Subscribing to session events...")
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
bash: ["Bash", "\x1b[31m\x1b[1m"],
edit: ["Edit", "\x1b[32m\x1b[1m"],
glob: ["Glob", "\x1b[34m\x1b[1m"],

View file

@ -15,6 +15,6 @@
"@actions/github": "6.0.1",
"@octokit/graphql": "9.0.1",
"@octokit/rest": "catalog:",
"@opencode-ai/sdk": "workspace:*"
"@opencode-ai/sdk": "1.18.5"
}
}

View file

@ -40,6 +40,7 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@standard-schema/spec": "1.0.0",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
@ -92,7 +93,8 @@
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
"@lydell/node-pty": "1.2.0-beta.12",
"resolve.exports": "2.0.3"
}
},
"devDependencies": {

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 Mintlify
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,44 +0,0 @@
# Mintlify Starter Kit
Use the starter kit to get your docs deployed and ready to customize.
Click the green **Use this template** button at the top of this repo to copy the Mintlify starter kit. The starter kit contains examples with
- Guide pages
- Navigation
- Customizations
- API reference pages
- Use of popular components
**[Follow the full quickstart guide](https://starter.mintlify.com/quickstart)**
## Development
Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally. To install, use the following command:
```
npm i -g mint
```
Run the following command at the root of your documentation, where your `docs.json` is located:
```
mint dev
```
View your local preview at `http://localhost:3000`.
## Publishing changes
Install our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch.
## Need help?
### Troubleshooting
- If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI.
- If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`.
### Resources
- [Mintlify documentation](https://mintlify.com/docs)

View file

@ -1,83 +0,0 @@
---
title: "Claude Code setup"
description: "Configure Claude Code for your documentation workflow"
icon: "asterisk"
---
Claude Code is Anthropic's official CLI tool. This guide will help you set up Claude Code to help you write and maintain your documentation.
## Prerequisites
- Active Claude subscription (Pro, Max, or API access)
## Setup
1. Install Claude Code globally:
```bash
npm install -g @anthropic-ai/claude-code
```
2. Navigate to your docs directory.
3. (Optional) Add the `CLAUDE.md` file below to your project.
4. Run `claude` to start.
## Create `CLAUDE.md`
Create a `CLAUDE.md` file at the root of your documentation repository to train Claude Code on your specific documentation standards:
```markdown
# Mintlify documentation
## Working relationship
- You can push back on ideas-this can lead to better documentation. Cite sources and explain your reasoning when you do so
- ALWAYS ask for clarification rather than making assumptions
- NEVER lie, guess, or make up information
## Project context
- Format: MDX files with YAML frontmatter
- Config: docs.json for navigation, theme, settings
- Components: Mintlify components
## Content strategy
- Document just enough for user success - not too much, not too little
- Prioritize accuracy and usability of information
- Make content evergreen when possible
- Search for existing information before adding new content. Avoid duplication unless it is done for a strategic reason
- Check existing patterns for consistency
- Start by making the smallest reasonable changes
## Frontmatter requirements for pages
- title: Clear, descriptive page title
- description: Concise summary for SEO/navigation
## Writing standards
- Second-person voice ("you")
- Prerequisites at start of procedural content
- Test all code examples before publishing
- Match style and formatting of existing pages
- Include both basic and advanced use cases
- Language tags on all code blocks
- Alt text on all images
- Relative paths for internal links
## Git workflow
- NEVER use --no-verify when committing
- Ask how to handle uncommitted changes before starting
- Create a new branch when no clear branch exists for changes
- Commit frequently throughout development
- NEVER skip or disable pre-commit hooks
## Do not
- Skip frontmatter on any MDX file
- Use absolute URLs for internal links
- Include untested code examples
- Make assumptions - always ask for clarification
```

View file

@ -1,423 +0,0 @@
---
title: "Cursor setup"
description: "Configure Cursor for your documentation workflow"
icon: "arrow-pointer"
---
Use Cursor to help write and maintain your documentation. This guide shows how to configure Cursor for better results on technical writing tasks and using Mintlify components.
## Prerequisites
- Cursor editor installed
- Access to your documentation repository
## Project rules
Create project rules that all team members can use. In your documentation repository root:
```bash
mkdir -p .cursor
```
Create `.cursor/rules.md`:
````markdown
# Mintlify technical writing rule
You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices.
## Core writing principles
### Language and style requirements
- Use clear, direct language appropriate for technical audiences
- Write in second person ("you") for instructions and procedures
- Use active voice over passive voice
- Employ present tense for current states, future tense for outcomes
- Avoid jargon unless necessary and define terms when first used
- Maintain consistent terminology throughout all documentation
- Keep sentences concise while providing necessary context
- Use parallel structure in lists, headings, and procedures
### Content organization standards
- Lead with the most important information (inverted pyramid structure)
- Use progressive disclosure: basic concepts before advanced ones
- Break complex procedures into numbered steps
- Include prerequisites and context before instructions
- Provide expected outcomes for each major step
- Use descriptive, keyword-rich headings for navigation and SEO
- Group related information logically with clear section breaks
### User-centered approach
- Focus on user goals and outcomes rather than system features
- Anticipate common questions and address them proactively
- Include troubleshooting for likely failure points
- Write for scannability with clear headings, lists, and white space
- Include verification steps to confirm success
## Mintlify component reference
### Callout components
#### Note - Additional helpful information
<Note>
Supplementary information that supports the main content without interrupting flow
</Note>
#### Tip - Best practices and pro tips
<Tip>
Expert advice, shortcuts, or best practices that enhance user success
</Tip>
#### Warning - Important cautions
<Warning>
Critical information about potential issues, breaking changes, or destructive actions
</Warning>
#### Info - Neutral contextual information
<Info>
Background information, context, or neutral announcements
</Info>
#### Check - Success confirmations
<Check>
Positive confirmations, successful completions, or achievement indicators
</Check>
### Code components
#### Single code block
Example of a single code block:
```javascript config.js
const apiConfig = {
baseURL: "https://api.example.com",
timeout: 5000,
headers: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
}
```
#### Code group with multiple languages
Example of a code group:
<CodeGroup>
```javascript Node.js
const response = await fetch('/api/endpoint', {
headers: { Authorization: `Bearer ${apiKey}` }
});
```
```python Python
import requests
response = requests.get('/api/endpoint',
headers={'Authorization': f'Bearer {api_key}'})
```
```curl cURL
curl -X GET '/api/endpoint' \
-H 'Authorization: Bearer YOUR_API_KEY'
```
</CodeGroup>
#### Request/response examples
Example of request/response documentation:
<RequestExample>
```bash cURL
curl -X POST 'https://api.example.com/users' \
-H 'Content-Type: application/json' \
-d '{"name": "John Doe", "email": "john@example.com"}'
```
</RequestExample>
<ResponseExample>
```json Success
{
"id": "user_123",
"name": "John Doe",
"email": "john@example.com",
"created_at": "2024-01-15T10:30:00Z"
}
```
</ResponseExample>
### Structural components
#### Steps for procedures
Example of step-by-step instructions:
<Steps>
<Step title="Install dependencies">
Run `npm install` to install required packages.
<Check>
Verify installation by running `npm list`.
</Check>
</Step>
<Step title="Configure environment">
Create a `.env` file with your API credentials.
```bash
API_KEY=your_api_key_here
```
<Warning>
Never commit API keys to version control.
</Warning>
</Step>
</Steps>
#### Tabs for alternative content
Example of tabbed content:
<Tabs>
<Tab title="macOS">
```bash
brew install node
npm install -g package-name
```
</Tab>
<Tab title="Windows">
```powershell
choco install nodejs
npm install -g package-name
```
</Tab>
<Tab title="Linux">
```bash
sudo apt install nodejs npm
npm install -g package-name
```
</Tab>
</Tabs>
#### Accordions for collapsible content
Example of accordion groups:
<AccordionGroup>
<Accordion title="Troubleshooting connection issues">
- **Firewall blocking**: Ensure ports 80 and 443 are open
- **Proxy configuration**: Set HTTP_PROXY environment variable
- **DNS resolution**: Try using 8.8.8.8 as DNS server
</Accordion>
<Accordion title="Advanced configuration">
```javascript
const config = {
performance: { cache: true, timeout: 30000 },
security: { encryption: 'AES-256' }
};
```
</Accordion>
</AccordionGroup>
### Cards and columns for emphasizing information
Example of cards and card groups:
<Card title="Getting started guide" icon="rocket" href="/quickstart">
Complete walkthrough from installation to your first API call in under 10 minutes.
</Card>
<CardGroup cols={2}>
<Card title="Authentication" icon="key" href="/auth">
Learn how to authenticate requests using API keys or JWT tokens.
</Card>
<Card title="Rate limiting" icon="clock" href="/rate-limits">
Understand rate limits and best practices for high-volume usage.
</Card>
</CardGroup>
### API documentation components
#### Parameter fields
Example of parameter documentation:
<ParamField path="user_id" type="string" required>
Unique identifier for the user. Must be a valid UUID v4 format.
</ParamField>
<ParamField body="email" type="string" required>
User's email address. Must be valid and unique within the system.
</ParamField>
<ParamField query="limit" type="integer" default="10">
Maximum number of results to return. Range: 1-100.
</ParamField>
<ParamField header="Authorization" type="string" required>
Bearer token for API authentication. Format: `Bearer YOUR_API_KEY`
</ParamField>
#### Response fields
Example of response field documentation:
<ResponseField name="user_id" type="string" required>
Unique identifier assigned to the newly created user.
</ResponseField>
<ResponseField name="created_at" type="timestamp">
ISO 8601 formatted timestamp of when the user was created.
</ResponseField>
<ResponseField name="permissions" type="array">
List of permission strings assigned to this user.
</ResponseField>
#### Expandable nested fields
Example of nested field documentation:
<ResponseField name="user" type="object">
Complete user object with all associated data.
<Expandable title="User properties">
<ResponseField name="profile" type="object">
User profile information including personal details.
<Expandable title="Profile details">
<ResponseField name="first_name" type="string">
User's first name as entered during registration.
</ResponseField>
<ResponseField name="avatar_url" type="string | null">
URL to user's profile picture. Returns null if no avatar is set.
</ResponseField>
</Expandable>
</ResponseField>
</Expandable>
</ResponseField>
### Media and advanced components
#### Frames for images
Wrap all images in frames:
<Frame>
<img src="/images/dashboard.png" alt="Main dashboard showing analytics overview" />
</Frame>
<Frame caption="The analytics dashboard provides real-time insights">
<img src="/images/analytics.png" alt="Analytics dashboard with charts" />
</Frame>
#### Videos
Use the HTML video element for self-hosted video content:
<video
controls
className="w-full aspect-video rounded-xl"
src="link-to-your-video.com"
> </video>
Embed YouTube videos using iframe elements:
<iframe
className="w-full aspect-video rounded-xl"
src="https://www.youtube.com/embed/4KzFe50RQkQ"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
#### Tooltips
Example of tooltip usage:
<Tooltip tip="Application Programming Interface - protocols for building software">
API
</Tooltip>
#### Updates
Use updates for changelogs:
<Update label="Version 2.1.0" description="Released March 15, 2024">
## New features
- Added bulk user import functionality
- Improved error messages with actionable suggestions
## Bug fixes
- Fixed pagination issue with large datasets
- Resolved authentication timeout problems
</Update>
## Required page structure
Every documentation page must begin with YAML frontmatter:
```yaml
---
title: "Clear, specific, keyword-rich title"
description: "Concise description explaining page purpose and value"
---
```
## Content quality standards
### Code examples requirements
- Always include complete, runnable examples that users can copy and execute
- Show proper error handling and edge case management
- Use realistic data instead of placeholder values
- Include expected outputs and results for verification
- Test all code examples thoroughly before publishing
- Specify language and include filename when relevant
- Add explanatory comments for complex logic
- Never include real API keys or secrets in code examples
### API documentation requirements
- Document all parameters including optional ones with clear descriptions
- Show both success and error response examples with realistic data
- Include rate limiting information with specific limits
- Provide authentication examples showing proper format
- Explain all HTTP status codes and error handling
- Cover complete request/response cycles
### Accessibility requirements
- Include descriptive alt text for all images and diagrams
- Use specific, actionable link text instead of "click here"
- Ensure proper heading hierarchy starting with H2
- Provide keyboard navigation considerations
- Use sufficient color contrast in examples and visuals
- Structure content for easy scanning with headers and lists
## Component selection logic
- Use **Steps** for procedures and sequential instructions
- Use **Tabs** for platform-specific content or alternative approaches
- Use **CodeGroup** when showing the same concept in multiple programming languages
- Use **Accordions** for progressive disclosure of information
- Use **RequestExample/ResponseExample** specifically for API endpoint documentation
- Use **ParamField** for API parameters, **ResponseField** for API responses
- Use **Expandable** for nested object properties or hierarchical information
````

View file

@ -1,96 +0,0 @@
---
title: "Windsurf setup"
description: "Configure Windsurf for your documentation workflow"
icon: "water"
---
Configure Windsurf's Cascade AI assistant to help you write and maintain documentation. This guide shows how to set up Windsurf specifically for your Mintlify documentation workflow.
## Prerequisites
- Windsurf editor installed
- Access to your documentation repository
## Workspace rules
Create workspace rules that provide Windsurf with context about your documentation project and standards.
Create `.windsurf/rules.md` in your project root:
````markdown
# Mintlify technical writing rule
## Project context
- This is a documentation project on the Mintlify platform
- We use MDX files with YAML frontmatter
- Navigation is configured in `docs.json`
- We follow technical writing best practices
## Writing standards
- Use second person ("you") for instructions
- Write in active voice and present tense
- Start procedures with prerequisites
- Include expected outcomes for major steps
- Use descriptive, keyword-rich headings
- Keep sentences concise but informative
## Required page structure
Every page must start with frontmatter:
```yaml
---
title: "Clear, specific title"
description: "Concise description for SEO and navigation"
---
```
## Mintlify components
### Callouts
- `<Note>` for helpful supplementary information
- `<Warning>` for important cautions and breaking changes
- `<Tip>` for best practices and expert advice
- `<Info>` for neutral contextual information
- `<Check>` for success confirmations
### Code examples
- When appropriate, include complete, runnable examples
- Use `<CodeGroup>` for multiple language examples
- Specify language tags on all code blocks
- Include realistic data, not placeholders
- Use `<RequestExample>` and `<ResponseExample>` for API docs
### Procedures
- Use `<Steps>` component for sequential instructions
- Include verification steps with `<Check>` components when relevant
- Break complex procedures into smaller steps
### Content organization
- Use `<Tabs>` for platform-specific content
- Use `<Accordion>` for progressive disclosure
- Use `<Card>` and `<CardGroup>` for highlighting content
- Wrap images in `<Frame>` components with descriptive alt text
## API documentation requirements
- Document all parameters with `<ParamField>`
- Show response structure with `<ResponseField>`
- Include both success and error examples
- Use `<Expandable>` for nested object properties
- Always include authentication examples
## Quality standards
- Test all code examples before publishing
- Use relative paths for internal links
- Include alt text for all images
- Ensure proper heading hierarchy (start with h2)
- Check existing patterns for consistency
````

View file

@ -1,96 +0,0 @@
---
title: "Development"
description: "Preview changes locally to update your docs"
---
<Info>**Prerequisites**: - Node.js version 19 or higher - A docs repository with a `docs.json` file</Info>
Follow these steps to install and run Mintlify on your operating system.
<Steps>
<Step title="Install the Mintlify CLI">
```bash
npm i -g mint
```
</Step>
<Step title="Preview locally">
Navigate to your docs directory where your `docs.json` file is located, and run the following command:
```bash
mint dev
```
A local preview of your documentation will be available at `http://localhost:3000`.
</Step>
</Steps>
## Custom ports
By default, Mintlify uses port 3000. You can customize the port Mintlify runs on by using the `--port` flag. For example, to run Mintlify on port 3333, use this command:
```bash
mint dev --port 3333
```
If you attempt to run Mintlify on a port that's already in use, it will use the next available port:
```md
Port 3000 is already in use. Trying 3001 instead.
```
## Mintlify versions
Please note that each CLI release is associated with a specific version of Mintlify. If your local preview does not align with the production version, please update the CLI:
```bash
npm mint update
```
## Validating links
The CLI can assist with validating links in your documentation. To identify any broken links, use the following command:
```bash
mint broken-links
```
## Deployment
If the deployment is successful, you should see the following:
<Frame>
<img
src="/images/checks-passed.png"
alt="Screenshot of a deployment confirmation message that says All checks have passed."
style={{ borderRadius: "0.5rem" }}
/>
</Frame>
## Code formatting
We suggest using extensions on your IDE to recognize and format MDX. If you're a VSCode user, consider the [MDX VSCode extension](https://marketplace.visualstudio.com/items?itemName=unifiedjs.vscode-mdx) for syntax highlighting, and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) for code formatting.
## Troubleshooting
<AccordionGroup>
<Accordion title='Error: Could not load the "sharp" module using the darwin-arm64 runtime'>
This may be due to an outdated version of node. Try the following:
1. Remove the currently-installed version of the CLI: `npm remove -g mint`
2. Upgrade to Node v19 or higher.
3. Reinstall the CLI: `npm i -g mint`
</Accordion>
<Accordion title="Issue: Encountering an unknown error">
Solution: Go to the root of your device and delete the `~/.mintlify` folder. Then run `mint dev` again.
</Accordion>
</AccordionGroup>
Curious about what changed in the latest CLI version? Check out the [CLI changelog](https://www.npmjs.com/package/mintlify?activeTab=versions).

View file

@ -1,53 +0,0 @@
{
"$schema": "https://mintlify.com/docs.json",
"theme": "mint",
"name": "@opencode-ai/docs",
"colors": {
"primary": "#16A34A",
"light": "#07C983",
"dark": "#15803D"
},
"favicon": "/favicon-v3.svg",
"navigation": {
"tabs": [
{
"tab": "SDK",
"groups": [
{
"group": "Getting started",
"pages": ["index", "quickstart", "development"],
"openapi": "https://opencode.ai/openapi.json"
}
]
}
],
"global": {}
},
"logo": {
"light": "/logo/light.svg",
"dark": "/logo/dark.svg"
},
"navbar": {
"links": [
{
"label": "Support",
"href": "mailto:hi@mintlify.com"
}
],
"primary": {
"type": "button",
"label": "Dashboard",
"href": "https://dashboard.mintlify.com"
}
},
"contextual": {
"options": ["copy", "view", "chatgpt", "claude", "perplexity", "mcp", "cursor", "vscode"]
},
"footer": {
"socials": {
"x": "https://x.com/mintlify",
"github": "https://github.com/mintlify",
"linkedin": "https://linkedin.com/company/mintlify"
}
}
}

View file

@ -1,35 +0,0 @@
---
title: "Code blocks"
description: "Display inline code and code blocks"
icon: "code"
---
## Inline code
To denote a `word` or `phrase` as code, enclose it in backticks (`).
```
To denote a `word` or `phrase` as code, enclose it in backticks (`).
```
## Code blocks
Use [fenced code blocks](https://www.markdownguide.org/extended-syntax/#fenced-code-blocks) by enclosing code in three backticks and follow the leading ticks with the programming language of your snippet to get syntax highlighting. Optionally, you can also write the name of your code after the programming language.
```java HelloWorld.java
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
```
````md
```java HelloWorld.java
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
```
````

View file

@ -1,56 +0,0 @@
---
title: "Images and embeds"
description: "Add image, video, and other HTML elements"
icon: "image"
---
<img style={{ borderRadius: "0.5rem" }} src="https://mintlify-assets.b-cdn.net/bigbend.jpg" />
## Image
### Using Markdown
The [markdown syntax](https://www.markdownguide.org/basic-syntax/#images) lets you add images using the following code
```md
![title](/path/image.jpg)
```
Note that the image file size must be less than 5MB. Otherwise, we recommend hosting on a service like [Cloudinary](https://cloudinary.com/) or [S3](https://aws.amazon.com/s3/). You can then use that URL and embed.
### Using embeds
To get more customizability with images, you can also use [embeds](/writing-content/embed) to add images
```html
<img height="200" src="/path/image.jpg" />
```
## Embeds and HTML elements
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/4KzFe50RQkQ"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ width: "100%", borderRadius: "0.5rem" }}
></iframe>
<br />
<Tip>
Mintlify supports [HTML tags in Markdown](https://www.markdownguide.org/basic-syntax/#html). This is helpful if you prefer HTML tags to Markdown syntax, and lets you create documentation with infinite flexibility.
</Tip>
### iFrames
Loads another HTML page within the document. Most commonly used for embedding videos.
```html
<iframe src="https://www.youtube.com/embed/4KzFe50RQkQ"> </iframe>
```

View file

@ -1,88 +0,0 @@
---
title: "Markdown syntax"
description: "Text, title, and styling in standard markdown"
icon: "text-size"
---
## Titles
Best used for section headers.
```md
## Titles
```
### Subtitles
Best used for subsection headers.
```md
### Subtitles
```
<Tip>
Each **title** and **subtitle** creates an anchor and also shows up on the table of contents on the right.
</Tip>
## Text formatting
We support most markdown formatting. Simply add `**`, `_`, or `~` around text to format it.
| Style | How to write it | Result |
| ------------- | ----------------- | --------------- |
| Bold | `**bold**` | **bold** |
| Italic | `_italic_` | _italic_ |
| Strikethrough | `~strikethrough~` | ~strikethrough~ |
You can combine these. For example, write `**_bold and italic_**` to get **_bold and italic_** text.
You need to use HTML to write superscript and subscript text. That is, add `<sup>` or `<sub>` around your text.
| Text Size | How to write it | Result |
| ----------- | ------------------------ | ---------------------- |
| Superscript | `<sup>superscript</sup>` | <sup>superscript</sup> |
| Subscript | `<sub>subscript</sub>` | <sub>subscript</sub> |
## Linking to pages
You can add a link by wrapping text in `[]()`. You would write `[link to google](https://google.com)` to [link to google](https://google.com).
Links to pages in your docs need to be root-relative. Basically, you should include the entire folder path. For example, `[link to text](/writing-content/text)` links to the page "Text" in our components section.
Relative links like `[link to text](../text)` will open slower because we cannot optimize them as easily.
## Blockquotes
### Singleline
To create a blockquote, add a `>` in front of a paragraph.
> Dorothy followed her through many of the beautiful rooms in her castle.
```md
> Dorothy followed her through many of the beautiful rooms in her castle.
```
### Multiline
> Dorothy followed her through many of the beautiful rooms in her castle.
>
> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
```md
> Dorothy followed her through many of the beautiful rooms in her castle.
>
> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
```
### LaTeX
Mintlify supports [LaTeX](https://www.latex-project.org) through the Latex component.
<Latex>8 x (vk x H1 - H2) = (0,1)</Latex>
```md
<Latex>8 x (vk x H1 - H2) = (0,1)</Latex>
```

View file

@ -1,87 +0,0 @@
---
title: "Navigation"
description: "The navigation field in docs.json defines the pages that go in the navigation menu"
icon: "map"
---
The navigation menu is the list of links on every website.
You will likely update `docs.json` every time you add a new page. Pages do not show up automatically.
## Navigation syntax
Our navigation syntax is recursive which means you can make nested navigation groups. You don't need to include `.mdx` in page names.
<CodeGroup>
```json Regular Navigation
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Getting Started",
"pages": ["quickstart"]
}
]
}
]
}
```
```json Nested Navigation
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Getting Started",
"pages": [
"quickstart",
{
"group": "Nested Reference Pages",
"pages": ["nested-reference-page"]
}
]
}
]
}
]
}
```
</CodeGroup>
## Folders
Simply put your MDX files in folders and update the paths in `docs.json`.
For example, to have a page at `https://yoursite.com/your-folder/your-page` you would make a folder called `your-folder` containing an MDX file called `your-page.mdx`.
<Warning>
You cannot use `api` for the name of a folder unless you nest it inside another folder. Mintlify uses Next.js which reserves the top-level `api` folder for internal server calls. A folder name such as `api-reference` would be accepted.
</Warning>
```json Navigation With Folder
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Group Name",
"pages": ["your-folder/your-page"]
}
]
}
]
}
```
## Hidden pages
MDX files not included in `docs.json` will not show up in the sidebar but are accessible through the search bar and by linking directly to them.

View file

@ -1,112 +0,0 @@
---
title: "Reusable snippets"
description: "Reusable, custom snippets to keep content in sync"
icon: "recycle"
---
import SnippetIntro from "/snippets/snippet-intro.mdx"
<SnippetIntro />
## Creating a custom snippet
**Pre-condition**: You must create your snippet file in the `snippets` directory.
<Note>
Any page in the `snippets` directory will be treated as a snippet and will not be rendered into a standalone page. If
you want to create a standalone page from the snippet, import the snippet into another file and call it as a
component.
</Note>
### Default export
1. Add content to your snippet file that you want to re-use across multiple
locations. Optionally, you can add variables that can be filled in via props
when you import the snippet.
```mdx snippets/my-snippet.mdx
Hello world! This is my content I want to reuse across pages. My keyword of the
day is {word}.
```
<Warning>
The content that you want to reuse must be inside the `snippets` directory in order for the import to work.
</Warning>
2. Import the snippet into your destination file.
```mdx destination-file.mdx
---
title: My title
description: My Description
---
import MySnippet from "/snippets/path/to/my-snippet.mdx"
## Header
Lorem impsum dolor sit amet.
<MySnippet word="bananas" />
```
### Reusable variables
1. Export a variable from your snippet file:
```mdx snippets/path/to/custom-variables.mdx
export const myName = "my name"
export const myObject = { fruit: "strawberries" }
;
```
2. Import the snippet from your destination file and use the variable:
```mdx destination-file.mdx
---
title: My title
description: My Description
---
import { myName, myObject } from "/snippets/path/to/custom-variables.mdx"
Hello, my name is {myName} and I like {myObject.fruit}.
```
### Reusable components
1. Inside your snippet file, create a component that takes in props by exporting
your component in the form of an arrow function.
```mdx snippets/custom-component.mdx
export const MyComponent = ({ title }) => (
<div>
<h1>{title}</h1>
<p>... snippet content ...</p>
</div>
)
;
```
<Warning>
MDX does not compile inside the body of an arrow function. Stick to HTML syntax when you can or use a default export
if you need to use MDX.
</Warning>
2. Import the snippet into your destination file and pass in the props
```mdx destination-file.mdx
---
title: My title
description: My Description
---
import { MyComponent } from "/snippets/custom-component.mdx"
Lorem ipsum dolor sit amet.
<MyComponent title={"Custom title"} />
```

View file

@ -1,316 +0,0 @@
---
title: "Global Settings"
description: "Mintlify gives you complete control over the look and feel of your documentation using the docs.json file"
icon: "gear"
---
Every Mintlify site needs a `docs.json` file with the core configuration settings. Learn more about the [properties](#properties) below.
## Properties
<ResponseField name="name" type="string" required>
Name of your project. Used for the global title.
Example: `mintlify`
</ResponseField>
<ResponseField name="navigation" type="Navigation[]" required>
An array of groups with all the pages within that group
<Expandable title="Navigation">
<ResponseField name="group" type="string">
The name of the group.
Example: `Settings`
</ResponseField>
<ResponseField name="pages" type="string[]">
The relative paths to the markdown files that will serve as pages.
Example: `["customization", "page"]`
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="logo" type="string or object">
Path to logo image or object with path to "light" and "dark" mode logo images
<Expandable title="Logo">
<ResponseField name="light" type="string">
Path to the logo in light mode
</ResponseField>
<ResponseField name="dark" type="string">
Path to the logo in dark mode
</ResponseField>
<ResponseField name="href" type="string" default="/">
Where clicking on the logo links you to
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="favicon" type="string">
Path to the favicon image
</ResponseField>
<ResponseField name="colors" type="Colors">
Hex color codes for your global theme
<Expandable title="Colors">
<ResponseField name="primary" type="string" required>
The primary color. Used most often for highlighted content, section headers, accents, in light mode
</ResponseField>
<ResponseField name="light" type="string">
The primary color for dark mode. Used most often for highlighted content, section headers, accents, in dark mode
</ResponseField>
<ResponseField name="dark" type="string">
The primary color for important buttons
</ResponseField>
<ResponseField name="background" type="object">
The color of the background in both light and dark mode
<Expandable title="Object">
<ResponseField name="light" type="string" required>
The hex color code of the background in light mode
</ResponseField>
<ResponseField name="dark" type="string" required>
The hex color code of the background in dark mode
</ResponseField>
</Expandable>
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="topbarLinks" type="TopbarLink[]">
Array of `name`s and `url`s of links you want to include in the topbar
<Expandable title="TopbarLink">
<ResponseField name="name" type="string">
The name of the button.
Example: `Contact us`
</ResponseField>
<ResponseField name="url" type="string">
The url once you click on the button. Example: `https://mintlify.com/docs`
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="topbarCtaButton" type="Call to Action">
<Expandable title="Topbar Call to Action">
<ResponseField name="type" type={'"link" or "github"'} default="link">
Link shows a button. GitHub shows the repo information at the url provided including the number of GitHub stars.
</ResponseField>
<ResponseField name="url" type="string">
If `link`: What the button links to.
If `github`: Link to the repository to load GitHub information from.
</ResponseField>
<ResponseField name="name" type="string">
Text inside the button. Only required if `type` is a `link`.
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="versions" type="string[]">
Array of version names. Only use this if you want to show different versions of docs with a dropdown in the navigation
bar.
</ResponseField>
<ResponseField name="anchors" type="Anchor[]">
An array of the anchors, includes the `icon`, `color`, and `url`.
<Expandable title="Anchor">
<ResponseField name="icon" type="string">
The [Font Awesome](https://fontawesome.com/search?q=heart) icon used to feature the anchor.
Example: `comments`
</ResponseField>
<ResponseField name="name" type="string">
The name of the anchor label.
Example: `Community`
</ResponseField>
<ResponseField name="url" type="string">
The start of the URL that marks what pages go in the anchor. Generally, this is the name of the folder you put your pages in.
</ResponseField>
<ResponseField name="color" type="string">
The hex color of the anchor icon background. Can also be a gradient if you pass an object with the properties `from` and `to` that are each a hex color.
</ResponseField>
<ResponseField name="version" type="string">
Used if you want to hide an anchor until the correct docs version is selected.
</ResponseField>
<ResponseField name="isDefaultHidden" type="boolean" default="false">
Pass `true` if you want to hide the anchor until you directly link someone to docs inside it.
</ResponseField>
<ResponseField name="iconType" default="duotone" type="string">
One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin"
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="topAnchor" type="Object">
Override the default configurations for the top-most anchor.
<Expandable title="Object">
<ResponseField name="name" default="Documentation" type="string">
The name of the top-most anchor
</ResponseField>
<ResponseField name="icon" default="book-open" type="string">
Font Awesome icon.
</ResponseField>
<ResponseField name="iconType" default="duotone" type="string">
One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin"
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="tabs" type="Tabs[]">
An array of navigational tabs.
<Expandable title="Tabs">
<ResponseField name="name" type="string">
The name of the tab label.
</ResponseField>
<ResponseField name="url" type="string">
The start of the URL that marks what pages go in the tab. Generally, this is the name of the folder you put your
pages in.
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="api" type="API">
Configuration for API settings. Learn more about API pages at [API Components](/api-playground/demo).
<Expandable title="API">
<ResponseField name="baseUrl" type="string">
The base url for all API endpoints. If `baseUrl` is an array, it will enable for multiple base url
options that the user can toggle.
</ResponseField>
<ResponseField name="auth" type="Auth">
<Expandable title="Auth">
<ResponseField name="method" type='"bearer" | "basic" | "key"'>
The authentication strategy used for all API endpoints.
</ResponseField>
<ResponseField name="name" type="string">
The name of the authentication parameter used in the API playground.
If method is `basic`, the format should be `[usernameName]:[passwordName]`
</ResponseField>
<ResponseField name="inputPrefix" type="string">
The default value that's designed to be a prefix for the authentication input field.
E.g. If an `inputPrefix` of `AuthKey` would inherit the default input result of the authentication field as `AuthKey`.
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="playground" type="Playground">
Configurations for the API playground
<Expandable title="Playground">
<ResponseField name="mode" default="show" type='"show" | "simple" | "hide"'>
Whether the playground is showing, hidden, or only displaying the endpoint with no added user interactivity `simple`
Learn more at the [playground guides](/api-playground/demo)
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="maintainOrder" type="boolean">
Enabling this flag ensures that key ordering in OpenAPI pages matches the key ordering defined in the OpenAPI file.
<Warning>This behavior will soon be enabled by default, at which point this field will be deprecated.</Warning>
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="openapi" type="string | string[]">
A string or an array of strings of URL(s) or relative path(s) pointing to your
OpenAPI file.
Examples:
<CodeGroup>
```json Absolute
"openapi": "https://example.com/openapi.json"
```
```json Relative
"openapi": "/openapi.json"
```
```json Multiple
"openapi": ["https://example.com/openapi1.json", "/openapi2.json", "/openapi3.json"]
```
</CodeGroup>
</ResponseField>
<ResponseField name="footerSocials" type="FooterSocials">
An object of social media accounts where the key:property pair represents the social media platform and the account url.
Example:
```json
{
"x": "https://x.com/mintlify",
"website": "https://mintlify.com"
}
```
<Expandable title="FooterSocials">
<ResponseField name="[key]" type="string">
One of the following values `website`, `facebook`, `x`, `discord`, `slack`, `github`, `linkedin`, `instagram`, `hacker-news`
Example: `x`
</ResponseField>
<ResponseField name="property" type="string">
The URL to the social platform.
Example: `https://x.com/mintlify`
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="feedback" type="Feedback">
Configurations to enable feedback buttons
<Expandable title="Feedback">
<ResponseField name="suggestEdit" type="boolean" default="false">
Enables a button to allow users to suggest edits via pull requests
</ResponseField>
<ResponseField name="raiseIssue" type="boolean" default="false">
Enables a button to allow users to raise an issue about the documentation
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="modeToggle" type="ModeToggle">
Customize the dark mode toggle.
<Expandable title="ModeToggle">
<ResponseField name="default" type={'"light" or "dark"'}>
Set if you always want to show light or dark mode for new users. When not
set, we default to the same mode as the user's operating system.
</ResponseField>
<ResponseField name="isHidden" type="boolean" default="false">
Set to true to hide the dark/light mode toggle. You can combine `isHidden` with `default` to force your docs to only use light or dark mode. For example:
<CodeGroup>
```json Only Dark Mode
"modeToggle": {
"default": "dark",
"isHidden": true
}
```
```json Only Light Mode
"modeToggle": {
"default": "light",
"isHidden": true
}
```
</CodeGroup>
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="backgroundImage" type="string">
A background image to be displayed behind every page. See example with [Infisical](https://infisical.com/docs) and
[FRPC](https://frpc.io).
</ResponseField>

View file

@ -1,19 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.06145 23.1079C5.26816 22.3769 -3.39077 20.6274 1.4173 5.06384C9.6344 6.09939 16.9728 14.0644 9.06145 23.1079Z" fill="url(#paint0_linear_17557_2021)"/>
<path d="M8.91928 23.0939C5.27642 21.2223 0.78371 4.20891 17.0071 0C20.7569 7.19341 19.6212 16.5452 8.91928 23.0939Z" fill="url(#paint1_linear_17557_2021)"/>
<path d="M8.91388 23.0788C8.73534 19.8817 10.1585 9.08525 23.5699 13.1107C23.1812 20.1229 18.984 26.4182 8.91388 23.0788Z" fill="url(#paint2_linear_17557_2021)"/>
<defs>
<linearGradient id="paint0_linear_17557_2021" x1="3.77557" y1="5.91571" x2="5.23185" y2="21.5589" gradientUnits="userSpaceOnUse">
<stop stop-color="#18E299"/>
<stop offset="1" stop-color="#15803D"/>
</linearGradient>
<linearGradient id="paint1_linear_17557_2021" x1="12.1711" y1="-0.718425" x2="10.1897" y2="22.9832" gradientUnits="userSpaceOnUse">
<stop stop-color="#16A34A"/>
<stop offset="1" stop-color="#4ADE80"/>
</linearGradient>
<linearGradient id="paint2_linear_17557_2021" x1="23.1327" y1="15.353" x2="9.33841" y2="18.5196" gradientUnits="userSpaceOnUse">
<stop stop-color="#4ADE80"/>
<stop offset="1" stop-color="#0D9373"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,19 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.06145 23.1079C5.26816 22.3769 -3.39077 20.6274 1.4173 5.06384C9.6344 6.09939 16.9728 14.0644 9.06145 23.1079Z" fill="url(#paint0_linear_17557_2021)"/>
<path d="M8.91928 23.0939C5.27642 21.2223 0.78371 4.20891 17.0071 0C20.7569 7.19341 19.6212 16.5452 8.91928 23.0939Z" fill="url(#paint1_linear_17557_2021)"/>
<path d="M8.91388 23.0788C8.73534 19.8817 10.1585 9.08525 23.5699 13.1107C23.1812 20.1229 18.984 26.4182 8.91388 23.0788Z" fill="url(#paint2_linear_17557_2021)"/>
<defs>
<linearGradient id="paint0_linear_17557_2021" x1="3.77557" y1="5.91571" x2="5.23185" y2="21.5589" gradientUnits="userSpaceOnUse">
<stop stop-color="#18E299"/>
<stop offset="1" stop-color="#15803D"/>
</linearGradient>
<linearGradient id="paint1_linear_17557_2021" x1="12.1711" y1="-0.718425" x2="10.1897" y2="22.9832" gradientUnits="userSpaceOnUse">
<stop stop-color="#16A34A"/>
<stop offset="1" stop-color="#4ADE80"/>
</linearGradient>
<linearGradient id="paint2_linear_17557_2021" x1="23.1327" y1="15.353" x2="9.33841" y2="18.5196" gradientUnits="userSpaceOnUse">
<stop stop-color="#4ADE80"/>
<stop offset="1" stop-color="#0D9373"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

View file

@ -1,56 +0,0 @@
---
title: "Introduction"
description: "Welcome to the new home for your documentation"
---
## Setting up
Get your documentation site up and running in minutes.
<Card title="Start here" icon="rocket" href="/quickstart" horizontal>
Follow our three step quickstart guide.
</Card>
## Make it yours
Design a docs site that looks great and empowers your users.
<Columns cols={2}>
<Card title="Edit locally" icon="pen-to-square" href="/development">
Edit your docs locally and preview them in real time.
</Card>
<Card title="Customize your site" icon="palette" href="/essentials/settings">
Customize the design and colors of your site to match your brand.
</Card>
<Card title="Set up navigation" icon="map" href="/essentials/navigation">
Organize your docs to help users find what they need and succeed with your product.
</Card>
<Card title="API documentation" icon="terminal" href="/api-reference/introduction">
Auto-generate API documentation from OpenAPI specifications.
</Card>
</Columns>
## Create beautiful pages
Everything you need to create world-class documentation.
<Columns cols={2}>
<Card title="Write with MDX" icon="pen-fancy" href="/essentials/markdown">
Use MDX to style your docs pages.
</Card>
<Card title="Code samples" icon="code" href="/essentials/code">
Add sample code to demonstrate how to use your product.
</Card>
<Card title="Images" icon="image" href="/essentials/images">
Display images and other media.
</Card>
<Card title="Reusable snippets" icon="recycle" href="/essentials/reusable-snippets">
Write once and reuse across your docs.
</Card>
</Columns>
## Need inspiration?
<Card title="See complete examples" icon="stars" href="https://mintlify.com/customers">
Browse our showcase of exceptional documentation sites.
</Card>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1 +0,0 @@
../sdk/openapi.json

View file

@ -1,81 +0,0 @@
---
title: "Quickstart"
description: "Start building awesome documentation in minutes"
---
## Get started in three steps
Get your documentation site running locally and make your first customization.
### Step 1: Set up your local environment
<AccordionGroup>
<Accordion icon="copy" title="Clone your docs locally">
During the onboarding process, you created a GitHub repository with your docs content if you didn't already have
one. You can find a link to this repository in your [dashboard](https://dashboard.mintlify.com). To clone the
repository locally so that you can make and preview changes to your docs, follow the [Cloning a
repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) guide
in the GitHub docs.
</Accordion>
<Accordion icon="rectangle-terminal" title="Start the preview server">
1. Install the Mintlify CLI: `npm i -g mint` 2. Navigate to your docs directory and run: `mint dev` 3. Open
`http://localhost:3000` to see your docs live!
<Tip>Your preview updates automatically as you edit files.</Tip>
</Accordion>
</AccordionGroup>
### Step 2: Deploy your changes
<AccordionGroup>
<Accordion icon="github" title="Install our GitHub app">
Install the Mintlify GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app).
Our GitHub app automatically deploys your changes to your docs site, so you don't need to manage deployments yourself.
</Accordion>
<Accordion icon="palette" title="Update your site name and colors">
For a first change, let's update the name and colors of your docs site.
1. Open `docs.json` in your editor.
2. Change the `"name"` field to your project name.
3. Update the `"colors"` to match your brand.
4. Save and see your changes instantly at `http://localhost:3000`.
<Tip>Try changing the primary color to see an immediate difference!</Tip>
</Accordion>
</AccordionGroup>
### Step 3: Go live
<Accordion icon="rocket" title="Publish your docs">
1. Commit and push your changes. 2. Your docs will update and be live in moments!
</Accordion>
## Next steps
Now that you have your docs running, explore these key features:
<CardGroup cols={2}>
<Card title="Write Content" icon="pen-to-square" href="/essentials/markdown">
Learn MDX syntax and start writing your documentation.
</Card>
<Card title="Customize style" icon="palette" href="/essentials/settings">
Make your docs match your brand perfectly.
</Card>
<Card title="Add code examples" icon="square-code" href="/essentials/code">
Include syntax-highlighted code blocks.
</Card>
<Card title="API documentation" icon="code" href="/api-reference/introduction">
Auto-generate API docs from OpenAPI specs.
</Card>
</CardGroup>
<Note>
**Need help?** See our [full documentation](https://mintlify.com/docs) or join our
[community](https://mintlify.com/community).
</Note>

View file

@ -1,4 +0,0 @@
One of the core principles of software development is DRY (Don't Repeat
Yourself). This is a principle that applies to documentation as
well. If you find yourself repeating the same content in multiple places, you
should consider creating a custom snippet to keep your content in sync.

View file

@ -0,0 +1,46 @@
/** JSON-compatible cassette metadata value. */
export type JsonValue =
| null
| boolean
| number
| string
| ReadonlyArray<JsonValue>
| { readonly [key: string]: JsonValue }
/** Additional JSON metadata stored with a cassette. */
export type CassetteMetadata = Readonly<Record<string, JsonValue>>
/** The normalized HTTP request representation used for matching. */
export interface RequestSnapshot {
readonly method: string
readonly url: string
readonly headers: Record<string, string>
readonly body: string
}
/** Returns whether an incoming HTTP request matches a recorded request. */
export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean
/** Additive redaction and header-preservation policy. */
export interface RedactOptions {
readonly headers?: ReadonlyArray<string>
readonly allowRequestHeaders?: ReadonlyArray<string>
readonly allowResponseHeaders?: ReadonlyArray<string>
readonly queryParameters?: ReadonlyArray<string>
readonly jsonFields?: ReadonlyArray<string>
readonly url?: (url: string) => string
readonly body?: (body: string) => string
}
/** Options shared by HTTP recorder layers. */
export interface RecorderOptions {
readonly directory?: string
readonly metadata?: CassetteMetadata
readonly redact?: RedactOptions
readonly match?: RequestMatcher
}
/** Recorder configuration for Effect socket and WebSocket layers. */
export type SocketRecorderOptions = Omit<RecorderOptions, "match">
export * as Api from "./api.js"

View file

@ -0,0 +1,43 @@
import { Schema } from "effect"
import type { CassetteMetadata, JsonValue } from "../api.js"
import { HttpInteractionSchema } from "../http/model.js"
import { WebSocketInteractionSchema } from "../websocket/model.js"
export type { CassetteMetadata, JsonValue } from "../api.js"
const JsonValueSchema = Schema.suspend(
(): Schema.Codec<JsonValue> =>
Schema.Union([
Schema.Null,
Schema.Boolean,
Schema.Number,
Schema.String,
Schema.Array(JsonValueSchema),
Schema.Record(Schema.String, JsonValueSchema),
]),
)
export const CassetteMetadataSchema = Schema.Record(Schema.String, JsonValueSchema)
export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe(
Schema.toTaggedUnion("transport"),
)
export type Interaction = Schema.Schema.Type<typeof InteractionSchema>
export const isHttpInteraction = InteractionSchema.guards.http
export const isWebSocketInteraction = InteractionSchema.guards.websocket
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
interactions.filter(isWebSocketInteraction)
export const CassetteSchema = Schema.Struct({
version: Schema.Literal(1),
metadata: Schema.optional(CassetteMetadataSchema),
interactions: Schema.Array(InteractionSchema),
})
export type Cassette = Schema.Schema.Type<typeof CassetteSchema>
export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema)
export const encodeCassette = Schema.encodeSync(CassetteSchema)
export * as CassetteModel from "./model.js"

View file

@ -0,0 +1,201 @@
import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect"
import { existsSync, rmSync } from "node:fs"
import path from "node:path"
import { secretFindings, SecretFindingSchema, type SecretFinding } from "../redaction/secrets.js"
import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./model.js"
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
cassetteName: Schema.String,
}) {
override get message() {
return `Cassette "${this.cassetteName}" not found`
}
}
export class InvalidCassetteError extends Schema.TaggedErrorClass<InvalidCassetteError>()("InvalidCassetteError", {
cassetteName: Schema.String,
description: Schema.String,
}) {
override get message() {
return `Cassette "${this.cassetteName}" is invalid: ${this.description}`
}
}
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
cassetteName: Schema.String,
findings: Schema.Array(SecretFindingSchema),
}) {
override get message() {
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
.map((finding) => `${finding.path} (${finding.reason})`)
.join(", ")}`
}
}
export interface Interface {
readonly read: (
name: string,
) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError | InvalidCassetteError>
readonly append: (
name: string,
interaction: Interaction,
metadata?: CassetteMetadata,
) => Effect.Effect<void, UnsafeCassetteError>
readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
const cassettePath = (directory: string, name: string) => {
if (!name || path.isAbsolute(name) || path.win32.isAbsolute(name) || name.split(/[\\/]/).includes(".."))
throw new Error(`Invalid cassette name "${name}"`)
const root = path.resolve(directory)
const target = path.resolve(root, `${name}.json`)
const relative = path.relative(root, target)
if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
throw new Error(`Invalid cassette name "${name}"`)
return target
}
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
export const removeCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
rmSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name), { force: true })
const buildCassette = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: { ...metadata, name, recordedAt: new Date().toISOString() },
interactions,
})
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))
const invalidCassette = (name: string, error: unknown) =>
new InvalidCassetteError({
cassetteName: name,
description: error instanceof Error ? error.message : String(error),
})
const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
export const fileSystem = (
options: { readonly directory?: string } = {},
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
const appendLock = yield* Semaphore.make(1)
const pathFor = (name: string) => cassettePath(directory, name)
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
Effect.gen(function* () {
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
const nested = yield* Effect.forEach(entries, (entry) => {
const full = path.join(current, entry)
return fs.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])),
)
})
return nested.flat()
})
return Service.of({
read: (name) =>
fs.readFileString(pathFor(name)).pipe(
Effect.mapError((error) =>
error.reason._tag === "NotFound"
? new CassetteNotFoundError({ cassetteName: name })
: invalidCassette(name, error),
),
Effect.flatMap((raw) =>
Effect.try({
try: () => parseCassette(raw).interactions,
catch: (error) => invalidCassette(name, error),
}),
),
),
append: (name, interaction, metadata) =>
appendLock.withPermit(
Effect.gen(function* () {
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
const interactions = [...entry.interactions, interaction]
const interactionFindings = [...entry.findings, ...secretFindings(interaction)]
const cassette = buildCassette(name, interactions, metadata)
const findings = [...interactionFindings, ...secretFindings(cassette.metadata ?? {})]
yield* failIfUnsafe(name, findings)
const target = pathFor(name)
yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie)
const temporary = `${target}.${crypto.randomUUID()}.tmp`
yield* fs.writeFileString(temporary, formatCassette(cassette)).pipe(
Effect.flatMap(() => fs.rename(temporary, target)),
Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.catch(() => Effect.void))),
Effect.orDie,
)
recorded.set(name, { interactions, findings: interactionFindings })
}),
),
exists: (name) =>
fs.access(pathFor(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
),
list: () =>
walk(directory).pipe(
Effect.map((files) =>
files
.filter((file) => file.endsWith(".json"))
.map((file) =>
path
.relative(directory, file)
.replace(/\\/g, "/")
.replace(/\.json$/, ""),
)
.toSorted((a, b) => a.localeCompare(b)),
),
),
})
}),
)
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
Layer.sync(Service, () => {
const stored = new Map<string, Interaction[]>(
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
)
const accumulatedFindings = new Map<string, SecretFinding[]>()
const appendLock = Semaphore.makeUnsafe(1)
return Service.of({
read: (name) =>
stored.has(name)
? Effect.succeed(stored.get(name) ?? [])
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
append: (name, interaction, metadata) =>
appendLock.withPermit(
Effect.suspend(() => {
const interactions = [...(stored.get(name) ?? []), interaction]
const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)]
const allFindings = metadata ? [...findings, ...secretFindings({ ...metadata, name })] : findings
return failIfUnsafe(name, allFindings).pipe(
Effect.tap(() =>
Effect.sync(() => {
stored.set(name, interactions)
accumulatedFindings.set(name, findings)
}),
),
)
}),
),
exists: (name) => Effect.sync(() => stored.has(name)),
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
})
})

View file

@ -0,0 +1,77 @@
import { HashSet, Option } from "effect"
import type { RequestMatcher, RequestSnapshot } from "../api.js"
import { canonicalizeJson, decodeJson, isJsonRecord, jsonBody, safeText } from "../replay/comparison.js"
import type { HttpInteraction } from "./model.js"
export type { RequestMatcher } from "../api.js"
export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
JSON.stringify({
method: snapshot.method,
url: snapshot.url,
headers: canonicalizeJson(snapshot.headers),
body: Option.match(decodeJson(snapshot.body), { onNone: () => snapshot.body, onSome: canonicalizeJson }),
})
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
if (Object.is(expected, received)) return []
if (isJsonRecord(expected) && isJsonRecord(received))
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
.toSorted()
.flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
.slice(0, limit)
if (Array.isArray(expected) && Array.isArray(received))
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
.slice(0, limit)
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
}
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
if (expected[key] === received[key]) return []
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
})
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
const lines: string[] = []
if (expected.method !== received.method)
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
if (expected.url !== received.url) lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
const headers = headerDiffs(expected.headers, received.headers)
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
const expectedBody = jsonBody(expected.body)
const receivedBody = jsonBody(received.body)
const body =
expectedBody !== undefined && receivedBody !== undefined
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
: expected.body === received.body
? []
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
if (body.length > 0) lines.push("body:", ...body)
return lines
}
export const selectFirstMatching = (
interactions: ReadonlyArray<HttpInteraction>,
incoming: RequestSnapshot,
match: RequestMatcher,
used: HashSet.HashSet<number>,
): { readonly _tag: "Matched"; readonly index: number } | { readonly _tag: "Unmatched"; readonly detail: string } => {
let firstUnused: HttpInteraction | undefined
for (let index = 0; index < interactions.length; index++) {
if (HashSet.has(used, index)) continue
const interaction = interactions[index]
firstUnused ??= interaction
if (match(incoming, interaction.request)) return { _tag: "Matched", index }
}
if (firstUnused === undefined)
return { _tag: "Unmatched", detail: `all ${interactions.length} recorded interactions have already been consumed` }
return { _tag: "Unmatched", detail: requestDiff(firstUnused.request, incoming).join("\n") }
}
export * as HttpMatching from "./matching.js"

View file

@ -0,0 +1,30 @@
import { Schema } from "effect"
import type { RequestSnapshot } from "../api.js"
export const RequestSnapshotSchema = Schema.Struct({
method: Schema.String,
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.String,
})
export type { RequestSnapshot } from "../api.js"
export const ResponseSnapshotSchema = Schema.Struct({
status: Schema.Number,
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.String,
bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
})
export interface ResponseSnapshot extends Schema.Schema.Type<typeof ResponseSnapshotSchema> {}
export const HttpInteractionSchema = Schema.Struct({
transport: Schema.tag("http"),
request: RequestSnapshotSchema,
response: ResponseSnapshotSchema,
})
export interface HttpInteraction extends Schema.Schema.Type<typeof HttpInteractionSchema> {}
export * as HttpModel from "./model.js"

View file

@ -0,0 +1,176 @@
import { NodeFileSystem } from "@effect/platform-node-shared"
import { Deferred, Effect, Layer, Ref } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { fileSystem, Service } from "../cassette/store.js"
import type { RecorderOptions } from "../options.js"
import { make, redactUrl, type Redactor } from "../redaction/redactor.js"
import { makeReplayPoolState, resolveAutoMode } from "../replay/state.js"
import { httpInteractions, type CassetteMetadata } from "../cassette/model.js"
import { defaultMatcher, selectFirstMatching, type RequestMatcher } from "./matching.js"
import type { HttpInteraction, ResponseSnapshot } from "./model.js"
export { defaultMatcher }
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
export interface RecordReplayOptions {
readonly mode?: RecordReplayMode
readonly directory?: string
readonly metadata?: CassetteMetadata
readonly redactor?: Redactor
readonly match?: RequestMatcher
}
const TEXT_CONTENT_TYPES = new Set([
"application/graphql",
"application/javascript",
"application/json",
"application/sql",
"application/x-www-form-urlencoded",
"application/xml",
"application/yaml",
"image/svg+xml",
])
const isTextContentType = (contentType: string | undefined) => {
const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase()
if (!mediaType) return false
return (
mediaType.startsWith("text/") ||
mediaType.endsWith("+json") ||
mediaType.endsWith("+xml") ||
TEXT_CONTENT_TYPES.has(mediaType)
)
}
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
response.arrayBuffer.pipe(
Effect.map((bytes) =>
isTextContentType(contentType)
? { body: new TextDecoder().decode(bytes) }
: { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const },
),
)
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snapshot: ResponseSnapshot) =>
HttpClientResponse.fromWeb(
request,
new Response(
request.method === "HEAD" || snapshot.status === 204 || snapshot.status === 205 || snapshot.status === 304
? null
: decodeResponseBody(snapshot),
snapshot,
),
)
export const redactedErrorRequest = (
request: HttpClientRequest.HttpClientRequest,
redactedUrl = redactUrl(request.url),
) => HttpClientRequest.make(request.method)(redactedUrl)
const transportError = (request: HttpClientRequest.HttpClientRequest, description: string, redactedUrl?: string) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request, redactedUrl), description }),
})
export const recordingLayer = (
name: string,
options: Omit<RecordReplayOptions, "directory"> = {},
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | Service> =>
Layer.effect(
HttpClient.HttpClient,
Effect.gen(function* () {
const upstream = yield* HttpClient.HttpClient
const cassette = yield* Service
const redactor = options.redactor ?? make()
const match = options.match ?? defaultMatcher
const requested = options.mode ?? "auto"
const mode = requested === "auto" ? yield* resolveAutoMode(cassette, name) : requested
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
return redactor.request({
method: web.method,
url: web.url,
headers: Object.fromEntries(web.headers.entries()),
body: yield* Effect.promise(() => web.text()),
})
})
if (mode === "passthrough") return upstream
if (mode === "record") {
const initial = yield* Deferred.make<void>()
yield* Deferred.succeed(initial, undefined)
const tail = yield* Ref.make(initial)
return HttpClient.make((request) =>
Effect.gen(function* () {
const completed = yield* Deferred.make<void>()
const previous = yield* Ref.modify(tail, (current) => [current, completed])
return yield* Effect.gen(function* () {
const incoming = yield* snapshotRequest(request)
const requestError = (description: string) => transportError(request, description, incoming.url)
const response = yield* upstream.execute(request)
const captured = yield* captureResponseBody(response, response.headers["content-type"])
const responseSnapshot: ResponseSnapshot = {
status: response.status,
headers: response.headers as Record<string, string>,
...captured,
}
const interaction: HttpInteraction = {
transport: "http",
request: incoming,
response: redactor.response(responseSnapshot),
}
yield* Deferred.await(previous)
yield* cassette
.append(name, interaction, options.metadata)
.pipe(Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(requestError(error.message))))
return responseFromSnapshot(request, responseSnapshot)
}).pipe(Effect.ensuring(Deferred.succeed(completed, undefined)))
}),
)
}
const replay = yield* makeReplayPoolState(cassette, name, httpInteractions)
return HttpClient.make((request) =>
Effect.gen(function* () {
const incoming = yield* snapshotRequest(request)
const requestError = (description: string) => transportError(request, description, incoming.url)
const claimed = yield* replay
.claim((interactions, used) => {
const result = selectFirstMatching(interactions, incoming, match, used)
if (result._tag === "Matched") return Effect.succeed(result.index)
return Effect.fail(
requestError(`Fixture "${name}" does not match the current request: ${result.detail}.`),
)
})
.pipe(
Effect.mapError((error) =>
error._tag === "CassetteNotFoundError"
? requestError(`Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`)
: requestError(error.message),
),
)
return responseFromSnapshot(request, claimed.interaction.response)
}),
)
}),
)
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
recordingLayer(name, options).pipe(
Layer.provide(fileSystem({ directory: options.directory })),
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer),
)
export const layer = (
name: string,
options: RecorderOptions = {},
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient> =>
recordingLayer(name, { metadata: options.metadata, redactor: make(options.redact), match: options.match }).pipe(
Layer.provide(fileSystem({ directory: options.directory })),
Layer.provide(NodeFileSystem.layer),
)
export const layerFetch = (name: string, options: RecorderOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
layer(name, options).pipe(Layer.provide(FetchHttpClient.layer))

View file

@ -0,0 +1 @@
export type { RecorderOptions, RedactOptions, SocketRecorderOptions } from "./api.js"

View file

@ -0,0 +1,173 @@
import { Option, Schema } from "effect"
import type { RequestSnapshot, ResponseSnapshot } from "../http/model.js"
import type { RedactOptions } from "../options.js"
export type { RedactOptions } from "../options.js"
export const REDACTED = "[REDACTED]"
const DEFAULT_REDACT_HEADERS = [
"authorization",
"cookie",
"proxy-authorization",
"set-cookie",
"x-api-key",
"x-amz-security-token",
"x-goog-api-key",
]
const DEFAULT_REDACT_QUERY = [
"access_token",
"api-key",
"api_key",
"apikey",
"code",
"key",
"signature",
"sig",
"token",
"x-amz-credential",
"x-amz-security-token",
"x-amz-signature",
]
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
export const redactUrl = (
raw: string,
query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY,
transform?: (url: string) => string,
) => {
if (!URL.canParse(raw)) return transform?.(raw) ?? raw
const url = new URL(raw)
if (url.username) url.username = REDACTED
if (url.password) url.password = REDACTED
const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
for (const key of url.searchParams.keys()) if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
return transform?.(url.toString()) ?? url.toString()
}
export const redactHeaders = (
headers: Record<string, string>,
allow: ReadonlyArray<string>,
redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
) => {
const allowed = new Set(allow.map((name) => name.toLowerCase()))
const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
return Object.fromEntries(
Object.entries(headers)
.map(([name, value]) => [name.toLowerCase(), value] as const)
.filter(([name]) => allowed.has(name))
.map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
.toSorted(([a], [b]) => a.localeCompare(b)),
)
}
const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
const identity = <T>(value: T) => value
export interface Redactor {
readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
}
export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
const requests = redactors
.map((redactor) => redactor.request)
.filter((fn): fn is Redactor["request"] => fn !== undefined)
const responses = redactors
.map((redactor) => redactor.response)
.filter((fn): fn is Redactor["response"] => fn !== undefined)
return {
request: requests.length === 0 ? identity : (snapshot) => requests.reduce((value, fn) => fn(value), snapshot),
response: responses.length === 0 ? identity : (snapshot) => responses.reduce((value, fn) => fn(value), snapshot),
}
}
interface HeaderOptions {
readonly allow?: ReadonlyArray<string>
readonly redact?: ReadonlyArray<string>
}
const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
request: (snapshot) => ({
...snapshot,
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
}),
})
const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
response: (snapshot) => ({
...snapshot,
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
}),
})
interface UrlOptions {
readonly query?: ReadonlyArray<string>
readonly transform?: (url: string) => string
}
const url = (options: UrlOptions = {}): Partial<Redactor> => ({
request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
})
const DEFAULT_REDACT_JSON_FIELDS = [
"access_token",
"api_key",
"apikey",
"client_secret",
"password",
"refresh_token",
"secret",
"token",
]
const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase()
interface RedactedJson {
readonly value: unknown
readonly changed: boolean
}
const redactJsonFields = (value: unknown, fields: ReadonlySet<string>): RedactedJson => {
if (Array.isArray(value)) {
const items = value.map((item) => redactJsonFields(item, fields))
return { value: items.map((item) => item.value), changed: items.some((item) => item.changed) }
}
if (!value || typeof value !== "object") return { value, changed: false }
let changed = false
const entries = Object.entries(value).map(([key, child]) => {
if (fields.has(normalizeField(key))) {
if (child !== REDACTED) changed = true
return [key, REDACTED] as const
}
const redacted = redactJsonFields(child, fields)
if (redacted.changed) changed = true
return [key, redacted.value] as const
})
return { value: Object.fromEntries(entries), changed }
}
const redactBody = (value: string, fields: ReadonlySet<string>, transform: ((body: string) => string) | undefined) => {
const redacted = Option.match(decodeJson(value), {
onNone: () => value,
onSome: (parsed) => {
const result = redactJsonFields(parsed, fields)
return result.changed ? JSON.stringify(result.value) : value
},
})
return transform?.(redacted) ?? redacted
}
export const make = (options: RedactOptions = {}): Redactor => {
const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField))
return compose(
requestHeaders({
allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])],
redact: options.headers,
}),
responseHeaders({
allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])],
redact: options.headers,
}),
url({ query: options.queryParameters, transform: options.url }),
{
request: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }),
response: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }),
},
)
}

View file

@ -0,0 +1,47 @@
import { Schema } from "effect"
const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
{ label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
{ label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
{ label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
{ label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
{ label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
{ label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
{ label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
]
const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
const envSecrets = () =>
Object.entries(process.env).flatMap(([name, value]) => {
if (!value || !ENV_SECRET_NAMES.test(name) || value.length < 12 || SAFE_ENV_VALUES.has(value.toLowerCase()))
return []
return [{ name, value }]
})
const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
if (typeof value === "string") return [{ path: base, value }]
if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
if (value && typeof value === "object")
return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
return []
}
export const SecretFindingSchema = Schema.Struct({ path: Schema.String, reason: Schema.String })
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> => {
const environment = envSecrets()
return stringEntries(value).flatMap((entry) => [
...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
path: entry.path,
reason: item.label,
})),
...environment
.filter((item) => entry.value.includes(item.value))
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
])
}

View file

@ -0,0 +1,29 @@
import { Option, Schema } from "effect"
import { REDACTED } from "../redaction/redactor.js"
import { secretFindings } from "../redaction/secrets.js"
export const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value)
export const canonicalizeJson = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(canonicalizeJson)
if (isRecord(value))
return Object.fromEntries(
Object.keys(value)
.toSorted()
.map((key) => [key, canonicalizeJson(value[key])]),
)
return value
}
export const safeText = (value: unknown) => {
if (value === undefined) return "undefined"
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
const text = JSON.stringify(value)
if (!text) return typeof value
return text.length > 300 ? `${text.slice(0, 300)}...` : text
}
export const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
export const isJsonRecord = isRecord

View file

@ -0,0 +1,96 @@
import { Effect, Exit, HashSet, Ref, Scope, SynchronizedRef } from "effect"
import type { Interaction } from "../cassette/model.js"
import type { CassetteNotFoundError, Interface, InvalidCassetteError } from "../cassette/store.js"
const isCI = () => {
const value = process.env.CI
return value !== undefined && value !== "" && value !== "false" && value !== "0"
}
export const resolveAutoMode = (
cassette: Interface,
name: string,
): Effect.Effect<"record" | "replay" | "passthrough"> =>
Effect.gen(function* () {
if (isCI()) return "replay"
return (yield* cassette.exists(name)) ? "replay" : "record"
})
export interface ReplayState<T> {
readonly claim: <E>(
validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray<T>) => Effect.Effect<void, E>,
) => Effect.Effect<
{ readonly interaction: T; readonly index: number },
CassetteNotFoundError | InvalidCassetteError | E
>
}
export interface ReplayPoolState<T> {
readonly claim: <E>(
select: (interactions: ReadonlyArray<T>, used: HashSet.HashSet<number>) => Effect.Effect<number, E>,
) => Effect.Effect<
{ readonly interaction: T; readonly index: number },
CassetteNotFoundError | InvalidCassetteError | E
>
}
export const makeReplayPoolState = <T>(
cassette: Interface,
name: string,
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
): Effect.Effect<ReplayPoolState<T>, never, Scope.Scope> =>
Effect.gen(function* () {
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
const claimed = yield* SynchronizedRef.make(HashSet.empty<number>())
const attempted = yield* Ref.make(false)
yield* Effect.addFinalizer((exit) =>
Exit.isFailure(exit)
? Effect.void
: Effect.gen(function* () {
const used = yield* SynchronizedRef.get(claimed)
if (HashSet.isEmpty(used) && (yield* Ref.get(attempted))) return yield* Effect.void
const interactions = yield* load.pipe(
Effect.catchTag("CassetteNotFoundError", () => Effect.succeed([] as ReadonlyArray<T>)),
Effect.orDie,
)
if (HashSet.size(used) < interactions.length)
return yield* Effect.die(
new Error(
`Unused recorded interactions in ${name}: used ${HashSet.size(used)} of ${interactions.length}`,
),
)
return yield* Effect.void
}),
)
return {
claim: (select) =>
Ref.set(attempted, true).pipe(
Effect.andThen(load),
Effect.flatMap((interactions) =>
SynchronizedRef.modifyEffect(claimed, (used) =>
Effect.gen(function* () {
const index = yield* select(interactions, used)
const interaction = interactions[index]
if (interaction === undefined || HashSet.has(used, index))
return yield* Effect.die("Replay selected an unavailable interaction")
return [{ interaction, index }, HashSet.add(used, index)] as const
}),
),
),
),
}
})
export const makeReplayState = <T>(
cassette: Interface,
name: string,
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
makeReplayPoolState(cassette, name, project).pipe(
Effect.map((pool) => ({
claim: (validate) =>
pool.claim((interactions, used) => {
const index = HashSet.size(used)
return validate(interactions[index], index, interactions).pipe(Effect.as(index))
}),
})),
)

View file

@ -0,0 +1,35 @@
import { Schema } from "effect"
export const WebSocketEventSchema = Schema.Union([
Schema.Struct({
direction: Schema.Literals(["client", "server"]),
kind: Schema.tag("text"),
body: Schema.String,
}),
Schema.Struct({
direction: Schema.Literals(["client", "server"]),
kind: Schema.tag("binary"),
body: Schema.String,
bodyEncoding: Schema.Literal("base64"),
}),
])
export type WebSocketEvent = Schema.Schema.Type<typeof WebSocketEventSchema>
export const WebSocketInteractionSchema = Schema.Struct({
transport: Schema.tag("websocket"),
connection: Schema.optional(
Schema.Struct({
sequence: Schema.Number,
url: Schema.String,
protocols: Schema.Array(Schema.String),
close: Schema.Struct({
code: Schema.Number,
reason: Schema.String,
}),
}),
),
events: Schema.Array(WebSocketEventSchema),
})
export interface WebSocketInteraction extends Schema.Schema.Type<typeof WebSocketInteractionSchema> {}

View file

@ -0,0 +1,584 @@
import { NodeFileSystem } from "@effect/platform-node-shared"
import { Deferred, Effect, Exit, FiberSet, Layer, Option, Ref, Scope, Semaphore } from "effect"
import { Socket } from "effect/unstable/socket"
import { fileSystem, type Interface, Service } from "../cassette/store.js"
import type { SocketRecorderOptions } from "../options.js"
import { make, type Redactor } from "../redaction/redactor.js"
import { canonicalizeJson, decodeJson, safeText } from "../replay/comparison.js"
import { makeReplayState, resolveAutoMode } from "../replay/state.js"
import { webSocketInteractions, type Interaction } from "../cassette/model.js"
import type { WebSocketEvent, WebSocketInteraction } from "./model.js"
interface WebSocketRecorderOptions extends SocketRecorderOptions {
readonly compareClientMessagesAsJson?: boolean
}
interface ActiveReplay {
readonly interaction: WebSocketInteraction
readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred<void> }>
readonly writeLock: Semaphore.Semaphore
readonly closed: Ref.Ref<boolean>
}
interface ActiveRecording {
readonly events: Array<WebSocketEvent>
readonly eventLock: Semaphore.Semaphore
readonly accepting: Ref.Ref<boolean>
opened: boolean
valid: boolean
}
interface PendingRecordings {
readonly promises: Set<Promise<void>>
readonly errors: Array<unknown>
}
type Frame = string | Uint8Array
const normalizeProtocols = (protocols?: string | Array<string>): Array<string> =>
protocols === undefined ? [] : typeof protocols === "string" ? [protocols] : [...protocols]
const frameFromWebSocketData = async (data: unknown): Promise<Frame> => {
if (typeof data === "string") return data
if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer())
if (data instanceof ArrayBuffer) return new Uint8Array(data)
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice()
throw new Error(`Unsupported WebSocket frame: ${Object.prototype.toString.call(data)}`)
}
const closeEvent = (code: number, reason: string): CloseEvent => {
if (typeof globalThis.CloseEvent === "function")
return new globalThis.CloseEvent("close", { code, reason, wasClean: code === 1000 })
const event = new Event("close")
Object.defineProperties(event, {
code: { value: code },
reason: { value: reason },
wasClean: { value: code === 1000 },
})
return event as CloseEvent
}
const errorEvent = (error: unknown): ErrorEvent => {
if (typeof globalThis.ErrorEvent === "function")
return new globalThis.ErrorEvent("error", {
error,
message: error instanceof Error ? error.message : String(error),
})
const event = new Event("error")
Object.defineProperties(event, {
error: { value: error },
message: { value: error instanceof Error ? error.message : String(error) },
})
return event as ErrorEvent
}
const webSocketFacade = (
target: EventTarget,
properties: {
readonly url: () => string
readonly readyState: () => number
readonly protocol: () => string
readonly extensions: () => string
readonly bufferedAmount: () => number
readonly send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void
readonly close: (code?: number, reason?: string) => void
},
): globalThis.WebSocket => {
Object.defineProperties(target, {
url: { get: properties.url },
readyState: { get: properties.readyState },
protocol: { get: properties.protocol },
extensions: { get: properties.extensions },
bufferedAmount: { get: properties.bufferedAmount },
binaryType: { value: "blob", writable: true },
send: { value: properties.send },
close: { value: properties.close },
CONNECTING: { value: 0 },
OPEN: { value: 1 },
CLOSING: { value: 2 },
CLOSED: { value: 3 },
})
for (const name of ["open", "message", "error", "close"] as const) {
let handler: ((event: Event) => unknown) | null = null
Object.defineProperty(target, `on${name}`, {
get: () => handler,
set: (next) => {
if (handler) target.removeEventListener(name, handler)
handler = typeof next === "function" ? next : null
if (handler) target.addEventListener(name, handler)
},
})
}
return target as globalThis.WebSocket
}
const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent =>
typeof message === "string"
? { direction, kind: "text", body: message }
: { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
const decodeEvent = (event: WebSocketEvent): Frame =>
event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => {
if (event.kind === "binary") return event
const body =
event.direction === "client"
? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
: redactor.response({ status: 101, headers: {}, body: event.body }).body
return { ...event, body }
}
const comparable = (event: WebSocketEvent, asJson: boolean) => {
if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event))
const decoded = decodeJson(event.body)
return JSON.stringify(
canonicalizeJson({ ...event, body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value) }),
)
}
const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
Effect.sync(() => {
if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return
throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
})
const runHandler = <A, E, R>(handler: (value: A) => Effect.Effect<unknown, E, R> | void, value: A) =>
Effect.suspend(() => {
const result = handler(value)
return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void
})
const runReplay = <A, E, R>(
state: ActiveReplay,
handler: (value: A) => Effect.Effect<unknown, E, R> | void,
decode: (event: WebSocketEvent) => A,
onOpen: Effect.Effect<void> | undefined,
) =>
Effect.scoped(
Effect.gen(function* () {
const handlers = yield* FiberSet.make<unknown, E>()
const run = yield* FiberSet.runtime(handlers)<R>()
if (onOpen) yield* onOpen
const drive = Effect.gen(function* () {
while (true) {
const current = yield* Ref.get(state.progress)
const event = state.interaction.events[current.position]
if (!event) return
if (yield* Ref.get(state.closed))
return yield* Effect.die(
new Error(
`WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
),
)
if (event.direction === "server") {
yield* Ref.set(state.progress, { position: current.position + 1, changed: yield* Deferred.make<void>() })
run(runHandler(handler, decode(event)))
continue
}
yield* Deferred.await(current.changed)
}
})
yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers)))
yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers)))
}),
)
const makeRecordingSocket = (
upstream: Socket.Socket,
cassette: Interface,
name: string,
options: WebSocketRecorderOptions,
redactor: Redactor,
) =>
Effect.gen(function* () {
const active = yield* Ref.make<ActiveRecording | undefined>(undefined)
const writeLock = yield* Semaphore.make(1)
return Socket.make({
runRaw: (handler, runOptions) =>
Effect.gen(function* () {
const state: ActiveRecording = {
events: [],
eventLock: yield* Semaphore.make(1),
accepting: yield* Ref.make(true),
opened: false,
valid: true,
}
const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported")
yield* upstream
.runRaw(
(message) => {
if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing")
state.events.push(redactEvent(encodeEvent("server", message), redactor))
return handler(message)
},
{
...runOptions,
onOpen: Effect.gen(function* () {
state.opened = true
if (runOptions?.onOpen) yield* runOptions.onOpen
}),
},
)
.pipe(
Effect.onExit((exit) =>
writeLock.withPermit(
state.eventLock.withPermit(
Effect.gen(function* () {
yield* Ref.set(state.accepting, false)
yield* Ref.set(active, undefined)
if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return
yield* cassette
.append(
name,
{
transport: "websocket",
events: [...state.events],
},
options.metadata,
)
.pipe(Effect.orDie)
}),
),
),
),
)
}),
writer: upstream.writer.pipe(
Effect.map(
(write) => (message) =>
writeLock.withPermit(
Effect.gen(function* () {
if (Socket.isCloseEvent(message)) return yield* write(message)
const state = yield* Ref.get(active)
if (!state || !(yield* Ref.get(state.accepting)))
return yield* Effect.die("WebSocket writer used without an active socket run")
const event = redactEvent(encodeEvent("client", message), redactor)
yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event)))
return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false))))
}),
),
),
),
})
})
const makeReplaySocket = (
cassette: Interface,
name: string,
options: WebSocketRecorderOptions,
redactor: Redactor,
): Effect.Effect<Socket.Socket, never, Scope.Scope> =>
Effect.gen(function* () {
const replay = yield* makeReplayState(cassette, name, webSocketInteractions)
const active = yield* Ref.make<ActiveReplay | undefined>(undefined)
const runLock = yield* Semaphore.make(1)
return Socket.make({
runRaw: (handler, runOptions) =>
runLock
.withPermitsIfAvailable(1)(
Effect.gen(function* () {
const claimed = yield* replay
.claim((interaction) =>
interaction ? Effect.void : Effect.die("Missing recorded WebSocket interaction"),
)
.pipe(Effect.orDie)
const state = {
interaction: claimed.interaction,
progress: yield* Ref.make({ position: 0, changed: yield* Deferred.make<void>() }),
writeLock: yield* Semaphore.make(1),
closed: yield* Ref.make(false),
}
yield* Ref.set(active, state)
yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe(
Effect.ensuring(Ref.set(active, undefined)),
)
}),
)
.pipe(
Effect.flatMap(
Option.match({
onNone: () => Effect.die("Concurrent runs of a replayed WebSocket are not supported"),
onSome: () => Effect.void,
}),
),
),
writer: Effect.succeed((message) =>
Ref.get(active).pipe(
Effect.flatMap((state) =>
state
? state.writeLock.withPermit(
Effect.gen(function* () {
const current = yield* Ref.get(state.progress)
if (Socket.isCloseEvent(message)) {
yield* Ref.set(state.closed, true)
yield* Deferred.succeed(current.changed, undefined)
if (current.position === state.interaction.events.length) return
return yield* Effect.die(
new Error(
`WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
),
)
}
const actual = redactEvent(encodeEvent("client", message), redactor)
yield* assertEvent(
actual,
state.interaction.events[current.position],
current.position,
options.compareClientMessagesAsJson === true,
)
yield* Ref.set(state.progress, {
position: current.position + 1,
changed: yield* Deferred.make<void>(),
})
yield* Deferred.succeed(current.changed, undefined)
}),
)
: Effect.die("WebSocket writer used without an active socket run"),
),
),
),
})
})
const recordingLayer = (
name: string,
options: WebSocketRecorderOptions,
forcedMode?: "record" | "replay",
): Layer.Layer<Socket.Socket, never, Socket.Socket | Service> =>
Layer.effect(
Socket.Socket,
Effect.gen(function* () {
const upstream = yield* Socket.Socket
const cassette = yield* Service
const redactor = make(options.redact)
if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record")
return yield* makeRecordingSocket(upstream, cassette, name, options, redactor)
return yield* makeReplaySocket(cassette, name, options, redactor)
}),
)
export const layerSocket = (
name: string,
options: SocketRecorderOptions = {},
): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
provideCassette(recordingLayer(name, { ...options, compareClientMessagesAsJson: true }), options)
/** @internal */
export const layerSocketWithMode = (
name: string,
options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" },
): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
provideCassette(recordingLayer(name, options, options.mode), options)
const provideCassette = <A, E, R>(layer: Layer.Layer<A, E, R>, options: WebSocketRecorderOptions) =>
layer.pipe(Layer.provide(fileSystem({ directory: options.directory })), Layer.provide(NodeFileSystem.layer))
const makeRecordingWebSocketConstructor = (
upstream: Socket.WebSocketConstructor["Service"],
cassette: Interface,
name: string,
metadata: SocketRecorderOptions["metadata"],
redactor: Redactor,
pending: PendingRecordings,
): Socket.WebSocketConstructor["Service"] => {
let nextSequence = 0
return (url, protocols) => {
const sequence = nextSequence++
const requestedProtocols = normalizeProtocols(protocols)
const native = upstream(url, requestedProtocols)
const events: WebSocketEvent[] = []
let opened = false
let failed = false
let closed = false
let queue = Promise.resolve()
const appendEvent = (direction: "client" | "server", data: unknown) => {
queue = queue.then(async () => {
if (failed || closed) return
try {
events.push(redactEvent(encodeEvent(direction, await frameFromWebSocketData(data)), redactor))
} catch {
failed = true
}
})
}
const onOpen = () => {
opened = true
}
const onMessage = (event: MessageEvent) => {
appendEvent("server", event.data)
}
const onError = () => {
failed = true
}
const onClose = (event: CloseEvent) => {
native.removeEventListener("open", onOpen)
native.removeEventListener("message", onMessage)
native.removeEventListener("error", onError)
native.removeEventListener("close", onClose)
const completion = queue.then(async () => {
closed = true
if (opened && !failed) {
const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" })
const interaction: WebSocketInteraction = {
transport: "websocket",
connection: {
sequence,
url: request.url,
protocols: requestedProtocols,
close: { code: event.code, reason: event.reason },
},
events: [...events],
}
events.length = 0
await Effect.runPromise(cassette.append(name, interaction, metadata).pipe(Effect.orDie))
}
})
pending.promises.add(completion)
void completion.then(
() => pending.promises.delete(completion),
(error) => {
pending.promises.delete(completion)
pending.errors.push(error)
},
)
}
native.addEventListener("open", onOpen)
native.addEventListener("message", onMessage)
native.addEventListener("error", onError)
native.addEventListener("close", onClose)
return new Proxy(native, {
get: (target, property) => {
if (property === "send")
return (data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
Reflect.apply(target.send, target, [data])
appendEvent("client", data)
}
const value: unknown = Reflect.get(target, property, target)
return typeof value === "function" ? value.bind(target) : value
},
set: (target, property, value) => Reflect.set(target, property, value, target),
})
}
}
const constructorWebSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
webSocketInteractions(interactions)
.filter((interaction) => interaction.connection !== undefined)
.map((interaction, index) => ({ interaction, index }))
.toSorted((a, b) => a.interaction.connection!.sequence - b.interaction.connection!.sequence)
.map(({ interaction }) => interaction)
const makeReplayWebSocketConstructor = (
cassette: Interface,
name: string,
redactor: Redactor,
): Effect.Effect<Socket.WebSocketConstructor["Service"], never, Scope.Scope> =>
Effect.gen(function* () {
const replay = yield* makeReplayState(cassette, name, constructorWebSocketInteractions)
return (url, protocols) => {
const target = new EventTarget()
const requestedProtocols = normalizeProtocols(protocols)
const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" })
let readyState = 0
let interaction: WebSocketInteraction | undefined
let position = 0
let finished = false
let closeRequested = false
let operations = Promise.resolve()
const fail = (error: unknown) => {
if (finished) return
finished = true
readyState = 3
target.dispatchEvent(errorEvent(error))
}
const finish = () => {
if (finished || !interaction || position !== interaction.events.length) return
finished = true
readyState = 3
const terminal = interaction.connection?.close ?? { code: 1000, reason: "" }
target.dispatchEvent(closeEvent(terminal.code, terminal.reason))
}
const drive = () => {
if (!interaction || finished) return
while (interaction.events[position]?.direction === "server") {
const event = interaction.events[position++]
if (!event) break
target.dispatchEvent(new MessageEvent("message", { data: decodeEvent(event) }))
}
if (position === interaction.events.length) setTimeout(finish, 0)
}
Effect.runPromise(
replay
.claim((recorded, index) =>
Effect.sync(() => {
if (!recorded) throw new Error(`Missing recorded WebSocket connection ${index + 1}`)
const connection = recorded.connection
if (!connection) throw new Error(`WebSocket interaction ${index + 1} has no connection metadata`)
if (connection.url !== request.url)
throw new Error(
`WebSocket connection ${index + 1}: expected URL ${safeText(connection.url)}, received ${safeText(request.url)}`,
)
if (
connection.protocols.length !== requestedProtocols.length ||
connection.protocols.some((protocol, protocolIndex) => protocol !== requestedProtocols[protocolIndex])
)
throw new Error(
`WebSocket connection ${index + 1}: expected protocols ${safeText(connection.protocols)}, received ${safeText(requestedProtocols)}`,
)
}),
)
.pipe(Effect.orDie),
).then((claimed) => {
if (closeRequested) return fail(new Error("WebSocket closed before it opened"))
interaction = claimed.interaction
readyState = 1
target.dispatchEvent(new Event("open"))
drive()
}, fail)
return webSocketFacade(target, {
url: () => url,
readyState: () => readyState,
protocol: () => requestedProtocols[0] ?? "",
extensions: () => "",
bufferedAmount: () => 0,
send: (data) => {
if (!interaction || readyState !== 1 || closeRequested) throw new Error("WebSocket is not open")
operations = operations.then(async () => {
try {
const frame = await frameFromWebSocketData(data)
const actual = redactEvent(encodeEvent("client", frame), redactor)
Effect.runSync(assertEvent(actual, interaction?.events[position], position, true))
position += 1
drive()
} catch (error) {
fail(error)
}
})
},
close: () => {
if (closeRequested || readyState === 3) return
closeRequested = true
readyState = 2
operations = operations.then(() => {
if (!interaction) return
if (position !== interaction.events.length)
return fail(
new Error(`WebSocket closed with unconsumed events: used ${position} of ${interaction.events.length}`),
)
finish()
})
},
})
}
})
export const layerWebSocketConstructor = (
name: string,
options: SocketRecorderOptions = {},
): Layer.Layer<Socket.WebSocketConstructor, never, Socket.WebSocketConstructor> =>
provideCassette(
Layer.effect(
Socket.WebSocketConstructor,
Effect.gen(function* () {
const upstream = yield* Socket.WebSocketConstructor
const cassette = yield* Service
const redactor = make(options.redact)
if ((yield* resolveAutoMode(cassette, name)) === "replay")
return yield* makeReplayWebSocketConstructor(cassette, name, redactor)
const pending: PendingRecordings = { promises: new Set(), errors: [] }
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.all(pending.promises)).pipe(
Effect.flatMap(() => (pending.errors.length === 0 ? Effect.void : Effect.die(pending.errors[0]))),
),
)
return makeRecordingWebSocketConstructor(upstream, cassette, name, options.metadata, redactor, pending)
}),
),
options,
)

View file

@ -0,0 +1,195 @@
import { describe, expect, test } from "bun:test"
import { Effect, Exit } from "effect"
import { existsSync, readdirSync, writeFileSync } from "node:fs"
import type { Interaction } from "../src/cassette/model"
import { HttpRecorder } from "../src"
import { Service, hasCassetteSync, memory } from "../src/cassette/store"
import { cassetteLayer } from "../src/http/recorder"
import { failureText, post, readCassette, runFileCassette, seedCassetteDirectory, tempDirectory } from "./support"
describe("cassette", () => {
test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => {
using server = Bun.serve({
port: 0,
fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234"),
})
const url = `http://127.0.0.1:${server.port}/leaky`
using directory = tempDirectory("http-recorder-unsafe-")
const exit = await Effect.runPromise(
Effect.exit(
post(url, { ok: true }).pipe(
Effect.provide(
cassetteLayer("unsafe-record", {
directory: directory.path,
mode: "record",
}),
),
),
),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain("contains possible secrets")
expect(existsSync(`${directory.path}/unsafe-record.json`)).toBe(false)
})
test("failed memory appends leave cassette state unchanged", async () => {
await Effect.runPromise(
Effect.gen(function* () {
const cassette = yield* Service
const interaction: Interaction = {
transport: "http",
request: {
method: "GET",
url: "https://example.test",
headers: {},
body: "",
},
response: { status: 200, headers: {}, body: "safe" },
}
yield* cassette.append("transactional", interaction)
yield* cassette
.append("transactional", {
...interaction,
response: {
...interaction.response,
body: "Bearer abcdefghijklmnopqrstuvwxyz1234",
},
})
.pipe(Effect.flip)
expect(yield* cassette.read("transactional")).toEqual([interaction])
}).pipe(Effect.provide(memory())),
)
})
test("concurrent file appends preserve every interaction", async () => {
using directory = tempDirectory("http-recorder-concurrent-")
await runFileCassette(
directory.path,
Effect.gen(function* () {
const cassette = yield* Service
yield* Effect.forEach(
Array.from({ length: 20 }, (_, index) => index),
(index) =>
cassette.append("concurrent", {
transport: "http",
request: {
method: "GET",
url: `https://example.test/${index}`,
headers: {},
body: "",
},
response: { status: 200, headers: {}, body: String(index) },
}),
{ concurrency: "unbounded" },
)
}),
)
const cassette = readCassette(`${directory.path}/concurrent.json`)
expect(cassette.interactions).toHaveLength(20)
expect(readdirSync(directory.path).filter((file) => file.endsWith(".tmp"))).toEqual([])
})
test("generated metadata cannot be overridden", async () => {
using directory = tempDirectory("http-recorder-metadata-")
await runFileCassette(
directory.path,
Effect.gen(function* () {
const cassette = yield* Service
yield* cassette.append(
"metadata",
{
transport: "http",
request: { method: "GET", url: "https://example.test", headers: {}, body: "" },
response: { status: 200, headers: {}, body: "safe" },
},
{ name: "wrong", recordedAt: "wrong" },
)
}),
)
const cassette = readCassette(`${directory.path}/metadata.json`)
expect(cassette.metadata?.name).toBe("metadata")
expect(cassette.metadata?.recordedAt).not.toBe("wrong")
})
test("reports malformed cassettes as invalid", async () => {
using directory = tempDirectory("http-recorder-invalid-")
writeFileSync(`${directory.path}/invalid.json`, "{not-json")
const error = await runFileCassette(
directory.path,
Effect.gen(function* () {
const cassette = yield* Service
return yield* cassette.read("invalid").pipe(Effect.flip)
}),
)
expect(error._tag).toBe("InvalidCassetteError")
})
test("rejects cassette paths outside the recordings directory", () => {
using directory = tempDirectory("http-recorder-path-")
expect(() => hasCassetteSync("../outside", { directory: directory.path })).toThrow("Invalid cassette name")
expect(() => hasCassetteSync("C:\\outside", { directory: directory.path })).toThrow("Invalid cassette name")
})
test("public cassette lifecycle helpers check and remove a recording", async () => {
using directory = tempDirectory("http-recorder-lifecycle-")
const options = { directory: directory.path }
expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false)
await seedCassetteDirectory(directory.path, "nested/example", [
{
transport: "http",
request: { method: "GET", url: "https://example.test", headers: {}, body: "" },
response: { status: 200, headers: {}, body: "safe" },
},
])
expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(true)
HttpRecorder.removeCassetteSync("nested/example", options)
expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false)
expect(() => HttpRecorder.removeCassetteSync("nested/example", options)).not.toThrow()
expect(() => HttpRecorder.removeCassetteSync("../outside", options)).toThrow("Invalid cassette name")
})
test("Cassette.list enumerates recorded cassette names", async () => {
using directory = tempDirectory("http-recorder-list-")
await seedCassetteDirectory(directory.path, "alpha/one", [
{
transport: "http",
request: {
method: "GET",
url: "https://x.test/a",
headers: {},
body: "",
},
response: { status: 200, headers: {}, body: "a" },
},
])
await seedCassetteDirectory(directory.path, "beta", [
{
transport: "http",
request: {
method: "GET",
url: "https://x.test/b",
headers: {},
body: "",
},
response: { status: 200, headers: {}, body: "b" },
},
])
const names = await runFileCassette(
directory.path,
Effect.gen(function* () {
const cassette = yield* Service
return yield* cassette.list()
}),
)
expect(names).toEqual(["alpha/one", "beta"])
})
})

View file

@ -0,0 +1,41 @@
{
"version": 1,
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/echo",
"headers": {
"content-type": "application/json"
},
"body": "{\"step\":1}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"reply\":\"first\"}"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/echo",
"headers": {
"content-type": "application/json"
},
"body": "{\"step\":2}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"reply\":\"second\"}"
}
}
]
}

View file

@ -0,0 +1,41 @@
{
"version": 1,
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/poll",
"headers": {
"content-type": "application/json"
},
"body": "{\"id\":\"job_1\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"status\":\"pending\"}"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/poll",
"headers": {
"content-type": "application/json"
},
"body": "{\"id\":\"job_1\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"status\":\"complete\"}"
}
}
]
}

View file

@ -0,0 +1,328 @@
import { describe, expect, test } from "bun:test"
import { Effect, Exit } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { existsSync } from "node:fs"
import { isHttpInteraction } from "../src/cassette/model"
import { HttpRecorder } from "../src"
import { failureText, post, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support"
const run = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch("http/multi-step"))))
const runWith = <A, E>(
name: string,
options: HttpRecorder.RecorderOptions,
effect: Effect.Effect<A, E, HttpClient.HttpClient>,
) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch(name, options))))
describe("HTTP", () => {
test("decorates a provided HTTP client", async () => {
await Effect.runPromise(
Effect.all([post("https://example.test/echo", { step: 1 }), post("https://example.test/echo", { step: 2 })]).pipe(
Effect.provide(HttpRecorder.layer("http/multi-step")),
Effect.provide(FetchHttpClient.layer),
),
)
})
test("replay returns recorded responses in order for identical requests", async () => {
await runWith(
"http/retry",
{},
Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
}),
)
})
test("replay reports exhaustion when more requests are made than recorded", async () => {
await run(
Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 })
yield* post("https://example.test/echo", { step: 2 })
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})
test("a mismatch does not consume an interaction", async () => {
await run(
Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 })
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain("$.step expected 2, received 3")
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
}),
)
})
test("distinct requests replay in any order", async () => {
await run(
Effect.gen(function* () {
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
}),
)
})
test("concurrent distinct requests atomically claim their matching interactions", async () => {
const results = await run(
Effect.all([post("https://example.test/echo", { step: 2 }), post("https://example.test/echo", { step: 1 })], {
concurrency: "unbounded",
}),
)
expect(results).toEqual(['{"reply":"second"}', '{"reply":"first"}'])
})
test("concurrent replay claims each interaction once", async () => {
const results = await runWith(
"http/retry",
{},
Effect.all(
[post("https://example.test/poll", { id: "job_1" }), post("https://example.test/poll", { id: "job_1" })],
{ concurrency: "unbounded" },
),
)
expect(results.toSorted()).toEqual(['{"status":"complete"}', '{"status":"pending"}'])
})
test("mismatch diagnostics show redacted request differences against the expected interaction", async () => {
await run(
Effect.gen(function* () {
const exit = yield* Effect.exit(
post("https://example.test/echo?api_key=secret-value", {
step: 3,
token: "sk-123456789012345678901234",
}),
)
const message = failureText(exit)
expect(message).toContain("url:")
expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
expect(message).toContain("body:")
expect(message).toContain("$.step expected 1, received 3")
expect(message).toContain('$.token expected undefined, received "[REDACTED]"')
expect(message).not.toContain("sk-123456789012345678901234")
}),
)
})
test("applies custom URL redaction to mismatch errors", async () => {
const secret = "private-account"
const exit = await Effect.runPromiseExit(
post(`https://example.test/${secret}`, { step: 1 }).pipe(
Effect.provide(
HttpRecorder.layerFetch("http/multi-step", {
redact: { url: (url) => url.replace(secret, "{account}") },
}),
),
),
)
const message = failureText(exit)
expect(message).toContain("https://example.test/{account}")
expect(message).not.toContain(secret)
})
test("fails when a non-empty replay cassette is completely unused", async () => {
const exit = await Effect.runPromiseExit(
Effect.void.pipe(Effect.scoped, Effect.provide(HttpRecorder.layerFetch("http/multi-step"))),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain("Unused recorded interactions in http/multi-step: used 0 of 2")
})
test("allows an unused replay layer when the cassette is missing", async () => {
using directory = tempDirectory("http-recorder-unused-missing-")
await withEnvironment("CI", "true", () =>
Effect.runPromise(
Effect.void.pipe(
Effect.scoped,
Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })),
),
),
)
})
describe("auto mode", () => {
test("replays when the cassette exists", async () => {
using directory = tempDirectory("http-recorder-auto-")
await seedCassetteDirectory(directory.path, "auto-replay", [
{
transport: "http",
request: {
method: "POST",
url: "https://example.test/echo",
headers: { "content-type": "application/json" },
body: JSON.stringify({ step: 1 }),
},
response: {
status: 200,
headers: { "content-type": "application/json" },
body: '{"reply":"hi"}',
},
},
])
const result = await runWith(
"auto-replay",
{ directory: directory.path },
post("https://example.test/echo", { step: 1 }),
)
expect(result).toBe('{"reply":"hi"}')
})
test("forces replay when CI=true even if cassette is missing", async () => {
using directory = tempDirectory("http-recorder-auto-ci-")
await withEnvironment("CI", "true", async () => {
const exit = await Effect.runPromise(
Effect.exit(
post("https://example.test/echo", { step: 1 }).pipe(
Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })),
),
),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
})
})
test("records to disk when the cassette is missing", async () => {
using directory = tempDirectory("http-recorder-auto-record-")
using server = Bun.serve({
port: 0,
fetch: () =>
new Response('{"reply":"recorded"}', {
headers: { "content-type": "application/json" },
}),
})
const url = `http://127.0.0.1:${server.port}/echo`
await withEnvironment("CI", undefined, async () => {
const result = await runWith("auto-record", { directory: directory.path }, post(url, { step: 1 }))
expect(result).toBe('{"reply":"recorded"}')
expect(existsSync(`${directory.path}/auto-record.json`)).toBe(true)
})
})
test("records concurrent requests in request-start order", async () => {
using directory = tempDirectory("http-recorder-order-")
const first = Promise.withResolvers<void>()
const completed: string[] = []
using server = Bun.serve({
port: 0,
fetch: async (request) => {
const name = new URL(request.url).pathname.slice(1)
if (name === "first") {
await first.promise
completed.push(name)
return new Response(name)
}
completed.push(name)
first.resolve()
return new Response(name)
},
})
await withEnvironment("CI", undefined, async () => {
const request = (name: string) =>
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const response = yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/${name}`))
return yield* response.text
})
const responses = await Effect.runPromise(
Effect.all([request("first"), request("second")], {
concurrency: "unbounded",
}).pipe(Effect.provide(HttpRecorder.layerFetch("concurrent-order", { directory: directory.path }))),
)
const cassette = readCassette(`${directory.path}/concurrent-order.json`)
expect(completed).toEqual(["second", "first"])
expect(responses).toEqual(["first", "second"])
expect(cassette.interactions.filter(isHttpInteraction).map((interaction) => interaction.request.url)).toEqual([
`http://127.0.0.1:${server.port}/first`,
`http://127.0.0.1:${server.port}/second`,
])
})
})
test("returns the live response while persisting its redacted snapshot", async () => {
using directory = tempDirectory("http-recorder-live-response-")
using server = Bun.serve({
port: 0,
fetch: () =>
new Response(JSON.stringify({ access_token: "live-secret", safe: true }), {
headers: {
"content-type": "application/json",
"x-request-id": "request-1",
},
}),
})
await withEnvironment("CI", undefined, async () => {
const body = await runWith(
"live-response",
{ directory: directory.path },
post(`http://127.0.0.1:${server.port}/response`, { ok: true }),
)
const cassette = readCassette(`${directory.path}/live-response.json`)
const interaction = cassette.interactions.find(isHttpInteraction)
expect(body).toBe('{"access_token":"live-secret","safe":true}')
expect(interaction?.response.body).toBe('{"access_token":"[REDACTED]","safe":true}')
})
})
test("reconstructs responses with null-body statuses", async () => {
using directory = tempDirectory("http-recorder-no-content-")
using server = Bun.serve({
port: 0,
fetch: () => new Response(null, { status: 204 }),
})
await withEnvironment("CI", undefined, async () => {
const program = Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`))
})
const response = await Effect.runPromise(
program.pipe(Effect.provide(HttpRecorder.layerFetch("no-content", { directory: directory.path }))),
)
expect(response.status).toBe(204)
})
})
test("records and replays arbitrary binary responses without changing bytes", async () => {
using directory = tempDirectory("http-recorder-binary-")
const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00, 0x80])
using server = Bun.serve({
port: 0,
fetch: () => new Response(expected, { headers: { "content-type": "image/png" } }),
})
const url = `http://127.0.0.1:${server.port}/image.png`
await withEnvironment("CI", undefined, async () => {
const program = Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const response = yield* http.execute(HttpClientRequest.get(url))
return new Uint8Array(yield* response.arrayBuffer)
})
const record = await Effect.runPromise(
program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))),
)
await server.stop()
const replay = await Effect.runPromise(
program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))),
)
const cassette = readCassette(`${directory.path}/binary.json`)
const interaction = cassette.interactions.find(isHttpInteraction)
expect(record).toEqual(expected)
expect(replay).toEqual(expected)
expect(interaction?.response.bodyEncoding).toBe("base64")
})
})
})
})

View file

@ -0,0 +1,167 @@
import { describe, expect, test } from "bun:test"
import { HttpBody, HttpClientRequest } from "effect/unstable/http"
import { redactedErrorRequest } from "../src/http/recorder"
import { make, redactHeaders, redactUrl } from "../src/redaction/redactor"
import { secretFindings } from "../src/redaction/secrets"
describe("redaction", () => {
test("redacts sensitive URL query parameters", () => {
expect(
redactUrl(
"https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature",
),
).toBe(
"https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D",
)
})
test("redacts URL credentials", () => {
expect(redactUrl("https://user:password@example.test/path?safe=value")).toBe(
"https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value",
)
})
test("applies custom URL redaction after built-in redaction", () => {
expect(
redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) =>
url.replace("/accounts/real-account/", "/accounts/{account}/"),
),
).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D")
})
test("redacts sensitive headers when allow-listed", () => {
expect(
redactHeaders(
{
authorization: "Bearer secret-token",
"content-type": "application/json",
"x-custom-token": "custom-secret",
"x-api-key": "secret-key",
"x-goog-api-key": "secret-google-key",
},
["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"],
["x-custom-token"],
),
).toEqual({
authorization: "[REDACTED]",
"content-type": "application/json",
"x-api-key": "[REDACTED]",
"x-custom-token": "[REDACTED]",
"x-goog-api-key": "[REDACTED]",
})
})
test("redacts error requests without retaining headers, params, or body", () => {
const request = HttpClientRequest.post("https://example.test/path", {
headers: { authorization: "Bearer super-secret" },
body: HttpBody.text("super-secret-body", "text/plain"),
}).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key"))
expect(redactedErrorRequest(request).toJSON()).toMatchObject({
url: "https://example.test/path",
urlParams: { params: [] },
headers: {},
body: { _tag: "Empty" },
})
})
test("detects secret-looking values without returning the secret", () => {
expect(
secretFindings({
version: 1,
interactions: [
{
transport: "http",
request: {
method: "POST",
url: "https://example.test/path?key=sk-123456789012345678901234",
headers: {},
body: JSON.stringify({
nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE",
}),
},
response: {
status: 200,
headers: {},
body: "Bearer abcdefghijklmnopqrstuvwxyz",
},
},
],
}),
).toEqual([
{ path: "interactions[0].request.url", reason: "API key" },
{ path: "interactions[0].request.body", reason: "Google API key" },
{ path: "interactions[0].response.body", reason: "bearer token" },
])
})
test("detects secret-looking values inside metadata", () => {
expect(
secretFindings({
version: 1,
metadata: { token: "sk-123456789012345678901234" },
interactions: [],
}),
).toEqual([{ path: "metadata.token", reason: "API key" }])
})
test("redacts configured and common sensitive JSON fields", () => {
const redactor = make({
jsonFields: ["account_id"],
})
const request = redactor.request({
method: "POST",
url: "https://example.test/path",
headers: { "content-type": "application/json" },
body: JSON.stringify({
password: "secret-password",
accessToken: "access-token",
nested: { account_id: "account-123", safe: "visible" },
}),
})
expect(JSON.parse(request.body)).toEqual({
password: "[REDACTED]",
accessToken: "[REDACTED]",
nested: { account_id: "[REDACTED]", safe: "visible" },
})
})
test("preserves JSON text when no fields are redacted", () => {
const body = '{\n "id": 9007199254740993,\n "safe": true\n}'
expect(
make().request({
method: "POST",
url: "https://example.test/path",
headers: { "content-type": "application/json" },
body,
}).body,
).toBe(body)
})
test("extends default header redaction and allow lists", () => {
const redactor = make({
headers: ["x-custom-token"],
allowRequestHeaders: ["anthropic-version", "x-custom-token"],
})
expect(
redactor.request({
method: "GET",
url: "https://example.test/path",
headers: {
authorization: "Bearer secret",
"content-type": "application/json",
"anthropic-version": "2023-06-01",
"x-custom-token": "secret",
},
body: "",
}).headers,
).toEqual({
"anthropic-version": "2023-06-01",
"content-type": "application/json",
"x-custom-token": "[REDACTED]",
})
})
})

View file

@ -0,0 +1,61 @@
import { NodeFileSystem } from "@effect/platform-node-shared"
import { Cause, Effect, Exit } from "effect"
import { HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { mkdtempSync, readFileSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { decodeCassette, type Interaction } from "../src/cassette/model"
import { Service, fileSystem } from "../src/cassette/store"
export const tempDirectory = (prefix: string) => {
const directory = mkdtempSync(join(tmpdir(), prefix))
return {
path: directory,
[Symbol.dispose]() {
rmSync(directory, { recursive: true, force: true })
},
}
}
export const post = (url: string, body: object) =>
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const response = yield* http.execute(
HttpClientRequest.post(url, {
headers: { "content-type": "application/json" },
body: HttpBody.text(JSON.stringify(body), "application/json"),
}),
)
return yield* response.text
})
export const readCassette = (file: string) => decodeCassette(JSON.parse(readFileSync(file, "utf8")))
export const runFileCassette = <A, E>(directory: string, effect: Effect.Effect<A, E, Service>) =>
Effect.runPromise(effect.pipe(Effect.provide(fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)))
export const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
runFileCassette(
directory,
Effect.gen(function* () {
const cassette = yield* Service
yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
}),
)
export const withEnvironment = async <A>(name: string, value: string | undefined, run: () => Promise<A>) => {
const previous = process.env[name]
if (value === undefined) delete process.env[name]
else process.env[name] = value
try {
return await run()
} finally {
if (previous === undefined) delete process.env[name]
else process.env[name] = previous
}
}
export const failureText = (exit: Exit.Exit<unknown, unknown>) => {
if (Exit.isSuccess(exit)) return ""
return Cause.prettyErrors(exit.cause).join("\n")
}

View file

@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "..",
"noEmit": true,
"declaration": false,
"module": "preserve",
"moduleResolution": "bundler"
},
"include": ["../src", "."]
}

View file

@ -0,0 +1,529 @@
import { describe, expect, test } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Socket } from "effect/unstable/socket"
import { existsSync } from "node:fs"
import { HttpRecorder } from "../src"
import { layerSocketWithMode } from "../src/websocket/recorder"
import { failureText, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support"
const unavailableSocket = Socket.make({
runRaw: () => Effect.die(new Error("unexpected live WebSocket run")),
writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))),
})
class EchoWebSocket extends EventTarget {
readonly protocol = ""
readonly extensions = ""
bufferedAmount = 0
binaryType: BinaryType = "blob"
readyState = 0
constructor(readonly url: string) {
super()
queueMicrotask(() => {
this.readyState = 1
this.dispatchEvent(new Event("open"))
})
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
queueMicrotask(() => this.dispatchEvent(new MessageEvent("message", { data })))
}
close(code = 1000, reason = "") {
if (this.readyState === 3) return
this.readyState = 3
this.dispatchEvent(new CloseEvent("close", { code, reason, wasClean: code === 1000 }))
}
}
describe("WebSocket", () => {
test("constructor recording is complete when the recorder layer closes", async () => {
using directory = tempDirectory("http-recorder-websocket-constructor-")
const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-record", {
directory: directory.path,
}).pipe(
Layer.provide(
Layer.succeed(Socket.WebSocketConstructor, (url) => new EchoWebSocket(url) as unknown as globalThis.WebSocket),
),
)
await withEnvironment("CI", undefined, () =>
Effect.runPromise(
Effect.gen(function* () {
const socket = yield* Socket.makeWebSocket("wss://echo.example.test/one", {
protocols: ["echo.v1"],
closeCodeIsError: () => false,
})
const write = yield* socket.writer
yield* socket.runString(() => write(new Socket.CloseEvent(1000, "complete")).pipe(Effect.orDie), {
onOpen: write("hello").pipe(Effect.orDie),
})
}).pipe(Effect.scoped, Effect.provide(recorder)),
),
)
expect(readCassette(`${directory.path}/websocket/constructor-record.json`).interactions).toEqual([
{
transport: "websocket",
connection: {
sequence: 0,
url: "wss://echo.example.test/one",
protocols: ["echo.v1"],
close: { code: 1000, reason: "complete" },
},
events: [
{ direction: "client", kind: "text", body: "hello" },
{ direction: "server", kind: "text", body: "hello" },
],
},
])
})
test("constructor replay validates dynamic URLs and protocols without opening a live socket", async () => {
using directory = tempDirectory("http-recorder-websocket-constructor-")
await seedCassetteDirectory(directory.path, "websocket/constructor", [
{
transport: "websocket",
connection: {
sequence: 0,
url: "wss://events.example.test/workspaces/one",
protocols: ["events.v1"],
close: { code: 1000, reason: "complete" },
},
events: [
{ direction: "client", kind: "text", body: '{"type":"subscribe"}' },
{ direction: "server", kind: "text", body: '{"type":"ready"}' },
],
},
])
const unavailableConstructor = () => {
throw new Error("unexpected live WebSocket construction")
}
const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor", {
directory: directory.path,
}).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, unavailableConstructor)))
const received = await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/one", {
protocols: ["events.v1"],
closeCodeIsError: () => false,
})
const write = yield* socket.writer
const received: string[] = []
yield* socket.runString(
(message) => {
received.push(message)
},
{
onOpen: write('{"type":"subscribe"}').pipe(Effect.orDie),
},
)
return received
}).pipe(Effect.scoped, Effect.provide(recorder)),
)
expect(received).toEqual(['{"type":"ready"}'])
})
test("constructor replay rejects a different dynamic URL", async () => {
using directory = tempDirectory("http-recorder-websocket-constructor-")
await seedCassetteDirectory(directory.path, "websocket/constructor-mismatch", [
{
transport: "websocket",
connection: {
sequence: 0,
url: "wss://events.example.test/workspaces/one",
protocols: [],
close: { code: 1000, reason: "complete" },
},
events: [],
},
])
const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-mismatch", {
directory: directory.path,
}).pipe(
Layer.provide(
Layer.succeed(Socket.WebSocketConstructor, () => {
throw new Error("unexpected live WebSocket construction")
}),
),
)
const exit = await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/two")
yield* socket.runString(() => {})
}).pipe(Effect.scoped, Effect.exit, Effect.provide(recorder)),
)
expect(Exit.isFailure(exit)).toBe(true)
})
test("records WebSocket frames in observed client/server order", async () => {
using directory = tempDirectory("http-recorder-websocket-")
const response = JSON.stringify({
type: "response.completed",
token: "server-secret",
})
const upstream = Socket.make({
runRaw: (handler, options) =>
Effect.gen(function* () {
if (options?.onOpen) yield* options.onOpen
const result = handler(response)
if (Effect.isEffect(result)) yield* result
}),
writer: Effect.succeed(() => Effect.void),
})
await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* Socket.Socket
const write = yield* socket.writer
yield* socket.runRaw(() => {}, {
onOpen: write(JSON.stringify({ type: "response.create", token: "client-secret" })).pipe(Effect.orDie),
})
}).pipe(
Effect.scoped,
Effect.provide(
layerSocketWithMode("websocket/record", {
directory: directory.path,
metadata: { provider: "test" },
mode: "record",
}).pipe(Layer.provide(Layer.succeed(Socket.Socket, upstream))),
),
),
)
expect(readCassette(`${directory.path}/websocket/record.json`)).toMatchObject({
interactions: [
{
transport: "websocket",
events: [
{
direction: "client",
kind: "text",
body: '{"type":"response.create","token":"[REDACTED]"}',
},
{
direction: "server",
kind: "text",
body: '{"type":"response.completed","token":"[REDACTED]"}',
},
],
},
],
})
})
test("WebSocket replay preserves causal frame ordering", async () => {
using directory = tempDirectory("http-recorder-websocket-")
await seedCassetteDirectory(directory.path, "websocket/replay", [
{
transport: "websocket",
events: [
{
direction: "server",
kind: "text",
body: '{"type":"session.created"}',
},
{
direction: "client",
kind: "text",
body: '{"type":"response.create","prompt":"hello"}',
},
{
direction: "server",
kind: "text",
body: '{"type":"response.completed"}',
},
],
},
])
const received: string[] = []
await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* Socket.Socket
const write = yield* socket.writer
yield* socket.runRaw((message) =>
Effect.gen(function* () {
if (typeof message !== "string") return
received.push(message)
const event: unknown = JSON.parse(message)
if (typeof event !== "object" || event === null || !("type" in event)) return
if (event.type === "session.created") yield* write('{"prompt":"hello","type":"response.create"}')
}),
)
}).pipe(
Effect.scoped,
Effect.provide(
layerSocketWithMode("websocket/replay", {
directory: directory.path,
compareClientMessagesAsJson: true,
mode: "replay",
}).pipe(Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket))),
),
),
)
expect(received).toEqual(['{"type":"session.created"}', '{"type":"response.completed"}'])
})
test("the public socket decorator replays a causal provider conversation", async () => {
using directory = tempDirectory("http-recorder-websocket-")
await seedCassetteDirectory(directory.path, "websocket/public-layer", [
{
transport: "websocket",
events: [
{
direction: "server",
kind: "text",
body: '{"type":"session.created"}',
},
{
direction: "client",
kind: "text",
body: '{"type":"response.create","prompt":"first"}',
},
{
direction: "server",
kind: "text",
body: '{"type":"response.completed","id":"first"}',
},
{
direction: "client",
kind: "text",
body: '{"type":"response.create","prompt":"second"}',
},
{
direction: "server",
kind: "text",
body: '{"type":"response.completed","id":"second"}',
},
],
},
])
const received: string[] = []
await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* Socket.Socket
const write = yield* socket.writer
yield* socket.runString((message) =>
Effect.gen(function* () {
received.push(message)
const event: unknown = JSON.parse(message)
if (typeof event !== "object" || event === null) return
if ("type" in event && event.type === "session.created") {
yield* write('{"prompt":"first","type":"response.create"}')
return
}
if ("id" in event && event.id === "first") {
yield* write('{"prompt":"second","type":"response.create"}')
return
}
yield* write(new Socket.CloseEvent(1000, "done"))
}),
)
}).pipe(
Effect.scoped,
Effect.provide(
HttpRecorder.layerSocket("websocket/public-layer", { directory: directory.path }).pipe(
Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)),
),
),
),
)
expect(received).toEqual([
'{"type":"session.created"}',
'{"type":"response.completed","id":"first"}',
'{"type":"response.completed","id":"second"}',
])
})
test("WebSocket replay runs message handlers concurrently", async () => {
using directory = tempDirectory("http-recorder-websocket-")
await seedCassetteDirectory(directory.path, "websocket/concurrent-handlers", [
{
transport: "websocket",
events: [
{ direction: "server", kind: "text", body: "first" },
{ direction: "server", kind: "text", body: "second" },
],
},
])
await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* Socket.Socket
const second = yield* Deferred.make<void>()
yield* socket.runString((message) =>
message === "first" ? Deferred.await(second) : Deferred.succeed(second, undefined),
)
}).pipe(
Effect.scoped,
Effect.provide(
layerSocketWithMode("websocket/concurrent-handlers", { directory: directory.path, mode: "replay" }).pipe(
Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)),
),
),
),
)
})
test("rejected concurrent replay does not consume the next interaction", async () => {
using directory = tempDirectory("http-recorder-websocket-")
await seedCassetteDirectory(directory.path, "websocket/concurrent-runs", [
{ transport: "websocket", events: [{ direction: "server", kind: "text", body: "first" }] },
{ transport: "websocket", events: [{ direction: "server", kind: "text", body: "second" }] },
])
const received: string[] = []
await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* Socket.Socket
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const first = yield* socket
.runString((message) =>
Effect.gen(function* () {
received.push(message)
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}),
)
.pipe(Effect.forkChild)
yield* Deferred.await(started)
const concurrent = yield* Effect.exit(socket.runString(() => Effect.void))
expect(failureText(concurrent)).toContain("Concurrent runs")
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* socket.runString((message) => Effect.sync(() => received.push(message)))
}).pipe(
Effect.scoped,
Effect.provide(
layerSocketWithMode("websocket/concurrent-runs", { directory: directory.path, mode: "replay" }).pipe(
Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)),
),
),
),
)
expect(received).toEqual(["first", "second"])
})
test("WebSocket replay rejects close with unconsumed events", async () => {
using directory = tempDirectory("http-recorder-websocket-")
await seedCassetteDirectory(directory.path, "websocket/early-close", [
{
transport: "websocket",
events: [{ direction: "client", kind: "text", body: "expected" }],
},
])
const exit = await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* Socket.Socket
const write = yield* socket.writer
return yield* Effect.exit(
socket.runRaw(() => {}, {
onOpen: write(new Socket.CloseEvent(1000)).pipe(Effect.orDie),
}),
)
}).pipe(
Effect.scoped,
Effect.provide(
layerSocketWithMode("websocket/early-close", { directory: directory.path, mode: "replay" }).pipe(
Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)),
),
),
),
)
expect(failureText(exit)).toContain("closed with unconsumed events")
})
test("failed WebSocket runs do not write complete cassettes", async () => {
using directory = tempDirectory("http-recorder-websocket-")
const exit = await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* Socket.Socket
return yield* Effect.exit(socket.runRaw(() => {}))
}).pipe(
Effect.scoped,
Effect.provide(
layerSocketWithMode("websocket/failed-run", { directory: directory.path, mode: "record" }).pipe(
Layer.provide(
Layer.succeed(
Socket.Socket,
Socket.make({
runRaw: () => Effect.die(new Error("connection failed")),
writer: Effect.succeed(() => Effect.void),
}),
),
),
),
),
),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(existsSync(`${directory.path}/websocket/failed-run.json`)).toBe(false)
})
test("WebSocket replay preserves binary frame kinds across reconnects", async () => {
using directory = tempDirectory("http-recorder-websocket-")
const interaction = {
transport: "websocket" as const,
events: [
{
direction: "client" as const,
kind: "binary" as const,
body: Buffer.from([1, 2]).toString("base64"),
bodyEncoding: "base64" as const,
},
{
direction: "server" as const,
kind: "binary" as const,
body: Buffer.from([3, 4]).toString("base64"),
bodyEncoding: "base64" as const,
},
],
}
await seedCassetteDirectory(directory.path, "websocket/binary", [interaction, interaction])
const received: number[][] = []
await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* Socket.Socket
const write = yield* socket.writer
const run = socket.runRaw(
(message) => {
if (typeof message === "string") throw new Error("Expected a binary WebSocket frame")
received.push([...message])
},
{ onOpen: write(new Uint8Array([1, 2])).pipe(Effect.orDie) },
)
yield* run
yield* run
}).pipe(
Effect.scoped,
Effect.provide(
layerSocketWithMode("websocket/binary", { directory: directory.path, mode: "replay" }).pipe(
Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)),
),
),
),
)
expect(received).toEqual([
[3, 4],
[3, 4],
])
})
})

View file

@ -0,0 +1,13 @@
import { expect, test } from "bun:test"
import { Marked } from "marked"
import { markedCodeSpanBoundary } from "./marked-code-span"
test("preserves code spans adjacent to tildes", async () => {
const marked = new Marked(markedCodeSpanBoundary)
expect(await marked.parse("~`0.1576` to measurement-window-only `0.00092`")).toBe(
"<p>~<code>0.1576</code> to measurement-window-only <code>0.00092</code></p>\n",
)
expect(await marked.parse("`before`~`after`")).toBe("<p><code>before</code>~<code>after</code></p>\n")
expect(await marked.parse("~~`deleted code`~~")).toBe("<p><del><code>deleted code</code></del></p>\n")
})

View file

@ -0,0 +1,17 @@
import type { MarkedExtension } from "marked"
// Keep adjacent tilde and backtick runs separate until markedjs/marked#4011 is released.
export const markedCodeSpanBoundary = {
tokenizer: {
inlineText(src) {
const match = /^(`+(?=~)|~+(?=`))/.exec(src)
if (!match) return false
return {
type: "text",
raw: match[0],
text: match[0],
escaped: this.lexer.state.inRawBlock,
}
},
},
} satisfies MarkedExtension

View file

@ -0,0 +1,57 @@
diff --git a/dist/unstable/httpapi/HttpApiSchema.js b/dist/unstable/httpapi/HttpApiSchema.js
index c51851b..2100420 100644
--- a/dist/unstable/httpapi/HttpApiSchema.js
+++ b/dist/unstable/httpapi/HttpApiSchema.js
@@ -151,7 +151,7 @@ export const StreamSse = options => {
const events = options.events ?? (options.data === undefined ? undefined : Schema.Struct({
id: Schema.UndefinedOr(Schema.String),
event: Schema.String,
- data: Schema.fromJsonString(options.data)
+ data: sseDataJsonSchema(options.data)
}));
if (events === undefined) {
throw new Error("StreamSse requires either an events schema or a data schema");
@@ -166,6 +166,14 @@ export const StreamSse = options => {
error: options.error ?? Schema.Never
});
};
+const sseDataJsonSchema = data => {
+ const identifier = SchemaAST.resolveIdentifier(data.ast);
+ return identifier === undefined ? Schema.fromJsonString(data) : Schema.fromJsonString(data).annotate({
+ // The SSE transport field is a JSON string. Give that wrapper its own
+ // OpenAPI identifier so it does not claim the decoded data schema's name.
+ identifier: `${identifier}Stream`
+ });
+};
/**
* Creates a streaming `Uint8Array` success response schema.
*
diff --git a/src/unstable/httpapi/HttpApiSchema.ts b/src/unstable/httpapi/HttpApiSchema.ts
index aae6cd5..f05e3ed 100644
--- a/src/unstable/httpapi/HttpApiSchema.ts
+++ b/src/unstable/httpapi/HttpApiSchema.ts
@@ -407,7 +407,7 @@ export const StreamSse: {
const events = options.events ?? (options.data === undefined ? undefined : Schema.Struct({
id: Schema.UndefinedOr(Schema.String),
event: Schema.String,
- data: Schema.fromJsonString(options.data)
+ data: sseDataJsonSchema(options.data)
}))
if (events === undefined) {
throw new Error("StreamSse requires either an events schema or a data schema")
@@ -423,6 +423,15 @@ export const StreamSse: {
})
}
+const sseDataJsonSchema = (data: Schema.Constraint) => {
+ const identifier = SchemaAST.resolveIdentifier(data.ast)
+ return identifier === undefined ? Schema.fromJsonString(data) : Schema.fromJsonString(data).annotate({
+ // The SSE transport field is a JSON string. Give that wrapper its own
+ // OpenAPI identifier so it does not claim the decoded data schema's name.
+ identifier: `${identifier}Stream`
+ })
+}
+
/**
* Creates a streaming `Uint8Array` success response schema.
*

View file

@ -0,0 +1,163 @@
# Mixed V1/V2 Config Normalization Plan
Status: **Implemented and verified**
## Goal
Replace whole-document V1/V2 detection with one config-domain compatibility pipeline. Supported V1 fields, native V2 fields, and practical mixtures of both should load without an unrelated legacy key changing how the rest of the document is decoded.
## Decision
Normalize recognized fields independently into the encoded side of the V2 `Config.Info` schema, then perform one final complete-document V2 decode:
```text
JSON/JSONC encoded input
-> parse and retain source-property presence
-> validate each recognized field or collection entry
-> migrate supported V1 candidates to V2 encoded values
-> decode and re-encode native V2 candidates
-> merge with native V2 precedence
-> decode Config.Info once
-> log redacted diagnostics
```
There is no whole-document version classification and no independent whole-document V1 and V2 decode.
The encoded boundary matters because schemas such as warming durations transform strings into runtime values. Decoded values must not be fed back into the encoded side of `Config.Info`.
## Behavior
| Situation | Result |
| --- | --- |
| Supported V1-only field | Migrate it to its canonical V2 destination. |
| Native V2 field | Preserve it after schema decode and encode. |
| Disjoint V1 and V2 map entries | Preserve both. |
| Same canonical scalar, map entry, or nested leaf | Valid native V2 wins regardless of JSON key order. |
| Malformed native value with valid legacy fallback | Skip native value, log it, and retain legacy value. |
| Malformed collection entry | Skip only the explicitly supported recovery unit. |
| Unsupported accepted V1 setting | Omit it and log a redacted warning. |
| Unknown field | Continue ignoring it for forward compatibility. |
Valid supported V1 syntax does not warn merely because it is legacy.
## Field Precedence
| Destination | Lowest to highest precedence |
| --- | --- |
| `snapshots` | `snapshot` < `snapshots` |
| `share` | `autoshare` < `share` |
| `references[name]` | `reference[name]` < `references[name]` |
| `agents[name]` | `agent[name]` < `mode[name]` < `agents[name]` |
| `commands[name]` | `command[name]` < `commands[name]` |
| `providers[name]` | `provider[name]` < `providers[name]` |
| `permissions` | `tools` rules < `permission` rules < native `permissions` |
| `plugins` | migrated `plugin` items < native `plugins` items |
| `media` | `attachment` < `media` |
| `experimental.policies` | enabled-provider policies < disabled-provider policies < native policies |
| `mcp.servers[name]` | direct legacy server < native `servers[name]` |
| `mcp.timeout.*` | `experimental.mcp_timeout` < native timeout leaf |
| `compaction.keep.tokens` | `preserve_recent_tokens` < `keep.tokens` |
| `compaction.buffer` | `reserved` < `buffer` |
Ordered rules and plugin directives retain both forms, with migrated V1 entries first and native V2 entries last.
## Shared Shapes
### Skills
- A V2 array retains each valid string item.
- A V1 object combines valid `paths` followed by valid `urls`.
- Empty and unknown-only V1 objects normalize to an empty array under permissive excess-property handling.
### MCP
- Direct entries under `mcp` are V1 servers.
- Entries under `mcp.servers` are native V2 servers.
- Both sets are merged by server name, with a complete native server replacing a duplicate legacy server.
- A malformed native duplicate is skipped so a valid legacy server remains.
- Native global timeout leaves override only matching values migrated from `experimental.mcp_timeout`.
- Raw `type` and `enabled` discriminators preserve legacy servers that happen to be named `servers` or `timeout`.
### Compaction
- `preserve_recent_tokens` becomes `keep.tokens`.
- `reserved` becomes `buffer`.
- Native leaves win conflicts.
- `tail_turns` and `prune` remain unsupported and produce warnings.
### Experimental
- `subagent_depth` is shared.
- Legacy provider lists generate ordered canonical policies.
- Native policies follow generated policies.
- An explicit empty `enabled_providers` keeps deny-all behavior.
- A non-empty list with no valid items contributes no policy, avoiding accidental deny-all from malformed input.
## Recovery Units
Named commands, agents, providers, MCP servers, formatters, language servers, and references recover independently. Plugin, permission, skill, instruction, provider-ID, and policy arrays recover by item. Top-level legacy permissions recover by action/resource rule. Complex interiors of one agent, provider, command, or MCP server remain atomic rather than being recursively salvaged.
Every decoder preserves `propertyOrder: "original"` because V1 permission precedence depends on user order. Excess properties remain ignored except for the explicit unsupported inventory.
## Provider IDs
Provider ID compatibility remains a config migration concern only. Existing V1 agent, command, provider, and provider-policy adapters continue using the migration helper's retired-ID mapping.
The shared top-level `model` field remains exact because its string and object forms are valid native V2 syntax and provider declarations may come from a different config layer. It is never reinterpreted based on unrelated legacy fields.
This change does not add runtime provider aliases or modify provider policy evaluation, catalog state, model resolution, Sessions, plugins, Server behavior, or generation.
## Diagnostics
Diagnostics contain only source, JSON path, category, and action. They never include raw values because config may contain credentials after substitution.
Malformed JSON, empty content, and valid non-object roots reject one document with a source-aware warning. Malformed recognized fields and entries are skipped at their recovery boundary while unrelated valid configuration continues loading.
## Implementation
- Add a pure `ConfigNormalize.normalize` module under `packages/core/src/config/`.
- Reuse field migration primitives from `packages/core/src/v1/config/migrate.ts`.
- Replace `ConfigMigrateV1.isV1` in `packages/core/src/config.ts` with normalization and one final V2 decode.
- Log diagnostics uniformly for files, `OPENCODE_CONFIG_CONTENT`, and well-known virtual config.
- Add property and table-driven config normalization tests.
- Update migration and compaction documentation.
## Verification
The implementation must establish:
1. Valid native V2 config preserves decoded meaning after encoded normalization.
2. Supported V1 fields preserve existing behavior.
3. Adding a legacy field cannot change unrelated native field interpretation.
4. Native V2 wins canonical conflicts independent of key order.
5. One malformed entry does not remove valid siblings.
6. Mixed MCP, compaction, and experimental values normalize deterministically.
7. Diagnostics are precise and value-redacted.
8. False, zero, empty, and absent values retain distinct presence semantics.
Run from `packages/core`:
```sh
bun test test/config
bun typecheck
```
Run from `packages/www` after documentation changes:
```sh
bun typecheck
bun validate
bun run build
```
## Non-Goals
- Runtime provider alias resolution.
- Provider policy or catalog changes.
- Model resolver or Session changes.
- Plugin API changes.
- Server or Protocol changes.
- Generation lifecycle changes.
- Recursive V1/V2 inference inside one agent, provider, command, or model.
- Restoring removed V1 functionality.
- Rewriting user files on disk.

261
plans/session-generate.md Normal file
View file

@ -0,0 +1,261 @@
# Session-Aware One-Shot Generation Plan
Status: **In progress**
## Decision
Add a Session operation that prepares one request from the Session's active model context, appends a transient prompt, executes exactly one Physical Attempt, returns the assistant text, and leaves the Session unchanged:
```ts
const text = yield * session.generate({ sessionID, prompt: "Summarize where we left off." })
```
The operation belongs to Session because its meaning depends on Session History, the selected agent and model, instructions, provider transforms, hooks, and prompt-cache identity. The existing stateless `Generate.text` module remains unaware of Session.
This feature should deepen the Session request-preparation module rather than add a second approximation of the runner. The durable runner and `session.generate` must share preparation, then diverge before provider output acquires durable consequences.
## Why The Current Shape Resists This Operation
`SessionRunner.attemptStep` currently interleaves six distinct concerns:
1. It synchronizes instructions and promotes pending inputs.
2. It resolves the Session's agent and model.
3. It selects active Session History and initiates compaction when required.
4. It constructs the provider request, materializes tools, and applies Session hooks.
5. It performs one Physical Attempt.
6. It projects assistant output, executes tools, records usage, and decides whether to continue.
`session.generate` needs the middle of that sequence without the durable work on either side. Calling `session.prompt` would admit durable input and enter the full loop. Calling `Generate.text` would lose Session context and cache identity. Forking would preserve context but create temporary durable state and cleanup obligations.
The desired architecture makes request preparation independently callable while preserving one canonical implementation.
## Target Architecture
```text
session.prompt
-> SessionAdmission
-> SessionExecution
-> SessionContext.select/load
-> SessionModelRequest.prepare
-> LLMClient.stream
-> SessionSettlement
session.generate
-> SessionContext.select
-> SessionHistory.preview
-> SessionGenerate request construction
-> LLMClient.generate
-> return text
```
The modules have distinct jobs:
- `SessionAdmission` records and promotes durable input.
- `SessionContext` resolves a read-only, internally consistent view of what the selected agent would see.
- `SessionModelRequest` converts durable Step context into a provider request paired with tool capability.
- `SessionGenerate` owns the one tool-free transient request shape rather than threading generation through the durable modules.
- `LLMClient` executes one provider request without deciding what becomes durable.
- `SessionSettlement` gives streamed provider events their durable Session meaning, executes local tools, records usage, and decides continuation.
- `SessionCompaction` replaces oversized active history and remains a durable Session operation.
Durability becomes a property of admission and settlement, not request preparation or provider execution.
## The Core Seam
The Location-scoped request module keeps its durable Step interface:
```ts
interface SessionModelRequest {
readonly prepare: (input: { context: SessionContext.Loaded; step: number }) => Effect<PreparedSessionModelRequest>
}
```
```ts
type PreparedSessionModelRequest = {
request: LLM.Request
resolveToolCall: (name: string) => ToolCallResolution
}
```
`prepare` hides:
- Session and Location validation;
- plugin flush and selected-agent resolution;
- selected-model and credential resolution;
- instruction source loading and assembly;
- active-history selection after the latest completed compaction;
- conversion to provider messages;
- provider system prompts and model headers;
- tool permission filtering and definition materialization;
- provider transforms and Session context hooks;
- Session-based prompt-cache identity.
Durable `prepare` advertises permitted tools and returns the capability used by settlement, except when the agent's Step limit disables tools. `SessionGenerate` constructs its request with no tool definitions and tool choice `none`, independently of agent permissions. Avoid a generic `step | generate | compaction` operation union or independently selectable behavior flags; the two operations own their concrete request shapes.
## Read-Only Session Context
Request preparation must not call `InstructionState.prepare`, promote pending input, or initiate compaction. Those operations mutate the Session.
Split instruction behavior into two phases:
```text
resolve and assemble current instruction context read-only
commit the canonical model's instruction state durable
```
Both a durable Step and `session.generate` resolve and assemble instructions. Only durable execution commits instruction-state changes. A transient request must not make the false durable claim that the canonical Session model saw an instruction update.
The active history and committed instruction state are loaded from one consistent database view. A concurrent durable Step may advance the Session afterward; the already prepared request remains immutable. No reusable snapshot or revision protocol is needed for this operation.
Pending inputs remain excluded. They are not Session History until promotion, and `session.generate` must not alter admission order or expose queued work early.
If the Session currently has an unsettled assistant message, generation stops history at that boundary. In particular, it never appends the transient user prompt after an unresolved tool call.
## One Physical Attempt Without Settlement
Use the existing `LLMClient` interface directly. The durable runner consumes `llm.stream(prepared.request)`, while `session.generate` calls `llm.generate(prepared.request)` to collect the same event stream into its existing `LLMResponse` model. No additional provider-attempt module is needed.
`LLMClient.generate` collects exactly one provider stream. It does not retry as a new logical Step, execute tools, continue after tool calls, publish Session events, capture filesystem snapshots, or update Session usage.
The internal result is the collected text string. Keeping richer evidence internal avoids prematurely committing a public transport contract.
If a provider returns tool calls, collection records and ignores them. No tool hook or execution path runs. Assistant text, including an empty string, remains a successful result. Empty text is required for cache-warming calls.
## Compaction Does Not Belong In The First Operation
Normal Step preparation may discover that active context requires compaction. Compaction is durable and usually requires another provider call. Automatically compacting from `session.generate` would violate both transcript immutability and the exactly-one-attempt contract.
The first operation should use history after the latest completed compaction and fail with a typed context-overflow error when that snapshot cannot fit. It must not initiate compaction.
Transient in-memory compaction can be considered later as a separate operation or explicit policy. It should not silently weaken the first contract.
## Concurrency Uses Snapshot Semantics
The first contract should state:
> `session.generate` uses the latest committed model context captured when request preparation begins. Later Session changes do not alter the in-flight request.
The operation does not acquire ownership of the durable Session Drain and does not fail merely because the Session is running. This keeps transient generation independent from durable scheduling.
A later recap integration can suppress stale output by comparing the Session aggregate sequence captured by that caller. Core does not expose a generic revision protocol before a concrete consumer needs one.
## Hooks Follow The Stage They Affect
The existing Session context hook runs because it participates in normal request preparation. Admission, projection, Session settlement, and tool-execution hooks do not run because those stages do not occur. Add operation metadata only when a concrete hook consumer needs it; do not introduce a speculative operation union.
The no-mutation guarantee covers OpenCode's durable Session state. Arbitrary plugin hooks may still perform external side effects.
## Internal Contract
Start with the smallest useful interface:
```ts
type SessionGenerateInput = {
sessionID: SessionID
prompt: string
}
```
The operation:
1. Resolves the Session or returns the normal Session-not-found error.
2. Captures its latest committed active model context.
3. Uses the selected agent, model, instructions, provider configuration, transforms, hooks, and Session cache key.
4. Appends `prompt` only to the in-memory provider request.
5. Executes exactly one Physical Attempt with tools disabled.
6. Returns collected assistant text, including an empty string.
7. Does not admit input, publish Session events, execute tools, initiate compaction, update usage, or mutate Session projections.
Files, agent attachments, usage, model identity, finish metadata, and revision are follow-up extensions. They should be added only when a concrete caller needs them.
## Implementation Sequence
Each stage should preserve existing durable runner behavior before the next stage starts.
### 1. Characterize Current Request Preparation
Add focused tests around a normal Step's prepared request. Pin:
- selected agent and model;
- system and instruction assembly;
- active history after compaction;
- tool definitions and last-step behavior;
- provider headers and prompt-cache key;
- Session context-hook transformations.
Use a recording LLM adapter rather than reproducing request-construction logic in tests.
### 2. Extract Instruction Resolution From Durable Synchronization
Move instruction source loading and read-only assembly behind one internal interface. Keep `InstructionState.prepare` in the durable runner path. Verify that normal Steps produce byte-equivalent instruction context and unchanged durable instruction events.
This commit should not add `session.generate`.
### 3. Extract Session Model Request Preparation
Move model resolution, history selection, request construction, tool definitions, cache identity, and context hooks into the Location-scoped `SessionModelRequest` module. Make the durable runner its only caller first.
Verify the recorded normal request before and after extraction. Keep compaction detection and pending promotion outside the new module if putting them inside would make preparation mutate state.
### 4. Separate Provider Attempt From Durable Settlement
Make the runner explicitly pass `llm.stream(prepared.request)` into durable settlement. Keep event publication, tool execution, snapshots, retries, usage, and continuation behavior unchanged.
This stage should make the durable path read as orchestration:
```ts
promote -> snapshot -> compact if required -> prepare -> attempt -> settle
```
### 5. Add The Core `Session.generate` Operation
Use read-only context and request preparation, append one transient user message, disable tools, collect one attempt, and return text. Add tests proving that messages, pending inputs, instruction state, Session events, and usage remain unchanged.
Test concurrent Session advancement with deterministic synchronization around request dispatch. The generated request should retain its captured context while the source Session advances independently.
### 6. Add Protocol, Server, Client, And Plugin Surfaces
Add `POST /api/session/:sessionID/generate` to Protocol, a thin Server handler, generated Promise and Effect clients, and `ctx.session.generate` in the V2 plugin context. Regenerate clients from the assembled `HttpApi`; do not edit generated files manually.
### 7. Build Recap As The First External Consumer
Implement recap outside Core using `session.generate`. The recap integration owns idle/focus policy, the recap prompt, stale-result suppression, output cleaning, and display. Core owns only session-authentic transient generation.
## Verification Laws
The implementation is complete when tests establish these laws:
1. **Request equivalence:** the durable runner's prepared request remains equivalent before and after extraction.
2. **Transcript immutability:** `session.generate` leaves Session messages and pending inputs unchanged.
3. **Instruction immutability:** transient generation does not advance instruction state or publish instruction events.
4. **Single attempt:** one call produces exactly one `llm.generate` invocation and no continuation.
5. **No tools:** transient requests advertise no tools and never reach tool settlement or tool hooks.
6. **Cache identity:** normal Steps and transient generation use the same Session-derived prompt-cache key.
7. **Hook parity:** Session request hooks see and may transform transient requests through the same preparation seam.
8. **Empty success:** a provider response with no assistant text returns `""`.
9. **Snapshot isolation:** concurrent Session advancement does not change an already prepared transient request.
10. **No accounting mutation:** transient usage does not alter durable Session cost or token totals.
## Rejected First Implementations
### Prompt, Wait, And Read
This path mutates Session History, enters the agent loop, may execute tools, and changes usage. Deleting projected messages afterward cannot undo durable events or external effects.
### Durable Fork With Cleanup
A fork is useful for a prototype but creates durable state, emits events, requires reliable deletion, and can execute tools unless the normal runner is changed anyway. It does not establish the reusable preparation seam.
### Session Calling Stateless Generate
`Generate.text` intentionally knows nothing about Session. Teaching it Session semantics reverses the intended dependency and duplicates request preparation outside the runner.
### A General `persist: false` Runner Mode
Persistence, tool execution, admission, compaction, and continuation are separate behaviors. One flag would leave callers responsible for understanding unsafe combinations and would keep the current concerns interleaved.
## Expected Scope
The narrow implementation is expected to touch Core Session and runner modules, Protocol, Server, generated clients, and the V2 plugin context. It should require no database migration and no new durable event.
The architectural work is larger than the endpoint. Most risk lies in extracting instruction and request preparation without changing normal Step behavior. The staged sequence keeps that risk observable and gives every new seam two real callers before broadening its interface.

View file

@ -0,0 +1,12 @@
id: no-drizzle-column-name
snapshots:
? |
const table = sqliteTable("session", {
projectID: text("project_id").notNull(),
createdAt: integer("time_created").notNull(),
})
: labels:
- source: text("project_id")
style: primary
start: 52
end: 70

View file

@ -0,0 +1,30 @@
id: no-effect-die-string
snapshots:
Effect.die("boom"):
labels:
- source: Effect.die("boom")
style: primary
start: 0
end: 18
- source: '"boom"'
style: secondary
start: 11
end: 17
- source: ("boom")
style: secondary
start: 10
end: 18
Effect.die(`boom ${value}`):
labels:
- source: Effect.die(`boom ${value}`)
style: primary
start: 0
end: 27
- source: '`boom ${value}`'
style: secondary
start: 11
end: 26
- source: (`boom ${value}`)
style: secondary
start: 10
end: 27

View file

@ -0,0 +1,42 @@
id: no-import-alias
snapshots:
import { baz, foo as bar } from "./foo":
labels:
- source: foo as bar
style: primary
start: 14
end: 24
- source: bar
style: secondary
start: 21
end: 24
import { foo as bar } from "./foo":
labels:
- source: foo as bar
style: primary
start: 9
end: 19
- source: bar
style: secondary
start: 16
end: 19
import { foo as bar, baz } from "./foo":
labels:
- source: foo as bar
style: primary
start: 9
end: 19
- source: bar
style: secondary
start: 16
end: 19
import { type Foo as Bar, baz } from "./foo":
labels:
- source: type Foo as Bar
style: primary
start: 9
end: 24
- source: Bar
style: secondary
start: 21
end: 24

View file

@ -0,0 +1,8 @@
id: no-json-parse-cast
snapshots:
const value = JSON.parse(input) as Record<string, unknown>:
labels:
- source: JSON.parse(input) as Record<string, unknown>
style: primary
start: 14
end: 58

View file

@ -0,0 +1,20 @@
id: no-nested-effect-service-yield
snapshots:
? |
Effect.gen(function* () {
yield* (yield* Foo.Service).client.run()
})
: labels:
- source: (yield* Foo.Service).client.run()
style: primary
start: 35
end: 68
? |
Effect.gen(function* () {
yield* (yield* Foo.Service).run()
})
: labels:
- source: (yield* Foo.Service).run()
style: primary
start: 35
end: 61

View file

@ -0,0 +1,22 @@
id: no-star-import
snapshots:
import * as Foo from "./foo":
labels:
- source: '* as Foo'
style: primary
start: 7
end: 15
- source: import * as Foo from "./foo"
style: secondary
start: 0
end: 28
import type * as Foo from "./foo":
labels:
- source: '* as Foo'
style: primary
start: 12
end: 20
- source: import type * as Foo from "./foo"
style: secondary
start: 0
end: 33

View file

@ -0,0 +1,14 @@
id: no-drizzle-column-name
valid:
- |
const table = sqliteTable("session", {
project_id: text().notNull(),
time_created: integer().notNull(),
payload: text({ mode: "json" }),
})
invalid:
- |
const table = sqliteTable("session", {
projectID: text("project_id").notNull(),
createdAt: integer("time_created").notNull(),
})

View file

@ -0,0 +1,7 @@
id: no-effect-die-string
valid:
- Effect.die(new Error("boom"))
- Effect.fail("boom")
invalid:
- Effect.die("boom")
- Effect.die(`boom ${value}`)

View file

@ -0,0 +1,13 @@
id: no-import-alias
valid:
- import { foo } from "./foo"
- import type { Foo } from "./foo"
- import foo from "./foo"
- export { foo as bar } from "./foo"
- import type { Plugin as EffectPlugin } from "./foo"
- import type { Foo as Bar, Baz } from "./foo"
invalid:
- import { foo as bar } from "./foo"
- import { baz, foo as bar } from "./foo"
- import { foo as bar, baz } from "./foo"
- import { type Foo as Bar, baz } from "./foo"

View file

@ -0,0 +1,6 @@
id: no-json-parse-cast
valid:
- const value = JSON.parse(input)
- const value = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(input)
invalid:
- const value = JSON.parse(input) as Record<string, unknown>

View file

@ -0,0 +1,21 @@
id: no-nested-effect-service-yield
valid:
- |
Effect.gen(function* () {
const service = yield* Foo.Service
yield* service.run()
})
- |
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db.run()
})
invalid:
- |
Effect.gen(function* () {
yield* (yield* Foo.Service).run()
})
- |
Effect.gen(function* () {
yield* (yield* Foo.Service).client.run()
})

View file

@ -0,0 +1,8 @@
id: no-star-import
valid:
- import { Foo } from "./foo"
- import Foo from "./foo"
- export * as Foo from "./foo"
invalid:
- import * as Foo from "./foo"
- import type * as Foo from "./foo"

View file

@ -0,0 +1,22 @@
id: no-drizzle-column-name
language: TypeScript
message: Use snake_case object keys instead of explicit drizzle column names.
severity: error
files:
- packages/core/src/**/sql.ts
- packages/core/src/**/*.sql.ts
rule:
any:
- pattern: text($NAME)
- pattern: text($NAME, $$$ARGS)
- pattern: integer($NAME)
- pattern: integer($NAME, $$$ARGS)
- pattern: blob($NAME)
- pattern: blob($NAME, $$$ARGS)
- pattern: real($NAME)
- pattern: real($NAME, $$$ARGS)
- pattern: numeric($NAME)
- pattern: numeric($NAME, $$$ARGS)
constraints:
NAME:
kind: string

View file

@ -0,0 +1,22 @@
id: no-effect-die-string
language: TypeScript
message: die with `new Error(...)`.
severity: error
rule:
any:
- all:
- pattern: Effect.die($MESSAGE)
- has:
field: arguments
all:
- kind: arguments
- has:
kind: string
- all:
- pattern: Effect.die($MESSAGE)
- has:
field: arguments
all:
- kind: arguments
- has:
kind: template_string

View file

@ -0,0 +1,14 @@
id: no-import-alias
language: TypeScript
message: Do not alias value imports. For type name collisions, alias inside a dedicated `import type` statement.
severity: error
rule:
all:
- kind: import_specifier
- has:
field: alias
kind: identifier
- not:
inside:
pattern: import type { $$$SPECS } from "$MOD"
stopBy: end

View file

@ -0,0 +1,6 @@
id: no-json-parse-cast
language: TypeScript
message: Prefer Effect Schema JSON decoding over JSON.parse casts.
severity: error
rule:
pattern: JSON.parse($INPUT) as $TYPE

View file

@ -0,0 +1,11 @@
id: no-nested-effect-service-yield
language: TypeScript
message: Bind Effect services before calling methods instead of nesting service yields.
severity: error
rule:
any:
- pattern: (yield* $SERVICE).$METHOD($$$ARGS)
- pattern: (yield* $SERVICE).$PROPERTY.$METHOD($$$ARGS)
constraints:
SERVICE:
regex: \.Service$

View file

@ -0,0 +1,10 @@
id: no-star-import
language: TypeScript
message: Do not use star imports.
severity: error
rule:
all:
- kind: namespace_import
- inside:
kind: import_statement
stopBy: end

View file

@ -0,0 +1,4 @@
ruleDirs:
- rules
testConfigs:
- testDir: rule-tests

View file

@ -2,8 +2,8 @@
import { $ } from "bun"
await $`bun ./packages/sdk/js/script/build.ts`
await $`bun run generate`.cwd("packages/protocol")
await $`bun dev generate > ../sdk/openapi.json`.cwd("packages/opencode")
await $`bun run generate`.cwd("packages/www")
await $`./script/format.ts`

View file

@ -0,0 +1,66 @@
#!/usr/bin/env bun
import path from "path"
const root = path.resolve(import.meta.dir, "..")
const proc = Bun.spawn(
[
"bun",
"turbo",
"typecheck",
"--concurrency=1",
"--force",
"--continue=always",
"--summarize",
"--output-logs=errors-only",
],
{
cwd: root,
stdout: "pipe",
stderr: "pipe",
},
)
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited])
const output = stdout + stderr
if (exitCode !== 0) {
process.stdout.write(stdout)
process.stderr.write(stderr)
process.exit(exitCode)
}
const summary = output.match(/Summary:\s+(.+\.json)/)?.[1]?.trim()
if (!summary) {
process.stdout.write(stdout)
process.stderr.write(stderr)
throw new Error("Turbo did not report a run summary")
}
const report = (await Bun.file(summary).json()) as {
tasks: Array<{
taskId: string
execution: { startTime: number; endTime: number; exitCode: number } | null
}>
}
const tasks = report.tasks
.flatMap((task) =>
task.execution
? [
{
task: task.taskId.replace(/#typecheck$/, ""),
durationMs: task.execution.endTime - task.execution.startTime,
},
]
: [],
)
.sort((a, b) => b.durationMs - a.durationMs)
const total = tasks.reduce((duration, task) => duration + task.durationMs, 0)
const width = Math.max(...tasks.map((task) => task.task.length), "Package".length)
console.log(`Package${" ".repeat(width - "Package".length)} Time Share`)
tasks.forEach((task) => {
const duration = `${(task.durationMs / 1000).toFixed(2)}s`.padStart(7)
const share = `${((task.durationMs / total) * 100).toFixed(1)}%`.padStart(6)
console.log(`${task.task.padEnd(width)} ${duration} ${share}`)
})
console.log(`\nTotal serial task time: ${(total / 1000).toFixed(2)}s`)
console.log(`Turbo summary: ${path.relative(root, summary)}`)

172
script/profile-typecheck.ts Normal file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env bun
import { mkdir } from "fs/promises"
import path from "path"
if (process.platform !== "darwin") throw new Error("System typecheck profiling currently supports macOS only")
const root = path.resolve(import.meta.dir, "..")
const startedAt = new Date()
const args = Bun.argv.slice(2)
const command = [
"bun",
"turbo",
"typecheck",
...(args.some((arg) => arg.startsWith("--concurrency")) ? [] : ["--concurrency=3"]),
...args,
]
const before = systemSnapshot()
const proc = Bun.spawn(command, {
cwd: root,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
})
const samples = [processTreeSnapshot(proc.pid, startedAt)]
const timer = setInterval(() => samples.push(processTreeSnapshot(proc.pid, startedAt)), 200)
const exitCode = await proc.exited
clearInterval(timer)
samples.push(processTreeSnapshot(proc.pid, startedAt))
const finishedAt = new Date()
const after = systemSnapshot()
const active = samples.filter((sample) => sample.processes > 0)
const report = {
command,
cwd: root,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationSeconds: (finishedAt.getTime() - startedAt.getTime()) / 1000,
exitCode,
summary: {
peakCpuPercent: Math.max(0, ...active.map((sample) => sample.cpuPercent)),
averageCpuPercent: average(active.map((sample) => sample.cpuPercent)),
peakAggregateRssMB: Math.max(0, ...active.map((sample) => sample.aggregateRssMB)),
peakProcesses: Math.max(0, ...active.map((sample) => sample.processes)),
peakTsgoRelatedProcesses: Math.max(0, ...active.map((sample) => sample.tsgoRelatedProcesses)),
swapDeltaMB: after.swapUsedMB - before.swapUsedMB,
compressedMemoryDeltaMB: after.compressedMemoryMB - before.compressedMemoryMB,
pageoutDelta: after.pageouts - before.pageouts,
},
system: { before, after },
samples,
}
const directory = path.join(root, ".typecheck-profiles")
const file = path.join(directory, `${startedAt.toISOString().replaceAll(":", "-")}.json`)
await mkdir(directory, { recursive: true })
await Bun.write(file, JSON.stringify(report, null, 2) + "\n")
console.log(`
Typecheck profile
Duration: ${report.durationSeconds.toFixed(1)}s
Average CPU: ${report.summary.averageCpuPercent.toFixed(0)}%
Peak CPU: ${report.summary.peakCpuPercent.toFixed(0)}%
Aggregate RSS: ${report.summary.peakAggregateRssMB.toFixed(0)} MB
Peak processes: ${report.summary.peakProcesses} (${report.summary.peakTsgoRelatedProcesses} tsgo-related)
Swap delta: ${signed(report.summary.swapDeltaMB)} MB
Compressed: ${signed(report.summary.compressedMemoryDeltaMB)} MB
Pageouts: ${signed(report.summary.pageoutDelta)}
Report: ${path.relative(root, file)}
`)
process.exit(exitCode)
function processTreeSnapshot(rootPID: number, startedAt: Date) {
const processes = processList()
const pids = new Set([rootPID])
const pending = [rootPID]
while (pending.length > 0) {
const parent = pending.shift()
processes
.filter((process) => process.ppid === parent && !pids.has(process.pid))
.forEach((process) => {
pids.add(process.pid)
pending.push(process.pid)
})
}
const tree = processes.filter((process) => pids.has(process.pid))
return {
elapsedSeconds: (Date.now() - startedAt.getTime()) / 1000,
processes: tree.length,
tsgoRelatedProcesses: tree.filter((process) => /\btsgo\b/.test(process.command)).length,
cpuPercent: sum(tree.map((process) => process.cpuPercent)),
aggregateRssMB: sum(tree.map((process) => process.rssKB)) / 1024,
}
}
function systemSnapshot() {
const vm = text(["vm_stat"])
const pageSize = Number(vm.match(/page size of (\d+) bytes/)?.[1] ?? 4096)
const fields = Object.fromEntries(
vm
.split("\n")
.map((line) => line.match(/^([^:]+):\s+(\d+)\.?$/))
.filter((match): match is RegExpMatchArray => match !== null)
.map((match) => [match[1], Number(match[2])]),
)
const swap = text(["sysctl", "-n", "vm.swapusage"])
return {
loadAverage: text(["sysctl", "-n", "vm.loadavg"]).trim(),
thermalState: text(["pmset", "-g", "therm"]).trim(),
swapUsedMB: Number(swap.match(/used = ([\d.]+)M/)?.[1] ?? 0),
freeMemoryMB: ((fields["Pages free"] ?? 0) * pageSize) / 1024 / 1024,
compressedMemoryMB: ((fields["Pages occupied by compressor"] ?? 0) * pageSize) / 1024 / 1024,
pageouts: fields.Pageouts ?? 0,
relevantProcesses: processList()
.filter((process) =>
/opencode|tsgo|tsserver|vtsls|eslintServer|tailwindcss-language-server/.test(process.command),
)
.sort((a, b) => b.rssKB - a.rssKB)
.map(processSummary),
topCpuProcesses: processList()
.sort((a, b) => b.cpuPercent - a.cpuPercent)
.slice(0, 15)
.map(processSummary),
topMemoryProcesses: processList()
.sort((a, b) => b.rssKB - a.rssKB)
.slice(0, 15)
.map(processSummary),
}
}
function processSummary(process: ReturnType<typeof processList>[number]) {
return {
pid: process.pid,
ppid: process.ppid,
rssMB: process.rssKB / 1024,
cpuPercent: process.cpuPercent,
command: process.command,
}
}
function processList() {
return text(["ps", "-axo", "pid=,ppid=,rss=,%cpu=,command="])
.split("\n")
.map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(.*)$/))
.filter((match): match is RegExpMatchArray => match !== null)
.map((match) => ({
pid: Number(match[1]),
ppid: Number(match[2]),
rssKB: Number(match[3]),
cpuPercent: Number(match[4]),
command: match[5],
}))
}
function text(command: string[]) {
return Bun.spawnSync(command).stdout.toString()
}
function sum(values: number[]) {
return values.reduce((total, value) => total + value, 0)
}
function average(values: number[]) {
if (values.length === 0) return 0
return sum(values) / values.length
}
function signed(value: number) {
return `${value >= 0 ? "+" : ""}${value.toFixed(0)}`
}

View file

@ -3,6 +3,7 @@
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { fileURLToPath } from "url"
import { UpdateArtifact } from "./update-artifact"
console.log("=== publishing ===\n")
@ -25,7 +26,6 @@ async function prepareReleaseFiles() {
}
await $`bun install`
await $`./packages/sdk/js/script/build.ts`
}
if (Script.release && !Script.preview) {
@ -35,15 +35,27 @@ if (Script.release && !Script.preview) {
await prepareReleaseFiles()
console.log("\n=== schema ===\n")
await $`bun ./packages/schema/script/publish.ts`
console.log("\n=== theme ===\n")
await $`bun ./packages/theme/script/publish.ts`
console.log("\n=== ai ===\n")
await $`bun ./packages/ai/script/publish.ts`
console.log("\n=== util ===\n")
await $`bun ./packages/util/script/publish.ts`
console.log("\n=== protocol ===\n")
await $`bun ./packages/protocol/script/publish.ts`
console.log("\n=== client ===\n")
await $`bun ./packages/client/script/publish.ts`
console.log("\n=== cli ===\n")
await $`bun ./packages/opencode/script/publish.ts`
console.log("\n=== preview cli ===\n")
await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== sdk ===\n")
await $`bun ./packages/sdk/js/script/publish.ts`
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
@ -70,4 +82,13 @@ if (Script.release && !Script.preview) {
if (Script.release) {
await $`gh release edit ${tag} --draft=false --repo ${process.env.GH_REPO}`
const repo = process.env.GH_REPO
if (!repo) throw new Error("GH_REPO is required")
await UpdateArtifact.publish({
channel: Script.channel,
name: "desktop",
distribution: "github",
version: Script.version,
metadata: await UpdateArtifact.desktopMetadata(Script.version, repo),
})
}

View file

@ -120,7 +120,7 @@ async function commits(from: string, to: string) {
}
const log =
await $`git log ${base}..${head} --format=%H -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text()
await $`git log ${base}..${head} --format=%H -- packages/opencode packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text()
const list: Commit[] = []
for (const hash of log.split("\n").filter(Boolean)) {
@ -136,7 +136,7 @@ async function commits(from: string, to: string) {
else if (file.startsWith("packages/opencode/")) areas.add("core")
else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri")
else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app")
else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk")
else if (file.startsWith("packages/plugin/")) areas.add("sdk")
else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode")
}

104
script/update-artifact.ts Normal file
View file

@ -0,0 +1,104 @@
type Artifact = {
channel: string
name: string
distribution: string
version: string
metadata: Record<string, unknown>
}
type DesktopFile = {
url: string
sha512: string
size: number
blockMapSize?: number
}
export namespace UpdateArtifact {
export async function publish(artifact: Artifact) {
if (process.env.GITHUB_ACTIONS !== "true") {
console.log("skipped update artifact publication outside GitHub Actions")
return
}
const requestURL = process.env.ACTIONS_ID_TOKEN_REQUEST_URL
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
if (!requestURL || !requestToken) throw new Error("GitHub Actions OIDC is unavailable")
const url = new URL(requestURL)
url.searchParams.set("audience", "https://update.opencode.ai")
const tokenResponse = await fetch(url, { headers: { Authorization: `Bearer ${requestToken}` } })
if (!tokenResponse.ok) throw new Error(`Failed to request GitHub OIDC token: ${tokenResponse.status}`)
const token: unknown = await tokenResponse.json()
if (!isRecord(token) || typeof token.value !== "string") throw new Error("GitHub OIDC response did not include a token")
const response = await fetch("https://update.opencode.ai/api/publish", {
method: "POST",
headers: {
Authorization: `Bearer ${token.value}`,
"Content-Type": "application/json",
},
body: JSON.stringify(artifact),
})
if (response.ok) return
throw new Error(`Failed to publish update artifact: ${response.status} ${await response.text()}`)
}
export async function desktopMetadata(version: string, repo: string) {
const directory = process.env.RUNNER_TEMP ?? "/tmp"
const entries = await Promise.all(
[
["desktop.yml", "latest.yml"],
["desktop-mac.yml", "latest-mac.yml"],
["desktop-linux.yml", "latest-linux.yml"],
["desktop-linux-arm64.yml", "latest-linux-arm64.yml"],
].map(async ([name, source]) => {
const file = Bun.file(`${directory}/${source}`)
if (!(await file.exists())) return
return [name, parseDesktop(await file.text(), version, repo)] as const
}),
)
const manifests = Object.fromEntries(entries.filter((entry) => entry !== undefined))
if (!Object.keys(manifests).length) throw new Error("No desktop update metadata found")
return { manifests }
}
}
function parseDesktop(content: string, version: string, repo: string) {
const lines = content.split("\n")
const found = lines.find((line) => line.startsWith("version:"))?.slice("version:".length).trim()
if (found !== version) throw new Error(`Desktop metadata version mismatch: expected ${version}, got ${found}`)
const releaseDate = lines
.find((line) => line.startsWith("releaseDate:"))
?.slice("releaseDate:".length)
.trim()
.replace(/^['"]|['"]$/g, "")
if (!releaseDate) throw new Error("Desktop metadata did not include a release date")
const files: DesktopFile[] = []
lines.forEach((line) => {
const value = line.trim()
if (value.startsWith("- url:")) {
const name = value.slice("- url:".length).trim()
files.push({
url: name.startsWith("http")
? name
: `https://github.com/${repo}/releases/download/v${version}/${encodeURIComponent(name)}`,
sha512: "",
size: 0,
})
return
}
const current = files.at(-1)
if (!current) return
if (value.startsWith("sha512:")) current.sha512 = value.slice("sha512:".length).trim()
if (value.startsWith("size:")) current.size = Number(value.slice("size:".length).trim())
if (value.startsWith("blockMapSize:")) current.blockMapSize = Number(value.slice("blockMapSize:".length).trim())
})
if (!files.length || files.some((file) => !file.sha512 || !file.size)) {
throw new Error("Desktop metadata contained an incomplete file")
}
return { files, releaseDate }
}
function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input)
}

View file

@ -18,7 +18,7 @@ This does not mean removing SQLite or Drizzle everywhere in one step. The smalle
## Current Inventory
Production imports from `packages/opencode/src/storage/db.ts` are concentrated in 22 source files:
Production imports from `packages/opencode/src/storage/db.ts` are concentrated in 21 source files:
- `packages/opencode/src/account/repo.ts`
- `packages/opencode/src/cli/cmd/db.ts`
@ -36,13 +36,12 @@ Production imports from `packages/opencode/src/storage/db.ts` are concentrated i
- `packages/opencode/src/session/projectors.ts`
- `packages/opencode/src/session/prompt.ts`
- `packages/opencode/src/session/session.ts`
- `packages/opencode/src/session/todo.ts`
- `packages/opencode/src/share/share-next.ts`
- `packages/opencode/src/storage/db.ts`
- `packages/opencode/src/sync/index.ts`
- `packages/opencode/src/worktree/index.ts`
There are 65 direct API/type references in those files. The references fall into the groups below.
There are 63 direct API/type references in those files. The references fall into the groups below.
## Group 1: Database Runtime And Startup
@ -151,7 +150,6 @@ Files:
- `packages/opencode/src/session/session.ts`
- `packages/opencode/src/session/message-v2.ts`
- `packages/opencode/src/session/prompt.ts`
- `packages/opencode/src/session/todo.ts`
- `packages/opencode/src/session/projectors.ts`
Current usage:
@ -159,7 +157,6 @@ Current usage:
- `session/session.ts` uses `Database.use` for session reads, list queries, children, part lookup, and global list helpers.
- `session/message-v2.ts` uses `Database.use` to page messages, hydrate parts, fetch one message, and fetch parts.
- `session/prompt.ts` imports `eq` from `@/storage/db` and reads current prompt-related session/message rows directly.
- `session/todo.ts` uses `Database.transaction` for todo replacement and `Database.use` for list reads.
- `session/projectors.ts` uses `TxOrDb` for session/message usage projection helpers.
Why this group should be split:
@ -171,7 +168,6 @@ Why this group should be split:
Target shape:
- Create or use a session/message read module with Effect-native methods for `get`, `list`, `page`, `parts`, and prompt assembly reads.
- Move todo persistence either into a session todo repository or into the sync event projection path.
- Convert `session/projectors.ts` only after Group 2 defines the replacement projector transaction type.
Suggested order:
@ -179,7 +175,6 @@ Suggested order:
- Migrate `session/message-v2.ts` reads first because the module already centralizes message pagination and hydration.
- Migrate `session/session.ts` read helpers next.
- Migrate `session/prompt.ts` after message/session reads exist, and import drizzle operators from `drizzle-orm` if any direct SQL remains temporarily.
- Migrate `session/todo.ts` writes with the sync transaction work or move them behind a repository.
## Group 5: Legacy CLI And One-Off Admin Reads

44
specs/v2/README.md Normal file
View file

@ -0,0 +1,44 @@
# V2 Specifications
These documents explain V2 behavior that is difficult to recover from one source file. They are not API reference or a backlog.
## Authority
Authority follows the concern:
| Concern | Owner |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| HTTP operations and transport errors | [Protocol](../../packages/protocol/src) endpoint definitions assembled by Server `HttpApi` |
| Public domain shapes and durable event payloads | [Schema](../../packages/schema/src) |
| Runtime behavior and persistence | [Core](../../packages/core/src) |
| Canonical vocabulary and cross-domain invariants | Root [CONTEXT.md](../../CONTEXT.md) |
| Contributor-critical regression guardrails | Root [AGENTS.md](../../AGENTS.md) |
Current specifications explain cross-module contracts without copying exact types. Decision records explain why a design was selected. Historical documents describe earlier states and may use obsolete names.
Generated clients follow the assembled public `HttpApi`. GitHub issues own active work; Git history preserves removed plans and scratchpads.
## Current Contracts
| Document | Job |
| ----------------------- | --------------------------------------------------------------------------------------- |
| [Session](./session.md) | Explain prompt admission, execution, instructions, compaction, and recovery boundaries. |
| [Tools](./tools.md) | Explain tool construction, registration, execution, and outcome laws. |
## Decisions And Proposals
| Document | Status | Job |
| ----------------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------- |
| [Event stream](./event-stream-architecture.md) | Accepted and implemented | Record why public events use one encoded feed with independent queues. |
| [Managed restart continuation](./session-restart-continuation.md) | Accepted and implemented | Record why graceful managed-service restart uses private Session suspension. |
| [Instruction sync](./instruction-sync-proposal.md) | Accepted and implemented | Record why instruction state is value deltas plus derived rendering. |
| [Provider policy](./provider-policy.md) | Proposed and unimplemented | Explore provider authorization independently from provider configuration. |
## Historical Context
| Document | Job |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| [Schema changelog](./schema-changelog.md) | Preserve the pre-release compatibility ledger. Names in older entries are intentionally historical. |
| [Catalog/config/plugin lifecycle](./catalog-config-plugin-lifecycle.md) | Preserve the option comparison that led to replayable Location-scoped catalog transforms. |
Do not add implementation checklists here. Put actionable work in GitHub issues and package-specific contributor guidance next to the code it governs.

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
# Catalog / Config / Plugin Lifecycle Options
Status: current core has selected replayable Location-scoped Catalog transforms, aligned with option B. Reload/watch behavior and deferred external plugin activation remain design work; the option comparison below is retained as historical context.
Status: **Historical decision record.** Option B, replayable Location-scoped Catalog transforms, was selected and implemented. All interfaces and flows below are historical option sketches, not current API reference; current behavior is owned by Core.
We need to choose where provider/model inputs live and how visible catalog state changes after boot. The designs below compare config, models.dev, auth, plugin activation/disablement, config edits, and policy changes under each option.
The decision compared where provider/model inputs live and how visible catalog state changes after boot. The designs below preserve that comparison.
## Scenarios
@ -15,7 +15,7 @@ We need to choose where provider/model inputs live and how visible catalog state
- Config edit: authored configuration changes while the location is open.
- Policy: allowed/denied provider selection changes after providers exist.
## A. Config Transforms, Service Reload
## A. Rejected: Config Transforms, Service Reload
`Config` merges its ordered documents and then runs ordered, replayable plugin transforms. Each transform is a callback receiving `Draft<Config.Info>` and may mutate any config field.
@ -27,13 +27,9 @@ const transform = yield * Config.transform()
yield *
transform((config) => {
config.providers ??= {}
config.providers.acme = {
/* ... */
}
config.providers.acme = {/* ... */}
config.model = "acme/code"
config.permissions = [
/* ... */
]
config.permissions = [/* ... */]
})
```
@ -188,7 +184,7 @@ policy config changes
- One reload produces at most one `Catalog.Event.Updated` notification.
- Deferred plugin activation avoids blocking readiness, but plugin completions may cause repeated full-service reload batches during startup.
## B. Catalog Transforms
## B. Selected: Catalog Transforms
Plugins register replayable catalog transforms. Each transform receives a `Catalog.Editor` whose helper methods mutate a private catalog draft; `Catalog` rematerializes visible records from its active transforms.

View file

@ -0,0 +1,227 @@
# V2 Event Stream Architecture
## Decision
The public HTTP event stream uses one Server-scoped encoded feed with one independently bounded queue per connection.
```text
Core EventV2.listen()
|
| one global subscription
v
Server EventFeed
public filter
schema encode
JSON encode
SSE frame once
|
| nonblocking offer of one shared immutable string
v
Queue A Queue B Queue C
| | |
HTTP A HTTP B HTTP C
```
Core owns event meaning, publication, persistence, typed observation, durable logs, replay, and transactional projection.
Server owns public event selection, wire encoding, bounded connection delivery, and subscriber lifecycle.
Protocol continues to own the `OpenCodeEvent` SSE contract. Generated Promise and Effect clients remain unchanged.
## Context
Before this change, every `/api/event` connection called `EventV2.liveBounded`. Each call registered a Core callback listener and allocated a dropping queue of raw event payloads. Every HTTP connection then independently performed:
1. Public-event filtering.
2. `OpenCodeEvent` schema encoding.
3. `JSON.stringify`.
4. SSE framing.
5. UTF-8 encoding.
With `N` connected TUIs, schema and wire encoding therefore ran `N` times for every accepted event.
`liveBounded` was introduced before zero-argument `events.subscribe()` became the unified live interface. It stayed on deprecated `listen` because it provided a stronger contract than the shared unbounded Core PubSub: one slow subscriber could overflow and fail without blocking healthy subscribers.
The global cross-location event stream is intentional. The endpoint is outside `LocationMiddleware`, and the TUI uses event location metadata to update state for multiple locations. The feed must not add request-location filtering.
## Delivery Law
The Server feed preserves this law:
> A connection has an independent finite lag budget. Exceeding it terminates only that connection while publication and healthy connections continue in order.
Each connection receives a `Queue.dropping` with capacity 4,096 accepted public frames.
When an offer returns `false`:
1. The queue is removed from the active subscriber registry immediately.
2. The queue is failed with `SubscriberOverflowError`.
3. The same frame is still offered to every other active queue.
4. Core publication and the Server observer never suspend on that connection.
Previously accepted frames drain before the queue failure surfaces. The overflow-causing frame is not accepted by that connection.
Internal Core events, `server.connected`, and heartbeats do not consume the queue capacity.
## Why Independent Queues
The design was reviewed twice, including explicit consideration of one shared Effect PubSub of encoded frames.
### Shared PubSub benefits
A shared PubSub stores each frame once and gives each subscriber a cursor. Its retained feed storage is proportional to maximum lag rather than the sum of every subscriber's lag.
### Shared PubSub costs
Effect's bounded PubSub strategies do not directly express independent subscriber failure:
- `bounded` can suspend the shared publisher behind the slowest subscriber;
- `dropping` rejects one publication for every subscriber when shared capacity is full;
- `sliding` silently skips events while leaving the stale subscriber connected;
- `unbounded` removes the structural memory bound.
Independent eviction can be built on a dropping PubSub, but requires:
- retaining every subscription's child scope;
- a separate typed overflow signal because subscription closure appears as interruption/completion;
- serialization of registration, removal, eviction, and publication;
- lag scans at capacity;
- waiting for scope closure to release shared ring slots;
- terminal handling if a supposedly impossible shared publish returns `false`;
- immediate discard of the stale subscriber's previously accepted unread backlog.
That is a custom multicast protocol layered over PubSub.
The incremental benefit is queue-slot references, not encoded frame copies: every independent queue stores the same immutable encoded string reference. At 50 clients each retaining 4,096 frames, raw references are roughly 1.6 MiB before array overhead. HTTP runtime, TLS, kernel, proxy, and client buffers may dominate that cost.
The chosen queue design captures the dominant optimization, encode once, while retaining direct queue-local overflow semantics and a smaller failure domain.
Revisit shared PubSub storage only if measurements after shared encoding show queue reference retention or per-queue offers are material.
## Capacity
The migration preserves the existing 4,096-event capacity.
This is compatibility, not a claim that 4,096 is optimal. It is an event-count lag threshold, not a complete memory bound:
- frames vary in size;
- stream pulls may move batches into HTTP buffers before queue lag reflects them;
- kernel and client buffers are outside Server accounting.
Do not raise capacity merely because frames are encoded once. A larger threshold retains stale clients longer.
Tune capacity separately using observed:
- public event rates and burst sizes;
- healthy subscriber queue high-water marks;
- encoded frame-size distribution;
- overflow and reconnect frequency;
- retained heap and RSS;
- downstream drain duration under a stalled reader.
Add a byte budget only if measurements show event count is an inadequate memory safeguard.
## Feed Lifecycle
### Server scope
`EventFeed.layer` is built once with the Server handler graph. It registers one global Core listener outside request location middleware.
The listener is installed synchronously before the feed service is exposed. Public filtering, encoding, and nonblocking queue offers happen inline once per Core event. This avoids both a startup gap and an unbounded asynchronous ingress backlog.
Core invokes the one observer sequentially. It does not fork encoding or fan-out per event, so every healthy subscriber observes the same order.
When no HTTP subscribers are registered, the observer returns before wire encoding, so headless and idle servers do not pay serialization cost.
### Connection scope
Each `feed.subscribe` acquisition:
1. Allocates one dropping queue.
2. Registers it synchronously.
3. Returns `Stream.fromQueue(queue)`.
4. Removes and shuts down the queue when the request scope closes.
The raw handler acquires and registers the queue before prepending its connection-specific `server.connected` frame:
```text
register queue
-> emit server.connected
-> drain queued live frames
```
Events before registration may be missed, consistent with a volatile stream. Events after registration queue behind `server.connected`.
Heartbeats remain connection-local and outside the feed.
### Encoding failure
If one accepted public event cannot be encoded:
1. Log its ID, type, and cause.
2. Fail every currently connected queue with `EncodingError`.
3. Skip the malformed volatile event.
4. Keep the feed available for later connections and valid events.
Keeping current clients connected would create a silent gap. Permanently terminating the feed would poison future connections.
## HTTP And Code Generation
Protocol remains unchanged:
```ts
HttpApiSchema.StreamSse({ data: OpenCodeEvent })
```
The raw handler continues to own:
- the unique `server.connected` event;
- the 15-second heartbeat;
- SSE response headers;
- `HttpServerResponse.stream` construction.
The feed supplies complete immutable SSE frame strings for ordinary public events. The handler merges connection-local frames and performs text-to-byte encoding.
Because method, path, schema, and wire representation do not change:
- OpenAPI does not change;
- generated Promise clients do not change;
- generated Effect clients do not change;
- TUI decoding and reconnect behavior do not change;
- client regeneration is not required.
## Core Cleanup
The Server no longer uses `EventV2.liveBounded`, so Core removes that dead helper and its transport-specific overflow error. The feed registers one observer through the existing `listen` interface; other listeners are unchanged.
Transactional projector registration is unrelated and remains unchanged.
## Benchmark
The disposable benchmark reproduced the previous per-connection schema/JSON/SSE encoding path with a representative 8 KiB public event. It used one warmup and nine measured runs; median was the primary metric and median absolute deviation was reported. The benchmark was intentionally not committed because it isolated the removed encoding boundary rather than exercising the complete HTTP stack.
Results on Apple Silicon with Bun 1.3.14:
| Clients | Current median | Shared median | Change |
| ------: | -------------: | ------------: | -----: |
| 1 | 9.488 ms | 9.554 ms | +0.7% |
| 10 | 96.312 ms | 10.352 ms | -89.3% |
| 50 | 553.928 ms | 12.389 ms | -97.8% |
The benchmark isolates the repeated encoding boundary. It does not claim to measure socket throughput, client decoding, or downstream HTTP buffering. Queue offers and socket writes remain proportional to connected clients.
An experiment replacing direct schema encoding plus `JSON.stringify` with `Schema.fromJsonString(OpenCodeEvent)` was discarded: the one-client median regressed from approximately 9.5 ms to 38.8 ms with substantially higher variance.
## Verification
Behavioral tests cover:
- one encoding operation for multiple subscribers;
- identical frame delivery to healthy subscribers;
- independent slow-subscriber overflow;
- healthy delivery of events after another subscriber overflows;
- filtering internal events before capacity;
- failure of current subscribers after malformed public encoding;
- continued delivery to later subscribers after an encoding failure.
Package typechecks and the existing Core event/event-logger suites protect the Core interface migration.

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