| author | |
| committer | |
| log | 384f35084e8a94985d37c95e17b3da83ed153e57 |
| tree | c443cb75cdd3651317bf29e15848a755bab23361 |
| parent | 1371ce2ccef7342399a6a020e69073334cfa364b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Cursor Agent <cursoragent@cursor.com>18 files changed, 1208 insertions(+), 150 deletions(-)
apps/server/src/git/Layers/GitCore.ts+258-7| ... | ... | @@ -8,7 +8,12 @@ import { |
| 8 | 8 | Layer, |
| 9 | 9 | Option, |
| 10 | 10 | Path, |
| 11 | PlatformError, | |
| 12 | Ref, | |
| 13 | Result, | |
| 11 | 14 | Schema, |
| 15 | Scope, | |
| 16 | Semaphore, | |
| 12 | 17 | Stream, |
| 13 | 18 | } from "effect"; |
| 14 | 19 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| ... | ... | @@ -16,11 +21,14 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 16 | 21 | import { GitCommandError } from "../Errors.ts"; |
| 17 | 22 | import { |
| 18 | 23 | GitCore, |
| 24 | type ExecuteGitProgress, | |
| 25 | type GitCommitOptions, | |
| 19 | 26 | type GitCoreShape, |
| 20 | 27 | type ExecuteGitInput, |
| 21 | 28 | type ExecuteGitResult, |
| 22 | 29 | } from "../Services/GitCore.ts"; |
| 23 | 30 | import { ServerConfig } from "../../config.ts"; |
| 31 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 24 | 32 | |
| 25 | 33 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 26 | 34 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; |
| ... | ... | @@ -29,6 +37,11 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 29 | 37 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| 30 | 38 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; |
| 31 | 39 | |
| 40 | type TraceTailState = { | |
| 41 | processedChars: number; | |
| 42 | remainder: string; | |
| 43 | }; | |
| 44 | ||
| 32 | 45 | class StatusUpstreamRefreshCacheKey extends Data.Class<{ |
| 33 | 46 | cwd: string; |
| 34 | 47 | upstreamRef: string; |
| ... | ... | @@ -40,6 +53,7 @@ interface ExecuteGitOptions { |
| 40 | 53 | timeoutMs?: number | undefined; |
| 41 | 54 | allowNonZeroExit?: boolean | undefined; |
| 42 | 55 | fallbackErrorMessage?: string | undefined; |
| 56 | progress?: ExecuteGitProgress | undefined; | |
| 43 | 57 | } |
| 44 | 58 | |
| 45 | 59 | function parseBranchAb(value: string): { ahead: number; behind: number } { |
| ... | ... | @@ -257,14 +271,201 @@ function toGitCommandError( |
| 257 | 271 | }); |
| 258 | 272 | } |
| 259 | 273 | |
| 274 | interface Trace2Monitor { | |
| 275 | readonly env: NodeJS.ProcessEnv; | |
| 276 | readonly flush: Effect.Effect<void, never>; | |
| 277 | } | |
| 278 | ||
| 279 | function trace2ChildKey(record: Record<string, unknown>): string | null { | |
| 280 | const childId = record.child_id; | |
| 281 | if (typeof childId === "number" || typeof childId === "string") { | |
| 282 | return String(childId); | |
| 283 | } | |
| 284 | const hookName = record.hook_name; | |
| 285 | return typeof hookName === "string" && hookName.trim().length > 0 ? hookName.trim() : null; | |
| 286 | } | |
| 287 | ||
| 288 | const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); | |
| 289 | ||
| 290 | const createTrace2Monitor = Effect.fn(function* ( | |
| 291 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 292 | progress: ExecuteGitProgress | undefined, | |
| 293 | ): Effect.fn.Return< | |
| 294 | Trace2Monitor, | |
| 295 | PlatformError.PlatformError, | |
| 296 | Scope.Scope | FileSystem.FileSystem | Path.Path | |
| 297 | > { | |
| 298 | if (!progress?.onHookStarted && !progress?.onHookFinished) { | |
| 299 | return { | |
| 300 | env: {}, | |
| 301 | flush: Effect.void, | |
| 302 | }; | |
| 303 | } | |
| 304 | ||
| 305 | const fs = yield* FileSystem.FileSystem; | |
| 306 | const path = yield* Path.Path; | |
| 307 | const traceFilePath = yield* fs.makeTempFileScoped({ | |
| 308 | prefix: `t3code-git-trace2-${process.pid}-`, | |
| 309 | suffix: ".json", | |
| 310 | }); | |
| 311 | const hookStartByChildKey = new Map<string, { hookName: string; startedAtMs: number }>(); | |
| 312 | const traceTailState = yield* Ref.make<TraceTailState>({ | |
| 313 | processedChars: 0, | |
| 314 | remainder: "", | |
| 315 | }); | |
| 316 | ||
| 317 | const handleTraceLine = (line: string) => | |
| 318 | Effect.gen(function* () { | |
| 319 | const trimmedLine = line.trim(); | |
| 320 | if (trimmedLine.length === 0) { | |
| 321 | return; | |
| 322 | } | |
| 323 | ||
| 324 | const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); | |
| 325 | if (Result.isFailure(traceRecord)) { | |
| 326 | yield* Effect.logDebug( | |
| 327 | `GitCore.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, | |
| 328 | traceRecord.failure, | |
| 329 | ); | |
| 330 | return; | |
| 331 | } | |
| 332 | ||
| 333 | if (traceRecord.success.child_class !== "hook") { | |
| 334 | return; | |
| 335 | } | |
| 336 | ||
| 337 | const event = traceRecord.success.event; | |
| 338 | const childKey = trace2ChildKey(traceRecord.success); | |
| 339 | if (childKey === null) { | |
| 340 | return; | |
| 341 | } | |
| 342 | const started = hookStartByChildKey.get(childKey); | |
| 343 | const hookNameFromEvent = | |
| 344 | typeof traceRecord.success.hook_name === "string" | |
| 345 | ? traceRecord.success.hook_name.trim() | |
| 346 | : ""; | |
| 347 | const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); | |
| 348 | if (hookName.length === 0) { | |
| 349 | return; | |
| 350 | } | |
| 351 | ||
| 352 | if (event === "child_start") { | |
| 353 | hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() }); | |
| 354 | if (progress.onHookStarted) { | |
| 355 | yield* progress.onHookStarted(hookName); | |
| 356 | } | |
| 357 | return; | |
| 358 | } | |
| 359 | ||
| 360 | if (event === "child_exit") { | |
| 361 | hookStartByChildKey.delete(childKey); | |
| 362 | if (progress.onHookFinished) { | |
| 363 | const code = traceRecord.success.code; | |
| 364 | yield* progress.onHookFinished({ | |
| 365 | hookName: started?.hookName ?? hookName, | |
| 366 | exitCode: typeof code === "number" && Number.isInteger(code) ? code : null, | |
| 367 | durationMs: started ? Math.max(0, Date.now() - started.startedAtMs) : null, | |
| 368 | }); | |
| 369 | } | |
| 370 | } | |
| 371 | }); | |
| 372 | ||
| 373 | const deltaMutex = yield* Semaphore.make(1); | |
| 374 | const readTraceDelta = deltaMutex.withPermit( | |
| 375 | fs.readFileString(traceFilePath).pipe( | |
| 376 | Effect.flatMap((contents) => | |
| 377 | Effect.uninterruptible( | |
| 378 | Ref.modify(traceTailState, ({ processedChars, remainder }) => { | |
| 379 | if (contents.length <= processedChars) { | |
| 380 | return [[], { processedChars, remainder }]; | |
| 381 | } | |
| 382 | ||
| 383 | const appended = contents.slice(processedChars); | |
| 384 | const combined = remainder + appended; | |
| 385 | const lines = combined.split("\n"); | |
| 386 | const nextRemainder = lines.pop() ?? ""; | |
| 387 | ||
| 388 | return [ | |
| 389 | lines.map((line) => line.replace(/\r$/, "")), | |
| 390 | { | |
| 391 | processedChars: contents.length, | |
| 392 | remainder: nextRemainder, | |
| 393 | }, | |
| 394 | ]; | |
| 395 | }).pipe( | |
| 396 | Effect.flatMap((lines) => Effect.forEach(lines, handleTraceLine, { discard: true })), | |
| 397 | ), | |
| 398 | ), | |
| 399 | ), | |
| 400 | Effect.ignore({ log: true }), | |
| 401 | ), | |
| 402 | ); | |
| 403 | const traceFileName = path.basename(traceFilePath); | |
| 404 | yield* Stream.runForEach(fs.watch(traceFilePath), (event) => { | |
| 405 | const eventPath = event.path; | |
| 406 | const isTargetTraceEvent = | |
| 407 | eventPath === traceFilePath || | |
| 408 | eventPath === traceFileName || | |
| 409 | path.basename(eventPath) === traceFileName; | |
| 410 | if (!isTargetTraceEvent) return Effect.void; | |
| 411 | return readTraceDelta; | |
| 412 | }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); | |
| 413 | ||
| 414 | yield* Effect.addFinalizer(() => | |
| 415 | Effect.gen(function* () { | |
| 416 | yield* readTraceDelta; | |
| 417 | const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [ | |
| 418 | remainder.trim(), | |
| 419 | { | |
| 420 | processedChars, | |
| 421 | remainder: "", | |
| 422 | }, | |
| 423 | ]); | |
| 424 | if (finalLine.length > 0) { | |
| 425 | yield* handleTraceLine(finalLine); | |
| 426 | } | |
| 427 | }), | |
| 428 | ); | |
| 429 | ||
| 430 | return { | |
| 431 | env: { | |
| 432 | GIT_TRACE2_EVENT: traceFilePath, | |
| 433 | }, | |
| 434 | flush: readTraceDelta, | |
| 435 | }; | |
| 436 | }); | |
| 437 | ||
| 260 | 438 | const collectOutput = Effect.fn(function* <E>( |
| 261 | 439 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, |
| 262 | 440 | stream: Stream.Stream<Uint8Array, E>, |
| 263 | 441 | maxOutputBytes: number, |
| 442 | onLine: ((line: string) => Effect.Effect<void, never>) | undefined, | |
| 264 | 443 | ): Effect.fn.Return<string, GitCommandError> { |
| 265 | 444 | const decoder = new TextDecoder(); |
| 266 | 445 | let bytes = 0; |
| 267 | 446 | let text = ""; |
| 447 | let lineBuffer = ""; | |
| 448 | ||
| 449 | const emitCompleteLines = (flush: boolean) => | |
| 450 | Effect.gen(function* () { | |
| 451 | let newlineIndex = lineBuffer.indexOf("\n"); | |
| 452 | while (newlineIndex >= 0) { | |
| 453 | const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); | |
| 454 | lineBuffer = lineBuffer.slice(newlineIndex + 1); | |
| 455 | if (line.length > 0 && onLine) { | |
| 456 | yield* onLine(line); | |
| 457 | } | |
| 458 | newlineIndex = lineBuffer.indexOf("\n"); | |
| 459 | } | |
| 460 | ||
| 461 | if (flush) { | |
| 462 | const trailing = lineBuffer.replace(/\r$/, ""); | |
| 463 | lineBuffer = ""; | |
| 464 | if (trailing.length > 0 && onLine) { | |
| 465 | yield* onLine(trailing); | |
| 466 | } | |
| 467 | } | |
| 468 | }); | |
| 268 | 469 | |
| 269 | 470 | yield* Stream.runForEach(stream, (chunk) => |
| 270 | 471 | Effect.gen(function* () { |
| ... | ... | @@ -277,11 +478,17 @@ const collectOutput = Effect.fn(function* <E>( |
| 277 | 478 | detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, |
| 278 | 479 | }); |
| 279 | 480 | } |
| 280 | text += decoder.decode(chunk, { stream: true }); | |
| 481 | const decoded = decoder.decode(chunk, { stream: true }); | |
| 482 | text += decoded; | |
| 483 | lineBuffer += decoded; | |
| 484 | yield* emitCompleteLines(false); | |
| 281 | 485 | }), |
| 282 | 486 | ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); |
| 283 | 487 | |
| 284 | text += decoder.decode(); | |
| 488 | const remainder = decoder.decode(); | |
| 489 | text += remainder; | |
| 490 | lineBuffer += remainder; | |
| 491 | yield* emitCompleteLines(true); | |
| 285 | 492 | return text; |
| 286 | 493 | }); |
| 287 | 494 | |
| ... | ... | @@ -306,19 +513,38 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" |
| 306 | 513 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; |
| 307 | 514 | |
| 308 | 515 | const commandEffect = Effect.gen(function* () { |
| 516 | const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( | |
| 517 | Effect.provideService(Path.Path, path), | |
| 518 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 519 | Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")), | |
| 520 | ); | |
| 309 | 521 | const child = yield* commandSpawner |
| 310 | 522 | .spawn( |
| 311 | 523 | ChildProcess.make("git", commandInput.args, { |
| 312 | 524 | cwd: commandInput.cwd, |
| 313 | ...(input.env ? { env: input.env } : {}), | |
| 525 | env: { | |
| 526 | ...process.env, | |
| 527 | ...input.env, | |
| 528 | ...trace2Monitor.env, | |
| 529 | }, | |
| 314 | 530 | }), |
| 315 | 531 | ) |
| 316 | 532 | .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); |
| 317 | 533 | |
| 318 | 534 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 319 | 535 | [ |
| 320 | collectOutput(commandInput, child.stdout, maxOutputBytes), | |
| 321 | collectOutput(commandInput, child.stderr, maxOutputBytes), | |
| 536 | collectOutput( | |
| 537 | commandInput, | |
| 538 | child.stdout, | |
| 539 | maxOutputBytes, | |
| 540 | input.progress?.onStdoutLine, | |
| 541 | ), | |
| 542 | collectOutput( | |
| 543 | commandInput, | |
| 544 | child.stderr, | |
| 545 | maxOutputBytes, | |
| 546 | input.progress?.onStderrLine, | |
| 547 | ), | |
| 322 | 548 | child.exitCode.pipe( |
| 323 | 549 | Effect.map((value) => Number(value)), |
| 324 | 550 | Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), |
| ... | ... | @@ -326,6 +552,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" |
| 326 | 552 | ], |
| 327 | 553 | { concurrency: "unbounded" }, |
| 328 | 554 | ); |
| 555 | yield* trace2Monitor.flush; | |
| 329 | 556 | |
| 330 | 557 | if (!input.allowNonZeroExit && exitCode !== 0) { |
| 331 | 558 | const trimmedStderr = stderr.trim(); |
| ... | ... | @@ -376,6 +603,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" |
| 376 | 603 | args, |
| 377 | 604 | allowNonZeroExit: true, |
| 378 | 605 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 606 | ...(options.progress ? { progress: options.progress } : {}), | |
| 379 | 607 | }).pipe( |
| 380 | 608 | Effect.flatMap((result) => { |
| 381 | 609 | if (options.allowNonZeroExit || result.code === 0) { |
| ... | ... | @@ -947,14 +1175,37 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" |
| 947 | 1175 | }; |
| 948 | 1176 | }); |
| 949 | 1177 | |
| 950 | const commit: GitCoreShape["commit"] = (cwd, subject, body) => | |
| 1178 | const commit: GitCoreShape["commit"] = (cwd, subject, body, options?: GitCommitOptions) => | |
| 951 | 1179 | Effect.gen(function* () { |
| 952 | 1180 | const args = ["commit", "-m", subject]; |
| 953 | 1181 | const trimmedBody = body.trim(); |
| 954 | 1182 | if (trimmedBody.length > 0) { |
| 955 | 1183 | args.push("-m", trimmedBody); |
| 956 | 1184 | } |
| 957 | yield* runGit("GitCore.commit.commit", cwd, args); | |
| 1185 | const progress = options?.progress | |
| 1186 | ? { | |
| 1187 | ...(options.progress.onOutputLine | |
| 1188 | ? { | |
| 1189 | onStdoutLine: (line: string) => | |
| 1190 | options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? | |
| 1191 | Effect.void, | |
| 1192 | onStderrLine: (line: string) => | |
| 1193 | options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? | |
| 1194 | Effect.void, | |
| 1195 | } | |
| 1196 | : {}), | |
| 1197 | ...(options.progress.onHookStarted | |
| 1198 | ? { onHookStarted: options.progress.onHookStarted } | |
| 1199 | : {}), | |
| 1200 | ...(options.progress.onHookFinished | |
| 1201 | ? { onHookFinished: options.progress.onHookFinished } | |
| 1202 | : {}), | |
| 1203 | } | |
| 1204 | : null; | |
| 1205 | yield* executeGit("GitCore.commit.commit", cwd, args, { | |
| 1206 | ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | |
| 1207 | ...(progress ? { progress } : {}), | |
| 1208 | }).pipe(Effect.asVoid); | |
| 958 | 1209 | const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ |
| 959 | 1210 | "rev-parse", |
| 960 | 1211 | "HEAD", |
apps/server/src/git/Layers/GitManager.test.ts+123-1| ... | ... | @@ -6,6 +6,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | 6 | import { it } from "@effect/vitest"; |
| 7 | 7 | import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; |
| 8 | 8 | import { expect } from "vitest"; |
| 9 | import type { GitActionProgressEvent } from "@t3tools/contracts"; | |
| 9 | 10 | |
| 10 | 11 | import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; |
| 11 | 12 | import { type GitManagerShape } from "../Services/GitManager.ts"; |
| ... | ... | @@ -449,12 +450,20 @@ function runStackedAction( |
| 449 | 450 | input: { |
| 450 | 451 | cwd: string; |
| 451 | 452 | action: "commit" | "commit_push" | "commit_push_pr"; |
| 453 | actionId?: string; | |
| 452 | 454 | commitMessage?: string; |
| 453 | 455 | featureBranch?: boolean; |
| 454 | 456 | filePaths?: readonly string[]; |
| 455 | 457 | }, |
| 458 | options?: Parameters<GitManagerShape["runStackedAction"]>[1], | |
| 456 | 459 | ) { |
| 457 | return manager.runStackedAction(input); | |
| 460 | return manager.runStackedAction( | |
| 461 | { | |
| 462 | ...input, | |
| 463 | actionId: input.actionId ?? "test-action-id", | |
| 464 | }, | |
| 465 | options, | |
| 466 | ); | |
| 458 | 467 | } |
| 459 | 468 | |
| 460 | 469 | function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { |
| ... | ... | @@ -1941,4 +1950,117 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1941 | 1950 | expect(errorMessage).toContain("already checked out in the main repo"); |
| 1942 | 1951 | }), |
| 1943 | 1952 | ); |
| 1953 | ||
| 1954 | it.effect("emits ordered progress events for commit hooks", () => | |
| 1955 | Effect.gen(function* () { | |
| 1956 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 1957 | yield* initRepo(repoDir); | |
| 1958 | fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n"); | |
| 1959 | fs.writeFileSync( | |
| 1960 | path.join(repoDir, ".git", "hooks", "pre-commit"), | |
| 1961 | '#!/bin/sh\necho "hook: start" >&2\nsleep 1\necho "hook: end" >&2\n', | |
| 1962 | { mode: 0o755 }, | |
| 1963 | ); | |
| 1964 | ||
| 1965 | const { manager } = yield* makeManager(); | |
| 1966 | const events: GitActionProgressEvent[] = []; | |
| 1967 | ||
| 1968 | const result = yield* runStackedAction( | |
| 1969 | manager, | |
| 1970 | { | |
| 1971 | cwd: repoDir, | |
| 1972 | action: "commit", | |
| 1973 | }, | |
| 1974 | { | |
| 1975 | actionId: "action-1", | |
| 1976 | progressReporter: { | |
| 1977 | publish: (event) => | |
| 1978 | Effect.sync(() => { | |
| 1979 | events.push(event); | |
| 1980 | }), | |
| 1981 | }, | |
| 1982 | }, | |
| 1983 | ); | |
| 1984 | ||
| 1985 | expect(result.commit.status).toBe("created"); | |
| 1986 | expect(events.map((event) => event.kind)).toContain("action_started"); | |
| 1987 | expect(events).toEqual( | |
| 1988 | expect.arrayContaining([ | |
| 1989 | expect.objectContaining({ | |
| 1990 | kind: "phase_started", | |
| 1991 | phase: "commit", | |
| 1992 | }), | |
| 1993 | expect.objectContaining({ | |
| 1994 | kind: "hook_started", | |
| 1995 | hookName: "pre-commit", | |
| 1996 | }), | |
| 1997 | expect.objectContaining({ | |
| 1998 | kind: "hook_output", | |
| 1999 | text: "hook: start", | |
| 2000 | }), | |
| 2001 | expect.objectContaining({ | |
| 2002 | kind: "hook_output", | |
| 2003 | text: "hook: end", | |
| 2004 | }), | |
| 2005 | expect.objectContaining({ | |
| 2006 | kind: "hook_finished", | |
| 2007 | hookName: "pre-commit", | |
| 2008 | }), | |
| 2009 | expect.objectContaining({ | |
| 2010 | kind: "action_finished", | |
| 2011 | }), | |
| 2012 | ]), | |
| 2013 | ); | |
| 2014 | }), | |
| 2015 | ); | |
| 2016 | ||
| 2017 | it.effect("emits action_failed when a commit hook rejects", () => | |
| 2018 | Effect.gen(function* () { | |
| 2019 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 2020 | yield* initRepo(repoDir); | |
| 2021 | fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n"); | |
| 2022 | fs.writeFileSync( | |
| 2023 | path.join(repoDir, ".git", "hooks", "pre-commit"), | |
| 2024 | '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', | |
| 2025 | { mode: 0o755 }, | |
| 2026 | ); | |
| 2027 | ||
| 2028 | const { manager } = yield* makeManager(); | |
| 2029 | const events: GitActionProgressEvent[] = []; | |
| 2030 | ||
| 2031 | const errorMessage = yield* runStackedAction( | |
| 2032 | manager, | |
| 2033 | { | |
| 2034 | cwd: repoDir, | |
| 2035 | action: "commit", | |
| 2036 | }, | |
| 2037 | { | |
| 2038 | actionId: "action-2", | |
| 2039 | progressReporter: { | |
| 2040 | publish: (event) => | |
| 2041 | Effect.sync(() => { | |
| 2042 | events.push(event); | |
| 2043 | }), | |
| 2044 | }, | |
| 2045 | }, | |
| 2046 | ).pipe( | |
| 2047 | Effect.flip, | |
| 2048 | Effect.map((error) => error.message), | |
| 2049 | ); | |
| 2050 | ||
| 2051 | expect(errorMessage).toContain("hook: fail"); | |
| 2052 | expect(events).toEqual( | |
| 2053 | expect.arrayContaining([ | |
| 2054 | expect.objectContaining({ | |
| 2055 | kind: "hook_started", | |
| 2056 | hookName: "pre-commit", | |
| 2057 | }), | |
| 2058 | expect.objectContaining({ | |
| 2059 | kind: "action_failed", | |
| 2060 | phase: "commit", | |
| 2061 | }), | |
| 2062 | ]), | |
| 2063 | ); | |
| 2064 | }), | |
| 2065 | ); | |
| 1944 | 2066 | }); |
apps/server/src/git/Layers/GitManager.ts+254-56| ... | ... | @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; |
| 2 | 2 | import { realpathSync } from "node:fs"; |
| 3 | 3 | |
| 4 | 4 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 5 | import type { GitActionProgressEvent, GitActionProgressPhase } from "@t3tools/contracts"; | |
| 5 | 6 | import { |
| 6 | 7 | resolveAutoFeatureBranchName, |
| 7 | 8 | sanitizeBranchFragment, |
| ... | ... | @@ -9,11 +10,21 @@ import { |
| 9 | 10 | } from "@t3tools/shared/git"; |
| 10 | 11 | |
| 11 | 12 | import { GitManagerError } from "../Errors.ts"; |
| 12 | import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; | |
| 13 | import { | |
| 14 | GitManager, | |
| 15 | type GitActionProgressReporter, | |
| 16 | type GitManagerShape, | |
| 17 | type GitRunStackedActionOptions, | |
| 18 | } from "../Services/GitManager.ts"; | |
| 13 | 19 | import { GitCore } from "../Services/GitCore.ts"; |
| 14 | 20 | import { GitHubCli } from "../Services/GitHubCli.ts"; |
| 15 | 21 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 16 | 22 | |
| 23 | const COMMIT_TIMEOUT_MS = 10 * 60_000; | |
| 24 | const MAX_PROGRESS_TEXT_LENGTH = 500; | |
| 25 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; | |
| 26 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; | |
| 27 | ||
| 17 | 28 | interface OpenPrInfo { |
| 18 | 29 | number: number; |
| 19 | 30 | title: string; |
| ... | ... | @@ -200,6 +211,17 @@ function sanitizeCommitMessage(generated: { |
| 200 | 211 | }; |
| 201 | 212 | } |
| 202 | 213 | |
| 214 | function sanitizeProgressText(value: string): string | null { | |
| 215 | const trimmed = value.trim(); | |
| 216 | if (trimmed.length === 0) { | |
| 217 | return null; | |
| 218 | } | |
| 219 | if (trimmed.length <= MAX_PROGRESS_TEXT_LENGTH) { | |
| 220 | return trimmed; | |
| 221 | } | |
| 222 | return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd(); | |
| 223 | } | |
| 224 | ||
| 203 | 225 | interface CommitAndBranchSuggestion { |
| 204 | 226 | subject: string; |
| 205 | 227 | body: string; |
| ... | ... | @@ -337,6 +359,29 @@ export const makeGitManager = Effect.gen(function* () { |
| 337 | 359 | const gitHubCli = yield* GitHubCli; |
| 338 | 360 | const textGeneration = yield* TextGeneration; |
| 339 | 361 | |
| 362 | const createProgressEmitter = ( | |
| 363 | input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" }, | |
| 364 | options?: GitRunStackedActionOptions, | |
| 365 | ) => { | |
| 366 | const actionId = options?.actionId ?? randomUUID(); | |
| 367 | const reporter = options?.progressReporter; | |
| 368 | ||
| 369 | const emit = (event: GitActionProgressPayload) => | |
| 370 | reporter | |
| 371 | ? reporter.publish({ | |
| 372 | actionId, | |
| 373 | cwd: input.cwd, | |
| 374 | action: input.action, | |
| 375 | ...event, | |
| 376 | } as GitActionProgressEvent) | |
| 377 | : Effect.void; | |
| 378 | ||
| 379 | return { | |
| 380 | actionId, | |
| 381 | emit, | |
| 382 | }; | |
| 383 | }; | |
| 384 | ||
| 340 | 385 | const configurePullRequestHeadUpstream = ( |
| 341 | 386 | cwd: string, |
| 342 | 387 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, |
| ... | ... | @@ -680,27 +725,111 @@ export const makeGitManager = Effect.gen(function* () { |
| 680 | 725 | |
| 681 | 726 | const runCommitStep = ( |
| 682 | 727 | cwd: string, |
| 728 | action: "commit" | "commit_push" | "commit_push_pr", | |
| 683 | 729 | branch: string | null, |
| 684 | 730 | commitMessage?: string, |
| 685 | 731 | preResolvedSuggestion?: CommitAndBranchSuggestion, |
| 686 | 732 | filePaths?: readonly string[], |
| 687 | 733 | model?: string, |
| 734 | progressReporter?: GitActionProgressReporter, | |
| 735 | actionId?: string, | |
| 688 | 736 | ) => |
| 689 | 737 | Effect.gen(function* () { |
| 690 | const suggestion = | |
| 691 | preResolvedSuggestion ?? | |
| 692 | (yield* resolveCommitAndBranchSuggestion({ | |
| 738 | const emit = (event: GitActionProgressPayload) => | |
| 739 | progressReporter && actionId | |
| 740 | ? progressReporter.publish({ | |
| 741 | actionId, | |
| 742 | cwd, | |
| 743 | action, | |
| 744 | ...event, | |
| 745 | } as GitActionProgressEvent) | |
| 746 | : Effect.void; | |
| 747 | ||
| 748 | let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion; | |
| 749 | if (!suggestion) { | |
| 750 | const needsGeneration = !commitMessage?.trim(); | |
| 751 | if (needsGeneration) { | |
| 752 | yield* emit({ | |
| 753 | kind: "phase_started", | |
| 754 | phase: "commit", | |
| 755 | label: "Generating commit message...", | |
| 756 | }); | |
| 757 | } | |
| 758 | suggestion = yield* resolveCommitAndBranchSuggestion({ | |
| 693 | 759 | cwd, |
| 694 | 760 | branch, |
| 695 | 761 | ...(commitMessage ? { commitMessage } : {}), |
| 696 | 762 | ...(filePaths ? { filePaths } : {}), |
| 697 | 763 | ...(model ? { model } : {}), |
| 698 | })); | |
| 764 | }); | |
| 765 | } | |
| 699 | 766 | if (!suggestion) { |
| 700 | 767 | return { status: "skipped_no_changes" as const }; |
| 701 | 768 | } |
| 702 | 769 | |
| 703 | const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body); | |
| 770 | yield* emit({ | |
| 771 | kind: "phase_started", | |
| 772 | phase: "commit", | |
| 773 | label: "Committing...", | |
| 774 | }); | |
| 775 | ||
| 776 | let currentHookName: string | null = null; | |
| 777 | const commitProgress = | |
| 778 | progressReporter && actionId | |
| 779 | ? { | |
| 780 | onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { | |
| 781 | const sanitized = sanitizeProgressText(text); | |
| 782 | if (!sanitized) { | |
| 783 | return Effect.void; | |
| 784 | } | |
| 785 | return emit({ | |
| 786 | kind: "hook_output", | |
| 787 | hookName: currentHookName, | |
| 788 | stream, | |
| 789 | text: sanitized, | |
| 790 | }); | |
| 791 | }, | |
| 792 | onHookStarted: (hookName: string) => { | |
| 793 | currentHookName = hookName; | |
| 794 | return emit({ | |
| 795 | kind: "hook_started", | |
| 796 | hookName, | |
| 797 | }); | |
| 798 | }, | |
| 799 | onHookFinished: ({ | |
| 800 | hookName, | |
| 801 | exitCode, | |
| 802 | durationMs, | |
| 803 | }: { | |
| 804 | hookName: string; | |
| 805 | exitCode: number | null; | |
| 806 | durationMs: number | null; | |
| 807 | }) => { | |
| 808 | if (currentHookName === hookName) { | |
| 809 | currentHookName = null; | |
| 810 | } | |
| 811 | return emit({ | |
| 812 | kind: "hook_finished", | |
| 813 | hookName, | |
| 814 | exitCode, | |
| 815 | durationMs, | |
| 816 | }); | |
| 817 | }, | |
| 818 | } | |
| 819 | : null; | |
| 820 | const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { | |
| 821 | timeoutMs: COMMIT_TIMEOUT_MS, | |
| 822 | ...(commitProgress ? { progress: commitProgress } : {}), | |
| 823 | }); | |
| 824 | if (currentHookName !== null) { | |
| 825 | yield* emit({ | |
| 826 | kind: "hook_finished", | |
| 827 | hookName: currentHookName, | |
| 828 | exitCode: 0, | |
| 829 | durationMs: null, | |
| 830 | }); | |
| 831 | currentHookName = null; | |
| 832 | } | |
| 704 | 833 | return { |
| 705 | 834 | status: "created" as const, |
| 706 | 835 | commitSha, |
| ... | ... | @@ -1010,66 +1139,135 @@ export const makeGitManager = Effect.gen(function* () { |
| 1010 | 1139 | }); |
| 1011 | 1140 | |
| 1012 | 1141 | const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( |
| 1013 | function* (input) { | |
| 1014 | const wantsPush = input.action !== "commit"; | |
| 1015 | const wantsPr = input.action === "commit_push_pr"; | |
| 1142 | function* (input, options) { | |
| 1143 | const progress = createProgressEmitter(input, options); | |
| 1144 | const phases: GitActionProgressPhase[] = [ | |
| 1145 | ...(input.featureBranch ? (["branch"] as const) : []), | |
| 1146 | "commit", | |
| 1147 | ...(input.action !== "commit" ? (["push"] as const) : []), | |
| 1148 | ...(input.action === "commit_push_pr" ? (["pr"] as const) : []), | |
| 1149 | ]; | |
| 1150 | let currentPhase: GitActionProgressPhase | null = null; | |
| 1151 | ||
| 1152 | const runAction = Effect.gen(function* () { | |
| 1153 | yield* progress.emit({ | |
| 1154 | kind: "action_started", | |
| 1155 | phases, | |
| 1156 | }); | |
| 1016 | 1157 | |
| 1017 | const initialStatus = yield* gitCore.statusDetails(input.cwd); | |
| 1018 | if (!input.featureBranch && wantsPush && !initialStatus.branch) { | |
| 1019 | return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); | |
| 1020 | } | |
| 1021 | if (!input.featureBranch && wantsPr && !initialStatus.branch) { | |
| 1022 | return yield* gitManagerError( | |
| 1023 | "runStackedAction", | |
| 1024 | "Cannot create a pull request from detached HEAD.", | |
| 1025 | ); | |
| 1026 | } | |
| 1158 | const wantsPush = input.action !== "commit"; | |
| 1159 | const wantsPr = input.action === "commit_push_pr"; | |
| 1160 | ||
| 1161 | const initialStatus = yield* gitCore.statusDetails(input.cwd); | |
| 1162 | if (!input.featureBranch && wantsPush && !initialStatus.branch) { | |
| 1163 | return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); | |
| 1164 | } | |
| 1165 | if (!input.featureBranch && wantsPr && !initialStatus.branch) { | |
| 1166 | return yield* gitManagerError( | |
| 1167 | "runStackedAction", | |
| 1168 | "Cannot create a pull request from detached HEAD.", | |
| 1169 | ); | |
| 1170 | } | |
| 1171 | ||
| 1172 | let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; | |
| 1173 | let commitMessageForStep = input.commitMessage; | |
| 1174 | let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; | |
| 1175 | ||
| 1176 | if (input.featureBranch) { | |
| 1177 | currentPhase = "branch"; | |
| 1178 | yield* progress.emit({ | |
| 1179 | kind: "phase_started", | |
| 1180 | phase: "branch", | |
| 1181 | label: "Preparing feature branch...", | |
| 1182 | }); | |
| 1183 | const result = yield* runFeatureBranchStep( | |
| 1184 | input.cwd, | |
| 1185 | initialStatus.branch, | |
| 1186 | input.commitMessage, | |
| 1187 | input.filePaths, | |
| 1188 | input.textGenerationModel, | |
| 1189 | ); | |
| 1190 | branchStep = result.branchStep; | |
| 1191 | commitMessageForStep = result.resolvedCommitMessage; | |
| 1192 | preResolvedCommitSuggestion = result.resolvedCommitSuggestion; | |
| 1193 | } else { | |
| 1194 | branchStep = { status: "skipped_not_requested" as const }; | |
| 1195 | } | |
| 1027 | 1196 | |
| 1028 | let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; | |
| 1029 | let commitMessageForStep = input.commitMessage; | |
| 1030 | let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; | |
| 1197 | const currentBranch = branchStep.name ?? initialStatus.branch; | |
| 1031 | 1198 | |
| 1032 | if (input.featureBranch) { | |
| 1033 | const result = yield* runFeatureBranchStep( | |
| 1199 | currentPhase = "commit"; | |
| 1200 | const commit = yield* runCommitStep( | |
| 1034 | 1201 | input.cwd, |
| 1035 | initialStatus.branch, | |
| 1036 | input.commitMessage, | |
| 1202 | input.action, | |
| 1203 | currentBranch, | |
| 1204 | commitMessageForStep, | |
| 1205 | preResolvedCommitSuggestion, | |
| 1037 | 1206 | input.filePaths, |
| 1038 | 1207 | input.textGenerationModel, |
| 1208 | options?.progressReporter, | |
| 1209 | progress.actionId, | |
| 1039 | 1210 | ); |
| 1040 | branchStep = result.branchStep; | |
| 1041 | commitMessageForStep = result.resolvedCommitMessage; | |
| 1042 | preResolvedCommitSuggestion = result.resolvedCommitSuggestion; | |
| 1043 | } else { | |
| 1044 | branchStep = { status: "skipped_not_requested" as const }; | |
| 1045 | } | |
| 1046 | 1211 | |
| 1047 | const currentBranch = branchStep.name ?? initialStatus.branch; | |
| 1212 | const push = wantsPush | |
| 1213 | ? yield* progress | |
| 1214 | .emit({ | |
| 1215 | kind: "phase_started", | |
| 1216 | phase: "push", | |
| 1217 | label: "Pushing...", | |
| 1218 | }) | |
| 1219 | .pipe( | |
| 1220 | Effect.flatMap(() => | |
| 1221 | Effect.gen(function* () { | |
| 1222 | currentPhase = "push"; | |
| 1223 | return yield* gitCore.pushCurrentBranch(input.cwd, currentBranch); | |
| 1224 | }), | |
| 1225 | ), | |
| 1226 | ) | |
| 1227 | : { status: "skipped_not_requested" as const }; | |
| 1228 | ||
| 1229 | const pr = wantsPr | |
| 1230 | ? yield* progress | |
| 1231 | .emit({ | |
| 1232 | kind: "phase_started", | |
| 1233 | phase: "pr", | |
| 1234 | label: "Creating PR...", | |
| 1235 | }) | |
| 1236 | .pipe( | |
| 1237 | Effect.flatMap(() => | |
| 1238 | Effect.gen(function* () { | |
| 1239 | currentPhase = "pr"; | |
| 1240 | return yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel); | |
| 1241 | }), | |
| 1242 | ), | |
| 1243 | ) | |
| 1244 | : { status: "skipped_not_requested" as const }; | |
| 1245 | ||
| 1246 | const result = { | |
| 1247 | action: input.action, | |
| 1248 | branch: branchStep, | |
| 1249 | commit, | |
| 1250 | push, | |
| 1251 | pr, | |
| 1252 | }; | |
| 1253 | yield* progress.emit({ | |
| 1254 | kind: "action_finished", | |
| 1255 | result, | |
| 1256 | }); | |
| 1257 | return result; | |
| 1258 | }); | |
| 1048 | 1259 | |
| 1049 | const commit = yield* runCommitStep( | |
| 1050 | input.cwd, | |
| 1051 | currentBranch, | |
| 1052 | commitMessageForStep, | |
| 1053 | preResolvedCommitSuggestion, | |
| 1054 | input.filePaths, | |
| 1055 | input.textGenerationModel, | |
| 1260 | return yield* runAction.pipe( | |
| 1261 | Effect.catch((error) => | |
| 1262 | progress | |
| 1263 | .emit({ | |
| 1264 | kind: "action_failed", | |
| 1265 | phase: currentPhase, | |
| 1266 | message: error.message, | |
| 1267 | }) | |
| 1268 | .pipe(Effect.flatMap(() => Effect.fail(error))), | |
| 1269 | ), | |
| 1056 | 1270 | ); |
| 1057 | ||
| 1058 | const push = wantsPush | |
| 1059 | ? yield* gitCore.pushCurrentBranch(input.cwd, currentBranch) | |
| 1060 | : { status: "skipped_not_requested" as const }; | |
| 1061 | ||
| 1062 | const pr = wantsPr | |
| 1063 | ? yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel) | |
| 1064 | : { status: "skipped_not_requested" as const }; | |
| 1065 | ||
| 1066 | return { | |
| 1067 | action: input.action, | |
| 1068 | branch: branchStep, | |
| 1069 | commit, | |
| 1070 | push, | |
| 1071 | pr, | |
| 1072 | }; | |
| 1073 | 1271 | }, |
| 1074 | 1272 | ); |
| 1075 | 1273 |
apps/server/src/git/Services/GitCore.ts+31| ... | ... | @@ -32,6 +32,7 @@ export interface ExecuteGitInput { |
| 32 | 32 | readonly allowNonZeroExit?: boolean; |
| 33 | 33 | readonly timeoutMs?: number; |
| 34 | 34 | readonly maxOutputBytes?: number; |
| 35 | readonly progress?: ExecuteGitProgress; | |
| 35 | 36 | } |
| 36 | 37 | |
| 37 | 38 | export interface ExecuteGitResult { |
| ... | ... | @@ -49,6 +50,35 @@ export interface GitPreparedCommitContext { |
| 49 | 50 | stagedPatch: string; |
| 50 | 51 | } |
| 51 | 52 | |
| 53 | export interface ExecuteGitProgress { | |
| 54 | readonly onStdoutLine?: (line: string) => Effect.Effect<void, never>; | |
| 55 | readonly onStderrLine?: (line: string) => Effect.Effect<void, never>; | |
| 56 | readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>; | |
| 57 | readonly onHookFinished?: (input: { | |
| 58 | hookName: string; | |
| 59 | exitCode: number | null; | |
| 60 | durationMs: number | null; | |
| 61 | }) => Effect.Effect<void, never>; | |
| 62 | } | |
| 63 | ||
| 64 | export interface GitCommitProgress { | |
| 65 | readonly onOutputLine?: (input: { | |
| 66 | stream: "stdout" | "stderr"; | |
| 67 | text: string; | |
| 68 | }) => Effect.Effect<void, never>; | |
| 69 | readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>; | |
| 70 | readonly onHookFinished?: (input: { | |
| 71 | hookName: string; | |
| 72 | exitCode: number | null; | |
| 73 | durationMs: number | null; | |
| 74 | }) => Effect.Effect<void, never>; | |
| 75 | } | |
| 76 | ||
| 77 | export interface GitCommitOptions { | |
| 78 | readonly timeoutMs?: number; | |
| 79 | readonly progress?: GitCommitProgress; | |
| 80 | } | |
| 81 | ||
| 52 | 82 | export interface GitPushResult { |
| 53 | 83 | status: "pushed" | "skipped_up_to_date"; |
| 54 | 84 | branch: string; |
| ... | ... | @@ -132,6 +162,7 @@ export interface GitCoreShape { |
| 132 | 162 | cwd: string, |
| 133 | 163 | subject: string, |
| 134 | 164 | body: string, |
| 165 | options?: GitCommitOptions, | |
| 135 | 166 | ) => Effect.Effect<{ commitSha: string }, GitCommandError>; |
| 136 | 167 | |
| 137 | 168 | /** |
apps/server/src/git/Services/GitManager.ts+11| ... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 | * @module GitManager |
| 8 | 8 | */ |
| 9 | 9 | import { |
| 10 | GitActionProgressEvent, | |
| 10 | 11 | GitPreparePullRequestThreadInput, |
| 11 | 12 | GitPreparePullRequestThreadResult, |
| 12 | 13 | GitPullRequestRefInput, |
| ... | ... | @@ -20,6 +21,15 @@ import { ServiceMap } from "effect"; |
| 20 | 21 | import type { Effect } from "effect"; |
| 21 | 22 | import type { GitManagerServiceError } from "../Errors.ts"; |
| 22 | 23 | |
| 24 | export interface GitActionProgressReporter { | |
| 25 | readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>; | |
| 26 | } | |
| 27 | ||
| 28 | export interface GitRunStackedActionOptions { | |
| 29 | readonly actionId?: string; | |
| 30 | readonly progressReporter?: GitActionProgressReporter; | |
| 31 | } | |
| 32 | ||
| 23 | 33 | /** |
| 24 | 34 | * GitManagerShape - Service API for high-level Git workflow actions. |
| 25 | 35 | */ |
| ... | ... | @@ -51,6 +61,7 @@ export interface GitManagerShape { |
| 51 | 61 | */ |
| 52 | 62 | readonly runStackedAction: ( |
| 53 | 63 | input: GitRunStackedActionInput, |
| 64 | options?: GitRunStackedActionOptions, | |
| 54 | 65 | ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>; |
| 55 | 66 | } |
| 56 | 67 |
apps/server/src/wsServer.test.ts+81-2| ... | ... | @@ -1811,15 +1811,94 @@ describe("WebSocket Server", () => { |
| 1811 | 1811 | connections.push(ws); |
| 1812 | 1812 | |
| 1813 | 1813 | const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, { |
| 1814 | actionId: "client-action-1", | |
| 1814 | 1815 | cwd: "/test", |
| 1815 | 1816 | action: "commit_push", |
| 1816 | 1817 | }); |
| 1817 | 1818 | expect(response.result).toBeUndefined(); |
| 1818 | 1819 | expect(response.error?.message).toContain("detached HEAD"); |
| 1819 | expect(runStackedAction).toHaveBeenCalledWith({ | |
| 1820 | expect(runStackedAction).toHaveBeenCalledWith( | |
| 1821 | { | |
| 1822 | actionId: "client-action-1", | |
| 1823 | cwd: "/test", | |
| 1824 | action: "commit_push", | |
| 1825 | }, | |
| 1826 | expect.objectContaining({ | |
| 1827 | actionId: "client-action-1", | |
| 1828 | progressReporter: expect.any(Object), | |
| 1829 | }), | |
| 1830 | ); | |
| 1831 | }); | |
| 1832 | ||
| 1833 | it("publishes git action progress only to the initiating websocket", async () => { | |
| 1834 | const runStackedAction = vi.fn( | |
| 1835 | (_input, options) => | |
| 1836 | options?.progressReporter | |
| 1837 | ?.publish({ | |
| 1838 | actionId: options.actionId ?? "action-1", | |
| 1839 | cwd: "/test", | |
| 1840 | action: "commit", | |
| 1841 | kind: "phase_started", | |
| 1842 | phase: "commit", | |
| 1843 | label: "Committing...", | |
| 1844 | }) | |
| 1845 | .pipe( | |
| 1846 | Effect.flatMap(() => | |
| 1847 | Effect.succeed({ | |
| 1848 | action: "commit" as const, | |
| 1849 | branch: { status: "skipped_not_requested" as const }, | |
| 1850 | commit: { | |
| 1851 | status: "created" as const, | |
| 1852 | commitSha: "abc1234", | |
| 1853 | subject: "Test commit", | |
| 1854 | }, | |
| 1855 | push: { status: "skipped_not_requested" as const }, | |
| 1856 | pr: { status: "skipped_not_requested" as const }, | |
| 1857 | }), | |
| 1858 | ), | |
| 1859 | ) ?? Effect.void, | |
| 1860 | ); | |
| 1861 | const gitManager: GitManagerShape = { | |
| 1862 | status: vi.fn(() => Effect.void as any), | |
| 1863 | resolvePullRequest: vi.fn(() => Effect.void as any), | |
| 1864 | preparePullRequestThread: vi.fn(() => Effect.void as any), | |
| 1865 | runStackedAction, | |
| 1866 | }; | |
| 1867 | ||
| 1868 | server = await createTestServer({ cwd: "/test", gitManager }); | |
| 1869 | const addr = server.address(); | |
| 1870 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1871 | ||
| 1872 | const [initiatingWs] = await connectAndAwaitWelcome(port); | |
| 1873 | const [otherWs] = await connectAndAwaitWelcome(port); | |
| 1874 | connections.push(initiatingWs, otherWs); | |
| 1875 | ||
| 1876 | const responsePromise = sendRequest(initiatingWs, WS_METHODS.gitRunStackedAction, { | |
| 1877 | actionId: "client-action-2", | |
| 1820 | 1878 | cwd: "/test", |
| 1821 | action: "commit_push", | |
| 1879 | action: "commit", | |
| 1880 | }); | |
| 1881 | const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress); | |
| 1882 | ||
| 1883 | expect(progressPush.data).toEqual({ | |
| 1884 | actionId: "client-action-2", | |
| 1885 | cwd: "/test", | |
| 1886 | action: "commit", | |
| 1887 | kind: "phase_started", | |
| 1888 | phase: "commit", | |
| 1889 | label: "Committing...", | |
| 1822 | 1890 | }); |
| 1891 | ||
| 1892 | await expect( | |
| 1893 | waitForPush(otherWs, WS_CHANNELS.gitActionProgress, undefined, 10, 100), | |
| 1894 | ).rejects.toThrow("Timed out waiting for WebSocket message after 100ms"); | |
| 1895 | await expect(responsePromise).resolves.toEqual( | |
| 1896 | expect.objectContaining({ | |
| 1897 | result: expect.objectContaining({ | |
| 1898 | action: "commit", | |
| 1899 | }), | |
| 1900 | }), | |
| 1901 | ); | |
| 1823 | 1902 | }); |
| 1824 | 1903 | |
| 1825 | 1904 | it("rejects websocket connections without a valid auth token", async () => { |
apps/server/src/wsServer.ts+9-3| ... | ... | @@ -704,7 +704,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 704 | 704 | Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]), |
| 705 | 705 | ); |
| 706 | 706 | |
| 707 | const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { | |
| 707 | const routeRequest = Effect.fnUntraced(function* (ws: WebSocket, request: WebSocketRequest) { | |
| 708 | 708 | switch (request.body._tag) { |
| 709 | 709 | case ORCHESTRATION_WS_METHODS.getSnapshot: |
| 710 | 710 | return yield* projectionReadModelQuery.getSnapshot(); |
| ... | ... | @@ -793,7 +793,13 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 793 | 793 | |
| 794 | 794 | case WS_METHODS.gitRunStackedAction: { |
| 795 | 795 | const body = stripRequestTag(request.body); |
| 796 | return yield* gitManager.runStackedAction(body); | |
| 796 | return yield* gitManager.runStackedAction(body, { | |
| 797 | actionId: body.actionId, | |
| 798 | progressReporter: { | |
| 799 | publish: (event) => | |
| 800 | pushBus.publishClient(ws, WS_CHANNELS.gitActionProgress, event).pipe(Effect.asVoid), | |
| 801 | }, | |
| 802 | }); | |
| 797 | 803 | } |
| 798 | 804 | |
| 799 | 805 | case WS_METHODS.gitResolvePullRequest: { |
| ... | ... | @@ -915,7 +921,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 915 | 921 | }); |
| 916 | 922 | } |
| 917 | 923 | |
| 918 | const result = yield* Effect.exit(routeRequest(request.success)); | |
| 924 | const result = yield* Effect.exit(routeRequest(ws, request.success)); | |
| 919 | 925 | if (Exit.isFailure(result)) { |
| 920 | 926 | return yield* sendWsResponse({ |
| 921 | 927 | id: request.success.id, |
apps/web/src/components/GitActionsControl.tsx+176-72| ... | ... | @@ -1,6 +1,11 @@ |
| 1 | import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | GitActionProgressEvent, | |
| 3 | GitStackedAction, | |
| 4 | GitStatusResult, | |
| 5 | ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 8 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | |
| 4 | 9 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| 5 | 10 | import { GitHubIcon } from "./Icons"; |
| 6 | 11 | import { |
| ... | ... | @@ -43,6 +48,7 @@ import { |
| 43 | 48 | gitStatusQueryOptions, |
| 44 | 49 | invalidateGitQueries, |
| 45 | 50 | } from "~/lib/gitReactQuery"; |
| 51 | import { randomUUID } from "~/lib/utils"; | |
| 46 | 52 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| 47 | 53 | import { readNativeApi } from "~/nativeApi"; |
| 48 | 54 | |
| ... | ... | @@ -63,6 +69,50 @@ interface PendingDefaultBranchAction { |
| 63 | 69 | |
| 64 | 70 | type GitActionToastId = ReturnType<typeof toastManager.add>; |
| 65 | 71 | |
| 72 | interface ActiveGitActionProgress { | |
| 73 | toastId: GitActionToastId; | |
| 74 | actionId: string; | |
| 75 | title: string; | |
| 76 | phaseStartedAtMs: number | null; | |
| 77 | hookStartedAtMs: number | null; | |
| 78 | hookName: string | null; | |
| 79 | lastOutputLine: string | null; | |
| 80 | currentPhaseLabel: string | null; | |
| 81 | } | |
| 82 | ||
| 83 | interface RunGitActionWithToastInput { | |
| 84 | action: GitStackedAction; | |
| 85 | commitMessage?: string; | |
| 86 | forcePushOnlyProgress?: boolean; | |
| 87 | onConfirmed?: () => void; | |
| 88 | skipDefaultBranchPrompt?: boolean; | |
| 89 | statusOverride?: GitStatusResult | null; | |
| 90 | featureBranch?: boolean; | |
| 91 | isDefaultBranchOverride?: boolean; | |
| 92 | progressToastId?: GitActionToastId; | |
| 93 | filePaths?: string[]; | |
| 94 | } | |
| 95 | ||
| 96 | function formatElapsedDescription(startedAtMs: number | null): string | undefined { | |
| 97 | if (startedAtMs === null) { | |
| 98 | return undefined; | |
| 99 | } | |
| 100 | const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); | |
| 101 | if (elapsedSeconds < 60) { | |
| 102 | return `Running for ${elapsedSeconds}s`; | |
| 103 | } | |
| 104 | const minutes = Math.floor(elapsedSeconds / 60); | |
| 105 | const seconds = elapsedSeconds % 60; | |
| 106 | return `Running for ${minutes}m ${seconds}s`; | |
| 107 | } | |
| 108 | ||
| 109 | function resolveProgressDescription(progress: ActiveGitActionProgress): string | undefined { | |
| 110 | if (progress.lastOutputLine) { | |
| 111 | return progress.lastOutputLine; | |
| 112 | } | |
| 113 | return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs); | |
| 114 | } | |
| 115 | ||
| 66 | 116 | function getMenuActionDisabledReason({ |
| 67 | 117 | item, |
| 68 | 118 | gitStatus, |
| ... | ... | @@ -167,6 +217,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 167 | 217 | const [isEditingFiles, setIsEditingFiles] = useState(false); |
| 168 | 218 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = |
| 169 | 219 | useState<PendingDefaultBranchAction | null>(null); |
| 220 | const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null); | |
| 221 | ||
| 222 | const updateActiveProgressToast = useCallback(() => { | |
| 223 | const progress = activeGitActionProgressRef.current; | |
| 224 | if (!progress) { | |
| 225 | return; | |
| 226 | } | |
| 227 | toastManager.update(progress.toastId, { | |
| 228 | type: "loading", | |
| 229 | title: progress.title, | |
| 230 | description: resolveProgressDescription(progress), | |
| 231 | timeout: 0, | |
| 232 | data: threadToastData, | |
| 233 | }); | |
| 234 | }, [threadToastData]); | |
| 170 | 235 | |
| 171 | 236 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); |
| 172 | 237 | |
| ... | ... | @@ -232,6 +297,84 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 232 | 297 | }) |
| 233 | 298 | : null; |
| 234 | 299 | |
| 300 | useEffect(() => { | |
| 301 | const api = readNativeApi(); | |
| 302 | if (!api) { | |
| 303 | return; | |
| 304 | } | |
| 305 | ||
| 306 | const applyProgressEvent = (event: GitActionProgressEvent) => { | |
| 307 | const progress = activeGitActionProgressRef.current; | |
| 308 | if (!progress) { | |
| 309 | return; | |
| 310 | } | |
| 311 | if (gitCwd && event.cwd !== gitCwd) { | |
| 312 | return; | |
| 313 | } | |
| 314 | if (progress.actionId !== event.actionId) { | |
| 315 | return; | |
| 316 | } | |
| 317 | ||
| 318 | const now = Date.now(); | |
| 319 | switch (event.kind) { | |
| 320 | case "action_started": | |
| 321 | progress.phaseStartedAtMs = now; | |
| 322 | progress.hookStartedAtMs = null; | |
| 323 | progress.hookName = null; | |
| 324 | progress.lastOutputLine = null; | |
| 325 | break; | |
| 326 | case "phase_started": | |
| 327 | progress.title = event.label; | |
| 328 | progress.currentPhaseLabel = event.label; | |
| 329 | progress.phaseStartedAtMs = now; | |
| 330 | progress.hookStartedAtMs = null; | |
| 331 | progress.hookName = null; | |
| 332 | progress.lastOutputLine = null; | |
| 333 | break; | |
| 334 | case "hook_started": | |
| 335 | progress.title = `Running ${event.hookName}...`; | |
| 336 | progress.hookName = event.hookName; | |
| 337 | progress.hookStartedAtMs = now; | |
| 338 | progress.lastOutputLine = null; | |
| 339 | break; | |
| 340 | case "hook_output": | |
| 341 | progress.lastOutputLine = event.text; | |
| 342 | break; | |
| 343 | case "hook_finished": | |
| 344 | progress.title = progress.currentPhaseLabel ?? "Committing..."; | |
| 345 | progress.hookName = null; | |
| 346 | progress.hookStartedAtMs = null; | |
| 347 | progress.lastOutputLine = null; | |
| 348 | break; | |
| 349 | case "action_finished": | |
| 350 | progress.phaseStartedAtMs = null; | |
| 351 | progress.hookStartedAtMs = null; | |
| 352 | break; | |
| 353 | case "action_failed": | |
| 354 | progress.phaseStartedAtMs = null; | |
| 355 | progress.hookStartedAtMs = null; | |
| 356 | break; | |
| 357 | } | |
| 358 | ||
| 359 | updateActiveProgressToast(); | |
| 360 | }; | |
| 361 | ||
| 362 | return api.git.onActionProgress(applyProgressEvent); | |
| 363 | }, [gitCwd, updateActiveProgressToast]); | |
| 364 | ||
| 365 | useEffect(() => { | |
| 366 | const interval = window.setInterval(() => { | |
| 367 | if (!activeGitActionProgressRef.current) { | |
| 368 | return; | |
| 369 | } | |
| 370 | updateActiveProgressToast(); | |
| 371 | }, 1000); | |
| 372 | ||
| 373 | return () => { | |
| 374 | window.clearInterval(interval); | |
| 375 | }; | |
| 376 | }, [updateActiveProgressToast]); | |
| 377 | ||
| 235 | 378 | const openExistingPr = useCallback(async () => { |
| 236 | 379 | const api = readNativeApi(); |
| 237 | 380 | if (!api) { |
| ... | ... | @@ -259,9 +402,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 259 | 402 | data: threadToastData, |
| 260 | 403 | }); |
| 261 | 404 | }); |
| 262 | }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); | |
| 405 | }, [gitStatusForActions, threadToastData]); | |
| 263 | 406 | |
| 264 | const runGitActionWithToast = useCallback( | |
| 407 | const runGitActionWithToast = useEffectEvent( | |
| 265 | 408 | async ({ |
| 266 | 409 | action, |
| 267 | 410 | commitMessage, |
| ... | ... | @@ -273,18 +416,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 273 | 416 | isDefaultBranchOverride, |
| 274 | 417 | progressToastId, |
| 275 | 418 | filePaths, |
| 276 | }: { | |
| 277 | action: GitStackedAction; | |
| 278 | commitMessage?: string; | |
| 279 | forcePushOnlyProgress?: boolean; | |
| 280 | onConfirmed?: () => void; | |
| 281 | skipDefaultBranchPrompt?: boolean; | |
| 282 | statusOverride?: GitStatusResult | null; | |
| 283 | featureBranch?: boolean; | |
| 284 | isDefaultBranchOverride?: boolean; | |
| 285 | progressToastId?: GitActionToastId; | |
| 286 | filePaths?: string[]; | |
| 287 | }) => { | |
| 419 | }: RunGitActionWithToastInput) => { | |
| 288 | 420 | const actionStatus = statusOverride ?? gitStatusForActions; |
| 289 | 421 | const actionBranch = actionStatus?.branch ?? null; |
| 290 | 422 | const actionIsDefaultBranch = |
| ... | ... | @@ -319,40 +451,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 319 | 451 | forcePushOnly: forcePushOnlyProgress, |
| 320 | 452 | featureBranch, |
| 321 | 453 | }); |
| 454 | const actionId = randomUUID(); | |
| 322 | 455 | const resolvedProgressToastId = |
| 323 | 456 | progressToastId ?? |
| 324 | 457 | toastManager.add({ |
| 325 | 458 | type: "loading", |
| 326 | 459 | title: progressStages[0] ?? "Running git action...", |
| 460 | description: "Waiting for Git...", | |
| 327 | 461 | timeout: 0, |
| 328 | 462 | data: threadToastData, |
| 329 | 463 | }); |
| 330 | 464 | |
| 465 | activeGitActionProgressRef.current = { | |
| 466 | toastId: resolvedProgressToastId, | |
| 467 | actionId, | |
| 468 | title: progressStages[0] ?? "Running git action...", | |
| 469 | phaseStartedAtMs: null, | |
| 470 | hookStartedAtMs: null, | |
| 471 | hookName: null, | |
| 472 | lastOutputLine: null, | |
| 473 | currentPhaseLabel: progressStages[0] ?? "Running git action...", | |
| 474 | }; | |
| 475 | ||
| 331 | 476 | if (progressToastId) { |
| 332 | 477 | toastManager.update(progressToastId, { |
| 333 | 478 | type: "loading", |
| 334 | 479 | title: progressStages[0] ?? "Running git action...", |
| 480 | description: "Waiting for Git...", | |
| 335 | 481 | timeout: 0, |
| 336 | 482 | data: threadToastData, |
| 337 | 483 | }); |
| 338 | 484 | } |
| 339 | 485 | |
| 340 | let stageIndex = 0; | |
| 341 | const stageInterval = setInterval(() => { | |
| 342 | stageIndex = Math.min(stageIndex + 1, progressStages.length - 1); | |
| 343 | toastManager.update(resolvedProgressToastId, { | |
| 344 | title: progressStages[stageIndex] ?? "Running git action...", | |
| 345 | type: "loading", | |
| 346 | timeout: 0, | |
| 347 | data: threadToastData, | |
| 348 | }); | |
| 349 | }, 1100); | |
| 350 | ||
| 351 | const stopProgressUpdates = () => { | |
| 352 | clearInterval(stageInterval); | |
| 353 | }; | |
| 354 | ||
| 355 | 486 | const promise = runImmediateGitActionMutation.mutateAsync({ |
| 487 | actionId, | |
| 356 | 488 | action, |
| 357 | 489 | ...(commitMessage ? { commitMessage } : {}), |
| 358 | 490 | ...(featureBranch ? { featureBranch } : {}), |
| ... | ... | @@ -361,7 +493,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 361 | 493 | |
| 362 | 494 | try { |
| 363 | 495 | const result = await promise; |
| 364 | stopProgressUpdates(); | |
| 496 | activeGitActionProgressRef.current = null; | |
| 365 | 497 | const resultToast = summarizeGitResult(result); |
| 366 | 498 | |
| 367 | 499 | const existingOpenPrUrl = |
| ... | ... | @@ -437,7 +569,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 437 | 569 | : {}), |
| 438 | 570 | }); |
| 439 | 571 | } catch (err) { |
| 440 | stopProgressUpdates(); | |
| 572 | activeGitActionProgressRef.current = null; | |
| 441 | 573 | toastManager.update(resolvedProgressToastId, { |
| 442 | 574 | type: "error", |
| 443 | 575 | title: "Action failed", |
| ... | ... | @@ -446,14 +578,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 446 | 578 | }); |
| 447 | 579 | } |
| 448 | 580 | }, |
| 449 | ||
| 450 | [ | |
| 451 | isDefaultBranch, | |
| 452 | runImmediateGitActionMutation, | |
| 453 | setPendingDefaultBranchAction, | |
| 454 | threadToastData, | |
| 455 | gitStatusForActions, | |
| 456 | ], | |
| 457 | 581 | ); |
| 458 | 582 | |
| 459 | 583 | const continuePendingDefaultBranchAction = useCallback(() => { |
| ... | ... | @@ -469,38 +593,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 469 | 593 | ...(filePaths ? { filePaths } : {}), |
| 470 | 594 | skipDefaultBranchPrompt: true, |
| 471 | 595 | }); |
| 472 | }, [pendingDefaultBranchAction, runGitActionWithToast]); | |
| 473 | ||
| 474 | const checkoutNewBranchAndRunAction = useCallback( | |
| 475 | (actionParams: { | |
| 476 | action: GitStackedAction; | |
| 477 | commitMessage?: string; | |
| 478 | forcePushOnlyProgress?: boolean; | |
| 479 | onConfirmed?: () => void; | |
| 480 | filePaths?: string[]; | |
| 481 | }) => { | |
| 482 | void runGitActionWithToast({ | |
| 483 | ...actionParams, | |
| 484 | featureBranch: true, | |
| 485 | skipDefaultBranchPrompt: true, | |
| 486 | }); | |
| 487 | }, | |
| 488 | [runGitActionWithToast], | |
| 489 | ); | |
| 596 | }, [pendingDefaultBranchAction]); | |
| 490 | 597 | |
| 491 | 598 | const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { |
| 492 | 599 | if (!pendingDefaultBranchAction) return; |
| 493 | 600 | const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = |
| 494 | 601 | pendingDefaultBranchAction; |
| 495 | 602 | setPendingDefaultBranchAction(null); |
| 496 | checkoutNewBranchAndRunAction({ | |
| 603 | void runGitActionWithToast({ | |
| 497 | 604 | action, |
| 498 | 605 | ...(commitMessage ? { commitMessage } : {}), |
| 499 | 606 | forcePushOnlyProgress, |
| 500 | 607 | ...(onConfirmed ? { onConfirmed } : {}), |
| 501 | 608 | ...(filePaths ? { filePaths } : {}), |
| 609 | featureBranch: true, | |
| 610 | skipDefaultBranchPrompt: true, | |
| 502 | 611 | }); |
| 503 | }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); | |
| 612 | }, [pendingDefaultBranchAction]); | |
| 504 | 613 | |
| 505 | 614 | const runDialogActionOnNewBranch = useCallback(() => { |
| 506 | 615 | if (!isCommitDialogOpen) return; |
| ... | ... | @@ -511,18 +620,14 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 511 | 620 | setExcludedFiles(new Set()); |
| 512 | 621 | setIsEditingFiles(false); |
| 513 | 622 | |
| 514 | checkoutNewBranchAndRunAction({ | |
| 623 | void runGitActionWithToast({ | |
| 515 | 624 | action: "commit", |
| 516 | 625 | ...(commitMessage ? { commitMessage } : {}), |
| 517 | 626 | ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), |
| 627 | featureBranch: true, | |
| 628 | skipDefaultBranchPrompt: true, | |
| 518 | 629 | }); |
| 519 | }, [ | |
| 520 | allSelected, | |
| 521 | isCommitDialogOpen, | |
| 522 | dialogCommitMessage, | |
| 523 | checkoutNewBranchAndRunAction, | |
| 524 | selectedFiles, | |
| 525 | ]); | |
| 630 | }, [allSelected, isCommitDialogOpen, dialogCommitMessage, selectedFiles]); | |
| 526 | 631 | |
| 527 | 632 | const runQuickAction = useCallback(() => { |
| 528 | 633 | if (quickAction.kind === "open_pr") { |
| ... | ... | @@ -562,7 +667,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 562 | 667 | if (quickAction.action) { |
| 563 | 668 | void runGitActionWithToast({ action: quickAction.action }); |
| 564 | 669 | } |
| 565 | }, [openExistingPr, pullMutation, quickAction, runGitActionWithToast, threadToastData]); | |
| 670 | }, [openExistingPr, pullMutation, quickAction, threadToastData]); | |
| 566 | 671 | |
| 567 | 672 | const openDialogForMenuItem = useCallback( |
| 568 | 673 | (item: GitActionMenuItem) => { |
| ... | ... | @@ -583,7 +688,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 583 | 688 | setIsEditingFiles(false); |
| 584 | 689 | setIsCommitDialogOpen(true); |
| 585 | 690 | }, |
| 586 | [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen], | |
| 691 | [openExistingPr, setIsCommitDialogOpen], | |
| 587 | 692 | ); |
| 588 | 693 | |
| 589 | 694 | const runDialogAction = useCallback(() => { |
| ... | ... | @@ -602,7 +707,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 602 | 707 | allSelected, |
| 603 | 708 | dialogCommitMessage, |
| 604 | 709 | isCommitDialogOpen, |
| 605 | runGitActionWithToast, | |
| 606 | 710 | selectedFiles, |
| 607 | 711 | setDialogCommitMessage, |
| 608 | 712 | setIsCommitDialogOpen, |
apps/web/src/lib/gitReactQuery.ts+3| ... | ... | @@ -117,11 +117,13 @@ export function gitRunStackedActionMutationOptions(input: { |
| 117 | 117 | return mutationOptions({ |
| 118 | 118 | mutationKey: gitMutationKeys.runStackedAction(input.cwd), |
| 119 | 119 | mutationFn: async ({ |
| 120 | actionId, | |
| 120 | 121 | action, |
| 121 | 122 | commitMessage, |
| 122 | 123 | featureBranch, |
| 123 | 124 | filePaths, |
| 124 | 125 | }: { |
| 126 | actionId: string; | |
| 125 | 127 | action: GitStackedAction; |
| 126 | 128 | commitMessage?: string; |
| 127 | 129 | featureBranch?: boolean; |
| ... | ... | @@ -130,6 +132,7 @@ export function gitRunStackedActionMutationOptions(input: { |
| 130 | 132 | const api = ensureNativeApi(); |
| 131 | 133 | if (!input.cwd) throw new Error("Git action is unavailable."); |
| 132 | 134 | return api.git.runStackedAction({ |
| 135 | actionId, | |
| 133 | 136 | cwd: input.cwd, |
| 134 | 137 | action, |
| 135 | 138 | ...(commitMessage ? { commitMessage } : {}), |
apps/web/src/wsNativeApi.test.ts+39| ... | ... | @@ -239,9 +239,11 @@ describe("wsNativeApi", () => { |
| 239 | 239 | const api = createWsNativeApi(); |
| 240 | 240 | const onTerminalEvent = vi.fn(); |
| 241 | 241 | const onDomainEvent = vi.fn(); |
| 242 | const onActionProgress = vi.fn(); | |
| 242 | 243 | |
| 243 | 244 | api.terminal.onEvent(onTerminalEvent); |
| 244 | 245 | api.orchestration.onDomainEvent(onDomainEvent); |
| 246 | api.git.onActionProgress(onActionProgress); | |
| 245 | 247 | |
| 246 | 248 | const terminalEvent = { |
| 247 | 249 | threadId: "thread-1", |
| ... | ... | @@ -274,11 +276,28 @@ describe("wsNativeApi", () => { |
| 274 | 276 | }, |
| 275 | 277 | } satisfies Extract<OrchestrationEvent, { type: "project.created" }>; |
| 276 | 278 | emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent); |
| 279 | emitPush(WS_CHANNELS.gitActionProgress, { | |
| 280 | actionId: "action-1", | |
| 281 | cwd: "/repo", | |
| 282 | action: "commit", | |
| 283 | kind: "phase_started", | |
| 284 | phase: "commit", | |
| 285 | label: "Committing...", | |
| 286 | }); | |
| 277 | 287 | |
| 278 | 288 | expect(onTerminalEvent).toHaveBeenCalledTimes(1); |
| 279 | 289 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); |
| 280 | 290 | expect(onDomainEvent).toHaveBeenCalledTimes(1); |
| 281 | 291 | expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); |
| 292 | expect(onActionProgress).toHaveBeenCalledTimes(1); | |
| 293 | expect(onActionProgress).toHaveBeenCalledWith({ | |
| 294 | actionId: "action-1", | |
| 295 | cwd: "/repo", | |
| 296 | action: "commit", | |
| 297 | kind: "phase_started", | |
| 298 | phase: "commit", | |
| 299 | label: "Committing...", | |
| 300 | }); | |
| 282 | 301 | }); |
| 283 | 302 | |
| 284 | 303 | it("wraps orchestration dispatch commands in the command envelope", async () => { |
| ... | ... | @@ -320,6 +339,26 @@ describe("wsNativeApi", () => { |
| 320 | 339 | }); |
| 321 | 340 | }); |
| 322 | 341 | |
| 342 | it("uses no client timeout for git.runStackedAction", async () => { | |
| 343 | requestMock.mockResolvedValue({ | |
| 344 | action: "commit", | |
| 345 | branch: { status: "skipped_not_requested" }, | |
| 346 | commit: { status: "created", commitSha: "abc1234", subject: "Test" }, | |
| 347 | push: { status: "skipped_not_requested" }, | |
| 348 | pr: { status: "skipped_not_requested" }, | |
| 349 | }); | |
| 350 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 351 | ||
| 352 | const api = createWsNativeApi(); | |
| 353 | await api.git.runStackedAction({ actionId: "action-1", cwd: "/repo", action: "commit" }); | |
| 354 | ||
| 355 | expect(requestMock).toHaveBeenCalledWith( | |
| 356 | WS_METHODS.gitRunStackedAction, | |
| 357 | { actionId: "action-1", cwd: "/repo", action: "commit" }, | |
| 358 | { timeoutMs: null }, | |
| 359 | ); | |
| 360 | }); | |
| 361 | ||
| 323 | 362 | it("forwards full-thread diff requests to the orchestration websocket method", async () => { |
| 324 | 363 | requestMock.mockResolvedValue({ diff: "patch" }); |
| 325 | 364 | const { createWsNativeApi } = await import("./wsNativeApi"); |
apps/web/src/wsNativeApi.ts+20-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | type GitActionProgressEvent, | |
| 2 | 3 | ORCHESTRATION_WS_CHANNELS, |
| 3 | 4 | ORCHESTRATION_WS_METHODS, |
| 4 | 5 | type ContextMenuItem, |
| ... | ... | @@ -15,6 +16,7 @@ import { WsTransport } from "./wsTransport"; |
| 15 | 16 | let instance: { api: NativeApi; transport: WsTransport } | null = null; |
| 16 | 17 | const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); |
| 17 | 18 | const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); |
| 19 | const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>(); | |
| 18 | 20 | |
| 19 | 21 | /** |
| 20 | 22 | * Subscribe to the server welcome message. If a welcome was already received |
| ... | ... | @@ -87,6 +89,16 @@ export function createWsNativeApi(): NativeApi { |
| 87 | 89 | } |
| 88 | 90 | } |
| 89 | 91 | }); |
| 92 | transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => { | |
| 93 | const payload = message.data; | |
| 94 | for (const listener of gitActionProgressListeners) { | |
| 95 | try { | |
| 96 | listener(payload); | |
| 97 | } catch { | |
| 98 | // Swallow listener errors | |
| 99 | } | |
| 100 | } | |
| 101 | }); | |
| 90 | 102 | |
| 91 | 103 | const api: NativeApi = { |
| 92 | 104 | dialogs: { |
| ... | ... | @@ -135,7 +147,8 @@ export function createWsNativeApi(): NativeApi { |
| 135 | 147 | git: { |
| 136 | 148 | pull: (input) => transport.request(WS_METHODS.gitPull, input), |
| 137 | 149 | status: (input) => transport.request(WS_METHODS.gitStatus, input), |
| 138 | runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), | |
| 150 | runStackedAction: (input) => | |
| 151 | transport.request(WS_METHODS.gitRunStackedAction, input, { timeoutMs: null }), | |
| 139 | 152 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), |
| 140 | 153 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), |
| 141 | 154 | removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), |
| ... | ... | @@ -145,6 +158,12 @@ export function createWsNativeApi(): NativeApi { |
| 145 | 158 | resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input), |
| 146 | 159 | preparePullRequestThread: (input) => |
| 147 | 160 | transport.request(WS_METHODS.gitPreparePullRequestThread, input), |
| 161 | onActionProgress: (callback) => { | |
| 162 | gitActionProgressListeners.add(callback); | |
| 163 | return () => { | |
| 164 | gitActionProgressListeners.delete(callback); | |
| 165 | }; | |
| 166 | }, | |
| 148 | 167 | }, |
| 149 | 168 | contextMenu: { |
| 150 | 169 | show: async <T extends string>( |
apps/web/src/wsTransport.test.ts+47| ... | ... | @@ -206,4 +206,51 @@ describe("WsTransport", () => { |
| 206 | 206 | await expect(requestPromise).resolves.toEqual({ projects: [] }); |
| 207 | 207 | transport.dispose(); |
| 208 | 208 | }); |
| 209 | ||
| 210 | it("does not create a timeout for requests with timeoutMs null", async () => { | |
| 211 | const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); | |
| 212 | const transport = new WsTransport("ws://localhost:3020"); | |
| 213 | const socket = getSocket(); | |
| 214 | socket.open(); | |
| 215 | ||
| 216 | const requestPromise = transport.request( | |
| 217 | "git.runStackedAction", | |
| 218 | { cwd: "/repo" }, | |
| 219 | { timeoutMs: null }, | |
| 220 | ); | |
| 221 | const sent = socket.sent.at(-1); | |
| 222 | if (!sent) { | |
| 223 | throw new Error("Expected request envelope to be sent"); | |
| 224 | } | |
| 225 | const requestEnvelope = JSON.parse(sent) as { id: string }; | |
| 226 | ||
| 227 | socket.serverMessage( | |
| 228 | JSON.stringify({ | |
| 229 | id: requestEnvelope.id, | |
| 230 | result: { ok: true }, | |
| 231 | }), | |
| 232 | ); | |
| 233 | ||
| 234 | await expect(requestPromise).resolves.toEqual({ ok: true }); | |
| 235 | expect(timeoutSpy.mock.calls.some(([callback]) => typeof callback === "function")).toBe(false); | |
| 236 | ||
| 237 | transport.dispose(); | |
| 238 | }); | |
| 239 | ||
| 240 | it("rejects pending requests when the websocket closes", async () => { | |
| 241 | const transport = new WsTransport("ws://localhost:3020"); | |
| 242 | const socket = getSocket(); | |
| 243 | socket.open(); | |
| 244 | ||
| 245 | const requestPromise = transport.request( | |
| 246 | "git.runStackedAction", | |
| 247 | { cwd: "/repo" }, | |
| 248 | { timeoutMs: null }, | |
| 249 | ); | |
| 250 | ||
| 251 | socket.close(); | |
| 252 | ||
| 253 | await expect(requestPromise).rejects.toThrow("WebSocket connection closed."); | |
| 254 | transport.dispose(); | |
| 255 | }); | |
| 209 | 256 | }); |
apps/web/src/wsTransport.ts+32-8| ... | ... | @@ -14,13 +14,17 @@ type PushListener<C extends WsPushChannel> = (message: WsPushMessage<C>) => void |
| 14 | 14 | interface PendingRequest { |
| 15 | 15 | resolve: (result: unknown) => void; |
| 16 | 16 | reject: (error: Error) => void; |
| 17 | timeout: ReturnType<typeof setTimeout>; | |
| 17 | timeout: ReturnType<typeof setTimeout> | null; | |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | 20 | interface SubscribeOptions { |
| 21 | 21 | readonly replayLatest?: boolean; |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | interface RequestOptions { | |
| 25 | readonly timeoutMs?: number | null; | |
| 26 | } | |
| 27 | ||
| 24 | 28 | type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed"; |
| 25 | 29 | |
| 26 | 30 | const REQUEST_TIMEOUT_MS = 60_000; |
| ... | ... | @@ -72,7 +76,11 @@ export class WsTransport { |
| 72 | 76 | this.connect(); |
| 73 | 77 | } |
| 74 | 78 | |
| 75 | async request<T = unknown>(method: string, params?: unknown): Promise<T> { | |
| 79 | async request<T = unknown>( | |
| 80 | method: string, | |
| 81 | params?: unknown, | |
| 82 | options?: RequestOptions, | |
| 83 | ): Promise<T> { | |
| 76 | 84 | if (typeof method !== "string" || method.length === 0) { |
| 77 | 85 | throw new Error("Request method is required"); |
| 78 | 86 | } |
| ... | ... | @@ -83,10 +91,14 @@ export class WsTransport { |
| 83 | 91 | const encoded = JSON.stringify(message); |
| 84 | 92 | |
| 85 | 93 | return new Promise<T>((resolve, reject) => { |
| 86 | const timeout = setTimeout(() => { | |
| 87 | this.pending.delete(id); | |
| 88 | reject(new Error(`Request timed out: ${method}`)); | |
| 89 | }, REQUEST_TIMEOUT_MS); | |
| 94 | const timeoutMs = options?.timeoutMs === undefined ? REQUEST_TIMEOUT_MS : options.timeoutMs; | |
| 95 | const timeout = | |
| 96 | timeoutMs === null | |
| 97 | ? null | |
| 98 | : setTimeout(() => { | |
| 99 | this.pending.delete(id); | |
| 100 | reject(new Error(`Request timed out: ${method}`)); | |
| 101 | }, timeoutMs); | |
| 90 | 102 | |
| 91 | 103 | this.pending.set(id, { |
| 92 | 104 | resolve: resolve as (result: unknown) => void, |
| ... | ... | @@ -146,7 +158,9 @@ export class WsTransport { |
| 146 | 158 | this.reconnectTimer = null; |
| 147 | 159 | } |
| 148 | 160 | for (const pending of this.pending.values()) { |
| 149 | clearTimeout(pending.timeout); | |
| 161 | if (pending.timeout !== null) { | |
| 162 | clearTimeout(pending.timeout); | |
| 163 | } | |
| 150 | 164 | pending.reject(new Error("Transport disposed")); |
| 151 | 165 | } |
| 152 | 166 | this.pending.clear(); |
| ... | ... | @@ -177,6 +191,14 @@ export class WsTransport { |
| 177 | 191 | ws.addEventListener("close", () => { |
| 178 | 192 | if (this.ws === ws) { |
| 179 | 193 | this.ws = null; |
| 194 | this.outboundQueue.length = 0; | |
| 195 | for (const [id, pending] of this.pending.entries()) { | |
| 196 | if (pending.timeout !== null) { | |
| 197 | clearTimeout(pending.timeout); | |
| 198 | } | |
| 199 | this.pending.delete(id); | |
| 200 | pending.reject(new Error("WebSocket connection closed.")); | |
| 201 | } | |
| 180 | 202 | } |
| 181 | 203 | if (this.disposed) { |
| 182 | 204 | this.state = "disposed"; |
| ... | ... | @@ -224,7 +246,9 @@ export class WsTransport { |
| 224 | 246 | return; |
| 225 | 247 | } |
| 226 | 248 | |
| 227 | clearTimeout(pending.timeout); | |
| 249 | if (pending.timeout !== null) { | |
| 250 | clearTimeout(pending.timeout); | |
| 251 | } | |
| 228 | 252 | this.pending.delete(message.id); |
| 229 | 253 | |
| 230 | 254 | if (message.error) { |
packages/contracts/src/git.test.ts+15| ... | ... | @@ -4,6 +4,7 @@ import { Schema } from "effect"; |
| 4 | 4 | import { |
| 5 | 5 | GitCreateWorktreeInput, |
| 6 | 6 | GitPreparePullRequestThreadInput, |
| 7 | GitRunStackedActionInput, | |
| 7 | 8 | GitResolvePullRequestResult, |
| 8 | 9 | } from "./git"; |
| 9 | 10 | |
| ... | ... | @@ -11,6 +12,7 @@ const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInpu |
| 11 | 12 | const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( |
| 12 | 13 | GitPreparePullRequestThreadInput, |
| 13 | 14 | ); |
| 15 | const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); | |
| 14 | 16 | const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); |
| 15 | 17 | |
| 16 | 18 | describe("GitCreateWorktreeInput", () => { |
| ... | ... | @@ -56,3 +58,16 @@ describe("GitResolvePullRequestResult", () => { |
| 56 | 58 | expect(parsed.pullRequest.headBranch).toBe("feature/pr-threads"); |
| 57 | 59 | }); |
| 58 | 60 | }); |
| 61 | ||
| 62 | describe("GitRunStackedActionInput", () => { | |
| 63 | it("requires a client-provided actionId for progress correlation", () => { | |
| 64 | const parsed = decodeRunStackedActionInput({ | |
| 65 | actionId: "action-1", | |
| 66 | cwd: "/repo", | |
| 67 | action: "commit", | |
| 68 | }); | |
| 69 | ||
| 70 | expect(parsed.actionId).toBe("action-1"); | |
| 71 | expect(parsed.action).toBe("commit"); | |
| 72 | }); | |
| 73 | }); |
packages/contracts/src/git.ts+74| ... | ... | @@ -8,6 +8,20 @@ const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 8 | 8 | |
| 9 | 9 | export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); |
| 10 | 10 | export type GitStackedAction = typeof GitStackedAction.Type; |
| 11 | export const GitActionProgressPhase = Schema.Literals(["branch", "commit", "push", "pr"]); | |
| 12 | export type GitActionProgressPhase = typeof GitActionProgressPhase.Type; | |
| 13 | export const GitActionProgressKind = Schema.Literals([ | |
| 14 | "action_started", | |
| 15 | "phase_started", | |
| 16 | "hook_started", | |
| 17 | "hook_output", | |
| 18 | "hook_finished", | |
| 19 | "action_finished", | |
| 20 | "action_failed", | |
| 21 | ]); | |
| 22 | export type GitActionProgressKind = typeof GitActionProgressKind.Type; | |
| 23 | export const GitActionProgressStream = Schema.Literals(["stdout", "stderr"]); | |
| 24 | export type GitActionProgressStream = typeof GitActionProgressStream.Type; | |
| 11 | 25 | const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); |
| 12 | 26 | const GitPushStepStatus = Schema.Literals([ |
| 13 | 27 | "pushed", |
| ... | ... | @@ -58,6 +72,7 @@ export const GitPullInput = Schema.Struct({ |
| 58 | 72 | export type GitPullInput = typeof GitPullInput.Type; |
| 59 | 73 | |
| 60 | 74 | export const GitRunStackedActionInput = Schema.Struct({ |
| 75 | actionId: TrimmedNonEmptyStringSchema, | |
| 61 | 76 | cwd: TrimmedNonEmptyStringSchema, |
| 62 | 77 | action: GitStackedAction, |
| 63 | 78 | commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), |
| ... | ... | @@ -211,3 +226,62 @@ export const GitPullResult = Schema.Struct({ |
| 211 | 226 | upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), |
| 212 | 227 | }); |
| 213 | 228 | export type GitPullResult = typeof GitPullResult.Type; |
| 229 | ||
| 230 | const GitActionProgressBase = Schema.Struct({ | |
| 231 | actionId: TrimmedNonEmptyStringSchema, | |
| 232 | cwd: TrimmedNonEmptyStringSchema, | |
| 233 | action: GitStackedAction, | |
| 234 | }); | |
| 235 | ||
| 236 | const GitActionStartedEvent = Schema.Struct({ | |
| 237 | ...GitActionProgressBase.fields, | |
| 238 | kind: Schema.Literal("action_started"), | |
| 239 | phases: Schema.Array(GitActionProgressPhase), | |
| 240 | }); | |
| 241 | const GitActionPhaseStartedEvent = Schema.Struct({ | |
| 242 | ...GitActionProgressBase.fields, | |
| 243 | kind: Schema.Literal("phase_started"), | |
| 244 | phase: GitActionProgressPhase, | |
| 245 | label: TrimmedNonEmptyStringSchema, | |
| 246 | }); | |
| 247 | const GitActionHookStartedEvent = Schema.Struct({ | |
| 248 | ...GitActionProgressBase.fields, | |
| 249 | kind: Schema.Literal("hook_started"), | |
| 250 | hookName: TrimmedNonEmptyStringSchema, | |
| 251 | }); | |
| 252 | const GitActionHookOutputEvent = Schema.Struct({ | |
| 253 | ...GitActionProgressBase.fields, | |
| 254 | kind: Schema.Literal("hook_output"), | |
| 255 | hookName: Schema.NullOr(TrimmedNonEmptyStringSchema), | |
| 256 | stream: GitActionProgressStream, | |
| 257 | text: TrimmedNonEmptyStringSchema, | |
| 258 | }); | |
| 259 | const GitActionHookFinishedEvent = Schema.Struct({ | |
| 260 | ...GitActionProgressBase.fields, | |
| 261 | kind: Schema.Literal("hook_finished"), | |
| 262 | hookName: TrimmedNonEmptyStringSchema, | |
| 263 | exitCode: Schema.NullOr(Schema.Int), | |
| 264 | durationMs: Schema.NullOr(NonNegativeInt), | |
| 265 | }); | |
| 266 | const GitActionFinishedEvent = Schema.Struct({ | |
| 267 | ...GitActionProgressBase.fields, | |
| 268 | kind: Schema.Literal("action_finished"), | |
| 269 | result: GitRunStackedActionResult, | |
| 270 | }); | |
| 271 | const GitActionFailedEvent = Schema.Struct({ | |
| 272 | ...GitActionProgressBase.fields, | |
| 273 | kind: Schema.Literal("action_failed"), | |
| 274 | phase: Schema.NullOr(GitActionProgressPhase), | |
| 275 | message: TrimmedNonEmptyStringSchema, | |
| 276 | }); | |
| 277 | ||
| 278 | export const GitActionProgressEvent = Schema.Union([ | |
| 279 | GitActionStartedEvent, | |
| 280 | GitActionPhaseStartedEvent, | |
| 281 | GitActionHookStartedEvent, | |
| 282 | GitActionHookOutputEvent, | |
| 283 | GitActionHookFinishedEvent, | |
| 284 | GitActionFinishedEvent, | |
| 285 | GitActionFailedEvent, | |
| 286 | ]); | |
| 287 | export type GitActionProgressEvent = typeof GitActionProgressEvent.Type; |
packages/contracts/src/ipc.ts+2| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import type { |
| 2 | 2 | GitCheckoutInput, |
| 3 | GitActionProgressEvent, | |
| 3 | 4 | GitCreateBranchInput, |
| 4 | 5 | GitPreparePullRequestThreadInput, |
| 5 | 6 | GitPreparePullRequestThreadResult, |
| ... | ... | @@ -149,6 +150,7 @@ export interface NativeApi { |
| 149 | 150 | pull: (input: GitPullInput) => Promise<GitPullResult>; |
| 150 | 151 | status: (input: GitStatusInput) => Promise<GitStatusResult>; |
| 151 | 152 | runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>; |
| 153 | onActionProgress: (callback: (event: GitActionProgressEvent) => void) => () => void; | |
| 152 | 154 | }; |
| 153 | 155 | contextMenu: { |
| 154 | 156 | show: <T extends string>( |
packages/contracts/src/ws.test.ts+24| ... | ... | @@ -95,6 +95,30 @@ it.effect("accepts typed websocket push envelopes with sequence", () => |
| 95 | 95 | }), |
| 96 | 96 | ); |
| 97 | 97 | |
| 98 | it.effect("accepts git.actionProgress push envelopes", () => | |
| 99 | Effect.gen(function* () { | |
| 100 | const parsed = yield* decodeWsResponse({ | |
| 101 | type: "push", | |
| 102 | sequence: 3, | |
| 103 | channel: WS_CHANNELS.gitActionProgress, | |
| 104 | data: { | |
| 105 | actionId: "action-1", | |
| 106 | cwd: "/repo", | |
| 107 | action: "commit", | |
| 108 | kind: "phase_started", | |
| 109 | phase: "commit", | |
| 110 | label: "Committing...", | |
| 111 | }, | |
| 112 | }); | |
| 113 | ||
| 114 | if (!("type" in parsed) || parsed.type !== "push") { | |
| 115 | assert.fail("expected websocket response to decode as a push envelope"); | |
| 116 | } | |
| 117 | ||
| 118 | assert.strictEqual(parsed.channel, WS_CHANNELS.gitActionProgress); | |
| 119 | }), | |
| 120 | ); | |
| 121 | ||
| 98 | 122 | it.effect("rejects push envelopes when channel payload does not match the channel schema", () => |
| 99 | 123 | Effect.gen(function* () { |
| 100 | 124 | const result = yield* Effect.exit( |
packages/contracts/src/ws.ts+9| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | OrchestrationReplayEventsInput, |
| 13 | 13 | } from "./orchestration"; |
| 14 | 14 | import { |
| 15 | GitActionProgressEvent, | |
| 15 | 16 | GitCheckoutInput, |
| 16 | 17 | GitCreateBranchInput, |
| 17 | 18 | GitPreparePullRequestThreadInput, |
| ... | ... | @@ -80,6 +81,7 @@ export const WS_METHODS = { |
| 80 | 81 | // ── Push Event Channels ────────────────────────────────────────────── |
| 81 | 82 | |
| 82 | 83 | export const WS_CHANNELS = { |
| 84 | gitActionProgress: "git.actionProgress", | |
| 83 | 85 | terminalEvent: "terminal.event", |
| 84 | 86 | serverWelcome: "server.welcome", |
| 85 | 87 | serverConfigUpdated: "server.configUpdated", |
| ... | ... | @@ -172,6 +174,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type; |
| 172 | 174 | export interface WsPushPayloadByChannel { |
| 173 | 175 | readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload; |
| 174 | 176 | readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type; |
| 177 | readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type; | |
| 175 | 178 | readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; |
| 176 | 179 | readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; |
| 177 | 180 | } |
| ... | ... | @@ -195,6 +198,10 @@ export const WsPushServerConfigUpdated = makeWsPushSchema( |
| 195 | 198 | WS_CHANNELS.serverConfigUpdated, |
| 196 | 199 | ServerConfigUpdatedPayload, |
| 197 | 200 | ); |
| 201 | export const WsPushGitActionProgress = makeWsPushSchema( | |
| 202 | WS_CHANNELS.gitActionProgress, | |
| 203 | GitActionProgressEvent, | |
| 204 | ); | |
| 198 | 205 | export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent); |
| 199 | 206 | export const WsPushOrchestrationDomainEvent = makeWsPushSchema( |
| 200 | 207 | ORCHESTRATION_WS_CHANNELS.domainEvent, |
| ... | ... | @@ -202,6 +209,7 @@ export const WsPushOrchestrationDomainEvent = makeWsPushSchema( |
| 202 | 209 | ); |
| 203 | 210 | |
| 204 | 211 | export const WsPushChannelSchema = Schema.Literals([ |
| 212 | WS_CHANNELS.gitActionProgress, | |
| 205 | 213 | WS_CHANNELS.serverWelcome, |
| 206 | 214 | WS_CHANNELS.serverConfigUpdated, |
| 207 | 215 | WS_CHANNELS.terminalEvent, |
| ... | ... | @@ -212,6 +220,7 @@ export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; |
| 212 | 220 | export const WsPush = Schema.Union([ |
| 213 | 221 | WsPushServerWelcome, |
| 214 | 222 | WsPushServerConfigUpdated, |
| 223 | WsPushGitActionProgress, | |
| 215 | 224 | WsPushTerminalEvent, |
| 216 | 225 | WsPushOrchestrationDomainEvent, |
| 217 | 226 | ]); |