| author | |
| committer | |
| log | 73b206f4bf99e7e98ae144a9d9edbf7cb2700fc1 |
| tree | 666297d86d075308a8aaab5f8d745452a498a294 |
| parent | b70015b6db8ffafd902174f78cb260cc8082fc6e |
| signature | Signed by PGP key B5690EEEBB952194 |
Starting a thread in a new worktree showed one static "Setting up worktree" line while git fetched, checked out files, and the setup script ran. Users could not tell which step was slow, see setup script output without hunting for the terminal, or stop a setup that was going wrong.
The timeline now shows a card with each step and its elapsed time. Check out files has a percent bar fed by git's own progress output. The setup script step shows the last lines of its terminal inline and links to the full terminal. Cancel stops the bootstrap and removes the half built worktree. Work locally cancels, switches the draft to the project checkout, and resends.
The server keeps an in-memory per-thread snapshot of the bootstrap stages and streams it over a new subscribeWorktreeSetup RPC. The bootstrap runs as a child fiber so worktreeSetup.cancel can interrupt it, and the turn handoff is uninterruptible. The setup script's exit code comes from a per-run sentinel echoed after the command in the setup PTY.
Created with Claude Fable 5.1 in Claude Code.24 files changed, 2352 insertions(+), 79 deletions(-)
apps/server/src/auth/RpcAuthorization.ts+2| ... | ... | @@ -111,6 +111,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 111 | 111 | [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, |
| 112 | 112 | [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, |
| 113 | 113 | [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, |
| 114 | [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, | |
| 115 | [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, | |
| 114 | 116 | [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, |
| 115 | 117 | [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, |
| 116 | 118 | [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, |
apps/server/src/git/GitWorkflowService.ts+3-2| ... | ... | @@ -69,6 +69,7 @@ export class GitWorkflowService extends Context.Service< |
| 69 | 69 | ) => Effect.Effect<VcsListRefsResult, GitCommandError>; |
| 70 | 70 | readonly createWorktree: ( |
| 71 | 71 | input: VcsCreateWorktreeInput, |
| 72 | options?: GitVcsDriver.CreateWorktreeOptions, | |
| 72 | 73 | ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>; |
| 73 | 74 | readonly fetchRemote: (input: { |
| 74 | 75 | readonly cwd: string; |
| ... | ... | @@ -338,9 +339,9 @@ export const make = Effect.gen(function* () { |
| 338 | 339 | isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), |
| 339 | 340 | ), |
| 340 | 341 | ), |
| 341 | createWorktree: (input) => | |
| 342 | createWorktree: (input, options) => | |
| 342 | 343 | ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( |
| 343 | Effect.andThen(git.createWorktree(input)), | |
| 344 | Effect.andThen(git.createWorktree(input, options)), | |
| 344 | 345 | ), |
| 345 | 346 | fetchRemote: (input) => |
| 346 | 347 | ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( |
apps/server/src/project/ProjectSetupScriptRunner.test.ts+212-6| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { describe, expect, it, vi } from "@effect/vitest"; |
| 2 | import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; | |
| 2 | import { type OrchestrationProject, ProjectId, type TerminalEvent } from "@t3tools/contracts"; | |
| 3 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as Layer from "effect/Layer"; |
| 5 | 6 | import * as Option from "effect/Option"; |
| ... | ... | @@ -54,11 +55,11 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => |
| 54 | 55 | searchThreads: () => Effect.succeed({ matches: [] }), |
| 55 | 56 | }); |
| 56 | 57 | |
| 57 | const makeTerminalManagerLayer = ( | |
| 58 | overrides: Pick<TerminalManager.TerminalManager["Service"], "open" | "write">, | |
| 59 | ) => | |
| 58 | type TerminalOverrides = Pick<TerminalManager.TerminalManager["Service"], "open" | "write"> & | |
| 59 | Partial<Pick<TerminalManager.TerminalManager["Service"], "subscribe">>; | |
| 60 | ||
| 61 | const makeTerminalManagerLayer = (overrides: TerminalOverrides) => | |
| 60 | 62 | Layer.succeed(TerminalManager.TerminalManager, { |
| 61 | ...overrides, | |
| 62 | 63 | attachStream: () => Effect.die(new Error("unused")), |
| 63 | 64 | resize: () => Effect.void, |
| 64 | 65 | clear: () => Effect.void, |
| ... | ... | @@ -66,11 +67,12 @@ const makeTerminalManagerLayer = ( |
| 66 | 67 | close: () => Effect.void, |
| 67 | 68 | subscribe: () => Effect.succeed(() => undefined), |
| 68 | 69 | subscribeMetadata: () => Effect.succeed(() => undefined), |
| 70 | ...overrides, | |
| 69 | 71 | }); |
| 70 | 72 | |
| 71 | 73 | const testLayer = ( |
| 72 | 74 | project: OrchestrationProject, |
| 73 | terminal: Pick<TerminalManager.TerminalManager["Service"], "open" | "write">, | |
| 75 | terminal: TerminalOverrides, | |
| 74 | 76 | settings = ServerSettings.layerTest(), |
| 75 | 77 | ) => |
| 76 | 78 | ProjectSetupScriptRunner.layer.pipe( |
| ... | ... | @@ -198,6 +200,7 @@ describe("ProjectSetupScriptRunner", () => { |
| 198 | 200 | status: "started", |
| 199 | 201 | scriptId: "setup", |
| 200 | 202 | scriptName: "Setup", |
| 203 | scriptCommand: "bun install", | |
| 201 | 204 | terminalId: "setup-setup", |
| 202 | 205 | cwd: "/repo/worktrees/a", |
| 203 | 206 | }); |
| ... | ... | @@ -220,6 +223,209 @@ describe("ProjectSetupScriptRunner", () => { |
| 220 | 223 | }, |
| 221 | 224 | ); |
| 222 | 225 | |
| 226 | it.effect( | |
| 227 | "wraps the command with a completion sentinel and resolves the exit code from terminal output", | |
| 228 | () => { | |
| 229 | const open = vi.fn(() => | |
| 230 | Effect.succeed({ | |
| 231 | threadId: "thread-1", | |
| 232 | terminalId: "setup-setup", | |
| 233 | cwd: "/repo/worktrees/a", | |
| 234 | worktreePath: "/repo/worktrees/a", | |
| 235 | status: "running" as const, | |
| 236 | pid: 123, | |
| 237 | history: "", | |
| 238 | exitCode: null, | |
| 239 | exitSignal: null, | |
| 240 | label: "setup-setup", | |
| 241 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 242 | }), | |
| 243 | ); | |
| 244 | const writes: string[] = []; | |
| 245 | const write = vi.fn((input: { data: string }) => | |
| 246 | Effect.sync(() => void writes.push(input.data)), | |
| 247 | ); | |
| 248 | let listener: ((event: TerminalEvent) => Effect.Effect<void>) | null = null; | |
| 249 | const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect<void>) => { | |
| 250 | listener = next; | |
| 251 | return Effect.succeed(() => { | |
| 252 | listener = null; | |
| 253 | }); | |
| 254 | }); | |
| 255 | const project = makeProject([ | |
| 256 | { | |
| 257 | id: "setup", | |
| 258 | name: "Setup", | |
| 259 | command: "bun install", | |
| 260 | icon: "configure", | |
| 261 | runOnWorktreeCreate: true, | |
| 262 | }, | |
| 263 | ]); | |
| 264 | const emit = (data: string) => | |
| 265 | Effect.suspend(() => | |
| 266 | listener | |
| 267 | ? listener({ threadId: "thread-1", terminalId: "setup-setup", type: "output", data }) | |
| 268 | : Effect.void, | |
| 269 | ); | |
| 270 | ||
| 271 | return Effect.gen(function* () { | |
| 272 | const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; | |
| 273 | const seen: string[] = []; | |
| 274 | const result = yield* runner.runForThread({ | |
| 275 | threadId: "thread-1", | |
| 276 | projectCwd: "/repo/project", | |
| 277 | worktreePath: "/repo/worktrees/a", | |
| 278 | observeCompletion: { | |
| 279 | onOutputLine: (line) => Effect.sync(() => void seen.push(line)), | |
| 280 | }, | |
| 281 | }); | |
| 282 | expect(result.status).toBe("started"); | |
| 283 | if (result.status !== "started") return; | |
| 284 | expect(result.completion).toBeDefined(); | |
| 285 | ||
| 286 | // The subscription is attached before the command is written. | |
| 287 | expect(subscribe).toHaveBeenCalledTimes(1); | |
| 288 | expect(writes).toHaveLength(1); | |
| 289 | // The block closes on its own line so a trailing comment in the | |
| 290 | // command cannot swallow the sentinel, and the sentinel carries a | |
| 291 | // per-run token so script output cannot spoof it. | |
| 292 | const written = writes[0] ?? ""; | |
| 293 | const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0]; | |
| 294 | expect(sentinel).toBeDefined(); | |
| 295 | expect(written).toBe(`( bun install\r); printf '\\n${sentinel}%s\\n' "$?"\r`); | |
| 296 | ||
| 297 | // Output arrives in chunks; partial lines are buffered until a newline, | |
| 298 | // control sequences are stripped, and the echoed wrapper is hidden. | |
| 299 | yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`); | |
| 300 | yield* emit("\u001b[32mResolving"); | |
| 301 | yield* emit(" deps\u001b[0m\r\nDone in 2s\r\n"); | |
| 302 | // A spoofed sentinel from the script itself must not settle completion. | |
| 303 | yield* emit("__T3_SETUP_DONE__:0\r\n"); | |
| 304 | yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`); | |
| 305 | yield* emit(`${sentinel}3\r\n`); | |
| 306 | ||
| 307 | const completion = yield* result.completion!; | |
| 308 | expect(completion.exitCode).toBe(3); | |
| 309 | expect(seen).toEqual([ | |
| 310 | "Resolving deps", | |
| 311 | "Done in 2s", | |
| 312 | "__T3_SETUP_DONE__:0", | |
| 313 | `__T3_SETUP_DONE___${"0".repeat(32)}:0`, | |
| 314 | ]); | |
| 315 | // The subscription is torn down once the sentinel arrives. | |
| 316 | expect(listener).toBeNull(); | |
| 317 | }).pipe( | |
| 318 | Effect.provide(testLayer(project, { open, write, subscribe })), | |
| 319 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 320 | Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), | |
| 321 | ); | |
| 322 | }, | |
| 323 | ); | |
| 324 | ||
| 325 | it.effect("unsubscribes from terminal output when the command cannot be written", () => { | |
| 326 | const open = vi.fn(() => | |
| 327 | Effect.succeed({ | |
| 328 | threadId: "thread-1", | |
| 329 | terminalId: "setup-setup", | |
| 330 | cwd: "/repo/worktrees/a", | |
| 331 | worktreePath: "/repo/worktrees/a", | |
| 332 | status: "running" as const, | |
| 333 | pid: 123, | |
| 334 | history: "", | |
| 335 | exitCode: null, | |
| 336 | exitSignal: null, | |
| 337 | label: "setup-setup", | |
| 338 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 339 | }), | |
| 340 | ); | |
| 341 | const write = vi.fn(() => | |
| 342 | Effect.fail( | |
| 343 | new TerminalManager.TerminalCwdStatError({ cwd: "/repo/worktrees/a", cause: {} }), | |
| 344 | ), | |
| 345 | ); | |
| 346 | const unsubscribe = vi.fn(); | |
| 347 | const subscribe = vi.fn(() => Effect.succeed(unsubscribe)); | |
| 348 | const project = makeProject([ | |
| 349 | { | |
| 350 | id: "setup", | |
| 351 | name: "Setup", | |
| 352 | command: "bun install", | |
| 353 | icon: "configure", | |
| 354 | runOnWorktreeCreate: true, | |
| 355 | }, | |
| 356 | ]); | |
| 357 | ||
| 358 | return Effect.gen(function* () { | |
| 359 | const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; | |
| 360 | const result = yield* runner | |
| 361 | .runForThread({ | |
| 362 | threadId: "thread-1", | |
| 363 | projectCwd: "/repo/project", | |
| 364 | worktreePath: "/repo/worktrees/a", | |
| 365 | observeCompletion: {}, | |
| 366 | }) | |
| 367 | .pipe(Effect.result); | |
| 368 | expect(result._tag).toBe("Failure"); | |
| 369 | expect(unsubscribe).toHaveBeenCalledTimes(1); | |
| 370 | }).pipe(Effect.provide(testLayer(project, { open, write, subscribe }))); | |
| 371 | }); | |
| 372 | ||
| 373 | it.effect.each([ | |
| 374 | { | |
| 375 | shell: "/usr/bin/fish", | |
| 376 | expected: | |
| 377 | /^begin\rbun install\rend; printf '\\n__T3_SETUP_DONE___[0-9a-f]{32}:%s\\n' \$status\r$/, | |
| 378 | }, | |
| 379 | { | |
| 380 | shell: "/bin/bash", | |
| 381 | expected: /^\( bun install\r\); printf '\\n__T3_SETUP_DONE___[0-9a-f]{32}:%s\\n' "\$\?"\r$/, | |
| 382 | }, | |
| 383 | ])("wraps the command for the $shell syntax", ({ shell, expected }) => { | |
| 384 | const open = vi.fn(() => | |
| 385 | Effect.succeed({ | |
| 386 | threadId: "thread-1", | |
| 387 | terminalId: "setup-setup", | |
| 388 | cwd: "/repo/worktrees/a", | |
| 389 | worktreePath: "/repo/worktrees/a", | |
| 390 | status: "running" as const, | |
| 391 | pid: 123, | |
| 392 | history: "", | |
| 393 | exitCode: null, | |
| 394 | exitSignal: null, | |
| 395 | label: "setup-setup", | |
| 396 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 397 | }), | |
| 398 | ); | |
| 399 | const writes: string[] = []; | |
| 400 | const write = vi.fn((input: { data: string }) => | |
| 401 | Effect.sync(() => void writes.push(input.data)), | |
| 402 | ); | |
| 403 | const project = makeProject([ | |
| 404 | { | |
| 405 | id: "setup", | |
| 406 | name: "Setup", | |
| 407 | command: "bun install", | |
| 408 | icon: "configure", | |
| 409 | runOnWorktreeCreate: true, | |
| 410 | }, | |
| 411 | ]); | |
| 412 | return Effect.gen(function* () { | |
| 413 | const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; | |
| 414 | yield* runner.runForThread({ | |
| 415 | threadId: "thread-1", | |
| 416 | projectCwd: "/repo/project", | |
| 417 | worktreePath: "/repo/worktrees/a", | |
| 418 | observeCompletion: {}, | |
| 419 | }); | |
| 420 | expect(writes).toHaveLength(1); | |
| 421 | expect(writes[0]).toMatch(expected); | |
| 422 | }).pipe( | |
| 423 | Effect.provide(testLayer(project, { open, write })), | |
| 424 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 425 | Effect.provideService(HostProcessEnvironment, { SHELL: shell }), | |
| 426 | ); | |
| 427 | }); | |
| 428 | ||
| 223 | 429 | it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { |
| 224 | 430 | const rootCause = new Error("stat failed"); |
| 225 | 431 | const terminalError = new TerminalManager.TerminalCwdStatError({ |
apps/server/src/project/ProjectSetupScriptRunner.ts+216-1| ... | ... | @@ -1,10 +1,15 @@ |
| 1 | 1 | import { ProjectId } from "@t3tools/contracts"; |
| 2 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 2 | 3 | import { |
| 3 | 4 | projectScriptRuntimeEnv, |
| 4 | 5 | resolveProjectScripts, |
| 5 | 6 | setupProjectScript, |
| 6 | 7 | } from "@t3tools/shared/projectScripts"; |
| 8 | import * as NodeCrypto from "node:crypto"; | |
| 9 | ||
| 10 | import * as Clock from "effect/Clock"; | |
| 7 | 11 | import * as Context from "effect/Context"; |
| 12 | import * as Deferred from "effect/Deferred"; | |
| 8 | 13 | import * as Effect from "effect/Effect"; |
| 9 | 14 | import * as Layer from "effect/Layer"; |
| 10 | 15 | import * as Option from "effect/Option"; |
| ... | ... | @@ -22,8 +27,24 @@ export interface ProjectSetupScriptRunnerResultStarted { |
| 22 | 27 | readonly status: "started"; |
| 23 | 28 | readonly scriptId: string; |
| 24 | 29 | readonly scriptName: string; |
| 30 | readonly scriptCommand: string; | |
| 25 | 31 | readonly terminalId: string; |
| 26 | 32 | readonly cwd: string; |
| 33 | /** | |
| 34 | * Resolves when the script's shell prints the completion sentinel. The | |
| 35 | * exit code is null when the terminal exited or was closed before the | |
| 36 | * sentinel arrived. Only present when `observeCompletion` was requested. | |
| 37 | */ | |
| 38 | readonly completion?: Effect.Effect<ProjectSetupScriptCompletion>; | |
| 39 | } | |
| 40 | ||
| 41 | export interface ProjectSetupScriptCompletion { | |
| 42 | readonly exitCode: number | null; | |
| 43 | readonly durationMs: number; | |
| 44 | } | |
| 45 | ||
| 46 | export interface ProjectSetupScriptOutputLine { | |
| 47 | readonly line: string; | |
| 27 | 48 | } |
| 28 | 49 | |
| 29 | 50 | export type ProjectSetupScriptRunnerResult = |
| ... | ... | @@ -36,6 +57,14 @@ export interface ProjectSetupScriptRunnerInput { |
| 36 | 57 | readonly projectCwd?: string; |
| 37 | 58 | readonly worktreePath: string; |
| 38 | 59 | readonly preferredTerminalId?: string; |
| 60 | /** | |
| 61 | * Wrap the command so the shell reports its exit code back through the | |
| 62 | * terminal stream, and forward cleaned output lines while it runs. The | |
| 63 | * bootstrap flow uses this to drive the worktree setup card. | |
| 64 | */ | |
| 65 | readonly observeCompletion?: { | |
| 66 | readonly onOutputLine?: (line: string) => Effect.Effect<void>; | |
| 67 | }; | |
| 39 | 68 | } |
| 40 | 69 | |
| 41 | 70 | export class ProjectSetupScriptOperationError extends Schema.TaggedError<ProjectSetupScriptOperationError>()( |
| ... | ... | @@ -83,11 +112,170 @@ export class ProjectSetupScriptRunner extends Context.Service< |
| 83 | 112 | } |
| 84 | 113 | >()("t3/project/ProjectSetupScriptRunner") {} |
| 85 | 114 | |
| 115 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 116 | /** | |
| 117 | * Marker the wrapped setup command echoes so the exit code can be read from | |
| 118 | * the PTY stream. Each run gets its own random token so script output cannot | |
| 119 | * spoof completion, and the sentinel pattern is built per run from it. | |
| 120 | */ | |
| 121 | const COMPLETION_SENTINEL_PREFIX = "__T3_SETUP_DONE__"; | |
| 122 | const OUTPUT_LINE_MAX_LENGTH = 400; | |
| 123 | /** A partial line longer than this is a byte stream, not a line. Keep only the tail. */ | |
| 124 | const PARTIAL_LINE_MAX_LENGTH = 4_096; | |
| 125 | ||
| 126 | function completionSentinel(token: string): string { | |
| 127 | return `${COMPLETION_SENTINEL_PREFIX}_${token}:`; | |
| 128 | } | |
| 129 | ||
| 130 | function completionSentinelPattern(token: string): RegExp { | |
| 131 | return new RegExp(`${COMPLETION_SENTINEL_PREFIX}_${token}:(-?\\d+)`); | |
| 132 | } | |
| 133 | ||
| 134 | /** Removes ANSI escape sequences and cursor controls so lines can be shown as plain text. */ | |
| 135 | function stripTerminalControl(text: string): string { | |
| 136 | return ( | |
| 137 | text | |
| 138 | .replace( | |
| 139 | // eslint-disable-next-line no-control-regex | |
| 140 | /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g, | |
| 141 | "", | |
| 142 | ) | |
| 143 | // eslint-disable-next-line no-control-regex | |
| 144 | .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") | |
| 145 | ); | |
| 146 | } | |
| 147 | ||
| 148 | type CompletionShell = "posix" | "fish" | "powershell"; | |
| 149 | ||
| 150 | /** | |
| 151 | * Predicts the shell TerminalManager will spawn for the setup terminal. The | |
| 152 | * manager takes `$SHELL` on POSIX and PowerShell on Windows, falling back to | |
| 153 | * other shells only when that one fails to spawn. | |
| 154 | */ | |
| 155 | function resolveCompletionShell( | |
| 156 | platform: NodeJS.Platform, | |
| 157 | env: NodeJS.ProcessEnv, | |
| 158 | ): CompletionShell { | |
| 159 | if (platform === "win32") return "powershell"; | |
| 160 | const shell = env.SHELL ?? ""; | |
| 161 | const name = shell.split("/").at(-1) ?? shell; | |
| 162 | if (name === "fish") return "fish"; | |
| 163 | if (name === "pwsh" || name === "powershell") return "powershell"; | |
| 164 | return "posix"; | |
| 165 | } | |
| 166 | ||
| 167 | /** | |
| 168 | * Builds the shell input for the setup script. The command runs inside a | |
| 169 | * block and the block closes on its own line, so a trailing `# comment` or a | |
| 170 | * heredoc terminator in the command cannot swallow the sentinel. The shell | |
| 171 | * reads the whole block before running any of it, so a script that reads | |
| 172 | * stdin cannot consume the sentinel line either. Lines are separated by `\r` | |
| 173 | * because that is the Enter key for every shell's line editor. | |
| 174 | */ | |
| 175 | function wrapCommandForCompletion( | |
| 176 | command: string, | |
| 177 | shell: CompletionShell, | |
| 178 | sentinel: string, | |
| 179 | ): string { | |
| 180 | const body = command.replace(/\r?\n/g, "\r"); | |
| 181 | switch (shell) { | |
| 182 | case "powershell": | |
| 183 | return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`; | |
| 184 | case "fish": | |
| 185 | return `begin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`; | |
| 186 | case "posix": | |
| 187 | return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 86 | 191 | /** @public Service construction is part of the canonical Effect module API. */ |
| 87 | 192 | export const make = Effect.gen(function* () { |
| 88 | 193 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 89 | 194 | const terminalManager = yield* TerminalManager.TerminalManager; |
| 90 | 195 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 196 | const completionShell = resolveCompletionShell( | |
| 197 | yield* HostProcessPlatform, | |
| 198 | yield* HostProcessEnvironment, | |
| 199 | ); | |
| 200 | ||
| 201 | /** | |
| 202 | * Watches the setup terminal for the completion sentinel. Terminal output is | |
| 203 | * a byte stream, so partial lines are buffered until a newline. The | |
| 204 | * subscription is torn down once the sentinel, an exit, or a close arrives. | |
| 205 | */ | |
| 206 | const observeTerminalCompletion = (input: { | |
| 207 | readonly threadId: string; | |
| 208 | readonly terminalId: string; | |
| 209 | /** Per-run sentinel, so only this run's wrapper can settle completion. */ | |
| 210 | readonly sentinel: string; | |
| 211 | readonly sentinelPattern: RegExp; | |
| 212 | /** The shell echoes typed input; lines ending with these are the wrapper, not output. */ | |
| 213 | readonly echoedWrapperLines: ReadonlyArray<string>; | |
| 214 | readonly onOutputLine: ((line: string) => Effect.Effect<void>) | undefined; | |
| 215 | }) => | |
| 216 | Effect.gen(function* () { | |
| 217 | const startedAtMs = yield* Clock.currentTimeMillis; | |
| 218 | const done = yield* Deferred.make<ProjectSetupScriptCompletion>(); | |
| 219 | let lineBuffer = ""; | |
| 220 | let settled = false; | |
| 221 | ||
| 222 | const settle = (exitCode: number | null) => | |
| 223 | Effect.suspend(() => { | |
| 224 | if (settled) return Effect.void; | |
| 225 | settled = true; | |
| 226 | return Clock.currentTimeMillis.pipe( | |
| 227 | Effect.flatMap((nowMs) => | |
| 228 | Deferred.succeed(done, { exitCode, durationMs: nowMs - startedAtMs }), | |
| 229 | ), | |
| 230 | Effect.asVoid, | |
| 231 | ); | |
| 232 | }); | |
| 233 | ||
| 234 | const handleLine = (rawLine: string) => | |
| 235 | Effect.suspend(() => { | |
| 236 | const sentinel = input.sentinelPattern.exec(rawLine); | |
| 237 | if (sentinel) { | |
| 238 | const parsed = Number(sentinel[1]); | |
| 239 | return settle(Number.isFinite(parsed) ? parsed : null); | |
| 240 | } | |
| 241 | const cleaned = stripTerminalControl(rawLine).trimEnd(); | |
| 242 | if ( | |
| 243 | cleaned.length === 0 || | |
| 244 | cleaned.includes(input.sentinel) || | |
| 245 | input.echoedWrapperLines.some((echoed) => cleaned.endsWith(echoed)) || | |
| 246 | input.onOutputLine === undefined | |
| 247 | ) { | |
| 248 | return Effect.void; | |
| 249 | } | |
| 250 | return input.onOutputLine(cleaned.slice(0, OUTPUT_LINE_MAX_LENGTH)); | |
| 251 | }); | |
| 252 | ||
| 253 | const unsubscribe = yield* terminalManager.subscribe((event) => { | |
| 254 | if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { | |
| 255 | return Effect.void; | |
| 256 | } | |
| 257 | if (event.type === "output") { | |
| 258 | lineBuffer += event.data; | |
| 259 | const lines = lineBuffer.split(/\r?\n/); | |
| 260 | lineBuffer = lines.pop() ?? ""; | |
| 261 | // A script that never prints a newline must not grow this forever. | |
| 262 | // The sentinel is always on its own line, so keeping the tail is safe. | |
| 263 | if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) { | |
| 264 | lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH); | |
| 265 | } | |
| 266 | return Effect.forEach(lines, handleLine, { discard: true }); | |
| 267 | } | |
| 268 | if (event.type === "exited" || event.type === "closed") { | |
| 269 | return settle(null); | |
| 270 | } | |
| 271 | return Effect.void; | |
| 272 | }); | |
| 273 | ||
| 274 | const completion = Deferred.await(done).pipe( | |
| 275 | Effect.ensuring(Effect.sync(() => unsubscribe())), | |
| 276 | ); | |
| 277 | return { completion, unsubscribe }; | |
| 278 | }); | |
| 91 | 279 | |
| 92 | 280 | const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( |
| 93 | 281 | "ProjectSetupScriptRunner.runForThread", |
| ... | ... | @@ -154,6 +342,16 @@ export const make = Effect.gen(function* () { |
| 154 | 342 | project: { cwd: project.workspaceRoot }, |
| 155 | 343 | worktreePath: input.worktreePath, |
| 156 | 344 | }); |
| 345 | const observe = input.observeCompletion; | |
| 346 | const completionToken = observe ? NodeCrypto.randomUUID().replaceAll("-", "") : null; | |
| 347 | const commandLine = | |
| 348 | observe && completionToken | |
| 349 | ? wrapCommandForCompletion( | |
| 350 | script.command, | |
| 351 | completionShell, | |
| 352 | completionSentinel(completionToken), | |
| 353 | ) | |
| 354 | : script.command; | |
| 157 | 355 | |
| 158 | 356 | yield* terminalManager |
| 159 | 357 | .open({ |
| ... | ... | @@ -173,11 +371,24 @@ export const make = Effect.gen(function* () { |
| 173 | 371 | }), |
| 174 | 372 | ), |
| 175 | 373 | ); |
| 374 | // Subscribe before writing so the sentinel cannot race past the listener. | |
| 375 | const observed = | |
| 376 | observe && completionToken | |
| 377 | ? yield* observeTerminalCompletion({ | |
| 378 | threadId: input.threadId, | |
| 379 | terminalId, | |
| 380 | sentinel: completionSentinel(completionToken), | |
| 381 | sentinelPattern: completionSentinelPattern(completionToken), | |
| 382 | echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0), | |
| 383 | onOutputLine: observe.onOutputLine, | |
| 384 | }) | |
| 385 | : undefined; | |
| 386 | ||
| 176 | 387 | yield* terminalManager |
| 177 | 388 | .write({ |
| 178 | 389 | threadId: input.threadId, |
| 179 | 390 | terminalId, |
| 180 | data: `${script.command}\r`, | |
| 391 | data: `${commandLine}\r`, | |
| 181 | 392 | }) |
| 182 | 393 | .pipe( |
| 183 | 394 | Effect.mapError( |
| ... | ... | @@ -188,14 +399,18 @@ export const make = Effect.gen(function* () { |
| 188 | 399 | cause, |
| 189 | 400 | }), |
| 190 | 401 | ), |
| 402 | // Nothing will ever settle the completion if the command never ran. | |
| 403 | Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), | |
| 191 | 404 | ); |
| 192 | 405 | |
| 193 | 406 | return { |
| 194 | 407 | status: "started", |
| 195 | 408 | scriptId: script.id, |
| 196 | 409 | scriptName: script.name, |
| 410 | scriptCommand: script.command, | |
| 197 | 411 | terminalId, |
| 198 | 412 | cwd, |
| 413 | ...(observed ? { completion: observed.completion } : {}), | |
| 199 | 414 | } as const; |
| 200 | 415 | }); |
| 201 | 416 |
apps/server/src/project/WorktreeSetupTracker.test.ts created+226| ... | ... | @@ -0,0 +1,226 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; | |
| 3 | import * as Deferred from "effect/Deferred"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Exit from "effect/Exit"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | import * as TestClock from "effect/testing/TestClock"; | |
| 11 | ||
| 12 | import * as WorktreeSetupTracker from "./WorktreeSetupTracker.ts"; | |
| 13 | ||
| 14 | const threadId = ThreadId.make("thread-1"); | |
| 15 | ||
| 16 | describe("WorktreeSetupTracker", () => { | |
| 17 | it.effect("records stage transitions, checkout progress, and the final phase", () => | |
| 18 | Effect.gen(function* () { | |
| 19 | const tracker = yield* WorktreeSetupTracker.make; | |
| 20 | yield* tracker.begin({ | |
| 21 | threadId, | |
| 22 | branch: "feature", | |
| 23 | baseRef: "main", | |
| 24 | stages: ["checkout", "fetch", "agent"], | |
| 25 | fiber: null, | |
| 26 | }); | |
| 27 | ||
| 28 | const initial = yield* tracker.get(threadId); | |
| 29 | // Stages are reordered into the canonical setup order. | |
| 30 | expect(initial?.stages.map((stage) => stage.id)).toEqual(["fetch", "checkout", "agent"]); | |
| 31 | expect(initial?.phase).toBe("running"); | |
| 32 | ||
| 33 | yield* tracker.stageStatus(threadId, "fetch", "running"); | |
| 34 | yield* tracker.stageStatus(threadId, "fetch", "done", "origin/main at abc1234"); | |
| 35 | yield* tracker.stageStatus(threadId, "checkout", "running"); | |
| 36 | yield* tracker.stage(threadId, "checkout", { percent: 42, detail: "42 / 100 files" }); | |
| 37 | yield* tracker.finish(threadId, "failed", "boom"); | |
| 38 | ||
| 39 | const final = yield* tracker.get(threadId); | |
| 40 | expect(final?.phase).toBe("failed"); | |
| 41 | expect(final?.error).toBe("boom"); | |
| 42 | const [fetch, checkout, agent] = final?.stages ?? []; | |
| 43 | expect(fetch).toMatchObject({ status: "done", detail: "origin/main at abc1234" }); | |
| 44 | expect(fetch?.startedAt).not.toBeNull(); | |
| 45 | expect(fetch?.endedAt).not.toBeNull(); | |
| 46 | // A stage still running when the setup fails is marked failed. | |
| 47 | expect(checkout).toMatchObject({ status: "failed", percent: 42 }); | |
| 48 | expect(agent?.status).toBe("pending"); | |
| 49 | expect(final?.sequence).toBeGreaterThan(initial?.sequence ?? 0); | |
| 50 | }), | |
| 51 | ); | |
| 52 | ||
| 53 | it.effect("stream emits the current snapshot first and then only newer ones", () => | |
| 54 | Effect.gen(function* () { | |
| 55 | const tracker = yield* WorktreeSetupTracker.make; | |
| 56 | yield* tracker.begin({ | |
| 57 | threadId, | |
| 58 | branch: null, | |
| 59 | baseRef: null, | |
| 60 | stages: ["agent"], | |
| 61 | fiber: null, | |
| 62 | }); | |
| 63 | ||
| 64 | const collected = yield* tracker.stream(threadId).pipe( | |
| 65 | Stream.takeUntil((snapshot) => snapshot?.sequence === 2), | |
| 66 | Stream.runCollect, | |
| 67 | Effect.forkChild, | |
| 68 | ); | |
| 69 | yield* Effect.yieldNow; | |
| 70 | yield* tracker.stageStatus(threadId, "agent", "running"); | |
| 71 | yield* tracker.appendTail(threadId, "agent", "line 1"); | |
| 72 | ||
| 73 | const snapshots = yield* Fiber.join(collected); | |
| 74 | const sequences = snapshots.map((snapshot) => snapshot?.sequence ?? -1); | |
| 75 | expect(sequences.at(-1)).toBe(2); | |
| 76 | // Delivery is latest-value per subscriber, so intermediates may be | |
| 77 | // skipped but never delivered out of order. | |
| 78 | expect(sequences).toEqual([...sequences].toSorted((a, b) => a - b)); | |
| 79 | expect(snapshots.at(-1)?.stages[0]?.tail).toEqual(["line 1"]); | |
| 80 | }), | |
| 81 | ); | |
| 82 | ||
| 83 | it.effect("stream never steps back behind the snapshot it started from", () => | |
| 84 | Effect.gen(function* () { | |
| 85 | const tracker = yield* WorktreeSetupTracker.make; | |
| 86 | yield* tracker.begin({ | |
| 87 | threadId, | |
| 88 | branch: null, | |
| 89 | baseRef: null, | |
| 90 | stages: ["agent"], | |
| 91 | fiber: null, | |
| 92 | }); | |
| 93 | yield* tracker.stageStatus(threadId, "agent", "running"); | |
| 94 | yield* tracker.stageStatus(threadId, "agent", "done"); | |
| 95 | ||
| 96 | // A late subscriber starts at sequence 2 and must never see 0 or 1. | |
| 97 | const collected = yield* tracker.stream(threadId).pipe( | |
| 98 | Stream.takeUntil((snapshot) => snapshot?.phase === "done"), | |
| 99 | Stream.runCollect, | |
| 100 | Effect.forkChild, | |
| 101 | ); | |
| 102 | yield* Effect.yieldNow; | |
| 103 | yield* tracker.finish(threadId, "done"); | |
| 104 | ||
| 105 | const snapshots = yield* Fiber.join(collected); | |
| 106 | expect(snapshots.length).toBeGreaterThan(0); | |
| 107 | expect(snapshots.every((snapshot) => (snapshot?.sequence ?? -1) >= 2)).toBe(true); | |
| 108 | expect(snapshots.at(-1)?.phase).toBe("done"); | |
| 109 | }), | |
| 110 | ); | |
| 111 | ||
| 112 | it.effect("a new setup on the same thread keeps sequences increasing", () => | |
| 113 | Effect.gen(function* () { | |
| 114 | const tracker = yield* WorktreeSetupTracker.make; | |
| 115 | yield* tracker.begin({ | |
| 116 | threadId, | |
| 117 | branch: "first", | |
| 118 | baseRef: null, | |
| 119 | stages: ["agent"], | |
| 120 | fiber: null, | |
| 121 | }); | |
| 122 | yield* tracker.finish(threadId, "failed", "boom"); | |
| 123 | const failedSequence = (yield* tracker.get(threadId))?.sequence ?? -1; | |
| 124 | ||
| 125 | // A stream opened on the failed setup must still receive the next one. | |
| 126 | const collected = yield* tracker.stream(threadId).pipe( | |
| 127 | Stream.takeUntil((snapshot) => snapshot?.branch === "second"), | |
| 128 | Stream.runCollect, | |
| 129 | Effect.forkChild, | |
| 130 | ); | |
| 131 | yield* Effect.yieldNow; | |
| 132 | yield* tracker.begin({ | |
| 133 | threadId, | |
| 134 | branch: "second", | |
| 135 | baseRef: null, | |
| 136 | stages: ["agent"], | |
| 137 | fiber: null, | |
| 138 | }); | |
| 139 | ||
| 140 | const snapshots = yield* Fiber.join(collected); | |
| 141 | const last = snapshots.at(-1); | |
| 142 | expect(last?.phase).toBe("running"); | |
| 143 | expect(last?.sequence).toBeGreaterThan(failedSequence); | |
| 144 | }), | |
| 145 | ); | |
| 146 | ||
| 147 | it.effect("finished setups are dropped after the retention window", () => | |
| 148 | Effect.gen(function* () { | |
| 149 | const tracker = yield* WorktreeSetupTracker.make; | |
| 150 | yield* tracker.begin({ | |
| 151 | threadId, | |
| 152 | branch: null, | |
| 153 | baseRef: null, | |
| 154 | stages: ["agent"], | |
| 155 | fiber: null, | |
| 156 | }); | |
| 157 | yield* tracker.finish(threadId, "done"); | |
| 158 | expect((yield* tracker.get(threadId))?.phase).toBe("done"); | |
| 159 | ||
| 160 | yield* TestClock.adjust(Duration.seconds(31)); | |
| 161 | expect(yield* tracker.get(threadId)).toBeNull(); | |
| 162 | }), | |
| 163 | ); | |
| 164 | ||
| 165 | it.effect("cancel interrupts the bootstrap fiber and reports whether one was running", () => | |
| 166 | Effect.gen(function* () { | |
| 167 | const tracker = yield* WorktreeSetupTracker.make; | |
| 168 | const started = yield* Deferred.make<void>(); | |
| 169 | const fiber = yield* Deferred.succeed(started, undefined).pipe( | |
| 170 | Effect.andThen(Effect.never), | |
| 171 | Effect.forkChild, | |
| 172 | ); | |
| 173 | yield* Deferred.await(started); | |
| 174 | yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber }); | |
| 175 | ||
| 176 | expect(yield* tracker.cancel(threadId)).toBe(true); | |
| 177 | // cancel returns only after the bootstrap fiber has unwound. | |
| 178 | const exit = yield* Fiber.await(fiber); | |
| 179 | expect(Exit.hasInterrupts(exit)).toBe(true); | |
| 180 | ||
| 181 | yield* tracker.finish(threadId, "cancelled"); | |
| 182 | expect(yield* tracker.cancel(threadId)).toBe(false); | |
| 183 | expect(yield* tracker.cancel(ThreadId.make("unknown"))).toBe(false); | |
| 184 | }), | |
| 185 | ); | |
| 186 | ||
| 187 | it.effect("markUncancellable makes a later cancel a no-op while the setup keeps running", () => | |
| 188 | Effect.gen(function* () { | |
| 189 | const tracker = yield* WorktreeSetupTracker.make; | |
| 190 | const fiber = yield* Effect.forkChild(Effect.never); | |
| 191 | yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber }); | |
| 192 | ||
| 193 | yield* tracker.markUncancellable(threadId); | |
| 194 | expect(yield* tracker.cancel(threadId)).toBe(false); | |
| 195 | expect((yield* tracker.get(threadId))?.phase).toBe("running"); | |
| 196 | yield* Fiber.interrupt(fiber); | |
| 197 | }), | |
| 198 | ); | |
| 199 | ||
| 200 | it.effect("clamps free text to the contract limits before publishing", () => | |
| 201 | Effect.gen(function* () { | |
| 202 | const tracker = yield* WorktreeSetupTracker.make; | |
| 203 | yield* tracker.begin({ | |
| 204 | threadId, | |
| 205 | branch: null, | |
| 206 | baseRef: null, | |
| 207 | stages: ["checkout", "setup-script"], | |
| 208 | fiber: null, | |
| 209 | }); | |
| 210 | const long = "x".repeat(2_000); | |
| 211 | ||
| 212 | yield* tracker.stageStatus(threadId, "checkout", "done", long); | |
| 213 | yield* tracker.stage(threadId, "setup-script", { detail: long }); | |
| 214 | yield* tracker.appendTail(threadId, "setup-script", long); | |
| 215 | yield* tracker.finish(threadId, "failed", long); | |
| 216 | ||
| 217 | const snapshot = yield* tracker.get(threadId); | |
| 218 | expect(snapshot?.stages[0]?.detail?.length).toBe(200); | |
| 219 | expect(snapshot?.stages[1]?.detail?.length).toBe(200); | |
| 220 | expect(snapshot?.stages[1]?.tail[0]?.length).toBe(400); | |
| 221 | expect(snapshot?.error?.length).toBe(1000); | |
| 222 | // The wire schema must accept what the tracker publishes. | |
| 223 | expect(Schema.is(WorktreeSetupSnapshot)(snapshot)).toBe(true); | |
| 224 | }), | |
| 225 | ); | |
| 226 | }); |
apps/server/src/project/WorktreeSetupTracker.ts created+364| ... | ... | @@ -0,0 +1,364 @@ |
| 1 | import type { | |
| 2 | ThreadId, | |
| 3 | WorktreeSetupSnapshot, | |
| 4 | WorktreeSetupStage, | |
| 5 | WorktreeSetupStageId, | |
| 6 | WorktreeSetupStageStatus, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { | |
| 9 | WORKTREE_SETUP_DETAIL_MAX_LENGTH, | |
| 10 | WORKTREE_SETUP_ERROR_MAX_LENGTH, | |
| 11 | WORKTREE_SETUP_STAGE_ORDER, | |
| 12 | WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import * as Context from "effect/Context"; | |
| 15 | import * as DateTime from "effect/DateTime"; | |
| 16 | import * as Effect from "effect/Effect"; | |
| 17 | import * as Fiber from "effect/Fiber"; | |
| 18 | import * as Layer from "effect/Layer"; | |
| 19 | import * as PubSub from "effect/PubSub"; | |
| 20 | import * as Queue from "effect/Queue"; | |
| 21 | import * as Ref from "effect/Ref"; | |
| 22 | import * as Stream from "effect/Stream"; | |
| 23 | ||
| 24 | /** | |
| 25 | * Tracks the live stages of a bootstrap worktree setup per thread so clients | |
| 26 | * can render a progress card while the first turn is still being prepared. | |
| 27 | * | |
| 28 | * State is memory only. It exists from the first `begin` until the turn starts | |
| 29 | * or the setup fails, plus a short grace window so a client that subscribes | |
| 30 | * late still sees the final state. Nothing here is persisted or event-sourced: | |
| 31 | * the durable record of a setup is the thread's worktree path and the setup | |
| 32 | * script activities, both of which already exist. | |
| 33 | */ | |
| 34 | export class WorktreeSetupTracker extends Context.Service< | |
| 35 | WorktreeSetupTracker, | |
| 36 | { | |
| 37 | /** Creates a fresh running snapshot for the thread, replacing any prior one. */ | |
| 38 | readonly begin: (input: { | |
| 39 | readonly threadId: ThreadId; | |
| 40 | readonly branch: string | null; | |
| 41 | readonly baseRef: string | null; | |
| 42 | readonly stages: ReadonlyArray<WorktreeSetupStageId>; | |
| 43 | /** Interrupting this fiber cancels the bootstrap. */ | |
| 44 | readonly fiber: Fiber.Fiber<unknown, unknown> | null; | |
| 45 | }) => Effect.Effect<void>; | |
| 46 | readonly update: ( | |
| 47 | threadId: ThreadId, | |
| 48 | mutate: (snapshot: WorktreeSetupSnapshot) => WorktreeSetupSnapshot, | |
| 49 | ) => Effect.Effect<void>; | |
| 50 | readonly stage: ( | |
| 51 | threadId: ThreadId, | |
| 52 | stageId: WorktreeSetupStageId, | |
| 53 | patch: Partial<Omit<WorktreeSetupStage, "id">>, | |
| 54 | ) => Effect.Effect<void>; | |
| 55 | readonly stageStatus: ( | |
| 56 | threadId: ThreadId, | |
| 57 | stageId: WorktreeSetupStageId, | |
| 58 | status: WorktreeSetupStageStatus, | |
| 59 | detail?: string | null, | |
| 60 | ) => Effect.Effect<void>; | |
| 61 | readonly appendTail: ( | |
| 62 | threadId: ThreadId, | |
| 63 | stageId: WorktreeSetupStageId, | |
| 64 | line: string, | |
| 65 | ) => Effect.Effect<void>; | |
| 66 | readonly finish: ( | |
| 67 | threadId: ThreadId, | |
| 68 | phase: "done" | "failed" | "cancelled", | |
| 69 | error?: string | null, | |
| 70 | ) => Effect.Effect<void>; | |
| 71 | /** | |
| 72 | * Drops the cancel handle. Called right before the turn is dispatched so a | |
| 73 | * late cancel cannot roll back a thread whose agent has already started. | |
| 74 | */ | |
| 75 | readonly markUncancellable: (threadId: ThreadId) => Effect.Effect<void>; | |
| 76 | /** | |
| 77 | * Interrupts the running bootstrap and waits for it to unwind, so the | |
| 78 | * caller's dispatch has already failed and rolled back when this returns. | |
| 79 | * Returns false when nothing is running or the setup is past cancellation. | |
| 80 | */ | |
| 81 | readonly cancel: (threadId: ThreadId) => Effect.Effect<boolean>; | |
| 82 | readonly get: (threadId: ThreadId) => Effect.Effect<WorktreeSetupSnapshot | null>; | |
| 83 | /** Emits the current snapshot (or null) first, then every change until unsubscribed. */ | |
| 84 | readonly stream: (threadId: ThreadId) => Stream.Stream<WorktreeSetupSnapshot | null>; | |
| 85 | } | |
| 86 | >()("t3/project/WorktreeSetupTracker") {} | |
| 87 | ||
| 88 | const TAIL_LINE_LIMIT = 4; | |
| 89 | ||
| 90 | /** Keeps free text inside the contract limit, ending in an ellipsis when cut. */ | |
| 91 | function clampText(text: string, maxLength: number): string { | |
| 92 | return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}\u2026`; | |
| 93 | } | |
| 94 | ||
| 95 | const clampDetail = (detail: string | null): string | null => | |
| 96 | detail === null ? null : clampText(detail, WORKTREE_SETUP_DETAIL_MAX_LENGTH); | |
| 97 | /** Finished snapshots stay visible this long so a late subscriber sees the outcome. */ | |
| 98 | const FINISHED_RETENTION = "30 seconds"; | |
| 99 | ||
| 100 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | |
| 101 | ||
| 102 | interface TrackedSetup { | |
| 103 | readonly snapshot: WorktreeSetupSnapshot; | |
| 104 | readonly fiber: Fiber.Fiber<unknown, unknown> | null; | |
| 105 | } | |
| 106 | ||
| 107 | function emptyStage(id: WorktreeSetupStageId): WorktreeSetupStage { | |
| 108 | return { | |
| 109 | id, | |
| 110 | status: "pending", | |
| 111 | startedAt: null, | |
| 112 | endedAt: null, | |
| 113 | percent: null, | |
| 114 | detail: null, | |
| 115 | tail: [], | |
| 116 | }; | |
| 117 | } | |
| 118 | ||
| 119 | export const make = Effect.gen(function* () { | |
| 120 | const setups = yield* Ref.make(new Map<ThreadId, TrackedSetup>()); | |
| 121 | const changes = yield* PubSub.unbounded<{ | |
| 122 | readonly threadId: ThreadId; | |
| 123 | readonly snapshot: WorktreeSetupSnapshot | null; | |
| 124 | }>(); | |
| 125 | const retentionFibers = new Map<ThreadId, Fiber.Fiber<void, never>>(); | |
| 126 | // Sequences keep increasing across setups of the same thread so a stream | |
| 127 | // opened during a previous setup still accepts the next one's first snapshot. | |
| 128 | const lastSequenceByThread = new Map<ThreadId, number>(); | |
| 129 | ||
| 130 | const publish = (threadId: ThreadId, snapshot: WorktreeSetupSnapshot | null) => | |
| 131 | PubSub.publish(changes, { threadId, snapshot }).pipe(Effect.asVoid); | |
| 132 | ||
| 133 | const modify = ( | |
| 134 | threadId: ThreadId, | |
| 135 | mutate: (tracked: TrackedSetup) => TrackedSetup, | |
| 136 | ): Effect.Effect<WorktreeSetupSnapshot | null> => | |
| 137 | Ref.modify(setups, (current) => { | |
| 138 | const existing = current.get(threadId); | |
| 139 | if (!existing) return [null, current] as const; | |
| 140 | const nextTracked = mutate(existing); | |
| 141 | const nextSnapshot = { | |
| 142 | ...nextTracked.snapshot, | |
| 143 | sequence: existing.snapshot.sequence + 1, | |
| 144 | }; | |
| 145 | lastSequenceByThread.set(threadId, nextSnapshot.sequence); | |
| 146 | const next = new Map(current); | |
| 147 | next.set(threadId, { ...nextTracked, snapshot: nextSnapshot }); | |
| 148 | return [nextSnapshot, next] as const; | |
| 149 | }).pipe(Effect.tap((snapshot) => (snapshot ? publish(threadId, snapshot) : Effect.void))); | |
| 150 | ||
| 151 | const clearRetention = (threadId: ThreadId) => { | |
| 152 | const fiber = retentionFibers.get(threadId); | |
| 153 | retentionFibers.delete(threadId); | |
| 154 | return fiber ? Fiber.interrupt(fiber).pipe(Effect.ignore) : Effect.void; | |
| 155 | }; | |
| 156 | ||
| 157 | const remove = (threadId: ThreadId) => | |
| 158 | Ref.update(setups, (current) => { | |
| 159 | if (!current.has(threadId)) return current; | |
| 160 | const next = new Map(current); | |
| 161 | next.delete(threadId); | |
| 162 | return next; | |
| 163 | }).pipe( | |
| 164 | Effect.andThen(publish(threadId, null)), | |
| 165 | // A subscriber that outlives retention sees `null` here and accepts any | |
| 166 | // sequence after it, so the counter can start over for this thread. | |
| 167 | Effect.tap(() => Effect.sync(() => lastSequenceByThread.delete(threadId))), | |
| 168 | ); | |
| 169 | ||
| 170 | const begin: WorktreeSetupTracker["Service"]["begin"] = (input) => | |
| 171 | Effect.gen(function* () { | |
| 172 | yield* clearRetention(input.threadId); | |
| 173 | const startedAt = yield* nowIso; | |
| 174 | const ordered = WORKTREE_SETUP_STAGE_ORDER.filter((id) => input.stages.includes(id)); | |
| 175 | const snapshot: WorktreeSetupSnapshot = { | |
| 176 | threadId: input.threadId, | |
| 177 | phase: "running", | |
| 178 | startedAt, | |
| 179 | endedAt: null, | |
| 180 | branch: input.branch, | |
| 181 | baseRef: input.baseRef, | |
| 182 | worktreePath: null, | |
| 183 | setupScript: null, | |
| 184 | stages: ordered.map(emptyStage), | |
| 185 | error: null, | |
| 186 | sequence: (lastSequenceByThread.get(input.threadId) ?? -1) + 1, | |
| 187 | }; | |
| 188 | lastSequenceByThread.set(input.threadId, snapshot.sequence); | |
| 189 | yield* Ref.update(setups, (current) => { | |
| 190 | const next = new Map(current); | |
| 191 | next.set(input.threadId, { snapshot, fiber: input.fiber }); | |
| 192 | return next; | |
| 193 | }); | |
| 194 | yield* publish(input.threadId, snapshot); | |
| 195 | }); | |
| 196 | ||
| 197 | const update: WorktreeSetupTracker["Service"]["update"] = (threadId, mutate) => | |
| 198 | modify(threadId, (tracked) => ({ ...tracked, snapshot: mutate(tracked.snapshot) })).pipe( | |
| 199 | Effect.asVoid, | |
| 200 | ); | |
| 201 | ||
| 202 | const stage: WorktreeSetupTracker["Service"]["stage"] = (threadId, stageId, patch) => | |
| 203 | update(threadId, (snapshot) => ({ | |
| 204 | ...snapshot, | |
| 205 | stages: snapshot.stages.map((entry) => | |
| 206 | entry.id === stageId | |
| 207 | ? { | |
| 208 | ...entry, | |
| 209 | ...patch, | |
| 210 | ...(patch.detail === undefined ? {} : { detail: clampDetail(patch.detail ?? null) }), | |
| 211 | } | |
| 212 | : entry, | |
| 213 | ), | |
| 214 | })); | |
| 215 | ||
| 216 | const stageStatus: WorktreeSetupTracker["Service"]["stageStatus"] = ( | |
| 217 | threadId, | |
| 218 | stageId, | |
| 219 | status, | |
| 220 | detail, | |
| 221 | ) => | |
| 222 | nowIso.pipe( | |
| 223 | Effect.flatMap((at) => | |
| 224 | update(threadId, (snapshot) => ({ | |
| 225 | ...snapshot, | |
| 226 | stages: snapshot.stages.map((entry) => { | |
| 227 | if (entry.id !== stageId) return entry; | |
| 228 | const startedAt = entry.startedAt ?? (status === "pending" ? null : at); | |
| 229 | const endedAt = | |
| 230 | status === "running" || status === "pending" ? null : (entry.endedAt ?? at); | |
| 231 | return { | |
| 232 | ...entry, | |
| 233 | status, | |
| 234 | startedAt, | |
| 235 | endedAt, | |
| 236 | ...(detail === undefined ? {} : { detail: clampDetail(detail ?? null) }), | |
| 237 | }; | |
| 238 | }), | |
| 239 | })), | |
| 240 | ), | |
| 241 | ); | |
| 242 | ||
| 243 | const appendTail: WorktreeSetupTracker["Service"]["appendTail"] = (threadId, stageId, line) => | |
| 244 | update(threadId, (snapshot) => ({ | |
| 245 | ...snapshot, | |
| 246 | stages: snapshot.stages.map((entry) => | |
| 247 | entry.id === stageId | |
| 248 | ? { | |
| 249 | ...entry, | |
| 250 | tail: [...entry.tail, clampText(line, WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH)].slice( | |
| 251 | -TAIL_LINE_LIMIT, | |
| 252 | ), | |
| 253 | } | |
| 254 | : entry, | |
| 255 | ), | |
| 256 | })); | |
| 257 | ||
| 258 | const finish: WorktreeSetupTracker["Service"]["finish"] = (threadId, phase, error) => | |
| 259 | Effect.gen(function* () { | |
| 260 | const endedAt = yield* nowIso; | |
| 261 | const snapshot = yield* modify(threadId, (tracked) => ({ | |
| 262 | fiber: null, | |
| 263 | snapshot: { | |
| 264 | ...tracked.snapshot, | |
| 265 | phase, | |
| 266 | endedAt, | |
| 267 | error: | |
| 268 | error === undefined || error === null | |
| 269 | ? null | |
| 270 | : clampText(error, WORKTREE_SETUP_ERROR_MAX_LENGTH), | |
| 271 | stages: tracked.snapshot.stages.map((entry) => | |
| 272 | entry.status === "running" | |
| 273 | ? { | |
| 274 | ...entry, | |
| 275 | status: phase === "done" ? "done" : phase === "cancelled" ? "skipped" : "failed", | |
| 276 | endedAt, | |
| 277 | } | |
| 278 | : entry, | |
| 279 | ), | |
| 280 | }, | |
| 281 | })); | |
| 282 | if (!snapshot) return; | |
| 283 | yield* clearRetention(threadId); | |
| 284 | const fiber = yield* remove(threadId).pipe( | |
| 285 | Effect.delay(FINISHED_RETENTION), | |
| 286 | Effect.ensuring( | |
| 287 | Effect.sync(() => { | |
| 288 | // Only drop our own entry: a newer setup may have replaced it. | |
| 289 | if (retentionFibers.get(threadId) === fiber) retentionFibers.delete(threadId); | |
| 290 | }), | |
| 291 | ), | |
| 292 | Effect.forkDetach, | |
| 293 | ); | |
| 294 | retentionFibers.set(threadId, fiber); | |
| 295 | }); | |
| 296 | ||
| 297 | const markUncancellable: WorktreeSetupTracker["Service"]["markUncancellable"] = (threadId) => | |
| 298 | Ref.update(setups, (current) => { | |
| 299 | const existing = current.get(threadId); | |
| 300 | if (!existing || existing.fiber === null) return current; | |
| 301 | const next = new Map(current); | |
| 302 | next.set(threadId, { ...existing, fiber: null }); | |
| 303 | return next; | |
| 304 | }); | |
| 305 | ||
| 306 | const cancel: WorktreeSetupTracker["Service"]["cancel"] = (threadId) => | |
| 307 | Effect.gen(function* () { | |
| 308 | const current = yield* Ref.get(setups); | |
| 309 | const tracked = current.get(threadId); | |
| 310 | if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) { | |
| 311 | return false; | |
| 312 | } | |
| 313 | yield* Fiber.interrupt(tracked.fiber); | |
| 314 | return true; | |
| 315 | }); | |
| 316 | ||
| 317 | const get: WorktreeSetupTracker["Service"]["get"] = (threadId) => | |
| 318 | Ref.get(setups).pipe(Effect.map((current) => current.get(threadId)?.snapshot ?? null)); | |
| 319 | ||
| 320 | /** | |
| 321 | * Each subscriber gets a one-slot sliding mailbox: a slow WebSocket only | |
| 322 | * ever holds the newest snapshot, so a chatty setup script cannot grow the | |
| 323 | * server heap. Snapshots are whole states, so skipping intermediates is safe. | |
| 324 | */ | |
| 325 | const stream: WorktreeSetupTracker["Service"]["stream"] = (threadId) => | |
| 326 | Stream.callback<WorktreeSetupSnapshot | null>( | |
| 327 | (mailbox) => | |
| 328 | Effect.gen(function* () { | |
| 329 | const subscription = yield* PubSub.subscribe(changes); | |
| 330 | const initial = yield* get(threadId); | |
| 331 | // Changes published between subscribing and reading `initial` are | |
| 332 | // already folded into it. Drop them so the client never steps back. | |
| 333 | let lastSequence = initial?.sequence ?? -1; | |
| 334 | Queue.offerUnsafe(mailbox, initial); | |
| 335 | yield* Stream.fromSubscription(subscription).pipe( | |
| 336 | Stream.runForEach((change) => | |
| 337 | Effect.sync(() => { | |
| 338 | if (change.threadId !== threadId) return; | |
| 339 | if (change.snapshot !== null && change.snapshot.sequence <= lastSequence) return; | |
| 340 | lastSequence = change.snapshot?.sequence ?? -1; | |
| 341 | Queue.offerUnsafe(mailbox, change.snapshot); | |
| 342 | }), | |
| 343 | ), | |
| 344 | Effect.forkScoped, | |
| 345 | ); | |
| 346 | }), | |
| 347 | { bufferSize: 1, strategy: "sliding" }, | |
| 348 | ); | |
| 349 | ||
| 350 | return WorktreeSetupTracker.of({ | |
| 351 | begin, | |
| 352 | update, | |
| 353 | stage, | |
| 354 | stageStatus, | |
| 355 | appendTail, | |
| 356 | finish, | |
| 357 | markUncancellable, | |
| 358 | cancel, | |
| 359 | get, | |
| 360 | stream, | |
| 361 | }); | |
| 362 | }); | |
| 363 | ||
| 364 | export const layer = Layer.effect(WorktreeSetupTracker, make); |
apps/server/src/server.test.ts+26-9| ... | ... | @@ -148,6 +148,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 148 | 148 | import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; |
| 149 | 149 | import * as ServerSettings from "./serverSettings.ts"; |
| 150 | 150 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 151 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; | |
| 151 | 152 | import * as PreviewManager from "./preview/Manager.ts"; |
| 152 | 153 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 153 | 154 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; |
| ... | ... | @@ -924,9 +925,12 @@ const buildAppUnderTest = (options?: { |
| 924 | 925 | }), |
| 925 | 926 | ), |
| 926 | 927 | Layer.provide( |
| 927 | Layer.mock(TerminalManager.TerminalManager)({ | |
| 928 | ...options?.layers?.terminalManager, | |
| 929 | }), | |
| 928 | Layer.mergeAll( | |
| 929 | Layer.mock(TerminalManager.TerminalManager)({ | |
| 930 | ...options?.layers?.terminalManager, | |
| 931 | }), | |
| 932 | WorktreeSetupTracker.layer, | |
| 933 | ), | |
| 930 | 934 | ), |
| 931 | 935 | Layer.provide( |
| 932 | 936 | Layer.mergeAll( |
| ... | ... | @@ -10558,6 +10562,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 10558 | 10562 | status: "started" as const, |
| 10559 | 10563 | scriptId: "setup", |
| 10560 | 10564 | scriptName: "Setup", |
| 10565 | scriptCommand: "npm install", | |
| 10561 | 10566 | terminalId: "setup-setup", |
| 10562 | 10567 | cwd: "/tmp/bootstrap-worktree", |
| 10563 | 10568 | }), |
| ... | ... | @@ -10673,12 +10678,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 10673 | 10678 | "resolve-remote-commit", |
| 10674 | 10679 | "create-worktree", |
| 10675 | 10680 | ]); |
| 10676 | assert.deepEqual(runForThread.mock.calls[0]?.[0], { | |
| 10677 | threadId: ThreadId.make("thread-bootstrap"), | |
| 10678 | projectId: defaultProjectId, | |
| 10679 | projectCwd: "/tmp/project", | |
| 10680 | worktreePath: "/tmp/bootstrap-worktree", | |
| 10681 | }); | |
| 10681 | const runForThreadInput = runForThread.mock.calls[0]?.[0]; | |
| 10682 | assert.deepEqual( | |
| 10683 | runForThreadInput && { | |
| 10684 | threadId: runForThreadInput.threadId, | |
| 10685 | projectId: runForThreadInput.projectId, | |
| 10686 | projectCwd: runForThreadInput.projectCwd, | |
| 10687 | worktreePath: runForThreadInput.worktreePath, | |
| 10688 | }, | |
| 10689 | { | |
| 10690 | threadId: ThreadId.make("thread-bootstrap"), | |
| 10691 | projectId: defaultProjectId, | |
| 10692 | projectCwd: "/tmp/project", | |
| 10693 | worktreePath: "/tmp/bootstrap-worktree", | |
| 10694 | }, | |
| 10695 | ); | |
| 10696 | // Worktree bootstraps observe script completion so the setup card can show the exit code. | |
| 10697 | assert.isDefined(runForThreadInput?.observeCompletion); | |
| 10682 | 10698 | assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); |
| 10683 | 10699 | |
| 10684 | 10700 | const setupActivities = dispatchedCommands.filter( |
| ... | ... | @@ -11111,6 +11127,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11111 | 11127 | status: "started" as const, |
| 11112 | 11128 | scriptId: "setup", |
| 11113 | 11129 | scriptName: "Setup", |
| 11130 | scriptCommand: "npm install", | |
| 11114 | 11131 | terminalId: "setup-setup", |
| 11115 | 11132 | cwd: "/tmp/bootstrap-worktree", |
| 11116 | 11133 | }), |
apps/server/src/server.ts+2| ... | ... | @@ -110,6 +110,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; |
| 110 | 110 | import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; |
| 111 | 111 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 112 | 112 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 113 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; | |
| 113 | 114 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; |
| 114 | 115 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 115 | 116 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; |
| ... | ... | @@ -332,6 +333,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( |
| 332 | 333 | |
| 333 | 334 | const GitManagerLayerLive = GitManager.layer.pipe( |
| 334 | 335 | Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))), |
| 336 | Layer.provideMerge(WorktreeSetupTracker.layer), | |
| 335 | 337 | Layer.provideMerge(GitVcsDriver.layer), |
| 336 | 338 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), |
| 337 | 339 | Layer.provideMerge(TextGeneration.layer), |
apps/server/src/vcs/GitVcsDriver.ts+31| ... | ... | @@ -102,6 +102,36 @@ export interface ExecuteGitProgress { |
| 102 | 102 | }) => Effect.Effect<void, never>; |
| 103 | 103 | } |
| 104 | 104 | |
| 105 | /** | |
| 106 | * Progress callbacks for `createWorktree`. Git prints `Updating files: 78% (2104/2700)` | |
| 107 | * to stderr during checkout, and `Submodule path 'x': checked out` during | |
| 108 | * submodule init. The tracker uses these to drive the worktree setup card. | |
| 109 | */ | |
| 110 | export interface CreateWorktreeProgress { | |
| 111 | /** | |
| 112 | * Fires once `git worktree add` has created and registered the directory, | |
| 113 | * before the (possibly long) submodule step. Git refuses an existing path, | |
| 114 | * so a path reported here belongs to this call and is safe to remove on | |
| 115 | * cancel. | |
| 116 | */ | |
| 117 | readonly onWorktreeClaimed?: (path: string) => Effect.Effect<void, never>; | |
| 118 | readonly onCheckoutProgress?: (input: { | |
| 119 | percent: number; | |
| 120 | completed: number; | |
| 121 | total: number; | |
| 122 | }) => Effect.Effect<void, never>; | |
| 123 | readonly onSubmodulesStarted?: () => Effect.Effect<void, never>; | |
| 124 | readonly onSubmoduleLine?: (line: string) => Effect.Effect<void, never>; | |
| 125 | readonly onSubmodulesFinished?: (input: { | |
| 126 | ok: boolean; | |
| 127 | detail: string | null; | |
| 128 | }) => Effect.Effect<void, never>; | |
| 129 | } | |
| 130 | ||
| 131 | export interface CreateWorktreeOptions { | |
| 132 | readonly progress?: CreateWorktreeProgress; | |
| 133 | } | |
| 134 | ||
| 105 | 135 | export interface GitCommitProgress { |
| 106 | 136 | readonly onOutputLine?: (input: { |
| 107 | 137 | stream: "stdout" | "stderr"; |
| ... | ... | @@ -280,6 +310,7 @@ export class GitVcsDriver extends Context.Service< |
| 280 | 310 | readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>; |
| 281 | 311 | readonly createWorktree: ( |
| 282 | 312 | input: VcsCreateWorktreeInput, |
| 313 | options?: CreateWorktreeOptions, | |
| 283 | 314 | ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>; |
| 284 | 315 | readonly fetchPullRequestBranch: ( |
| 285 | 316 | input: GitFetchPullRequestBranchInput, |
apps/server/src/vcs/GitVcsDriverCore.test.ts+66-1| ... | ... | @@ -20,7 +20,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 20 | 20 | |
| 21 | 21 | import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts"; |
| 22 | 22 | import { ServerConfig } from "../config.ts"; |
| 23 | import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; | |
| 23 | import { | |
| 24 | makeGitVcsDriverCore, | |
| 25 | parseGitCheckoutProgressLine, | |
| 26 | splitNullSeparatedGitStdoutPaths, | |
| 27 | } from "./GitVcsDriverCore.ts"; | |
| 24 | 28 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 25 | 29 | |
| 26 | 30 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| ... | ... | @@ -1553,6 +1557,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 1553 | 1557 | }); |
| 1554 | 1558 | |
| 1555 | 1559 | describe("worktree operations", () => { |
| 1560 | it("parses checkout progress lines from git's stderr", () => { | |
| 1561 | assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), { | |
| 1562 | percent: 78, | |
| 1563 | completed: 2104, | |
| 1564 | total: 2700, | |
| 1565 | }); | |
| 1566 | // Progress lines arrive carriage-return separated and end with a done marker. | |
| 1567 | assert.deepStrictEqual( | |
| 1568 | parseGitCheckoutProgressLine("Updating files: 100% (2700/2700), done."), | |
| 1569 | { percent: 100, completed: 2700, total: 2700 }, | |
| 1570 | ); | |
| 1571 | assert.strictEqual(parseGitCheckoutProgressLine("Preparing worktree (new branch 'x')"), null); | |
| 1572 | }); | |
| 1573 | ||
| 1556 | 1574 | // NTFS rejects a newline in a file name, so there is nothing to preserve there. |
| 1557 | 1575 | it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( |
| 1558 | 1576 | "preserves newline characters in worktree paths when listing refs", |
| ... | ... | @@ -1668,6 +1686,53 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 1668 | 1686 | }), |
| 1669 | 1687 | ); |
| 1670 | 1688 | |
| 1689 | it.effect("reports checkout progress while creating a worktree", () => | |
| 1690 | Effect.gen(function* () { | |
| 1691 | const cwd = yield* makeTmpDir(); | |
| 1692 | const { initialBranch } = yield* initRepoWithCommit(cwd); | |
| 1693 | for (let index = 0; index < 5; index += 1) { | |
| 1694 | yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`); | |
| 1695 | } | |
| 1696 | yield* git(cwd, ["add", "."]); | |
| 1697 | yield* git(cwd, ["commit", "-m", "add files"]); | |
| 1698 | const pathService = yield* Path.Path; | |
| 1699 | const worktreePath = pathService.join( | |
| 1700 | yield* makeTmpDir("git-worktrees-"), | |
| 1701 | "progress-worktree", | |
| 1702 | ); | |
| 1703 | const driver = yield* GitVcsDriver.GitVcsDriver; | |
| 1704 | const seen = yield* Ref.make<Array<{ percent: number; completed: number; total: number }>>( | |
| 1705 | [], | |
| 1706 | ); | |
| 1707 | const claimed = yield* Ref.make<{ path: string; existed: boolean } | null>(null); | |
| 1708 | ||
| 1709 | yield* driver.createWorktree( | |
| 1710 | { cwd, path: worktreePath, refName: initialBranch, newRefName: "feature/progress" }, | |
| 1711 | { | |
| 1712 | progress: { | |
| 1713 | onWorktreeClaimed: (path) => | |
| 1714 | Ref.set(claimed, { path, existed: NodeFS.existsSync(path) }), | |
| 1715 | onCheckoutProgress: (update) => Ref.update(seen, (all) => [...all, update]), | |
| 1716 | }, | |
| 1717 | }, | |
| 1718 | ); | |
| 1719 | // Claimed only once git has registered the directory. | |
| 1720 | assert.deepEqual(yield* Ref.get(claimed), { path: worktreePath, existed: true }); | |
| 1721 | ||
| 1722 | // Git separates live progress updates with `\r`, so the driver must | |
| 1723 | // surface every intermediate percentage, not just the final line. | |
| 1724 | const updates = yield* Ref.get(seen); | |
| 1725 | assert.isAbove(updates.length, 1); | |
| 1726 | assert.equal(updates.at(-1)?.percent, 100); | |
| 1727 | assert.equal(updates.at(-1)?.total, 6); | |
| 1728 | const completed = updates.map((update) => update.completed); | |
| 1729 | assert.deepEqual( | |
| 1730 | completed, | |
| 1731 | completed.toSorted((a, b) => a - b), | |
| 1732 | ); | |
| 1733 | }), | |
| 1734 | ); | |
| 1735 | ||
| 1671 | 1736 | it.effect("creates and removes a worktree for a new refName", () => |
| 1672 | 1737 | Effect.gen(function* () { |
| 1673 | 1738 | const cwd = yield* makeTmpDir(); |
apps/server/src/vcs/GitVcsDriverCore.ts+81-20| ... | ... | @@ -641,6 +641,25 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( |
| 641 | 641 | }; |
| 642 | 642 | }); |
| 643 | 643 | |
| 644 | const GIT_CHECKOUT_PROGRESS_LINE = /Updating files:\s+(\d+)%\s+\((\d+)\/(\d+)\)/; | |
| 645 | ||
| 646 | /** Parses `Updating files: 78% (2104/2700)` from git's stderr progress output. */ | |
| 647 | export function parseGitCheckoutProgressLine( | |
| 648 | line: string, | |
| 649 | ): { percent: number; completed: number; total: number } | null { | |
| 650 | const match = GIT_CHECKOUT_PROGRESS_LINE.exec(line); | |
| 651 | if (!match) return null; | |
| 652 | const percent = Number(match[1]); | |
| 653 | const completed = Number(match[2]); | |
| 654 | const total = Number(match[3]); | |
| 655 | if (!Number.isFinite(percent) || !Number.isFinite(completed) || !Number.isFinite(total)) { | |
| 656 | return null; | |
| 657 | } | |
| 658 | return { percent: Math.max(0, Math.min(100, percent)), completed, total }; | |
| 659 | } | |
| 660 | ||
| 661 | const OUTPUT_LINE_SEPARATOR = /\r\n|\r|\n/; | |
| 662 | ||
| 644 | 663 | const collectOutput = Effect.fnUntraced(function* ( |
| 645 | 664 | input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">, |
| 646 | 665 | stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>, |
| ... | ... | @@ -654,19 +673,21 @@ const collectOutput = Effect.fnUntraced(function* ( |
| 654 | 673 | let lineBuffer = ""; |
| 655 | 674 | let truncated = false; |
| 656 | 675 | |
| 676 | // Git redraws progress with a bare `\r` between updates and only ends the | |
| 677 | // line once the step is done, so `\r` has to count as a line break here. | |
| 657 | 678 | const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { |
| 658 | let newlineIndex = lineBuffer.indexOf("\n"); | |
| 659 | while (newlineIndex >= 0) { | |
| 660 | const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); | |
| 661 | lineBuffer = lineBuffer.slice(newlineIndex + 1); | |
| 679 | let separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); | |
| 680 | while (separator) { | |
| 681 | const line = lineBuffer.slice(0, separator.index); | |
| 682 | lineBuffer = lineBuffer.slice(separator.index + separator[0].length); | |
| 662 | 683 | if (line.length > 0 && onLine) { |
| 663 | 684 | yield* onLine(line); |
| 664 | 685 | } |
| 665 | newlineIndex = lineBuffer.indexOf("\n"); | |
| 686 | separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); | |
| 666 | 687 | } |
| 667 | 688 | |
| 668 | 689 | if (flush) { |
| 669 | const trailing = lineBuffer.replace(/\r$/, ""); | |
| 690 | const trailing = lineBuffer; | |
| 670 | 691 | lineBuffer = ""; |
| 671 | 692 | if (trailing.length > 0 && onLine) { |
| 672 | 693 | yield* onLine(trailing); |
| ... | ... | @@ -3010,7 +3031,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3010 | 3031 | |
| 3011 | 3032 | const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( |
| 3012 | 3033 | "createWorktree", |
| 3013 | )(function* (input) { | |
| 3034 | )(function* (input, options) { | |
| 3014 | 3035 | const targetBranch = input.newRefName ?? input.refName; |
| 3015 | 3036 | const sanitizedBranch = targetBranch.replace(/\//g, "-"); |
| 3016 | 3037 | const repoName = path.basename(input.cwd); |
| ... | ... | @@ -3018,12 +3039,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3018 | 3039 | const args = input.newRefName |
| 3019 | 3040 | ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] |
| 3020 | 3041 | : ["worktree", "add", worktreePath, input.refName]; |
| 3042 | const progress = options?.progress; | |
| 3043 | const onCheckoutProgress = progress?.onCheckoutProgress; | |
| 3021 | 3044 | |
| 3022 | 3045 | yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { |
| 3023 | 3046 | fallbackErrorDetail: "git worktree add failed", |
| 3024 | 3047 | timeoutMs: WORKTREE_ADD_TIMEOUT_MS, |
| 3048 | ...(onCheckoutProgress | |
| 3049 | ? { | |
| 3050 | // Git only prints checkout progress when stderr is a tty or the | |
| 3051 | // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. | |
| 3052 | env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, | |
| 3053 | progress: { | |
| 3054 | onStderrLine: (line) => { | |
| 3055 | const parsed = parseGitCheckoutProgressLine(line); | |
| 3056 | return parsed ? onCheckoutProgress(parsed) : Effect.void; | |
| 3057 | }, | |
| 3058 | }, | |
| 3059 | } | |
| 3060 | : {}), | |
| 3025 | 3061 | }); |
| 3026 | 3062 | |
| 3063 | if (progress?.onWorktreeClaimed) { | |
| 3064 | yield* progress.onWorktreeClaimed(worktreePath); | |
| 3065 | } | |
| 3066 | ||
| 3027 | 3067 | // `git worktree add` leaves submodules empty, so a repo that keeps agent |
| 3028 | 3068 | // skills, tooling or source in one gets a worktree that is quietly missing |
| 3029 | 3069 | // them. Best-effort: the objects are usually already in the parent's |
| ... | ... | @@ -3033,18 +3073,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3033 | 3073 | .exists(path.join(worktreePath, ".gitmodules")) |
| 3034 | 3074 | .pipe(Effect.orElseSucceed(() => false)); |
| 3035 | 3075 | if (hasSubmodules) { |
| 3036 | yield* runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, [ | |
| 3037 | "submodule", | |
| 3038 | "update", | |
| 3039 | "--init", | |
| 3040 | "--recursive", | |
| 3041 | ]).pipe( | |
| 3042 | Effect.catch((cause) => | |
| 3043 | Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { | |
| 3044 | worktreePath, | |
| 3045 | cause, | |
| 3046 | }), | |
| 3047 | ), | |
| 3076 | if (progress?.onSubmodulesStarted) { | |
| 3077 | yield* progress.onSubmodulesStarted(); | |
| 3078 | } | |
| 3079 | const onSubmoduleLine = progress?.onSubmoduleLine; | |
| 3080 | yield* runGit( | |
| 3081 | "GitVcsDriver.createWorktree.updateSubmodules", | |
| 3082 | worktreePath, | |
| 3083 | ["submodule", "update", "--init", "--recursive"], | |
| 3084 | onSubmoduleLine | |
| 3085 | ? { | |
| 3086 | env: { LC_ALL: "C" }, | |
| 3087 | progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, | |
| 3088 | } | |
| 3089 | : {}, | |
| 3090 | ).pipe( | |
| 3091 | Effect.matchEffect({ | |
| 3092 | onFailure: (cause) => | |
| 3093 | Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { | |
| 3094 | worktreePath, | |
| 3095 | cause, | |
| 3096 | }).pipe( | |
| 3097 | Effect.andThen( | |
| 3098 | progress?.onSubmodulesFinished | |
| 3099 | ? progress.onSubmodulesFinished({ ok: false, detail: cause.message }) | |
| 3100 | : Effect.void, | |
| 3101 | ), | |
| 3102 | ), | |
| 3103 | onSuccess: () => | |
| 3104 | progress?.onSubmodulesFinished | |
| 3105 | ? progress.onSubmodulesFinished({ ok: true, detail: null }) | |
| 3106 | : Effect.void, | |
| 3107 | }), | |
| 3048 | 3108 | ); |
| 3049 | 3109 | } |
| 3050 | 3110 | |
| ... | ... | @@ -3496,7 +3556,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3496 | 3556 | getReviewDiffFileContents, |
| 3497 | 3557 | readConfigValue, |
| 3498 | 3558 | listRefs, |
| 3499 | createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), | |
| 3559 | createWorktree: (input, options) => | |
| 3560 | withListRefsInvalidation(input.cwd, createWorktree(input, options)), | |
| 3500 | 3561 | fetchPullRequestBranch: (input) => |
| 3501 | 3562 | withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), |
| 3502 | 3563 | fetchPullRequestHeadCommit, |
apps/server/src/ws.ts+319-37| ... | ... | @@ -7,11 +7,13 @@ import * as Crypto from "effect/Crypto"; |
| 7 | 7 | import * as DateTime from "effect/DateTime"; |
| 8 | 8 | import * as Duration from "effect/Duration"; |
| 9 | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 10 | 11 | import * as Layer from "effect/Layer"; |
| 11 | 12 | import * as Option from "effect/Option"; |
| 12 | 13 | import * as Path from "effect/Path"; |
| 13 | 14 | import * as Queue from "effect/Queue"; |
| 14 | 15 | import * as Ref from "effect/Ref"; |
| 16 | import * as Schedule from "effect/Schedule"; | |
| 15 | 17 | import * as Schema from "effect/Schema"; |
| 16 | 18 | import * as Stream from "effect/Stream"; |
| 17 | 19 | import { |
| ... | ... | @@ -129,6 +131,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 129 | 131 | import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; |
| 130 | 132 | import * as ReviewService from "./review/ReviewService.ts"; |
| 131 | 133 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 134 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; | |
| 132 | 135 | import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; |
| 133 | 136 | import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; |
| 134 | 137 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| ... | ... | @@ -597,6 +600,7 @@ const makeWsRpcLayer = ( |
| 597 | 600 | return true; |
| 598 | 601 | }); |
| 599 | 602 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; |
| 603 | const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; | |
| 600 | 604 | const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; |
| 601 | 605 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; |
| 602 | 606 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; |
| ... | ... | @@ -991,6 +995,9 @@ const makeWsRpcLayer = ( |
| 991 | 995 | let targetProjectId = bootstrap?.createThread?.projectId; |
| 992 | 996 | let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; |
| 993 | 997 | let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; |
| 998 | // The setup script's terminal, once started. Cancel closes only this | |
| 999 | // one so terminals the user opened meanwhile survive. | |
| 1000 | let setupTerminalId: string | null = null; | |
| 994 | 1001 | |
| 995 | 1002 | const cleanupCreatedThread = () => |
| 996 | 1003 | createdThread |
| ... | ... | @@ -1083,19 +1090,37 @@ const makeWsRpcLayer = ( |
| 1083 | 1090 | ); |
| 1084 | 1091 | }); |
| 1085 | 1092 | |
| 1093 | const tracked = bootstrap?.prepareWorktree !== undefined; | |
| 1094 | const threadId = command.threadId; | |
| 1095 | const track = (effect: Effect.Effect<void>) => (tracked ? effect : Effect.void); | |
| 1096 | ||
| 1097 | // Runs the setup script and, for tracked bootstraps, waits for it to | |
| 1098 | // exit so the card can show the exit code and the agent stage never | |
| 1099 | // starts on a half-installed tree. Untracked callers keep the old | |
| 1100 | // fire-and-forget behavior. | |
| 1086 | 1101 | const runSetupProgram = () => |
| 1087 | 1102 | Effect.gen(function* () { |
| 1088 | 1103 | if (!bootstrap?.runSetupScript || !targetWorktreePath) { |
| 1104 | yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "skipped")); | |
| 1089 | 1105 | return; |
| 1090 | 1106 | } |
| 1091 | 1107 | const worktreePath = targetWorktreePath; |
| 1092 | 1108 | const requestedAt = yield* nowIso; |
| 1093 | yield* projectSetupScriptRunner | |
| 1109 | yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "running")); | |
| 1110 | const setupResult = yield* projectSetupScriptRunner | |
| 1094 | 1111 | .runForThread({ |
| 1095 | threadId: command.threadId, | |
| 1112 | threadId, | |
| 1096 | 1113 | ...(targetProjectId ? { projectId: targetProjectId } : {}), |
| 1097 | 1114 | ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), |
| 1098 | 1115 | worktreePath, |
| 1116 | ...(tracked | |
| 1117 | ? { | |
| 1118 | observeCompletion: { | |
| 1119 | onOutputLine: (line) => | |
| 1120 | worktreeSetupTracker.appendTail(threadId, "setup-script", line), | |
| 1121 | }, | |
| 1122 | } | |
| 1123 | : {}), | |
| 1099 | 1124 | }) |
| 1100 | 1125 | .pipe( |
| 1101 | 1126 | Effect.matchEffect({ |
| ... | ... | @@ -1104,21 +1129,71 @@ const makeWsRpcLayer = ( |
| 1104 | 1129 | error, |
| 1105 | 1130 | requestedAt, |
| 1106 | 1131 | worktreePath, |
| 1107 | }), | |
| 1132 | }).pipe( | |
| 1133 | Effect.andThen( | |
| 1134 | track( | |
| 1135 | worktreeSetupTracker.stageStatus( | |
| 1136 | threadId, | |
| 1137 | "setup-script", | |
| 1138 | "failed", | |
| 1139 | "failed to start", | |
| 1140 | ), | |
| 1141 | ), | |
| 1142 | ), | |
| 1143 | Effect.as(null), | |
| 1144 | ), | |
| 1108 | 1145 | onSuccess: (setupResult) => { |
| 1109 | 1146 | if (setupResult.status !== "started") { |
| 1110 | return Effect.void; | |
| 1147 | return track( | |
| 1148 | worktreeSetupTracker.stageStatus( | |
| 1149 | threadId, | |
| 1150 | "setup-script", | |
| 1151 | "skipped", | |
| 1152 | "no setup script", | |
| 1153 | ), | |
| 1154 | ).pipe(Effect.as(null)); | |
| 1111 | 1155 | } |
| 1156 | setupTerminalId = setupResult.terminalId; | |
| 1112 | 1157 | return recordSetupScriptStarted({ |
| 1113 | 1158 | requestedAt, |
| 1114 | 1159 | worktreePath, |
| 1115 | 1160 | scriptId: setupResult.scriptId, |
| 1116 | 1161 | scriptName: setupResult.scriptName, |
| 1117 | 1162 | terminalId: setupResult.terminalId, |
| 1118 | }); | |
| 1163 | }).pipe( | |
| 1164 | Effect.andThen( | |
| 1165 | track( | |
| 1166 | worktreeSetupTracker.update(threadId, (snapshot) => ({ | |
| 1167 | ...snapshot, | |
| 1168 | setupScript: { | |
| 1169 | name: setupResult.scriptName, | |
| 1170 | command: setupResult.scriptCommand, | |
| 1171 | terminalId: setupResult.terminalId, | |
| 1172 | }, | |
| 1173 | })), | |
| 1174 | ), | |
| 1175 | ), | |
| 1176 | Effect.as(setupResult), | |
| 1177 | ); | |
| 1119 | 1178 | }, |
| 1120 | 1179 | }), |
| 1121 | 1180 | ); |
| 1181 | if (!tracked || !setupResult?.completion) { | |
| 1182 | return; | |
| 1183 | } | |
| 1184 | // The setup script is best effort, like the untracked path: a | |
| 1185 | // failed install must not throw away the worktree the user just | |
| 1186 | // waited for. The card keeps the failed stage and its terminal. | |
| 1187 | const completion = yield* setupResult.completion; | |
| 1188 | if (completion.exitCode === 0) { | |
| 1189 | yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "done"); | |
| 1190 | return; | |
| 1191 | } | |
| 1192 | const detail = | |
| 1193 | completion.exitCode === null | |
| 1194 | ? "terminal closed before the script finished" | |
| 1195 | : `exit ${completion.exitCode}`; | |
| 1196 | yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "failed", detail); | |
| 1122 | 1197 | }); |
| 1123 | 1198 | |
| 1124 | 1199 | const bootstrapProgram = Effect.gen(function* () { |
| ... | ... | @@ -1138,6 +1213,7 @@ const makeWsRpcLayer = ( |
| 1138 | 1213 | remoteName: "origin", |
| 1139 | 1214 | })); |
| 1140 | 1215 | if (startFromOrigin) { |
| 1216 | yield* track(worktreeSetupTracker.stageStatus(threadId, "fetch", "running")); | |
| 1141 | 1217 | yield* gitWorkflow.fetchRemote({ |
| 1142 | 1218 | cwd: prepareWorktree.projectCwd, |
| 1143 | 1219 | remoteName: "origin", |
| ... | ... | @@ -1154,7 +1230,26 @@ const makeWsRpcLayer = ( |
| 1154 | 1230 | fallbackRemoteName: "origin", |
| 1155 | 1231 | }); |
| 1156 | 1232 | worktreeBaseRef = resolvedRemoteBase.commitSha; |
| 1233 | yield* track( | |
| 1234 | worktreeSetupTracker.stageStatus( | |
| 1235 | threadId, | |
| 1236 | "fetch", | |
| 1237 | "done", | |
| 1238 | `origin/${prepareWorktree.baseBranch} at ${resolvedRemoteBase.commitSha.slice(0, 7)}`, | |
| 1239 | ), | |
| 1240 | ); | |
| 1241 | } else { | |
| 1242 | yield* track( | |
| 1243 | worktreeSetupTracker.stageStatus( | |
| 1244 | threadId, | |
| 1245 | "fetch", | |
| 1246 | "warning", | |
| 1247 | `origin/${prepareWorktree.baseBranch} not found, using local branch`, | |
| 1248 | ), | |
| 1249 | ); | |
| 1157 | 1250 | } |
| 1251 | } else { | |
| 1252 | yield* track(worktreeSetupTracker.stageStatus(threadId, "fetch", "skipped")); | |
| 1158 | 1253 | } |
| 1159 | 1254 | |
| 1160 | 1255 | const resolvedWorktreeBaseRef = worktreeBaseRef ?? prepareWorktree.baseBranch; |
| ... | ... | @@ -1163,6 +1258,27 @@ const makeWsRpcLayer = ( |
| 1163 | 1258 | refName: resolvedWorktreeBaseRef, |
| 1164 | 1259 | }); |
| 1165 | 1260 | worktreeBaseRef = resolvedWorktreeBaseRef; |
| 1261 | yield* track( | |
| 1262 | worktreeSetupTracker.update(threadId, (snapshot) => ({ | |
| 1263 | ...snapshot, | |
| 1264 | baseRef: resolvedWorktreeBaseRef, | |
| 1265 | })), | |
| 1266 | ); | |
| 1267 | } | |
| 1268 | ||
| 1269 | if (prepareWorktree && !shouldPrepareWorktree) { | |
| 1270 | // Not a git repo, or the base has no commit: the thread runs in | |
| 1271 | // the project checkout instead. The card says so and moves on. | |
| 1272 | yield* track( | |
| 1273 | worktreeSetupTracker.update(threadId, (snapshot) => ({ | |
| 1274 | ...snapshot, | |
| 1275 | stages: snapshot.stages.map((stage) => | |
| 1276 | stage.id === "fetch" || stage.id === "checkout" || stage.id === "submodules" | |
| 1277 | ? { ...stage, status: "skipped", detail: "using project checkout" } | |
| 1278 | : stage, | |
| 1279 | ), | |
| 1280 | })), | |
| 1281 | ); | |
| 1166 | 1282 | } |
| 1167 | 1283 | |
| 1168 | 1284 | if (bootstrap?.createThread) { |
| ... | ... | @@ -1188,18 +1304,92 @@ const makeWsRpcLayer = ( |
| 1188 | 1304 | } |
| 1189 | 1305 | |
| 1190 | 1306 | if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) { |
| 1191 | const worktree = yield* gitWorkflow.createWorktree({ | |
| 1192 | cwd: prepareWorktree.projectCwd, | |
| 1193 | refName: worktreeBaseRef, | |
| 1194 | newRefName: prepareWorktree.branch, | |
| 1195 | baseRefName: prepareWorktree.baseBranch, | |
| 1196 | path: null, | |
| 1197 | }); | |
| 1307 | yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); | |
| 1308 | let checkoutTotal: number | null = null; | |
| 1309 | const worktree = yield* gitWorkflow.createWorktree( | |
| 1310 | { | |
| 1311 | cwd: prepareWorktree.projectCwd, | |
| 1312 | refName: worktreeBaseRef, | |
| 1313 | newRefName: prepareWorktree.branch, | |
| 1314 | baseRefName: prepareWorktree.baseBranch, | |
| 1315 | path: null, | |
| 1316 | }, | |
| 1317 | { | |
| 1318 | progress: { | |
| 1319 | // Git has registered the directory at this point, so a | |
| 1320 | // cancel during the submodule step can still remove it. | |
| 1321 | onWorktreeClaimed: (path) => | |
| 1322 | Effect.sync(() => { | |
| 1323 | targetWorktreePath = path; | |
| 1324 | }), | |
| 1325 | onCheckoutProgress: ({ percent, completed, total }) => { | |
| 1326 | checkoutTotal = total; | |
| 1327 | return worktreeSetupTracker.stage(threadId, "checkout", { | |
| 1328 | percent, | |
| 1329 | detail: `${completed.toLocaleString("en-US")} / ${total.toLocaleString("en-US")} files`, | |
| 1330 | }); | |
| 1331 | }, | |
| 1332 | onSubmodulesStarted: () => | |
| 1333 | worktreeSetupTracker | |
| 1334 | .stageStatus( | |
| 1335 | threadId, | |
| 1336 | "checkout", | |
| 1337 | "done", | |
| 1338 | checkoutTotal === null | |
| 1339 | ? null | |
| 1340 | : `${checkoutTotal.toLocaleString("en-US")} files`, | |
| 1341 | ) | |
| 1342 | .pipe( | |
| 1343 | Effect.andThen( | |
| 1344 | worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), | |
| 1345 | ), | |
| 1346 | ), | |
| 1347 | onSubmoduleLine: (line) => { | |
| 1348 | const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; | |
| 1349 | return submodulePath === undefined | |
| 1350 | ? Effect.void | |
| 1351 | : worktreeSetupTracker.stage(threadId, "submodules", { | |
| 1352 | detail: submodulePath, | |
| 1353 | }); | |
| 1354 | }, | |
| 1355 | onSubmodulesFinished: ({ ok, detail }) => | |
| 1356 | worktreeSetupTracker.stageStatus( | |
| 1357 | threadId, | |
| 1358 | "submodules", | |
| 1359 | ok ? "done" : "warning", | |
| 1360 | ok ? undefined : (detail ?? "submodule checkout failed"), | |
| 1361 | ), | |
| 1362 | }, | |
| 1363 | }, | |
| 1364 | ); | |
| 1365 | const checkoutEndedAt = yield* nowIso; | |
| 1366 | yield* worktreeSetupTracker.update(threadId, (snapshot) => ({ | |
| 1367 | ...snapshot, | |
| 1368 | worktreePath: worktree.worktree.path, | |
| 1369 | stages: snapshot.stages.map((stage) => { | |
| 1370 | if (stage.id === "checkout" && stage.status === "running") { | |
| 1371 | return { | |
| 1372 | ...stage, | |
| 1373 | status: "done", | |
| 1374 | percent: 100, | |
| 1375 | endedAt: checkoutEndedAt, | |
| 1376 | detail: | |
| 1377 | checkoutTotal === null | |
| 1378 | ? stage.detail | |
| 1379 | : `${checkoutTotal.toLocaleString("en-US")} files`, | |
| 1380 | }; | |
| 1381 | } | |
| 1382 | if (stage.id === "submodules" && stage.status === "pending") { | |
| 1383 | return { ...stage, status: "skipped", detail: "none" }; | |
| 1384 | } | |
| 1385 | return stage; | |
| 1386 | }), | |
| 1387 | })); | |
| 1198 | 1388 | targetWorktreePath = worktree.worktree.path; |
| 1199 | 1389 | yield* dispatchFromClient({ |
| 1200 | 1390 | type: "thread.meta.update", |
| 1201 | 1391 | commandId: yield* serverCommandId("bootstrap-thread-meta-update"), |
| 1202 | threadId: command.threadId, | |
| 1392 | threadId, | |
| 1203 | 1393 | branch: worktree.worktree.refName, |
| 1204 | 1394 | worktreePath: targetWorktreePath, |
| 1205 | 1395 | }); |
| ... | ... | @@ -1208,36 +1398,114 @@ const makeWsRpcLayer = ( |
| 1208 | 1398 | |
| 1209 | 1399 | yield* runSetupProgram(); |
| 1210 | 1400 | |
| 1211 | return yield* dispatchFromClient(finalTurnStartCommand); | |
| 1401 | yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "running")); | |
| 1402 | // Past this point a cancel would roll back a thread whose turn has | |
| 1403 | // started. Drop the cancel handle and make the handoff atomic. | |
| 1404 | yield* track(worktreeSetupTracker.markUncancellable(threadId)); | |
| 1405 | const started = yield* Effect.uninterruptible( | |
| 1406 | dispatchFromClient(finalTurnStartCommand), | |
| 1407 | ); | |
| 1408 | yield* track( | |
| 1409 | worktreeSetupTracker | |
| 1410 | .stageStatus(threadId, "agent", "done") | |
| 1411 | .pipe(Effect.andThen(worktreeSetupTracker.finish(threadId, "done"))), | |
| 1412 | ); | |
| 1413 | return started; | |
| 1212 | 1414 | }); |
| 1213 | 1415 | |
| 1214 | return yield* bootstrapProgram.pipe( | |
| 1416 | const runBootstrap = tracked | |
| 1417 | ? Effect.gen(function* () { | |
| 1418 | const fiber = yield* Effect.forkChild(bootstrapProgram); | |
| 1419 | yield* worktreeSetupTracker.begin({ | |
| 1420 | threadId, | |
| 1421 | branch: bootstrap?.prepareWorktree?.branch ?? null, | |
| 1422 | baseRef: bootstrap?.prepareWorktree?.baseBranch ?? null, | |
| 1423 | stages: ["fetch", "checkout", "submodules", "setup-script", "agent"], | |
| 1424 | fiber, | |
| 1425 | }); | |
| 1426 | return yield* Fiber.join(fiber); | |
| 1427 | }) | |
| 1428 | : bootstrapProgram; | |
| 1429 | ||
| 1430 | const cleanupAndFail = ( | |
| 1431 | cause: Cause.Cause<unknown>, | |
| 1432 | dispatchError: OrchestrationDispatchCommandError, | |
| 1433 | ) => | |
| 1434 | Effect.uninterruptible(cleanupCreatedThread()).pipe( | |
| 1435 | Effect.matchCauseEffect({ | |
| 1436 | onFailure: (cleanupCause) => | |
| 1437 | Effect.logWarning("bootstrap thread cleanup failed", { | |
| 1438 | threadId, | |
| 1439 | detail: Cause.pretty(cleanupCause), | |
| 1440 | }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), | |
| 1441 | onSuccess: (threadDeleted) => | |
| 1442 | Effect.fail( | |
| 1443 | threadDeleted | |
| 1444 | ? new OrchestrationDispatchCommandError({ | |
| 1445 | message: dispatchError.message, | |
| 1446 | ...(dispatchError.cause !== undefined | |
| 1447 | ? { cause: dispatchError.cause } | |
| 1448 | : {}), | |
| 1449 | bootstrapThreadDisposition: "deleted", | |
| 1450 | }) | |
| 1451 | : dispatchError, | |
| 1452 | ), | |
| 1453 | }), | |
| 1454 | ); | |
| 1455 | ||
| 1456 | return yield* runBootstrap.pipe( | |
| 1215 | 1457 | Effect.catchCause((cause) => { |
| 1216 | 1458 | const dispatchError = toBootstrapDispatchCommandCauseError(cause); |
| 1217 | 1459 | if (Cause.hasInterruptsOnly(cause)) { |
| 1218 | return Effect.fail(dispatchError); | |
| 1460 | // A user cancel interrupts the forked bootstrap fiber. The | |
| 1461 | // created thread is rolled back like any other failure so the | |
| 1462 | // draft returns to the composer. The setup terminal is closed | |
| 1463 | // first so a still-running script cannot hold files open in | |
| 1464 | // the worktree while git removes it. Closing kills the | |
| 1465 | // process asynchronously, so the removal retries briefly. | |
| 1466 | const closeSetupTerminal = setupTerminalId | |
| 1467 | ? terminalManager.close({ | |
| 1468 | threadId, | |
| 1469 | terminalId: setupTerminalId, | |
| 1470 | deleteHistory: true, | |
| 1471 | }) | |
| 1472 | : Effect.void; | |
| 1473 | const removeCreatedWorktree = | |
| 1474 | tracked && targetWorktreePath && bootstrap?.prepareWorktree | |
| 1475 | ? closeSetupTerminal.pipe( | |
| 1476 | Effect.ignoreCause({ log: true }), | |
| 1477 | Effect.andThen( | |
| 1478 | gitWorkflow | |
| 1479 | .removeWorktree({ | |
| 1480 | cwd: bootstrap.prepareWorktree.projectCwd, | |
| 1481 | path: targetWorktreePath, | |
| 1482 | force: true, | |
| 1483 | }) | |
| 1484 | .pipe( | |
| 1485 | Effect.retry({ times: 4, schedule: Schedule.spaced("500 millis") }), | |
| 1486 | ), | |
| 1487 | ), | |
| 1488 | Effect.ignoreCause({ log: true }), | |
| 1489 | Effect.uninterruptible, | |
| 1490 | ) | |
| 1491 | : Effect.void; | |
| 1492 | return track(worktreeSetupTracker.finish(threadId, "cancelled")).pipe( | |
| 1493 | Effect.andThen(removeCreatedWorktree), | |
| 1494 | Effect.andThen( | |
| 1495 | tracked | |
| 1496 | ? cleanupAndFail( | |
| 1497 | cause, | |
| 1498 | new OrchestrationDispatchCommandError({ | |
| 1499 | message: "Worktree setup cancelled.", | |
| 1500 | }), | |
| 1501 | ) | |
| 1502 | : Effect.fail(dispatchError), | |
| 1503 | ), | |
| 1504 | ); | |
| 1219 | 1505 | } |
| 1220 | return Effect.uninterruptible(cleanupCreatedThread()).pipe( | |
| 1221 | Effect.matchCauseEffect({ | |
| 1222 | onFailure: (cleanupCause) => | |
| 1223 | Effect.logWarning("bootstrap thread cleanup failed", { | |
| 1224 | threadId: command.threadId, | |
| 1225 | detail: Cause.pretty(cleanupCause), | |
| 1226 | }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), | |
| 1227 | onSuccess: (threadDeleted) => | |
| 1228 | Effect.fail( | |
| 1229 | threadDeleted | |
| 1230 | ? new OrchestrationDispatchCommandError({ | |
| 1231 | message: dispatchError.message, | |
| 1232 | ...(dispatchError.cause !== undefined | |
| 1233 | ? { cause: dispatchError.cause } | |
| 1234 | : {}), | |
| 1235 | bootstrapThreadDisposition: "deleted", | |
| 1236 | }) | |
| 1237 | : dispatchError, | |
| 1238 | ), | |
| 1239 | }), | |
| 1240 | ); | |
| 1506 | return track( | |
| 1507 | worktreeSetupTracker.finish(threadId, "failed", dispatchError.message), | |
| 1508 | ).pipe(Effect.andThen(cleanupAndFail(cause, dispatchError))); | |
| 1241 | 1509 | }), |
| 1242 | 1510 | ); |
| 1243 | 1511 | }); |
| ... | ... | @@ -2614,6 +2882,20 @@ const makeWsRpcLayer = ( |
| 2614 | 2882 | "rpc.aggregate": "vcs", |
| 2615 | 2883 | }, |
| 2616 | 2884 | ), |
| 2885 | [WS_METHODS.subscribeWorktreeSetup]: (input) => | |
| 2886 | observeRpcStream( | |
| 2887 | WS_METHODS.subscribeWorktreeSetup, | |
| 2888 | worktreeSetupTracker.stream(input.threadId), | |
| 2889 | { "rpc.aggregate": "vcs" }, | |
| 2890 | ), | |
| 2891 | [WS_METHODS.worktreeSetupCancel]: (input) => | |
| 2892 | observeRpcEffect( | |
| 2893 | WS_METHODS.worktreeSetupCancel, | |
| 2894 | worktreeSetupTracker | |
| 2895 | .cancel(input.threadId) | |
| 2896 | .pipe(Effect.map((cancelled) => ({ cancelled }))), | |
| 2897 | { "rpc.aggregate": "vcs" }, | |
| 2898 | ), | |
| 2617 | 2899 | [WS_METHODS.vcsRefreshStatus]: (input) => |
| 2618 | 2900 | observeRpcEffect( |
| 2619 | 2901 | WS_METHODS.vcsRefreshStatus, |
apps/web/src/components/ChatView.logic.test.ts+24| ... | ... | @@ -597,6 +597,30 @@ describe("draft hero submission transition", () => { |
| 597 | 597 | ).toBe(false); |
| 598 | 598 | }); |
| 599 | 599 | |
| 600 | it("leaves the hero layout while a worktree setup card is on the timeline", () => { | |
| 601 | expect( | |
| 602 | resolveDraftHeroState({ | |
| 603 | isLocalDraftThread: true, | |
| 604 | hasTimelineEntries: false, | |
| 605 | isWorking: false, | |
| 606 | draftHeroDockRequested: false, | |
| 607 | backgroundSubmissionPending: false, | |
| 608 | hasWorktreeSetupCard: true, | |
| 609 | }), | |
| 610 | ).toBe(false); | |
| 611 | // A background submission normally pins the hero, but never over the card. | |
| 612 | expect( | |
| 613 | resolveDraftHeroState({ | |
| 614 | isLocalDraftThread: true, | |
| 615 | hasTimelineEntries: false, | |
| 616 | isWorking: false, | |
| 617 | draftHeroDockRequested: false, | |
| 618 | backgroundSubmissionPending: true, | |
| 619 | hasWorktreeSetupCard: true, | |
| 620 | }), | |
| 621 | ).toBe(false); | |
| 622 | }); | |
| 623 | ||
| 600 | 624 | it("keeps the composer in the hero layout until navigation after server promotion", () => { |
| 601 | 625 | expect( |
| 602 | 626 | resolveDraftHeroState({ |
apps/web/src/components/ChatView.logic.ts+5| ... | ... | @@ -257,7 +257,12 @@ export function resolveDraftHeroState(input: { |
| 257 | 257 | isWorking: boolean; |
| 258 | 258 | draftHeroDockRequested: boolean; |
| 259 | 259 | backgroundSubmissionPending: boolean; |
| 260 | /** A worktree setup card is on the timeline, so the timeline must stay visible. */ | |
| 261 | hasWorktreeSetupCard?: boolean; | |
| 260 | 262 | }): boolean { |
| 263 | if (input.hasWorktreeSetupCard) { | |
| 264 | return false; | |
| 265 | } | |
| 261 | 266 | if (input.backgroundSubmissionPending) { |
| 262 | 267 | return true; |
| 263 | 268 | } |
apps/web/src/components/ChatView.tsx+156| ... | ... | @@ -43,6 +43,7 @@ import { |
| 43 | 43 | resolveEnvironmentMachineKind, |
| 44 | 44 | RuntimeMode, |
| 45 | 45 | TerminalOpenInput, |
| 46 | type WorktreeSetupSnapshot, | |
| 46 | 47 | } from "@t3tools/contracts"; |
| 47 | 48 | import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; |
| 48 | 49 | import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; |
| ... | ... | @@ -1645,6 +1646,27 @@ export default function ChatView(props: ChatViewProps) { |
| 1645 | 1646 | return () => revokeBlobPreviewUrl(src); |
| 1646 | 1647 | }, [expandedImage]); |
| 1647 | 1648 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| 1649 | // The bootstrap worktree setup this composer last dispatched. Set when a | |
| 1650 | // worktree send starts and cleared once the turn starts or the next send | |
| 1651 | // begins, so a failed or cancelled card stays until the user acts. | |
| 1652 | const [worktreeSetupRef, setWorktreeSetupRef] = useState<{ | |
| 1653 | environmentId: EnvironmentId; | |
| 1654 | threadId: ThreadId; | |
| 1655 | ownerKey: string; | |
| 1656 | } | null>(null); | |
| 1657 | const [heldWorktreeSetup, setHeldWorktreeSetup] = useState<WorktreeSetupSnapshot | null>(null); | |
| 1658 | // Set by "Work locally": the draft whose restored message should be resent | |
| 1659 | // once the cancelled dispatch has settled and the draft is in local mode. | |
| 1660 | // Keyed by draft id so a bootstrap rotating the thread id keeps it, while | |
| 1661 | // moving to another draft drops it without an effect. | |
| 1662 | const [workLocallyResendDraftId, setWorkLocallyResendDraftId] = useState<DraftId | null>(null); | |
| 1663 | // The draft route reuses this component across drafts, so a resend recorded | |
| 1664 | // for one draft must not fire when the user comes back to it later. | |
| 1665 | useEffect(() => { | |
| 1666 | if (workLocallyResendDraftId !== null && workLocallyResendDraftId !== draftId) { | |
| 1667 | setWorkLocallyResendDraftId(null); | |
| 1668 | } | |
| 1669 | }, [draftId, workLocallyResendDraftId]); | |
| 1648 | 1670 | const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< |
| 1649 | 1671 | Record<string, ReadonlyArray<CodexFeedbackSubmission>> |
| 1650 | 1672 | >({}); |
| ... | ... | @@ -3337,6 +3359,64 @@ export default function ChatView(props: ChatViewProps) { |
| 3337 | 3359 | activeThreadKey, |
| 3338 | 3360 | ); |
| 3339 | 3361 | const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); |
| 3362 | // Live stages of a bootstrap worktree setup. The subscription follows the | |
| 3363 | // thread that was set up, not the route: a deleted bootstrap thread rotates | |
| 3364 | // the draft's thread id, and the failed card must survive that. | |
| 3365 | const worktreeSetupOwnerKey = draftId ?? routeThreadKey; | |
| 3366 | const worktreeSetupActive = | |
| 3367 | worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey; | |
| 3368 | // The setup runs on the environment that received the dispatch, so both | |
| 3369 | // the subscription and cancel target that one even if the draft's machine | |
| 3370 | // picker changes underneath. | |
| 3371 | const worktreeSetupQuery = useEnvironmentQuery( | |
| 3372 | worktreeSetupActive | |
| 3373 | ? vcsEnvironment.worktreeSetup({ | |
| 3374 | environmentId: worktreeSetupRef.environmentId, | |
| 3375 | input: { threadId: worktreeSetupRef.threadId }, | |
| 3376 | }) | |
| 3377 | : null, | |
| 3378 | ); | |
| 3379 | const latestWorktreeSetup = worktreeSetupQuery.data; | |
| 3380 | useEffect(() => { | |
| 3381 | // The server drops finished snapshots after a grace period and emits null. | |
| 3382 | // Hold the last real snapshot so a settled card does not vanish. | |
| 3383 | if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup); | |
| 3384 | }, [latestWorktreeSetup]); | |
| 3385 | const worktreeSetup = | |
| 3386 | worktreeSetupActive && heldWorktreeSetup?.threadId === worktreeSetupRef.threadId | |
| 3387 | ? heldWorktreeSetup | |
| 3388 | : null; | |
| 3389 | // A finished card is dropped once the agent's turn shows in the timeline: | |
| 3390 | // the card belongs to the send, and the agent takes over from there. | |
| 3391 | const worktreeSetupDoneAndTurnVisible = | |
| 3392 | worktreeSetup?.phase === "done" && activeThread?.latestTurn?.startedAt != null; | |
| 3393 | useEffect(() => { | |
| 3394 | if (!worktreeSetupDoneAndTurnVisible) return; | |
| 3395 | setWorktreeSetupRef(null); | |
| 3396 | setHeldWorktreeSetup(null); | |
| 3397 | }, [worktreeSetupDoneAndTurnVisible]); | |
| 3398 | const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { | |
| 3399 | reportFailure: false, | |
| 3400 | }); | |
| 3401 | const onCancelWorktreeSetup = useCallback(() => { | |
| 3402 | if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running") return; | |
| 3403 | void cancelWorktreeSetup({ | |
| 3404 | environmentId: worktreeSetupRef.environmentId, | |
| 3405 | input: { threadId: worktreeSetup.threadId }, | |
| 3406 | }); | |
| 3407 | }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupRef]); | |
| 3408 | // The setup terminal belongs to the thread that was set up. A failed | |
| 3409 | // bootstrap deletes that thread and closes its terminals, so only offer the | |
| 3410 | // terminal while the setup thread is still the active one. | |
| 3411 | const onOpenWorktreeSetupTerminal = useMemo(() => { | |
| 3412 | if (!worktreeSetup || !activeThreadRef || worktreeSetup.threadId !== activeThreadRef.threadId) { | |
| 3413 | return null; | |
| 3414 | } | |
| 3415 | const setupThreadRef = activeThreadRef; | |
| 3416 | return (terminalId: string) => { | |
| 3417 | storeEnsureTerminal(setupThreadRef, terminalId, { open: true, active: true }); | |
| 3418 | }; | |
| 3419 | }, [activeThreadRef, storeEnsureTerminal, worktreeSetup]); | |
| 3340 | 3420 | const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null); |
| 3341 | 3421 | const draftHeroDockRequested = |
| 3342 | 3422 | activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; |
| ... | ... | @@ -3346,6 +3426,9 @@ export default function ChatView(props: ChatViewProps) { |
| 3346 | 3426 | isWorking, |
| 3347 | 3427 | draftHeroDockRequested, |
| 3348 | 3428 | backgroundSubmissionPending, |
| 3429 | // A cancelled or failed setup card stays on the draft's timeline; the | |
| 3430 | // hero headline would paint over it. | |
| 3431 | hasWorktreeSetupCard: worktreeSetup !== null, | |
| 3349 | 3432 | }); |
| 3350 | 3433 | const [ |
| 3351 | 3434 | attachDraftHeroTransitionGroupRef, |
| ... | ... | @@ -7279,6 +7362,11 @@ export default function ChatView(props: ChatViewProps) { |
| 7279 | 7362 | preparingWorktree: Boolean(baseBranchForWorktree), |
| 7280 | 7363 | submissionIntent: resolvedSubmissionIntent, |
| 7281 | 7364 | }); |
| 7365 | setWorktreeSetupRef( | |
| 7366 | baseBranchForWorktree | |
| 7367 | ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey } | |
| 7368 | : null, | |
| 7369 | ); | |
| 7282 | 7370 | |
| 7283 | 7371 | const messageIdForSend = newMessageId(); |
| 7284 | 7372 | const messageCreatedAt = new Date().toISOString(); |
| ... | ... | @@ -8365,6 +8453,70 @@ export default function ChatView(props: ChatViewProps) { |
| 8365 | 8453 | ], |
| 8366 | 8454 | ); |
| 8367 | 8455 | |
| 8456 | // "Work locally" on the setup card: cancel the bootstrap and remember the | |
| 8457 | // draft. The cancelled dispatch deletes the half-made thread and puts the | |
| 8458 | // message back in the composer; the effect below then flips the draft to | |
| 8459 | // local mode and resends. The draft is a server thread for the whole | |
| 8460 | // setup (the bootstrap created it), so this keys off the route, not | |
| 8461 | // `isLocalDraftThread`. | |
| 8462 | const onWorktreeSetupWorkLocally = useCallback(() => { | |
| 8463 | if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running" || !draftId) { | |
| 8464 | return; | |
| 8465 | } | |
| 8466 | const target = { | |
| 8467 | environmentId: worktreeSetupRef.environmentId, | |
| 8468 | input: { threadId: worktreeSetup.threadId }, | |
| 8469 | }; | |
| 8470 | void (async () => { | |
| 8471 | const result = await cancelWorktreeSetup(target); | |
| 8472 | if (result._tag !== "Success" || !result.value.cancelled) return; | |
| 8473 | setWorkLocallyResendDraftId(draftId); | |
| 8474 | })(); | |
| 8475 | }, [cancelWorktreeSetup, draftId, worktreeSetup, worktreeSetupRef]); | |
| 8476 | const onSendRef = useRef(onSend); | |
| 8477 | onSendRef.current = onSend; | |
| 8478 | // Resend once the cancelled dispatch has settled and the composer is free. | |
| 8479 | // Every state that makes `onSend` bail and wait is part of the readiness | |
| 8480 | // check, so the flag survives a reconnect, a reverting checkpoint, or a | |
| 8481 | // feedback upload in between. What remains inside `onSend` are the checks | |
| 8482 | // that need the user to change something, and those should not auto retry. | |
| 8483 | const workLocallyResendReady = | |
| 8484 | workLocallyResendDraftId !== null && | |
| 8485 | workLocallyResendDraftId === draftId && | |
| 8486 | isLocalDraftThread && | |
| 8487 | !isSendBusy && | |
| 8488 | !isConnecting && | |
| 8489 | !isRevertingCheckpoint && | |
| 8490 | !threadDetailLoading && | |
| 8491 | clientSettingsHydrated && | |
| 8492 | !needsLoadBalancing && | |
| 8493 | !activeEnvironmentUnavailable && | |
| 8494 | !activePendingProgress && | |
| 8495 | !feedbackUploading; | |
| 8496 | useEffect(() => { | |
| 8497 | if ( | |
| 8498 | !workLocallyResendReady || | |
| 8499 | sendInFlightRef.current || | |
| 8500 | feedbackUploadsInFlightRef.current.has(routeThreadKey) | |
| 8501 | ) { | |
| 8502 | return; | |
| 8503 | } | |
| 8504 | if (sendEnvMode !== "local") { | |
| 8505 | // The draft is back; switch it to the project checkout and let the next | |
| 8506 | // render resend. | |
| 8507 | setDraftThreadContext(composerDraftTarget, { envMode: "local", startFromOrigin: false }); | |
| 8508 | return; | |
| 8509 | } | |
| 8510 | setWorkLocallyResendDraftId(null); | |
| 8511 | void onSendRef.current(); | |
| 8512 | }, [ | |
| 8513 | composerDraftTarget, | |
| 8514 | routeThreadKey, | |
| 8515 | sendEnvMode, | |
| 8516 | setDraftThreadContext, | |
| 8517 | workLocallyResendReady, | |
| 8518 | ]); | |
| 8519 | ||
| 8368 | 8520 | const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { |
| 8369 | 8521 | if (canOverrideServerThreadEnvMode && activeThread) { |
| 8370 | 8522 | setPendingServerThreadStartFromOriginByThreadId((current) => |
| ... | ... | @@ -8808,6 +8960,10 @@ export default function ChatView(props: ChatViewProps) { |
| 8808 | 8960 | isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} |
| 8809 | 8961 | isCompacting={!paintOnlyDisplayedTimeline && isCompacting} |
| 8810 | 8962 | activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} |
| 8963 | worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} | |
| 8964 | onCancelWorktreeSetup={onCancelWorktreeSetup} | |
| 8965 | {...(draftId ? { onWorktreeSetupWorkLocally } : {})} | |
| 8966 | {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} | |
| 8811 | 8967 | listRef={legendListRef} |
| 8812 | 8968 | timelineEntries={displayedTimeline.entries} |
| 8813 | 8969 | latestTurn={paintOnlyDisplayedTimeline ? null : activeLatestTurn} |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+78| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | ThreadId, |
| 11 | 11 | TurnId, |
| 12 | 12 | type OrchestrationThread, |
| 13 | type WorktreeSetupSnapshot, | |
| 13 | 14 | } from "@t3tools/contracts"; |
| 14 | 15 | import { |
| 15 | 16 | applyThreadDetailEvent, |
| ... | ... | @@ -31,6 +32,7 @@ import { |
| 31 | 32 | shouldPreserveAssistantLineBreaks, |
| 32 | 33 | type MessagesTimelineRow, |
| 33 | 34 | type MessagesTimelineRowsProjection, |
| 35 | WORKTREE_SETUP_ROW_ID, | |
| 34 | 36 | workEntryDisplayLabel, |
| 35 | 37 | } from "./MessagesTimeline.logic"; |
| 36 | 38 | import { |
| ... | ... | @@ -1090,6 +1092,82 @@ describe("resolveAssistantMessageCopyState", () => { |
| 1090 | 1092 | }); |
| 1091 | 1093 | |
| 1092 | 1094 | describe("deriveMessagesTimelineRows", () => { |
| 1095 | it("shows the worktree setup card instead of the working placeholder", () => { | |
| 1096 | const snapshot: WorktreeSetupSnapshot = { | |
| 1097 | threadId: ThreadId.make("thread-setup"), | |
| 1098 | phase: "running", | |
| 1099 | startedAt: "2026-01-01T00:00:00Z", | |
| 1100 | endedAt: null, | |
| 1101 | branch: "feature", | |
| 1102 | baseRef: "main", | |
| 1103 | worktreePath: null, | |
| 1104 | setupScript: null, | |
| 1105 | stages: [], | |
| 1106 | error: null, | |
| 1107 | sequence: 3, | |
| 1108 | }; | |
| 1109 | const userEntry = { | |
| 1110 | id: "user-entry", | |
| 1111 | kind: "message", | |
| 1112 | createdAt: "2026-01-01T00:00:00Z", | |
| 1113 | message: { | |
| 1114 | id: "user-1" as never, | |
| 1115 | role: "user", | |
| 1116 | text: "Build it", | |
| 1117 | turnId: null, | |
| 1118 | createdAt: "2026-01-01T00:00:00Z", | |
| 1119 | updatedAt: "2026-01-01T00:00:00Z", | |
| 1120 | streaming: false, | |
| 1121 | }, | |
| 1122 | } as const; | |
| 1123 | const assistantEntry = { | |
| 1124 | id: "assistant-entry", | |
| 1125 | kind: "message", | |
| 1126 | createdAt: "2026-01-01T00:00:30Z", | |
| 1127 | message: { | |
| 1128 | id: "assistant-1" as never, | |
| 1129 | role: "assistant", | |
| 1130 | text: "On it", | |
| 1131 | turnId: "turn-1" as never, | |
| 1132 | createdAt: "2026-01-01T00:00:30Z", | |
| 1133 | updatedAt: "2026-01-01T00:00:30Z", | |
| 1134 | streaming: true, | |
| 1135 | }, | |
| 1136 | } as const; | |
| 1137 | const withoutMessages = deriveMessagesTimelineRows({ | |
| 1138 | timelineEntries: [], | |
| 1139 | isWorking: true, | |
| 1140 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | |
| 1141 | turnDiffSummaries: [], | |
| 1142 | supportsConversationRollback: false, | |
| 1143 | worktreeSetup: snapshot, | |
| 1144 | }); | |
| 1145 | expect(withoutMessages).toEqual([ | |
| 1146 | { | |
| 1147 | kind: "worktree-setup", | |
| 1148 | id: WORKTREE_SETUP_ROW_ID, | |
| 1149 | createdAt: "2026-01-01T00:00:00Z", | |
| 1150 | snapshot, | |
| 1151 | }, | |
| 1152 | ]); | |
| 1153 | ||
| 1154 | // Once the agent has replied the finished card stays under the send. | |
| 1155 | const withMessages = deriveMessagesTimelineRows({ | |
| 1156 | timelineEntries: [userEntry, assistantEntry], | |
| 1157 | isWorking: true, | |
| 1158 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | |
| 1159 | turnDiffSummaries: [], | |
| 1160 | supportsConversationRollback: false, | |
| 1161 | worktreeSetup: { ...snapshot, phase: "done" }, | |
| 1162 | }); | |
| 1163 | expect(withMessages.map((row) => row.kind)).toEqual([ | |
| 1164 | "message", | |
| 1165 | "worktree-setup", | |
| 1166 | "working", | |
| 1167 | "message", | |
| 1168 | ]); | |
| 1169 | }); | |
| 1170 | ||
| 1093 | 1171 | it("keeps context compaction visible outside folded work", () => { |
| 1094 | 1172 | const rows = deriveMessagesTimelineRows({ |
| 1095 | 1173 | timelineEntries: [ |
apps/web/src/components/chat/MessagesTimeline.logic.ts+42-1| ... | ... | @@ -28,7 +28,12 @@ import { |
| 28 | 28 | type WorkLogEntry, |
| 29 | 29 | } from "../../session-logic"; |
| 30 | 30 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; |
| 31 | import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; | |
| 31 | import { | |
| 32 | type MessageId, | |
| 33 | type OrchestrationLatestTurn, | |
| 34 | type TurnId, | |
| 35 | type WorktreeSetupSnapshot, | |
| 36 | } from "@t3tools/contracts"; | |
| 32 | 37 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 33 | 38 | |
| 34 | 39 | const TIMELINE_MINIMAP_ITEM_SPACING = 8; |
| ... | ... | @@ -389,6 +394,12 @@ export type MessagesTimelineRow = |
| 389 | 394 | kind: "thinking"; |
| 390 | 395 | id: string; |
| 391 | 396 | createdAt: string | null; |
| 397 | } | |
| 398 | | { | |
| 399 | kind: "worktree-setup"; | |
| 400 | id: string; | |
| 401 | createdAt: string | null; | |
| 402 | snapshot: WorktreeSetupSnapshot; | |
| 392 | 403 | }; |
| 393 | 404 | |
| 394 | 405 | export interface StableMessagesTimelineRowsState { |
| ... | ... | @@ -857,6 +868,8 @@ export function deriveMessagesTimelineRows(input: { |
| 857 | 868 | supportsConversationRollback: boolean; |
| 858 | 869 | /** Task ids of subagents still working, used by the active tool indicator. */ |
| 859 | 870 | liveAgentTaskIds?: ReadonlySet<string> | undefined; |
| 871 | /** Live bootstrap progress. Renders a stage card under the first user message. */ | |
| 872 | worktreeSetup?: WorktreeSetupSnapshot | null; | |
| 860 | 873 | }): MessagesTimelineRow[] { |
| 861 | 874 | const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>(); |
| 862 | 875 | for (const summary of input.turnDiffSummaries) { |
| ... | ... | @@ -1247,6 +1260,30 @@ export function deriveMessagesTimelineRows(input: { |
| 1247 | 1260 | }); |
| 1248 | 1261 | } |
| 1249 | 1262 | |
| 1263 | // The setup card takes the place of the working and thinking placeholders | |
| 1264 | // while a worktree is being prepared. It stays after the setup settles so a | |
| 1265 | // failure and its actions remain visible until the thread state moves on. | |
| 1266 | if (input.worktreeSetup) { | |
| 1267 | const setupRow = { | |
| 1268 | kind: "worktree-setup", | |
| 1269 | id: WORKTREE_SETUP_ROW_ID, | |
| 1270 | createdAt: input.worktreeSetup.startedAt, | |
| 1271 | snapshot: input.worktreeSetup, | |
| 1272 | } as const; | |
| 1273 | // Sit directly under the first user message: a finished snapshot can | |
| 1274 | // outlive the first assistant reply, and it belongs to the send, not the | |
| 1275 | // end of the thread. | |
| 1276 | const firstUserRowIndex = nextRows.findIndex( | |
| 1277 | (row) => row.kind === "message" && row.message.role === "user", | |
| 1278 | ); | |
| 1279 | if (firstUserRowIndex >= 0) { | |
| 1280 | nextRows.splice(firstUserRowIndex + 1, 0, setupRow); | |
| 1281 | } else { | |
| 1282 | nextRows.push(setupRow); | |
| 1283 | } | |
| 1284 | return attachTrailingToolGroupsToAssistant(nextRows); | |
| 1285 | } | |
| 1286 | ||
| 1250 | 1287 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 1251 | 1288 | appendWorkingRow(); |
| 1252 | 1289 | } |
| ... | ... | @@ -1261,6 +1298,8 @@ export function deriveMessagesTimelineRows(input: { |
| 1261 | 1298 | return attachTrailingToolGroupsToAssistant(nextRows); |
| 1262 | 1299 | } |
| 1263 | 1300 | |
| 1301 | export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; | |
| 1302 | ||
| 1264 | 1303 | type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0]; |
| 1265 | 1304 | |
| 1266 | 1305 | export interface MessagesTimelineRowsProjection { |
| ... | ... | @@ -1363,6 +1402,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1363 | 1402 | case "working": |
| 1364 | 1403 | case "thinking": |
| 1365 | 1404 | return a.createdAt === (b as typeof a).createdAt; |
| 1405 | case "worktree-setup": | |
| 1406 | return a.snapshot === (b as typeof a).snapshot; | |
| 1366 | 1407 | |
| 1367 | 1408 | case "assistant-meta": { |
| 1368 | 1409 | const bm = b as typeof a; |
apps/web/src/components/chat/MessagesTimeline.tsx+47-1| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | type ServerProviderSkill, |
| 20 | 20 | type ToolActivityIcon, |
| 21 | 21 | type TurnId, |
| 22 | type WorktreeSetupSnapshot, | |
| 22 | 23 | } from "@t3tools/contracts"; |
| 23 | 24 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 24 | 25 | import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; |
| ... | ... | @@ -188,6 +189,7 @@ import { |
| 188 | 189 | } from "./MessagesTimeline.logic"; |
| 189 | 190 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| 190 | 191 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 192 | import { WorktreeSetupCard } from "./WorktreeSetupCard"; | |
| 191 | 193 | import { |
| 192 | 194 | ContextChipPopover as UserMessageContextPopover, |
| 193 | 195 | ContextChipShell, |
| ... | ... | @@ -275,6 +277,9 @@ interface TimelineRowSharedState { |
| 275 | 277 | agentPanelModel: AgentPanelModel; |
| 276 | 278 | expandedSpawnEntryIds: ReadonlySet<string>; |
| 277 | 279 | onOpenAgents: () => void; |
| 280 | onCancelWorktreeSetup: (() => void) | null; | |
| 281 | onWorktreeSetupWorkLocally: (() => void) | null; | |
| 282 | onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; | |
| 278 | 283 | } |
| 279 | 284 | |
| 280 | 285 | interface TimelineRowActivityState { |
| ... | ... | @@ -374,6 +379,11 @@ interface MessagesTimelineProps { |
| 374 | 379 | isPreparingWorktree?: boolean; |
| 375 | 380 | isCompacting?: boolean; |
| 376 | 381 | activeTurnStartedAt: string | null; |
| 382 | /** Live bootstrap progress for this thread, or null when none is tracked. */ | |
| 383 | worktreeSetup?: WorktreeSetupSnapshot | null; | |
| 384 | onCancelWorktreeSetup?: () => void; | |
| 385 | onWorktreeSetupWorkLocally?: () => void; | |
| 386 | onOpenWorktreeSetupTerminal?: (terminalId: string) => void; | |
| 377 | 387 | listRef: React.RefObject<LegendListRef | null>; |
| 378 | 388 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 379 | 389 | latestTurn: TimelineLatestTurn | null; |
| ... | ... | @@ -433,6 +443,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 433 | 443 | citationHistoryLoading = false, |
| 434 | 444 | onCiteAssistantText, |
| 435 | 445 | isWorking, |
| 446 | worktreeSetup = null, | |
| 447 | onCancelWorktreeSetup, | |
| 448 | onWorktreeSetupWorkLocally, | |
| 449 | onOpenWorktreeSetupTerminal, | |
| 436 | 450 | isPreparingWorktree = false, |
| 437 | 451 | isCompacting = false, |
| 438 | 452 | activeTurnStartedAt, |
| ... | ... | @@ -692,6 +706,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 692 | 706 | turnDiffSummaries, |
| 693 | 707 | supportsConversationRollback, |
| 694 | 708 | liveAgentTaskIds, |
| 709 | worktreeSetup, | |
| 695 | 710 | }, |
| 696 | 711 | previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot |
| 697 | 712 | ? previous.projection |
| ... | ... | @@ -713,6 +728,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 713 | 728 | turnDiffSummaries, |
| 714 | 729 | supportsConversationRollback, |
| 715 | 730 | liveAgentTaskIds, |
| 731 | worktreeSetup, | |
| 716 | 732 | ]); |
| 717 | 733 | const rows = useStableRows(rawRows, listIdentityKey); |
| 718 | 734 | const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); |
| ... | ... | @@ -905,6 +921,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 905 | 921 | agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL, |
| 906 | 922 | expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, |
| 907 | 923 | onOpenAgents, |
| 924 | onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, | |
| 925 | onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, | |
| 926 | onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, | |
| 908 | 927 | }), |
| 909 | 928 | [ |
| 910 | 929 | readyCitationRequest, |
| ... | ... | @@ -932,6 +951,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 932 | 951 | agentPanelModel, |
| 933 | 952 | paintedExpandedSpawnEntryIds, |
| 934 | 953 | onOpenAgents, |
| 954 | onCancelWorktreeSetup, | |
| 955 | onWorktreeSetupWorkLocally, | |
| 956 | onOpenWorktreeSetupTerminal, | |
| 935 | 957 | ], |
| 936 | 958 | ); |
| 937 | 959 | const activityState = useMemo<TimelineRowActivityState>( |
| ... | ... | @@ -1386,7 +1408,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1386 | 1408 | row.kind === "work" || |
| 1387 | 1409 | row.kind === "work-live" || |
| 1388 | 1410 | row.kind === "work-toggle" || |
| 1389 | row.kind === "thinking" | |
| 1411 | row.kind === "thinking" || | |
| 1412 | row.kind === "worktree-setup" | |
| 1390 | 1413 | ? "pb-2" |
| 1391 | 1414 | : "pb-4", |
| 1392 | 1415 | (row.kind === "message" && row.message.role === "assistant") || |
| ... | ... | @@ -1421,10 +1444,33 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1421 | 1444 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} |
| 1422 | 1445 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} |
| 1423 | 1446 | {row.kind === "thinking" ? <ThinkingTimelineRow /> : null} |
| 1447 | {row.kind === "worktree-setup" ? <WorktreeSetupTimelineRow row={row} /> : null} | |
| 1424 | 1448 | </div> |
| 1425 | 1449 | ); |
| 1426 | 1450 | }); |
| 1427 | 1451 | |
| 1452 | function WorktreeSetupTimelineRow({ | |
| 1453 | row, | |
| 1454 | }: { | |
| 1455 | row: Extract<TimelineRow, { kind: "worktree-setup" }>; | |
| 1456 | }) { | |
| 1457 | const ctx = use(TimelineRowCtx); | |
| 1458 | const terminalId = row.snapshot.setupScript?.terminalId ?? null; | |
| 1459 | const openTerminal = ctx.onOpenWorktreeSetupTerminal; | |
| 1460 | const onOpenTerminal = useMemo( | |
| 1461 | () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null), | |
| 1462 | [openTerminal, terminalId], | |
| 1463 | ); | |
| 1464 | return ( | |
| 1465 | <WorktreeSetupCard | |
| 1466 | snapshot={row.snapshot} | |
| 1467 | onCancel={ctx.onCancelWorktreeSetup} | |
| 1468 | onWorkLocally={row.snapshot.phase === "running" ? ctx.onWorktreeSetupWorkLocally : null} | |
| 1469 | onOpenTerminal={onOpenTerminal} | |
| 1470 | /> | |
| 1471 | ); | |
| 1472 | } | |
| 1473 | ||
| 1428 | 1474 | function ContextCompactionTimelineRow({ |
| 1429 | 1475 | row, |
| 1430 | 1476 | }: { |
apps/web/src/components/chat/WorktreeSetupCard.tsx created+296| ... | ... | @@ -0,0 +1,296 @@ |
| 1 | import { | |
| 2 | worktreeSetupStageLabel, | |
| 3 | type WorktreeSetupSnapshot, | |
| 4 | type WorktreeSetupStage, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | |
| 7 | import { | |
| 8 | CheckIcon, | |
| 9 | ChevronDownIcon, | |
| 10 | ChevronRightIcon, | |
| 11 | CircleAlertIcon, | |
| 12 | CircleIcon, | |
| 13 | GitBranchIcon, | |
| 14 | LaptopIcon, | |
| 15 | MinusIcon, | |
| 16 | TerminalIcon, | |
| 17 | XIcon, | |
| 18 | } from "lucide-react"; | |
| 19 | import { useEffect, useState } from "react"; | |
| 20 | ||
| 21 | import { Button } from "~/components/ui/button"; | |
| 22 | import { Spinner } from "~/components/ui/spinner"; | |
| 23 | import { cn } from "~/lib/utils"; | |
| 24 | ||
| 25 | interface WorktreeSetupCardProps { | |
| 26 | snapshot: WorktreeSetupSnapshot; | |
| 27 | /** Interrupts the server-side bootstrap. Hidden once the setup has settled. */ | |
| 28 | onCancel: (() => void) | null; | |
| 29 | /** Restarts the same message in the project checkout instead of a worktree. */ | |
| 30 | onWorkLocally: (() => void) | null; | |
| 31 | /** Reveals the setup script terminal tab. Null when no script ran. */ | |
| 32 | onOpenTerminal: (() => void) | null; | |
| 33 | } | |
| 34 | ||
| 35 | function stageElapsedMs(stage: WorktreeSetupStage, nowMs: number): number | null { | |
| 36 | if (!stage.startedAt) return null; | |
| 37 | const start = Date.parse(stage.startedAt); | |
| 38 | const end = stage.endedAt ? Date.parse(stage.endedAt) : nowMs; | |
| 39 | if (!Number.isFinite(start) || !Number.isFinite(end)) return null; | |
| 40 | return Math.max(0, end - start); | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * Ticks once a second while any stage runs so elapsed labels stay live | |
| 45 | * without pushing a React commit through the timeline for every second. | |
| 46 | */ | |
| 47 | function useNowWhile(active: boolean): number { | |
| 48 | const [nowMs, setNowMs] = useState(() => Date.now()); | |
| 49 | useEffect(() => { | |
| 50 | if (!active) return; | |
| 51 | const id = setInterval(() => setNowMs(Date.now()), 1_000); | |
| 52 | return () => clearInterval(id); | |
| 53 | }, [active]); | |
| 54 | return nowMs; | |
| 55 | } | |
| 56 | ||
| 57 | function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) { | |
| 58 | const className = "size-4 shrink-0 stroke-[1.8]"; | |
| 59 | switch (status) { | |
| 60 | case "done": | |
| 61 | return <CheckIcon aria-hidden className={className} />; | |
| 62 | case "running": | |
| 63 | return <Spinner className={className} />; | |
| 64 | case "failed": | |
| 65 | return <XIcon aria-hidden className={className} />; | |
| 66 | case "warning": | |
| 67 | return <CircleAlertIcon aria-hidden className={className} />; | |
| 68 | case "skipped": | |
| 69 | return <MinusIcon aria-hidden className={className} />; | |
| 70 | case "pending": | |
| 71 | return <CircleIcon aria-hidden className={className} />; | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | function stageRowClassName(status: WorktreeSetupStage["status"]): string { | |
| 76 | switch (status) { | |
| 77 | case "running": | |
| 78 | return "text-foreground"; | |
| 79 | case "failed": | |
| 80 | return "text-destructive-foreground"; | |
| 81 | case "warning": | |
| 82 | return "text-warning-foreground"; | |
| 83 | case "pending": | |
| 84 | case "skipped": | |
| 85 | return "text-secondary-label opacity-50"; | |
| 86 | case "done": | |
| 87 | return "text-secondary-label"; | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | function StageRow({ | |
| 92 | stage, | |
| 93 | nowMs, | |
| 94 | scriptName, | |
| 95 | }: { | |
| 96 | stage: WorktreeSetupStage; | |
| 97 | nowMs: number; | |
| 98 | scriptName: string | null; | |
| 99 | }) { | |
| 100 | const elapsed = stageElapsedMs(stage, nowMs); | |
| 101 | const label = | |
| 102 | stage.id === "setup-script" && scriptName ? scriptName : worktreeSetupStageLabel(stage.id); | |
| 103 | const showBar = stage.id === "checkout" && stage.status === "running" && stage.percent !== null; | |
| 104 | const trailing = | |
| 105 | stage.status === "pending" | |
| 106 | ? null | |
| 107 | : stage.status === "skipped" | |
| 108 | ? (stage.detail ?? "skipped") | |
| 109 | : stage.detail; | |
| 110 | ||
| 111 | return ( | |
| 112 | <div | |
| 113 | className={cn( | |
| 114 | "flex min-h-6 items-center gap-1.5 px-0.5 py-0.5 text-sm leading-relaxed", | |
| 115 | stageRowClassName(stage.status), | |
| 116 | )} | |
| 117 | data-worktree-setup-stage={stage.id} | |
| 118 | data-worktree-setup-status={stage.status} | |
| 119 | > | |
| 120 | <span className="flex size-6 shrink-0 items-center justify-center"> | |
| 121 | <StageIcon status={stage.status} /> | |
| 122 | </span> | |
| 123 | <span className="min-w-0 flex-1 truncate">{label}</span> | |
| 124 | <span className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground tabular-nums"> | |
| 125 | {showBar ? ( | |
| 126 | <> | |
| 127 | <span className="h-1 w-18 overflow-hidden rounded-full bg-input"> | |
| 128 | <span | |
| 129 | className="block h-full rounded-full bg-primary" | |
| 130 | style={{ width: `${stage.percent ?? 0}%` }} | |
| 131 | /> | |
| 132 | </span> | |
| 133 | <span className="text-foreground">{stage.percent}%</span> | |
| 134 | </> | |
| 135 | ) : null} | |
| 136 | {!showBar && trailing ? <span className="truncate">{trailing}</span> : null} | |
| 137 | {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? ( | |
| 138 | <span>{formatDuration(elapsed)}</span> | |
| 139 | ) : null} | |
| 140 | </span> | |
| 141 | </div> | |
| 142 | ); | |
| 143 | } | |
| 144 | ||
| 145 | function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: boolean }) { | |
| 146 | if (lines.length === 0) return null; | |
| 147 | return ( | |
| 148 | <pre | |
| 149 | className={cn( | |
| 150 | "mb-1 ml-8 rounded-md border px-2.5 py-1.5 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all select-text", | |
| 151 | failed | |
| 152 | ? "border-destructive/20 bg-error-surface text-destructive-foreground" | |
| 153 | : "border-border bg-code text-muted-foreground", | |
| 154 | )} | |
| 155 | > | |
| 156 | {lines.join("\n")} | |
| 157 | </pre> | |
| 158 | ); | |
| 159 | } | |
| 160 | ||
| 161 | function headerLabel(snapshot: WorktreeSetupSnapshot): string { | |
| 162 | switch (snapshot.phase) { | |
| 163 | case "running": | |
| 164 | return "Creating worktree"; | |
| 165 | case "done": | |
| 166 | return snapshot.stages.some((stage) => stage.status === "failed") | |
| 167 | ? "Worktree ready, setup script failed" | |
| 168 | : "Worktree ready"; | |
| 169 | case "failed": | |
| 170 | return "Worktree setup failed"; | |
| 171 | case "cancelled": | |
| 172 | return "Worktree setup cancelled"; | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | export function WorktreeSetupCard({ | |
| 177 | snapshot, | |
| 178 | onCancel, | |
| 179 | onWorkLocally, | |
| 180 | onOpenTerminal, | |
| 181 | }: WorktreeSetupCardProps) { | |
| 182 | const running = snapshot.phase === "running"; | |
| 183 | const nowMs = useNowWhile(running); | |
| 184 | const [detailsOpen, setDetailsOpen] = useState(false); | |
| 185 | const totalElapsed = (() => { | |
| 186 | const start = Date.parse(snapshot.startedAt); | |
| 187 | const end = snapshot.endedAt ? Date.parse(snapshot.endedAt) : nowMs; | |
| 188 | return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null; | |
| 189 | })(); | |
| 190 | const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); | |
| 191 | const failed = snapshot.phase === "failed"; | |
| 192 | const finishedWithFailedStage = | |
| 193 | snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed"); | |
| 194 | const headerClassName = failed | |
| 195 | ? "text-destructive-foreground" | |
| 196 | : finishedWithFailedStage | |
| 197 | ? "text-warning-foreground" | |
| 198 | : snapshot.phase === "cancelled" | |
| 199 | ? "text-muted-foreground" | |
| 200 | : "text-secondary-label"; | |
| 201 | ||
| 202 | return ( | |
| 203 | <section | |
| 204 | aria-label="Worktree setup" | |
| 205 | className="mt-3 rounded-lg border border-border bg-card/60 px-2.5 pt-1.5 pb-2" | |
| 206 | data-worktree-setup-phase={snapshot.phase} | |
| 207 | > | |
| 208 | <div className={cn("flex min-h-6 items-center gap-1.5 px-0.5 text-sm", headerClassName)}> | |
| 209 | <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted"> | |
| 210 | <GitBranchIcon aria-hidden className="size-4 shrink-0 stroke-[1.8]" /> | |
| 211 | </span> | |
| 212 | <span className="min-w-0 flex-1 truncate">{headerLabel(snapshot)}</span> | |
| 213 | {totalElapsed !== null ? ( | |
| 214 | <span className="shrink-0 text-xs text-muted-foreground tabular-nums"> | |
| 215 | {formatDuration(totalElapsed)} | |
| 216 | </span> | |
| 217 | ) : null} | |
| 218 | </div> | |
| 219 | ||
| 220 | {snapshot.stages.map((stage) => ( | |
| 221 | <div key={stage.id}> | |
| 222 | <StageRow stage={stage} nowMs={nowMs} scriptName={snapshot.setupScript?.name ?? null} /> | |
| 223 | {stage.id === "setup-script" && | |
| 224 | (stage.status === "running" || stage.status === "failed") ? ( | |
| 225 | <OutputTail lines={stage.tail} failed={stage.status === "failed"} /> | |
| 226 | ) : null} | |
| 227 | </div> | |
| 228 | ))} | |
| 229 | ||
| 230 | {failed && snapshot.error ? ( | |
| 231 | <p className="mt-1 px-0.5 text-xs text-muted-foreground">{snapshot.error}</p> | |
| 232 | ) : null} | |
| 233 | ||
| 234 | {detailsOpen ? ( | |
| 235 | <dl className="mt-1 mb-1.5 ml-8 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground"> | |
| 236 | {snapshot.branch ? ( | |
| 237 | <> | |
| 238 | <dt className="text-foreground/80">Branch</dt> | |
| 239 | <dd className="truncate font-mono">{snapshot.branch}</dd> | |
| 240 | </> | |
| 241 | ) : null} | |
| 242 | {snapshot.baseRef ? ( | |
| 243 | <> | |
| 244 | <dt className="text-foreground/80">Base</dt> | |
| 245 | <dd className="truncate font-mono">{snapshot.baseRef}</dd> | |
| 246 | </> | |
| 247 | ) : null} | |
| 248 | {snapshot.worktreePath ? ( | |
| 249 | <> | |
| 250 | <dt className="text-foreground/80">Path</dt> | |
| 251 | <dd className="truncate font-mono">{snapshot.worktreePath}</dd> | |
| 252 | </> | |
| 253 | ) : null} | |
| 254 | {snapshot.setupScript ? ( | |
| 255 | <> | |
| 256 | <dt className="text-foreground/80">Setup</dt> | |
| 257 | <dd className="truncate font-mono">{snapshot.setupScript.command}</dd> | |
| 258 | </> | |
| 259 | ) : null} | |
| 260 | </dl> | |
| 261 | ) : null} | |
| 262 | ||
| 263 | <div className="mt-1 flex flex-wrap items-center gap-1.5"> | |
| 264 | <Button | |
| 265 | type="button" | |
| 266 | size="xs" | |
| 267 | variant="ghost-muted" | |
| 268 | aria-expanded={detailsOpen} | |
| 269 | onClick={() => setDetailsOpen((open) => !open)} | |
| 270 | > | |
| 271 | {detailsOpen ? <ChevronDownIcon aria-hidden /> : <ChevronRightIcon aria-hidden />} | |
| 272 | Details | |
| 273 | </Button> | |
| 274 | <span className="flex-1" /> | |
| 275 | {onOpenTerminal && setupStage && setupStage.status !== "pending" ? ( | |
| 276 | <Button type="button" size="xs" variant="outline" onClick={onOpenTerminal}> | |
| 277 | <TerminalIcon aria-hidden /> | |
| 278 | Open terminal | |
| 279 | </Button> | |
| 280 | ) : null} | |
| 281 | {onWorkLocally ? ( | |
| 282 | <Button type="button" size="xs" variant="outline" onClick={onWorkLocally}> | |
| 283 | <LaptopIcon aria-hidden /> | |
| 284 | Work locally | |
| 285 | </Button> | |
| 286 | ) : null} | |
| 287 | {onCancel && running ? ( | |
| 288 | <Button type="button" size="xs" variant="outline" onClick={onCancel}> | |
| 289 | <XIcon aria-hidden /> | |
| 290 | Cancel | |
| 291 | </Button> | |
| 292 | ) : null} | |
| 293 | </div> | |
| 294 | </section> | |
| 295 | ); | |
| 296 | } |
packages/client-runtime/src/rpc/client.ts+1| ... | ... | @@ -56,6 +56,7 @@ export type EnvironmentSubscriptionRpcTag = |
| 56 | 56 | | typeof WS_METHODS.pullRequestsSubscribeRefreshes |
| 57 | 57 | | typeof WS_METHODS.previewAutomationConnect |
| 58 | 58 | | typeof WS_METHODS.subscribeVcsStatus |
| 59 | | typeof WS_METHODS.subscribeWorktreeSetup | |
| 59 | 60 | | typeof WS_METHODS.terminalAttach; |
| 60 | 61 | |
| 61 | 62 | export type EnvironmentStreamCommandRpcTag = |
packages/client-runtime/src/state/vcs.ts+17-1| ... | ... | @@ -15,7 +15,11 @@ import * as Stream from "effect/Stream"; |
| 15 | 15 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 16 | 16 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 17 | 17 | |
| 18 | import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; | |
| 18 | import { | |
| 19 | createEnvironmentRpcCommand, | |
| 20 | createEnvironmentRpcSubscriptionAtomFamily, | |
| 21 | createEnvironmentSubscriptionAtomFamily, | |
| 22 | } from "./runtime.ts"; | |
| 19 | 23 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 20 | 24 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 21 | 25 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; |
| ... | ... | @@ -311,6 +315,18 @@ export function createVcsEnvironmentAtoms<R, E>( |
| 311 | 315 | concurrency: vcsCommandConcurrency, |
| 312 | 316 | onSettled: invalidateRefs, |
| 313 | 317 | }), |
| 318 | // Live stages of a bootstrap worktree setup. Null until the server begins | |
| 319 | // tracking, then a snapshot per change, then null again after the setup | |
| 320 | // is dropped. Short TTL so a closed thread releases its subscription. | |
| 321 | worktreeSetup: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | |
| 322 | label: "environment-data:vcs:worktree-setup", | |
| 323 | tag: WS_METHODS.subscribeWorktreeSetup, | |
| 324 | idleTtlMs: VCS_STATUS_IDLE_TTL_MS, | |
| 325 | }), | |
| 326 | cancelWorktreeSetup: createEnvironmentRpcCommand(runtime, { | |
| 327 | label: "environment-data:vcs:cancel-worktree-setup", | |
| 328 | tag: WS_METHODS.worktreeSetupCancel, | |
| 329 | }), | |
| 314 | 330 | removeWorktree: createEnvironmentRpcCommand(runtime, { |
| 315 | 331 | label: "environment-data:vcs:remove-worktree", |
| 316 | 332 | tag: WS_METHODS.vcsRemoveWorktree, |
packages/contracts/src/index.ts+1| ... | ... | @@ -42,3 +42,4 @@ export * from "./previewAutomation.ts"; |
| 42 | 42 | export * from "./resourceTelemetry.ts"; |
| 43 | 43 | export * from "./usage.ts"; |
| 44 | 44 | export * from "./rpc.ts"; |
| 45 | export * from "./worktreeSetup.ts"; |
packages/contracts/src/rpc.ts+23| ... | ... | @@ -46,6 +46,12 @@ import { |
| 46 | 46 | AttachmentDeleteInput, |
| 47 | 47 | AttachmentUploadSigningKeyError, |
| 48 | 48 | } from "./assets.ts"; |
| 49 | import { | |
| 50 | WorktreeSetupCancelInput, | |
| 51 | WorktreeSetupCancelResult, | |
| 52 | WorktreeSetupStreamEvent, | |
| 53 | WorktreeSetupSubscribeInput, | |
| 54 | } from "./worktreeSetup.ts"; | |
| 49 | 55 | import { |
| 50 | 56 | GitActionProgressEvent, |
| 51 | 57 | VcsSwitchRefInput, |
| ... | ... | @@ -404,6 +410,8 @@ export const WS_METHODS = { |
| 404 | 410 | |
| 405 | 411 | // Streaming subscriptions |
| 406 | 412 | subscribeVcsStatus: "subscribeVcsStatus", |
| 413 | subscribeWorktreeSetup: "subscribeWorktreeSetup", | |
| 414 | worktreeSetupCancel: "worktreeSetup.cancel", | |
| 407 | 415 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| 408 | 416 | subscribeTerminalMetadata: "subscribeTerminalMetadata", |
| 409 | 417 | subscribePreviewEvents: "subscribePreviewEvents", |
| ... | ... | @@ -940,6 +948,19 @@ const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { |
| 940 | 948 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), |
| 941 | 949 | }); |
| 942 | 950 | |
| 951 | const WsSubscribeWorktreeSetupRpc = Rpc.make(WS_METHODS.subscribeWorktreeSetup, { | |
| 952 | payload: WorktreeSetupSubscribeInput, | |
| 953 | success: WorktreeSetupStreamEvent, | |
| 954 | error: EnvironmentAuthorizationError, | |
| 955 | stream: true, | |
| 956 | }); | |
| 957 | ||
| 958 | const WsWorktreeSetupCancelRpc = Rpc.make(WS_METHODS.worktreeSetupCancel, { | |
| 959 | payload: WorktreeSetupCancelInput, | |
| 960 | success: WorktreeSetupCancelResult, | |
| 961 | error: EnvironmentAuthorizationError, | |
| 962 | }); | |
| 963 | ||
| 943 | 964 | const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { |
| 944 | 965 | payload: GitRunStackedActionInput, |
| 945 | 966 | success: GitActionProgressEvent, |
| ... | ... | @@ -1372,6 +1393,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 1372 | 1393 | WsAttachmentsDeleteRpc, |
| 1373 | 1394 | WsProviderUploadFeedbackRpc, |
| 1374 | 1395 | WsSubscribeVcsStatusRpc, |
| 1396 | WsSubscribeWorktreeSetupRpc, | |
| 1397 | WsWorktreeSetupCancelRpc, | |
| 1375 | 1398 | WsVcsPullRpc, |
| 1376 | 1399 | WsVcsRefreshStatusRpc, |
| 1377 | 1400 | WsGitRunStackedActionRpc, |
packages/contracts/src/worktreeSetup.ts created+114| ... | ... | @@ -0,0 +1,114 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | import { IsoDateTime, NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 4 | ||
| 5 | /** | |
| 6 | * Live progress for a thread whose first turn is creating a worktree. The | |
| 7 | * server keeps this in memory only; a client that reconnects mid-setup gets a | |
| 8 | * fresh snapshot, and a finished setup is dropped once its turn starts. | |
| 9 | */ | |
| 10 | /** Producers clamp free text to these before publishing so encoding never fails. */ | |
| 11 | export const WORKTREE_SETUP_DETAIL_MAX_LENGTH = 200; | |
| 12 | export const WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH = 400; | |
| 13 | export const WORKTREE_SETUP_ERROR_MAX_LENGTH = 1000; | |
| 14 | ||
| 15 | export const WorktreeSetupStageId = Schema.Literals([ | |
| 16 | "fetch", | |
| 17 | "checkout", | |
| 18 | "submodules", | |
| 19 | "setup-script", | |
| 20 | "agent", | |
| 21 | ]); | |
| 22 | export type WorktreeSetupStageId = typeof WorktreeSetupStageId.Type; | |
| 23 | ||
| 24 | export const WorktreeSetupStageStatus = Schema.Literals([ | |
| 25 | "pending", | |
| 26 | "running", | |
| 27 | "done", | |
| 28 | "skipped", | |
| 29 | "warning", | |
| 30 | "failed", | |
| 31 | ]); | |
| 32 | export type WorktreeSetupStageStatus = typeof WorktreeSetupStageStatus.Type; | |
| 33 | ||
| 34 | export const WorktreeSetupStage = Schema.Struct({ | |
| 35 | id: WorktreeSetupStageId, | |
| 36 | status: WorktreeSetupStageStatus, | |
| 37 | startedAt: Schema.NullOr(IsoDateTime), | |
| 38 | endedAt: Schema.NullOr(IsoDateTime), | |
| 39 | /** Only the checkout stage reports a real percentage, parsed from git's `Updating files` lines. */ | |
| 40 | percent: Schema.NullOr(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))), | |
| 41 | /** Short trailing text for the row: a file count, an exit code, a submodule name. */ | |
| 42 | detail: Schema.NullOr(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_DETAIL_MAX_LENGTH))), | |
| 43 | /** Last few output lines from the setup script, ANSI stripped, newest last. */ | |
| 44 | tail: Schema.Array(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH))), | |
| 45 | }); | |
| 46 | export type WorktreeSetupStage = typeof WorktreeSetupStage.Type; | |
| 47 | ||
| 48 | export const WorktreeSetupPhase = Schema.Literals(["running", "done", "failed", "cancelled"]); | |
| 49 | export type WorktreeSetupPhase = typeof WorktreeSetupPhase.Type; | |
| 50 | ||
| 51 | export const WorktreeSetupSnapshot = Schema.Struct({ | |
| 52 | threadId: ThreadId, | |
| 53 | phase: WorktreeSetupPhase, | |
| 54 | startedAt: IsoDateTime, | |
| 55 | endedAt: Schema.NullOr(IsoDateTime), | |
| 56 | branch: Schema.NullOr(TrimmedNonEmptyString), | |
| 57 | baseRef: Schema.NullOr(TrimmedNonEmptyString), | |
| 58 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), | |
| 59 | /** Display name plus command of the setup script from t3.json, when one runs. */ | |
| 60 | setupScript: Schema.NullOr( | |
| 61 | Schema.Struct({ | |
| 62 | name: TrimmedNonEmptyString, | |
| 63 | command: TrimmedNonEmptyString, | |
| 64 | terminalId: TrimmedNonEmptyString, | |
| 65 | }), | |
| 66 | ), | |
| 67 | stages: Schema.Array(WorktreeSetupStage), | |
| 68 | /** Human readable reason when phase is failed. */ | |
| 69 | error: Schema.NullOr(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_ERROR_MAX_LENGTH))), | |
| 70 | sequence: NonNegativeInt, | |
| 71 | }); | |
| 72 | export type WorktreeSetupSnapshot = typeof WorktreeSetupSnapshot.Type; | |
| 73 | ||
| 74 | export const WorktreeSetupSubscribeInput = Schema.Struct({ | |
| 75 | threadId: ThreadId, | |
| 76 | }); | |
| 77 | export type WorktreeSetupSubscribeInput = typeof WorktreeSetupSubscribeInput.Type; | |
| 78 | ||
| 79 | /** Null means no setup is tracked for that thread. Sent first, then after every change. */ | |
| 80 | export const WorktreeSetupStreamEvent = Schema.NullOr(WorktreeSetupSnapshot); | |
| 81 | export type WorktreeSetupStreamEvent = typeof WorktreeSetupStreamEvent.Type; | |
| 82 | ||
| 83 | export const WorktreeSetupCancelInput = Schema.Struct({ | |
| 84 | threadId: ThreadId, | |
| 85 | }); | |
| 86 | export type WorktreeSetupCancelInput = typeof WorktreeSetupCancelInput.Type; | |
| 87 | ||
| 88 | export const WorktreeSetupCancelResult = Schema.Struct({ | |
| 89 | cancelled: Schema.Boolean, | |
| 90 | }); | |
| 91 | export type WorktreeSetupCancelResult = typeof WorktreeSetupCancelResult.Type; | |
| 92 | ||
| 93 | export const WORKTREE_SETUP_STAGE_ORDER: ReadonlyArray<WorktreeSetupStageId> = [ | |
| 94 | "fetch", | |
| 95 | "checkout", | |
| 96 | "submodules", | |
| 97 | "setup-script", | |
| 98 | "agent", | |
| 99 | ]; | |
| 100 | ||
| 101 | export function worktreeSetupStageLabel(id: WorktreeSetupStageId): string { | |
| 102 | switch (id) { | |
| 103 | case "fetch": | |
| 104 | return "Fetch base branch"; | |
| 105 | case "checkout": | |
| 106 | return "Check out files"; | |
| 107 | case "submodules": | |
| 108 | return "Init submodules"; | |
| 109 | case "setup-script": | |
| 110 | return "Run setup script"; | |
| 111 | case "agent": | |
| 112 | return "Start agent"; | |
| 113 | } | |
| 114 | } |