summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authoropencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>2026-04-30 15:46:04 +0000
committeropencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>2026-04-30 15:46:04 +0000
commit8f57a2a46225109fbd1f74e545e22a9378f777b4 (patch)
tree3d95d51f815cffcf61e8a2bc21c844a0f0c7d8d6 /packages
parent53e9cac383859f7bb33f771db3ac6967cf4a98a7 (diff)
downloadopencode-8f57a2a46225109fbd1f74e545e22a9378f777b4.tar.gz
opencode-8f57a2a46225109fbd1f74e545e22a9378f777b4.zip
chore: generate
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/control-plane/dev/README.md7
-rw-r--r--packages/opencode/src/control-plane/workspace.ts11
-rw-r--r--packages/opencode/test/control-plane/workspace.test.ts1106
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts80
-rw-r--r--packages/sdk/openapi.json232
5 files changed, 778 insertions, 658 deletions
diff --git a/packages/opencode/src/control-plane/dev/README.md b/packages/opencode/src/control-plane/dev/README.md
index dbd62c0b1..74d68a75a 100644
--- a/packages/opencode/src/control-plane/dev/README.md
+++ b/packages/opencode/src/control-plane/dev/README.md
@@ -1,4 +1,3 @@
-
This is a plugin to simulate a remote environment locally. Add this to `.opencode/opencode.jsonc`:
```json
@@ -15,6 +14,6 @@ With the plugin install, you can now run OpenCode and create a `debug` workspace
How this works:
-* The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written.
-* The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server.
-* The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist. \ No newline at end of file
+- The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written.
+- The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server.
+- The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist.
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
index 2d8c57044..870bdba50 100644
--- a/packages/opencode/src/control-plane/workspace.ts
+++ b/packages/opencode/src/control-plane/workspace.ts
@@ -101,10 +101,13 @@ export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("Wor
body: Schema.optional(Schema.String),
}) {}
-export class WorkspaceNotFoundError extends Schema.TaggedErrorClass<WorkspaceNotFoundError>()("WorkspaceNotFoundError", {
- message: Schema.String,
- workspaceID: WorkspaceID,
-}) {}
+export class WorkspaceNotFoundError extends Schema.TaggedErrorClass<WorkspaceNotFoundError>()(
+ "WorkspaceNotFoundError",
+ {
+ message: Schema.String,
+ workspaceID: WorkspaceID,
+ },
+) {}
export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionEventsNotFoundError>()(
"WorkspaceSessionEventsNotFoundError",
diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts
index c94d3f9a3..bd5c4df7d 100644
--- a/packages/opencode/test/control-plane/workspace.test.ts
+++ b/packages/opencode/test/control-plane/workspace.test.ts
@@ -170,7 +170,11 @@ function recordedAdaptor(input: {
return input.configure?.(info) ?? info
},
async create(info, env, from) {
- calls.create.push({ info: structuredClone(info), env: { ...env }, from: from ? structuredClone(from) : undefined })
+ calls.create.push({
+ info: structuredClone(info),
+ env: { ...env },
+ from: from ? structuredClone(from) : undefined,
+ })
await input.create?.(info, env, from)
},
async remove(info) {
@@ -279,12 +283,18 @@ function insertProject(id: ProjectID, worktree: string) {
}
function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceID) {
- Database.use((db) => db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run())
+ Database.use((db) =>
+ db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run(),
+ )
}
function sessionSequence(sessionID: SessionID) {
return Database.use((db) =>
- db.select({ seq: EventSequenceTable.seq }).from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).get(),
+ db
+ .select({ seq: EventSequenceTable.seq })
+ .from(EventSequenceTable)
+ .where(eq(EventSequenceTable.aggregate_id, sessionID))
+ .get(),
)?.seq
}
@@ -308,7 +318,9 @@ function replaceSessionEvents(sessionID: SessionID, count: number) {
db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run()
if (count === 0) return
- db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: count - 1 }).run()
+ db.insert(EventSequenceTable)
+ .values({ aggregate_id: sessionID, seq: count - 1 })
+ .run()
db.insert(EventTable)
.values(
Array.from({ length: count }, (_, i) => ({
@@ -522,43 +534,46 @@ describe("workspace-old CRUD", () => {
it.live("remote create connects to routed event and history endpoints", () => {
const calls: FetchCall[] = []
return Effect.gen(function* () {
- yield* HttpServer.serveEffect()(Effect.gen(function* () {
- const req = yield* HttpServerRequest.HttpServerRequest
- const bodyText = yield* req.text
- const call = {
- url: new URL(req.url, "http://localhost"),
- method: req.method,
- headers: new Headers(req.headers),
- bodyText,
- json: bodyText ? JSON.parse(bodyText) : undefined,
- }
- calls.push(call)
- if (call.url.pathname === "/base/global/event") return HttpServerResponse.fromWeb(eventStreamResponse([], false))
- if (call.url.pathname === "/base/sync/history") return yield* HttpServerResponse.json([])
- return HttpServerResponse.text("unexpected", { status: 500 })
- }))
- const url = yield* serverUrl()
- yield* provideTmpdirInstance((dir) =>
+ yield* HttpServer.serveEffect()(
Effect.gen(function* () {
- const workspace = yield* WorkspaceOld.Service
- const type = unique("remote-create")
- const recorded = remoteAdaptor(`${url}/base/?ignored=1#hash`, { directory: dir })
- registerAdaptor(Instance.project.id, type, recorded.adaptor)
-
- const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null })
-
- expect(calls.map((call) => `${call.method} ${call.url.pathname}${call.url.search}${call.url.hash}`)).toEqual([
- "GET /base/global/event",
- "POST /base/sync/history",
- ])
- expect(calls[1].json).toEqual({})
- expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("connected")
- expect(yield* workspace.isSyncing(info.id)).toBe(true)
-
- yield* workspace.remove(info.id)
- expect(yield* workspace.isSyncing(info.id)).toBe(false)
- expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
+ const req = yield* HttpServerRequest.HttpServerRequest
+ const bodyText = yield* req.text
+ const call = {
+ url: new URL(req.url, "http://localhost"),
+ method: req.method,
+ headers: new Headers(req.headers),
+ bodyText,
+ json: bodyText ? JSON.parse(bodyText) : undefined,
+ }
+ calls.push(call)
+ if (call.url.pathname === "/base/global/event")
+ return HttpServerResponse.fromWeb(eventStreamResponse([], false))
+ if (call.url.pathname === "/base/sync/history") return yield* HttpServerResponse.json([])
+ return HttpServerResponse.text("unexpected", { status: 500 })
}),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const type = unique("remote-create")
+ const recorded = remoteAdaptor(`${url}/base/?ignored=1#hash`, { directory: dir })
+ registerAdaptor(Instance.project.id, type, recorded.adaptor)
+
+ const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null })
+
+ expect(
+ calls.map((call) => `${call.method} ${call.url.pathname}${call.url.search}${call.url.hash}`),
+ ).toEqual(["GET /base/global/event", "POST /base/sync/history"])
+ expect(calls[1].json).toEqual({})
+ expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("connected")
+ expect(yield* workspace.isSyncing(info.id)).toBe(true)
+
+ yield* workspace.remove(info.id)
+ expect(yield* workspace.isSyncing(info.id)).toBe(false)
+ expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
+ }),
{ git: true },
)
})
@@ -651,11 +666,16 @@ describe("workspace-old sync state", () => {
insertWorkspace(withoutSession)
registerAdaptor(Instance.project.id, withSessionType, localAdaptor(withSessionDir).adaptor)
registerAdaptor(Instance.project.id, withoutSessionType, localAdaptor(withoutSessionDir).adaptor)
- attachSessionToWorkspace((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, withSession.id)
+ attachSessionToWorkspace(
+ (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
+ withSession.id,
+ )
WorkspaceOld.startWorkspaceSyncing(Instance.project.id)
- await eventually(() => expect(WorkspaceOld.status().find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"))
+ await eventually(() =>
+ expect(WorkspaceOld.status().find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"),
+ )
expect(WorkspaceOld.status().find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined()
await WorkspaceOld.remove(withSession.id)
await WorkspaceOld.remove(withoutSession.id)
@@ -667,12 +687,21 @@ describe("workspace-old sync state", () => {
const type = unique("missing-local")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
- registerAdaptor(Instance.project.id, type, localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor)
- attachSessionToWorkspace((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, info.id)
+ registerAdaptor(
+ Instance.project.id,
+ type,
+ localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor,
+ )
+ attachSessionToWorkspace(
+ (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
+ info.id,
+ )
WorkspaceOld.startWorkspaceSyncing(Instance.project.id)
- await eventually(() => expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("error"))
+ await eventually(() =>
+ expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("error"),
+ )
expect(await WorkspaceOld.isSyncing(info.id)).toBe(false)
await WorkspaceOld.remove(info.id)
})
@@ -688,12 +717,17 @@ describe("workspace-old sync state", () => {
await fs.mkdir(target, { recursive: true })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, localAdaptor(target).adaptor)
- attachSessionToWorkspace((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, info.id)
+ attachSessionToWorkspace(
+ (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
+ info.id,
+ )
WorkspaceOld.startWorkspaceSyncing(Instance.project.id)
WorkspaceOld.startWorkspaceSyncing(Instance.project.id)
- await eventually(() => expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("connected"))
+ await eventually(() =>
+ expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("connected"),
+ )
expect(
captured.events.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
@@ -709,56 +743,65 @@ describe("workspace-old sync state", () => {
it.live("remote start emits disconnected, connecting, and connected then refuses duplicate listeners", () => {
const calls: FetchCall[] = []
return Effect.gen(function* () {
- yield* HttpServer.serveEffect()(Effect.gen(function* () {
- const req = yield* HttpServerRequest.HttpServerRequest
- const bodyText = yield* req.text
- const call = {
- url: new URL(req.url, "http://localhost"),
- method: req.method,
- headers: new Headers(req.headers),
- bodyText,
- json: bodyText ? JSON.parse(bodyText) : undefined,
- }
- calls.push(call)
- if (call.url.pathname === "/sync/global/event") return HttpServerResponse.fromWeb(eventStreamResponse())
- if (call.url.pathname === "/sync/sync/history") return HttpServerResponse.fromWeb(Response.json([]))
- return HttpServerResponse.text("unexpected", { status: 500 })
- }))
- const url = yield* serverUrl()
- yield* provideTmpdirInstance(() =>
+ yield* HttpServer.serveEffect()(
Effect.gen(function* () {
- const workspace = yield* WorkspaceOld.Service
- const sessionSvc = yield* SessionNs.Service
- const captured = captureGlobalEvents()
- try {
- const type = unique("remote-start")
- const info = workspaceInfo(Instance.project.id, type)
- insertWorkspace(info)
- registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor)
- attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
-
- yield* workspace.startWorkspaceSyncing(Instance.project.id)
- yield* eventuallyEffect(Effect.gen(function* () {
- expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("connected")
- }))
- yield* workspace.startWorkspaceSyncing(Instance.project.id)
- yield* Effect.sleep("25 millis")
-
- expect(
- captured.events
- .filter((event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type)
- .map((event) => event.payload.properties.status),
- ).toEqual(["disconnected", "connecting", "connected"])
- expect(calls.filter((call) => call.url.pathname === "/sync/global/event")).toHaveLength(1)
- expect(calls.filter((call) => call.url.pathname === "/sync/sync/history")).toHaveLength(1)
- expect(yield* workspace.isSyncing(info.id)).toBe(true)
-
- yield* workspace.remove(info.id)
- expect(yield* workspace.isSyncing(info.id)).toBe(false)
- } finally {
- captured.dispose()
- }
+ const req = yield* HttpServerRequest.HttpServerRequest
+ const bodyText = yield* req.text
+ const call = {
+ url: new URL(req.url, "http://localhost"),
+ method: req.method,
+ headers: new Headers(req.headers),
+ bodyText,
+ json: bodyText ? JSON.parse(bodyText) : undefined,
+ }
+ calls.push(call)
+ if (call.url.pathname === "/sync/global/event") return HttpServerResponse.fromWeb(eventStreamResponse())
+ if (call.url.pathname === "/sync/sync/history") return HttpServerResponse.fromWeb(Response.json([]))
+ return HttpServerResponse.text("unexpected", { status: 500 })
}),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const sessionSvc = yield* SessionNs.Service
+ const captured = captureGlobalEvents()
+ try {
+ const type = unique("remote-start")
+ const info = workspaceInfo(Instance.project.id, type)
+ insertWorkspace(info)
+ registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor)
+ attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
+
+ yield* workspace.startWorkspaceSyncing(Instance.project.id)
+ yield* eventuallyEffect(
+ Effect.gen(function* () {
+ expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe(
+ "connected",
+ )
+ }),
+ )
+ yield* workspace.startWorkspaceSyncing(Instance.project.id)
+ yield* Effect.sleep("25 millis")
+
+ expect(
+ captured.events
+ .filter(
+ (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
+ )
+ .map((event) => event.payload.properties.status),
+ ).toEqual(["disconnected", "connecting", "connected"])
+ expect(calls.filter((call) => call.url.pathname === "/sync/global/event")).toHaveLength(1)
+ expect(calls.filter((call) => call.url.pathname === "/sync/sync/history")).toHaveLength(1)
+ expect(yield* workspace.isSyncing(info.id)).toBe(true)
+
+ yield* workspace.remove(info.id)
+ expect(yield* workspace.isSyncing(info.id)).toBe(false)
+ } finally {
+ captured.dispose()
+ }
+ }),
{ git: true },
)
})
@@ -766,31 +809,36 @@ describe("workspace-old sync state", () => {
it.live("remote connection HTTP failures set error and clear syncing", () =>
Effect.gen(function* () {
- yield* HttpServer.serveEffect()(Effect.gen(function* () {
- const req = yield* HttpServerRequest.HttpServerRequest
- if (new URL(req.url, "http://localhost").pathname === "/failed/global/event")
- return HttpServerResponse.text("nope", { status: 503 })
- return HttpServerResponse.fromWeb(Response.json([]))
- }))
- const url = yield* serverUrl()
- yield* provideTmpdirInstance(() =>
+ yield* HttpServer.serveEffect()(
Effect.gen(function* () {
- const workspace = yield* WorkspaceOld.Service
- const sessionSvc = yield* SessionNs.Service
- const type = unique("remote-connect-fail")
- const info = workspaceInfo(Instance.project.id, type)
- insertWorkspace(info)
- registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor)
- attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
-
- yield* workspace.startWorkspaceSyncing(Instance.project.id)
-
- yield* eventuallyEffect(Effect.gen(function* () {
- expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error")
- }))
- expect(yield* workspace.isSyncing(info.id)).toBe(false)
- yield* workspace.remove(info.id)
+ const req = yield* HttpServerRequest.HttpServerRequest
+ if (new URL(req.url, "http://localhost").pathname === "/failed/global/event")
+ return HttpServerResponse.text("nope", { status: 503 })
+ return HttpServerResponse.fromWeb(Response.json([]))
}),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const sessionSvc = yield* SessionNs.Service
+ const type = unique("remote-connect-fail")
+ const info = workspaceInfo(Instance.project.id, type)
+ insertWorkspace(info)
+ registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor)
+ attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
+
+ yield* workspace.startWorkspaceSyncing(Instance.project.id)
+
+ yield* eventuallyEffect(
+ Effect.gen(function* () {
+ expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error")
+ }),
+ )
+ expect(yield* workspace.isSyncing(info.id)).toBe(false)
+ yield* workspace.remove(info.id)
+ }),
{ git: true },
)
}),
@@ -798,32 +846,39 @@ describe("workspace-old sync state", () => {
it.live("remote history HTTP failures set error", () =>
Effect.gen(function* () {
- yield* HttpServer.serveEffect()(Effect.gen(function* () {
- const req = yield* HttpServerRequest.HttpServerRequest
- const url = new URL(req.url, "http://localhost")
- if (url.pathname === "/history-failed/global/event") return HttpServerResponse.fromWeb(eventStreamResponse([], false))
- if (url.pathname === "/history-failed/sync/history") return HttpServerResponse.text("history failed", { status: 500 })
- return HttpServerResponse.fromWeb(Response.json([]))
- }))
- const url = yield* serverUrl()
- yield* provideTmpdirInstance(() =>
+ yield* HttpServer.serveEffect()(
Effect.gen(function* () {
- const workspace = yield* WorkspaceOld.Service
- const sessionSvc = yield* SessionNs.Service
- const type = unique("remote-history-fail")
- const info = workspaceInfo(Instance.project.id, type)
- insertWorkspace(info)
- registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history-failed`).adaptor)
- attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
-
- yield* workspace.startWorkspaceSyncing(Instance.project.id)
-
- yield* eventuallyEffect(Effect.gen(function* () {
- expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error")
- }))
- expect(yield* workspace.isSyncing(info.id)).toBe(false)
- yield* workspace.remove(info.id)
+ const req = yield* HttpServerRequest.HttpServerRequest
+ const url = new URL(req.url, "http://localhost")
+ if (url.pathname === "/history-failed/global/event")
+ return HttpServerResponse.fromWeb(eventStreamResponse([], false))
+ if (url.pathname === "/history-failed/sync/history")
+ return HttpServerResponse.text("history failed", { status: 500 })
+ return HttpServerResponse.fromWeb(Response.json([]))
}),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const sessionSvc = yield* SessionNs.Service
+ const type = unique("remote-history-fail")
+ const info = workspaceInfo(Instance.project.id, type)
+ insertWorkspace(info)
+ registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history-failed`).adaptor)
+ attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
+
+ yield* workspace.startWorkspaceSyncing(Instance.project.id)
+
+ yield* eventuallyEffect(
+ Effect.gen(function* () {
+ expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error")
+ }),
+ )
+ expect(yield* workspace.isSyncing(info.id)).toBe(false)
+ yield* workspace.remove(info.id)
+ }),
{ git: true },
)
}),
@@ -834,60 +889,67 @@ describe("workspace-old sync state", () => {
let historySessionID: SessionID | undefined
let historyNextSeq = 0
return Effect.gen(function* () {
- yield* HttpServer.serveEffect()(Effect.gen(function* () {
- const req = yield* HttpServerRequest.HttpServerRequest
- const bodyText = yield* req.text
- const url = new URL(req.url, "http://localhost")
- if (url.pathname === "/history/global/event") return HttpServerResponse.fromWeb(eventStreamResponse())
- if (url.pathname === "/history/sync/history") {
- historyBodies.push(bodyText ? JSON.parse(bodyText) : undefined)
- return HttpServerResponse.fromWeb(
- Response.json([
- {
- id: `evt_${unique("history")}`,
- aggregate_id: historySessionID!,
- seq: historyNextSeq,
- type: sessionUpdatedType(),
- data: { sessionID: historySessionID!, info: { title: "from history" } },
- },
- ]),
- )
- }
- return HttpServerResponse.text("unexpected", { status: 500 })
- }))
- const url = yield* serverUrl()
- yield* provideTmpdirInstance(() =>
+ yield* HttpServer.serveEffect()(
Effect.gen(function* () {
- const workspace = yield* WorkspaceOld.Service
- const sessionSvc = yield* SessionNs.Service
- const captured = captureGlobalEvents()
- try {
- const type = unique("history-replay")
- const info = workspaceInfo(Instance.project.id, type)
- insertWorkspace(info)
- registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor)
- const session = yield* sessionSvc.create({ title: "before history" })
- attachSessionToWorkspace(session.id, info.id)
- historySessionID = session.id
- historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
-
- yield* workspace.startWorkspaceSyncing(Instance.project.id)
-
- yield* eventuallyEffect(Effect.gen(function* () {
- expect((yield* sessionSvc.get(session.id)).title).toBe("from history")
- }))
- expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
- expect(
- captured.events.some(
- (event) =>
- event.workspace === info.id && event.payload.type === "sync" && event.payload.syncEvent.seq === historyNextSeq,
- ),
- ).toBe(true)
- yield* workspace.remove(info.id)
- } finally {
- captured.dispose()
- }
+ const req = yield* HttpServerRequest.HttpServerRequest
+ const bodyText = yield* req.text
+ const url = new URL(req.url, "http://localhost")
+ if (url.pathname === "/history/global/event") return HttpServerResponse.fromWeb(eventStreamResponse())
+ if (url.pathname === "/history/sync/history") {
+ historyBodies.push(bodyText ? JSON.parse(bodyText) : undefined)
+ return HttpServerResponse.fromWeb(
+ Response.json([
+ {
+ id: `evt_${unique("history")}`,
+ aggregate_id: historySessionID!,
+ seq: historyNextSeq,
+ type: sessionUpdatedType(),
+ data: { sessionID: historySessionID!, info: { title: "from history" } },
+ },
+ ]),
+ )
+ }
+ return HttpServerResponse.text("unexpected", { status: 500 })
}),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const sessionSvc = yield* SessionNs.Service
+ const captured = captureGlobalEvents()
+ try {
+ const type = unique("history-replay")
+ const info = workspaceInfo(Instance.project.id, type)
+ insertWorkspace(info)
+ registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor)
+ const session = yield* sessionSvc.create({ title: "before history" })
+ attachSessionToWorkspace(session.id, info.id)
+ historySessionID = session.id
+ historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
+
+ yield* workspace.startWorkspaceSyncing(Instance.project.id)
+
+ yield* eventuallyEffect(
+ Effect.gen(function* () {
+ expect((yield* sessionSvc.get(session.id)).title).toBe("from history")
+ }),
+ )
+ expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
+ expect(
+ captured.events.some(
+ (event) =>
+ event.workspace === info.id &&
+ event.payload.type === "sync" &&
+ event.payload.syncEvent.seq === historyNextSeq,
+ ),
+ ).toBe(true)
+ yield* workspace.remove(info.id)
+ } finally {
+ captured.dispose()
+ }
+ }),
{ git: true },
)
})
@@ -895,56 +957,70 @@ describe("workspace-old sync state", () => {
it.live("SSE forwards non-heartbeat events and ignores heartbeats", () =>
Effect.gen(function* () {
- yield* HttpServer.serveEffect()(Effect.gen(function* () {
- const req = yield* HttpServerRequest.HttpServerRequest
- const url = new URL(req.url, "http://localhost")
- if (url.pathname === "/sse-forward/global/event")
- return HttpServerResponse.fromWeb(
- eventStreamResponse(
- [
- { directory: "remote-dir", project: "remote-project", payload: { type: "server.heartbeat" } },
- {
- directory: "remote-dir",
- project: "remote-project",
- payload: { type: "custom.remote", properties: { ok: true } },
- },
- ],
- false,
- ),
- )
- if (url.pathname === "/sse-forward/sync/history") return HttpServerResponse.fromWeb(Response.json([]))
- return HttpServerResponse.text("unexpected", { status: 500 })
- }))
- const url = yield* serverUrl()
- yield* provideTmpdirInstance(() =>
+ yield* HttpServer.serveEffect()(
Effect.gen(function* () {
- const workspace = yield* WorkspaceOld.Service
- const sessionSvc = yield* SessionNs.Service
- const captured = captureGlobalEvents()
- try {
- const type = unique("sse-forward")
- const info = workspaceInfo(Instance.project.id, type)
- insertWorkspace(info)
- registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor)
- attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
-
- yield* workspace.startWorkspaceSyncing(Instance.project.id)
-
- yield* eventuallyEffect(Effect.sync(() =>
- expect(captured.events.some((event) => event.workspace === info.id && event.payload.type === "custom.remote"))
- .toBe(true),
- ))
- expect(captured.events.some((event) => event.workspace === info.id && event.payload.type === "server.heartbeat")).toBe(
- false,
- )
- expect(
- captured.events.find((event) => event.workspace === info.id && event.payload.type === "custom.remote"),
- ).toMatchObject({ directory: "remote-dir", project: "remote-project", payload: { properties: { ok: true } } })
- yield* workspace.remove(info.id)
- } finally {
- captured.dispose()
- }
+ const req = yield* HttpServerRequest.HttpServerRequest
+ const url = new URL(req.url, "http://localhost")
+ if (url.pathname === "/sse-forward/global/event")
+ return HttpServerResponse.fromWeb(
+ eventStreamResponse(
+ [
+ { directory: "remote-dir", project: "remote-project", payload: { type: "server.heartbeat" } },
+ {
+ directory: "remote-dir",
+ project: "remote-project",
+ payload: { type: "custom.remote", properties: { ok: true } },
+ },
+ ],
+ false,
+ ),
+ )
+ if (url.pathname === "/sse-forward/sync/history") return HttpServerResponse.fromWeb(Response.json([]))
+ return HttpServerResponse.text("unexpected", { status: 500 })
}),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const sessionSvc = yield* SessionNs.Service
+ const captured = captureGlobalEvents()
+ try {
+ const type = unique("sse-forward")
+ const info = workspaceInfo(Instance.project.id, type)
+ insertWorkspace(info)
+ registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor)
+ attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
+
+ yield* workspace.startWorkspaceSyncing(Instance.project.id)
+
+ yield* eventuallyEffect(
+ Effect.sync(() =>
+ expect(
+ captured.events.some(
+ (event) => event.workspace === info.id && event.payload.type === "custom.remote",
+ ),
+ ).toBe(true),
+ ),
+ )
+ expect(
+ captured.events.some(
+ (event) => event.workspace === info.id && event.payload.type === "server.heartbeat",
+ ),
+ ).toBe(false)
+ expect(
+ captured.events.find((event) => event.workspace === info.id && event.payload.type === "custom.remote"),
+ ).toMatchObject({
+ directory: "remote-dir",
+ project: "remote-project",
+ payload: { properties: { ok: true } },
+ })
+ yield* workspace.remove(info.id)
+ } finally {
+ captured.dispose()
+ }
+ }),
{ git: true },
)
}),
@@ -954,65 +1030,73 @@ describe("workspace-old sync state", () => {
let sseSessionID: SessionID | undefined
let sseNextSeq = 0
return Effect.gen(function* () {
- yield* HttpServer.serveEffect()(Effect.gen(function* () {
- const req = yield* HttpServerRequest.HttpServerRequest
- const url = new URL(req.url, "http://localhost")
- if (url.pathname === "/sse-sync/global/event")
- return HttpServerResponse.fromWeb(
- eventStreamResponse(
- [
- {
- directory: "remote-dir",
- project: "remote-project",
- payload: {
- type: "sync",
- syncEvent: {
- id: `evt_${unique("sse")}`,
- aggregateID: sseSessionID!,
- seq: sseNextSeq,
- type: sessionUpdatedType(),
- data: { sessionID: sseSessionID!, info: { title: "from sse" } },
+ yield* HttpServer.serveEffect()(
+ Effect.gen(function* () {
+ const req = yield* HttpServerRequest.HttpServerRequest
+ const url = new URL(req.url, "http://localhost")
+ if (url.pathname === "/sse-sync/global/event")
+ return HttpServerResponse.fromWeb(
+ eventStreamResponse(
+ [
+ {
+ directory: "remote-dir",
+ project: "remote-project",
+ payload: {
+ type: "sync",
+ syncEvent: {
+ id: `evt_${unique("sse")}`,
+ aggregateID: sseSessionID!,
+ seq: sseNextSeq,
+ type: sessionUpdatedType(),
+ data: { sessionID: sseSessionID!, info: { title: "from sse" } },
+ },
},
},
- },
- ],
- false,
- ),
- )
- if (url.pathname === "/sse-sync/sync/history") return HttpServerResponse.fromWeb(Response.json([]))
- return HttpServerResponse.text("unexpected", { status: 500 })
- }))
- const url = yield* serverUrl()
- yield* provideTmpdirInstance(() =>
- Effect.gen(function* () {
- const workspace = yield* WorkspaceOld.Service
- const sessionSvc = yield* SessionNs.Service
- const captured = captureGlobalEvents()
- try {
- const type = unique("sse-sync")
- const info = workspaceInfo(Instance.project.id, type)
- insertWorkspace(info)
- registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor)
- const session = yield* sessionSvc.create({ title: "before sse" })
- attachSessionToWorkspace(session.id, info.id)
- sseSessionID = session.id
- sseNextSeq = (sessionSequence(session.id) ?? -1) + 1
-
- yield* workspace.startWorkspaceSyncing(Instance.project.id)
-
- yield* eventuallyEffect(Effect.gen(function* () {
- expect((yield* sessionSvc.get(session.id)).title).toBe("from sse")
- }))
- expect(
- captured.events.some(
- (event) => event.workspace === info.id && event.payload.type === "sync" && event.payload.syncEvent.seq === sseNextSeq,
- ),
- ).toBe(true)
- yield* workspace.remove(info.id)
- } finally {
- captured.dispose()
- }
+ ],
+ false,
+ ),
+ )
+ if (url.pathname === "/sse-sync/sync/history") return HttpServerResponse.fromWeb(Response.json([]))
+ return HttpServerResponse.text("unexpected", { status: 500 })
}),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const sessionSvc = yield* SessionNs.Service
+ const captured = captureGlobalEvents()
+ try {
+ const type = unique("sse-sync")
+ const info = workspaceInfo(Instance.project.id, type)
+ insertWorkspace(info)
+ registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor)
+ const session = yield* sessionSvc.create({ title: "before sse" })
+ attachSessionToWorkspace(session.id, info.id)
+ sseSessionID = session.id
+ sseNextSeq = (sessionSequence(session.id) ?? -1) + 1
+
+ yield* workspace.startWorkspaceSyncing(Instance.project.id)
+
+ yield* eventuallyEffect(
+ Effect.gen(function* () {
+ expect((yield* sessionSvc.get(session.id)).title).toBe("from sse")
+ }),
+ )
+ expect(
+ captured.events.some(
+ (event) =>
+ event.workspace === info.id &&
+ event.payload.type === "sync" &&
+ event.payload.syncEvent.seq === sseNextSeq,
+ ),
+ ).toBe(true)
+ yield* workspace.remove(info.id)
+ } finally {
+ captured.dispose()
+ }
+ }),
{ git: true },
)
})
@@ -1031,8 +1115,12 @@ describe("workspace-old waitForSync", () => {
const sessionID = SessionID.descending("ses_wait_done")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run())
- await expect(WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 })).resolves.toBeUndefined()
- await expect(WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 })).resolves.toBeUndefined()
+ await expect(
+ WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 }),
+ ).resolves.toBeUndefined()
+ await expect(
+ WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }),
+ ).resolves.toBeUndefined()
})
})
@@ -1084,23 +1172,23 @@ describe("workspace-old waitForSync", () => {
)
abort.abort(reason)
- await expect(waited).rejects.toMatchObject({ _tag: "WorkspaceSyncAbortedError", message: reason.message, cause: reason })
+ await expect(waited).rejects.toMatchObject({
+ _tag: "WorkspaceSyncAbortedError",
+ message: reason.message,
+ cause: reason,
+ })
})
})
- test(
- "times out with the requested fence in the error message",
- async () => {
- await withInstance(async () => {
- const sessionID = SessionID.descending("ses_wait_timeout")
+ test("times out with the requested fence in the error message", async () => {
+ await withInstance(async () => {
+ const sessionID = SessionID.descending("ses_wait_timeout")
- await expect(
- WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }),
- ).rejects.toThrow(`Timed out waiting for sync fence: {"${sessionID}":1}`)
- })
- },
- 7000,
- )
+ await expect(
+ WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }),
+ ).rejects.toThrow(`Timed out waiting for sync fence: {"${sessionID}":1}`)
+ })
+ }, 7000)
})
describe("workspace-old sessionRestore", () => {
@@ -1132,75 +1220,84 @@ describe("workspace-old sessionRestore", () => {
it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
- yield* HttpServer.serveEffect()(Effect.gen(function* () {
- const req = yield* HttpServerRequest.HttpServerRequest
- const bodyText = yield* req.text
- const call = {
- url: new URL(req.url, "http://localhost"),
- method: req.method,
- headers: new Headers(req.headers),
- bodyText,
- json: bodyText ? JSON.parse(bodyText) : undefined,
- }
- if (call.url.pathname === "/restore/sync/replay") {
- replay.push(call)
- return HttpServerResponse.fromWeb(Response.json({ ok: true }))
- }
- return HttpServerResponse.text("unexpected", { status: 500 })
- }))
- const url = yield* serverUrl()
- yield* provideTmpdirInstance((dir) =>
+ yield* HttpServer.serveEffect()(
Effect.gen(function* () {
- const workspace = yield* WorkspaceOld.Service
- const sessionSvc = yield* SessionNs.Service
- const captured = captureGlobalEvents()
- try {
- const type = unique("restore-remote")
- const info = workspaceInfo(Instance.project.id, type, { directory: dir })
- insertWorkspace(info)
- registerAdaptor(
- Instance.project.id,
- type,
- remoteAdaptor(`${url}/restore/?ignored=1#hash`, {
- directory: dir,
- headers: { authorization: "Bearer restore" },
- }).adaptor,
- )
- const session = yield* sessionSvc.create({ title: "restore remote" })
- replaceSessionEvents(session.id, 24)
-
- const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
-
- expect(result).toEqual({ total: 3 })
- expect(replay).toHaveLength(3)
- expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
- "/restore/sync/replay",
- "/restore/sync/replay",
- "/restore/sync/replay",
- ])
- expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
- expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
- expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
- expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
- expect(
- replay.flatMap((call) => (call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq)),
- ).toEqual(Array.from({ length: 25 }, (_, i) => i))
- expect((replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1)).toMatchObject({
- seq: 24,
- type: sessionUpdatedType(),
- data: { sessionID: session.id, info: { workspaceID: info.id } },
- })
- expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
- expect(
- captured.events
- .filter((event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type)
- .map((event) => event.payload.properties.step),
- ).toEqual([0, 1, 2, 3])
- yield* workspace.remove(info.id)
- } finally {
- captured.dispose()
- }
+ const req = yield* HttpServerRequest.HttpServerRequest
+ const bodyText = yield* req.text
+ const call = {
+ url: new URL(req.url, "http://localhost"),
+ method: req.method,
+ headers: new Headers(req.headers),
+ bodyText,
+ json: bodyText ? JSON.parse(bodyText) : undefined,
+ }
+ if (call.url.pathname === "/restore/sync/replay") {
+ replay.push(call)
+ return HttpServerResponse.fromWeb(Response.json({ ok: true }))
+ }
+ return HttpServerResponse.text("unexpected", { status: 500 })
}),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const sessionSvc = yield* SessionNs.Service
+ const captured = captureGlobalEvents()
+ try {
+ const type = unique("restore-remote")
+ const info = workspaceInfo(Instance.project.id, type, { directory: dir })
+ insertWorkspace(info)
+ registerAdaptor(
+ Instance.project.id,
+ type,
+ remoteAdaptor(`${url}/restore/?ignored=1#hash`, {
+ directory: dir,
+ headers: { authorization: "Bearer restore" },
+ }).adaptor,
+ )
+ const session = yield* sessionSvc.create({ title: "restore remote" })
+ replaceSessionEvents(session.id, 24)
+
+ const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
+
+ expect(result).toEqual({ total: 3 })
+ expect(replay).toHaveLength(3)
+ expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
+ "/restore/sync/replay",
+ "/restore/sync/replay",
+ "/restore/sync/replay",
+ ])
+ expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
+ expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
+ expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
+ expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
+ expect(
+ replay.flatMap((call) =>
+ (call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq),
+ ),
+ ).toEqual(Array.from({ length: 25 }, (_, i) => i))
+ expect(
+ (replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1),
+ ).toMatchObject({
+ seq: 24,
+ type: sessionUpdatedType(),
+ data: { sessionID: session.id, info: { workspaceID: info.id } },
+ })
+ expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
+ expect(
+ captured.events
+ .filter(
+ (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
+ )
+ .map((event) => event.payload.properties.step),
+ ).toEqual([0, 1, 2, 3])
+ yield* workspace.remove(info.id)
+ } finally {
+ captured.dispose()
+ }
+ }),
{ git: true },
)
})
@@ -1209,35 +1306,40 @@ describe("workspace-old sessionRestore", () => {
it.live("remote restore sends an empty directory string when the workspace directory is null", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
- yield* HttpServer.serveEffect()(Effect.gen(function* () {
- const req = yield* HttpServerRequest.HttpServerRequest
- const bodyText = yield* req.text
- replay.push({
- url: new URL(req.url, "http://localhost"),
- method: req.method,
- headers: new Headers(req.headers),
- bodyText,
- json: bodyText ? JSON.parse(bodyText) : undefined,
- })
- return HttpServerResponse.fromWeb(Response.json({ ok: true }))
- }))
- const url = yield* serverUrl()
- yield* provideTmpdirInstance(() =>
+ yield* HttpServer.serveEffect()(
Effect.gen(function* () {
- const workspace = yield* WorkspaceOld.Service
- const sessionSvc = yield* SessionNs.Service
- const type = unique("restore-null-dir")
- const info = workspaceInfo(Instance.project.id, type, { directory: null })
- insertWorkspace(info)
- registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor)
- const session = yield* sessionSvc.create({ title: "null dir" })
- replaceSessionEvents(session.id, 0)
-
- expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 1 })
- expect((replay[0].json as { directory: string }).directory).toBe("")
- expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
- yield* workspace.remove(info.id)
+ const req = yield* HttpServerRequest.HttpServerRequest
+ const bodyText = yield* req.text
+ replay.push({
+ url: new URL(req.url, "http://localhost"),
+ method: req.method,
+ headers: new Headers(req.headers),
+ bodyText,
+ json: bodyText ? JSON.parse(bodyText) : undefined,
+ })
+ return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const sessionSvc = yield* SessionNs.Service
+ const type = unique("restore-null-dir")
+ const info = workspaceInfo(Instance.project.id, type, { directory: null })
+ insertWorkspace(info)
+ registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor)
+ const session = yield* sessionSvc.create({ title: "null dir" })
+ replaceSessionEvents(session.id, 0)
+
+ expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
+ total: 1,
+ })
+ expect((replay[0].json as { directory: string }).directory).toBe("")
+ expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
+ yield* workspace.remove(info.id)
+ }),
{ git: true },
)
})
@@ -1246,48 +1348,55 @@ describe("workspace-old sessionRestore", () => {
it.live("remote restore failures include status and body and do not emit completed batch progress", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
- yield* HttpServer.serveEffect()(Effect.gen(function* () {
- const req = yield* HttpServerRequest.HttpServerRequest
- const bodyText = yield* req.text
- replay.push({
- url: new URL(req.url, "http://localhost"),
- method: req.method,
- headers: new Headers(req.headers),
- bodyText,
- json: bodyText ? JSON.parse(bodyText) : undefined,
- })
- return HttpServerResponse.text("replay failed", { status: 503 })
- }))
- const url = yield* serverUrl()
- yield* provideTmpdirInstance((dir) =>
+ yield* HttpServer.serveEffect()(
Effect.gen(function* () {
- const workspace = yield* WorkspaceOld.Service
- const sessionSvc = yield* SessionNs.Service
- const captured = captureGlobalEvents()
- try {
- const type = unique("restore-remote-fail")
- const info = workspaceInfo(Instance.project.id, type, { directory: dir })
- insertWorkspace(info)
- registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor)
- const session = yield* sessionSvc.create({ title: "restore fail" })
- replaceSessionEvents(session.id, 11)
-
- const error = yield* Effect.flip(workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }))
- expect((error as Error).message).toContain(
- `Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
- )
-
- expect(replay).toHaveLength(1)
- expect(
- captured.events
- .filter((event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type)
- .map((event) => event.payload.properties.step),
- ).toEqual([0])
- yield* workspace.remove(info.id)
- } finally {
- captured.dispose()
- }
+ const req = yield* HttpServerRequest.HttpServerRequest
+ const bodyText = yield* req.text
+ replay.push({
+ url: new URL(req.url, "http://localhost"),
+ method: req.method,
+ headers: new Headers(req.headers),
+ bodyText,
+ json: bodyText ? JSON.parse(bodyText) : undefined,
+ })
+ return HttpServerResponse.text("replay failed", { status: 503 })
}),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const sessionSvc = yield* SessionNs.Service
+ const captured = captureGlobalEvents()
+ try {
+ const type = unique("restore-remote-fail")
+ const info = workspaceInfo(Instance.project.id, type, { directory: dir })
+ insertWorkspace(info)
+ registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor)
+ const session = yield* sessionSvc.create({ title: "restore fail" })
+ replaceSessionEvents(session.id, 11)
+
+ const error = yield* Effect.flip(
+ workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }),
+ )
+ expect((error as Error).message).toContain(
+ `Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
+ )
+
+ expect(replay).toHaveLength(1)
+ expect(
+ captured.events
+ .filter(
+ (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
+ )
+ .map((event) => event.payload.properties.step),
+ ).toEqual([0])
+ yield* workspace.remove(info.id)
+ } finally {
+ captured.dispose()
+ }
+ }),
{ git: true },
)
})
@@ -1310,7 +1419,9 @@ describe("workspace-old sessionRestore", () => {
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
- const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({ title: "restore local" })))
+ const session = await AppRuntime.runPromise(
+ SessionNs.Service.use((svc) => svc.create({ title: "restore local" })),
+ )
replaceSessionEvents(session.id, 20)
expect(await WorkspaceOld.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 3 })
@@ -1318,7 +1429,9 @@ describe("workspace-old sessionRestore", () => {
expect(fetchCallCount).toBe(0)
expect(replayAll).toHaveBeenCalledTimes(3)
expect(replayAll.mock.calls.map((call) => call[0].length)).toEqual([10, 10, 1])
- expect((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(session.id)))).workspaceID).toBe(info.id)
+ expect((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(session.id)))).workspaceID).toBe(
+ info.id,
+ )
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
expect(
captured.events
@@ -1335,55 +1448,60 @@ describe("workspace-old sessionRestore", () => {
it.live("session restore includes real message and part events in sequence order", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
- yield* HttpServer.serveEffect()(Effect.gen(function* () {
- const req = yield* HttpServerRequest.HttpServerRequest
- const bodyText = yield* req.text
- replay.push({
- url: new URL(req.url, "http://localhost"),
- method: req.method,
- headers: new Headers(req.headers),
- bodyText,
- json: bodyText ? JSON.parse(bodyText) : undefined,
- })
- return HttpServerResponse.fromWeb(Response.json({ ok: true }))
- }))
- const url = yield* serverUrl()
- yield* provideTmpdirInstance((dir) =>
+ yield* HttpServer.serveEffect()(
Effect.gen(function* () {
- const workspace = yield* WorkspaceOld.Service
- const sessionSvc = yield* SessionNs.Service
- const type = unique("restore-real-events")
- const info = workspaceInfo(Instance.project.id, type, { directory: dir })
- insertWorkspace(info)
- registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor)
- const session = yield* sessionSvc.create({ title: "real events" })
- for (let i = 0; i < 3; i++) {
- const msg = yield* sessionSvc.updateMessage({
- id: MessageID.ascending(),
- role: "user",
- sessionID: session.id,
- agent: "build",
- model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
- time: { created: Date.now() },
+ const req = yield* HttpServerRequest.HttpServerRequest
+ const bodyText = yield* req.text
+ replay.push({
+ url: new URL(req.url, "http://localhost"),
+ method: req.method,
+ headers: new Headers(req.headers),
+ bodyText,
+ json: bodyText ? JSON.parse(bodyText) : undefined,
})
- yield* sessionSvc.updatePart({
- id: PartID.ascending(),
- sessionID: session.id,
- messageID: msg.id,
- type: "text",
- text: `message ${i}`,
- })
- }
- const before = eventRows(session.id)
-
- expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 1 })
-
- const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
- expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
- expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
- expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
- yield* workspace.remove(info.id)
+ return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const sessionSvc = yield* SessionNs.Service
+ const type = unique("restore-real-events")
+ const info = workspaceInfo(Instance.project.id, type, { directory: dir })
+ insertWorkspace(info)
+ registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor)
+ const session = yield* sessionSvc.create({ title: "real events" })
+ for (let i = 0; i < 3; i++) {
+ const msg = yield* sessionSvc.updateMessage({
+ id: MessageID.ascending(),
+ role: "user",
+ sessionID: session.id,
+ agent: "build",
+ model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
+ time: { created: Date.now() },
+ })
+ yield* sessionSvc.updatePart({
+ id: PartID.ascending(),
+ sessionID: session.id,
+ messageID: msg.id,
+ type: "text",
+ text: `message ${i}`,
+ })
+ }
+ const before = eventRows(session.id)
+
+ expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
+ total: 1,
+ })
+
+ const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
+ expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
+ expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
+ expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
+ yield* workspace.remove(info.id)
+ }),
{ git: true },
)
})
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index d98d5c6fe..9bb1e50aa 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -452,6 +452,38 @@ export type EventVcsBranchUpdated = {
}
}
+export type EventWorkspaceReady = {
+ type: "workspace.ready"
+ properties: {
+ name: string
+ }
+}
+
+export type EventWorkspaceFailed = {
+ type: "workspace.failed"
+ properties: {
+ message: string
+ }
+}
+
+export type EventWorkspaceRestore = {
+ type: "workspace.restore"
+ properties: {
+ workspaceID: string
+ sessionID: string
+ total: number
+ step: number
+ }
+}
+
+export type EventWorkspaceStatus = {
+ type: "workspace.status"
+ properties: {
+ workspaceID: string
+ status: "connected" | "connecting" | "disconnected" | "error"
+ }
+}
+
export type EventWorktreeReady = {
type: "worktree.ready"
properties: {
@@ -506,38 +538,6 @@ export type EventPtyDeleted = {
}
}
-export type EventWorkspaceReady = {
- type: "workspace.ready"
- properties: {
- name: string
- }
-}
-
-export type EventWorkspaceFailed = {
- type: "workspace.failed"
- properties: {
- message: string
- }
-}
-
-export type EventWorkspaceRestore = {
- type: "workspace.restore"
- properties: {
- workspaceID: string
- sessionID: string
- total: number
- step: number
- }
-}
-
-export type EventWorkspaceStatus = {
- type: "workspace.status"
- properties: {
- workspaceID: string
- status: "connected" | "connecting" | "disconnected" | "error"
- }
-}
-
export type OutputFormatText = {
type: "text"
}
@@ -1141,16 +1141,16 @@ export type GlobalEvent = {
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventVcsBranchUpdated
+ | EventWorkspaceReady
+ | EventWorkspaceFailed
+ | EventWorkspaceRestore
+ | EventWorkspaceStatus
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
- | EventWorkspaceReady
- | EventWorkspaceFailed
- | EventWorkspaceRestore
- | EventWorkspaceStatus
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
@@ -2084,16 +2084,16 @@ export type Event =
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventVcsBranchUpdated
+ | EventWorkspaceReady
+ | EventWorkspaceFailed
+ | EventWorkspaceRestore
+ | EventWorkspaceStatus
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
- | EventWorkspaceReady
- | EventWorkspaceFailed
- | EventWorkspaceRestore
- | EventWorkspaceStatus
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index bfca971ef..22e66c7d1 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -8743,6 +8743,102 @@
},
"required": ["type", "properties"]
},
+ "Event.workspace.ready": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "workspace.ready"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": ["name"]
+ }
+ },
+ "required": ["type", "properties"]
+ },
+ "Event.workspace.failed": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "workspace.failed"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ },
+ "required": ["message"]
+ }
+ },
+ "required": ["type", "properties"]
+ },
+ "Event.workspace.restore": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "workspace.restore"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "workspaceID": {
+ "type": "string",
+ "pattern": "^wrk.*"
+ },
+ "sessionID": {
+ "type": "string",
+ "pattern": "^ses.*"
+ },
+ "total": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "step": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ }
+ },
+ "required": ["workspaceID", "sessionID", "total", "step"]
+ }
+ },
+ "required": ["type", "properties"]
+ },
+ "Event.workspace.status": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "workspace.status"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "workspaceID": {
+ "type": "string",
+ "pattern": "^wrk.*"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["connected", "connecting", "disconnected", "error"]
+ }
+ },
+ "required": ["workspaceID", "status"]
+ }
+ },
+ "required": ["type", "properties"]
+ },
"Event.worktree.ready": {
"type": "object",
"properties": {
@@ -8901,102 +8997,6 @@
},
"required": ["type", "properties"]
},
- "Event.workspace.ready": {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "workspace.ready"
- },
- "properties": {
- "type": "object",
- "properties": {
- "name": {
- "type": "string"
- }
- },
- "required": ["name"]
- }
- },
- "required": ["type", "properties"]
- },
- "Event.workspace.failed": {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "workspace.failed"
- },
- "properties": {
- "type": "object",
- "properties": {
- "message": {
- "type": "string"
- }
- },
- "required": ["message"]
- }
- },
- "required": ["type", "properties"]
- },
- "Event.workspace.restore": {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "workspace.restore"
- },
- "properties": {
- "type": "object",
- "properties": {
- "workspaceID": {
- "type": "string",
- "pattern": "^wrk.*"
- },
- "sessionID": {
- "type": "string",
- "pattern": "^ses.*"
- },
- "total": {
- "type": "integer",
- "minimum": 0,
- "maximum": 9007199254740991
- },
- "step": {
- "type": "integer",
- "minimum": 0,
- "maximum": 9007199254740991
- }
- },
- "required": ["workspaceID", "sessionID", "total", "step"]
- }
- },
- "required": ["type", "properties"]
- },
- "Event.workspace.status": {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "workspace.status"
- },
- "properties": {
- "type": "object",
- "properties": {
- "workspaceID": {
- "type": "string",
- "pattern": "^wrk.*"
- },
- "status": {
- "type": "string",
- "enum": ["connected", "connecting", "disconnected", "error"]
- }
- },
- "required": ["workspaceID", "status"]
- }
- },
- "required": ["type", "properties"]
- },
"OutputFormatText": {
"type": "object",
"properties": {
@@ -11048,34 +11048,34 @@
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
- "$ref": "#/components/schemas/Event.worktree.ready"
+ "$ref": "#/components/schemas/Event.workspace.ready"
},
{
- "$ref": "#/components/schemas/Event.worktree.failed"
+ "$ref": "#/components/schemas/Event.workspace.failed"
},
{
- "$ref": "#/components/schemas/Event.pty.created"
+ "$ref": "#/components/schemas/Event.workspace.restore"
},
{
- "$ref": "#/components/schemas/Event.pty.updated"
+ "$ref": "#/components/schemas/Event.workspace.status"
},
{
- "$ref": "#/components/schemas/Event.pty.exited"
+ "$ref": "#/components/schemas/Event.worktree.ready"
},
{
- "$ref": "#/components/schemas/Event.pty.deleted"
+ "$ref": "#/components/schemas/Event.worktree.failed"
},
{
- "$ref": "#/components/schemas/Event.workspace.ready"
+ "$ref": "#/components/schemas/Event.pty.created"
},
{
- "$ref": "#/components/schemas/Event.workspace.failed"
+ "$ref": "#/components/schemas/Event.pty.updated"
},
{
- "$ref": "#/components/schemas/Event.workspace.restore"
+ "$ref": "#/components/schemas/Event.pty.exited"
},
{
- "$ref": "#/components/schemas/Event.workspace.status"
+ "$ref": "#/components/schemas/Event.pty.deleted"
},
{
"$ref": "#/components/schemas/Event.message.updated"
@@ -13341,34 +13341,34 @@
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
- "$ref": "#/components/schemas/Event.worktree.ready"
+ "$ref": "#/components/schemas/Event.workspace.ready"
},
{
- "$ref": "#/components/schemas/Event.worktree.failed"
+ "$ref": "#/components/schemas/Event.workspace.failed"
},
{
- "$ref": "#/components/schemas/Event.pty.created"
+ "$ref": "#/components/schemas/Event.workspace.restore"
},
{
- "$ref": "#/components/schemas/Event.pty.updated"
+ "$ref": "#/components/schemas/Event.workspace.status"
},
{
- "$ref": "#/components/schemas/Event.pty.exited"
+ "$ref": "#/components/schemas/Event.worktree.ready"
},
{
- "$ref": "#/components/schemas/Event.pty.deleted"
+ "$ref": "#/components/schemas/Event.worktree.failed"
},
{
- "$ref": "#/components/schemas/Event.workspace.ready"
+ "$ref": "#/components/schemas/Event.pty.created"
},
{
- "$ref": "#/components/schemas/Event.workspace.failed"
+ "$ref": "#/components/schemas/Event.pty.updated"
},
{
- "$ref": "#/components/schemas/Event.workspace.restore"
+ "$ref": "#/components/schemas/Event.pty.exited"
},
{
- "$ref": "#/components/schemas/Event.workspace.status"
+ "$ref": "#/components/schemas/Event.pty.deleted"
},
{
"$ref": "#/components/schemas/Event.message.updated"