| ... | @@ -9,10 +9,15 @@ | ... | @@ -9,10 +9,15 @@ |
| 9 | import { | 9 | import { |
| 10 | DEFAULT_TERMINAL_ID, | 10 | DEFAULT_TERMINAL_ID, |
| 11 | TerminalCwdError, | 11 | TerminalCwdError, |
| | 12 | TerminalCwdNotDirectoryError, |
| | 13 | TerminalCwdNotFoundError, |
| | 14 | TerminalCwdStatError, |
| 12 | TerminalError, | 15 | TerminalError, |
| 13 | TerminalHistoryError, | 16 | TerminalHistoryError, |
| 14 | TerminalNotRunningError, | 17 | TerminalNotRunningError, |
| | 18 | TerminalResizeError, |
| 15 | TerminalSessionLookupError, | 19 | TerminalSessionLookupError, |
| | 20 | TerminalWriteError, |
| 16 | type TerminalAttachInput, | 21 | type TerminalAttachInput, |
| 17 | type TerminalAttachStreamEvent, | 22 | type TerminalAttachStreamEvent, |
| 18 | type TerminalClearInput, | 23 | type TerminalClearInput, |
| ... | @@ -58,10 +63,15 @@ import * as PtyAdapter from "./PtyAdapter.ts"; | ... | @@ -58,10 +63,15 @@ import * as PtyAdapter from "./PtyAdapter.ts"; |
| 58 | | 63 | |
| 59 | export { | 64 | export { |
| 60 | TerminalCwdError, | 65 | TerminalCwdError, |
| | 66 | TerminalCwdNotDirectoryError, |
| | 67 | TerminalCwdNotFoundError, |
| | 68 | TerminalCwdStatError, |
| 61 | TerminalError, | 69 | TerminalError, |
| 62 | TerminalHistoryError, | 70 | TerminalHistoryError, |
| 63 | TerminalNotRunningError, | 71 | TerminalNotRunningError, |
| | 72 | TerminalResizeError, |
| 64 | TerminalSessionLookupError, | 73 | TerminalSessionLookupError, |
| | 74 | TerminalWriteError, |
| 65 | }; | 75 | }; |
| 66 | | 76 | |
| 67 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; | 77 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; |
| ... | @@ -190,6 +200,25 @@ interface TerminalSubprocessInspector { | ... | @@ -190,6 +200,25 @@ interface TerminalSubprocessInspector { |
| 190 | ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>; | 200 | ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>; |
| 191 | } | 201 | } |
| 192 | | 202 | |
| | 203 | const resizePtyProcess = ( |
| | 204 | session: TerminalSessionState, |
| | 205 | process: PtyAdapter.PtyProcess, |
| | 206 | cols: number, |
| | 207 | rows: number, |
| | 208 | ) => |
| | 209 | Effect.try({ |
| | 210 | try: () => process.resize(cols, rows), |
| | 211 | catch: (cause) => |
| | 212 | new TerminalResizeError({ |
| | 213 | threadId: session.threadId, |
| | 214 | terminalId: session.terminalId, |
| | 215 | terminalPid: process.pid, |
| | 216 | cols, |
| | 217 | rows, |
| | 218 | cause, |
| | 219 | }), |
| | 220 | }); |
| | 221 | |
| 193 | export interface ShellCandidate { | 222 | export interface ShellCandidate { |
| 194 | shell: string; | 223 | shell: string; |
| 195 | args?: string[]; | 224 | args?: string[]; |
| ... | @@ -1157,16 +1186,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -1157,16 +1186,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1157 | const legacyHistoryPath = (threadId: string) => | 1186 | const legacyHistoryPath = (threadId: string) => |
| 1158 | path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); | 1187 | path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); |
| 1159 | | 1188 | |
| 1160 | const toTerminalHistoryError = | | |
| 1161 | (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) => | | |
| 1162 | (cause: unknown) => | | |
| 1163 | new TerminalHistoryError({ | | |
| 1164 | operation, | | |
| 1165 | threadId, | | |
| 1166 | terminalId, | | |
| 1167 | cause, | | |
| 1168 | }); | | |
| 1169 | | | |
| 1170 | const readManagerState = SynchronizedRef.get(managerStateRef); | 1189 | const readManagerState = SynchronizedRef.get(managerStateRef); |
| 1171 | | 1190 | |
| 1172 | const modifyManagerState = <A>( | 1191 | const modifyManagerState = <A>( |
| ... | @@ -1250,7 +1269,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -1250,7 +1269,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1250 | threadId, | 1269 | threadId, |
| 1251 | terminalId, | 1270 | terminalId, |
| 1252 | signal: "SIGTERM", | 1271 | signal: "SIGTERM", |
| 1253 | error: error.message, | 1272 | cause: error, |
| 1254 | }).pipe(Effect.as(false)), | 1273 | }).pipe(Effect.as(false)), |
| 1255 | ), | 1274 | ), |
| 1256 | ); | 1275 | ); |
| ... | @@ -1274,7 +1293,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -1274,7 +1293,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1274 | threadId, | 1293 | threadId, |
| 1275 | terminalId, | 1294 | terminalId, |
| 1276 | signal: "SIGKILL", | 1295 | signal: "SIGKILL", |
| 1277 | error: error.message, | 1296 | cause: error, |
| 1278 | }), | 1297 | }), |
| 1279 | ), | 1298 | ), |
| 1280 | ); | 1299 | ); |
| ... | @@ -1372,16 +1391,29 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -1372,16 +1391,29 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1372 | if ( | 1391 | if ( |
| 1373 | yield* fileSystem | 1392 | yield* fileSystem |
| 1374 | .exists(nextPath) | 1393 | .exists(nextPath) |
| 1375 | .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))) | 1394 | .pipe( |
| | 1395 | Effect.mapError( |
| | 1396 | (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), |
| | 1397 | ), |
| | 1398 | ) |
| 1376 | ) { | 1399 | ) { |
| 1377 | const raw = yield* fileSystem | 1400 | const raw = yield* fileSystem |
| 1378 | .readFileString(nextPath) | 1401 | .readFileString(nextPath) |
| 1379 | .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); | 1402 | .pipe( |
| | 1403 | Effect.mapError( |
| | 1404 | (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), |
| | 1405 | ), |
| | 1406 | ); |
| 1380 | const capped = capHistory(raw, historyLineLimit); | 1407 | const capped = capHistory(raw, historyLineLimit); |
| 1381 | if (capped !== raw) { | 1408 | if (capped !== raw) { |
| 1382 | yield* fileSystem | 1409 | yield* fileSystem |
| 1383 | .writeFileString(nextPath, capped) | 1410 | .writeFileString(nextPath, capped) |
| 1384 | .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); | 1411 | .pipe( |
| | 1412 | Effect.mapError( |
| | 1413 | (cause) => |
| | 1414 | new TerminalHistoryError({ operation: "truncate", threadId, terminalId, cause }), |
| | 1415 | ), |
| | 1416 | ); |
| 1385 | } | 1417 | } |
| 1386 | return capped; | 1418 | return capped; |
| 1387 | } | 1419 | } |
| ... | @@ -1394,18 +1426,33 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -1394,18 +1426,33 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1394 | if ( | 1426 | if ( |
| 1395 | !(yield* fileSystem | 1427 | !(yield* fileSystem |
| 1396 | .exists(legacyPath) | 1428 | .exists(legacyPath) |
| 1397 | .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) | 1429 | .pipe( |
| | 1430 | Effect.mapError( |
| | 1431 | (cause) => |
| | 1432 | new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), |
| | 1433 | ), |
| | 1434 | )) |
| 1398 | ) { | 1435 | ) { |
| 1399 | return ""; | 1436 | return ""; |
| 1400 | } | 1437 | } |
| 1401 | | 1438 | |
| 1402 | const raw = yield* fileSystem | 1439 | const raw = yield* fileSystem |
| 1403 | .readFileString(legacyPath) | 1440 | .readFileString(legacyPath) |
| 1404 | .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); | 1441 | .pipe( |
| | 1442 | Effect.mapError( |
| | 1443 | (cause) => |
| | 1444 | new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), |
| | 1445 | ), |
| | 1446 | ); |
| 1405 | const capped = capHistory(raw, historyLineLimit); | 1447 | const capped = capHistory(raw, historyLineLimit); |
| 1406 | yield* fileSystem | 1448 | yield* fileSystem |
| 1407 | .writeFileString(nextPath, capped) | 1449 | .writeFileString(nextPath, capped) |
| 1408 | .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); | 1450 | .pipe( |
| | 1451 | Effect.mapError( |
| | 1452 | (cause) => |
| | 1453 | new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), |
| | 1454 | ), |
| | 1455 | ); |
| 1409 | yield* fileSystem.remove(legacyPath, { force: true }).pipe( | 1456 | yield* fileSystem.remove(legacyPath, { force: true }).pipe( |
| 1410 | Effect.catch((cleanupError) => | 1457 | Effect.catch((cleanupError) => |
| 1411 | Effect.logWarning("failed to remove legacy terminal history", { | 1458 | Effect.logWarning("failed to remove legacy terminal history", { |
| ... | @@ -1472,20 +1519,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -1472,20 +1519,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1472 | | 1519 | |
| 1473 | const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { | 1520 | const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { |
| 1474 | const stats = yield* fileSystem.stat(cwd).pipe( | 1521 | const stats = yield* fileSystem.stat(cwd).pipe( |
| 1475 | Effect.mapError( | 1522 | Effect.catchTags({ |
| 1476 | (cause) => | 1523 | PlatformError: (cause) => |
| 1477 | new TerminalCwdError({ | 1524 | cause.reason._tag === "NotFound" |
| 1478 | cwd, | 1525 | ? new TerminalCwdNotFoundError({ cwd }) |
| 1479 | reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed", | 1526 | : new TerminalCwdStatError({ cwd, cause }), |
| 1480 | cause, | 1527 | }), |
| 1481 | }), | | |
| 1482 | ), | | |
| 1483 | ); | 1528 | ); |
| 1484 | if (stats.type !== "Directory") { | 1529 | if (stats.type !== "Directory") { |
| 1485 | return yield* new TerminalCwdError({ | 1530 | return yield* new TerminalCwdNotDirectoryError({ cwd }); |
| 1486 | cwd, | | |
| 1487 | reason: "notDirectory", | | |
| 1488 | }); | | |
| 1489 | } | 1531 | } |
| 1490 | }); | 1532 | }); |
| 1491 | | 1533 | |
| ... | @@ -1881,7 +1923,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -1881,7 +1923,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1881 | yield* Effect.logError("failed to start terminal", { | 1923 | yield* Effect.logError("failed to start terminal", { |
| 1882 | threadId: session.threadId, | 1924 | threadId: session.threadId, |
| 1883 | terminalId: session.terminalId, | 1925 | terminalId: session.terminalId, |
| 1884 | error: message, | 1926 | cause: error, |
| 1885 | ...(startedShell ? { shell: startedShell } : {}), | 1927 | ...(startedShell ? { shell: startedShell } : {}), |
| 1886 | }); | 1928 | }); |
| 1887 | } | 1929 | } |
| ... | @@ -2168,10 +2210,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -2168,10 +2210,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2168 | } | 2210 | } |
| 2169 | | 2211 | |
| 2170 | if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { | 2212 | if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { |
| | 2213 | yield* resizePtyProcess(liveSession, liveSession.process, targetCols, targetRows); |
| 2171 | liveSession.cols = targetCols; | 2214 | liveSession.cols = targetCols; |
| 2172 | liveSession.rows = targetRows; | 2215 | liveSession.rows = targetRows; |
| 2173 | liveSession.updatedAt = yield* nowIso; | 2216 | liveSession.updatedAt = yield* nowIso; |
| 2174 | liveSession.process.resize(targetCols, targetRows); | | |
| 2175 | } | 2217 | } |
| 2176 | | 2218 | |
| 2177 | return snapshot(liveSession); | 2219 | return snapshot(liveSession); |
| ... | @@ -2219,10 +2261,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -2219,10 +2261,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2219 | session.status === "running" && | 2261 | session.status === "running" && |
| 2220 | (session.cols !== targetCols || session.rows !== targetRows) | 2262 | (session.cols !== targetCols || session.rows !== targetRows) |
| 2221 | ) { | 2263 | ) { |
| | 2264 | const process = session.process; |
| | 2265 | yield* resizePtyProcess(session, process, targetCols, targetRows); |
| 2222 | session.cols = targetCols; | 2266 | session.cols = targetCols; |
| 2223 | session.rows = targetRows; | 2267 | session.rows = targetRows; |
| 2224 | session.updatedAt = yield* nowIso; | 2268 | session.updatedAt = yield* nowIso; |
| 2225 | yield* Effect.sync(() => session.process?.resize(targetCols, targetRows)); | | |
| 2226 | } | 2269 | } |
| 2227 | | 2270 | |
| 2228 | return snapshot(session); | 2271 | return snapshot(session); |
| ... | @@ -2409,7 +2452,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -2409,7 +2452,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2409 | terminalId, | 2452 | terminalId, |
| 2410 | }); | 2453 | }); |
| 2411 | } | 2454 | } |
| 2412 | yield* Effect.sync(() => process.write(input.data)); | 2455 | yield* Effect.try({ |
| | 2456 | try: () => process.write(input.data), |
| | 2457 | catch: (cause) => |
| | 2458 | new TerminalWriteError({ |
| | 2459 | threadId: input.threadId, |
| | 2460 | terminalId, |
| | 2461 | terminalPid: process.pid, |
| | 2462 | cause, |
| | 2463 | }), |
| | 2464 | }); |
| 2413 | }); | 2465 | }); |
| 2414 | | 2466 | |
| 2415 | const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { | 2467 | const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { |
| ... | @@ -2422,10 +2474,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -2422,10 +2474,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2422 | if (!process || session.value.status !== "running") { | 2474 | if (!process || session.value.status !== "running") { |
| 2423 | return; | 2475 | return; |
| 2424 | } | 2476 | } |
| | 2477 | yield* resizePtyProcess(session.value, process, input.cols, input.rows); |
| 2425 | session.value.cols = input.cols; | 2478 | session.value.cols = input.cols; |
| 2426 | session.value.rows = input.rows; | 2479 | session.value.rows = input.rows; |
| 2427 | session.value.updatedAt = yield* nowIso; | 2480 | session.value.updatedAt = yield* nowIso; |
| 2428 | yield* Effect.sync(() => process.resize(input.cols, input.rows)); | | |
| 2429 | }); | 2481 | }); |
| 2430 | | 2482 | |
| 2431 | const resize: TerminalManager["Service"]["resize"] = (input) => | 2483 | const resize: TerminalManager["Service"]["resize"] = (input) => |