Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/specs/effect-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ Decision table for the design:
- `WriteTool` / `EditTool` / `LspTool` — checklist corrected 2026-06-15. Tool bodies already used named `Effect.fn(...execute)` boundaries and the shared `testEffect(...).live` harness; this follow-up verified the existing write/edit/lsp coverage and closed the stale checklist without code or test changes.
- `ShellTool` / public `bash` tool — migrated 2026-06-15. The current tree exposes this tool from `tool/shell.ts` with public tool id `bash`; there is no standalone `bash.ts` file. The tool body already used named `Effect.fn(...)` boundaries, and this follow-up moved `shell.test.ts` off its local `ManagedRuntime` / `runtime.runPromise(...)` helper and onto an explicit `Effect.provide(testLayer)` runner that initializes and executes the tool inside the same Effect scope while preserving the shell behavior matrix.
- `SkillTool` — migrated 2026-06-15. The tool body already used the named `Effect.fn("SkillTool.execute")` boundary; this follow-up moved the remaining `skill.test.ts` execute coverage off its local `ManagedRuntime` / `runtime.runPromise(...)` helper and onto an inline `Effect.scoped` + `Effect.provide(testLayer)` boundary, without changing skill discovery or ToolRegistry behavior.
- Light instance route handlers — migrated 2026-06-15. The `server/instance/permission.ts` e2e ask and list/prune handlers, `server/instance/session.ts` status and todo handlers, `server/instance/index.ts` raw/apply VCS handlers, and `server/instance/global.ts` upgrade handler now run their bodies through one `AppRuntime.runPromise(Effect.gen(...))` service injection path while preserving fire-and-forget logging, dangling-session pruning, VCS error mappings, and upgrade result handling. This does not claim full session, global, or heavy route migration.

## Route handler effectification

Expand Down
42 changes: 21 additions & 21 deletions packages/opencode/src/server/instance/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,28 +489,28 @@ export function createGlobalRoutes(options: GlobalRoutesOptions = {}) {
}),
),
async (c) => {
const json = c.req.valid("json")
const result = await AppRuntime.runPromise(
Installation.Service.use((svc) =>
Effect.gen(function* () {
const method = yield* svc.method()
if (method === "unknown") {
return { success: false as const, status: 400 as const, error: "Unknown installation method" }
}

const target = c.req.valid("json").target || (yield* svc.latest(method))
const result = yield* Effect.catch(
svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })),
(err) =>
Effect.succeed({
success: false as const,
status: 500 as const,
error: err instanceof Error ? err.message : String(err),
}),
)
if (!result.success) return result
return { ...result, status: 200 as const }
}),
),
Effect.gen(function* () {
const installation = yield* Installation.Service
const method = yield* installation.method()
if (method === "unknown") {
return { success: false as const, status: 400 as const, error: "Unknown installation method" }
}

const target = json.target || (yield* installation.latest(method))
const result = yield* Effect.catch(
installation.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })),
(err) =>
Effect.succeed({
success: false as const,
status: 500 as const,
error: err instanceof Error ? err.message : String(err),
}),
)
if (!result.success) return result
return { ...result, status: 200 as const }
}),
)
if (!result.success) {
return c.json({ success: false, error: result.error }, result.status)
Expand Down
17 changes: 15 additions & 2 deletions packages/opencode/src/server/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,13 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
async (c) => {
try {
c.header("content-type", "text/plain; charset=UTF-8")
return c.text(await Vcs.diffRaw())
const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diffRaw()
}),
)
return c.text(diff)
} catch (error) {
if (error instanceof Vcs.RawDiffError) {
const body = {
Expand Down Expand Up @@ -334,7 +340,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}),
async (c) => {
try {
return c.json(await Vcs.apply(c.req.valid("json")))
const input = c.req.valid("json")
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.apply(input)
}),
)
return c.json(result)
} catch (error) {
if (error instanceof Vcs.PatchApplyError) {
const body =
Expand Down
22 changes: 13 additions & 9 deletions packages/opencode/src/server/instance/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,17 @@ export const PermissionRoutes = lazy(() =>

const json = c.req.valid("json")
void AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.ask({
Effect.gen(function* () {
const permission = yield* Permission.Service
yield* permission.ask({
sessionID: json.sessionID,
permission: json.permission,
patterns: json.patterns,
metadata: json.metadata ?? {},
always: json.always ?? json.patterns,
ruleset: [{ permission: json.permission, pattern: "*", action: "ask" }],
}),
),
})
}),
).catch((error) => {
log.error("e2e permission seed failed", { sessionID: json.sessionID, error })
})
Expand Down Expand Up @@ -111,15 +112,18 @@ export const PermissionRoutes = lazy(() =>
}),
async (c) => {
const permissions = await AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc
Effect.gen(function* () {
const permission = yield* Permission.Service
return yield* permission
.list()
.pipe(
Effect.flatMap((items) =>
SessionLiveness.pruneDangling(items, (sessionID) => svc.clearSession(sessionID, "dangling_session")),
SessionLiveness.pruneDangling(items, (sessionID) =>
permission.clearSession(sessionID, "dangling_session"),
),
),
),
),
)
}),
)
return c.json(permissions)
},
Expand Down
23 changes: 17 additions & 6 deletions packages/opencode/src/server/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,12 @@ export const SessionRoutes = lazy(() =>
},
}),
async (c) => {
const result = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list()))
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const status = yield* SessionStatus.Service
return yield* status.list()
}),
)
return c.json(Object.fromEntries(result))
},
)
Expand All @@ -169,12 +174,13 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const json = c.req.valid("json")
await AppRuntime.runPromise(
Todo.Service.use((svc) =>
svc.update({
Effect.gen(function* () {
const todo = yield* Todo.Service
yield* todo.update({
sessionID: json.sessionID,
todos: json.todos,
}),
),
})
}),
)
return c.body(null, 204)
},
Expand Down Expand Up @@ -277,7 +283,12 @@ 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 AppRuntime.runPromise(
Effect.gen(function* () {
const todo = yield* Todo.Service
return yield* todo.get(sessionID)
}),
)
return c.json(todos)
},
)
Expand Down
42 changes: 23 additions & 19 deletions packages/opencode/test/server/vcs-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,30 +439,34 @@ describe("VCS routes", () => {
})

