authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-24 02:25:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-24 02:25:17-07:00
log384f35084e8a94985d37c95e17b3da83ed153e57
treec443cb75cdd3651317bf29e15848a755bab23361
parent1371ce2ccef7342399a6a020e69073334cfa364b
signature Signed by PGP key B5690EEEBB952194

Stream git hook progress events for stacked git actions (#1214)

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 {
88 Layer,
99 Option,
1010 Path,
11 PlatformError,
12 Ref,
13 Result,
1114 Schema,
15 Scope,
16 Semaphore,
1217 Stream,
1318} from "effect";
1419import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
......@@ -16,11 +21,14 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1621import { GitCommandError } from "../Errors.ts";
1722import {
1823 GitCore,
24 type ExecuteGitProgress,
25 type GitCommitOptions,
1926 type GitCoreShape,
2027 type ExecuteGitInput,
2128 type ExecuteGitResult,
2229} from "../Services/GitCore.ts";
2330import { ServerConfig } from "../../config.ts";
31import { decodeJsonResult } from "@t3tools/shared/schemaJson";
2432
2533const DEFAULT_TIMEOUT_MS = 30_000;
2634const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
......@@ -29,6 +37,11 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
2937const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
3038const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
3139
40type TraceTailState = {
41 processedChars: number;
42 remainder: string;
43};
44
3245class StatusUpstreamRefreshCacheKey extends Data.Class<{
3346 cwd: string;
3447 upstreamRef: string;
......@@ -40,6 +53,7 @@ interface ExecuteGitOptions {
4053 timeoutMs?: number | undefined;
4154 allowNonZeroExit?: boolean | undefined;
4255 fallbackErrorMessage?: string | undefined;
56 progress?: ExecuteGitProgress | undefined;
4357}
4458
4559function parseBranchAb(value: string): { ahead: number; behind: number } {
......@@ -257,14 +271,201 @@ function toGitCommandError(
257271 });
258272}
259273
274interface Trace2Monitor {
275 readonly env: NodeJS.ProcessEnv;
276 readonly flush: Effect.Effect<void, never>;
277}
278
279function 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
288const Trace2Record = Schema.Record(Schema.String, Schema.Unknown);
289
290const 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
260438const collectOutput = Effect.fn(function* <E>(
261439 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
262440 stream: Stream.Stream<Uint8Array, E>,
263441 maxOutputBytes: number,
442 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,
264443): Effect.fn.Return<string, GitCommandError> {
265444 const decoder = new TextDecoder();
266445 let bytes = 0;
267446 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 });
268469
269470 yield* Stream.runForEach(stream, (chunk) =>
270471 Effect.gen(function* () {
......@@ -277,11 +478,17 @@ const collectOutput = Effect.fn(function* <E>(
277478 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
278479 });
279480 }
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);
281485 }),
282486 ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed.")));
283487
284 text += decoder.decode();
488 const remainder = decoder.decode();
489 text += remainder;
490 lineBuffer += remainder;
491 yield* emitCompleteLines(true);
285492 return text;
286493});
287494
......@@ -306,19 +513,38 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
306513 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
307514
308515 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 );
309521 const child = yield* commandSpawner
310522 .spawn(
311523 ChildProcess.make("git", commandInput.args, {
312524 cwd: commandInput.cwd,
313 ...(input.env ? { env: input.env } : {}),
525 env: {
526 ...process.env,
527 ...input.env,
528 ...trace2Monitor.env,
529 },
314530 }),
315531 )
316532 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));
317533
318534 const [stdout, stderr, exitCode] = yield* Effect.all(
319535 [
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 ),
322548 child.exitCode.pipe(
323549 Effect.map((value) => Number(value)),
324550 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),
......@@ -326,6 +552,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
326552 ],
327553 { concurrency: "unbounded" },
328554 );
555 yield* trace2Monitor.flush;
329556
330557 if (!input.allowNonZeroExit && exitCode !== 0) {
331558 const trimmedStderr = stderr.trim();
......@@ -376,6 +603,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
376603 args,
377604 allowNonZeroExit: true,
378605 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
606 ...(options.progress ? { progress: options.progress } : {}),
379607 }).pipe(
380608 Effect.flatMap((result) => {
381609 if (options.allowNonZeroExit || result.code === 0) {
......@@ -947,14 +1175,37 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
9471175 };
9481176 });
9491177
950 const commit: GitCoreShape["commit"] = (cwd, subject, body) =>
1178 const commit: GitCoreShape["commit"] = (cwd, subject, body, options?: GitCommitOptions) =>
9511179 Effect.gen(function* () {
9521180 const args = ["commit", "-m", subject];
9531181 const trimmedBody = body.trim();
9541182 if (trimmedBody.length > 0) {
9551183 args.push("-m", trimmedBody);
9561184 }
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);
9581209 const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [
9591210 "rev-parse",
9601211 "HEAD",
apps/server/src/git/Layers/GitManager.test.ts+123-1
......@@ -6,6 +6,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
66import { it } from "@effect/vitest";
77import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
88import { expect } from "vitest";
9import type { GitActionProgressEvent } from "@t3tools/contracts";
910
1011import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";
1112import { type GitManagerShape } from "../Services/GitManager.ts";
......@@ -449,12 +450,20 @@ function runStackedAction(
449450 input: {
450451 cwd: string;
451452 action: "commit" | "commit_push" | "commit_push_pr";
453 actionId?: string;
452454 commitMessage?: string;
453455 featureBranch?: boolean;
454456 filePaths?: readonly string[];
455457 },
458 options?: Parameters<GitManagerShape["runStackedAction"]>[1],
456459) {
457 return manager.runStackedAction(input);
460 return manager.runStackedAction(
461 {
462 ...input,
463 actionId: input.actionId ?? "test-action-id",
464 },
465 options,
466 );
458467}
459468
460469function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) {
......@@ -1941,4 +1950,117 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
19411950 expect(errorMessage).toContain("already checked out in the main repo");
19421951 }),
19431952 );
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 );
19442066});
apps/server/src/git/Layers/GitManager.ts+254-56
......@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
22import { realpathSync } from "node:fs";
33
44import { Effect, FileSystem, Layer, Path } from "effect";
5import type { GitActionProgressEvent, GitActionProgressPhase } from "@t3tools/contracts";
56import {
67 resolveAutoFeatureBranchName,
78 sanitizeBranchFragment,
......@@ -9,11 +10,21 @@ import {
910} from "@t3tools/shared/git";
1011
1112import { GitManagerError } from "../Errors.ts";
12import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";
13import {
14 GitManager,
15 type GitActionProgressReporter,
16 type GitManagerShape,
17 type GitRunStackedActionOptions,
18} from "../Services/GitManager.ts";
1319import { GitCore } from "../Services/GitCore.ts";
1420import { GitHubCli } from "../Services/GitHubCli.ts";
1521import { TextGeneration } from "../Services/TextGeneration.ts";
1622
23const COMMIT_TIMEOUT_MS = 10 * 60_000;
24const MAX_PROGRESS_TEXT_LENGTH = 500;
25type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;
26type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;
27
1728interface OpenPrInfo {
1829 number: number;
1930 title: string;
......@@ -200,6 +211,17 @@ function sanitizeCommitMessage(generated: {
200211 };
201212}
202213
214function 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
203225interface CommitAndBranchSuggestion {
204226 subject: string;
205227 body: string;
......@@ -337,6 +359,29 @@ export const makeGitManager = Effect.gen(function* () {
337359 const gitHubCli = yield* GitHubCli;
338360 const textGeneration = yield* TextGeneration;
339361
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
340385 const configurePullRequestHeadUpstream = (
341386 cwd: string,
342387 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
......@@ -680,27 +725,111 @@ export const makeGitManager = Effect.gen(function* () {
680725
681726 const runCommitStep = (
682727 cwd: string,
728 action: "commit" | "commit_push" | "commit_push_pr",
683729 branch: string | null,
684730 commitMessage?: string,
685731 preResolvedSuggestion?: CommitAndBranchSuggestion,
686732 filePaths?: readonly string[],
687733 model?: string,
734 progressReporter?: GitActionProgressReporter,
735 actionId?: string,
688736 ) =>
689737 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({
693759 cwd,
694760 branch,
695761 ...(commitMessage ? { commitMessage } : {}),
696762 ...(filePaths ? { filePaths } : {}),
697763 ...(model ? { model } : {}),
698 }));
764 });
765 }
699766 if (!suggestion) {
700767 return { status: "skipped_no_changes" as const };
701768 }
702769
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 }
704833 return {
705834 status: "created" as const,
706835 commitSha,
......@@ -1010,66 +1139,135 @@ export const makeGitManager = Effect.gen(function* () {
10101139 });
10111140
10121141 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 });
10161157
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 }
10271196
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;
10311198
1032 if (input.featureBranch) {
1033 const result = yield* runFeatureBranchStep(
1199 currentPhase = "commit";
1200 const commit = yield* runCommitStep(
10341201 input.cwd,
1035 initialStatus.branch,
1036 input.commitMessage,
1202 input.action,
1203 currentBranch,
1204 commitMessageForStep,
1205 preResolvedCommitSuggestion,
10371206 input.filePaths,
10381207 input.textGenerationModel,
1208 options?.progressReporter,
1209 progress.actionId,
10391210 );
1040 branchStep = result.branchStep;
1041 commitMessageForStep = result.resolvedCommitMessage;
1042 preResolvedCommitSuggestion = result.resolvedCommitSuggestion;
1043 } else {
1044 branchStep = { status: "skipped_not_requested" as const };
1045 }
10461211
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 });
10481259
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 ),
10561270 );
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 };
10731271 },
10741272 );
10751273
apps/server/src/git/Services/GitCore.ts+31
......@@ -32,6 +32,7 @@ export interface ExecuteGitInput {
3232 readonly allowNonZeroExit?: boolean;
3333 readonly timeoutMs?: number;
3434 readonly maxOutputBytes?: number;
35 readonly progress?: ExecuteGitProgress;
3536}
3637
3738export interface ExecuteGitResult {
......@@ -49,6 +50,35 @@ export interface GitPreparedCommitContext {
4950 stagedPatch: string;
5051}
5152
53export 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
64export 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
77export interface GitCommitOptions {
78 readonly timeoutMs?: number;
79 readonly progress?: GitCommitProgress;
80}
81
5282export interface GitPushResult {
5383 status: "pushed" | "skipped_up_to_date";
5484 branch: string;
......@@ -132,6 +162,7 @@ export interface GitCoreShape {
132162 cwd: string,
133163 subject: string,
134164 body: string,
165 options?: GitCommitOptions,
135166 ) => Effect.Effect<{ commitSha: string }, GitCommandError>;
136167
137168 /**
apps/server/src/git/Services/GitManager.ts+11
......@@ -7,6 +7,7 @@
77 * @module GitManager
88 */
99import {
10 GitActionProgressEvent,
1011 GitPreparePullRequestThreadInput,
1112 GitPreparePullRequestThreadResult,
1213 GitPullRequestRefInput,
......@@ -20,6 +21,15 @@ import { ServiceMap } from "effect";
2021import type { Effect } from "effect";
2122import type { GitManagerServiceError } from "../Errors.ts";
2223
24export interface GitActionProgressReporter {
25 readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>;
26}
27
28export interface GitRunStackedActionOptions {
29 readonly actionId?: string;
30 readonly progressReporter?: GitActionProgressReporter;
31}
32
2333/**
2434 * GitManagerShape - Service API for high-level Git workflow actions.
2535 */
......@@ -51,6 +61,7 @@ export interface GitManagerShape {
5161 */
5262 readonly runStackedAction: (
5363 input: GitRunStackedActionInput,
64 options?: GitRunStackedActionOptions,
5465 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
5566}
5667
apps/server/src/wsServer.test.ts+81-2
......@@ -1811,15 +1811,94 @@ describe("WebSocket Server", () => {
18111811 connections.push(ws);
18121812
18131813 const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, {
1814 actionId: "client-action-1",
18141815 cwd: "/test",
18151816 action: "commit_push",
18161817 });
18171818 expect(response.result).toBeUndefined();
18181819 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",
18201878 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...",
18221890 });
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 );
18231902 });
18241903
18251904 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<
704704 Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]),
705705 );
706706
707 const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) {
707 const routeRequest = Effect.fnUntraced(function* (ws: WebSocket, request: WebSocketRequest) {
708708 switch (request.body._tag) {
709709 case ORCHESTRATION_WS_METHODS.getSnapshot:
710710 return yield* projectionReadModelQuery.getSnapshot();
......@@ -793,7 +793,13 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
793793
794794 case WS_METHODS.gitRunStackedAction: {
795795 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 });
797803 }
798804
799805 case WS_METHODS.gitResolvePullRequest: {
......@@ -915,7 +921,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
915921 });
916922 }
917923
918 const result = yield* Effect.exit(routeRequest(request.success));
924 const result = yield* Effect.exit(routeRequest(ws, request.success));
919925 if (Exit.isFailure(result)) {
920926 return yield* sendWsResponse({
921927 id: request.success.id,
apps/web/src/components/GitActionsControl.tsx+176-72
......@@ -1,6 +1,11 @@
1import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts";
1import type {
2 GitActionProgressEvent,
3 GitStackedAction,
4 GitStatusResult,
5 ThreadId,
6} from "@t3tools/contracts";
27import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useCallback, useEffect, useMemo, useState } from "react";
8import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
49import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
510import { GitHubIcon } from "./Icons";
611import {
......@@ -43,6 +48,7 @@ import {
4348 gitStatusQueryOptions,
4449 invalidateGitQueries,
4550} from "~/lib/gitReactQuery";
51import { randomUUID } from "~/lib/utils";
4652import { resolvePathLinkTarget } from "~/terminal-links";
4753import { readNativeApi } from "~/nativeApi";
4854
......@@ -63,6 +69,50 @@ interface PendingDefaultBranchAction {
6369
6470type GitActionToastId = ReturnType<typeof toastManager.add>;
6571
72interface 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
83interface 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
96function 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
109function resolveProgressDescription(progress: ActiveGitActionProgress): string | undefined {
110 if (progress.lastOutputLine) {
111 return progress.lastOutputLine;
112 }
113 return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs);
114}
115
66116function getMenuActionDisabledReason({
67117 item,
68118 gitStatus,
......@@ -167,6 +217,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
167217 const [isEditingFiles, setIsEditingFiles] = useState(false);
168218 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
169219 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]);
170235
171236 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
172237
......@@ -232,6 +297,84 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
232297 })
233298 : null;
234299
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
235378 const openExistingPr = useCallback(async () => {
236379 const api = readNativeApi();
237380 if (!api) {
......@@ -259,9 +402,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
259402 data: threadToastData,
260403 });
261404 });
262 }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]);
405 }, [gitStatusForActions, threadToastData]);
263406
264 const runGitActionWithToast = useCallback(
407 const runGitActionWithToast = useEffectEvent(
265408 async ({
266409 action,
267410 commitMessage,
......@@ -273,18 +416,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
273416 isDefaultBranchOverride,
274417 progressToastId,
275418 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) => {
288420 const actionStatus = statusOverride ?? gitStatusForActions;
289421 const actionBranch = actionStatus?.branch ?? null;
290422 const actionIsDefaultBranch =
......@@ -319,40 +451,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
319451 forcePushOnly: forcePushOnlyProgress,
320452 featureBranch,
321453 });
454 const actionId = randomUUID();
322455 const resolvedProgressToastId =
323456 progressToastId ??
324457 toastManager.add({
325458 type: "loading",
326459 title: progressStages[0] ?? "Running git action...",
460 description: "Waiting for Git...",
327461 timeout: 0,
328462 data: threadToastData,
329463 });
330464
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
331476 if (progressToastId) {
332477 toastManager.update(progressToastId, {
333478 type: "loading",
334479 title: progressStages[0] ?? "Running git action...",
480 description: "Waiting for Git...",
335481 timeout: 0,
336482 data: threadToastData,
337483 });
338484 }
339485
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
355486 const promise = runImmediateGitActionMutation.mutateAsync({
487 actionId,
356488 action,
357489 ...(commitMessage ? { commitMessage } : {}),
358490 ...(featureBranch ? { featureBranch } : {}),
......@@ -361,7 +493,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
361493
362494 try {
363495 const result = await promise;
364 stopProgressUpdates();
496 activeGitActionProgressRef.current = null;
365497 const resultToast = summarizeGitResult(result);
366498
367499 const existingOpenPrUrl =
......@@ -437,7 +569,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
437569 : {}),
438570 });
439571 } catch (err) {
440 stopProgressUpdates();
572 activeGitActionProgressRef.current = null;
441573 toastManager.update(resolvedProgressToastId, {
442574 type: "error",
443575 title: "Action failed",
......@@ -446,14 +578,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
446578 });
447579 }
448580 },
449
450 [
451 isDefaultBranch,
452 runImmediateGitActionMutation,
453 setPendingDefaultBranchAction,
454 threadToastData,
455 gitStatusForActions,
456 ],
457581 );
458582
459583 const continuePendingDefaultBranchAction = useCallback(() => {
......@@ -469,38 +593,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
469593 ...(filePaths ? { filePaths } : {}),
470594 skipDefaultBranchPrompt: true,
471595 });
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]);
490597
491598 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
492599 if (!pendingDefaultBranchAction) return;
493600 const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =
494601 pendingDefaultBranchAction;
495602 setPendingDefaultBranchAction(null);
496 checkoutNewBranchAndRunAction({
603 void runGitActionWithToast({
497604 action,
498605 ...(commitMessage ? { commitMessage } : {}),
499606 forcePushOnlyProgress,
500607 ...(onConfirmed ? { onConfirmed } : {}),
501608 ...(filePaths ? { filePaths } : {}),
609 featureBranch: true,
610 skipDefaultBranchPrompt: true,
502611 });
503 }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]);
612 }, [pendingDefaultBranchAction]);
504613
505614 const runDialogActionOnNewBranch = useCallback(() => {
506615 if (!isCommitDialogOpen) return;
......@@ -511,18 +620,14 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
511620 setExcludedFiles(new Set());
512621 setIsEditingFiles(false);
513622
514 checkoutNewBranchAndRunAction({
623 void runGitActionWithToast({
515624 action: "commit",
516625 ...(commitMessage ? { commitMessage } : {}),
517626 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),
627 featureBranch: true,
628 skipDefaultBranchPrompt: true,
518629 });
519 }, [
520 allSelected,
521 isCommitDialogOpen,
522 dialogCommitMessage,
523 checkoutNewBranchAndRunAction,
524 selectedFiles,
525 ]);
630 }, [allSelected, isCommitDialogOpen, dialogCommitMessage, selectedFiles]);
526631
527632 const runQuickAction = useCallback(() => {
528633 if (quickAction.kind === "open_pr") {
......@@ -562,7 +667,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
562667 if (quickAction.action) {
563668 void runGitActionWithToast({ action: quickAction.action });
564669 }
565 }, [openExistingPr, pullMutation, quickAction, runGitActionWithToast, threadToastData]);
670 }, [openExistingPr, pullMutation, quickAction, threadToastData]);
566671
567672 const openDialogForMenuItem = useCallback(
568673 (item: GitActionMenuItem) => {
......@@ -583,7 +688,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
583688 setIsEditingFiles(false);
584689 setIsCommitDialogOpen(true);
585690 },
586 [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen],
691 [openExistingPr, setIsCommitDialogOpen],
587692 );
588693
589694 const runDialogAction = useCallback(() => {
......@@ -602,7 +707,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
602707 allSelected,
603708 dialogCommitMessage,
604709 isCommitDialogOpen,
605 runGitActionWithToast,
606710 selectedFiles,
607711 setDialogCommitMessage,
608712 setIsCommitDialogOpen,
apps/web/src/lib/gitReactQuery.ts+3
......@@ -117,11 +117,13 @@ export function gitRunStackedActionMutationOptions(input: {
117117 return mutationOptions({
118118 mutationKey: gitMutationKeys.runStackedAction(input.cwd),
119119 mutationFn: async ({
120 actionId,
120121 action,
121122 commitMessage,
122123 featureBranch,
123124 filePaths,
124125 }: {
126 actionId: string;
125127 action: GitStackedAction;
126128 commitMessage?: string;
127129 featureBranch?: boolean;
......@@ -130,6 +132,7 @@ export function gitRunStackedActionMutationOptions(input: {
130132 const api = ensureNativeApi();
131133 if (!input.cwd) throw new Error("Git action is unavailable.");
132134 return api.git.runStackedAction({
135 actionId,
133136 cwd: input.cwd,
134137 action,
135138 ...(commitMessage ? { commitMessage } : {}),
apps/web/src/wsNativeApi.test.ts+39
......@@ -239,9 +239,11 @@ describe("wsNativeApi", () => {
239239 const api = createWsNativeApi();
240240 const onTerminalEvent = vi.fn();
241241 const onDomainEvent = vi.fn();
242 const onActionProgress = vi.fn();
242243
243244 api.terminal.onEvent(onTerminalEvent);
244245 api.orchestration.onDomainEvent(onDomainEvent);
246 api.git.onActionProgress(onActionProgress);
245247
246248 const terminalEvent = {
247249 threadId: "thread-1",
......@@ -274,11 +276,28 @@ describe("wsNativeApi", () => {
274276 },
275277 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>;
276278 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 });
277287
278288 expect(onTerminalEvent).toHaveBeenCalledTimes(1);
279289 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);
280290 expect(onDomainEvent).toHaveBeenCalledTimes(1);
281291 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 });
282301 });
283302
284303 it("wraps orchestration dispatch commands in the command envelope", async () => {
......@@ -320,6 +339,26 @@ describe("wsNativeApi", () => {
320339 });
321340 });
322341
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
323362 it("forwards full-thread diff requests to the orchestration websocket method", async () => {
324363 requestMock.mockResolvedValue({ diff: "patch" });
325364 const { createWsNativeApi } = await import("./wsNativeApi");
apps/web/src/wsNativeApi.ts+20-1
......@@ -1,4 +1,5 @@
11import {
2 type GitActionProgressEvent,
23 ORCHESTRATION_WS_CHANNELS,
34 ORCHESTRATION_WS_METHODS,
45 type ContextMenuItem,
......@@ -15,6 +16,7 @@ import { WsTransport } from "./wsTransport";
1516let instance: { api: NativeApi; transport: WsTransport } | null = null;
1617const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
1718const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
19const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>();
1820
1921/**
2022 * Subscribe to the server welcome message. If a welcome was already received
......@@ -87,6 +89,16 @@ export function createWsNativeApi(): NativeApi {
8789 }
8890 }
8991 });
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 });
90102
91103 const api: NativeApi = {
92104 dialogs: {
......@@ -135,7 +147,8 @@ export function createWsNativeApi(): NativeApi {
135147 git: {
136148 pull: (input) => transport.request(WS_METHODS.gitPull, input),
137149 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 }),
139152 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
140153 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
141154 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
......@@ -145,6 +158,12 @@ export function createWsNativeApi(): NativeApi {
145158 resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input),
146159 preparePullRequestThread: (input) =>
147160 transport.request(WS_METHODS.gitPreparePullRequestThread, input),
161 onActionProgress: (callback) => {
162 gitActionProgressListeners.add(callback);
163 return () => {
164 gitActionProgressListeners.delete(callback);
165 };
166 },
148167 },
149168 contextMenu: {
150169 show: async <T extends string>(
apps/web/src/wsTransport.test.ts+47
......@@ -206,4 +206,51 @@ describe("WsTransport", () => {
206206 await expect(requestPromise).resolves.toEqual({ projects: [] });
207207 transport.dispose();
208208 });
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 });
209256});
apps/web/src/wsTransport.ts+32-8
......@@ -14,13 +14,17 @@ type PushListener<C extends WsPushChannel> = (message: WsPushMessage<C>) => void
1414interface PendingRequest {
1515 resolve: (result: unknown) => void;
1616 reject: (error: Error) => void;
17 timeout: ReturnType<typeof setTimeout>;
17 timeout: ReturnType<typeof setTimeout> | null;
1818}
1919
2020interface SubscribeOptions {
2121 readonly replayLatest?: boolean;
2222}
2323
24interface RequestOptions {
25 readonly timeoutMs?: number | null;
26}
27
2428type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed";
2529
2630const REQUEST_TIMEOUT_MS = 60_000;
......@@ -72,7 +76,11 @@ export class WsTransport {
7276 this.connect();
7377 }
7478
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> {
7684 if (typeof method !== "string" || method.length === 0) {
7785 throw new Error("Request method is required");
7886 }
......@@ -83,10 +91,14 @@ export class WsTransport {
8391 const encoded = JSON.stringify(message);
8492
8593 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);
90102
91103 this.pending.set(id, {
92104 resolve: resolve as (result: unknown) => void,
......@@ -146,7 +158,9 @@ export class WsTransport {
146158 this.reconnectTimer = null;
147159 }
148160 for (const pending of this.pending.values()) {
149 clearTimeout(pending.timeout);
161 if (pending.timeout !== null) {
162 clearTimeout(pending.timeout);
163 }
150164 pending.reject(new Error("Transport disposed"));
151165 }
152166 this.pending.clear();
......@@ -177,6 +191,14 @@ export class WsTransport {
177191 ws.addEventListener("close", () => {
178192 if (this.ws === ws) {
179193 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 }
180202 }
181203 if (this.disposed) {
182204 this.state = "disposed";
......@@ -224,7 +246,9 @@ export class WsTransport {
224246 return;
225247 }
226248
227 clearTimeout(pending.timeout);
249 if (pending.timeout !== null) {
250 clearTimeout(pending.timeout);
251 }
228252 this.pending.delete(message.id);
229253
230254 if (message.error) {
packages/contracts/src/git.test.ts+15
......@@ -4,6 +4,7 @@ import { Schema } from "effect";
44import {
55 GitCreateWorktreeInput,
66 GitPreparePullRequestThreadInput,
7 GitRunStackedActionInput,
78 GitResolvePullRequestResult,
89} from "./git";
910
......@@ -11,6 +12,7 @@ const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInpu
1112const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(
1213 GitPreparePullRequestThreadInput,
1314);
15const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput);
1416const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);
1517
1618describe("GitCreateWorktreeInput", () => {
......@@ -56,3 +58,16 @@ describe("GitResolvePullRequestResult", () => {
5658 expect(parsed.pullRequest.headBranch).toBe("feature/pr-threads");
5759 });
5860});
61
62describe("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;
88
99export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]);
1010export type GitStackedAction = typeof GitStackedAction.Type;
11export const GitActionProgressPhase = Schema.Literals(["branch", "commit", "push", "pr"]);
12export type GitActionProgressPhase = typeof GitActionProgressPhase.Type;
13export 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]);
22export type GitActionProgressKind = typeof GitActionProgressKind.Type;
23export const GitActionProgressStream = Schema.Literals(["stdout", "stderr"]);
24export type GitActionProgressStream = typeof GitActionProgressStream.Type;
1125const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]);
1226const GitPushStepStatus = Schema.Literals([
1327 "pushed",
......@@ -58,6 +72,7 @@ export const GitPullInput = Schema.Struct({
5872export type GitPullInput = typeof GitPullInput.Type;
5973
6074export const GitRunStackedActionInput = Schema.Struct({
75 actionId: TrimmedNonEmptyStringSchema,
6176 cwd: TrimmedNonEmptyStringSchema,
6277 action: GitStackedAction,
6378 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),
......@@ -211,3 +226,62 @@ export const GitPullResult = Schema.Struct({
211226 upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
212227});
213228export type GitPullResult = typeof GitPullResult.Type;
229
230const GitActionProgressBase = Schema.Struct({
231 actionId: TrimmedNonEmptyStringSchema,
232 cwd: TrimmedNonEmptyStringSchema,
233 action: GitStackedAction,
234});
235
236const GitActionStartedEvent = Schema.Struct({
237 ...GitActionProgressBase.fields,
238 kind: Schema.Literal("action_started"),
239 phases: Schema.Array(GitActionProgressPhase),
240});
241const GitActionPhaseStartedEvent = Schema.Struct({
242 ...GitActionProgressBase.fields,
243 kind: Schema.Literal("phase_started"),
244 phase: GitActionProgressPhase,
245 label: TrimmedNonEmptyStringSchema,
246});
247const GitActionHookStartedEvent = Schema.Struct({
248 ...GitActionProgressBase.fields,
249 kind: Schema.Literal("hook_started"),
250 hookName: TrimmedNonEmptyStringSchema,
251});
252const GitActionHookOutputEvent = Schema.Struct({
253 ...GitActionProgressBase.fields,
254 kind: Schema.Literal("hook_output"),
255 hookName: Schema.NullOr(TrimmedNonEmptyStringSchema),
256 stream: GitActionProgressStream,
257 text: TrimmedNonEmptyStringSchema,
258});
259const 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});
266const GitActionFinishedEvent = Schema.Struct({
267 ...GitActionProgressBase.fields,
268 kind: Schema.Literal("action_finished"),
269 result: GitRunStackedActionResult,
270});
271const GitActionFailedEvent = Schema.Struct({
272 ...GitActionProgressBase.fields,
273 kind: Schema.Literal("action_failed"),
274 phase: Schema.NullOr(GitActionProgressPhase),
275 message: TrimmedNonEmptyStringSchema,
276});
277
278export const GitActionProgressEvent = Schema.Union([
279 GitActionStartedEvent,
280 GitActionPhaseStartedEvent,
281 GitActionHookStartedEvent,
282 GitActionHookOutputEvent,
283 GitActionHookFinishedEvent,
284 GitActionFinishedEvent,
285 GitActionFailedEvent,
286]);
287export type GitActionProgressEvent = typeof GitActionProgressEvent.Type;
packages/contracts/src/ipc.ts+2
......@@ -1,5 +1,6 @@
11import type {
22 GitCheckoutInput,
3 GitActionProgressEvent,
34 GitCreateBranchInput,
45 GitPreparePullRequestThreadInput,
56 GitPreparePullRequestThreadResult,
......@@ -149,6 +150,7 @@ export interface NativeApi {
149150 pull: (input: GitPullInput) => Promise<GitPullResult>;
150151 status: (input: GitStatusInput) => Promise<GitStatusResult>;
151152 runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>;
153 onActionProgress: (callback: (event: GitActionProgressEvent) => void) => () => void;
152154 };
153155 contextMenu: {
154156 show: <T extends string>(
packages/contracts/src/ws.test.ts+24
......@@ -95,6 +95,30 @@ it.effect("accepts typed websocket push envelopes with sequence", () =>
9595 }),
9696);
9797
98it.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
98122it.effect("rejects push envelopes when channel payload does not match the channel schema", () =>
99123 Effect.gen(function* () {
100124 const result = yield* Effect.exit(
packages/contracts/src/ws.ts+9
......@@ -12,6 +12,7 @@ import {
1212 OrchestrationReplayEventsInput,
1313} from "./orchestration";
1414import {
15 GitActionProgressEvent,
1516 GitCheckoutInput,
1617 GitCreateBranchInput,
1718 GitPreparePullRequestThreadInput,
......@@ -80,6 +81,7 @@ export const WS_METHODS = {
8081// ── Push Event Channels ──────────────────────────────────────────────
8182
8283export const WS_CHANNELS = {
84 gitActionProgress: "git.actionProgress",
8385 terminalEvent: "terminal.event",
8486 serverWelcome: "server.welcome",
8587 serverConfigUpdated: "server.configUpdated",
......@@ -172,6 +174,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type;
172174export interface WsPushPayloadByChannel {
173175 readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload;
174176 readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type;
177 readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type;
175178 readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type;
176179 readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent;
177180}
......@@ -195,6 +198,10 @@ export const WsPushServerConfigUpdated = makeWsPushSchema(
195198 WS_CHANNELS.serverConfigUpdated,
196199 ServerConfigUpdatedPayload,
197200);
201export const WsPushGitActionProgress = makeWsPushSchema(
202 WS_CHANNELS.gitActionProgress,
203 GitActionProgressEvent,
204);
198205export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent);
199206export const WsPushOrchestrationDomainEvent = makeWsPushSchema(
200207 ORCHESTRATION_WS_CHANNELS.domainEvent,
......@@ -202,6 +209,7 @@ export const WsPushOrchestrationDomainEvent = makeWsPushSchema(
202209);
203210
204211export const WsPushChannelSchema = Schema.Literals([
212 WS_CHANNELS.gitActionProgress,
205213 WS_CHANNELS.serverWelcome,
206214 WS_CHANNELS.serverConfigUpdated,
207215 WS_CHANNELS.terminalEvent,
......@@ -212,6 +220,7 @@ export type WsPushChannelSchema = typeof WsPushChannelSchema.Type;
212220export const WsPush = Schema.Union([
213221 WsPushServerWelcome,
214222 WsPushServerConfigUpdated,
223 WsPushGitActionProgress,
215224 WsPushTerminalEvent,
216225 WsPushOrchestrationDomainEvent,
217226]);