Skip to content

Commit e2912d9

Browse files
committed
feat(httpapi): bridge sync routes
1 parent fcc4f22 commit e2912d9

5 files changed

Lines changed: 226 additions & 5 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
183183
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
184184
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
185185
| `session` | `later/special` | large stateful surface plus streaming |
186-
| `sync` | `later` | process/control side effects |
186+
| `sync` | `bridged` | start/replay/history |
187187
| `event` | `special` | SSE |
188188
| `pty` | `special` | websocket |
189189
| `tui` | `special` | UI bridge |
@@ -280,9 +280,9 @@ This checklist tracks bridge parity only. Checked routes are available through t
280280

281281
### Sync Routes
282282

283-
- [ ] `POST /sync/start` - start workspace sync.
284-
- [ ] `POST /sync/replay` - replay sync events.
285-
- [ ] `POST /sync/history` - list sync event history.
283+
- [x] `POST /sync/start` - start workspace sync.
284+
- [x] `POST /sync/replay` - replay sync events.
285+
- [x] `POST /sync/history` - list sync event history.
286286

287287
### Session Routes
288288

@@ -353,7 +353,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
353353
4. [x] Bridge experimental console switch and tool list routes.
354354
5. [x] Bridge experimental global session list.
355355
6. [x] Bridge workspace create/remove/session-restore routes.
356-
7. [ ] Bridge sync start/replay/history routes.
356+
7. [x] Bridge sync start/replay/history routes.
357357
8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
358358
9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
359359
10. [ ] Bridge session share/summary/message/part mutation routes.

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
1818
import { ProjectApi, projectHandlers } from "./project"
1919
import { ProviderApi, providerHandlers } from "./provider"
2020
import { QuestionApi, questionHandlers } from "./question"
21+
import { SyncApi, syncHandlers } from "./sync"
2122
import { WorkspaceApi, workspaceHandlers } from "./workspace"
2223
import { disposeMiddleware } from "./lifecycle"
2324
import { memoMap } from "@opencode-ai/core/effect/memo-map"
@@ -73,6 +74,7 @@ export const routes = Layer.mergeAll(
7374
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
7475
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
7576
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
77+
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
7678
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
7779
).pipe(
7880
Layer.provide(authorizationLayer),
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { startWorkspaceSyncing } from "@/control-plane/workspace"
2+
import * as InstanceState from "@/effect/instance-state"
3+
import { Database, asc, and, eq, lte, not, or } from "@/storage"
4+
import { SyncEvent } from "@/sync"
5+
import { EventTable } from "@/sync/event.sql"
6+
import { Effect, Layer, Schema } from "effect"
7+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
8+
import { Authorization } from "./auth"
9+
10+
const root = "/sync"
11+
const ReplayEvent = Schema.Struct({
12+
id: Schema.String,
13+
aggregateID: Schema.String,
14+
seq: Schema.Number,
15+
type: Schema.String,
16+
data: Schema.Record(Schema.String, Schema.Unknown),
17+
}).annotate({ identifier: "SyncReplayEvent" })
18+
const ReplayPayload = Schema.Struct({
19+
directory: Schema.String,
20+
events: Schema.NonEmptyArray(ReplayEvent),
21+
}).annotate({ identifier: "SyncReplayInput" })
22+
const ReplayResponse = Schema.Struct({
23+
sessionID: Schema.String,
24+
}).annotate({ identifier: "SyncReplayResponse" })
25+
const HistoryPayload = Schema.Record(Schema.String, Schema.Number)
26+
const HistoryEvent = Schema.Struct({
27+
id: Schema.String,
28+
aggregate_id: Schema.String,
29+
seq: Schema.Number,
30+
type: Schema.String,
31+
data: Schema.Record(Schema.String, Schema.Unknown),
32+
}).annotate({ identifier: "SyncHistoryEvent" })
33+
34+
export const SyncPaths = {
35+
start: `${root}/start`,
36+
replay: `${root}/replay`,
37+
history: `${root}/history`,
38+
} as const
39+
40+
export const SyncApi = HttpApi.make("sync")
41+
.add(
42+
HttpApiGroup.make("sync")
43+
.add(
44+
HttpApiEndpoint.post("start", SyncPaths.start, {
45+
success: Schema.Boolean,
46+
}).annotateMerge(
47+
OpenApi.annotations({
48+
identifier: "sync.start",
49+
summary: "Start workspace sync",
50+
description: "Start sync loops for workspaces in the current project that have active sessions.",
51+
}),
52+
),
53+
HttpApiEndpoint.post("replay", SyncPaths.replay, {
54+
payload: ReplayPayload,
55+
success: ReplayResponse,
56+
}).annotateMerge(
57+
OpenApi.annotations({
58+
identifier: "sync.replay",
59+
summary: "Replay sync events",
60+
description: "Validate and replay a complete sync event history.",
61+
}),
62+
),
63+
HttpApiEndpoint.post("history", SyncPaths.history, {
64+
payload: HistoryPayload,
65+
success: Schema.Array(HistoryEvent),
66+
}).annotateMerge(
67+
OpenApi.annotations({
68+
identifier: "sync.history.list",
69+
summary: "List sync events",
70+
description:
71+
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
72+
}),
73+
),
74+
)
75+
.annotateMerge(
76+
OpenApi.annotations({
77+
title: "sync",
78+
description: "Experimental HttpApi sync routes.",
79+
}),
80+
)
81+
.middleware(Authorization),
82+
)
83+
.annotateMerge(
84+
OpenApi.annotations({
85+
title: "opencode experimental HttpApi",
86+
version: "0.0.1",
87+
description: "Experimental HttpApi surface for selected instance routes.",
88+
}),
89+
)
90+
91+
export const syncHandlers = Layer.unwrap(
92+
Effect.gen(function* () {
93+
const start = Effect.fn("SyncHttpApi.start")(function* () {
94+
startWorkspaceSyncing((yield* InstanceState.context).project.id)
95+
return true
96+
})
97+
98+
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
99+
const payload = Schema.decodeUnknownSync(ReplayPayload)(ctx.payload)
100+
const events: SyncEvent.SerializedEvent[] = payload.events.map((event) => ({
101+
id: event.id,
102+
aggregateID: event.aggregateID,
103+
seq: event.seq,
104+
type: event.type,
105+
data: { ...event.data },
106+
}))
107+
SyncEvent.replayAll(events)
108+
return { sessionID: events[0].aggregateID }
109+
})
110+
111+
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
112+
const exclude = Object.entries(ctx.payload)
113+
return Database.use((db) =>
114+
db
115+
.select()
116+
.from(EventTable)
117+
.where(
118+
exclude.length > 0
119+
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
120+
: undefined,
121+
)
122+
.orderBy(asc(EventTable.seq))
123+
.all(),
124+
)
125+
})
126+
127+
return HttpApiBuilder.group(SyncApi, "sync", (handlers) =>
128+
handlers.handle("start", start).handle("replay", replay).handle("history", history),
129+
)
130+
}),
131+
)

