refactor(app): keep the SSH contract schemas out of the renderer

The Effect schemas move to servers/ssh/schema.ts, imported by the desktop main process for its RPC server; the renderer gets types only. The SSH attempt controller tracks its one in-flight request per server with a cancellation flag instead of Effect fibers, with the same settle-on-cancel behaviour.
This commit is contained in:
LukeParkerDev
2026-09-19 21:55:39 +10:00
parent 7930c5f676
commit 6a57105b2c
7 changed files with 122 additions and 115 deletions
+3 -1
View File
@@ -10,7 +10,8 @@
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
"./updater": "./src/shell/updates/types.ts",
"./wsl/types": "./src/servers/wsl/types.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./ssh/schema": "./src/servers/ssh/schema.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
@@ -94,3 +95,4 @@
"tailwindcss": "4.3.3"
}
}
+44 -51
View File
@@ -1,4 +1,3 @@
import { Effect, Fiber } from "effect"
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { SshConfig, SshItem, SshPlatform } from "./types"
@@ -24,7 +23,8 @@ export function createSshController(input: {
| undefined
>
>({})
const tasks = new Map<string, Fiber.Fiber<void>>()
// One in-flight request per server; cancelling settles it immediately and ignores its outcome.
const tasks = new Map<string, { cancelled: boolean }>()
const item = (id: string) => input.items().find((item) => item.config.id === id)
const settle = (id: string) => {
const attempt = attempts[id]
@@ -33,31 +33,32 @@ export function createSshController(input: {
setAttempts(id, { active: false, onConnected: undefined })
if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
}
const run = (id: string, effect: Effect.Effect<unknown, unknown>) => {
setAttempts(id, { submitting: true, error: false })
tasks.set(
id,
Effect.runFork(
effect.pipe(
Effect.asVoid,
Effect.catch(() =>
Effect.sync(() => {
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
}),
),
Effect.ensuring(
Effect.sync(() => {
tasks.delete(id)
setAttempts(id, "submitting", false)
}),
),
),
),
)
}
onCleanup(() => {
Effect.runFork(Effect.forEach([...tasks.values()], Fiber.interrupt, { discard: true }))
const finish = (id: string, task: { cancelled: boolean }) => {
if (task.cancelled) return
task.cancelled = true
if (tasks.get(id) === task) tasks.delete(id)
setAttempts(id, "submitting", false)
}
const interrupt = (id: string) => {
const task = tasks.get(id)
if (task) finish(id, task)
}
const run = (id: string, work: () => Promise<unknown>) => {
setAttempts(id, { submitting: true, error: false })
const task = { cancelled: false }
tasks.set(id, task)
work().then(
() => finish(id, task),
() => {
if (task.cancelled) return
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
finish(id, task)
},
)
}
onCleanup(() => {
for (const id of [...tasks.keys()]) interrupt(id)
})
createEffect(() => {
for (const item of input.items()) {
@@ -114,40 +115,32 @@ export function createSshController(input: {
error: false,
onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
})
run(
config.id,
Effect.gen(function* () {
yield* Effect.tryPromise(() => api.start({ ...config, replace: options?.replace }))
// Observe admission before treating an older disconnected snapshot as cancellation.
yield* Effect.tryPromise(input.refresh)
}),
)
run(config.id, async () => {
await api.start({ ...config, replace: options?.replace })
// Observe admission before treating an older disconnected snapshot as cancellation.
await input.refresh()
})
},
respond: (id: string, prompt: string, value: string) => {
const api = input.api
if (!api || item(id)?.prompt?.id !== prompt || attempts[id]?.submitting) return
if (attempts[id]?.answered === prompt && !attempts[id]?.error) return
setAttempts(id, "answered", prompt)
run(
id,
Effect.tryPromise(() => api.respond(id, prompt, value)),
)
run(id, () => api.respond(id, prompt, value))
},
cancel: (id: string) => {
const task = tasks.get(id)
const api = input.api
Effect.runFork(
Effect.gen(function* () {
if (task) yield* Fiber.interrupt(task)
setAttempts(id, undefined)
if (!api) return
yield* Effect.tryPromise(() => api.cancel(id))
if (!item(id)?.saved) yield* Effect.tryPromise(() => api.forget(id))
}).pipe(Effect.ignore),
)
cancel: (id: string) => {
const api = input.api
interrupt(id)
setAttempts(id, undefined)
if (!api) return
void api
.cancel(id)
.then(() => (item(id)?.saved ? undefined : api.forget(id)))
.catch(() => undefined)
},
restore: (config: SshConfig) => input.api?.start({ ...config, background: true }),
disconnect: (id: string) => input.api?.disconnect(id),
forget: (id: string) => input.api?.forget(id),
}
}
+61
View File
@@ -0,0 +1,61 @@
import { Schema } from "effect"
// The SSH state contract between the desktop main process and its renderer. Only the main process
// needs the schemas (its RPC server validates with them); the renderer imports the types from ./types.
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
+8 -60
View File
@@ -1,63 +1,9 @@
import { Schema } from "effect"
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
import type { SshConfig, SshHttp, SshItem, SshStart, SshState } from "./schema"
export type { SshConfig, SshHttp, SshItem, SshStart, SshState }
export type SshPlatform = {
getState(): Promise<SshState>
subscribe(callback: (state: SshState) => void): () => void
@@ -70,3 +16,5 @@ export type SshPlatform = {
forget(id: string): Promise<void>
openConfig(): Promise<void>
}
+2 -1
View File
@@ -5,7 +5,7 @@ import { NodeChildProcessSpawner } from "@effect/platform-node"
import { FetchHttpClient } from "effect/unstable/http"
import { app, shell, type WebContents } from "electron"
import { homedir } from "node:os"
import { SshConfig, type SshState } from "@opencode/app/ssh"
import { SshConfig, type SshState } from "@opencode/app/ssh/schema"
import { SshChanged } from "../../shared/ipc-rpc/events"
import { DesktopCli } from "../service/desktop-cli"
import { Shutdown } from "../lifecycle/shutdown"
@@ -88,3 +88,4 @@ export const layer = Layer.effect(
return service
}),
).pipe(Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(FetchHttpClient.layer))
@@ -3,7 +3,7 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
import { SshState } from "@opencode/app/ssh"
import { SshState } from "@opencode/app/ssh/schema"
export class SshChanged extends Schema.TaggedClass<SshChanged>()("SshChanged", { state: SshState }) {}
@@ -64,3 +64,4 @@ export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
+2 -1
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { SshHttp, SshStart, SshState } from "@opencode/app/ssh"
import { SshHttp, SshStart, SshState } from "@opencode/app/ssh/schema"
export const SshRpcs = RpcGroup.make(
Rpc.make("SshGetState", { success: SshState }),
@@ -15,3 +15,4 @@ export const SshRpcs = RpcGroup.make(
Rpc.make("SshForget", { payload: { id: Schema.String } }),
Rpc.make("SshOpenConfig"),
)