workspace.test.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import { beforeEach, expect } from "bun:test"
  2. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  3. import { Database } from "@opencode-ai/core/database/database"
  4. import { makeMemoryDriver } from "@opencode-ai/core/environment"
  5. import { Workspace } from "@opencode-ai/core/workspace"
  6. import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
  7. import { WorkspaceTable } from "@opencode-ai/core/workspace/sql"
  8. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  9. import { eq } from "drizzle-orm"
  10. import { Effect } from "effect"
  11. import { TestClock } from "effect/testing"
  12. import { ChildProcess } from "effect/unstable/process"
  13. import { testEffect } from "./lib/effect"
  14. const calls: Array<{ readonly operation: string; readonly binding?: WorkspaceDriver.Binding }> = []
  15. const memory = makeMemoryDriver()
  16. let failConnect = false
  17. const driver = WorkspaceDriver.make({
  18. create: ({ workspaceID }) => {
  19. calls.push({ operation: "create" })
  20. return Effect.succeed({ binding: { workspaceID, generation: 0 } })
  21. },
  22. connect: ({ binding }) => {
  23. calls.push({ operation: "connect", binding })
  24. if (failConnect) return Effect.fail(new WorkspaceDriver.Error({ message: "wake failed" }))
  25. return Effect.succeed(memory)
  26. },
  27. suspendForIdle: ({ binding, saveBinding }) => {
  28. calls.push({ operation: "suspendForIdle", binding })
  29. return saveBinding({ ...binding, generation: Number(binding.generation) + 1, suspended: true })
  30. },
  31. destroy: ({ binding }) => {
  32. calls.push({ operation: "destroy", binding })
  33. return Effect.void
  34. },
  35. })
  36. const it = testEffect(
  37. AppNodeBuilder.build(
  38. LayerNode.group([Database.node, Workspace.configured({ idleThreshold: "5 minutes", pollInterval: "1 minute" })]),
  39. [[WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: driver })]],
  40. ),
  41. )
  42. beforeEach(() => {
  43. calls.splice(0)
  44. failConnect = false
  45. })
  46. it.effect("persists the workspace lifecycle and reconnects after idle suspension", () =>
  47. Effect.gen(function* () {
  48. const workspace = yield* Workspace.Service
  49. const created = yield* workspace.create("fake")
  50. expect(created.id.startsWith("wrk_")).toBe(true)
  51. expect(created.binding).toEqual({ workspaceID: created.id, generation: 0 })
  52. const environment = yield* workspace.connect(created.id)
  53. expect(calls.map((call) => call.operation)).toEqual(["create"])
  54. yield* TestClock.adjust("4 minutes")
  55. yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("activity"))).pipe(Effect.exit)
  56. yield* TestClock.adjust("4 minutes")
  57. expect(calls.map((call) => call.operation)).toEqual(["create", "connect"])
  58. yield* TestClock.adjust("2 minutes")
  59. expect(calls.map((call) => call.operation)).toEqual(["create", "connect", "suspendForIdle"])
  60. const stored = yield* Database.Service.use(({ db }) =>
  61. db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, created.id)).get(),
  62. ).pipe(Effect.orDie)
  63. expect(stored?.binding).toEqual({ workspaceID: created.id, generation: 1, suspended: true })
  64. expect(stored?.last_used_at).toBe(4 * 60 * 1000)
  65. yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("wake"))).pipe(Effect.exit)
  66. expect(calls.map((call) => call.operation)).toEqual(["create", "connect", "suspendForIdle", "connect"])
  67. expect(calls.at(-1)?.binding).toEqual({ workspaceID: created.id, generation: 1, suspended: true })
  68. yield* workspace.destroy(created.id)
  69. expect(calls.at(-1)?.operation).toBe("destroy")
  70. }),
  71. )
  72. it.effect("surfaces wake failures through the spawn error channel", () =>
  73. Effect.gen(function* () {
  74. const workspace = yield* Workspace.Service
  75. const created = yield* workspace.create("fake")
  76. const environment = yield* workspace.connect(created.id)
  77. yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("connect"))).pipe(Effect.exit)
  78. yield* TestClock.adjust("6 minutes")
  79. failConnect = true
  80. const error = yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("wake"))).pipe(Effect.flip)
  81. expect(error).toMatchObject({
  82. _tag: "PlatformError",
  83. reason: {
  84. _tag: "Unknown",
  85. module: "Workspace",
  86. method: "spawn",
  87. description: `Failed to wake workspace ${created.id}`,
  88. },
  89. })
  90. }),
  91. )