instance.test.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import { describe, expect } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  4. import { Deferred, Effect, Fiber, Layer } from "effect"
  5. import { InstanceRef } from "../../src/effect/instance-ref"
  6. import { registerDisposer } from "../../src/effect/instance-registry"
  7. import { InstanceBootstrap } from "../../src/project/bootstrap"
  8. import { InstanceStore } from "../../src/project/instance-store"
  9. import { tmpdirScoped } from "../fixture/fixture"
  10. import { testEffect } from "../lib/effect"
  11. let bootstrapRun: Effect.Effect<void> = Effect.void
  12. const noopBootstrap = Layer.succeed(
  13. InstanceBootstrap.Service,
  14. InstanceBootstrap.Service.of({ run: Effect.suspend(() => bootstrapRun) }),
  15. )
  16. const it = testEffect(
  17. LayerNode.compile(LayerNode.group([InstanceStore.node, CrossSpawnSpawner.node]), [
  18. [InstanceStore.bootstrapNode, noopBootstrap],
  19. ]),
  20. )
  21. const setBootstrap = (run: Effect.Effect<void>) =>
  22. Effect.acquireRelease(
  23. Effect.sync(() => {
  24. bootstrapRun = run
  25. }),
  26. () =>
  27. Effect.sync(() => {
  28. bootstrapRun = Effect.void
  29. }),
  30. )
  31. const registerDisposerScoped = (disposer: (directory: string) => Promise<void>) =>
  32. Effect.acquireRelease(
  33. Effect.sync(() => registerDisposer(disposer)),
  34. (off) => Effect.sync(off),
  35. )
  36. describe("InstanceStore", () => {
  37. it.live("loads instance context", () =>
  38. Effect.gen(function* () {
  39. const dir = yield* tmpdirScoped({ git: true })
  40. const store = yield* InstanceStore.Service
  41. const ctx = yield* store.load({ directory: dir })
  42. expect(ctx.directory).toBe(dir)
  43. expect(ctx.worktree).toBe(dir)
  44. }),
  45. )
  46. it.live("runs bootstrap with InstanceRef provided", () =>
  47. Effect.gen(function* () {
  48. const dir = yield* tmpdirScoped({ git: true })
  49. const store = yield* InstanceStore.Service
  50. let initializedDirectory: string | undefined
  51. yield* setBootstrap(
  52. Effect.gen(function* () {
  53. initializedDirectory = (yield* InstanceRef)?.directory
  54. }),
  55. )
  56. yield* store.load({ directory: dir })
  57. expect(initializedDirectory).toBe(dir)
  58. }),
  59. )
  60. it.live("caches loaded instance context by directory", () =>
  61. Effect.gen(function* () {
  62. const dir = yield* tmpdirScoped({ git: true })
  63. const store = yield* InstanceStore.Service
  64. let initialized = 0
  65. yield* setBootstrap(
  66. Effect.sync(() => {
  67. initialized++
  68. }),
  69. )
  70. const first = yield* store.load({ directory: dir })
  71. const second = yield* store.load({ directory: dir })
  72. expect(second).toBe(first)
  73. expect(initialized).toBe(1)
  74. }),
  75. )
  76. it.live("dedupes concurrent loads while init is in flight", () =>
  77. Effect.gen(function* () {
  78. const dir = yield* tmpdirScoped({ git: true })
  79. const store = yield* InstanceStore.Service
  80. const started = yield* Deferred.make<void>()
  81. const release = yield* Deferred.make<void>()
  82. let initialized = 0
  83. yield* setBootstrap(
  84. Effect.gen(function* () {
  85. initialized++
  86. yield* Deferred.succeed(started, undefined)
  87. yield* Deferred.await(release)
  88. }),
  89. )
  90. const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped)
  91. yield* Deferred.await(started)
  92. yield* setBootstrap(
  93. Effect.sync(() => {
  94. initialized++
  95. }),
  96. )
  97. const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped)
  98. expect(initialized).toBe(1)
  99. yield* Deferred.succeed(release, undefined)
  100. const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)])
  101. expect(secondCtx).toBe(firstCtx)
  102. expect(initialized).toBe(1)
  103. }),
  104. )
  105. it.live("removes failed loads from the cache", () =>
  106. Effect.gen(function* () {
  107. const dir = yield* tmpdirScoped({ git: true })
  108. const store = yield* InstanceStore.Service
  109. let attempts = 0
  110. yield* setBootstrap(
  111. Effect.sync(() => {
  112. attempts++
  113. throw new Error("init failed")
  114. }),
  115. )
  116. const failed = yield* store.load({ directory: dir }).pipe(
  117. Effect.as(false),
  118. Effect.catchCause(() => Effect.succeed(true)),
  119. )
  120. expect(failed).toBe(true)
  121. yield* setBootstrap(
  122. Effect.sync(() => {
  123. attempts++
  124. }),
  125. )
  126. const ctx = yield* store.load({ directory: dir })
  127. expect(ctx.directory).toBe(dir)
  128. expect(attempts).toBe(2)
  129. }),
  130. )
  131. it.live("reload replaces the cached context", () =>
  132. Effect.gen(function* () {
  133. const dir = yield* tmpdirScoped({ git: true })
  134. const store = yield* InstanceStore.Service
  135. const first = yield* store.load({ directory: dir })
  136. const second = yield* store.reload({ directory: dir })
  137. const cached = yield* store.load({ directory: dir })
  138. expect(second).not.toBe(first)
  139. expect(cached).toBe(second)
  140. }),
  141. )
  142. it.live("stale dispose does not delete an in-flight reload", () =>
  143. Effect.gen(function* () {
  144. const dir = yield* tmpdirScoped({ git: true })
  145. const store = yield* InstanceStore.Service
  146. const reloading = yield* Deferred.make<void>()
  147. const releaseReload = yield* Deferred.make<void>()
  148. const disposed: Array<string> = []
  149. yield* registerDisposerScoped(async (directory) => {
  150. disposed.push(directory)
  151. })
  152. const first = yield* store.load({ directory: dir })
  153. yield* setBootstrap(
  154. Effect.gen(function* () {
  155. yield* Deferred.succeed(reloading, undefined)
  156. yield* Deferred.await(releaseReload)
  157. }),
  158. )
  159. const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped)
  160. yield* Deferred.await(reloading)
  161. const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped)
  162. yield* Deferred.succeed(releaseReload, undefined)
  163. const second = yield* Fiber.join(reload)
  164. yield* Fiber.join(staleDispose)
  165. expect(disposed).toEqual([dir])
  166. expect(yield* store.load({ directory: dir })).toBe(second)
  167. }),
  168. )
  169. it.live("dedupes concurrent disposeAll calls", () =>
  170. Effect.gen(function* () {
  171. const dir = yield* tmpdirScoped({ git: true })
  172. const store = yield* InstanceStore.Service
  173. const disposing = yield* Deferred.make<void>()
  174. const releaseDispose = yield* Deferred.make<() => void>()
  175. const disposed: Array<string> = []
  176. yield* registerDisposerScoped((directory) => {
  177. disposed.push(directory)
  178. Deferred.doneUnsafe(disposing, Effect.void)
  179. return new Promise<void>((resolve) => {
  180. Deferred.doneUnsafe(releaseDispose, Effect.succeed(resolve))
  181. })
  182. })
  183. yield* store.load({ directory: dir })
  184. const first = yield* store.disposeAll().pipe(Effect.forkScoped)
  185. yield* Deferred.await(disposing)
  186. const release = yield* Deferred.await(releaseDispose)
  187. const second = yield* store.disposeAll().pipe(Effect.forkScoped)
  188. expect(disposed).toEqual([dir])
  189. yield* Effect.sync(release)
  190. yield* Effect.all([Fiber.join(first), Fiber.join(second)])
  191. expect(disposed).toEqual([dir])
  192. }),
  193. )
  194. it.live("re-arms disposeAll after completion", () =>
  195. Effect.gen(function* () {
  196. const dir1 = yield* tmpdirScoped({ git: true })
  197. const dir2 = yield* tmpdirScoped({ git: true })
  198. const store = yield* InstanceStore.Service
  199. const disposed: Array<string> = []
  200. yield* registerDisposerScoped(async (directory) => {
  201. disposed.push(directory)
  202. })
  203. yield* store.load({ directory: dir1 })
  204. yield* store.disposeAll()
  205. expect(disposed).toEqual([dir1])
  206. yield* store.load({ directory: dir2 })
  207. yield* store.disposeAll()
  208. expect(disposed).toEqual([dir1, dir2])
  209. }),
  210. )
  211. })