Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline b726e9b59e fix: anthropic msg transform 2026-04-11 01:06:51 -05:00
16 changed files with 363 additions and 579 deletions
@@ -308,32 +308,3 @@ Current raw fs users that will convert during tool migration:
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
## Destroying the facades
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
### Process
For each service, the migration is roughly:
1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself.
2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`:
- Add the service to the caller's layer R type (`Layer.Layer<Self, never, ... | Namespace.Service>`)
- Yield it at the top of the layer: `const ns = yield* Namespace.Service`
- Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case)
- Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain
3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency.
4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`.
5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`.
### Pitfalls
- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases.
- **`Effect.tryPromise``yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong.
- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition.
- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean.
### Migration log
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
-67
View File
@@ -1,67 +0,0 @@
import { Cause, Effect, Logger, References } from "effect"
import { Log } from "@/util/log"
export namespace EffectLogger {
type Fields = Record<string, unknown>
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
const text = (input: unknown): string => {
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
return input === undefined ? "" : String(input)
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
}
export const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
extra[`logSpan.${key}`] = `${now - start}ms`
}
if (opts.cause.reasons.length > 0) {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
case "Warn":
return log.warn(msg, extra)
case "Error":
case "Fatal":
return log.error(msg, extra)
default:
return log.info(msg, extra)
}
})
export const layer = Logger.layer([Logger.tracerLogger, logger], { mergeWithExisting: false })
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})
}
+25 -33
View File
@@ -1,42 +1,34 @@
import { Duration, Layer } from "effect"
import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Otlp } from "effect/unstable/observability"
import { EffectLogger } from "@/effect/logger"
import { Flag } from "@/flag/flag"
import { CHANNEL, VERSION } from "@/installation/meta"
export namespace Observability {
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT ?? (CHANNEL === "local" ? "http://127.0.0.1:27686" : undefined)
export const enabled = !!base
export const enabled = !!Flag.OTEL_EXPORTER_OTLP_ENDPOINT
const resource = {
serviceName: "opencode",
serviceVersion: VERSION,
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"opencode.client": Flag.OPENCODE_CLIENT,
},
}
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, value] = x.split("=")
acc[key] = value
return acc
},
{} as Record<string, string>,
)
: undefined
export const layer = !base
? EffectLogger.layer
export const layer = !Flag.OTEL_EXPORTER_OTLP_ENDPOINT
? Layer.empty
: Otlp.layerJson({
baseUrl: base,
loggerExportInterval: Duration.seconds(1),
tracerExportInterval: Duration.seconds(1),
loggerMergeWithExisting: true,
resource,
headers,
}).pipe(Layer.provide(EffectLogger.layer), Layer.provide(FetchHttpClient.layer))
baseUrl: Flag.OTEL_EXPORTER_OTLP_ENDPOINT,
loggerMergeWithExisting: false,
resource: {
serviceName: "opencode",
serviceVersion: VERSION,
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"opencode.client": Flag.OPENCODE_CLIENT,
},
},
headers: Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, value] = x.split("=")
acc[key] = value
return acc
},
{} as Record<string, string>,
)
: undefined,
}).pipe(Layer.provide(FetchHttpClient.layer))
}
+1 -1
View File
@@ -53,7 +53,7 @@ export namespace ProviderTransform {
): ModelMessage[] {
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") {
if (model.api.npm === "@ai-sdk/amazon-bedrock") {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
@@ -13,7 +13,6 @@ import { SessionShare } from "@/share/session"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo"
import { AppRuntime } from "../../effect/app-runtime"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Command } from "../../command"
@@ -186,7 +185,7 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const todos = await AppRuntime.runPromise(Todo.Service.use((svc) => svc.get(sessionID)))
const todos = await Todo.get(sessionID)
return c.json(todos)
},
)
+5 -6
View File
@@ -361,11 +361,10 @@ export namespace Session {
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, Bus.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const storage = yield* Storage.Service
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
@@ -586,9 +585,9 @@ export namespace Session {
})
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
return yield* storage
.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
.pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => []))
return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe(
Effect.orElseSucceed((): Snapshot.FileDiff[] => []),
)
})
const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) {
@@ -661,7 +660,7 @@ export namespace Session {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
+7 -49
View File
@@ -6,7 +6,7 @@ import { Config } from "@/config/config"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { Snapshot } from "@/snapshot"
import { EffectLogger } from "@/effect/logger"
import { Log } from "@/util/log"
import { Session } from "."
import { LLM } from "./llm"
import { MessageV2 } from "./message-v2"
@@ -23,7 +23,7 @@ import { isRecord } from "@/util/record"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
const log = EffectLogger.create({ service: "session.processor" })
const log = Log.create({ service: "session.processor" })
export type Result = "compact" | "stop" | "continue"
@@ -121,15 +121,6 @@ export namespace SessionProcessor {
reasoningMap: {},
}
let aborted = false
const slog = log.with({ sessionID: input.sessionID, messageID: input.assistantMessage.id })
yield* Effect.annotateCurrentSpan({
sessionID: input.sessionID,
messageID: input.assistantMessage.id,
agent: input.assistantMessage.agent,
providerID: input.model.providerID,
modelID: input.model.id,
})
const parse = (e: unknown) =>
MessageV2.fromError(e, {
@@ -457,7 +448,7 @@ export namespace SessionProcessor {
return
default:
yield* slog.info("unhandled", { event: value.type, value })
log.info("unhandled", { ...value })
return
}
})
@@ -523,7 +514,7 @@ export namespace SessionProcessor {
})
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
yield* slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined })
const error = parse(e)
if (MessageV2.ContextOverflowError.isInstance(error)) {
ctx.needsCompaction = true
@@ -539,18 +530,7 @@ export namespace SessionProcessor {
})
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
yield* Effect.annotateCurrentSpan({
sessionID: ctx.sessionID,
messageID: ctx.assistantMessage.id,
agent: ctx.assistantMessage.agent,
providerID: ctx.model.providerID,
modelID: ctx.model.id,
})
yield* slog.info("process", {
agent: ctx.assistantMessage.agent,
providerID: ctx.model.providerID,
modelID: ctx.model.id,
})
log.info("process")
ctx.needsCompaction = false
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
@@ -564,17 +544,6 @@ export namespace SessionProcessor {
Stream.tap((event) => handleEvent(event)),
Stream.takeUntil(() => ctx.needsCompaction),
Stream.runDrain,
Effect.withSpan(
"SessionProcessor.stream",
{
attributes: {
sessionID: ctx.sessionID,
messageID: ctx.assistantMessage.id,
agent: ctx.assistantMessage.agent,
},
},
{ captureStackTrace: false },
),
)
}).pipe(
Effect.onInterrupt(() =>
@@ -605,19 +574,8 @@ export namespace SessionProcessor {
Effect.ensuring(cleanup()),
)
if (ctx.needsCompaction) {
yield* slog.warn("compact", { finish: ctx.assistantMessage.finish, blocked: ctx.blocked })
return "compact"
}
if (ctx.blocked || ctx.assistantMessage.error) {
yield* slog.warn("stop", {
blocked: ctx.blocked,
finish: ctx.assistantMessage.finish,
hasError: !!ctx.assistantMessage.error,
})
return "stop"
}
yield* slog.info("continue", { finish: ctx.assistantMessage.finish })
if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
return "continue"
})
})
+12 -96
View File
@@ -44,7 +44,6 @@ import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool, type TaskPromptOps } from "@/tool/task"
@@ -65,7 +64,6 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
const elog = EffectLogger.create({ service: "session.prompt" })
export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
@@ -104,7 +102,7 @@ export namespace SessionPrompt {
const revert = yield* SessionRevert.Service
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
yield* elog.info("cancel", { sessionID })
log.info("cancel", { sessionID })
yield* state.cancel(sessionID)
})
@@ -198,7 +196,11 @@ export namespace SessionPrompt {
const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
yield* sessions
.setTitle({ sessionID: input.session.id, title: t })
.pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
.pipe(
Effect.catchCause((cause) =>
Effect.sync(() => log.error("failed to generate title", { error: Cause.squash(cause) })),
),
)
})
const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
@@ -398,17 +400,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return Effect.runPromise(
Effect.gen(function* () {
const ctx = context(args, options)
yield* Effect.annotateCurrentSpan({
tool: item.id,
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
callID: ctx.callID,
})
yield* elog.info("tool.start", {
tool: item.id,
sessionID: ctx.sessionID,
callID: ctx.callID,
})
yield* plugin.trigger(
"tool.execute.before",
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
@@ -432,27 +423,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (options.abortSignal?.aborted) {
yield* input.processor.completeToolCall(options.toolCallId, output)
}
yield* elog.info("tool.done", {
tool: item.id,
sessionID: ctx.sessionID,
callID: ctx.callID,
truncated: output.metadata.truncated,
})
return output
}).pipe(
Effect.withSpan(
`Tool.${item.id}`,
{
attributes: {
tool: item.id,
sessionID: input.session.id,
messageID: input.processor.message.id,
callID: options.toolCallId,
},
},
{ captureStackTrace: false },
),
),
}),
)
},
})
@@ -469,13 +441,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Effect.runPromise(
Effect.gen(function* () {
const ctx = context(args, opts)
yield* Effect.annotateCurrentSpan({
tool: key,
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
callID: ctx.callID,
})
yield* elog.info("tool.start", { tool: key, sessionID: ctx.sessionID, callID: ctx.callID })
yield* plugin.trigger(
"tool.execute.before",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
@@ -537,27 +502,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
yield* elog.info("tool.done", {
tool: key,
sessionID: ctx.sessionID,
callID: ctx.callID,
truncated: output.metadata.truncated,
})
return output
}).pipe(
Effect.withSpan(
`Tool.${key}`,
{
attributes: {
tool: key,
sessionID: input.session.id,
messageID: input.processor.message.id,
callID: opts.toolCallId,
},
},
{ captureStackTrace: false },
),
),
}),
)
tools[key] = item
}
@@ -1356,14 +1302,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(
function* (sessionID: SessionID) {
const ctx = yield* InstanceState.context
const slog = elog.with({ sessionID })
let structured: unknown | undefined
let step = 0
const session = yield* sessions.get(sessionID)
while (true) {
yield* status.set(sessionID, { type: "busy" })
yield* slog.info("loop", { step })
log.info("loop", { step, sessionID })
let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
@@ -1383,14 +1328,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
yield* Effect.annotateCurrentSpan({
sessionID,
step,
agent: lastUser.agent,
providerID: lastUser.model.providerID,
modelID: lastUser.model.modelID,
})
const lastAssistantMsg = msgs.findLast(
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
)
@@ -1407,17 +1344,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
yield* slog.info("exiting loop")
log.info("exiting loop", { sessionID })
break
}
step++
yield* slog.info("step", {
step,
agent: lastUser.agent,
providerID: lastUser.model.providerID,
modelID: lastUser.model.modelID,
})
if (step === 1)
yield* title({
session,
@@ -1435,7 +1366,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
if (task?.type === "compaction") {
yield* slog.warn("compaction", { step, auto: task.auto, overflow: task.overflow })
const result = yield* compaction.process({
messages: msgs,
parentID: lastUser.id,
@@ -1540,21 +1470,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Effect.promise(() => SystemPrompt.environment(model)),
instruction.system().pipe(Effect.orDie),
MessageV2.toModelMessagesEffect(msgs, model),
]).pipe(
Effect.withSpan(
"SessionPrompt.prepareInput",
{
attributes: {
sessionID,
step,
agent: agent.name,
providerID: model.providerID,
modelID: model.id,
},
},
{ captureStackTrace: false },
),
)
])
const system = [...env, ...(skills ? [skills] : []), ...instructions]
const format = lastUser.format ?? { type: "text" as const }
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
@@ -1624,7 +1540,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
)
const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
yield* elog.info("command", { sessionID: input.sessionID, command: input.command, agent: input.agent })
log.info("command", input)
const cmd = yield* commands.get(input.command)
if (!cmd) {
const available = (yield* commands.list()).map((c) => c.name)
+6
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { makeRuntime } from "@/effect/run-service"
import { SessionID } from "./schema"
import { Effect, Layer, ServiceMap } from "effect"
import z from "zod"
@@ -82,4 +83,9 @@ export namespace Todo {
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(sessionID: SessionID) {
return runPromise((svc) => svc.get(sessionID))
}
}
+23
View File
@@ -4,6 +4,7 @@ import { Global } from "../global"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import { AppFileSystem } from "@/filesystem"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect"
import { Git } from "@/git"
@@ -330,4 +331,26 @@ export namespace Storage {
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function remove(key: string[]) {
return runPromise((svc) => svc.remove(key))
}
export async function read<T>(key: string[]) {
return runPromise((svc) => svc.read<T>(key))
}
export async function update<T>(key: string[], fn: (draft: T) => void) {
return runPromise((svc) => svc.update<T>(key, fn))
}
export async function write<T>(key: string[], content: T) {
return runPromise((svc) => svc.write(key, content))
}
export async function list(prefix: string[]) {
return runPromise((svc) => svc.list(prefix))
}
}
+1 -2
View File
@@ -24,7 +24,6 @@ export const WriteTool = Tool.defineEffect(
const lsp = yield* LSP.Service
const fs = yield* AppFileSystem.Service
const filetime = yield* FileTime.Service
const format = yield* Format.Service
return {
description: DESCRIPTION,
@@ -57,7 +56,7 @@ export const WriteTool = Tool.defineEffect(
)
yield* fs.writeWithDirs(filepath, params.content)
yield* format.file(filepath)
yield* Effect.promise(() => Format.file(filepath))
Bus.publish(File.Event.Edited, { file: filepath })
yield* Effect.promise(() =>
Bus.publish(FileWatcher.Event.Updated, {
@@ -13,7 +13,6 @@ import { Provider } from "../../src/provider/provider"
import { Session } from "../../src/session"
import type { SessionID } from "../../src/session/schema"
import { ShareNext } from "../../src/share/share-next"
import { Storage } from "../../src/storage/storage"
import { SessionShareTable } from "../../src/share/share.sql"
import { Database, eq } from "../../src/storage/db"
import { provideTmpdirInstance } from "../fixture/fixture"
@@ -56,7 +55,7 @@ function wired(client: HttpClient.HttpClient) {
return Layer.mergeAll(
Bus.layer,
ShareNext.layer,
Session.defaultLayer,
Session.layer,
AccountRepo.layer,
NodeFileSystem.layer,
CrossSpawnSpawner.defaultLayer,
+216 -213
View File
@@ -1,293 +1,296 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Exit, Layer } from "effect"
import { Effect, Layer, ManagedRuntime } from "effect"
import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Git } from "../../src/git"
import { Global } from "../../src/global"
import { Storage } from "../../src/storage/storage"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { tmpdir } from "../fixture/fixture"
const dir = path.join(Global.Path.data, "storage")
const it = testEffect(Layer.mergeAll(Storage.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer))
const scope = Effect.fnUntraced(function* () {
async function withScope<T>(fn: (root: string[]) => Promise<T>) {
const root = ["storage_test", crypto.randomUUID()]
const fs = yield* AppFileSystem.Service
const svc = yield* Storage.Service
yield* Effect.addFinalizer(() =>
fs.remove(path.join(dir, ...root), { recursive: true, force: true }).pipe(Effect.ignore),
)
return { root, svc }
})
try {
return await fn(root)
} finally {
await fs.rm(path.join(dir, ...root), { recursive: true, force: true })
}
}
// remap(root) rewrites any path under Global.Path.data to live under `root` instead.
// Used by remappedFs to build an AppFileSystem that Storage thinks is the real global
// data dir but actually targets a tmp dir — letting migration tests stage legacy layouts.
// NOTE: only the 6 methods below are intercepted. If Storage starts using a different
// AppFileSystem method that touches Global.Path.data, add it here.
function remap(root: string, file: string) {
function map(root: string, file: string) {
if (file === Global.Path.data) return root
if (file.startsWith(Global.Path.data + path.sep)) return path.join(root, path.relative(Global.Path.data, file))
return file
}
function remappedFs(root: string) {
function layer(root: string) {
return Layer.effect(
AppFileSystem.Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return AppFileSystem.Service.of({
...fs,
isDir: (file) => fs.isDir(remap(root, file)),
readJson: (file) => fs.readJson(remap(root, file)),
writeWithDirs: (file, content, mode) => fs.writeWithDirs(remap(root, file), content, mode),
readFileString: (file) => fs.readFileString(remap(root, file)),
remove: (file) => fs.remove(remap(root, file)),
isDir: (file) => fs.isDir(map(root, file)),
readJson: (file) => fs.readJson(map(root, file)),
writeWithDirs: (file, content, mode) => fs.writeWithDirs(map(root, file), content, mode),
readFileString: (file) => fs.readFileString(map(root, file)),
remove: (file) => fs.remove(map(root, file)),
glob: (pattern, options) =>
fs.glob(pattern, options?.cwd ? { ...options, cwd: remap(root, options.cwd) } : options),
fs.glob(pattern, options?.cwd ? { ...options, cwd: map(root, options.cwd) } : options),
})
}),
).pipe(Layer.provide(AppFileSystem.defaultLayer))
}
// Layer.fresh forces a new Storage instance — without it, Effect's in-test layer cache
// returns the outer testEffect's Storage (which uses the real AppFileSystem), not a new
// one built on top of remappedFs.
const remappedStorage = (root: string) =>
Layer.fresh(Storage.layer.pipe(Layer.provide(remappedFs(root)), Layer.provide(Git.defaultLayer)))
async function withStorage<T>(
root: string,
fn: (run: <A, E>(body: Effect.Effect<A, E, Storage.Service>) => Promise<A>) => Promise<T>,
) {
const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)), Layer.provide(Git.defaultLayer)))
try {
return await fn((body) => rt.runPromise(body))
} finally {
await rt.dispose()
}
}
async function write(file: string, value: unknown) {
await fs.mkdir(path.dirname(file), { recursive: true })
await Bun.write(file, JSON.stringify(value, null, 2))
}
async function text(file: string, value: string) {
await fs.mkdir(path.dirname(file), { recursive: true })
await Bun.write(file, value)
}
async function exists(file: string) {
return fs
.stat(file)
.then(() => true)
.catch(() => false)
}
describe("Storage", () => {
it.live("round-trips JSON content", () =>
Effect.gen(function* () {
const { root, svc } = yield* scope()
test("round-trips JSON content", async () => {
await withScope(async (root) => {
const key = [...root, "session_diff", "roundtrip"]
const value = [{ file: "a.ts", additions: 2, deletions: 1 }]
yield* svc.write(key, value)
expect(yield* svc.read<typeof value>(key)).toEqual(value)
}),
)
await Storage.write(key, value)
it.live("maps missing reads to NotFoundError", () =>
Effect.gen(function* () {
const { root, svc } = yield* scope()
const exit = yield* svc.read([...root, "missing", "value"]).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
expect(await Storage.read<typeof value>(key)).toEqual(value)
})
})
it.live("update on missing key throws NotFoundError", () =>
Effect.gen(function* () {
const { root, svc } = yield* scope()
const exit = yield* svc
.update<{ value: number }>([...root, "missing", "key"], (draft) => {
test("maps missing reads to NotFoundError", async () => {
await withScope(async (root) => {
await expect(Storage.read([...root, "missing", "value"])).rejects.toMatchObject({ name: "NotFoundError" })
})
})
test("update on missing key throws NotFoundError", async () => {
await withScope(async (root) => {
await expect(
Storage.update<{ value: number }>([...root, "missing", "key"], (draft) => {
draft.value += 1
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
}),
).rejects.toMatchObject({ name: "NotFoundError" })
})
})
it.live("write overwrites existing value", () =>
Effect.gen(function* () {
const { root, svc } = yield* scope()
test("write overwrites existing value", async () => {
await withScope(async (root) => {
const key = [...root, "overwrite", "test"]
await Storage.write<{ v: number }>(key, { v: 1 })
await Storage.write<{ v: number }>(key, { v: 2 })
yield* svc.write<{ v: number }>(key, { v: 1 })
yield* svc.write<{ v: number }>(key, { v: 2 })
expect(await Storage.read<{ v: number }>(key)).toEqual({ v: 2 })
})
})
expect(yield* svc.read<{ v: number }>(key)).toEqual({ v: 2 })
}),
)
test("remove on missing key is a no-op", async () => {
await withScope(async (root) => {
await expect(Storage.remove([...root, "nonexistent", "key"])).resolves.toBeUndefined()
})
})
it.live("remove on missing key is a no-op", () =>
Effect.gen(function* () {
const { root, svc } = yield* scope()
yield* svc.remove([...root, "nonexistent", "key"])
}),
)
test("list on missing prefix returns empty", async () => {
await withScope(async (root) => {
expect(await Storage.list([...root, "nonexistent"])).toEqual([])
})
})
it.live("list on missing prefix returns empty", () =>
Effect.gen(function* () {
const { root, svc } = yield* scope()
expect(yield* svc.list([...root, "nonexistent"])).toEqual([])
}),
)
it.live("serializes concurrent updates for the same key", () =>
Effect.gen(function* () {
const { root, svc } = yield* scope()
test("serializes concurrent updates for the same key", async () => {
await withScope(async (root) => {
const key = [...root, "counter", "shared"]
await Storage.write(key, { value: 0 })
yield* svc.write(key, { value: 0 })
yield* Effect.all(
await Promise.all(
Array.from({ length: 25 }, () =>
svc.update<{ value: number }>(key, (draft) => {
Storage.update<{ value: number }>(key, (draft) => {
draft.value += 1
}),
),
{ concurrency: "unbounded" },
)
expect(yield* svc.read<{ value: number }>(key)).toEqual({ value: 25 })
}),
)
expect(await Storage.read<{ value: number }>(key)).toEqual({ value: 25 })
})
})
it.live("concurrent reads do not block each other", () =>
Effect.gen(function* () {
const { root, svc } = yield* scope()
test("concurrent reads do not block each other", async () => {
await withScope(async (root) => {
const key = [...root, "concurrent", "reads"]
await Storage.write(key, { ok: true })
yield* svc.write(key, { ok: true })
const results = yield* Effect.all(
Array.from({ length: 10 }, () => svc.read(key)),
{ concurrency: "unbounded" },
)
const results = await Promise.all(Array.from({ length: 10 }, () => Storage.read(key)))
expect(results).toHaveLength(10)
for (const r of results) expect(r).toEqual({ ok: true })
}),
)
})
})
it.live("nested keys create deep paths", () =>
Effect.gen(function* () {
const { root, svc } = yield* scope()
test("nested keys create deep paths", async () => {
await withScope(async (root) => {
const key = [...root, "a", "b", "c", "deep"]
await Storage.write<{ nested: boolean }>(key, { nested: true })
yield* svc.write<{ nested: boolean }>(key, { nested: true })
expect(await Storage.read<{ nested: boolean }>(key)).toEqual({ nested: true })
expect(await Storage.list([...root, "a"])).toEqual([key])
})
})
expect(yield* svc.read<{ nested: boolean }>(key)).toEqual({ nested: true })
expect(yield* svc.list([...root, "a"])).toEqual([key])
}),
)
it.live("lists and removes stored entries", () =>
Effect.gen(function* () {
const { root, svc } = yield* scope()
test("lists and removes stored entries", async () => {
await withScope(async (root) => {
const a = [...root, "list", "a"]
const b = [...root, "list", "b"]
const prefix = [...root, "list"]
yield* svc.write(b, { value: 2 })
yield* svc.write(a, { value: 1 })
await Storage.write(b, { value: 2 })
await Storage.write(a, { value: 1 })
expect(yield* svc.list(prefix)).toEqual([a, b])
expect(await Storage.list(prefix)).toEqual([a, b])
yield* svc.remove(a)
await Storage.remove(a)
expect(yield* svc.list(prefix)).toEqual([b])
const exit = yield* svc.read(a).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
expect(await Storage.list(prefix)).toEqual([b])
await expect(Storage.read(a)).rejects.toMatchObject({ name: "NotFoundError" })
})
})
it.live("migration 2 runs when marker contents are invalid", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* tmpdirScoped()
const storage = path.join(tmp, "storage")
const diffs = [
{ additions: 2, deletions: 1 },
{ additions: 3, deletions: 4 },
]
test("migration 2 runs when marker contents are invalid", async () => {
await using tmp = await tmpdir()
const storage = path.join(tmp.path, "storage")
const diffs = [
{ additions: 2, deletions: 1 },
{ additions: 3, deletions: 4 },
]
yield* fs.writeWithDirs(path.join(storage, "migration"), "wat")
yield* fs.writeWithDirs(
path.join(storage, "session", "proj_test", "ses_test.json"),
JSON.stringify({
id: "ses_test",
projectID: "proj_test",
title: "legacy",
summary: { diffs },
}),
await text(path.join(storage, "migration"), "wat")
await write(path.join(storage, "session", "proj_test", "ses_test.json"), {
id: "ses_test",
projectID: "proj_test",
title: "legacy",
summary: { diffs },
})
await withStorage(tmp.path, async (run) => {
expect(await run(Storage.Service.use((svc) => svc.list(["session_diff"])))).toEqual([
["session_diff", "ses_test"],
])
expect(await run(Storage.Service.use((svc) => svc.read<typeof diffs>(["session_diff", "ses_test"])))).toEqual(
diffs,
)
expect(
await run(
Storage.Service.use((svc) =>
svc.read<{
id: string
projectID: string
title: string
summary: {
additions: number
deletions: number
}
}>(["session", "proj_test", "ses_test"]),
),
),
).toEqual({
id: "ses_test",
projectID: "proj_test",
title: "legacy",
summary: {
additions: 5,
deletions: 5,
},
})
})
yield* Effect.gen(function* () {
const svc = yield* Storage.Service
expect(yield* svc.list(["session_diff"])).toEqual([["session_diff", "ses_test"]])
expect(yield* svc.read<typeof diffs>(["session_diff", "ses_test"])).toEqual(diffs)
expect(
yield* svc.read<{
id: string
projectID: string
title: string
summary: { additions: number; deletions: number }
}>(["session", "proj_test", "ses_test"]),
).toEqual({
id: "ses_test",
projectID: "proj_test",
title: "legacy",
summary: { additions: 5, deletions: 5 },
})
}).pipe(Effect.provide(remappedStorage(tmp)))
expect(await Bun.file(path.join(storage, "migration")).text()).toBe("2")
})
expect(yield* fs.readFileString(path.join(storage, "migration"))).toBe("2")
}),
)
test("migration 1 tolerates malformed legacy records", async () => {
await using tmp = await tmpdir({ git: true })
const storage = path.join(tmp.path, "storage")
const legacy = path.join(tmp.path, "project", "legacy")
it.live("migration 1 tolerates malformed legacy records", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* tmpdirScoped({ git: true })
const storage = path.join(tmp, "storage")
const legacy = path.join(tmp, "project", "legacy")
await write(path.join(legacy, "storage", "session", "message", "probe", "0.json"), [])
await write(path.join(legacy, "storage", "session", "message", "probe", "1.json"), {
path: { root: tmp.path },
})
await write(path.join(legacy, "storage", "session", "info", "ses_legacy.json"), {
id: "ses_legacy",
title: "legacy",
})
await write(path.join(legacy, "storage", "session", "message", "ses_legacy", "msg_legacy.json"), {
role: "user",
text: "hello",
})
yield* fs.writeWithDirs(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "[]")
yield* fs.writeWithDirs(
path.join(legacy, "storage", "session", "message", "probe", "1.json"),
JSON.stringify({ path: { root: tmp } }),
)
yield* fs.writeWithDirs(
path.join(legacy, "storage", "session", "info", "ses_legacy.json"),
JSON.stringify({ id: "ses_legacy", title: "legacy" }),
)
yield* fs.writeWithDirs(
path.join(legacy, "storage", "session", "message", "ses_legacy", "msg_legacy.json"),
JSON.stringify({ role: "user", text: "hello" }),
)
await withStorage(tmp.path, async (run) => {
const projects = await run(Storage.Service.use((svc) => svc.list(["project"])))
expect(projects).toHaveLength(1)
const project = projects[0]![1]
yield* Effect.gen(function* () {
const svc = yield* Storage.Service
const projects = yield* svc.list(["project"])
expect(projects).toHaveLength(1)
const project = projects[0]![1]
expect(await run(Storage.Service.use((svc) => svc.list(["session", project])))).toEqual([
["session", project, "ses_legacy"],
])
expect(
await run(
Storage.Service.use((svc) => svc.read<{ id: string; title: string }>(["session", project, "ses_legacy"])),
),
).toEqual({
id: "ses_legacy",
title: "legacy",
})
expect(
await run(
Storage.Service.use((svc) =>
svc.read<{ role: string; text: string }>(["message", "ses_legacy", "msg_legacy"]),
),
),
).toEqual({
role: "user",
text: "hello",
})
})
expect(yield* svc.list(["session", project])).toEqual([["session", project, "ses_legacy"]])
expect(yield* svc.read<{ id: string; title: string }>(["session", project, "ses_legacy"])).toEqual({
id: "ses_legacy",
title: "legacy",
})
expect(yield* svc.read<{ role: string; text: string }>(["message", "ses_legacy", "msg_legacy"])).toEqual({
role: "user",
text: "hello",
})
}).pipe(Effect.provide(remappedStorage(tmp)))
expect(await Bun.file(path.join(storage, "migration")).text()).toBe("2")
})
expect(yield* fs.readFileString(path.join(storage, "migration"))).toBe("2")
}),
)
test("failed migrations do not advance the marker", async () => {
await using tmp = await tmpdir()
const storage = path.join(tmp.path, "storage")
const legacy = path.join(tmp.path, "project", "legacy")
it.live("failed migrations do not advance the marker", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* tmpdirScoped()
const storage = path.join(tmp, "storage")
const legacy = path.join(tmp, "project", "legacy")
await text(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "{")
yield* fs.writeWithDirs(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "{")
await withStorage(tmp.path, async (run) => {
expect(await run(Storage.Service.use((svc) => svc.list(["project"])))).toEqual([])
})
yield* Effect.gen(function* () {
const svc = yield* Storage.Service
expect(yield* svc.list(["project"])).toEqual([])
}).pipe(Effect.provide(remappedStorage(tmp)))
const exit = yield* fs.access(path.join(storage, "migration")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
expect(await exists(path.join(storage, "migration"))).toBe(false)
})
})
+1 -15
View File
@@ -7,7 +7,6 @@ import { Instance } from "../../src/project/instance"
import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem"
import { FileTime } from "../../src/file/time"
import { Format } from "../../src/format"
import { Tool } from "../../src/tool/tool"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -30,20 +29,7 @@ afterEach(async () => {
})
const it = testEffect(
Layer.mergeAll(
LSP.defaultLayer,
AppFileSystem.defaultLayer,
FileTime.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Layer.succeed(
Format.Service,
Format.Service.of({
init: () => Effect.void,
status: () => Effect.succeed([]),
file: () => Effect.void,
}),
),
),
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer),
)
const init = Effect.fn("WriteToolTest.init")(function* () {
+17 -17
View File
@@ -469,21 +469,6 @@ export type EventTodoUpdated = {
}
}
export type EventWorktreeReady = {
type: "worktree.ready"
properties: {
name: string
branch: string
}
}
export type EventWorktreeFailed = {
type: "worktree.failed"
properties: {
message: string
}
}
export type Pty = {
id: string
title: string
@@ -523,6 +508,21 @@ export type EventPtyDeleted = {
}
}
export type EventWorktreeReady = {
type: "worktree.ready"
properties: {
name: string
branch: string
}
}
export type EventWorktreeFailed = {
type: "worktree.failed"
properties: {
message: string
}
}
export type OutputFormatText = {
type: "text"
}
@@ -1005,12 +1005,12 @@ export type Event =
| EventSessionIdle
| EventSessionCompacted
| EventTodoUpdated
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventWorktreeReady
| EventWorktreeFailed
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
+47 -47
View File
@@ -8351,47 +8351,6 @@
},
"required": ["type", "properties"]
},
"Event.worktree.ready": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "worktree.ready"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"branch": {
"type": "string"
}
},
"required": ["name", "branch"]
}
},
"required": ["type", "properties"]
},
"Event.worktree.failed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "worktree.failed"
},
"properties": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": ["message"]
}
},
"required": ["type", "properties"]
},
"Pty": {
"type": "object",
"properties": {
@@ -8505,6 +8464,47 @@
},
"required": ["type", "properties"]
},
"Event.worktree.ready": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "worktree.ready"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"branch": {
"type": "string"
}
},
"required": ["name", "branch"]
}
},
"required": ["type", "properties"]
},
"Event.worktree.failed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "worktree.failed"
},
"properties": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": ["message"]
}
},
"required": ["type", "properties"]
},
"OutputFormatText": {
"type": "object",
"properties": {
@@ -9967,12 +9967,6 @@
{
"$ref": "#/components/schemas/Event.todo.updated"
},
{
"$ref": "#/components/schemas/Event.worktree.ready"
},
{
"$ref": "#/components/schemas/Event.worktree.failed"
},
{
"$ref": "#/components/schemas/Event.pty.created"
},
@@ -9985,6 +9979,12 @@
{
"$ref": "#/components/schemas/Event.pty.deleted"
},
{
"$ref": "#/components/schemas/Event.worktree.ready"
},
{
"$ref": "#/components/schemas/Event.worktree.failed"
},
{
"$ref": "#/components/schemas/Event.message.updated"
},