daemon.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import { Global } from "@opencode-ai/core/global"
  2. import { InstallationVersion } from "@opencode-ai/core/installation/version"
  3. import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
  4. import { ServerAuth } from "@opencode-ai/server/auth"
  5. import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
  6. import { HttpServer } from "effect/unstable/http"
  7. import { randomBytes, randomUUID } from "crypto"
  8. import { spawn } from "node:child_process"
  9. import path from "path"
  10. export interface Interface {
  11. readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
  12. readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
  13. readonly start: () => Effect.Effect<string, Error>
  14. readonly status: () => Effect.Effect<string | undefined>
  15. readonly stop: () => Effect.Effect<void, unknown>
  16. readonly password: (value?: string) => Effect.Effect<string, unknown>
  17. readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
  18. }
  19. export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Daemon") {}
  20. const Registration = Schema.Struct({
  21. id: Schema.optional(Schema.String),
  22. version: Schema.optional(Schema.String),
  23. url: Schema.String,
  24. pid: Schema.Int.check(Schema.isGreaterThan(0)),
  25. })
  26. type Registration = typeof Registration.Type
  27. function sameRegistration(left: Registration, right: Registration) {
  28. return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
  29. }
  30. export const layer = Layer.effect(
  31. Service,
  32. Effect.gen(function* () {
  33. const fs = yield* FileSystem.FileSystem
  34. const directory = Global.Path.state
  35. const file = path.join(directory, "server.json")
  36. const passwordFile = path.join(directory, "password")
  37. const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
  38. const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
  39. const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
  40. if (value === undefined && existing) return existing
  41. // Keep one private credential across server restarts so discovered clients
  42. // can reconnect without exposing a password flag or environment variable.
  43. const generated = value ?? randomBytes(32).toString("base64url")
  44. const temp = passwordFile + ".tmp"
  45. yield* fs.makeDirectory(directory, { recursive: true })
  46. yield* fs.writeFileString(temp, generated, { mode: 0o600 })
  47. yield* fs.rename(temp, passwordFile)
  48. return generated
  49. })
  50. const registration = Effect.fnUntraced(function* () {
  51. return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration))
  52. })
  53. const createClient = Effect.fnUntraced(function* (url: string) {
  54. return createOpencodeClient({ baseUrl: url, headers: ServerAuth.headers({ password: yield* password() }) })
  55. })
  56. const healthy = Effect.fnUntraced(function* () {
  57. const info = yield* registration()
  58. const client = yield* createClient(info.url)
  59. const response = yield* Effect.tryPromise(() => client.v2.health.get({ signal: AbortSignal.timeout(2_000) }))
  60. if (response.data?.healthy === true) return info
  61. return yield* Effect.fail(new Error("Registered server is not healthy"))
  62. })
  63. const compatible = Effect.fnUntraced(function* () {
  64. const info = yield* healthy()
  65. if (info.version === InstallationVersion) return info
  66. return yield* Effect.fail(new Error("Registered server version does not match the client"))
  67. })
  68. const signal = (pid: number, signal: NodeJS.Signals) =>
  69. Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore)
  70. const awaitStopped = Effect.fnUntraced(function* (pid: number) {
  71. const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
  72. Effect.orElseSucceed(() => false),
  73. )
  74. if (!running) return true
  75. return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
  76. })
  77. const stopProcess = Effect.fnUntraced(function* (info: Registration) {
  78. const current = yield* healthy().pipe(Effect.option)
  79. if (Option.isNone(current) || !sameRegistration(current.value, info)) return
  80. yield* signal(info.pid, "SIGTERM")
  81. const stopped = yield* awaitStopped(info.pid).pipe(
  82. Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
  83. Effect.option,
  84. )
  85. if (Option.isSome(stopped)) return
  86. const latest = yield* healthy().pipe(Effect.option)
  87. if (Option.isNone(latest) || !sameRegistration(latest.value, info)) return
  88. yield* signal(info.pid, "SIGKILL")
  89. yield* awaitStopped(info.pid).pipe(
  90. Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
  91. )
  92. })
  93. const start = Effect.fn("cli.daemon.start")(function* () {
  94. const existing = yield* healthy().pipe(Effect.option)
  95. const found = Option.getOrUndefined(existing)
  96. const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
  97. if (found?.version === InstallationVersion && compiled) return found.url
  98. if (found) yield* stopProcess(found).pipe(Effect.ignore)
  99. const entrypoint = compiled ? undefined : process.argv[1]
  100. if (!compiled && entrypoint === undefined)
  101. return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
  102. yield* Effect.try({
  103. try: () => {
  104. spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
  105. detached: true,
  106. stdio: "ignore",
  107. }).unref()
  108. },
  109. catch: (cause) => new Error("Failed to start server", { cause }),
  110. })
  111. return yield* compatible().pipe(
  112. Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
  113. Effect.map((info) => info.url),
  114. Effect.mapError(() => new Error("Failed to start server")),
  115. )
  116. })
  117. const transport = Effect.fn("cli.daemon.transport")(function* () {
  118. return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
  119. })
  120. const client = Effect.fn("cli.daemon.client")(function* () {
  121. const connection = yield* transport()
  122. return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
  123. })
  124. const status = Effect.fn("cli.daemon.status")(function* () {
  125. const existing = yield* healthy().pipe(Effect.option)
  126. const found = Option.getOrUndefined(existing)
  127. if (found?.version === InstallationVersion) return found.url
  128. if (found) return undefined
  129. yield* fs.remove(file).pipe(Effect.ignore)
  130. return undefined
  131. })
  132. const stop = Effect.fn("cli.daemon.stop")(function* () {
  133. const existing = yield* healthy().pipe(Effect.option)
  134. // A stale registration may point at a PID that has since been reused by
  135. // another process. Only signal the PID after authenticating the server.
  136. if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore)
  137. yield* stopProcess(existing.value)
  138. yield* fs.remove(file).pipe(Effect.ignore)
  139. })
  140. const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
  141. const id = randomUUID()
  142. const temp = file + "." + id + ".tmp"
  143. yield* fs.makeDirectory(directory, { recursive: true })
  144. yield* fs.writeFileString(
  145. temp,
  146. JSON.stringify({ id, version: InstallationVersion, url: HttpServer.formatAddress(address), pid: process.pid }),
  147. { mode: 0o600 },
  148. )
  149. yield* fs.rename(temp, file)
  150. yield* registration().pipe(
  151. Effect.flatMap((info) => (info.id === id ? Effect.void : signal(process.pid, "SIGTERM"))),
  152. Effect.catch(() => signal(process.pid, "SIGTERM")),
  153. Effect.repeat(Schedule.spaced("10 seconds")),
  154. Effect.forkScoped,
  155. )
  156. yield* Effect.addFinalizer(() =>
  157. registration().pipe(
  158. Effect.flatMap((info) => (info.id === id ? fs.remove(file) : Effect.void)),
  159. Effect.ignore,
  160. ),
  161. )
  162. })
  163. return Service.of({ client, transport, start, status, stop, password, register })
  164. }),
  165. )
  166. export * as Daemon from "./daemon"