diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts new file mode 100644 index 0000000000..f3bfe06689 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts @@ -0,0 +1,20 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { Effect } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import * as Fence from "@/server/shared/fence" + +const ignoredMethods = new Set(["GET", "HEAD", "OPTIONS"]) + +export const fenceLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect + + const previous = Fence.load() + const response = yield* effect + const current = Fence.diff(previous, Fence.load()) + if (Object.keys(current).length === 0) return response + + return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current)) + }), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 783a01ae63..4e07ab21ba 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -82,6 +82,7 @@ import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" import { errorLayer } from "./middleware/error" +import { fenceLayer } from "./middleware/fence" export const context = Context.makeUnsafe(new Map()) @@ -173,6 +174,7 @@ export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe( Layer.provide([ errorLayer, + fenceLayer, cors(corsOptions), runtime, Account.defaultLayer, diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 8adb21e463..930cc6d0aa 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -4,8 +4,11 @@ import { describe, expect } from "bun:test" import { Config, Effect, FileSystem, Layer, Path } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { WorkspaceID } from "../../src/control-plane/schema" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { HEADER as FenceHeader } from "../../src/server/shared/fence" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -65,6 +68,28 @@ describe("instance HttpApi", () => { }), ) + it.live("emits a sync fence header for fixed-workspace mutations", () => + Effect.gen(function* () { + const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID + Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID + }), + ) + + const dir = yield* tmpdirScoped({ git: true }) + const response = yield* HttpClientRequest.post(SessionPaths.create).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ title: "fenced" }), + Effect.flatMap(HttpClient.execute), + ) + + expect(response.status).toBe(200) + expect(JSON.parse(response.headers[FenceHeader] ?? "{}")).not.toEqual({}) + }), + ) + it.live("serves path and VCS read endpoints", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true })