test("accepts escaped JSON bodies when decoded apply patches are within the byte limit", async () => {
await using tmp = await tmpdir()
const patch = "\n".repeat(5_000_001)
await using tmp = await tmpdir({ git: true })
const escapedLine = "\\".repeat(5_000_001)
const patch = [
"diff --git a/escaped.txt b/escaped.txt",
"new file mode 100644",
"--- /dev/null",
"+++ b/escaped.txt",
"@@ -0,0 +1 @@",
`+${escapedLine}`,
"",
].join("\n")
expect(Buffer.byteLength(patch)).toBeLessThanOrEqual(Vcs.MAX_APPLY_PATCH_BYTES)
const body = JSON.stringify({ patch })
expect(Buffer.byteLength(body)).toBeGreaterThan(Vcs.MAX_APPLY_PATCH_BYTES + Buffer.byteLength(JSON.stringify({ patch: "" })))

const apply = spyOn(Vcs, "apply").mockResolvedValue({ applied: true })
try {
const response = await Server.Default().app.request("/vcs/apply", {
method: "POST",
headers: {
"content-length": String(Buffer.byteLength(body)),
"content-type": "application/json",
"x-opencode-directory": tmp.path,
},
body,
})
const response = await Server.Default().app.request("/vcs/apply", {
method: "POST",
headers: {
"content-length": String(Buffer.byteLength(body)),
"content-type": "application/json",
"x-opencode-directory": tmp.path,
},
body,
})

expect(response.status).toBe(200)
expect(apply).toHaveBeenCalledWith({ patch })
expect(await response.json()).toEqual({ applied: true })
} finally {
apply.mockRestore()
}
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ applied: true })
expect(await fs.readFile(path.join(tmp.path, "escaped.txt"), "utf-8")).toBe(`${escapedLine}\n`)
})

test("applies a patch and reports apply failures", async () => {
Expand Down
Loading