mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-23 03:28:38 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8c40005d9 |
@@ -27,8 +27,13 @@ export async function resolveOpencodePty(target: Target): Promise<OpencodePtyAss
|
||||
.map((value) => (value === "glibc" ? "gnu" : value))
|
||||
.join("-")
|
||||
const name = `@opencode-ai/pty-${suffix}`
|
||||
const source = pty.resolve(`${name}/bin/opencode-pty`)
|
||||
const manifest: unknown = JSON.parse(await readFile(pty.resolve(`${name}/package.json`), "utf8"))
|
||||
const local = process.env.OPENCODE_PTY_BIN
|
||||
if (local && (target.platform !== process.platform || target.arch !== process.arch))
|
||||
throw new Error("OPENCODE_PTY_BIN can only be embedded in a build for the current platform and architecture")
|
||||
const source = local ? path.resolve(local) : pty.resolve(`${name}/bin/opencode-pty`)
|
||||
const manifest: unknown = local
|
||||
? { version: "local" }
|
||||
: JSON.parse(await readFile(pty.resolve(`${name}/package.json`), "utf8"))
|
||||
if (!manifest || typeof manifest !== "object" || !("version" in manifest) || typeof manifest.version !== "string")
|
||||
throw new Error(`Invalid package metadata for ${name}`)
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { Effect, Option, Redacted, Schedule } from "effect"
|
||||
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
@@ -40,6 +41,14 @@ export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
})
|
||||
|
||||
const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const inherited = process.env.OPENCODE_PTY_HANDOFF
|
||||
delete process.env.OPENCODE_PTY_HANDOFF
|
||||
const handoff =
|
||||
inherited === undefined
|
||||
? undefined
|
||||
: yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PersistentPty.Handoff))(inherited).pipe(
|
||||
Effect.mapError(() => new Error("Invalid PTY restart handoff")),
|
||||
)
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
return yield* Effect.scoped(
|
||||
@@ -80,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
hostname,
|
||||
port,
|
||||
password,
|
||||
pty: { handoff },
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
database: {
|
||||
path:
|
||||
|
||||
@@ -1658,9 +1658,9 @@ export type ExperimentalPersistentPtyListOperation<E = never> = (
|
||||
|
||||
export type ExperimentalPersistentPtyCreateInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly command: string
|
||||
readonly command?: string | undefined
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string | undefined
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number } | undefined
|
||||
@@ -1689,6 +1689,19 @@ export type ExperimentalPersistentPtyShutdownOperation<E = never> = () => Effect
|
||||
E
|
||||
>
|
||||
|
||||
export type ExperimentalPersistentPtyPrepareRestartOutput = {
|
||||
readonly handoff: {
|
||||
readonly directory: string
|
||||
readonly instanceID: string
|
||||
readonly ticket: string
|
||||
readonly expiresAt: number
|
||||
} | null
|
||||
}
|
||||
export type ExperimentalPersistentPtyPrepareRestartOperation<E = never> = () => Effect.Effect<
|
||||
ExperimentalPersistentPtyPrepareRestartOutput,
|
||||
E
|
||||
>
|
||||
|
||||
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: Pty.ID }
|
||||
export type ExperimentalPersistentPtyGetOutput = {
|
||||
readonly id: Pty.ID
|
||||
@@ -1775,6 +1788,7 @@ export interface ExperimentalApi<E = never> {
|
||||
readonly list: ExperimentalPersistentPtyListOperation<E>
|
||||
readonly create: ExperimentalPersistentPtyCreateOperation<E>
|
||||
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
|
||||
readonly prepareRestart: ExperimentalPersistentPtyPrepareRestartOperation<E>
|
||||
readonly get: ExperimentalPersistentPtyGetOperation<E>
|
||||
readonly update: ExperimentalPersistentPtyUpdateOperation<E>
|
||||
readonly snapshot: ExperimentalPersistentPtySnapshotOperation<E>
|
||||
|
||||
@@ -203,6 +203,7 @@ import type {
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
ExperimentalPersistentPtyCreateOutput,
|
||||
ExperimentalPersistentPtyShutdownOutput,
|
||||
ExperimentalPersistentPtyPrepareRestartOutput,
|
||||
ExperimentalPersistentPtyGetInput,
|
||||
ExperimentalPersistentPtyGetOutput,
|
||||
ExperimentalPersistentPtyUpdateInput,
|
||||
@@ -1265,6 +1266,11 @@ const EndpointExperimentalPersistentPtyShutdown = (raw: RawClient["server.experi
|
||||
raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyPrepareRestart = (raw: RawClient["server.experimental"]) => () =>
|
||||
preserveEffect<ExperimentalPersistentPtyPrepareRestartOutput>()(
|
||||
raw["persistentPty.prepareRestart"]({}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyGet =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyGetInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyGetOutput>()(
|
||||
@@ -1318,6 +1324,7 @@ const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
|
||||
list: EndpointExperimentalPersistentPtyList(raw),
|
||||
create: EndpointExperimentalPersistentPtyCreate(raw),
|
||||
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
|
||||
prepareRestart: EndpointExperimentalPersistentPtyPrepareRestart(raw),
|
||||
get: EndpointExperimentalPersistentPtyGet(raw),
|
||||
update: EndpointExperimentalPersistentPtyUpdate(raw),
|
||||
snapshot: EndpointExperimentalPersistentPtySnapshot(raw),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { ServiceHandoff } from "../service-handoff.js"
|
||||
|
||||
export * from "../service.js"
|
||||
/** Contents of the local service registration file. */
|
||||
@@ -65,9 +66,10 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
const spawnContender = Effect.gen(function* () {
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||
const env = yield* Effect.tryPromise(() => ServiceHandoff.environment(options.file ?? fallback(), options.env))
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
return spawnServiceContender(command, args, options.env)
|
||||
return spawnServiceContender(command, args, env)
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
@@ -83,6 +85,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
yield* announce("missing")
|
||||
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
yield* Effect.tryPromise(() => ServiceHandoff.clear(options.file ?? fallback()))
|
||||
yield* terminate(info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
@@ -91,11 +95,23 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
if (service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const compatible = !service.legacy && matchesVersion(service.version, options)
|
||||
if (compatible && service.state === "ready") return Option.some(service)
|
||||
if (compatible && service.state === "ready") {
|
||||
yield* Effect.tryPromise(() => ServiceHandoff.complete(options.file ?? fallback(), service.info))
|
||||
return Option.some(service)
|
||||
}
|
||||
if (compatible && service.state === "failed")
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* announce("version-mismatch", service.version)
|
||||
if (!service.legacy && service.state === "ready")
|
||||
yield* Effect.tryPromise(() =>
|
||||
ServiceHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout),
|
||||
)
|
||||
else {
|
||||
if (!service.legacy)
|
||||
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
yield* Effect.tryPromise(() => ServiceHandoff.clear(options.file ?? fallback()))
|
||||
}
|
||||
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
@@ -129,6 +145,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
yield* Effect.tryPromise(() => ServiceHandoff.clear(options.file ?? fallback()))
|
||||
const info = yield* read(options.file)
|
||||
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
|
||||
})
|
||||
|
||||
@@ -199,6 +199,7 @@ import type {
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
ExperimentalPersistentPtyCreateOutput,
|
||||
ExperimentalPersistentPtyShutdownOutput,
|
||||
ExperimentalPersistentPtyPrepareRestartOutput,
|
||||
ExperimentalPersistentPtyGetInput,
|
||||
ExperimentalPersistentPtyGetOutput,
|
||||
ExperimentalPersistentPtyUpdateInput,
|
||||
@@ -1707,7 +1708,7 @@ export function make(options: ClientOptions) {
|
||||
size: input["size"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
declaredStatuses: [400, 503, 404, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1723,6 +1724,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
prepareRestart: (requestOptions?: RequestOptions) =>
|
||||
request<ExperimentalPersistentPtyPrepareRestartOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/persistent-pty/prepare-restart`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: ExperimentalPersistentPtyGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyGetOutput }>(
|
||||
{
|
||||
|
||||
@@ -376,6 +376,8 @@ export type SessionStatus =
|
||||
|
||||
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
|
||||
|
||||
export type PersistentPtyHandoff = { directory: string; instanceID: string; ticket: string; expiresAt: number }
|
||||
|
||||
export type ShellInfo1 = {
|
||||
id: string
|
||||
status: "running" | "exited" | "timeout" | "killed"
|
||||
@@ -5753,50 +5755,50 @@ export type ExperimentalPersistentPtyListOutput = { data: Array<PersistentPtyInf
|
||||
|
||||
export type ExperimentalPersistentPtyCreateInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly command: {
|
||||
readonly command: string
|
||||
readonly command?: {
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["command"]
|
||||
readonly args: {
|
||||
readonly command: string
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["args"]
|
||||
readonly cwd: {
|
||||
readonly command: string
|
||||
readonly cwd?: {
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["cwd"]
|
||||
readonly title: {
|
||||
readonly command: string
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["title"]
|
||||
readonly env: {
|
||||
readonly command: string
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["env"]
|
||||
readonly size?: {
|
||||
readonly command: string
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
@@ -5807,6 +5809,8 @@ export type ExperimentalPersistentPtyCreateOutput = { data: PersistentPtyInfo }[
|
||||
|
||||
export type ExperimentalPersistentPtyShutdownOutput = void
|
||||
|
||||
export type ExperimentalPersistentPtyPrepareRestartOutput = { handoff: PersistentPtyHandoff | null }
|
||||
|
||||
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ExperimentalPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { ServiceHandoff } from "../service-handoff.js"
|
||||
import type { ServiceHealth } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
@@ -43,11 +44,15 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
announced = true
|
||||
options.onStart?.(reason, previousVersion)
|
||||
}
|
||||
const spawnContender = () => {
|
||||
const spawnContender = async () => {
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) throw new Error("Missing service command")
|
||||
try {
|
||||
return spawnServiceContender(command, args, options.env)
|
||||
return spawnServiceContender(
|
||||
command,
|
||||
args,
|
||||
await ServiceHandoff.environment(options.file ?? fallback(), options.env),
|
||||
)
|
||||
} catch (cause) {
|
||||
throw new Error("Failed to start server", { cause })
|
||||
}
|
||||
@@ -64,6 +69,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
await ServiceHandoff.clear(options.file ?? fallback())
|
||||
await terminate(registration.info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
@@ -74,10 +81,20 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const service = registration.service
|
||||
const compatible = !service.legacy && matchesVersion(service.version, options)
|
||||
if (compatible && service.state === "ready") return service.endpoint
|
||||
if (compatible && service.state === "ready") {
|
||||
await ServiceHandoff.complete(options.file ?? fallback(), service.info)
|
||||
return service.endpoint
|
||||
}
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
announce("version-mismatch", service.version)
|
||||
if (!service.legacy && service.state === "ready")
|
||||
await ServiceHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout)
|
||||
else {
|
||||
if (!service.legacy)
|
||||
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
await ServiceHandoff.clear(options.file ?? fallback())
|
||||
}
|
||||
await terminate(service.info, options, timing).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
@@ -93,7 +110,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
announce("missing")
|
||||
contenders.add(spawnContender())
|
||||
contenders.add(await spawnContender())
|
||||
lastSpawn = Date.now()
|
||||
}
|
||||
}
|
||||
@@ -106,6 +123,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export async function stop(options: StopOptions = {}) {
|
||||
await ServiceHandoff.clear(options.file ?? fallback())
|
||||
const info = await read(options.file)
|
||||
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const stderrLimit = 8 * 1024
|
||||
export function spawnServiceContender(
|
||||
command: string,
|
||||
args: ReadonlyArray<string>,
|
||||
env?: Readonly<Record<string, string>>,
|
||||
env?: Readonly<Record<string, string | undefined>>,
|
||||
): ServiceContender {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
export * as ServiceHandoff from "./service-handoff.js"
|
||||
|
||||
import type { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { readFile, rename, rm, writeFile } from "node:fs/promises"
|
||||
import type { Info } from "./service.js"
|
||||
|
||||
type Sidecar = {
|
||||
readonly source: Pick<Info, "id" | "pid" | "url">
|
||||
readonly handoff: PersistentPty.Handoff | null
|
||||
readonly expiresAt: number
|
||||
}
|
||||
|
||||
/** Publish the ticket before stopping its owner so every replacement contender can adopt it. */
|
||||
export async function prepare(file: string, info: Info, timeout: number) {
|
||||
const existing = await read(file)
|
||||
if (existing !== undefined && existing.expiresAt > Date.now() && same(existing.source, info)) return
|
||||
const request = (operation: string) =>
|
||||
fetch(new URL(`/api/experimental/persistent-pty/${operation}`, info.url), {
|
||||
method: "POST",
|
||||
headers:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { authorization: "Basic " + Buffer.from(`opencode:${info.password}`).toString("base64") },
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
})
|
||||
const result = await request("prepare-restart").then(
|
||||
(response) => ({ response }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
)
|
||||
if ("cause" in result) {
|
||||
// Another caller may already have prepared and stopped this server.
|
||||
const concurrent = await read(file)
|
||||
if (concurrent !== undefined && concurrent.expiresAt > Date.now() && same(concurrent.source, info)) return
|
||||
throw new Error("Failed to prepare persistent terminals for service replacement", { cause: result.cause })
|
||||
}
|
||||
if (result.response.status === 404) {
|
||||
console.warn("Background service cannot hand off persistent terminals; shutting them down before replacement")
|
||||
const shutdown = await request("shutdown")
|
||||
if (!shutdown.ok && shutdown.status !== 404)
|
||||
throw new Error(`Failed to shut down persistent terminals before service replacement: HTTP ${shutdown.status}`)
|
||||
await publish(file, info, null)
|
||||
return
|
||||
}
|
||||
if (!result.response.ok)
|
||||
throw new Error(`Failed to prepare persistent terminals for service replacement: HTTP ${result.response.status}`)
|
||||
const body: unknown = await result.response.json()
|
||||
if (typeof body !== "object" || body === null || !("handoff" in body))
|
||||
throw new Error("Invalid persistent terminal handoff response")
|
||||
if (body.handoff === null) {
|
||||
await publish(file, info, null)
|
||||
return
|
||||
}
|
||||
if (!isHandoff(body.handoff) || body.handoff.expiresAt <= Date.now())
|
||||
throw new Error("Invalid or expired persistent terminal handoff")
|
||||
await publish(file, info, body.handoff)
|
||||
}
|
||||
|
||||
async function publish(file: string, info: Info, handoff: PersistentPty.Handoff | null) {
|
||||
const temporary = `${file}.pty-handoff.${crypto.randomUUID()}.tmp`
|
||||
await writeFile(
|
||||
temporary,
|
||||
JSON.stringify({
|
||||
source: { id: info.id, pid: info.pid, url: info.url },
|
||||
handoff,
|
||||
expiresAt: handoff?.expiresAt ?? Date.now() + 30_000,
|
||||
} satisfies Sidecar),
|
||||
{ mode: 0o600, flag: "wx" },
|
||||
)
|
||||
await rename(temporary, file + ".pty-handoff").finally(() => rm(temporary, { force: true }))
|
||||
}
|
||||
|
||||
export async function environment(file: string, env?: Readonly<Record<string, string>>) {
|
||||
const record = await read(file)
|
||||
const current: Info | undefined = await readFile(file, "utf8")
|
||||
.then((text) => JSON.parse(text))
|
||||
.catch(() => undefined)
|
||||
const handoff =
|
||||
record !== undefined && record.expiresAt > Date.now() && (current === undefined || same(record.source, current))
|
||||
? record.handoff
|
||||
: undefined
|
||||
return { ...env, OPENCODE_PTY_HANDOFF: handoff == null ? undefined : JSON.stringify(handoff) }
|
||||
}
|
||||
|
||||
export async function complete(file: string, info: Info) {
|
||||
const record = await read(file)
|
||||
if (record !== undefined && !same(record.source, info)) await clear(file)
|
||||
}
|
||||
|
||||
export async function clear(file: string) {
|
||||
await rm(file + ".pty-handoff", { force: true })
|
||||
}
|
||||
|
||||
async function read(file: string): Promise<Sidecar | undefined> {
|
||||
const value: unknown = await readFile(file + ".pty-handoff", "utf8")
|
||||
.then((text) => JSON.parse(text))
|
||||
.catch(() => undefined)
|
||||
if (typeof value !== "object" || value === null || !("source" in value) || !("handoff" in value)) return
|
||||
if (typeof value.source !== "object" || value.source === null) return
|
||||
if (!("pid" in value.source) || typeof value.source.pid !== "number") return
|
||||
if (!("url" in value.source) || typeof value.source.url !== "string") return
|
||||
if ("id" in value.source && typeof value.source.id !== "string") return
|
||||
if (value.handoff !== null && !isHandoff(value.handoff)) return
|
||||
if (!("expiresAt" in value) || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) return
|
||||
return {
|
||||
source: {
|
||||
id: "id" in value.source && typeof value.source.id === "string" ? value.source.id : undefined,
|
||||
pid: value.source.pid,
|
||||
url: value.source.url,
|
||||
},
|
||||
handoff: value.handoff,
|
||||
expiresAt: value.expiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
function same(left: Sidecar["source"], right: Info) {
|
||||
return left.id === right.id && left.pid === right.pid && left.url === right.url
|
||||
}
|
||||
|
||||
function isHandoff(value: unknown): value is PersistentPty.Handoff {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"directory" in value &&
|
||||
typeof value.directory === "string" &&
|
||||
"instanceID" in value &&
|
||||
typeof value.instanceID === "string" &&
|
||||
"ticket" in value &&
|
||||
typeof value.ticket === "string" &&
|
||||
"expiresAt" in value &&
|
||||
typeof value.expiresAt === "number" &&
|
||||
Number.isFinite(value.expiresAt)
|
||||
)
|
||||
}
|
||||
@@ -11,8 +11,17 @@ if (mode === "record-start") {
|
||||
await writeFile(registration + ".started", "")
|
||||
process.exit(1)
|
||||
}
|
||||
if (mode === "environment")
|
||||
await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
|
||||
if (mode === "environment") await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
|
||||
if (mode === "environment" || mode === "handoff-loser" || mode === "handoff-winner")
|
||||
await appendFile(
|
||||
registration + ".handoffs",
|
||||
JSON.stringify({ handoff: process.env.OPENCODE_PTY_HANDOFF ?? null }) + "\n",
|
||||
)
|
||||
if (mode === "handoff-loser") {
|
||||
await writeFile(registration + ".loser", "")
|
||||
while (!(await Bun.file(registration + ".winner").exists())) await Bun.sleep(5)
|
||||
process.exit(0)
|
||||
}
|
||||
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
|
||||
|
||||
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
|
||||
@@ -30,14 +39,35 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
|
||||
|
||||
let requests = 0
|
||||
let version = "test"
|
||||
if (mode === "old") version = "old"
|
||||
if (mode === "old" || mode === "handoff" || mode === "handoff-null" || mode === "handoff-failed") version = "old"
|
||||
if (mode === "incompatible") version = "1.9.0"
|
||||
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
|
||||
const id = crypto.randomUUID()
|
||||
const handoff = {
|
||||
directory: registration + ".daemon",
|
||||
instanceID: crypto.randomUUID(),
|
||||
ticket: crypto.randomUUID(),
|
||||
expiresAt: Date.now() + 30_000,
|
||||
}
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
if (pathname === "/api/experimental/persistent-pty/prepare-restart") {
|
||||
await appendFile(registration + ".pty-requests", "prepare\n")
|
||||
if (request.method !== "POST" || request.headers.get("authorization") !== "Basic " + btoa("opencode:private"))
|
||||
return new Response(null, { status: 401 })
|
||||
if (mode === "handoff-failed") return new Response(null, { status: 500 })
|
||||
if (mode === "handoff" || mode === "handoff-null") {
|
||||
await writeFile(registration + ".prepared", JSON.stringify(mode === "handoff-null" ? null : handoff))
|
||||
return Response.json({ handoff: mode === "handoff-null" ? null : handoff })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
}
|
||||
if (pathname === "/api/experimental/persistent-pty/shutdown") {
|
||||
await appendFile(registration + ".pty-requests", "shutdown\n")
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "starting") await writeFile(registration + ".health-request", "")
|
||||
@@ -54,8 +84,7 @@ const server = Bun.serve({
|
||||
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
||||
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
|
||||
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
||||
if (mode === "starting" || mode === "graceful")
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
if (mode === "starting" || mode === "graceful") return Response.json({ healthy: true, version, pid: process.pid })
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
},
|
||||
})
|
||||
@@ -67,12 +96,16 @@ await writeFile(
|
||||
version: mode === "legacy" ? undefined : version,
|
||||
url: server.url.toString(),
|
||||
pid: process.pid,
|
||||
password: "private",
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
await rename(registration + ".tmp", registration)
|
||||
if (mode === "handoff-winner") await writeFile(registration + ".winner", "")
|
||||
|
||||
async function shutdown(signal?: NodeJS.Signals) {
|
||||
if (mode === "handoff" || mode === "handoff-null")
|
||||
await writeFile(registration + ".handoff-at-stop", await Bun.file(registration + ".pty-handoff").text())
|
||||
if (signal !== undefined) await writeFile(registration + ".signal", signal)
|
||||
server.stop(true)
|
||||
process.exit()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Service, type EnsureReason } from "../src/promise/service"
|
||||
import { ServiceHandoff } from "../src/service-handoff"
|
||||
import { accelerate, waitForExit } from "./fixture/service-timing"
|
||||
|
||||
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||
@@ -79,6 +80,38 @@ test("adds configured environment variables with native promises", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["handoff", "handoff-null", "old"])("replaces %s with the acknowledged terminal policy", async (mode) => {
|
||||
const registration = await setup(mode)
|
||||
await ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "environment"],
|
||||
env: { OPENCODE_PTY_HANDOFF: "must-not-inherit" },
|
||||
})
|
||||
const replacement = await Bun.file(registration).json()
|
||||
try {
|
||||
const captured = JSON.parse((await Bun.file(registration + ".handoffs").text()).trim()).handoff
|
||||
expect(captured === null ? null : JSON.parse(captured)).toEqual(
|
||||
mode === "old" ? null : await Bun.file(registration + ".prepared").json(),
|
||||
)
|
||||
expect(await Bun.file(registration + ".pty-requests").text()).toBe(
|
||||
mode === "old" ? "prepare\nshutdown\n" : "prepare\n",
|
||||
)
|
||||
expect(await Bun.file(registration + ".pty-handoff").exists()).toBe(false)
|
||||
} finally {
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
}
|
||||
})
|
||||
|
||||
test("does not terminate a healthy server when handoff preparation fails", async () => {
|
||||
const registration = await setup("handoff-failed")
|
||||
await expect(ensure({ file: registration, version: "test", command: [] })).rejects.toThrow(
|
||||
"Failed to prepare persistent terminals for service replacement: HTTP 500",
|
||||
)
|
||||
expect(await Bun.file(registration + ".signal").exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("waits for a live contender when another native contender fails", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
@@ -148,11 +181,25 @@ test("evicts an unresponsive registered service before starting its replacement"
|
||||
|
||||
test("signals the registered service process", async () => {
|
||||
const registration = await setup("graceful")
|
||||
const source = await Bun.file(registration).json()
|
||||
const handoff = { directory: "unused", instanceID: "daemon", ticket: "ticket", expiresAt: Date.now() + 30_000 }
|
||||
await writeFile(registration + ".pty-handoff", JSON.stringify({ source, handoff, expiresAt: 0 }))
|
||||
expect((await ServiceHandoff.environment(registration)).OPENCODE_PTY_HANDOFF).toBeUndefined()
|
||||
await writeFile(
|
||||
registration + ".pty-handoff",
|
||||
JSON.stringify({
|
||||
source: { ...source, id: "another-server" },
|
||||
handoff,
|
||||
expiresAt: handoff.expiresAt,
|
||||
}),
|
||||
)
|
||||
expect((await ServiceHandoff.environment(registration)).OPENCODE_PTY_HANDOFF).toBeUndefined()
|
||||
|
||||
await Service.stop({ file: registration })
|
||||
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
expect(await Bun.file(registration + ".pty-handoff").exists()).toBe(false)
|
||||
})
|
||||
|
||||
async function setup(mode: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Service, type EnsureReason } from "../src/effect/service"
|
||||
@@ -115,6 +115,43 @@ test("replaces an incompatible registered service", async () => {
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("shares a prepared handoff when another client's contender wins replacement", async () => {
|
||||
const promise = await import("../src/promise/service")
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "handoff")
|
||||
await waitForFile(registration)
|
||||
const first = run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "handoff-loser"],
|
||||
}),
|
||||
)
|
||||
await waitForFile(registration + ".loser")
|
||||
if (process.platform !== "win32") expect((await stat(registration + ".pty-handoff")).mode & 0o777).toBe(0o600)
|
||||
const second = accelerate(promise.ensure)({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "handoff-winner"],
|
||||
})
|
||||
const endpoints = await Promise.all([first, second])
|
||||
const replacement = await Bun.file(registration).json()
|
||||
try {
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(endpoints[0]).toEqual(endpoints[1])
|
||||
const handoff = await Bun.file(registration + ".prepared").json()
|
||||
expect((await Bun.file(registration + ".handoff-at-stop").json()).handoff).toEqual(handoff)
|
||||
const contenders = (await Bun.file(registration + ".handoffs").text()).trim().split("\n")
|
||||
expect(contenders.length).toBeGreaterThanOrEqual(2)
|
||||
contenders.forEach((line) => expect(JSON.parse(JSON.parse(line).handoff)).toEqual(handoff))
|
||||
expect(await Bun.file(registration + ".pty-handoff").exists()).toBe(false)
|
||||
} finally {
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
}
|
||||
})
|
||||
|
||||
test("waits for a registered service to finish starting", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
@@ -3,8 +3,9 @@ import { readFile } from "node:fs/promises"
|
||||
import net from "node:net"
|
||||
import path from "node:path"
|
||||
import { Data, Duration, Effect, Schema, Semaphore } from "effect"
|
||||
import type { Handoff } from "@opencode-ai/schema/persistent-pty"
|
||||
|
||||
const ProtocolVersion = 6
|
||||
const ProtocolVersion = 7
|
||||
const MaxFrameBytes = 8 * 1024 * 1024
|
||||
|
||||
const Lifecycle = Schema.Union([
|
||||
@@ -48,6 +49,8 @@ export const WireResponse = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
|
||||
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
|
||||
Schema.Struct({ type: Schema.Literal("ok") }),
|
||||
Schema.Struct({ type: Schema.Literal("owned") }),
|
||||
Schema.Struct({ type: Schema.Literal("handoff"), ticket: Schema.String, expires_at: Schema.Number }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("snapshot"),
|
||||
terminal: WireTerminal,
|
||||
@@ -131,6 +134,7 @@ export interface DaemonTransport {
|
||||
readonly request: (value: object, start?: boolean) => Effect.Effect<WireResponse, DaemonError>
|
||||
readonly requestIfRunning: (value: object) => Effect.Effect<WireResponse | undefined, DaemonError>
|
||||
readonly shutdown: Effect.Effect<WireResponse | undefined, DaemonError>
|
||||
readonly prepareRestart: Effect.Effect<Handoff | null, DaemonError>
|
||||
readonly subscribe: (
|
||||
id: number,
|
||||
input: {
|
||||
@@ -147,19 +151,51 @@ export interface DaemonTransport {
|
||||
export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport")(function* (
|
||||
directory: string,
|
||||
binary: () => Promise<string> = () => Promise.resolve(process.env.OPENCODE_PTY_BIN || "opencode-pty"),
|
||||
handoff?: Handoff,
|
||||
) {
|
||||
const startup = Semaphore.makeUnsafe(1)
|
||||
let registration: Registration | undefined
|
||||
let owner: Awaited<ReturnType<typeof openOwner>> | undefined
|
||||
let closed = false
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
closed = true
|
||||
owner?.socket.destroy()
|
||||
}),
|
||||
)
|
||||
|
||||
const claim = (current: Registration, ticket?: string) =>
|
||||
Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const connection = await openOwner(current, ticket, signal)
|
||||
if (closed || signal.aborted) {
|
||||
connection.socket.destroy()
|
||||
throw new Error("PTY owner scope is closed")
|
||||
}
|
||||
owner = connection
|
||||
registration = current
|
||||
connection.socket.once("close", () => {
|
||||
if (owner !== connection) return
|
||||
owner = undefined
|
||||
registration = undefined
|
||||
})
|
||||
},
|
||||
catch: (cause) => failure("connect", cause),
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(5),
|
||||
orElse: () => Effect.fail(new DaemonError({ kind: "connect", message: "PTY ownership claim timed out" })),
|
||||
}),
|
||||
)
|
||||
|
||||
const discover = Effect.fn("PersistentPty.daemon.discover")(function* () {
|
||||
const value = yield* Effect.tryPromise({
|
||||
try: () => readFile(path.join(directory, "service.json"), "utf8"),
|
||||
catch: (cause) => failure("connect", cause),
|
||||
})
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => Schema.decodeUnknownSync(Registration)(JSON.parse(value)),
|
||||
catch: (cause) => failure("protocol", cause),
|
||||
})
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))(value).pipe(
|
||||
Effect.mapError((cause) => failure("protocol", cause)),
|
||||
)
|
||||
if (decoded.protocol !== ProtocolVersion)
|
||||
return yield* Effect.fail(
|
||||
new DaemonError({
|
||||
@@ -181,9 +217,9 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
|
||||
|
||||
const start = Effect.fn("PersistentPty.daemon.start")(function* () {
|
||||
const executable = yield* Effect.tryPromise({ try: binary, catch: (cause) => failure("spawn", cause) })
|
||||
yield* Effect.tryPromise({
|
||||
const child = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
new Promise<ReturnType<typeof spawn>>((resolve, reject) => {
|
||||
const child = spawn(executable, ["daemon"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
@@ -191,43 +227,54 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
resolve(child)
|
||||
})
|
||||
child.once("error", reject)
|
||||
}),
|
||||
catch: (cause) => failure("spawn", cause),
|
||||
})
|
||||
const deadline = Date.now() + 5_000
|
||||
let last: DaemonError | undefined
|
||||
while (Date.now() < deadline) {
|
||||
const found = yield* discover().pipe(
|
||||
Effect.map((value) => ({ value })),
|
||||
Effect.catch((error) => {
|
||||
last = error
|
||||
return Effect.succeed(undefined)
|
||||
}),
|
||||
return yield* Effect.gen(function* () {
|
||||
const deadline = Date.now() + 5_000
|
||||
let last: DaemonError | undefined
|
||||
while (Date.now() < deadline) {
|
||||
const found = yield* discover().pipe(
|
||||
Effect.map((value) => ({ value })),
|
||||
Effect.catch((error) => {
|
||||
last = error
|
||||
return Effect.succeed(undefined)
|
||||
}),
|
||||
)
|
||||
if (found) {
|
||||
yield* claim(found.value)
|
||||
return found.value
|
||||
}
|
||||
yield* Effect.sleep(50)
|
||||
}
|
||||
return yield* Effect.fail(
|
||||
last ?? new DaemonError({ kind: "connect", message: "opencode-pty did not become ready" }),
|
||||
)
|
||||
if (found) return found.value
|
||||
yield* Effect.sleep(50)
|
||||
}
|
||||
return yield* Effect.fail(
|
||||
last ?? new DaemonError({ kind: "connect", message: "opencode-pty did not become ready" }),
|
||||
}).pipe(
|
||||
Effect.onError(() =>
|
||||
Effect.sync(() => {
|
||||
child.kill("SIGTERM")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const connect = Effect.fn("PersistentPty.daemon.connect")(function* (shouldStart: boolean) {
|
||||
if (closed) return yield* Effect.fail(new DaemonError({ kind: "connect", message: "PTY owner scope is closed" }))
|
||||
if (registration) return registration
|
||||
return yield* startup.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (registration) return registration
|
||||
const found = yield* discover().pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!shouldStart) return Effect.fail(error)
|
||||
if (error.kind === "connect") return start()
|
||||
if (error.kind !== "protocol" || error.pid === undefined) return Effect.fail(error)
|
||||
return terminate(error.pid).pipe(Effect.andThen(start()))
|
||||
if (shouldStart && error.kind === "connect") return start()
|
||||
return Effect.fail(error)
|
||||
}),
|
||||
)
|
||||
if (!owner) yield* claim(found)
|
||||
registration = found
|
||||
return found
|
||||
}),
|
||||
@@ -277,6 +324,27 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
|
||||
return yield* Effect.fail(new DaemonError({ kind: "connect", message: "opencode-pty did not stop" }))
|
||||
})
|
||||
|
||||
const prepareRestart = startup.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = owner
|
||||
const registered = registration
|
||||
if (!current || !registered) return null
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: (signal) => current.exchange({ op: "prepare_handoff" }, signal),
|
||||
catch: (cause) => failure("response", cause),
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(5),
|
||||
orElse: () =>
|
||||
Effect.fail(new DaemonError({ kind: "response", message: "PTY handoff preparation timed out" })),
|
||||
}),
|
||||
)
|
||||
if (response.type !== "handoff")
|
||||
return yield* Effect.fail(new DaemonError({ kind: "protocol", message: "Expected PTY handoff ticket" }))
|
||||
return { directory, instanceID: registered.instance_id, ticket: response.ticket, expiresAt: response.expires_at }
|
||||
}),
|
||||
)
|
||||
|
||||
const subscribe = Effect.fn("PersistentPty.daemon.subscribe")(function* (
|
||||
id: number,
|
||||
input: Parameters<DaemonTransport["subscribe"]>[1],
|
||||
@@ -296,9 +364,56 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
|
||||
return yield* attempt.pipe(Effect.catch((error) => (error.kind === "registration" ? attempt : Effect.fail(error))))
|
||||
})
|
||||
|
||||
return { request, requestIfRunning, shutdown, subscribe } satisfies DaemonTransport
|
||||
// A replacement must own its inherited daemon before the server becomes ready.
|
||||
if (handoff) {
|
||||
if (handoff.expiresAt <= Date.now())
|
||||
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart handoff expired" }))
|
||||
const current = yield* discover()
|
||||
if (current.instance_id !== handoff.instanceID)
|
||||
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart daemon changed" }))
|
||||
yield* claim(current, handoff.ticket)
|
||||
}
|
||||
|
||||
return { request, requestIfRunning, shutdown, prepareRestart, subscribe } satisfies DaemonTransport
|
||||
})
|
||||
|
||||
async function openOwner(registration: Registration, ticket: string | undefined, signal: AbortSignal) {
|
||||
const socket = net.createConnection({ path: registration.socket })
|
||||
const frames = decoder(socket)
|
||||
const abort = () => socket.destroy()
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
const exchange = async (request: object, signal: AbortSignal) => {
|
||||
if (signal.aborted) throw new Error("PTY ownership request interrupted")
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
try {
|
||||
socket.write(encode({ token: registration.token, request }))
|
||||
const frame = await frames.next()
|
||||
if (frame.done) throw new Error("PTY daemon closed its ownership connection")
|
||||
const response = decode(frame.value)
|
||||
if (response.type === "error") throw new Error(response.message)
|
||||
return response
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", resolve)
|
||||
socket.once("error", reject)
|
||||
socket.once("close", () => reject(new Error("PTY ownership connection closed")))
|
||||
})
|
||||
const response = await exchange({ op: "own", instance_id: registration.instance_id, ticket }, signal)
|
||||
if (response.type !== "owned") throw new Error("PTY daemon does not support server ownership")
|
||||
socket.unref()
|
||||
return { socket, exchange }
|
||||
} catch (error) {
|
||||
socket.destroy()
|
||||
throw error
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort)
|
||||
}
|
||||
}
|
||||
|
||||
const oneShot = Effect.fn("PersistentPty.daemon.oneShot")(function* (registration: Registration, request: object) {
|
||||
const payload = yield* Effect.try({
|
||||
try: () => encode({ token: registration.token, request }),
|
||||
@@ -507,29 +622,3 @@ function decode(payload: Uint8Array) {
|
||||
function failure(kind: DaemonError["kind"], cause: unknown) {
|
||||
return new DaemonError({ kind, message: cause instanceof Error ? cause.message : String(cause) })
|
||||
}
|
||||
|
||||
const terminate = Effect.fn("PersistentPty.daemon.terminate-incompatible")(function* (pid: number) {
|
||||
yield* Effect.logWarning("replacing incompatible opencode-pty daemon", { pid })
|
||||
yield* Effect.try({ try: () => process.kill(pid, "SIGTERM"), catch: (cause) => failure("spawn", cause) }).pipe(
|
||||
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
|
||||
)
|
||||
const deadline = Date.now() + 2_000
|
||||
while (Date.now() < deadline && processRunning(pid)) yield* Effect.sleep(25)
|
||||
if (!processRunning(pid)) return
|
||||
yield* Effect.try({ try: () => process.kill(pid, "SIGKILL"), catch: (cause) => failure("spawn", cause) }).pipe(
|
||||
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
|
||||
)
|
||||
})
|
||||
|
||||
function processRunning(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingProcess(error: DaemonError) {
|
||||
return error.message.includes("ESRCH") || error.message.includes("no such process")
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export * as PersistentPty from "./index.js"
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Added, Removed } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Added, Handoff, Removed } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../location.js"
|
||||
import { ShellSelect } from "../shell/select.js"
|
||||
import {
|
||||
makeDaemonTransport,
|
||||
type DaemonTransport,
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
import { resolveBinary } from "#persistent-pty-binary"
|
||||
|
||||
export type { Role, StreamEvent } from "./daemon.js"
|
||||
export { Handoff } from "@opencode-ai/schema/persistent-pty"
|
||||
|
||||
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export type Info = Pty.Info & {
|
||||
readonly sessionID: Session.ID
|
||||
@@ -66,15 +70,15 @@ export interface Interface {
|
||||
readonly create: (
|
||||
sessionID: Session.ID,
|
||||
input: {
|
||||
readonly command: string
|
||||
readonly command?: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
) => Effect.Effect<Info, UnavailableError>
|
||||
) => Effect.Effect<Info, UnavailableError, Location.Service | ShellSelect.Service>
|
||||
readonly write: (
|
||||
id: Pty.ID,
|
||||
data: string,
|
||||
@@ -102,6 +106,7 @@ export interface Interface {
|
||||
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
|
||||
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly shutdown: () => Effect.Effect<void, UnavailableError>
|
||||
readonly prepareRestart: () => Effect.Effect<Handoff | null, UnavailableError>
|
||||
readonly attach: (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
@@ -117,218 +122,240 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const database = yield* Database.Service
|
||||
const global = yield* Global.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
let binary: Promise<string> | undefined
|
||||
const daemon = yield* makeDaemonTransport(
|
||||
runtimeDirectory(databasePath(database.db)),
|
||||
() =>
|
||||
(binary ??= resolveBinary(global.bin).catch((error) => {
|
||||
binary = undefined
|
||||
throw error
|
||||
})),
|
||||
)
|
||||
const removing = new Set<Pty.ID>()
|
||||
export const configured = (options: Options = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const global = yield* Global.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
let binary: Promise<string> | undefined
|
||||
const daemon = yield* makeDaemonTransport(
|
||||
options.handoff?.directory ?? runtimeDirectory(),
|
||||
() =>
|
||||
(binary ??= resolveBinary(global.bin).catch((error) => {
|
||||
binary = undefined
|
||||
throw error
|
||||
})),
|
||||
options.handoff,
|
||||
).pipe(Effect.mapError(unavailable))
|
||||
const removing = new Set<Pty.ID>()
|
||||
|
||||
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
|
||||
const response = yield* optionalRequest(daemon, { op: "list" })
|
||||
if (!response) return []
|
||||
if (response.type !== "terminals") return yield* unexpected(response)
|
||||
return response.terminals
|
||||
.map(toInfo)
|
||||
.filter((terminal) => sessionID === undefined || terminal.sessionID === sessionID)
|
||||
})
|
||||
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
|
||||
const response = yield* optionalRequest(daemon, { op: "list" })
|
||||
if (!response) return []
|
||||
if (response.type !== "terminals") return yield* unexpected(response)
|
||||
return response.terminals
|
||||
.map(toInfo)
|
||||
.filter((terminal) => sessionID === undefined || terminal.sessionID === sessionID)
|
||||
})
|
||||
|
||||
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
|
||||
const found = (yield* list()).find((terminal) => terminal.id === id)
|
||||
if (!found) return yield* new NotFoundError({ ptyID: id })
|
||||
return found
|
||||
})
|
||||
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
|
||||
const found = (yield* list()).find((terminal) => terminal.id === id)
|
||||
if (!found) return yield* new NotFoundError({ ptyID: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("PersistentPty.create")(function* (
|
||||
sessionID: Session.ID,
|
||||
input: {
|
||||
readonly command: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
) {
|
||||
const response = yield* request(
|
||||
daemon,
|
||||
{
|
||||
op: "create",
|
||||
program: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
group_id: sessionID,
|
||||
env: input.env,
|
||||
cols: input.cols ?? 80,
|
||||
rows: input.rows ?? 24,
|
||||
const create = Effect.fn("PersistentPty.create")(function* (
|
||||
sessionID: Session.ID,
|
||||
input: {
|
||||
readonly command?: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
true,
|
||||
)
|
||||
if (response.type !== "created") return yield* unexpected(response)
|
||||
const terminal = toInfo(response.terminal)
|
||||
yield* bus.publish(Added, { sessionID, terminal })
|
||||
return terminal
|
||||
})
|
||||
|
||||
const write = Effect.fn("PersistentPty.write")(function* (id: Pty.ID, data: string, attachmentID?: string) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "write",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const resize = Effect.fn("PersistentPty.resize")(function* (
|
||||
id: Pty.ID,
|
||||
cols: number,
|
||||
rows: number,
|
||||
attachmentID?: string,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "resize",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const control = Effect.fn("PersistentPty.control")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "control",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const input = Effect.fn("PersistentPty.input")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
data: Uint8Array,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "input",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, { op: "snapshot", id: fromID(id) })
|
||||
if (response.type !== "snapshot") return yield* unexpected(response)
|
||||
return {
|
||||
info: toInfo(response.terminal),
|
||||
text: response.text,
|
||||
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
|
||||
cursor: { x: response.cursor_x, y: response.cursor_y },
|
||||
}
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
|
||||
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
|
||||
if (!response) return
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
})
|
||||
|
||||
const removeVisibleExit = (id: Pty.ID) => {
|
||||
if (removing.has(id)) return
|
||||
removing.add(id)
|
||||
runFork(
|
||||
remove(id).pipe(
|
||||
Effect.catchTags({
|
||||
"PersistentPty.NotFoundError": () => Effect.void,
|
||||
"PersistentPty.UnavailableError": (error) =>
|
||||
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => removing.delete(id))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const attach = Effect.fn("PersistentPty.attach")(function* (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
) {
|
||||
yield* get(id)
|
||||
const attachment = yield* daemon
|
||||
.subscribe(fromID(id), {
|
||||
...input,
|
||||
onEvent: (event) => {
|
||||
if (event.type === "exited") removeVisibleExit(id)
|
||||
input.onEvent(event)
|
||||
) {
|
||||
const location = yield* Location.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const command = input.command ?? (yield* shell.resolve({ priority: "config" }))
|
||||
const response = yield* request(
|
||||
daemon,
|
||||
{
|
||||
op: "create",
|
||||
program: command,
|
||||
args: input.args,
|
||||
cwd: input.cwd ?? location.directory,
|
||||
title: input.title,
|
||||
group_id: sessionID,
|
||||
env: input.env,
|
||||
cols: input.cols ?? 80,
|
||||
rows: input.rows ?? 24,
|
||||
},
|
||||
true,
|
||||
)
|
||||
if (response.type !== "created") return yield* unexpected(response)
|
||||
const terminal = toInfo(response.terminal)
|
||||
yield* bus.publish(Added, { sessionID, terminal })
|
||||
return terminal
|
||||
})
|
||||
|
||||
const write = Effect.fn("PersistentPty.write")(function* (id: Pty.ID, data: string, attachmentID?: string) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "write",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
.pipe(Effect.mapError(unavailable))
|
||||
return {
|
||||
info: toInfo(attachment.terminal),
|
||||
role: attachment.role,
|
||||
generation: attachment.generation,
|
||||
replay: attachment.replay,
|
||||
activate: attachment.activate,
|
||||
detach: attachment.detach,
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const resize = Effect.fn("PersistentPty.resize")(function* (
|
||||
id: Pty.ID,
|
||||
cols: number,
|
||||
rows: number,
|
||||
attachmentID?: string,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "resize",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const control = Effect.fn("PersistentPty.control")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "control",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const input = Effect.fn("PersistentPty.input")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
data: Uint8Array,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "input",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, { op: "snapshot", id: fromID(id) })
|
||||
if (response.type !== "snapshot") return yield* unexpected(response)
|
||||
return {
|
||||
info: toInfo(response.terminal),
|
||||
text: response.text,
|
||||
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
|
||||
cursor: { x: response.cursor_x, y: response.cursor_y },
|
||||
}
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
|
||||
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
|
||||
if (!response) return
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
})
|
||||
|
||||
const prepareRestart = Effect.fn("PersistentPty.prepareRestart")(() =>
|
||||
daemon.prepareRestart.pipe(Effect.mapError(unavailable)),
|
||||
)
|
||||
|
||||
const removeVisibleExit = (id: Pty.ID) => {
|
||||
if (removing.has(id)) return
|
||||
removing.add(id)
|
||||
runFork(
|
||||
remove(id).pipe(
|
||||
Effect.catchTags({
|
||||
"PersistentPty.NotFoundError": () => Effect.void,
|
||||
"PersistentPty.UnavailableError": (error) =>
|
||||
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => removing.delete(id))),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ list, get, create, write, resize, control, input, snapshot, remove, shutdown, attach })
|
||||
}),
|
||||
)
|
||||
const attach = Effect.fn("PersistentPty.attach")(function* (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
) {
|
||||
yield* get(id)
|
||||
const attachment = yield* daemon
|
||||
.subscribe(fromID(id), {
|
||||
...input,
|
||||
onEvent: (event) => {
|
||||
if (event.type === "exited") removeVisibleExit(id)
|
||||
input.onEvent(event)
|
||||
},
|
||||
})
|
||||
.pipe(Effect.mapError(unavailable))
|
||||
return {
|
||||
info: toInfo(attachment.terminal),
|
||||
role: attachment.role,
|
||||
generation: attachment.generation,
|
||||
replay: attachment.replay,
|
||||
activate: attachment.activate,
|
||||
detach: attachment.detach,
|
||||
}
|
||||
})
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node, Global.node] })
|
||||
return Service.of({
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
write,
|
||||
resize,
|
||||
control,
|
||||
input,
|
||||
snapshot,
|
||||
remove,
|
||||
shutdown,
|
||||
prepareRestart,
|
||||
attach,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = configured()
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Global.node] })
|
||||
|
||||
const request = (daemon: DaemonTransport, value: object, start = false) =>
|
||||
daemon.request(value, start).pipe(Effect.mapError(unavailable))
|
||||
@@ -342,17 +369,7 @@ const unexpected = (response: WireResponse) =>
|
||||
const unavailable = (error: unknown) =>
|
||||
new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
|
||||
|
||||
function databasePath(db: Database.Interface["db"]) {
|
||||
const client: unknown = db.$client
|
||||
if ((typeof client !== "object" && typeof client !== "function") || client === null || !("config" in client))
|
||||
return undefined
|
||||
const config = client.config
|
||||
if (typeof config !== "object" || config === null || !("filename" in config)) return undefined
|
||||
if (typeof config.filename !== "string" || config.filename === ":memory:") return undefined
|
||||
return path.resolve(config.filename)
|
||||
}
|
||||
|
||||
const runtimeDirectory = (databasePath?: string) => {
|
||||
const runtimeDirectory = () => {
|
||||
const root =
|
||||
process.env.OPENCODE_PTY_RUNTIME_DIR ??
|
||||
(process.env.XDG_RUNTIME_DIR
|
||||
@@ -361,8 +378,7 @@ const runtimeDirectory = (databasePath?: string) => {
|
||||
os.tmpdir(),
|
||||
`opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`,
|
||||
))
|
||||
const identity = databasePath ?? `memory:${crypto.randomUUID()}`
|
||||
return path.join(root, createHash("sha256").update(identity).digest("hex").slice(0, 16))
|
||||
return path.join(root, crypto.randomUUID())
|
||||
}
|
||||
|
||||
function toInfo(value: WireTerminal): Info {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { expect } from "bun:test"
|
||||
import { spawn } from "node:child_process"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import net from "node:net"
|
||||
@@ -6,198 +6,267 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import { makeDaemonTransport } from "../src/persistent-pty/daemon"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const pong = { type: "pong", instance_id: "test", pid: process.pid, protocol: 6 }
|
||||
const pong = { type: "pong", instance_id: "test", pid: process.pid, protocol: 7 }
|
||||
|
||||
test("rediscovers a same-protocol daemon after its registration rotates", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-registration-"))
|
||||
const socketPath = path.join(directory, "daemon.sock")
|
||||
let token = "old-token"
|
||||
let instance = "old-instance"
|
||||
let creates = 0
|
||||
const server = await listen(socketPath, (_socket, request, receivedToken) => {
|
||||
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
|
||||
if (request.op === "ping") return { ...pong, instance_id: instance }
|
||||
if (request.op === "create") creates++
|
||||
return request.op === "list" ? { type: "terminals", terminals: [] } : { type: "ok" }
|
||||
})
|
||||
try {
|
||||
await writeRegistration(directory, socketPath, instance, token)
|
||||
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
|
||||
await Effect.runPromise(daemon.request({ op: "list" }))
|
||||
it.live("rediscovers a same-protocol daemon after its registration rotates", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* temporaryDirectory()
|
||||
const socketPath = path.join(directory, "daemon.sock")
|
||||
let token = "old-token"
|
||||
let instance = "old-instance"
|
||||
let creates = 0
|
||||
yield* listen(socketPath, (_socket, request, receivedToken) => {
|
||||
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
|
||||
if (request.op === "ping") return { ...pong, instance_id: instance }
|
||||
if (request.op === "create") creates++
|
||||
return request.op === "list" ? { type: "terminals", terminals: [] } : { type: "ok" }
|
||||
})
|
||||
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
|
||||
const daemon = yield* makeDaemonTransport(directory)
|
||||
yield* daemon.request({ op: "list" })
|
||||
|
||||
token = "new-token"
|
||||
instance = "new-instance"
|
||||
await writeRegistration(directory, socketPath, instance, token)
|
||||
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
|
||||
|
||||
const running = await Effect.runPromise(daemon.requestIfRunning({ op: "list" }))
|
||||
const running = yield* daemon.requestIfRunning({ op: "list" })
|
||||
expect(running).toEqual({ type: "terminals", terminals: [] })
|
||||
|
||||
token = "newest-token"
|
||||
instance = "newest-instance"
|
||||
await writeRegistration(directory, socketPath, instance, token)
|
||||
await Effect.runPromise(daemon.request({ op: "create" }, true))
|
||||
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
|
||||
yield* daemon.request({ op: "create" }, true)
|
||||
expect(creates).toBe(1)
|
||||
} finally {
|
||||
await close(server)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("rediscovers a rotated registration when acquiring a subscription", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-subscription-"))
|
||||
const socketPath = path.join(directory, "daemon.sock")
|
||||
let token = "old-token"
|
||||
let instance = "old-instance"
|
||||
let subscriptions = 0
|
||||
const server = await listen(socketPath, (_socket, request, receivedToken) => {
|
||||
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
|
||||
if (request.op === "ping") return { ...pong, instance_id: instance }
|
||||
if (request.op !== "subscribe") return { type: "terminals", terminals: [] }
|
||||
subscriptions++
|
||||
return {
|
||||
type: "attached",
|
||||
terminal: terminal(1),
|
||||
role: "observer",
|
||||
generation: 1,
|
||||
requested_offset: 0,
|
||||
available_offset: 0,
|
||||
end_offset: 0,
|
||||
truncated: false,
|
||||
replay_base64: "",
|
||||
}
|
||||
})
|
||||
try {
|
||||
await writeRegistration(directory, socketPath, instance, token)
|
||||
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
|
||||
await Effect.runPromise(daemon.request({ op: "list" }))
|
||||
it.live("rediscovers a rotated registration when acquiring a subscription", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* temporaryDirectory()
|
||||
const socketPath = path.join(directory, "daemon.sock")
|
||||
let token = "old-token"
|
||||
let instance = "old-instance"
|
||||
let subscriptions = 0
|
||||
yield* listen(socketPath, (_socket, request, receivedToken) => {
|
||||
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
|
||||
if (request.op === "ping") return { ...pong, instance_id: instance }
|
||||
if (request.op !== "subscribe") return { type: "terminals", terminals: [] }
|
||||
subscriptions++
|
||||
return {
|
||||
type: "attached",
|
||||
terminal: terminal(1),
|
||||
role: "observer",
|
||||
generation: 1,
|
||||
requested_offset: 0,
|
||||
available_offset: 0,
|
||||
end_offset: 0,
|
||||
truncated: false,
|
||||
replay_base64: "",
|
||||
}
|
||||
})
|
||||
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
|
||||
const daemon = yield* makeDaemonTransport(directory)
|
||||
yield* daemon.request({ op: "list" })
|
||||
|
||||
token = "new-token"
|
||||
instance = "new-instance"
|
||||
await writeRegistration(directory, socketPath, instance, token)
|
||||
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
|
||||
|
||||
const attachment = await Effect.runPromise(
|
||||
daemon.subscribe(1, {
|
||||
cursor: 0,
|
||||
attachmentID: "attachment",
|
||||
role: "observer",
|
||||
onEvent: () => {},
|
||||
onEnd: () => {},
|
||||
}),
|
||||
)
|
||||
const attachment = yield* daemon.subscribe(1, {
|
||||
cursor: 0,
|
||||
attachmentID: "attachment",
|
||||
role: "observer",
|
||||
onEvent: () => {},
|
||||
onEnd: () => {},
|
||||
})
|
||||
expect(attachment.terminal.id).toBe(1)
|
||||
expect(subscriptions).toBe(1)
|
||||
attachment.detach()
|
||||
} finally {
|
||||
await close(server)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("retries a start-required request when connection fails before dispatch", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-retry-"))
|
||||
const firstSocket = path.join(directory, "first.sock")
|
||||
const secondSocket = path.join(directory, "second.sock")
|
||||
const first = await listen(firstSocket, (_socket, request) => {
|
||||
if (request.op === "ping") return pong
|
||||
return { type: "terminals", terminals: [] }
|
||||
})
|
||||
try {
|
||||
await writeRegistration(directory, firstSocket)
|
||||
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
|
||||
await Effect.runPromise(daemon.request({ op: "list" }))
|
||||
await close(first)
|
||||
it.live("retries a start-required request when connection fails before dispatch", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* temporaryDirectory()
|
||||
const firstSocket = path.join(directory, "first.sock")
|
||||
const secondSocket = path.join(directory, "second.sock")
|
||||
const first = yield* listen(firstSocket, (_socket, request) => {
|
||||
if (request.op === "ping") return pong
|
||||
return { type: "terminals", terminals: [] }
|
||||
})
|
||||
yield* Effect.promise(() => writeRegistration(directory, firstSocket))
|
||||
const daemon = yield* makeDaemonTransport(directory)
|
||||
yield* daemon.request({ op: "list" })
|
||||
yield* Effect.promise(first.close)
|
||||
|
||||
let creates = 0
|
||||
const second = await listen(secondSocket, (_socket, request) => {
|
||||
yield* listen(secondSocket, (_socket, request) => {
|
||||
if (request.op === "ping") return pong
|
||||
creates++
|
||||
return { type: "ok" }
|
||||
})
|
||||
try {
|
||||
await writeRegistration(directory, secondSocket)
|
||||
await Effect.runPromise(daemon.request({ op: "create" }, true))
|
||||
expect(creates).toBe(1)
|
||||
} finally {
|
||||
await close(second)
|
||||
}
|
||||
} finally {
|
||||
await close(first)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
yield* Effect.promise(() => writeRegistration(directory, secondSocket))
|
||||
yield* daemon.request({ op: "create" }, true)
|
||||
expect(creates).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
test("does not replay a dispatched mutating request when its response is lost", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-response-"))
|
||||
const socketPath = path.join(directory, "daemon.sock")
|
||||
let creates = 0
|
||||
const server = await listen(socketPath, (socket, request) => {
|
||||
if (request.op === "ping") return pong
|
||||
creates++
|
||||
socket.destroy()
|
||||
return undefined
|
||||
})
|
||||
try {
|
||||
await writeRegistration(directory, socketPath)
|
||||
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
|
||||
const error = await Effect.runPromise(Effect.flip(daemon.request({ op: "create" }, true)))
|
||||
it.live("does not replay a dispatched mutating request when its response is lost", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* temporaryDirectory()
|
||||
const socketPath = path.join(directory, "daemon.sock")
|
||||
let creates = 0
|
||||
yield* listen(socketPath, (socket, request) => {
|
||||
if (request.op === "ping") return pong
|
||||
creates++
|
||||
socket.destroy()
|
||||
return undefined
|
||||
})
|
||||
yield* Effect.promise(() => writeRegistration(directory, socketPath))
|
||||
const daemon = yield* makeDaemonTransport(directory)
|
||||
const error = yield* Effect.flip(daemon.request({ op: "create" }, true))
|
||||
|
||||
expect(error.kind).toBe("response")
|
||||
expect(creates).toBe(1)
|
||||
} finally {
|
||||
await close(server)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("reports protocol mismatches until a start-required request replaces the daemon", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-mismatch-"))
|
||||
const existing = spawn("sleep", ["30"])
|
||||
const exited = new Promise<void>((resolve) => existing.once("exit", () => resolve()))
|
||||
try {
|
||||
it.live("rejects incompatible daemons without replacing or killing them", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* temporaryDirectory()
|
||||
const existing = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => spawn("sleep", ["30"])),
|
||||
(child) =>
|
||||
Effect.promise(async () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return
|
||||
const exited = new Promise<void>((resolve) => child.once("exit", () => resolve()))
|
||||
child.kill("SIGKILL")
|
||||
await exited
|
||||
}),
|
||||
)
|
||||
if (existing.pid === undefined) throw new Error("Expected fixture process PID")
|
||||
await writeFile(
|
||||
path.join(directory, "service.json"),
|
||||
JSON.stringify({ instance_id: "old", pid: existing.pid, protocol: 5, socket: "/unused", token: "old" }),
|
||||
)
|
||||
const daemon = await Effect.runPromise(
|
||||
makeDaemonTransport(directory, () => Promise.resolve("/missing/opencode-pty")),
|
||||
yield* Effect.promise(() =>
|
||||
writeFile(
|
||||
path.join(directory, "service.json"),
|
||||
JSON.stringify({ instance_id: "old", pid: existing.pid, protocol: 6, socket: "/unused", token: "old" }),
|
||||
),
|
||||
)
|
||||
const daemon = yield* makeDaemonTransport(directory, () => Promise.resolve("/missing/opencode-pty"))
|
||||
|
||||
const optional = await Effect.runPromise(Effect.flip(daemon.requestIfRunning({ op: "list" })))
|
||||
const optional = yield* Effect.flip(daemon.requestIfRunning({ op: "list" }))
|
||||
expect(optional).toMatchObject({
|
||||
kind: "protocol",
|
||||
message: "opencode-pty protocol mismatch: daemon=5, client=6",
|
||||
message: "opencode-pty protocol mismatch: daemon=6, client=7",
|
||||
pid: existing.pid,
|
||||
})
|
||||
expect(existing.exitCode).toBeNull()
|
||||
|
||||
const starting = await Effect.runPromise(Effect.flip(daemon.request({ op: "create" }, true)))
|
||||
await exited
|
||||
expect(starting).toMatchObject({ kind: "spawn" })
|
||||
expect(existing.signalCode).toBe(process.platform === "win32" ? null : "SIGTERM")
|
||||
} finally {
|
||||
existing.kill("SIGKILL")
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
const starting = yield* Effect.flip(daemon.request({ op: "create" }, true))
|
||||
expect(starting).toEqual(optional)
|
||||
expect(existing.exitCode).toBeNull()
|
||||
expect(existing.signalCode).toBeNull()
|
||||
expect(process.kill(existing.pid, 0)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preparing a restart does not start an unused daemon", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* temporaryDirectory()
|
||||
const daemon = yield* makeDaemonTransport(directory, () => Promise.reject(new Error("must not spawn")))
|
||||
expect(yield* daemon.prepareRestart).toBeNull()
|
||||
expect(yield* daemon.requestIfRunning({ op: "list" })).toBeUndefined()
|
||||
expect(yield* daemon.prepareRestart).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prepares handoff on the owner connection and releases ownership with its scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* temporaryDirectory()
|
||||
const socketPath = path.join(directory, "daemon.sock")
|
||||
const expiresAt = Date.now() + 30_000
|
||||
let owner: net.Socket | undefined
|
||||
let ended = false
|
||||
yield* listen(
|
||||
socketPath,
|
||||
(socket, request) => {
|
||||
if (request.op === "ping") return pong
|
||||
if (request.op === "own") {
|
||||
owner = socket
|
||||
socket.once("end", () => {
|
||||
ended = true
|
||||
})
|
||||
return { type: "owned" }
|
||||
}
|
||||
if (request.op === "prepare_handoff") {
|
||||
expect(owner).toBe(socket)
|
||||
return { type: "handoff", ticket: "restart-ticket", expires_at: expiresAt }
|
||||
}
|
||||
return { type: "terminals", terminals: [] }
|
||||
},
|
||||
false,
|
||||
)
|
||||
yield* Effect.promise(() => writeRegistration(directory, socketPath))
|
||||
yield* Effect.gen(function* () {
|
||||
const daemon = yield* makeDaemonTransport(directory)
|
||||
yield* daemon.request({ op: "list" })
|
||||
expect(yield* daemon.prepareRestart).toEqual({
|
||||
directory,
|
||||
instanceID: "test",
|
||||
ticket: "restart-ticket",
|
||||
expiresAt,
|
||||
})
|
||||
expect(ended).toBeFalse()
|
||||
}).pipe(Effect.scoped)
|
||||
yield* Effect.promise(async () => {
|
||||
for (let attempt = 0; attempt < 100 && !ended; attempt++) await Bun.sleep(10)
|
||||
})
|
||||
expect(ended).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
function temporaryDirectory() {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-pty-test-"))),
|
||||
(directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
|
||||
)
|
||||
}
|
||||
|
||||
function listen(
|
||||
socketPath: string,
|
||||
handle: (socket: net.Socket, request: Record<string, unknown>, token: string) => object | undefined,
|
||||
autoOwn = true,
|
||||
) {
|
||||
const server = net.createServer((socket) => {
|
||||
void readRequest(socket)
|
||||
.then((envelope) => {
|
||||
const response = handle(socket, envelope.request, envelope.token)
|
||||
if (response) socket.write(frame(response))
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const sockets = new Set<net.Socket>()
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once("close", () => sockets.delete(socket))
|
||||
void (async () => {
|
||||
while (!socket.destroyed) {
|
||||
const envelope = await readRequest(socket)
|
||||
const response =
|
||||
autoOwn && envelope.request.op === "own"
|
||||
? { type: "owned" }
|
||||
: handle(socket, envelope.request, envelope.token)
|
||||
if (response) socket.write(frame(response))
|
||||
}
|
||||
})().catch(() => socket.destroy())
|
||||
})
|
||||
.catch(() => socket.destroy())
|
||||
})
|
||||
return new Promise<net.Server>((resolve, reject) => {
|
||||
server.once("error", reject)
|
||||
server.listen(socketPath, () => resolve(server))
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject)
|
||||
server.listen(socketPath, resolve)
|
||||
})
|
||||
return {
|
||||
close: () => {
|
||||
sockets.forEach((socket) => socket.destroy())
|
||||
return close(server)
|
||||
},
|
||||
}
|
||||
}),
|
||||
(server) => Effect.promise(server.close),
|
||||
)
|
||||
}
|
||||
|
||||
function readRequest(socket: net.Socket) {
|
||||
@@ -246,7 +315,7 @@ function frame(value: object) {
|
||||
function writeRegistration(directory: string, socket: string, instance = "test", token = "test") {
|
||||
return writeFile(
|
||||
path.join(directory, "service.json"),
|
||||
JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 6, socket, token }),
|
||||
JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 7, socket, token }),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { HealthGroup } from "./groups/health.js"
|
||||
import { ServerGroup } from "./groups/server.js"
|
||||
import { DebugGroup } from "./groups/debug.js"
|
||||
import { PtyGroup } from "./groups/pty.js"
|
||||
import { PersistentPtyGroup } from "./groups/persistent-pty.js"
|
||||
import { makePersistentPtyGroup } from "./groups/persistent-pty.js"
|
||||
import { ShellGroup } from "./groups/shell.js"
|
||||
import { ReferenceGroup } from "./groups/reference.js"
|
||||
import { Authorization } from "./middleware/authorization.js"
|
||||
@@ -89,7 +89,7 @@ type ApiGroups<
|
||||
| typeof WorktreeGroup
|
||||
| typeof WorkspaceGroup
|
||||
| typeof GenerateGroup
|
||||
| typeof PersistentPtyGroup
|
||||
| ReturnType<typeof makePersistentPtyGroup<SessionLocationId, SessionLocationService>>
|
||||
| LocationGroups<LocationId>
|
||||
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||
| SessionGroups<SessionLocationId, SessionLocationService>
|
||||
@@ -170,7 +170,7 @@ const makeApiFromGroup = <
|
||||
.add(SkillGroup.middleware(locationMiddleware))
|
||||
.add(eventGroup)
|
||||
.add(PtyGroup.middleware(locationMiddleware))
|
||||
.add(PersistentPtyGroup)
|
||||
.add(makePersistentPtyGroup(sessionLocationMiddleware))
|
||||
.add(ShellGroup.middleware(locationMiddleware))
|
||||
.add(ReferenceGroup.middleware(locationMiddleware))
|
||||
.add(WorktreeGroup)
|
||||
|
||||
@@ -2,9 +2,15 @@ import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ForbiddenError, InvalidRequestError, PtyNotFoundError, ServiceUnavailableError } from "../errors.js"
|
||||
import { Context, Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import {
|
||||
ForbiddenError,
|
||||
InvalidRequestError,
|
||||
PtyNotFoundError,
|
||||
ServiceUnavailableError,
|
||||
SessionNotFoundError,
|
||||
} from "../errors.js"
|
||||
import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE } from "./pty.js"
|
||||
|
||||
export { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE }
|
||||
@@ -18,86 +24,95 @@ export function hasPersistentPtyConnectTicketURL(url: URL) {
|
||||
const errors = [InvalidRequestError, ServiceUnavailableError] as const
|
||||
const terminalErrors = [PtyNotFoundError, ServiceUnavailableError] as const
|
||||
|
||||
export const PersistentPtyGroup = HttpApiGroup.make("server.experimental")
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.list", "/api/experimental/session/:sessionID/terminal", {
|
||||
params: { sessionID: Session.ID },
|
||||
success: Schema.Struct({ data: Schema.Array(PersistentPty.Info) }),
|
||||
error: errors,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("persistentPty.create", "/api/experimental/session/:sessionID/terminal", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: PersistentPty.CreateInput,
|
||||
success: Schema.Struct({ data: PersistentPty.Info }),
|
||||
error: errors,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("persistentPty.shutdown", "/api/experimental/persistent-pty/shutdown", {
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [ServiceUnavailableError],
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.get", "/api/experimental/persistent-pty/:ptyID", {
|
||||
params: { ptyID: Pty.ID },
|
||||
success: Schema.Struct({ data: PersistentPty.Info }),
|
||||
error: terminalErrors,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.put("persistentPty.update", "/api/experimental/persistent-pty/:ptyID", {
|
||||
params: { ptyID: Pty.ID },
|
||||
payload: PersistentPty.UpdateInput,
|
||||
success: Schema.Struct({ data: PersistentPty.Info }),
|
||||
error: terminalErrors,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.snapshot", "/api/experimental/persistent-pty/:ptyID/snapshot", {
|
||||
params: { ptyID: Pty.ID },
|
||||
success: Schema.Struct({ data: PersistentPty.Snapshot }),
|
||||
error: terminalErrors,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("persistentPty.remove", "/api/experimental/persistent-pty/:ptyID", {
|
||||
params: { ptyID: Pty.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: terminalErrors,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("persistentPty.connectToken", "/api/experimental/persistent-pty/:ptyID/connect-token", {
|
||||
params: { ptyID: Pty.ID },
|
||||
headers: Schema.Struct({ [PTY_CONNECT_TOKEN_HEADER]: Schema.optional(Schema.String) }),
|
||||
success: Schema.Struct({ data: PtyTicket.ConnectToken }),
|
||||
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.connect", "/api/experimental/persistent-pty/:ptyID/connect", {
|
||||
params: { ptyID: Pty.ID },
|
||||
success: Schema.Boolean,
|
||||
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.persistentPty.connect",
|
||||
summary: "Connect to a persistent PTY",
|
||||
description: "Stream persistent PTY output through the OpenCode server.",
|
||||
transform: (operation) => ({
|
||||
...operation,
|
||||
"x-websocket": true,
|
||||
parameters: [
|
||||
...(operation.parameters ?? []),
|
||||
...["cursor", "role", "attachment_id", "takeover", "input_protocol", PTY_CONNECT_TICKET_QUERY].map(
|
||||
(name) => ({ in: "query", name, schema: { type: "string" } }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
export const makePersistentPtyGroup = <I extends HttpApiMiddleware.AnyId, S>(
|
||||
sessionLocationMiddleware: Context.Key<I, S>,
|
||||
) =>
|
||||
HttpApiGroup.make("server.experimental")
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.list", "/api/experimental/session/:sessionID/terminal", {
|
||||
params: { sessionID: Session.ID },
|
||||
success: Schema.Struct({ data: Schema.Array(PersistentPty.Info) }),
|
||||
error: errors,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "persistentPty", description: "Prototype persistent PTY routes." }))
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("persistentPty.create", "/api/experimental/session/:sessionID/terminal", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: PersistentPty.CreateInput,
|
||||
success: Schema.Struct({ data: PersistentPty.Info }),
|
||||
error: [...errors, SessionNotFoundError],
|
||||
}).middleware(sessionLocationMiddleware),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("persistentPty.shutdown", "/api/experimental/persistent-pty/shutdown", {
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [ServiceUnavailableError],
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("persistentPty.prepareRestart", "/api/experimental/persistent-pty/prepare-restart", {
|
||||
success: Schema.Struct({ handoff: Schema.NullOr(PersistentPty.Handoff) }),
|
||||
error: [ServiceUnavailableError],
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.get", "/api/experimental/persistent-pty/:ptyID", {
|
||||
params: { ptyID: Pty.ID },
|
||||
success: Schema.Struct({ data: PersistentPty.Info }),
|
||||
error: terminalErrors,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.put("persistentPty.update", "/api/experimental/persistent-pty/:ptyID", {
|
||||
params: { ptyID: Pty.ID },
|
||||
payload: PersistentPty.UpdateInput,
|
||||
success: Schema.Struct({ data: PersistentPty.Info }),
|
||||
error: terminalErrors,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.snapshot", "/api/experimental/persistent-pty/:ptyID/snapshot", {
|
||||
params: { ptyID: Pty.ID },
|
||||
success: Schema.Struct({ data: PersistentPty.Snapshot }),
|
||||
error: terminalErrors,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("persistentPty.remove", "/api/experimental/persistent-pty/:ptyID", {
|
||||
params: { ptyID: Pty.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: terminalErrors,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("persistentPty.connectToken", "/api/experimental/persistent-pty/:ptyID/connect-token", {
|
||||
params: { ptyID: Pty.ID },
|
||||
headers: Schema.Struct({ [PTY_CONNECT_TOKEN_HEADER]: Schema.optional(Schema.String) }),
|
||||
success: Schema.Struct({ data: PtyTicket.ConnectToken }),
|
||||
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.connect", "/api/experimental/persistent-pty/:ptyID/connect", {
|
||||
params: { ptyID: Pty.ID },
|
||||
success: Schema.Boolean,
|
||||
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.persistentPty.connect",
|
||||
summary: "Connect to a persistent PTY",
|
||||
description: "Stream persistent PTY output through the OpenCode server.",
|
||||
transform: (operation) => ({
|
||||
...operation,
|
||||
"x-websocket": true,
|
||||
parameters: [
|
||||
...(operation.parameters ?? []),
|
||||
...["cursor", "role", "attachment_id", "takeover", "input_protocol", PTY_CONNECT_TICKET_QUERY].map(
|
||||
(name) => ({ in: "query", name, schema: { type: "string" } }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "persistentPty", description: "Prototype persistent PTY routes." }))
|
||||
|
||||
@@ -15,10 +15,18 @@ export const Info = Schema.Struct({
|
||||
}).annotate({ identifier: "PersistentPty.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const Handoff = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
instanceID: Schema.String,
|
||||
ticket: Schema.String,
|
||||
expiresAt: Schema.Number,
|
||||
}).annotate({ identifier: "PersistentPty.Handoff" })
|
||||
export interface Handoff extends Schema.Schema.Type<typeof Handoff> {}
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
command: Schema.String,
|
||||
command: optional(Schema.String),
|
||||
args: Schema.Array(Schema.String),
|
||||
cwd: Schema.String,
|
||||
cwd: optional(Schema.String),
|
||||
title: Schema.String,
|
||||
env: Schema.Record(Schema.String, Schema.String),
|
||||
size: optional(Schema.Struct({ cols: PositiveInt, rows: PositiveInt })),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
|
||||
import { ForbiddenError, PtyNotFoundError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
|
||||
import {
|
||||
PTY_CONNECT_TICKET_QUERY,
|
||||
@@ -29,6 +30,8 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
|
||||
.handle(
|
||||
"persistentPty.create",
|
||||
Effect.fn(function* (ctx) {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return {
|
||||
data: yield* pty
|
||||
.create(ctx.params.sessionID, {
|
||||
@@ -51,6 +54,12 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"persistentPty.prepareRestart",
|
||||
Effect.fn(function* () {
|
||||
return { handoff: yield* pty.prepareRestart().pipe(mapUnavailable) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"persistentPty.get",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const ServerOptions = Schema.Struct({
|
||||
@@ -15,6 +16,7 @@ export const ServerOptions = Schema.Struct({
|
||||
password: Schema.optional(Schema.String),
|
||||
simulation: Schema.optional(Schema.Boolean),
|
||||
database: Schema.optional(Database.Options),
|
||||
pty: Schema.optional(PersistentPty.Options),
|
||||
events: Schema.optional(
|
||||
Schema.Struct({
|
||||
persist: Schema.optional(Schema.Boolean),
|
||||
|
||||
@@ -100,6 +100,7 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||
const standard: LayerNode.Replacements = [
|
||||
[Database.node, Database.configured(options.database)],
|
||||
[PersistentPty.node, PersistentPty.configured(options.pty)],
|
||||
[Bus.node, Bus.configured({ persist: options.events?.persist })],
|
||||
[App.node, App.configured(options.app)],
|
||||
[ModelsDev.node, ModelsDev.configured(options.models)],
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import fs from "node:fs/promises"
|
||||
import { createHash } from "node:crypto"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Exit, Schema, Scope } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
@@ -17,179 +17,370 @@ const smoke = existsSync(binary) ? it.live : it.live.skip
|
||||
smoke(
|
||||
"creates two persistent terminals for one session through the client API",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(async () => {
|
||||
const environment = {
|
||||
binary: process.env.OPENCODE_PTY_BIN,
|
||||
runtime: process.env.OPENCODE_PTY_RUNTIME_DIR,
|
||||
xdg: process.env.XDG_RUNTIME_DIR,
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* testDirectory("xdg")
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: fixture.database },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const base = HttpServer.formatAddress(server.address)
|
||||
const sessionID = Session.ID.make("ses_persistent_pty_test")
|
||||
yield* request(base, "POST", "/api/session", { id: sessionID, location: { directory: fixture.root } })
|
||||
const events = yield* Effect.promise(() => openEventStream(base))
|
||||
expect(existsSync(fixture.directory)).toBeFalse()
|
||||
expect(yield* request(base, "POST", "/api/experimental/persistent-pty/prepare-restart")).toEqual({
|
||||
handoff: null,
|
||||
})
|
||||
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
|
||||
expect(existsSync(fixture.directory)).toBeFalse()
|
||||
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
|
||||
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
||||
command: "/usr/bin/env",
|
||||
args: ["/bin/sh", "-c", "stty -echo; printf 'terminal-one:%s\\n' \"$PWD\"; cat"],
|
||||
cwd: process.cwd(),
|
||||
title: "first",
|
||||
env: {},
|
||||
})).data,
|
||||
)
|
||||
expect(yield* Effect.promise(() => events.next("persistent-pty.added"))).toMatchObject({
|
||||
data: { sessionID, terminal: { id: first.id } },
|
||||
})
|
||||
expect(first.size).toEqual({ cols: 80, rows: 24 })
|
||||
expect(first).toMatchObject({
|
||||
command: "/usr/bin/env",
|
||||
args: ["/bin/sh", "-c", "stty -echo; printf 'terminal-one:%s\\n' \"$PWD\"; cat"],
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
expect(first.cwd).not.toBe(fixture.root)
|
||||
const directories = yield* Effect.promise(() => fs.readdir(fixture.directory))
|
||||
expect(directories).toHaveLength(1)
|
||||
expect(directories[0]).toMatch(/^[0-9a-f-]{36}$/)
|
||||
if (!directories[0]) throw new Error("Missing daemon directory")
|
||||
expect(existsSync(path.join(fixture.directory, directories[0], "service.json"))).toBeTrue()
|
||||
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
|
||||
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "printf terminal-two; sleep 30"],
|
||||
cwd: process.cwd(),
|
||||
title: "second",
|
||||
env: {},
|
||||
})).data,
|
||||
)
|
||||
|
||||
const terminals = Schema.decodeUnknownSync(Schema.Array(PersistentPty.Info))(
|
||||
(yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
|
||||
)
|
||||
expect(terminals.map((terminal) => terminal.id).sort()).toEqual([first.id, second.id].sort())
|
||||
expect(yield* waitForText(base, first.id, "terminal-one")).toContain(`terminal-one:${process.cwd()}`)
|
||||
expect(yield* waitForText(base, second.id, "terminal-two")).toContain("terminal-two")
|
||||
yield* Effect.promise(() => verifySharedControl(base, first.id))
|
||||
const snapshot = yield* request(base, "GET", `/api/experimental/persistent-pty/${first.id}/snapshot`)
|
||||
if (
|
||||
!isRecord(snapshot.data) ||
|
||||
typeof snapshot.data.checkpoint !== "string" ||
|
||||
!isRecord(snapshot.data.info) ||
|
||||
!isRecord(snapshot.data.info.output) ||
|
||||
typeof snapshot.data.info.output.tail !== "number"
|
||||
)
|
||||
throw new Error("Persistent PTY snapshot response was invalid")
|
||||
expect(Buffer.from(snapshot.data.checkpoint, "base64").byteLength).toBeGreaterThan(0)
|
||||
expect(snapshot.data.info.output.tail).toBeGreaterThan(0)
|
||||
|
||||
const ticket = yield* request(
|
||||
base,
|
||||
"POST",
|
||||
`/api/experimental/persistent-pty/${first.id}/connect-token`,
|
||||
undefined,
|
||||
{
|
||||
"x-opencode-ticket": "1",
|
||||
},
|
||||
)
|
||||
if (!isRecord(ticket.data) || typeof ticket.data.ticket !== "string") throw new Error("Invalid connect ticket")
|
||||
const connectTicket = ticket.data.ticket
|
||||
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${first.id}`)
|
||||
yield* Effect.promise(async () => {
|
||||
const url = new URL(`/api/experimental/persistent-pty/${first.id}/connect`, base)
|
||||
url.searchParams.set("ticket", "invalid")
|
||||
expect((await fetch(url)).status).toBe(403)
|
||||
url.protocol = "ws:"
|
||||
url.searchParams.set("ticket", connectTicket)
|
||||
const socket = new WebSocket(url)
|
||||
try {
|
||||
const closed = await new Promise<CloseEvent>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Removed terminal socket did not close")), 5_000)
|
||||
socket.addEventListener("close", (event) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
socket.addEventListener("error", () => {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error("Valid ticket should upgrade before the missing terminal is reported"))
|
||||
})
|
||||
})
|
||||
expect(closed.code).toBe(4404)
|
||||
expect(closed.reason).toBe("terminal unavailable")
|
||||
} finally {
|
||||
socket.close()
|
||||
}
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-pty-server-test-"))
|
||||
const database = path.join(root, "opencode.db")
|
||||
const runtime = path.join(root, "runtime")
|
||||
process.env.OPENCODE_PTY_BIN = binary
|
||||
delete process.env.OPENCODE_PTY_RUNTIME_DIR
|
||||
process.env.XDG_RUNTIME_DIR = runtime
|
||||
return {
|
||||
database,
|
||||
directory: path.join(
|
||||
runtime,
|
||||
"opencode-pty",
|
||||
createHash("sha256").update(database).digest("hex").slice(0, 16),
|
||||
),
|
||||
environment,
|
||||
root,
|
||||
}
|
||||
}),
|
||||
(fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: fixture.database },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const base = HttpServer.formatAddress(server.address)
|
||||
const sessionID = Session.ID.make("ses_persistent_pty_test")
|
||||
const events = yield* Effect.promise(() => openEventStream(base))
|
||||
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
|
||||
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
|
||||
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
|
||||
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
|
||||
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "stty -echo; printf terminal-one; cat"],
|
||||
cwd: process.cwd(),
|
||||
title: "first",
|
||||
env: {},
|
||||
})).data,
|
||||
)
|
||||
expect(yield* Effect.promise(() => events.next("persistent-pty.added"))).toMatchObject({
|
||||
data: { sessionID, terminal: { id: first.id } },
|
||||
})
|
||||
expect(first.size).toEqual({ cols: 80, rows: 24 })
|
||||
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeTrue()
|
||||
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
|
||||
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "printf terminal-two; sleep 30"],
|
||||
cwd: process.cwd(),
|
||||
title: "second",
|
||||
env: {},
|
||||
})).data,
|
||||
)
|
||||
})
|
||||
expect(yield* Effect.promise(() => events.next("persistent-pty.removed"))).toMatchObject({
|
||||
data: { sessionID, ptyID: first.id },
|
||||
})
|
||||
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${second.id}`)
|
||||
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
|
||||
|
||||
const terminals = Schema.decodeUnknownSync(Schema.Array(PersistentPty.Info))(
|
||||
(yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
|
||||
)
|
||||
expect(terminals.map((terminal) => terminal.id).sort()).toEqual([first.id, second.id].sort())
|
||||
expect(yield* waitForText(base, first.id, "terminal-one")).toContain("terminal-one")
|
||||
expect(yield* waitForText(base, second.id, "terminal-two")).toContain("terminal-two")
|
||||
yield* Effect.promise(() => verifySharedControl(base, first.id))
|
||||
const snapshot = yield* request(base, "GET", `/api/experimental/persistent-pty/${first.id}/snapshot`)
|
||||
if (
|
||||
!isRecord(snapshot.data) ||
|
||||
typeof snapshot.data.checkpoint !== "string" ||
|
||||
!isRecord(snapshot.data.info) ||
|
||||
!isRecord(snapshot.data.info.output) ||
|
||||
typeof snapshot.data.info.output.tail !== "number"
|
||||
)
|
||||
throw new Error("Persistent PTY snapshot response was invalid")
|
||||
expect(Buffer.from(snapshot.data.checkpoint, "base64").byteLength).toBeGreaterThan(0)
|
||||
expect(snapshot.data.info.output.tail).toBeGreaterThan(0)
|
||||
yield* request(base, "POST", "/api/experimental/persistent-pty/shutdown")
|
||||
|
||||
const ticket = yield* request(
|
||||
base,
|
||||
"POST",
|
||||
`/api/experimental/persistent-pty/${first.id}/connect-token`,
|
||||
undefined,
|
||||
{
|
||||
"x-opencode-ticket": "1",
|
||||
},
|
||||
)
|
||||
if (!isRecord(ticket.data) || typeof ticket.data.ticket !== "string")
|
||||
throw new Error("Invalid connect ticket")
|
||||
const connectTicket = ticket.data.ticket
|
||||
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${first.id}`)
|
||||
yield* Effect.promise(async () => {
|
||||
const url = new URL(`/api/experimental/persistent-pty/${first.id}/connect`, base)
|
||||
url.searchParams.set("ticket", "invalid")
|
||||
expect((await fetch(url)).status).toBe(403)
|
||||
url.protocol = "ws:"
|
||||
url.searchParams.set("ticket", connectTicket)
|
||||
const socket = new WebSocket(url)
|
||||
try {
|
||||
const closed = await new Promise<CloseEvent>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Removed terminal socket did not close")), 5_000)
|
||||
socket.addEventListener("close", (event) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
socket.addEventListener("error", () => {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error("Valid ticket should upgrade before the missing terminal is reported"))
|
||||
})
|
||||
})
|
||||
expect(closed.code).toBe(4404)
|
||||
expect(closed.reason).toBe("terminal unavailable")
|
||||
} finally {
|
||||
socket.close()
|
||||
}
|
||||
})
|
||||
expect(yield* Effect.promise(() => events.next("persistent-pty.removed"))).toMatchObject({
|
||||
data: { sessionID, ptyID: first.id },
|
||||
})
|
||||
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${second.id}`)
|
||||
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
|
||||
const unattended = Schema.decodeUnknownSync(PersistentPty.Info)(
|
||||
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "exit 7"],
|
||||
cwd: process.cwd(),
|
||||
title: "unattended",
|
||||
env: {},
|
||||
})).data,
|
||||
)
|
||||
yield* waitForStatus(base, unattended.id, "exited")
|
||||
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toMatchObject([
|
||||
{ id: unattended.id, status: "exited" },
|
||||
])
|
||||
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${unattended.id}`)
|
||||
|
||||
yield* request(base, "POST", "/api/experimental/persistent-pty/shutdown")
|
||||
|
||||
const unattended = Schema.decodeUnknownSync(PersistentPty.Info)(
|
||||
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "exit 7"],
|
||||
cwd: process.cwd(),
|
||||
title: "unattended",
|
||||
env: {},
|
||||
})).data,
|
||||
)
|
||||
yield* waitForStatus(base, unattended.id, "exited")
|
||||
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toMatchObject([
|
||||
{ id: unattended.id, status: "exited" },
|
||||
])
|
||||
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${unattended.id}`)
|
||||
|
||||
const visible = Schema.decodeUnknownSync(PersistentPty.Info)(
|
||||
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "read value"],
|
||||
cwd: process.cwd(),
|
||||
title: "visible",
|
||||
env: {},
|
||||
})).data,
|
||||
)
|
||||
yield* attachAndExit(base, visible.id)
|
||||
yield* waitForTerminals(base, sessionID, [])
|
||||
yield* Effect.promise(() => events.close())
|
||||
}),
|
||||
(fixture) =>
|
||||
Effect.promise(async () => {
|
||||
await Bun.spawn([binary, "stop"], {
|
||||
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: fixture.directory },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
}).exited
|
||||
await fs.rm(fixture.root, { recursive: true, force: true })
|
||||
restore("OPENCODE_PTY_BIN", fixture.environment.binary)
|
||||
restore("OPENCODE_PTY_RUNTIME_DIR", fixture.environment.runtime)
|
||||
restore("XDG_RUNTIME_DIR", fixture.environment.xdg)
|
||||
}),
|
||||
),
|
||||
const visible = Schema.decodeUnknownSync(PersistentPty.Info)(
|
||||
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "read value"],
|
||||
cwd: process.cwd(),
|
||||
title: "visible",
|
||||
env: {},
|
||||
})).data,
|
||||
)
|
||||
yield* attachAndExit(base, visible.id)
|
||||
yield* waitForTerminals(base, sessionID, [])
|
||||
yield* Effect.promise(() => events.close())
|
||||
}),
|
||||
20_000,
|
||||
)
|
||||
|
||||
smoke(
|
||||
"isolates servers sharing a database and preserves terminals only through explicit restart handoff",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* testDirectory("override")
|
||||
const scope = yield* Scope.Scope
|
||||
const originalScope = yield* Scope.fork(scope)
|
||||
const options = {
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: fixture.database },
|
||||
fs: { filewatcher: false },
|
||||
}
|
||||
const original = yield* ServerProcess.start<never, never>(options).pipe(
|
||||
Effect.provideService(Scope.Scope, originalScope),
|
||||
)
|
||||
const base = HttpServer.formatAddress(original.address)
|
||||
const sessionID = Session.ID.make("ses_persistent_pty_restart")
|
||||
yield* request(base, "POST", "/api/session", { id: sessionID, location: { directory: fixture.root } })
|
||||
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
|
||||
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "stty -echo; printf before-restart; exec cat"],
|
||||
cwd: process.cwd(),
|
||||
title: "survivor",
|
||||
env: {},
|
||||
})).data,
|
||||
)
|
||||
expect(yield* waitForText(base, first.id, "before-restart")).toContain("before-restart")
|
||||
|
||||
const independentScope = yield* Scope.fork(scope)
|
||||
const independent = yield* ServerProcess.start<never, never>(options).pipe(
|
||||
Effect.provideService(Scope.Scope, independentScope),
|
||||
)
|
||||
const otherBase = HttpServer.formatAddress(independent.address)
|
||||
expect((yield* request(otherBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
|
||||
expect(yield* request(otherBase, "POST", "/api/experimental/persistent-pty/prepare-restart")).toEqual({
|
||||
handoff: null,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readdir(fixture.directory))).toHaveLength(1)
|
||||
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
|
||||
(yield* request(otherBase, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "printf isolated; exec cat"],
|
||||
cwd: process.cwd(),
|
||||
title: "independent",
|
||||
env: {},
|
||||
})).data,
|
||||
)
|
||||
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toMatchObject([
|
||||
{ id: first.id, pid: first.pid },
|
||||
])
|
||||
expect((yield* request(otherBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toMatchObject([
|
||||
{ id: second.id, pid: second.pid },
|
||||
])
|
||||
expect(second.pid).not.toBe(first.pid)
|
||||
expect(yield* Effect.promise(() => fs.readdir(fixture.directory))).toHaveLength(2)
|
||||
|
||||
const handoff = Schema.decodeUnknownSync(PersistentPty.Handoff)(
|
||||
(yield* request(base, "POST", "/api/experimental/persistent-pty/prepare-restart")).handoff,
|
||||
)
|
||||
expect(path.dirname(handoff.directory)).toBe(fixture.directory)
|
||||
expect(handoff.expiresAt).toBeGreaterThan(Date.now())
|
||||
const registration = Schema.decodeUnknownSync(Schema.Struct({ pid: Schema.Number, instance_id: Schema.String }))(
|
||||
yield* Effect.promise(() => Bun.file(path.join(handoff.directory, "service.json")).json()),
|
||||
)
|
||||
expect(registration.instance_id).toBe(handoff.instanceID)
|
||||
yield* Scope.close(originalScope, Exit.void)
|
||||
expect(process.kill(registration.pid, 0)).toBeTrue()
|
||||
expect(process.kill(first.pid, 0)).toBeTrue()
|
||||
|
||||
const replacementScope = yield* Scope.fork(scope)
|
||||
const replacement = yield* ServerProcess.start<never, never>({ ...options, pty: { handoff } }).pipe(
|
||||
Effect.provideService(Scope.Scope, replacementScope),
|
||||
)
|
||||
const replacementBase = HttpServer.formatAddress(replacement.address)
|
||||
expect(
|
||||
(yield* request(replacementBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
|
||||
).toMatchObject([{ id: first.id, pid: first.pid }])
|
||||
expect(yield* waitForText(replacementBase, first.id, "before-restart")).toContain("before-restart")
|
||||
yield* Effect.promise(async () => {
|
||||
const attachment = await openTerminalSocket(replacementBase, first.id, "replacement")
|
||||
try {
|
||||
attachment.socket.send(inputFrame(80, 24, "after-restart\n"))
|
||||
await waitForSocketOutput([attachment], "after-restart")
|
||||
} finally {
|
||||
attachment.socket.close()
|
||||
}
|
||||
})
|
||||
expect(yield* waitForText(replacementBase, first.id, "after-restart")).toContain("before-restart")
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Schema.Struct({ pid: Schema.Number }))(
|
||||
yield* Effect.promise(() => Bun.file(path.join(handoff.directory, "service.json")).json()),
|
||||
).pid,
|
||||
).toBe(registration.pid)
|
||||
|
||||
yield* Scope.close(replacementScope, Exit.void)
|
||||
yield* waitForExit(registration.pid)
|
||||
yield* waitForExit(first.pid)
|
||||
expect(existsSync(path.join(handoff.directory, "service.json"))).toBeFalse()
|
||||
expect(yield* waitForText(otherBase, second.id, "isolated")).toContain("isolated")
|
||||
const remaining = yield* Effect.promise(async () => {
|
||||
const directories = await fs.readdir(fixture.directory)
|
||||
const directory = directories.find((name) => path.join(fixture.directory, name) !== handoff.directory)
|
||||
if (!directory) throw new Error("Missing independent daemon directory")
|
||||
return Schema.decodeUnknownSync(Schema.Struct({ pid: Schema.Number }))(
|
||||
await Bun.file(path.join(fixture.directory, directory, "service.json")).json(),
|
||||
)
|
||||
})
|
||||
yield* Scope.close(independentScope, Exit.void)
|
||||
yield* waitForExit(remaining.pid)
|
||||
yield* waitForExit(second.pid)
|
||||
}),
|
||||
30_000,
|
||||
)
|
||||
|
||||
for (const shell of ["/bin/sh", "/missing/server/zsh"]) {
|
||||
smoke(
|
||||
`defaults persistent terminals to the session directory and server shell (${shell})`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* testDirectory("xdg", shell)
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: fixture.database },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const base = HttpServer.formatAddress(server.address)
|
||||
const sessionID = Session.ID.make("ses_persistent_pty_defaults")
|
||||
const input = {
|
||||
args: ["-c", "printf 'cwd:%s\\n' \"$PWD\"; cat"],
|
||||
title: "default shell",
|
||||
env: { SHELL: "/missing/client/zsh" },
|
||||
}
|
||||
const missing = yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, input).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
expect(missing.message).toContain("(404)")
|
||||
expect(existsSync(fixture.directory)).toBeFalse()
|
||||
yield* request(base, "POST", "/api/session", { id: sessionID, location: { directory: fixture.root } })
|
||||
const terminal = Schema.decodeUnknownSync(PersistentPty.Info)(
|
||||
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, input)).data,
|
||||
)
|
||||
expect(terminal).toMatchObject({ cwd: fixture.root, args: input.args, status: "running" })
|
||||
expect(terminal.cwd).not.toBe(process.cwd())
|
||||
if (shell === "/bin/sh") expect(terminal.command).toBe("/bin/sh")
|
||||
expect(existsSync(terminal.command)).toBeTrue()
|
||||
expect(terminal.command).not.toBe("/missing/server/zsh")
|
||||
expect(terminal.command).not.toBe(input.env.SHELL)
|
||||
expect(yield* waitForText(base, terminal.id, `cwd:${fixture.root}`)).toContain(`cwd:${fixture.root}`)
|
||||
}),
|
||||
20_000,
|
||||
)
|
||||
}
|
||||
|
||||
function testDirectory(mode: "xdg" | "override", shell?: string) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const environment = {
|
||||
binary: process.env.OPENCODE_PTY_BIN,
|
||||
runtime: process.env.OPENCODE_PTY_RUNTIME_DIR,
|
||||
xdg: process.env.XDG_RUNTIME_DIR,
|
||||
shell: process.env.SHELL,
|
||||
}
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-pty-server-test-"))
|
||||
const runtime = path.join(root, "runtime")
|
||||
process.env.OPENCODE_PTY_BIN = binary
|
||||
delete process.env.OPENCODE_PTY_RUNTIME_DIR
|
||||
process.env.XDG_RUNTIME_DIR = runtime
|
||||
if (mode === "override") process.env.OPENCODE_PTY_RUNTIME_DIR = runtime
|
||||
if (shell !== undefined) {
|
||||
process.env.SHELL = shell
|
||||
ShellSelect.resolve.reset()
|
||||
}
|
||||
return {
|
||||
database: path.join(root, "opencode.db"),
|
||||
directory: mode === "override" ? runtime : path.join(runtime, "opencode-pty"),
|
||||
environment,
|
||||
root,
|
||||
}
|
||||
}),
|
||||
(fixture) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.rm(fixture.root, { recursive: true, force: true })
|
||||
restore("OPENCODE_PTY_BIN", fixture.environment.binary)
|
||||
restore("OPENCODE_PTY_RUNTIME_DIR", fixture.environment.runtime)
|
||||
restore("XDG_RUNTIME_DIR", fixture.environment.xdg)
|
||||
if (shell !== undefined) {
|
||||
restore("SHELL", fixture.environment.shell)
|
||||
ShellSelect.resolve.reset()
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function waitForExit(pid: number) {
|
||||
return Effect.promise(async () => {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch (error) {
|
||||
if (isRecord(error) && error.code === "ESRCH") return
|
||||
throw error
|
||||
}
|
||||
await Bun.sleep(20)
|
||||
}
|
||||
throw new Error(`Process ${pid} survived its server scope`)
|
||||
})
|
||||
}
|
||||
|
||||
function request(base: string, method: string, pathname: string, body?: unknown, headers?: Record<string, string>) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createSignal, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { useConfig } from "../config"
|
||||
import { useData } from "./data"
|
||||
import { useEvent } from "./event"
|
||||
import { useStorage } from "./storage"
|
||||
|
||||
@@ -16,7 +15,6 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
|
||||
init: () => {
|
||||
const client = useClient()
|
||||
const config = useConfig().data
|
||||
const data = useData()
|
||||
const event = useEvent()
|
||||
const [focus, setFocus] = createSignal<string>()
|
||||
const storage = useStorage()
|
||||
@@ -76,12 +74,9 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
|
||||
refresh,
|
||||
selectTerminal,
|
||||
async newTerminal(sessionID: string): Promise<PersistentPtyInfo> {
|
||||
const session = data.session.get(sessionID)
|
||||
const terminal = await client.api.experimental.persistentPty.create({
|
||||
sessionID,
|
||||
command: process.env.SHELL || "/bin/sh",
|
||||
args: [],
|
||||
cwd: session?.location.directory ?? process.cwd(),
|
||||
title: "Terminal",
|
||||
env: {},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user