Preserve workspace context in session HTTP routes (#25136)

This commit is contained in:
Kit Langton 2026-04-30 13:53:26 -04:00 committed by GitHub
commit feeebbe7d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 154 additions and 248 deletions

View file

@ -1,5 +1,5 @@
import * as InstanceState from "@/effect/instance-state" import * as InstanceState from "@/effect/instance-state"
import { AppRuntime } from "@/effect/app-runtime" import { EffectBridge } from "@/effect/bridge"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Command } from "@/command" import { Command } from "@/command"
@ -53,6 +53,13 @@ const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
const shareSvc = yield* SessionShare.Service
const promptSvc = yield* SessionPrompt.Service
const revertSvc = yield* SessionRevert.Service
const compactSvc = yield* SessionCompaction.Service
const runState = yield* SessionRunState.Service
const agentSvc = yield* Agent.Service
const permissionSvc = yield* Permission.Service
const statusSvc = yield* SessionStatus.Service const statusSvc = yield* SessionStatus.Service
const todoSvc = yield* Todo.Service const todoSvc = yield* Todo.Service
const summary = yield* SessionSummary.Service const summary = yield* SessionSummary.Service
@ -148,14 +155,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
}) })
const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload?: Session.CreateInput }) { const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload?: Session.CreateInput }) {
const instance = yield* InstanceState.context return yield* shareSvc.create(ctx.payload)
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
}) })
const createRaw = Effect.fn("SessionHttpApi.createRaw")(function* (ctx: { const createRaw = Effect.fn("SessionHttpApi.createRaw")(function* (ctx: {
@ -175,14 +175,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
}) })
const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) { const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context yield* session.remove(ctx.params.sessionID)
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
return true return true
}) })
@ -190,60 +183,31 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof UpdatePayload.Type payload: typeof UpdatePayload.Type
}) { }) {
const instance = yield* InstanceState.context const current = yield* session.get(ctx.params.sessionID)
return yield* Effect.promise(() => if (ctx.payload.title !== undefined) {
Instance.restore(instance, () => yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
AppRuntime.runPromise( }
Session.Service.use((svc) => if (ctx.payload.permission !== undefined) {
Effect.gen(function* () { yield* session.setPermission({
const current = yield* svc.get(ctx.params.sessionID) sessionID: ctx.params.sessionID,
if (ctx.payload.title !== undefined) { permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) })
} }
if (ctx.payload.permission !== undefined) { if (ctx.payload.time?.archived !== undefined) {
yield* svc.setPermission({ yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
sessionID: ctx.params.sessionID, }
permission: Permission.merge(current.permission ?? [], ctx.payload.permission), return yield* session.get(ctx.params.sessionID)
})
}
if (ctx.payload.time?.archived !== undefined) {
yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
}
return yield* svc.get(ctx.params.sessionID)
}),
).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
}) })
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: { const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof ForkPayload.Type payload: typeof ForkPayload.Type
}) { }) {
const instance = yield* InstanceState.context return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }),
).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
}) })
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) { const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context yield* promptSvc.cancel(ctx.params.sessionID)
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe(
Effect.provide(SessionPrompt.defaultLayer),
),
),
),
)
return true return true
}) })
@ -251,98 +215,45 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof InitPayload.Type payload: typeof InitPayload.Type
}) { }) {
const instance = yield* InstanceState.context yield* promptSvc.command({
yield* Effect.promise(() => sessionID: ctx.params.sessionID,
Instance.restore(instance, () => messageID: ctx.payload.messageID,
AppRuntime.runPromise( model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
SessionPrompt.Service.use((svc) => command: Command.Default.INIT,
svc.command({ arguments: "",
sessionID: ctx.params.sessionID, })
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
}),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
return true return true
}) })
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) { const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* Effect.promise(() => return yield* session.get(ctx.params.sessionID)
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(ctx.params.sessionID)
return yield* session.get(ctx.params.sessionID)
}).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
}) })
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) { const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* Effect.promise(() => return yield* session.get(ctx.params.sessionID)
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(ctx.params.sessionID)
return yield* session.get(ctx.params.sessionID)
}).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
}) })
const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: { const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof SummarizePayload.Type payload: typeof SummarizePayload.Type
}) { }) {
const instance = yield* InstanceState.context yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
yield* Effect.promise(() => const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
Instance.restore(instance, () => const defaultAgent = yield* agentSvc.defaultAgent()
AppRuntime.runPromise( const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const compact = yield* SessionCompaction.Service
const prompt = yield* SessionPrompt.Service
const agent = yield* Agent.Service
yield* revert.cleanup(yield* session.get(ctx.params.sessionID)) yield* compactSvc.create({
const messages = yield* session.messages({ sessionID: ctx.params.sessionID }) sessionID: ctx.params.sessionID,
const defaultAgent = yield* agent.defaultAgent() agent: currentAgent,
const currentAgent = model: {
messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
yield* compact.create({ },
sessionID: ctx.params.sessionID, auto: ctx.payload.auto ?? false,
agent: currentAgent, })
model: { yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
},
auto: ctx.payload.auto ?? false,
})
yield* prompt.loop({ sessionID: ctx.params.sessionID })
}).pipe(
Effect.provide(SessionRevert.defaultLayer),
Effect.provide(SessionCompaction.defaultLayer),
Effect.provide(SessionPrompt.defaultLayer),
Effect.provide(Agent.defaultLayer),
Effect.provide(Session.defaultLayer),
),
),
),
)
return true return true
}) })
@ -350,19 +261,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof PromptPayload.Type payload: typeof PromptPayload.Type
}) { }) {
const instance = yield* InstanceState.context const bridge = yield* EffectBridge.make()
return HttpServerResponse.stream( return HttpServerResponse.stream(
Stream.fromEffect( Stream.fromEffect(
Effect.promise(() => Effect.promise(() =>
Instance.restore(instance, () => bridge.promise(
AppRuntime.runPromise( promptSvc.prompt({
SessionPrompt.Service.use((svc) => ...ctx.payload,
svc.prompt({ sessionID: ctx.params.sessionID,
...ctx.payload, } as unknown as SessionPrompt.PromptInput),
sessionID: ctx.params.sessionID,
} as unknown as SessionPrompt.PromptInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
), ),
), ),
).pipe( ).pipe(
@ -377,23 +284,23 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof PromptPayload.Type payload: typeof PromptPayload.Type
}) { }) {
const instance = yield* InstanceState.context const bridge = yield* EffectBridge.make()
yield* Effect.sync(() => { yield* Effect.sync(() => {
Instance.restore(instance, () => { bridge.fork(
void AppRuntime.runPromise( promptSvc
SessionPrompt.Service.use((svc) => .prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput)
svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput), .pipe(
).pipe(Effect.provide(SessionPrompt.defaultLayer)), Effect.catchCause((error) =>
).catch((error) => { Effect.sync(() => {
log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error }) log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error })
void Bus.publish(Session.Event.Error, { void Bus.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID, sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({ error: new NamedError.Unknown({ message: String(error) }).toObject(),
message: error instanceof Error ? error.message : String(error), })
}).toObject(), }),
}) ),
}) ),
}) )
}) })
return HttpApiSchema.NoContent.make() return HttpApiSchema.NoContent.make()
}) })
@ -402,111 +309,47 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof CommandPayload.Type payload: typeof CommandPayload.Type
}) { }) {
const instance = yield* InstanceState.context return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput)
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
}) })
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof ShellPayload.Type payload: typeof ShellPayload.Type
}) { }) {
const instance = yield* InstanceState.context return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput)
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
}) })
const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: { const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: {
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof RevertPayload.Type payload: typeof RevertPayload.Type
}) { }) {
const instance = yield* InstanceState.context return yield* revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })
log.info("revert", ctx.payload)
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionRevert.Service.use((svc) => svc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })).pipe(
Effect.provide(SessionRevert.defaultLayer),
),
),
),
)
}) })
const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) { const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context return yield* revertSvc.unrevert({ sessionID: ctx.params.sessionID })
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionRevert.Service.use((svc) => svc.unrevert({ sessionID: ctx.params.sessionID })).pipe(
Effect.provide(SessionRevert.defaultLayer),
),
),
),
)
}) })
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: { const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
params: { permissionID: PermissionID } params: { permissionID: PermissionID }
payload: typeof PermissionResponsePayload.Type payload: typeof PermissionResponsePayload.Type
}) { }) {
const instance = yield* InstanceState.context yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response })
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }),
).pipe(Effect.provide(Permission.defaultLayer)),
),
),
)
return true return true
}) })
const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: { const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID } params: { sessionID: SessionID; messageID: MessageID }
}) { }) {
const instance = yield* InstanceState.context yield* runState.assertNotBusy(ctx.params.sessionID)
yield* Effect.promise(() => yield* session.removeMessage(ctx.params)
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(ctx.params.sessionID)
yield* session.removeMessage(ctx.params)
}).pipe(Effect.provide(SessionRunState.defaultLayer), Effect.provide(Session.defaultLayer)),
),
),
)
return true return true
}) })
const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: { const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID; partID: PartID } params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
}) { }) {
const instance = yield* InstanceState.context yield* session.removePart(ctx.params)
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.removePart(ctx.params)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
return true return true
}) })
@ -524,14 +367,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
`Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`, `Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`,
) )
} }
const instance = yield* InstanceState.context return yield* session.updatePart(payload)
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.updatePart(payload)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
}) })
return handlers return handlers

