server-process.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. export * as ServerProcess from "./server-process"
  2. import { NodeServices } from "@effect/platform-node"
  3. import { Service } from "@opencode-ai/client/effect"
  4. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  5. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  6. import { Global } from "@opencode-ai/core/global"
  7. import { InstallationVersion } from "@opencode-ai/core/installation/version"
  8. import { AppProcess } from "@opencode-ai/core/process"
  9. import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
  10. import { start } from "@opencode-ai/server/process"
  11. import { randomBytes, randomUUID } from "node:crypto"
  12. import path from "node:path"
  13. import { Effect, Exit, FileSystem, Logger, Option, Redacted, Schedule, Schema, Scope } from "effect"
  14. import { HttpServer } from "effect/unstable/http"
  15. import { Env } from "./env"
  16. import { ServiceConfig } from "./services/service-config"
  17. import { Updater } from "./services/updater"
  18. export type Mode = "default" | "service" | "stdio"
  19. export type Options = {
  20. readonly mode: Mode
  21. readonly hostname?: string
  22. readonly port?: number
  23. }
  24. export const run = Effect.fn("cli.server-process.run")((options: Options) =>
  25. processEffect(options).pipe(
  26. Effect.provide(Updater.layer),
  27. Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, EffectFlock.node]))),
  28. Effect.provide(NodeServices.layer),
  29. ),
  30. )
  31. const processEffect = Effect.fnUntraced(function* (options: Options) {
  32. if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
  33. return yield* Effect.scoped(
  34. Effect.gen(function* () {
  35. const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
  36. const lockScope = serviceOptions === undefined ? undefined : yield* acquireServiceLock(serviceOptions.file)
  37. if (
  38. serviceOptions !== undefined &&
  39. lockScope !== undefined &&
  40. (yield* Service.discover(serviceOptions)) !== undefined
  41. ) {
  42. yield* Scope.close(lockScope, Exit.void)
  43. return
  44. }
  45. const environmentPassword = yield* Env.password
  46. // Keep the lease credential out of the environment inherited by tools.
  47. if (options.mode === "stdio") {
  48. delete process.env.OPENCODE_PASSWORD
  49. delete process.env.OPENCODE_SERVER_PASSWORD
  50. }
  51. const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
  52. const password =
  53. options.mode === "service"
  54. ? yield* ServiceConfig.password()
  55. : environmentPassword
  56. ? Redacted.value(environmentPassword)
  57. : randomBytes(32).toString("base64url")
  58. if (!password) return yield* Effect.fail(new Error("Missing server password"))
  59. const address = yield* start({
  60. hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
  61. port: Option.fromNullishOr(options.port ?? config.port),
  62. password,
  63. restartContinuity: options.mode === "service",
  64. }).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
  65. if (lockScope !== undefined) {
  66. yield* register(address, password)
  67. yield* Scope.close(lockScope, Exit.void)
  68. }
  69. const url = HttpServer.formatAddress(address)
  70. console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
  71. if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
  72. const updater = yield* Updater.Service
  73. yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
  74. return yield* options.mode === "stdio" ? waitForStdinClose() : Effect.never
  75. }).pipe(Effect.annotateLogs({ role: "server" })),
  76. )
  77. })
  78. const acquireServiceLock = Effect.fnUntraced(function* (file: string) {
  79. const flock = yield* EffectFlock.Service
  80. const scope = yield* Scope.make()
  81. yield* Effect.addFinalizer((exit) => Scope.close(scope, exit))
  82. yield* flock
  83. .acquire(`service:${file}`, undefined, { staleMs: 3_000, timeoutMs: 3_000 })
  84. .pipe(Effect.provideService(Scope.Scope, scope))
  85. return scope
  86. })
  87. // The latest atomic registration wins. A displaced process notices the new id,
  88. // exits, and cannot remove its successor's registration from its finalizer.
  89. const infoJson = Schema.fromJsonString(Service.Info)
  90. const encodeInfo = Schema.encodeEffect(infoJson)
  91. const decodeInfo = Schema.decodeUnknownEffect(infoJson)
  92. const register = Effect.fnUntraced(function* (address: HttpServer.Address, password: string) {
  93. const fs = yield* FileSystem.FileSystem
  94. const options = yield* ServiceConfig.options()
  95. const id = randomUUID()
  96. const temp = options.file + "." + id + ".tmp"
  97. yield* fs.makeDirectory(path.dirname(options.file), { recursive: true })
  98. const encoded = yield* encodeInfo({
  99. id,
  100. version: InstallationVersion,
  101. url: HttpServer.formatAddress(address),
  102. pid: process.pid,
  103. password,
  104. })
  105. yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
  106. yield* fs.rename(temp, options.file)
  107. const currentID = fs.readFileString(options.file).pipe(
  108. Effect.flatMap(decodeInfo),
  109. Effect.map((info) => info.id),
  110. Effect.orElseSucceed(() => undefined),
  111. )
  112. yield* currentID.pipe(
  113. Effect.flatMap((current) =>
  114. current === id
  115. ? Effect.void
  116. : Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
  117. ),
  118. Effect.repeat(Schedule.spaced("10 seconds")),
  119. Effect.forkScoped,
  120. )
  121. yield* Effect.addFinalizer(() =>
  122. currentID.pipe(
  123. Effect.flatMap((current) => (current === id ? fs.remove(options.file) : Effect.void)),
  124. Effect.ignore,
  125. ),
  126. )
  127. })
  128. function waitForStdinClose() {
  129. return Effect.callback<void>((resume) => {
  130. const close = () => resume(Effect.void)
  131. process.stdin.once("end", close)
  132. process.stdin.once("close", close)
  133. process.stdin.resume()
  134. if (process.stdin.readableEnded || process.stdin.destroyed) close()
  135. return Effect.sync(() => {
  136. process.stdin.off("end", close)
  137. process.stdin.off("close", close)
  138. process.stdin.pause()
  139. })
  140. })
  141. }