chore: generate

This commit is contained in:
opencode-agent[bot]
2026-04-30 15:46:04 +00:00
parent 53e9cac383
commit 8f57a2a462
5 changed files with 782 additions and 662 deletions
@@ -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.
- 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.
@@ -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", {
export class WorkspaceNotFoundError extends Schema.TaggedErrorClass<WorkspaceNotFoundError>()(
"WorkspaceNotFoundError",
{
message: Schema.String,
workspaceID: WorkspaceID,
}) {}
},
) {}
export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionEventsNotFoundError>()(
"WorkspaceSessionEventsNotFoundError",
@@ -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,7 +534,8 @@ 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* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
@@ -533,12 +546,15 @@ describe("workspace-old CRUD", () => {
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/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* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const type = unique("remote-create")
@@ -547,10 +563,9 @@ describe("workspace-old CRUD", () => {
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.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)
@@ -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,7 +743,8 @@ 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* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
@@ -723,9 +758,11 @@ describe("workspace-old sync state", () => {
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* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
@@ -738,15 +775,21 @@ describe("workspace-old sync state", () => {
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* 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)
.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)
@@ -766,14 +809,17 @@ 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* () {
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* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
@@ -785,9 +831,11 @@ describe("workspace-old sync state", () => {
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(Effect.gen(function* () {
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)
}),
@@ -798,15 +846,20 @@ describe("workspace-old sync state", () => {
it.live("remote history HTTP failures set error", () =>
Effect.gen(function* () {
yield* HttpServer.serveEffect()(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 })
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* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
@@ -818,9 +871,11 @@ describe("workspace-old sync state", () => {
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(Effect.gen(function* () {
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)
}),
@@ -834,7 +889,8 @@ describe("workspace-old sync state", () => {
let historySessionID: SessionID | undefined
let historyNextSeq = 0
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(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")
@@ -854,9 +910,11 @@ describe("workspace-old sync state", () => {
)
}
return HttpServerResponse.text("unexpected", { status: 500 })
}))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(() =>
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
@@ -873,14 +931,18 @@ describe("workspace-old sync state", () => {
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(Effect.gen(function* () {
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,
event.workspace === info.id &&
event.payload.type === "sync" &&
event.payload.syncEvent.seq === historyNextSeq,
),
).toBe(true)
yield* workspace.remove(info.id)
@@ -895,7 +957,8 @@ describe("workspace-old sync state", () => {
it.live("SSE forwards non-heartbeat events and ignores heartbeats", () =>
Effect.gen(function* () {
yield* HttpServer.serveEffect()(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")
@@ -914,9 +977,11 @@ describe("workspace-old sync state", () => {
)
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* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
@@ -930,16 +995,27 @@ describe("workspace-old sync state", () => {
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,
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 } } })
).toMatchObject({
directory: "remote-dir",
project: "remote-project",
payload: { properties: { ok: true } },
})
yield* workspace.remove(info.id)
} finally {
captured.dispose()
@@ -954,7 +1030,8 @@ describe("workspace-old sync state", () => {
let sseSessionID: SessionID | undefined
let sseNextSeq = 0
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(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")
@@ -981,9 +1058,11 @@ describe("workspace-old sync state", () => {
)
if (url.pathname === "/sse-sync/sync/history") return HttpServerResponse.fromWeb(Response.json([]))
return HttpServerResponse.text("unexpected", { status: 500 })
}))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(() =>
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
@@ -1000,12 +1079,17 @@ describe("workspace-old sync state", () => {
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(Effect.gen(function* () {
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,
(event) =>
event.workspace === info.id &&
event.payload.type === "sync" &&
event.payload.syncEvent.seq === sseNextSeq,
),
).toBe(true)
yield* workspace.remove(info.id)
@@ -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,13 +1172,15 @@ 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 () => {
test("times out with the requested fence in the error message", async () => {
await withInstance(async () => {
const sessionID = SessionID.descending("ses_wait_timeout")
@@ -1098,9 +1188,7 @@ describe("workspace-old waitForSync", () => {
WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }),
).rejects.toThrow(`Timed out waiting for sync fence: {"${sessionID}":1}`)
})
},
7000,
)
}, 7000)
})
describe("workspace-old sessionRestore", () => {
@@ -1132,7 +1220,8 @@ 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* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
@@ -1147,9 +1236,11 @@ describe("workspace-old sessionRestore", () => {
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}
return HttpServerResponse.text("unexpected", { status: 500 })
}))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance((dir) =>
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
@@ -1183,9 +1274,13 @@ describe("workspace-old sessionRestore", () => {
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)),
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({
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 } },
@@ -1193,7 +1288,9 @@ describe("workspace-old sessionRestore", () => {
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)
.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)
@@ -1209,7 +1306,8 @@ 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* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
@@ -1220,9 +1318,11 @@ describe("workspace-old sessionRestore", () => {
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(() =>
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
@@ -1233,7 +1333,9 @@ describe("workspace-old sessionRestore", () => {
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(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)
@@ -1246,7 +1348,8 @@ 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* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
@@ -1257,9 +1360,11 @@ describe("workspace-old sessionRestore", () => {
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.text("replay failed", { status: 503 })
}))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance((dir) =>
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
@@ -1272,7 +1377,9 @@ describe("workspace-old sessionRestore", () => {
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 }))
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`,
)
@@ -1280,7 +1387,9 @@ describe("workspace-old sessionRestore", () => {
expect(replay).toHaveLength(1)
expect(
captured.events
.filter((event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type)
.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)
@@ -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,7 +1448,8 @@ 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* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
@@ -1346,9 +1460,11 @@ describe("workspace-old sessionRestore", () => {
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance((dir) =>
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
@@ -1376,7 +1492,9 @@ describe("workspace-old sessionRestore", () => {
}
const before = eventRows(session.id)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 1 })
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])
+40 -40
View File
@@ -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
+120 -120
View File
@@ -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": {
@@ -11047,6 +11047,18 @@
{
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.restore"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{
"$ref": "#/components/schemas/Event.worktree.ready"
},
@@ -11065,18 +11077,6 @@
{
"$ref": "#/components/schemas/Event.pty.deleted"
},
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.restore"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{
"$ref": "#/components/schemas/Event.message.updated"
},
@@ -13340,6 +13340,18 @@
{
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.restore"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{
"$ref": "#/components/schemas/Event.worktree.ready"
},
@@ -13358,18 +13370,6 @@
{
"$ref": "#/components/schemas/Event.pty.deleted"
},
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.restore"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{
"$ref": "#/components/schemas/Event.message.updated"
},