View file

@ -22,10 +22,14 @@ import { Provider } from "@/provider/provider"
import { Pty } from "@/pty" import { Pty } from "@/pty"
import { Question } from "@/question" import { Question } from "@/question"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state" import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status" import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary" import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo" import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { Skill } from "@/skill" import { Skill } from "@/skill"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
@ -134,6 +138,10 @@ export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
Question.defaultLayer, Question.defaultLayer,
Ripgrep.defaultLayer, Ripgrep.defaultLayer,
Session.defaultLayer, Session.defaultLayer,
SessionCompaction.defaultLayer,
SessionPrompt.defaultLayer,
SessionRevert.defaultLayer,
SessionShare.defaultLayer,
SessionRunState.defaultLayer, SessionRunState.defaultLayer,
SessionStatus.defaultLayer, SessionStatus.defaultLayer,
SessionSummary.defaultLayer, SessionSummary.defaultLayer,

View file

@ -589,7 +589,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
path: sessionPath(ctx.worktree, ctx.directory), path: sessionPath(ctx.worktree, ctx.directory),
title: input?.title, title: input?.title,
permission: input?.permission, permission: input?.permission,
workspaceID: workspace, workspaceID: input?.workspaceID ?? workspace,
}) })
}) })

View file

@ -3,9 +3,13 @@ import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { Effect } from "effect" import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema" import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session" import { Session } from "@/session/session"
@ -22,6 +26,7 @@ import { it } from "../lib/effect"
void Log.init({ print: false }) void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
function app(experimental = true) { function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
@ -77,6 +82,28 @@ function createTextMessage(directory: string, sessionID: SessionID, text: string
) )
} }
const localAdaptor = (directory: string): WorkspaceAdaptor => ({
name: "Local Test",
description: "Create a local test workspace",
configure: (info) => ({ ...info, name: "local-test", directory }),
create: async () => {
await mkdir(directory, { recursive: true })
},
async remove() {},
target: () => ({ type: "local" as const, directory }),
})
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
Effect.promise(async () => {
registerAdaptor(input.projectID, input.type, localAdaptor(input.directory))
return Workspace.create({
type: input.type,
branch: null,
extra: null,
projectID: input.projectID,
})
})
function request(path: string, init?: RequestInit) { function request(path: string, init?: RequestInit) {
return Effect.promise(async () => app().request(path, init)) return Effect.promise(async () => app().request(path, init))
} }
@ -108,6 +135,7 @@ function withTmp<A, E, R>(
afterEach(async () => { afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await Instance.disposeAll() await Instance.disposeAll()
await resetDatabase() await resetDatabase()
}) })
@ -226,6 +254,40 @@ describe("session HttpApi", () => {
), ),
) )
it.live(
"persists selected workspace id when creating a session",
withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const project = yield* Project.use.fromDirectory(tmp.path).pipe(Effect.provide(Project.defaultLayer))
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "session-create-workspace",
directory: path.join(tmp.path, ".workspace-local"),
})
const created = yield* requestJson<Session.Info>(`${SessionPaths.create}?workspace=${workspace.id}`, {
method: "POST",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ title: "workspace session" }),
})
expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id })
expect(
yield* Effect.sync(() =>
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, created.id))
.get(),
),
),
).toEqual({ workspaceID: workspace.id })
}),
),
)
it.live( it.live(
"matches legacy archived timestamp validation", "matches legacy archived timestamp validation",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>