Skip to content

Commit c3c069d

Browse files
committed
feat(docker-git): persist terminals and expose container tasks
- add API, CLI, and web task views for SSH/agent/container processes - persist browser terminal tabs and replay terminal output after reconnect - align project search and launch-time ordering across CLI and web
1 parent 05378a7 commit c3c069d

51 files changed

Lines changed: 2302 additions & 203 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/api/src/api/contracts.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type ProjectSummary = {
1010
readonly displayName: string
1111
readonly repoUrl: string
1212
readonly repoRef: string
13+
readonly containerName?: string | undefined
1314
readonly status: ProjectStatus
1415
readonly statusLabel: string
1516
readonly sshSessions: number
@@ -367,6 +368,31 @@ export type TerminalSession = {
367368
readonly signal?: number | undefined
368369
}
369370

371+
export type ContainerTaskKind = "ssh" | "web-terminal" | "agent" | "background" | "system"
372+
373+
export type ContainerTask = {
374+
readonly pid: number
375+
readonly ppid: number
376+
readonly user: string
377+
readonly tty: string
378+
readonly etime: string
379+
readonly etimes: number
380+
readonly command: string
381+
readonly kind: ContainerTaskKind
382+
readonly managedId?: string | undefined
383+
readonly logAvailable: boolean
384+
}
385+
386+
export type ContainerTaskSnapshot = {
387+
readonly projectId: string
388+
readonly containerName: string
389+
readonly generatedAt: string
390+
readonly sshConnections: number
391+
readonly tasks: ReadonlyArray<ContainerTask>
392+
readonly terminalSessions: ReadonlyArray<TerminalSession>
393+
readonly agents: ReadonlyArray<AgentSession>
394+
}
395+
370396
export type ForgeFedTicket = {
371397
readonly id: string
372398
readonly attributedTo: string

packages/api/src/http.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { streamGithubAuthLogin } from "./services/auth-github-login-stream.js"
4848
import { createAuthTerminalSession, deleteAuthTerminalSession } from "./services/auth-terminal-sessions.js"
4949
import { streamCodexAuthLogin } from "./services/auth-codex-login-stream.js"
5050
import { getAgent, getAgentAttachInfo, listAgents, readAgentLogs, startAgent, stopAgent } from "./services/agents.js"
51+
import { readContainerTaskLogs, readContainerTaskSnapshot, stopContainerTask } from "./services/container-tasks.js"
5152
import { latestProjectCursor, listProjectEventsSince } from "./services/events.js"
5253
import {
5354
createFollowSubscription,
@@ -135,6 +136,11 @@ const TerminalSessionParamsSchema = Schema.Struct({
135136
sessionId: Schema.String
136137
})
137138

139+
const ContainerTaskParamsSchema = Schema.Struct({
140+
projectId: Schema.String,
141+
pid: Schema.String
142+
})
143+
138144
const AuthTerminalSessionParamsSchema = Schema.Struct({
139145
sessionId: Schema.String
140146
})
@@ -213,6 +219,18 @@ const parsePortParam = (value: string): Effect.Effect<number, ApiBadRequestError
213219
: Effect.fail(new ApiBadRequestError({ message: `Invalid port: ${value}` }))
214220
}
215221

222+
const parsePidParam = (value: string): Effect.Effect<number, ApiBadRequestError> => {
223+
const parsed = Number.parseInt(value, 10)
224+
return String(parsed) === value && parsed > 0
225+
? Effect.succeed(parsed)
226+
: Effect.fail(new ApiBadRequestError({ message: `Invalid pid: ${value}` }))
227+
}
228+
229+
const parseQueryBoolean = (url: string, key: string): boolean => {
230+
const value = new URL(url, "http://localhost").searchParams.get(key)
231+
return value === "1" || value === "true" || value === "yes"
232+
}
233+
216234
const hostWithoutPort = (host: string): string => {
217235
if (host.startsWith("[")) {
218236
const end = host.indexOf("]")
@@ -289,6 +307,7 @@ const projectPortForwardParams = HttpRouter.schemaParams(ProjectPortForwardParam
289307
const projectDatabaseProfileParams = HttpRouter.schemaParams(ProjectDatabaseProfileParamsSchema)
290308
const agentParams = HttpRouter.schemaParams(AgentParamsSchema)
291309
const terminalSessionParams = HttpRouter.schemaParams(TerminalSessionParamsSchema)
310+
const containerTaskParams = HttpRouter.schemaParams(ContainerTaskParamsSchema)
292311
const authTerminalSessionParams = HttpRouter.schemaParams(AuthTerminalSessionParamsSchema)
293312

294313
const readCreateProjectRequest = () => HttpServerRequest.schemaBodyJson(CreateProjectRequestSchema)
@@ -914,6 +933,37 @@ export const makeRouter = () => {
914933
Effect.catchAll(errorResponse)
915934
)
916935
),
936+
HttpRouter.get(
937+
"/projects/:projectId/tasks",
938+
Effect.gen(function*(_) {
939+
const { projectId } = yield* _(projectParams)
940+
const request = yield* _(HttpServerRequest.HttpServerRequest)
941+
const snapshot = yield* _(readContainerTaskSnapshot(projectId, parseQueryBoolean(request.url, "includeDefault")))
942+
return yield* _(jsonResponse({ snapshot }, 200))
943+
}).pipe(Effect.catchAll(errorResponse))
944+
),
945+
HttpRouter.post(
946+
"/projects/:projectId/tasks/:pid/stop",
947+
containerTaskParams.pipe(
948+
Effect.flatMap(({ projectId, pid }) =>
949+
parsePidParam(pid).pipe(
950+
Effect.flatMap((taskPid) => stopContainerTask(projectId, taskPid))
951+
)
952+
),
953+
Effect.flatMap(() => jsonResponse({ ok: true }, 200)),
954+
Effect.catchAll(errorResponse)
955+
)
956+
),
957+
HttpRouter.get(
958+
"/projects/:projectId/tasks/:pid/logs",
959+
Effect.gen(function*(_) {
960+
const { projectId, pid } = yield* _(containerTaskParams)
961+
const taskPid = yield* _(parsePidParam(pid))
962+
const request = yield* _(HttpServerRequest.HttpServerRequest)
963+
const output = yield* _(readContainerTaskLogs(projectId, taskPid, parseQueryInt(request.url, "lines", 200)))
964+
return yield* _(jsonResponse({ output }, 200))
965+
}).pipe(Effect.catchAll(errorResponse))
966+
),
917967
HttpRouter.post(
918968
"/projects/:projectId/recreate",
919969
projectParams.pipe(

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

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import { WebSocket, WebSocketServer, type RawData } from "ws"
1010
import type { AuthTerminalFlow, AuthTerminalSessionRequest, TerminalSession, TerminalSessionStatus } from "../api/contracts.js"
1111
import { ApiConflictError, ApiNotFoundError, describeUnknown } from "../api/errors.js"
1212
import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js"
13+
import {
14+
appendTerminalOutput,
15+
emptyTerminalOutputBuffer,
16+
renderTerminalOutputBuffer,
17+
type TerminalOutputBuffer
18+
} from "./terminal-output-buffer.js"
1319
import { attachWebSocketHeartbeat } from "./websocket-heartbeat.js"
1420

1521
type TerminalClientMessage =
@@ -28,13 +34,13 @@ type AuthTerminalRecord = {
2834
args: ReadonlyArray<string>
2935
cwd: string
3036
detachTimeout: ReturnType<typeof setTimeout> | null
37+
outputBuffer: TerminalOutputBuffer
3138
pty: PtyBridge | null
3239
session: TerminalSession
3340
socket: WebSocket | null
3441
}
3542

3643
const attachTimeoutMs = 30_000
37-
const reconnectGraceMs = 60_000
3844
const authTerminalProjectId = "__controller__"
3945
const authTerminalWsPathPattern = /^(?:\/api)?\/auth\/terminal-sessions\/([^/]+)\/ws$/u
4046
const authRunnerPath = fileURLToPath(new URL("../auth-terminal-runner.js", import.meta.url))
@@ -91,6 +97,18 @@ const sendServerMessage = (socket: WebSocket | null, message: TerminalServerMess
9197
socket.send(encodeServerMessage(message))
9298
}
9399

100+
const sendTerminalOutput = (record: AuthTerminalRecord, data: string): void => {
101+
record.outputBuffer = appendTerminalOutput(record.outputBuffer, data)
102+
sendServerMessage(record.socket, { type: "output", data })
103+
}
104+
105+
const replayTerminalOutput = (record: AuthTerminalRecord, socket: WebSocket): void => {
106+
const data = renderTerminalOutputBuffer(record.outputBuffer)
107+
if (data.length > 0) {
108+
sendServerMessage(socket, { type: "output", data })
109+
}
110+
}
111+
94112
const clearAttachTimeout = (record: AuthTerminalRecord): void => {
95113
if (record.attachTimeout !== null) {
96114
clearTimeout(record.attachTimeout)
@@ -201,7 +219,7 @@ const startTerminalPty = (record: AuthTerminalRecord, cols: number, rows: number
201219
status: "attached"
202220
})
203221
pty.onData((data) => {
204-
sendServerMessage(record.socket, { type: "output", data })
222+
sendTerminalOutput(record, data)
205223
})
206224
pty.onExit(({ exitCode, signal }) => {
207225
finalizeRecord(
@@ -234,6 +252,7 @@ const registerRecord = (request: AuthTerminalSessionRequest): TerminalSession =>
234252
attachTimeout: null,
235253
cwd: process.cwd(),
236254
detachTimeout: null,
255+
outputBuffer: emptyTerminalOutputBuffer,
237256
pty: null,
238257
session,
239258
socket: null
@@ -243,14 +262,6 @@ const registerRecord = (request: AuthTerminalSessionRequest): TerminalSession =>
243262
return session
244263
}
245264

246-
const createDetachTimeout = (sessionId: string): ReturnType<typeof setTimeout> =>
247-
setTimeout(() => {
248-
const record = records.get(sessionId)
249-
if (record !== undefined && record.socket === null) {
250-
cleanupRecord(record)
251-
}
252-
}, reconnectGraceMs)
253-
254265
const detachSocketFromRecord = (
255266
record: AuthTerminalRecord,
256267
socket: WebSocket
@@ -261,7 +272,6 @@ const detachSocketFromRecord = (
261272
}
262273
current.socket = null
263274
clearDetachTimeout(current)
264-
current.detachTimeout = createDetachTimeout(current.session.id)
265275
}
266276

267277
const handleSocketMessage = (record: AuthTerminalRecord, raw: RawData): void => {
@@ -296,6 +306,7 @@ const attachSocketToRecord = (
296306
attachWebSocketHeartbeat(socket)
297307
startTerminalPty(record, cols, rows)
298308
sendServerMessage(socket, { type: "ready", session: record.session })
309+
replayTerminalOutput(record, socket)
299310
socket.on("message", (raw: RawData) => {
300311
handleSocketMessage(record, raw)
301312
})
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { AgentSession, ContainerTask, ContainerTaskKind } from "../api/contracts.js"
2+
3+
export type RawContainerProcess = {
4+
readonly pid: number
5+
readonly ppid: number
6+
readonly user: string
7+
readonly tty: string
8+
readonly etime: string
9+
readonly etimes: number
10+
readonly command: string
11+
readonly baseline: boolean
12+
readonly logAvailable: boolean
13+
}
14+
15+
export type ManagedAgentPid = {
16+
readonly agentId: string
17+
readonly pid: number
18+
}
19+
20+
const interactiveAgentPattern = /\b(codex|claude|opencode|gemini)\b/u
21+
22+
const hasInteractiveTty = (process: RawContainerProcess): boolean =>
23+
process.tty !== "?" && process.tty.trim().length > 0
24+
25+
const commandSuggestsSsh = (command: string): boolean => command.startsWith("sshd:") || command.includes(" sshd:")
26+
27+
const commandSuggestsAgent = (command: string): boolean =>
28+
interactiveAgentPattern.test(command) || command.includes("docker-git-agent-")
29+
30+
const resolveAncestorManagedId = (
31+
process: RawContainerProcess,
32+
pidToProcess: ReadonlyMap<number, RawContainerProcess>,
33+
managedPidToAgentId: ReadonlyMap<number, string>
34+
): string | undefined => {
35+
let cursor: RawContainerProcess | undefined = process
36+
const seen = new Set<number>()
37+
while (cursor !== undefined && !seen.has(cursor.pid)) {
38+
seen.add(cursor.pid)
39+
const managedId = managedPidToAgentId.get(cursor.pid)
40+
if (managedId !== undefined) {
41+
return managedId
42+
}
43+
cursor = pidToProcess.get(cursor.ppid)
44+
}
45+
return undefined
46+
}
47+
48+
const classifyProcess = (
49+
process: RawContainerProcess,
50+
managedId: string | undefined
51+
): ContainerTaskKind => {
52+
if (managedId !== undefined || commandSuggestsAgent(process.command)) {
53+
return "agent"
54+
}
55+
if (process.baseline) {
56+
return "system"
57+
}
58+
if (commandSuggestsSsh(process.command) || hasInteractiveTty(process)) {
59+
return "ssh"
60+
}
61+
return "background"
62+
}
63+
64+
const compareTasks = (left: ContainerTask, right: ContainerTask): number => {
65+
const byKind = left.kind.localeCompare(right.kind)
66+
if (byKind !== 0) {
67+
return byKind
68+
}
69+
const byRuntime = right.etimes - left.etimes
70+
return byRuntime !== 0 ? byRuntime : left.pid - right.pid
71+
}
72+
73+
// CHANGE: classify raw container processes into stable task-manager rows.
74+
// WHY: API, CLI and WEB must share one total model for terminal/task visibility.
75+
// QUOTE(ТЗ): "можем мы сделать возможность видеть все запщуенные терминалы в контейнере?"
76+
// REF: user-message-2026-04-22-container-task-manager
77+
// SOURCE: n/a
78+
// FORMAT THEOREM: forall p in Processes: classify(p) in ContainerTaskKind
79+
// PURITY: CORE
80+
// EFFECT: none
81+
// INVARIANT: includeDefault=false excludes only baseline system processes
82+
// COMPLEXITY: O(n * h) where h is maximum process ancestry depth
83+
export const buildContainerTasks = (
84+
processes: ReadonlyArray<RawContainerProcess>,
85+
managedAgentPids: ReadonlyArray<ManagedAgentPid>,
86+
includeDefault: boolean
87+
): ReadonlyArray<ContainerTask> => {
88+
const pidToProcess = new Map(processes.map((process) => [process.pid, process]))
89+
const managedPidToAgentId = new Map(managedAgentPids.map((entry) => [entry.pid, entry.agentId]))
90+
91+
return processes
92+
.map((process) => {
93+
const managedId = resolveAncestorManagedId(process, pidToProcess, managedPidToAgentId)
94+
const kind = classifyProcess(process, managedId)
95+
return {
96+
pid: process.pid,
97+
ppid: process.ppid,
98+
user: process.user,
99+
tty: process.tty,
100+
etime: process.etime,
101+
etimes: process.etimes,
102+
command: process.command,
103+
kind,
104+
...(managedId === undefined ? {} : { managedId }),
105+
logAvailable: process.logAvailable
106+
} satisfies ContainerTask
107+
})
108+
.filter((task) => includeDefault || task.kind !== "system")
109+
.sort(compareTasks)
110+
}
111+
112+
export const activeAgents = (agents: ReadonlyArray<AgentSession>): ReadonlyArray<AgentSession> =>
113+
agents.filter((agent) => !["stopped", "exited", "failed"].includes(agent.status))

0 commit comments

Comments
 (0)