Skip to content

Commit 5c65f4c

Browse files
committed
perf(api,app,lib): speed up cold ssh terminal open
1 parent 4ca6a9f commit 5c65f4c

17 files changed

Lines changed: 594 additions & 83 deletions

packages/api/src/services/projects.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,10 @@ const markDeployment = (projectId: string, phase: string, message: string) =>
663663
emitProjectEvent(projectId, "project.deployment.status", { phase, message })
664664
})
665665

666+
type UpProjectOptions = {
667+
readonly startupMode?: "default" | "ssh-open"
668+
}
669+
666670
const syncContainerAuthorizedKeys = (
667671
project: ProjectItem
668672
) =>
@@ -737,16 +741,30 @@ const syncContainerAuthorizedKeys = (
737741
export const upProject = (
738742
projectId: string,
739743
authorizedKeysContents?: string,
740-
useManagedAuthorizedKeys?: boolean
744+
useManagedAuthorizedKeys?: boolean,
745+
options: UpProjectOptions = {}
741746
) =>
742747
Effect.gen(function*(_) {
743748
const project = yield* _(findProjectById(projectId))
749+
const startupMode = options.startupMode ?? "default"
744750
const resolvedAuthorizedKeysContents = yield* _(
745751
resolveRequestedAuthorizedKeysContents(authorizedKeysContents, useManagedAuthorizedKeys === true)
746752
)
747753
yield* _(seedAuthorizedKeysForCreate(project.projectDir, resolvedAuthorizedKeysContents))
748-
yield* _(markDeployment(projectId, "build", "docker compose up -d --build"))
749-
yield* _(runDockerComposeUpWithPortCheck(project.projectDir))
754+
yield* _(markDeployment(
755+
projectId,
756+
startupMode === "ssh-open" ? "ssh.compose-up" : "build",
757+
startupMode === "ssh-open" ? "docker compose up -d for SSH terminal" : "docker compose up -d --build"
758+
))
759+
yield* _(runDockerComposeUpWithPortCheck(
760+
project.projectDir,
761+
startupMode === "ssh-open"
762+
? {
763+
buildMode: "reuse",
764+
waitForPostStart: false
765+
}
766+
: undefined
767+
))
750768
if ((resolvedAuthorizedKeysContents ?? "").trim().length > 0) {
751769
yield* _(syncContainerAuthorizedKeys(project))
752770
}

packages/api/src/services/terminal-sessions.ts

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type AppError, prepareProjectSsh, renderError, waitForProjectSshReady } from "@effect-template/lib"
1+
import { type AppError, prepareProjectSsh, probeProjectSshReady, renderError, waitForProjectSshReady } from "@effect-template/lib"
22
import { runCommandCapture } from "@effect-template/lib/shell/command-runner"
33
import { parseInspectNetworkEntry } from "@effect-template/lib/shell/docker-inspect-parse"
44
import { CommandFailedError } from "@effect-template/lib/shell/errors"
@@ -33,7 +33,7 @@ import {
3333
type TerminalOutputBuffer
3434
} from "./terminal-output-buffer.js"
3535
import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js"
36-
import { getProjectItemById, upProject } from "./projects.js"
36+
import { getProject, getProjectItemById, upProject } from "./projects.js"
3737
import { attachWebSocketHeartbeat } from "./websocket-heartbeat.js"
3838

3939
type TerminalClientMessage =
@@ -609,49 +609,61 @@ const registerRecord = (
609609
return session
610610
}
611611

612+
const emitTerminalStatus = (projectId: string, phase: string, message: string) =>
613+
Effect.sync(() => {
614+
emitProjectEvent(projectId, "project.deployment.status", { phase, message })
615+
})
616+
617+
const emitTerminalSessionCreated = (projectId: string, sessionId: string) =>
618+
Effect.sync(() => {
619+
emitProjectEvent(projectId, "project.ssh.session", {
620+
phase: "created",
621+
sessionId
622+
})
623+
})
624+
612625
export const createTerminalSession = (
613626
projectId: string
614627
) =>
615628
Effect.gen(function*(_) {
616-
yield* _(
617-
Effect.sync(() => {
618-
emitProjectEvent(projectId, "project.deployment.status", {
619-
phase: "ssh.prepare",
620-
message: "Preparing SSH session"
621-
})
622-
})
623-
)
624-
const project = yield* _(upProject(projectId, undefined, true))
629+
yield* _(emitTerminalStatus(projectId, "ssh.prepare", "Preparing SSH session"))
625630
const loadedProjectItem = yield* _(getProjectItemById(projectId))
626631
const projectItem = yield* _(resolveControllerReachableProject(loadedProjectItem))
627632
yield* _(normalizeSshKeyPermissions(projectItem.sshKeyPath))
628-
yield* _(
629-
Effect.sync(() => {
630-
emitProjectEvent(projectId, "project.deployment.status", {
631-
phase: "ssh.wait",
632-
message: "Waiting for SSH"
633-
})
634-
})
635-
)
636-
yield* _(waitForProjectSshReady(projectItem).pipe(Effect.mapError(toApiInternalError)))
637-
yield* _(
638-
Effect.sync(() => {
639-
emitProjectEvent(projectId, "project.deployment.status", {
640-
phase: "ssh.ready",
641-
message: "SSH is ready"
642-
})
643-
})
644-
)
645-
const prepared = prepareProjectSsh(projectItem)
646-
const session = registerRecord(projectId, project.projectKey, project.displayName, prepared, projectItem.containerName)
647-
yield* _(
648-
Effect.sync(() => {
649-
emitProjectEvent(projectId, "project.ssh.session", {
650-
phase: "created",
651-
sessionId: session.id
652-
})
653-
})
633+
const sshAlreadyReady = yield* _(probeProjectSshReady(projectItem).pipe(Effect.orElseSucceed(() => false)))
634+
635+
if (sshAlreadyReady) {
636+
yield* _(emitTerminalStatus(projectId, "ssh.fast-ready", "SSH is already ready"))
637+
const project = yield* _(getProject(projectId))
638+
const prepared = prepareProjectSsh(projectItem)
639+
const session = registerRecord(
640+
projectId,
641+
project.projectKey,
642+
project.displayName,
643+
prepared,
644+
projectItem.containerName
645+
)
646+
yield* _(emitTerminalSessionCreated(projectId, session.id))
647+
return { project, session }
648+
}
649+
650+
const project = yield* _(upProject(projectId, undefined, true, { startupMode: "ssh-open" }))
651+
const refreshedProjectItem = yield* _(getProjectItemById(projectId))
652+
const reachableProjectItem = yield* _(resolveControllerReachableProject(refreshedProjectItem))
653+
yield* _(normalizeSshKeyPermissions(reachableProjectItem.sshKeyPath))
654+
yield* _(emitTerminalStatus(projectId, "ssh.wait", "Waiting for SSH"))
655+
yield* _(waitForProjectSshReady(reachableProjectItem).pipe(Effect.mapError(toApiInternalError)))
656+
yield* _(emitTerminalStatus(projectId, "ssh.ready", "SSH is ready"))
657+
const prepared = prepareProjectSsh(reachableProjectItem)
658+
const session = registerRecord(
659+
projectId,
660+
project.projectKey,
661+
project.displayName,
662+
prepared,
663+
reachableProjectItem.containerName
654664
)
665+
yield* _(emitTerminalSessionCreated(projectId, session.id))
666+
yield* _(emitTerminalStatus(projectId, "ssh.post-start", "Post-start self-heal continues in background"))
655667
return { project, session }
656668
})
657669

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { Effect } from "effect"
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
3+
4+
import type { ProjectItem } from "@effect-template/lib"
5+
6+
import type { ProjectDetails } from "../src/api/contracts.js"
7+
import { clearProjectEvents, listProjectEventsSince } from "../src/services/events.js"
8+
import {
9+
createTerminalSession,
10+
deleteTerminalSession,
11+
listProjectTerminalSessions
12+
} from "../src/services/terminal-sessions.js"
13+
14+
const prepareProjectSshMock = vi.hoisted(() => vi.fn())
15+
const probeProjectSshReadyMock = vi.hoisted(() => vi.fn())
16+
const runCommandCaptureMock = vi.hoisted(() => vi.fn())
17+
const upProjectMock = vi.hoisted(() => vi.fn())
18+
const getProjectMock = vi.hoisted(() => vi.fn())
19+
const getProjectItemByIdMock = vi.hoisted(() => vi.fn())
20+
const waitForProjectSshReadyMock = vi.hoisted(() => vi.fn())
21+
22+
vi.mock("@effect-template/lib", () => ({
23+
prepareProjectSsh: prepareProjectSshMock,
24+
probeProjectSshReady: probeProjectSshReadyMock,
25+
renderError: vi.fn((error: unknown) => String(error)),
26+
waitForProjectSshReady: waitForProjectSshReadyMock
27+
}))
28+
29+
vi.mock("@effect-template/lib/shell/command-runner", () => ({
30+
runCommandCapture: runCommandCaptureMock
31+
}))
32+
33+
vi.mock("../src/services/projects.js", () => ({
34+
getProject: getProjectMock,
35+
getProjectItemById: getProjectItemByIdMock,
36+
upProject: upProjectMock
37+
}))
38+
39+
const projectId = "/controller/org/repo/issue-7"
40+
const projectKey = "repo-issue-7"
41+
const displayName = "org/repo"
42+
43+
const projectItem = {
44+
authorizedKeysExists: true,
45+
authorizedKeysPath: "/controller/org/repo/issue-7/authorized_keys",
46+
codexAuthPath: "/controller/org/repo/issue-7/.orch/auth/codex",
47+
codexHome: "/home/dev/.codex",
48+
containerName: "dg-repo-issue-7",
49+
displayName,
50+
envGlobalPath: "/controller/org/repo/issue-7/.orch/env/global.env",
51+
envProjectPath: "/controller/org/repo/issue-7/.orch/env/project.env",
52+
lastKnownStatus: "running",
53+
lastStartAction: "up",
54+
lastStartedAtEpochMs: 1_778_000_000_000,
55+
lastStartedAtIso: "2026-05-06T19:00:00.000Z",
56+
projectDir: projectId,
57+
repoRef: "issue-7",
58+
repoUrl: "https://github.com/org/repo.git",
59+
serviceName: "app",
60+
sshCommand: "ssh -p 2222 dev@localhost",
61+
sshKeyPath: null,
62+
sshPort: 2222,
63+
sshUser: "dev",
64+
targetDir: "/home/dev/app"
65+
} satisfies ProjectItem
66+
67+
const projectDetails = {
68+
authorizedKeysExists: true,
69+
authorizedKeysPath: "/controller/org/repo/issue-7/authorized_keys",
70+
clonedOnHostname: "host",
71+
codexAuthPath: "/controller/org/repo/issue-7/.orch/auth/codex",
72+
codexHome: "/home/dev/.codex",
73+
containerName: "dg-repo-issue-7",
74+
displayName,
75+
envGlobalPath: "/controller/org/repo/issue-7/.orch/env/global.env",
76+
envProjectPath: "/controller/org/repo/issue-7/.orch/env/project.env",
77+
id: projectId,
78+
projectDir: projectId,
79+
projectKey,
80+
repoRef: "issue-7",
81+
repoUrl: "https://github.com/org/repo.git",
82+
serviceName: "app",
83+
sshCommand: "ssh -p 2222 dev@localhost",
84+
sshPort: 2222,
85+
sshSessions: 0,
86+
sshUser: "dev",
87+
startedAtEpochMs: 1_778_000_000_000,
88+
startedAtIso: "2026-05-06T19:00:00.000Z",
89+
status: "running",
90+
statusLabel: "Up",
91+
targetDir: "/home/dev/app"
92+
} satisfies ProjectDetails
93+
94+
const cleanupSessions = (): Effect.Effect<void, never> =>
95+
Effect.forEach(
96+
listProjectTerminalSessions(projectId),
97+
(session) => deleteTerminalSession(projectId, session.id).pipe(Effect.catchAll(() => Effect.void)),
98+
{ discard: true }
99+
)
100+
101+
const runTestEffect = <A>(effect: Effect.Effect<A, unknown, unknown>): Promise<A> =>
102+
Effect.runPromise(effect as Effect.Effect<A, unknown, never>)
103+
104+
const phaseFromEvent = (event: { readonly payload: unknown }): string | null => {
105+
if (typeof event.payload !== "object" || event.payload === null || !Object.hasOwn(event.payload, "phase")) {
106+
return null
107+
}
108+
return String(Reflect.get(event.payload, "phase"))
109+
}
110+
111+
describe("terminal sessions service", () => {
112+
beforeEach(() => {
113+
clearProjectEvents(projectId)
114+
prepareProjectSshMock.mockReset()
115+
probeProjectSshReadyMock.mockReset()
116+
runCommandCaptureMock.mockReset()
117+
upProjectMock.mockReset()
118+
getProjectMock.mockReset()
119+
getProjectItemByIdMock.mockReset()
120+
waitForProjectSshReadyMock.mockReset()
121+
122+
prepareProjectSshMock.mockReturnValue({
123+
args: ["-p", "2222", "dev@localhost"],
124+
command: "ssh",
125+
cwd: "/repo",
126+
item: projectItem
127+
})
128+
runCommandCaptureMock.mockImplementation(() => Effect.fail(new Error("docker inspect skipped in tests")))
129+
getProjectItemByIdMock.mockImplementation(() => Effect.succeed(projectItem))
130+
})
131+
132+
afterEach(() => {
133+
Effect.runSync(cleanupSessions())
134+
clearProjectEvents(projectId)
135+
})
136+
137+
it("creates a terminal session immediately when SSH is already ready", async () => {
138+
probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true))
139+
getProjectMock.mockImplementation(() => Effect.succeed(projectDetails))
140+
141+
const result = await runTestEffect(createTerminalSession(projectId))
142+
const events = listProjectEventsSince(projectId, 0)
143+
const phases = events
144+
.filter((event) => event.type === "project.deployment.status")
145+
.map(phaseFromEvent)
146+
.filter((phase): phase is string => phase !== null)
147+
148+
expect(upProjectMock).not.toHaveBeenCalled()
149+
expect(waitForProjectSshReadyMock).not.toHaveBeenCalled()
150+
expect(getProjectMock).toHaveBeenCalledWith(projectId)
151+
expect(result.project).toEqual(projectDetails)
152+
expect(result.session.projectId).toBe(projectId)
153+
expect(result.session.sshCommand).toBe("ssh -p 2222 dev@localhost")
154+
expect(phases).toEqual(["ssh.prepare", "ssh.fast-ready"])
155+
})
156+
157+
it("falls back to project startup and SSH wait when SSH is not ready", async () => {
158+
probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(false))
159+
upProjectMock.mockImplementation(() => Effect.succeed(projectDetails))
160+
waitForProjectSshReadyMock.mockImplementation(() => Effect.void)
161+
162+
const result = await runTestEffect(createTerminalSession(projectId))
163+
const events = listProjectEventsSince(projectId, 0)
164+
const phases = events
165+
.filter((event) => event.type === "project.deployment.status")
166+
.map(phaseFromEvent)
167+
.filter((phase): phase is string => phase !== null)
168+
169+
expect(upProjectMock).toHaveBeenCalledWith(projectId, undefined, true, { startupMode: "ssh-open" })
170+
expect(waitForProjectSshReadyMock).toHaveBeenCalledTimes(1)
171+
expect(getProjectMock).not.toHaveBeenCalled()
172+
expect(result.project).toEqual(projectDetails)
173+
expect(result.session.projectId).toBe(projectId)
174+
expect(phases).toEqual(["ssh.prepare", "ssh.wait", "ssh.ready", "ssh.post-start"])
175+
})
176+
})

0 commit comments

Comments
 (0)