packages/opencode/src/server/routes/instance/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ExperimentalPaths } from "./httpapi/experimental"
2020
import { FilePaths } from "./httpapi/file"
2121
import { InstancePaths } from "./httpapi/instance"
2222
import { McpPaths } from "./httpapi/mcp"
23+
import { SyncPaths } from "./httpapi/sync"
2324
import { ProjectRoutes } from "./project"
2425
import { SessionRoutes } from "./session"
2526
import { PtyRoutes } from "./pty"
@@ -89,6 +90,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
8990
app.delete(McpPaths.auth, (c) => handler(c.req.raw, context))
9091
app.post(McpPaths.connect, (c) => handler(c.req.raw, context))
9192
app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context))
93+
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
94+
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
95+
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
9296
}
9397

9498
return app
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import type { UpgradeWebSocket } from "hono/ws"
3+
import { Effect } from "effect"
4+
import { Flag } from "@opencode-ai/core/flag/flag"
5+
import { Instance } from "../../src/project/instance"
6+
import { InstanceRoutes } from "../../src/server/routes/instance"
7+
import { SyncPaths } from "../../src/server/routes/instance/httpapi/sync"
8+
import { Session } from "../../src/session"
9+
import { Log } from "../../src/util"
10+
import { resetDatabase } from "../fixture/db"
11+
import { tmpdir } from "../fixture/fixture"
12+
13+
void Log.init({ print: false })
14+
15+
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
16+
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
17+
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
18+
19+
function app() {
20+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
21+
return InstanceRoutes(websocket)
22+
}
23+
24+
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
25+
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
26+
}
27+
28+
afterEach(async () => {
29+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
30+
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
31+
await Instance.disposeAll()
32+
await resetDatabase()
33+
})
34+
35+
describe("sync HttpApi", () => {
36+
test("serves sync routes through Hono bridge", async () => {
37+
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
38+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
39+
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
40+
41+
const session = await Instance.provide({
42+
directory: tmp.path,
43+
fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))),
44+
})
45+
46+
const started = await app().request(SyncPaths.start, { method: "POST", headers })
47+
expect(started.status).toBe(200)
48+
expect(await started.json()).toBe(true)
49+
50+
const history = await app().request(SyncPaths.history, {
51+
method: "POST",
52+
headers,
53+
body: JSON.stringify({}),
54+
})
55+
expect(history.status).toBe(200)
56+
const rows = (await history.json()) as Array<{
57+
id: string
58+
aggregate_id: string
59+
seq: number
60+
type: string
61+
data: Record<string, unknown>
62+
}>
63+
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
64+
65+
const replayed = await app().request(SyncPaths.replay, {
66+
method: "POST",
67+
headers,
68+
body: JSON.stringify({
69+
directory: tmp.path,
70+
events: rows
71+
.filter((row) => row.aggregate_id === session.id)
72+
.map((row) => ({
73+
id: row.id,
74+
aggregateID: row.aggregate_id,
75+
seq: row.seq,
76+
type: row.type,
77+
data: row.data,
78+
})),
79+
}),
80+
})
81+
expect(replayed.status).toBe(200)
82+
expect(await replayed.json()).toEqual({ sessionID: session.id })
83+
})
84+
})

0 commit comments

Comments
 (0)