service.test.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { Service } from "@opencode-ai/client/effect"
  3. import { Database } from "@opencode-ai/core/database/database"
  4. import { EventV2 } from "@opencode-ai/core/event"
  5. import { EventTable } from "@opencode-ai/core/event/sql"
  6. import { Global } from "@opencode-ai/core/global"
  7. import { Project } from "@opencode-ai/core/project"
  8. import { ProjectTable } from "@opencode-ai/core/project/sql"
  9. import { AbsolutePath } from "@opencode-ai/core/schema"
  10. import { SessionV2 } from "@opencode-ai/core/session"
  11. import { SessionEvent } from "@opencode-ai/core/session/event"
  12. import { SessionTable } from "@opencode-ai/core/session/sql"
  13. import { expect, test } from "bun:test"
  14. import { Effect, Schedule, Schema } from "effect"
  15. import fs from "node:fs/promises"
  16. import os from "node:os"
  17. import path from "node:path"
  18. import { ServiceConfig } from "../src/services/service-config"
  19. test("local channel stores service config with the local service filename", async () => {
  20. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
  21. try {
  22. await Effect.runPromise(
  23. ServiceConfig.set("hostname", "127.0.0.2").pipe(
  24. Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
  25. Effect.provide(NodeFileSystem.layer),
  26. ),
  27. )
  28. expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({
  29. hostname: "127.0.0.2",
  30. })
  31. expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false)
  32. } finally {
  33. await fs.rm(root, { recursive: true, force: true })
  34. }
  35. })
  36. test("concurrent service processes elect one server", async () => {
  37. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
  38. const database = path.join(root, "opencode.db")
  39. const env = {
  40. ...process.env,
  41. HOME: root,
  42. OPENCODE_DB: database,
  43. OPENCODE_TEST_HOME: root,
  44. XDG_CACHE_HOME: path.join(root, "cache"),
  45. XDG_CONFIG_HOME: path.join(root, "config"),
  46. XDG_DATA_HOME: path.join(root, "data"),
  47. XDG_STATE_HOME: path.join(root, "state"),
  48. }
  49. const sessionID = SessionV2.ID.make("ses_service_recovery")
  50. await withDatabase(
  51. database,
  52. Effect.gen(function* () {
  53. const { db } = yield* Database.Service
  54. yield* db
  55. .insert(ProjectTable)
  56. .values({ id: Project.ID.global, worktree: AbsolutePath.make(root), sandboxes: [] })
  57. .run()
  58. .pipe(Effect.orDie)
  59. yield* db
  60. .insert(SessionTable)
  61. .values({
  62. id: sessionID,
  63. project_id: Project.ID.global,
  64. slug: "recovery",
  65. directory: root,
  66. title: "recovery",
  67. version: "test",
  68. time_suspended: Date.now(),
  69. })
  70. .run()
  71. .pipe(Effect.orDie)
  72. }),
  73. )
  74. const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
  75. const first = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
  76. const second = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
  77. try {
  78. const registration = path.join(root, "state", "opencode", "service-local.json")
  79. const info = await waitForInfo(registration)
  80. const winner = info.pid === first.pid ? first : second
  81. const loser = info.pid === first.pid ? second : first
  82. const exited = await Promise.race([loser.exited.then(() => true), Bun.sleep(10_000).then(() => false)])
  83. expect(exited).toBe(true)
  84. expect(winner.exitCode).toBe(null)
  85. expect(
  86. await withDatabase(
  87. database,
  88. Effect.gen(function* () {
  89. const { db } = yield* Database.Service
  90. return yield* db
  91. .select({ timeSuspended: SessionTable.time_suspended })
  92. .from(SessionTable)
  93. .get()
  94. .pipe(Effect.orDie)
  95. }),
  96. ),
  97. ).toEqual({ timeSuspended: null })
  98. expect(await waitForExecutionStart(database, sessionID)).toBe(1)
  99. } finally {
  100. first.kill("SIGTERM")
  101. second.kill("SIGTERM")
  102. await Promise.all([first.exited, second.exited])
  103. await fs.rm(root, { recursive: true, force: true })
  104. }
  105. })
  106. function withDatabase<A, E>(file: string, effect: Effect.Effect<A, E, Database.Service>) {
  107. return Effect.runPromise(effect.pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped))
  108. }
  109. function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
  110. return withDatabase(
  111. file,
  112. Effect.gen(function* () {
  113. const { db } = yield* Database.Service
  114. return yield* db
  115. .select({ id: EventTable.id, sessionID: EventTable.aggregate_id, type: EventTable.type })
  116. .from(EventTable)
  117. .all()
  118. .pipe(
  119. Effect.orDie,
  120. Effect.map((rows) =>
  121. rows.filter(
  122. (row) =>
  123. row.sessionID === sessionID &&
  124. row.type ===
  125. EventV2.versionedType(
  126. SessionEvent.Execution.Started.type,
  127. SessionEvent.Execution.Started.durable.version,
  128. ),
  129. ),
  130. ),
  131. Effect.filterOrFail((rows) => rows.length > 0),
  132. Effect.map((rows) => rows.length),
  133. Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(200)))),
  134. )
  135. }),
  136. )
  137. }
  138. async function waitForInfo(file: string) {
  139. for (let attempt = 0; attempt < 200; attempt++) {
  140. const value = await Bun.file(file)
  141. .json()
  142. .catch(() => undefined)
  143. if (value !== undefined) return Schema.decodeUnknownPromise(Service.Info)(value)
  144. await Bun.sleep(50)
  145. }
  146. throw new Error("Timed out waiting for service registration")
  147. }