workspace.test.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Exit } from "effect"
  3. import { adjust } from "effect/testing/TestClock"
  4. import { eq } from "drizzle-orm"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  7. import { Database } from "@opencode-ai/core/database/database"
  8. import { AppProcess } from "@opencode-ai/core/process"
  9. import { Project } from "@opencode-ai/core/project"
  10. import { ProjectTable } from "@opencode-ai/core/project/sql"
  11. import { AbsolutePath } from "@opencode-ai/core/schema"
  12. import { WorkspaceV2 } from "@opencode-ai/core/workspace"
  13. import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
  14. import { Sandbox } from "@opencode-ai/core/workspace/sandbox"
  15. import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
  16. import { testEffect } from "./lib/effect"
  17. const it = testEffect(
  18. AppNodeBuilder.build(LayerNode.group([Database.node, Sandbox.registryNode, WorkspaceV2.node, AppProcess.node])),
  19. )
  20. describe("WorkspaceV2", () => {
  21. it.effect("loads metadata without connecting and shares a scoped connection", () =>
  22. Effect.gen(function* () {
  23. const db = (yield* Database.Service).db
  24. const process = yield* AppProcess.Service
  25. const registry = yield* Sandbox.RegistryService
  26. const workspace = yield* WorkspaceV2.Service
  27. const id = WorkspaceV2.ID.make("wrk_hosted")
  28. const projectID = Project.ID.make("hosted-project")
  29. const directory = AbsolutePath.make("/workspace/repo")
  30. const lifecycle = { connected: 0, reconciled: 0, released: 0 }
  31. const unsupported = (operation: string) => Effect.fail(new WorkspaceEnvironment.Error({ operation }))
  32. const environment = WorkspaceEnvironment.Service.of({
  33. platform: "linux",
  34. directory,
  35. process,
  36. shell: {
  37. executable: "/bin/sh",
  38. args: (command) => ["-c", command],
  39. environmentOverrides: {},
  40. detached: false,
  41. },
  42. ripgrep: Effect.succeed("/usr/bin/rg"),
  43. files: {
  44. inspect: () => unsupported("inspect"),
  45. resolve: () => unsupported("resolve"),
  46. read: () => unsupported("read"),
  47. list: () => unsupported("list"),
  48. ensureDirectory: () => unsupported("ensureDirectory"),
  49. createExclusive: () => unsupported("createExclusive"),
  50. write: () => unsupported("write"),
  51. writeIfUnchanged: () => unsupported("writeIfUnchanged"),
  52. remove: () => unsupported("remove"),
  53. },
  54. })
  55. yield* db
  56. .insert(ProjectTable)
  57. .values({
  58. id: projectID,
  59. worktree: directory,
  60. sandboxes: [],
  61. time_created: 1,
  62. time_updated: 1,
  63. })
  64. .run()
  65. yield* db
  66. .insert(WorkspaceTable)
  67. .values({
  68. id,
  69. type: "fake",
  70. name: "Hosted",
  71. directory,
  72. extra: { kind: "sandbox", version: 1, binding: { sandbox: "one" } },
  73. project_id: projectID,
  74. time_used: 1,
  75. })
  76. .run()
  77. yield* registry.register({
  78. key: "fake",
  79. decode: Effect.succeed,
  80. connect: () =>
  81. Effect.acquireRelease(
  82. Effect.sync(() => {
  83. lifecycle.connected++
  84. return { binding: { sandbox: "live", retired: "one" }, environment }
  85. }),
  86. () => Effect.sync(() => lifecycle.released++),
  87. ),
  88. reconcile: () =>
  89. Effect.sync(() => {
  90. lifecycle.reconciled++
  91. return { sandbox: "live" }
  92. }),
  93. })
  94. expect(yield* workspace.get(id)).toEqual({
  95. id,
  96. name: "Hosted",
  97. directory,
  98. project: { id: projectID, directory },
  99. })
  100. expect(lifecycle.connected).toBe(0)
  101. const borrowed = yield* Effect.all([workspace.borrow(id), workspace.borrow(id)]).pipe(Effect.scoped)
  102. expect(borrowed[0]).toBe(environment)
  103. expect(borrowed[1]).toBe(environment)
  104. expect(lifecycle.connected).toBe(1)
  105. expect(lifecycle.reconciled).toBe(1)
  106. expect(lifecycle.released).toBe(0)
  107. const placement = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()
  108. expect(placement?.extra).toEqual({
  109. kind: "sandbox",
  110. version: 1,
  111. binding: { sandbox: "live" },
  112. })
  113. yield* adjust("1 minute")
  114. yield* Effect.yieldNow
  115. expect(lifecycle.released).toBe(1)
  116. const invalidID = WorkspaceV2.ID.make("wrk_invalid")
  117. yield* db
  118. .insert(WorkspaceTable)
  119. .values({
  120. id: invalidID,
  121. type: "fake",
  122. name: "Legacy",
  123. directory,
  124. extra: { sandbox: "legacy-adapter-state" },
  125. project_id: projectID,
  126. time_used: 1,
  127. })
  128. .run()
  129. const invalid = yield* workspace.borrow(invalidID).pipe(Effect.scoped, Effect.flip)
  130. expect(invalid._tag).toBe("Workspace.InvalidError")
  131. expect(lifecycle.connected).toBe(1)
  132. const retryID = WorkspaceV2.ID.make("wrk_retry")
  133. const retry = { attempts: 0 }
  134. yield* db
  135. .insert(WorkspaceTable)
  136. .values({
  137. id: retryID,
  138. type: "flaky",
  139. name: "Retry",
  140. directory,
  141. extra: { kind: "sandbox", version: 1, binding: { sandbox: "retry" } },
  142. project_id: projectID,
  143. time_used: 1,
  144. })
  145. .run()
  146. yield* registry.register({
  147. key: "flaky",
  148. decode: Effect.succeed,
  149. connect: (binding) =>
  150. Effect.sync(() => ++retry.attempts).pipe(
  151. Effect.flatMap((attempt) =>
  152. attempt === 1 ? Effect.die("Transient provider defect") : Effect.succeed({ binding, environment }),
  153. ),
  154. ),
  155. reconcile: Effect.succeed,
  156. })
  157. expect(Exit.isFailure(yield* workspace.borrow(retryID).pipe(Effect.scoped, Effect.exit))).toBe(true)
  158. expect(yield* workspace.borrow(retryID).pipe(Effect.scoped)).toBe(environment)
  159. expect(retry.attempts).toBe(2)
  160. }),
  161. )
  162. })