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 {...@@ -8,7 +8,12 @@ import {
8 Layer,8 Layer,
9 Option,9 Option,
10 Path,10 Path,
11 PlatformError,
12 Ref,
13 Result,
11 Schema,14 Schema,
15 Scope,
16 Semaphore,
12 Stream,17 Stream,
13} from "effect";18} from "effect";
14import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";19import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
...@@ -16,11 +21,14 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -16,11 +21,14 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
16import { GitCommandError } from "../Errors.ts";21import { GitCommandError } from "../Errors.ts";
17import {22import {
18 GitCore,23 GitCore,
24 type ExecuteGitProgress,
25 type GitCommitOptions,
19 type GitCoreShape,26 type GitCoreShape,
20 type ExecuteGitInput,27 type ExecuteGitInput,
21 type ExecuteGitResult,28 type ExecuteGitResult,
22} from "../Services/GitCore.ts";29} from "../Services/GitCore.ts";
23import { ServerConfig } from "../../config.ts";30import { ServerConfig } from "../../config.ts";
31import { decodeJsonResult } from "@t3tools/shared/schemaJson";
2432
25const DEFAULT_TIMEOUT_MS = 30_000;33const DEFAULT_TIMEOUT_MS = 30_000;
26const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;34const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
...@@ -29,6 +37,11 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);...@@ -29,6 +37,11 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
29const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;37const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
30const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;38const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
3139
40type TraceTailState = {
41 processedChars: number;
42 remainder: string;
43};
44
32class StatusUpstreamRefreshCacheKey extends Data.Class<{45class StatusUpstreamRefreshCacheKey extends Data.Class<{
33 cwd: string;46 cwd: string;
34 upstreamRef: string;47 upstreamRef: string;
...@@ -40,6 +53,7 @@ interface ExecuteGitOptions {...@@ -40,6 +53,7 @@ interface ExecuteGitOptions {
40 timeoutMs?: number | undefined;53 timeoutMs?: number | undefined;
41 allowNonZeroExit?: boolean | undefined;54 allowNonZeroExit?: boolean | undefined;
42 fallbackErrorMessage?: string | undefined;55 fallbackErrorMessage?: string | undefined;
56 progress?: ExecuteGitProgress | undefined;
43}57}
4458
45function parseBranchAb(value: string): { ahead: number; behind: number } {59function parseBranchAb(value: string): { ahead: number; behind: number } {
...@@ -257,14 +271,201 @@ function toGitCommandError(...@@ -257,14 +271,201 @@ function toGitCommandError(
257 });271 });
258}272}
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
260const collectOutput = Effect.fn(function* <E>(438const collectOutput = Effect.fn(function* <E>(
261 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,439 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
262 stream: Stream.Stream<Uint8Array, E>,440 stream: Stream.Stream<Uint8Array, E>,
263 maxOutputBytes: number,441 maxOutputBytes: number,
442 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,
264): Effect.fn.Return<string, GitCommandError> {443): Effect.fn.Return<string, GitCommandError> {
265 const decoder = new TextDecoder();444 const decoder = new TextDecoder();
266 let bytes = 0;445 let bytes = 0;
267 let text = "";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 });
268469
269 yield* Stream.runForEach(stream, (chunk) =>470 yield* Stream.runForEach(stream, (chunk) =>
270 Effect.gen(function* () {471 Effect.gen(function* () {
...@@ -277,11 +478,17 @@ const collectOutput = Effect.fn(function* <E>(...@@ -277,11 +478,17 @@ const collectOutput = Effect.fn(function* <E>(
277 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,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 ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed.")));486 ).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);
285 return text;492 return text;
286});493});
287494
...@@ -306,19 +513,38 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"...@@ -306,19 +513,38 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
306 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;513 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
307514
308 const commandEffect = Effect.gen(function* () {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 const child = yield* commandSpawner521 const child = yield* commandSpawner
310 .spawn(522 .spawn(
311 ChildProcess.make("git", commandInput.args, {523 ChildProcess.make("git", commandInput.args, {
312 cwd: commandInput.cwd,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 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));532 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));
317533
318 const [stdout, stderr, exitCode] = yield* Effect.all(534 const [stdout, stderr, exitCode] = yield* Effect.all(
319 [535 [
320 collectOutput(commandInput, child.stdout, maxOutputBytes),536 collectOutput(
321 collectOutput(commandInput, child.stderr, maxOutputBytes),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 child.exitCode.pipe(548 child.exitCode.pipe(
323 Effect.map((value) => Number(value)),549 Effect.map((value) => Number(value)),
324 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),550 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),
...@@ -326,6 +552,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"...@@ -326,6 +552,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
326 ],552 ],
327 { concurrency: "unbounded" },553 { concurrency: "unbounded" },
328 );554 );
555 yield* trace2Monitor.flush;
329556
330 if (!input.allowNonZeroExit && exitCode !== 0) {557 if (!input.allowNonZeroExit && exitCode !== 0) {
331 const trimmedStderr = stderr.trim();558 const trimmedStderr = stderr.trim();
...@@ -376,6 +603,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"...@@ -376,6 +603,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
376 args,603 args,
377 allowNonZeroExit: true,604 allowNonZeroExit: true,
378 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),605 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
606 ...(options.progress ? { progress: options.progress } : {}),
379 }).pipe(607 }).pipe(
380 Effect.flatMap((result) => {608 Effect.flatMap((result) => {
381 if (options.allowNonZeroExit || result.code === 0) {609 if (options.allowNonZeroExit || result.code === 0) {
...@@ -947,14 +1175,37 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"...@@ -947,14 +1175,37 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
947 };1175 };
948 });1176 });
9491177
950 const commit: GitCoreShape["commit"] = (cwd, subject, body) =>1178 const commit: GitCoreShape["commit"] = (cwd, subject, body, options?: GitCommitOptions) =>
951 Effect.gen(function* () {1179 Effect.gen(function* () {
952 const args = ["commit", "-m", subject];1180 const args = ["commit", "-m", subject];
953 const trimmedBody = body.trim();1181 const trimmedBody = body.trim();
954 if (trimmedBody.length > 0) {1182 if (trimmedBody.length > 0) {
955 args.push("-m", trimmedBody);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 const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [1209 const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [
959 "rev-parse",1210 "rev-parse",
960 "HEAD",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 +6,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
6import { it } from "@effect/vitest";6import { it } from "@effect/vitest";
7import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";7import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
8import { expect } from "vitest";8import { expect } from "vitest";
9import type { GitActionProgressEvent } from "@t3tools/contracts";
910
10import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";11import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";
11import { type GitManagerShape } from "../Services/GitManager.ts";12import { type GitManagerShape } from "../Services/GitManager.ts";
...@@ -449,12 +450,20 @@ function runStackedAction(...@@ -449,12 +450,20 @@ function runStackedAction(
449 input: {450 input: {
450 cwd: string;451 cwd: string;
451 action: "commit" | "commit_push" | "commit_push_pr";452 action: "commit" | "commit_push" | "commit_push_pr";
453 actionId?: string;
452 commitMessage?: string;454 commitMessage?: string;
453 featureBranch?: boolean;455 featureBranch?: boolean;
454 filePaths?: readonly string[];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}
459468
460function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) {469function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) {
...@@ -1941,4 +1950,117 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1941,4 +1950,117 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1941 expect(errorMessage).toContain("already checked out in the main repo");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,6 +2,7 @@ import { randomUUID } from "node:crypto";
2import { realpathSync } from "node:fs";2import { realpathSync } from "node:fs";
33
4import { Effect, FileSystem, Layer, Path } from "effect";4import { Effect, FileSystem, Layer, Path } from "effect";
5import type { GitActionProgressEvent, GitActionProgressPhase } from "@t3tools/contracts";
5import {6import {
6 resolveAutoFeatureBranchName,7 resolveAutoFeatureBranchName,
7 sanitizeBranchFragment,8 sanitizeBranchFragment,
...@@ -9,11 +10,21 @@ import {...@@ -9,11 +10,21 @@ import {
9} from "@t3tools/shared/git";10} from "@t3tools/shared/git";
1011
11import { GitManagerError } from "../Errors.ts";12import { 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";
13import { GitCore } from "../Services/GitCore.ts";19import { GitCore } from "../Services/GitCore.ts";
14import { GitHubCli } from "../Services/GitHubCli.ts";20import { GitHubCli } from "../Services/GitHubCli.ts";
15import { TextGeneration } from "../Services/TextGeneration.ts";21import { 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
17interface OpenPrInfo {28interface OpenPrInfo {
18 number: number;29 number: number;
19 title: string;30 title: string;
...@@ -200,6 +211,17 @@ function sanitizeCommitMessage(generated: {...@@ -200,6 +211,17 @@ function sanitizeCommitMessage(generated: {
200 };211 };
201}212}
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
203interface CommitAndBranchSuggestion {225interface CommitAndBranchSuggestion {
204 subject: string;226 subject: string;
205 body: string;227 body: string;
...@@ -337,6 +359,29 @@ export const makeGitManager = Effect.gen(function* () {...@@ -337,6 +359,29 @@ export const makeGitManager = Effect.gen(function* () {
337 const gitHubCli = yield* GitHubCli;359 const gitHubCli = yield* GitHubCli;
338 const textGeneration = yield* TextGeneration;360 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
340 const configurePullRequestHeadUpstream = (385 const configurePullRequestHeadUpstream = (
341 cwd: string,386 cwd: string,
342 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,387 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
...@@ -680,27 +725,111 @@ export const makeGitManager = Effect.gen(function* () {...@@ -680,27 +725,111 @@ export const makeGitManager = Effect.gen(function* () {
680725
681 const runCommitStep = (726 const runCommitStep = (
682 cwd: string,727 cwd: string,
728 action: "commit" | "commit_push" | "commit_push_pr",
683 branch: string | null,729 branch: string | null,
684 commitMessage?: string,730 commitMessage?: string,
685 preResolvedSuggestion?: CommitAndBranchSuggestion,731 preResolvedSuggestion?: CommitAndBranchSuggestion,
686 filePaths?: readonly string[],732 filePaths?: readonly string[],
687 model?: string,733 model?: string,
734 progressReporter?: GitActionProgressReporter,
735 actionId?: string,
688 ) =>736 ) =>
689 Effect.gen(function* () {737 Effect.gen(function* () {
690 const suggestion =738 const emit = (event: GitActionProgressPayload) =>
691 preResolvedSuggestion ??739 progressReporter && actionId
692 (yield* resolveCommitAndBranchSuggestion({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 cwd,759 cwd,
694 branch,760 branch,
695 ...(commitMessage ? { commitMessage } : {}),761 ...(commitMessage ? { commitMessage } : {}),
696 ...(filePaths ? { filePaths } : {}),762 ...(filePaths ? { filePaths } : {}),
697 ...(model ? { model } : {}),763 ...(model ? { model } : {}),
698 }));764 });
765 }
699 if (!suggestion) {766 if (!suggestion) {
700 return { status: "skipped_no_changes" as const };767 return { status: "skipped_no_changes" as const };
701 }768 }
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 }
704 return {833 return {
705 status: "created" as const,834 status: "created" as const,
706 commitSha,835 commitSha,
...@@ -1010,66 +1139,135 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1010,66 +1139,135 @@ export const makeGitManager = Effect.gen(function* () {
1010 });1139 });
10111140
1012 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(1141 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(
1013 function* (input) {1142 function* (input, options) {
1014 const wantsPush = input.action !== "commit";1143 const progress = createProgressEmitter(input, options);
1015 const wantsPr = input.action === "commit_push_pr";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);1158 const wantsPush = input.action !== "commit";
1018 if (!input.featureBranch && wantsPush && !initialStatus.branch) {1159 const wantsPr = input.action === "commit_push_pr";
1019 return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");1160
1020 }1161 const initialStatus = yield* gitCore.statusDetails(input.cwd);
1021 if (!input.featureBranch && wantsPr && !initialStatus.branch) {1162 if (!input.featureBranch && wantsPush && !initialStatus.branch) {
1022 return yield* gitManagerError(1163 return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");
1023 "runStackedAction",1164 }
1024 "Cannot create a pull request from detached HEAD.",1165 if (!input.featureBranch && wantsPr && !initialStatus.branch) {
1025 );1166 return yield* gitManagerError(
1026 }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 };1197 const currentBranch = branchStep.name ?? initialStatus.branch;
1029 let commitMessageForStep = input.commitMessage;
1030 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
10311198
1032 if (input.featureBranch) {1199 currentPhase = "commit";
1033 const result = yield* runFeatureBranchStep(1200 const commit = yield* runCommitStep(
1034 input.cwd,1201 input.cwd,
1035 initialStatus.branch,1202 input.action,
1036 input.commitMessage,1203 currentBranch,
1204 commitMessageForStep,
1205 preResolvedCommitSuggestion,
1037 input.filePaths,1206 input.filePaths,
1038 input.textGenerationModel,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 }
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(1260 return yield* runAction.pipe(
1050 input.cwd,1261 Effect.catch((error) =>
1051 currentBranch,1262 progress
1052 commitMessageForStep,1263 .emit({
1053 preResolvedCommitSuggestion,1264 kind: "action_failed",
1054 input.filePaths,1265 phase: currentPhase,
1055 input.textGenerationModel,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 );
10751273
apps/server/src/git/Services/GitCore.ts+31
...@@ -32,6 +32,7 @@ export interface ExecuteGitInput {...@@ -32,6 +32,7 @@ export interface ExecuteGitInput {
32 readonly allowNonZeroExit?: boolean;32 readonly allowNonZeroExit?: boolean;
33 readonly timeoutMs?: number;33 readonly timeoutMs?: number;
34 readonly maxOutputBytes?: number;34 readonly maxOutputBytes?: number;
35 readonly progress?: ExecuteGitProgress;
35}36}
3637
37export interface ExecuteGitResult {38export interface ExecuteGitResult {
...@@ -49,6 +50,35 @@ export interface GitPreparedCommitContext {...@@ -49,6 +50,35 @@ export interface GitPreparedCommitContext {
49 stagedPatch: string;50 stagedPatch: string;
50}51}
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
52export interface GitPushResult {82export interface GitPushResult {
53 status: "pushed" | "skipped_up_to_date";83 status: "pushed" | "skipped_up_to_date";
54 branch: string;84 branch: string;
...@@ -132,6 +162,7 @@ export interface GitCoreShape {...@@ -132,6 +162,7 @@ export interface GitCoreShape {
132 cwd: string,162 cwd: string,
133 subject: string,163 subject: string,
134 body: string,164 body: string,
165 options?: GitCommitOptions,
135 ) => Effect.Effect<{ commitSha: string }, GitCommandError>;166 ) => Effect.Effect<{ commitSha: string }, GitCommandError>;
136167
137 /**168 /**
apps/server/src/git/Services/GitManager.ts+11
...@@ -7,6 +7,7 @@...@@ -7,6 +7,7 @@
7 * @module GitManager7 * @module GitManager
8 */8 */
9import {9import {
10 GitActionProgressEvent,
10 GitPreparePullRequestThreadInput,11 GitPreparePullRequestThreadInput,
11 GitPreparePullRequestThreadResult,12 GitPreparePullRequestThreadResult,
12 GitPullRequestRefInput,13 GitPullRequestRefInput,
...@@ -20,6 +21,15 @@ import { ServiceMap } from "effect";...@@ -20,6 +21,15 @@ import { ServiceMap } from "effect";
20import type { Effect } from "effect";21import type { Effect } from "effect";
21import type { GitManagerServiceError } from "../Errors.ts";22import 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
23/**33/**
24 * GitManagerShape - Service API for high-level Git workflow actions.34 * GitManagerShape - Service API for high-level Git workflow actions.
25 */35 */
...@@ -51,6 +61,7 @@ export interface GitManagerShape {...@@ -51,6 +61,7 @@ export interface GitManagerShape {
51 */61 */
52 readonly runStackedAction: (62 readonly runStackedAction: (
53 input: GitRunStackedActionInput,63 input: GitRunStackedActionInput,
64 options?: GitRunStackedActionOptions,
54 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;65 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
55}66}
5667
apps/server/src/wsServer.test.ts+81-2
...@@ -1811,15 +1811,94 @@ describe("WebSocket Server", () => {...@@ -1811,15 +1811,94 @@ describe("WebSocket Server", () => {
1811 connections.push(ws);1811 connections.push(ws);
18121812
1813 const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, {1813 const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, {
1814 actionId: "client-action-1",
1814 cwd: "/test",1815 cwd: "/test",
1815 action: "commit_push",1816 action: "commit_push",
1816 });1817 });
1817 expect(response.result).toBeUndefined();1818 expect(response.result).toBeUndefined();
1818 expect(response.error?.message).toContain("detached HEAD");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 cwd: "/test",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 });
18241903
1825 it("rejects websocket connections without a valid auth token", async () => {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,7 +704,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
704 Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]),704 Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]),
705 );705 );
706706
707 const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) {707 const routeRequest = Effect.fnUntraced(function* (ws: WebSocket, request: WebSocketRequest) {
708 switch (request.body._tag) {708 switch (request.body._tag) {
709 case ORCHESTRATION_WS_METHODS.getSnapshot:709 case ORCHESTRATION_WS_METHODS.getSnapshot:
710 return yield* projectionReadModelQuery.getSnapshot();710 return yield* projectionReadModelQuery.getSnapshot();
...@@ -793,7 +793,13 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -793,7 +793,13 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
793793
794 case WS_METHODS.gitRunStackedAction: {794 case WS_METHODS.gitRunStackedAction: {
795 const body = stripRequestTag(request.body);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 }
798804
799 case WS_METHODS.gitResolvePullRequest: {805 case WS_METHODS.gitResolvePullRequest: {
...@@ -915,7 +921,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -915,7 +921,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
915 });921 });
916 }922 }
917923
918 const result = yield* Effect.exit(routeRequest(request.success));924 const result = yield* Effect.exit(routeRequest(ws, request.success));
919 if (Exit.isFailure(result)) {925 if (Exit.isFailure(result)) {
920 return yield* sendWsResponse({926 return yield* sendWsResponse({
921 id: request.success.id,927 id: request.success.id,
apps/web/src/components/GitActionsControl.tsx+176-72
...@@ -1,6 +1,11 @@...@@ -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";
2import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";7import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useCallback, useEffect, useMemo, useState } from "react";8import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
4import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";9import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
5import { GitHubIcon } from "./Icons";10import { GitHubIcon } from "./Icons";
6import {11import {
...@@ -43,6 +48,7 @@ import {...@@ -43,6 +48,7 @@ import {
43 gitStatusQueryOptions,48 gitStatusQueryOptions,
44 invalidateGitQueries,49 invalidateGitQueries,
45} from "~/lib/gitReactQuery";50} from "~/lib/gitReactQuery";
51import { randomUUID } from "~/lib/utils";
46import { resolvePathLinkTarget } from "~/terminal-links";52import { resolvePathLinkTarget } from "~/terminal-links";
47import { readNativeApi } from "~/nativeApi";53import { readNativeApi } from "~/nativeApi";
4854
...@@ -63,6 +69,50 @@ interface PendingDefaultBranchAction {...@@ -63,6 +69,50 @@ interface PendingDefaultBranchAction {
6369
64type GitActionToastId = ReturnType<typeof toastManager.add>;70type 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
66function getMenuActionDisabledReason({116function getMenuActionDisabledReason({
67 item,117 item,
68 gitStatus,118 gitStatus,
...@@ -167,6 +217,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -167,6 +217,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
167 const [isEditingFiles, setIsEditingFiles] = useState(false);217 const [isEditingFiles, setIsEditingFiles] = useState(false);
168 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =218 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
169 useState<PendingDefaultBranchAction | null>(null);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]);
170235
171 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));236 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
172237
...@@ -232,6 +297,84 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -232,6 +297,84 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
232 })297 })
233 : null;298 : 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
235 const openExistingPr = useCallback(async () => {378 const openExistingPr = useCallback(async () => {
236 const api = readNativeApi();379 const api = readNativeApi();
237 if (!api) {380 if (!api) {
...@@ -259,9 +402,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -259,9 +402,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
259 data: threadToastData,402 data: threadToastData,
260 });403 });
261 });404 });
262 }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]);405 }, [gitStatusForActions, threadToastData]);
263406
264 const runGitActionWithToast = useCallback(407 const runGitActionWithToast = useEffectEvent(
265 async ({408 async ({
266 action,409 action,
267 commitMessage,410 commitMessage,
...@@ -273,18 +416,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -273,18 +416,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
273 isDefaultBranchOverride,416 isDefaultBranchOverride,
274 progressToastId,417 progressToastId,
275 filePaths,418 filePaths,
276 }: {419 }: RunGitActionWithToastInput) => {
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 }) => {
288 const actionStatus = statusOverride ?? gitStatusForActions;420 const actionStatus = statusOverride ?? gitStatusForActions;
289 const actionBranch = actionStatus?.branch ?? null;421 const actionBranch = actionStatus?.branch ?? null;
290 const actionIsDefaultBranch =422 const actionIsDefaultBranch =
...@@ -319,40 +451,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -319,40 +451,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
319 forcePushOnly: forcePushOnlyProgress,451 forcePushOnly: forcePushOnlyProgress,
320 featureBranch,452 featureBranch,
321 });453 });
454 const actionId = randomUUID();
322 const resolvedProgressToastId =455 const resolvedProgressToastId =
323 progressToastId ??456 progressToastId ??
324 toastManager.add({457 toastManager.add({
325 type: "loading",458 type: "loading",
326 title: progressStages[0] ?? "Running git action...",459 title: progressStages[0] ?? "Running git action...",
460 description: "Waiting for Git...",
327 timeout: 0,461 timeout: 0,
328 data: threadToastData,462 data: threadToastData,
329 });463 });
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
331 if (progressToastId) {476 if (progressToastId) {
332 toastManager.update(progressToastId, {477 toastManager.update(progressToastId, {
333 type: "loading",478 type: "loading",
334 title: progressStages[0] ?? "Running git action...",479 title: progressStages[0] ?? "Running git action...",
480 description: "Waiting for Git...",
335 timeout: 0,481 timeout: 0,
336 data: threadToastData,482 data: threadToastData,
337 });483 });
338 }484 }
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
355 const promise = runImmediateGitActionMutation.mutateAsync({486 const promise = runImmediateGitActionMutation.mutateAsync({
487 actionId,
356 action,488 action,
357 ...(commitMessage ? { commitMessage } : {}),489 ...(commitMessage ? { commitMessage } : {}),
358 ...(featureBranch ? { featureBranch } : {}),490 ...(featureBranch ? { featureBranch } : {}),
...@@ -361,7 +493,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -361,7 +493,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
361493
362 try {494 try {
363 const result = await promise;495 const result = await promise;
364 stopProgressUpdates();496 activeGitActionProgressRef.current = null;
365 const resultToast = summarizeGitResult(result);497 const resultToast = summarizeGitResult(result);
366498
367 const existingOpenPrUrl =499 const existingOpenPrUrl =
...@@ -437,7 +569,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -437,7 +569,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
437 : {}),569 : {}),
438 });570 });
439 } catch (err) {571 } catch (err) {
440 stopProgressUpdates();572 activeGitActionProgressRef.current = null;
441 toastManager.update(resolvedProgressToastId, {573 toastManager.update(resolvedProgressToastId, {
442 type: "error",574 type: "error",
443 title: "Action failed",575 title: "Action failed",
...@@ -446,14 +578,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -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 );
458582
459 const continuePendingDefaultBranchAction = useCallback(() => {583 const continuePendingDefaultBranchAction = useCallback(() => {
...@@ -469,38 +593,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -469,38 +593,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
469 ...(filePaths ? { filePaths } : {}),593 ...(filePaths ? { filePaths } : {}),
470 skipDefaultBranchPrompt: true,594 skipDefaultBranchPrompt: true,
471 });595 });
472 }, [pendingDefaultBranchAction, runGitActionWithToast]);596 }, [pendingDefaultBranchAction]);
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 );
490597
491 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {598 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
492 if (!pendingDefaultBranchAction) return;599 if (!pendingDefaultBranchAction) return;
493 const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =600 const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =
494 pendingDefaultBranchAction;601 pendingDefaultBranchAction;
495 setPendingDefaultBranchAction(null);602 setPendingDefaultBranchAction(null);
496 checkoutNewBranchAndRunAction({603 void runGitActionWithToast({
497 action,604 action,
498 ...(commitMessage ? { commitMessage } : {}),605 ...(commitMessage ? { commitMessage } : {}),
499 forcePushOnlyProgress,606 forcePushOnlyProgress,
500 ...(onConfirmed ? { onConfirmed } : {}),607 ...(onConfirmed ? { onConfirmed } : {}),
501 ...(filePaths ? { filePaths } : {}),608 ...(filePaths ? { filePaths } : {}),
609 featureBranch: true,
610 skipDefaultBranchPrompt: true,
502 });611 });
503 }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]);612 }, [pendingDefaultBranchAction]);
504613
505 const runDialogActionOnNewBranch = useCallback(() => {614 const runDialogActionOnNewBranch = useCallback(() => {
506 if (!isCommitDialogOpen) return;615 if (!isCommitDialogOpen) return;
...@@ -511,18 +620,14 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -511,18 +620,14 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
511 setExcludedFiles(new Set());620 setExcludedFiles(new Set());
512 setIsEditingFiles(false);621 setIsEditingFiles(false);
513622
514 checkoutNewBranchAndRunAction({623 void runGitActionWithToast({
515 action: "commit",624 action: "commit",
516 ...(commitMessage ? { commitMessage } : {}),625 ...(commitMessage ? { commitMessage } : {}),
517 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),626 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),
627 featureBranch: true,
628 skipDefaultBranchPrompt: true,
518 });629 });
519 }, [630 }, [allSelected, isCommitDialogOpen, dialogCommitMessage, selectedFiles]);
520 allSelected,
521 isCommitDialogOpen,
522 dialogCommitMessage,
523 checkoutNewBranchAndRunAction,
524 selectedFiles,
525 ]);
526631
527 const runQuickAction = useCallback(() => {632 const runQuickAction = useCallback(() => {
528 if (quickAction.kind === "open_pr") {633 if (quickAction.kind === "open_pr") {
...@@ -562,7 +667,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -562,7 +667,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
562 if (quickAction.action) {667 if (quickAction.action) {
563 void runGitActionWithToast({ action: quickAction.action });668 void runGitActionWithToast({ action: quickAction.action });
564 }669 }
565 }, [openExistingPr, pullMutation, quickAction, runGitActionWithToast, threadToastData]);670 }, [openExistingPr, pullMutation, quickAction, threadToastData]);
566671
567 const openDialogForMenuItem = useCallback(672 const openDialogForMenuItem = useCallback(
568 (item: GitActionMenuItem) => {673 (item: GitActionMenuItem) => {
...@@ -583,7 +688,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -583,7 +688,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
583 setIsEditingFiles(false);688 setIsEditingFiles(false);
584 setIsCommitDialogOpen(true);689 setIsCommitDialogOpen(true);
585 },690 },
586 [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen],691 [openExistingPr, setIsCommitDialogOpen],
587 );692 );
588693
589 const runDialogAction = useCallback(() => {694 const runDialogAction = useCallback(() => {
...@@ -602,7 +707,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -602,7 +707,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
602 allSelected,707 allSelected,
603 dialogCommitMessage,708 dialogCommitMessage,
604 isCommitDialogOpen,709 isCommitDialogOpen,
605 runGitActionWithToast,
606 selectedFiles,710 selectedFiles,
607 setDialogCommitMessage,711 setDialogCommitMessage,
608 setIsCommitDialogOpen,712 setIsCommitDialogOpen,
apps/web/src/lib/gitReactQuery.ts+3
...@@ -117,11 +117,13 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -117,11 +117,13 @@ export function gitRunStackedActionMutationOptions(input: {
117 return mutationOptions({117 return mutationOptions({
118 mutationKey: gitMutationKeys.runStackedAction(input.cwd),118 mutationKey: gitMutationKeys.runStackedAction(input.cwd),
119 mutationFn: async ({119 mutationFn: async ({
120 actionId,
120 action,121 action,
121 commitMessage,122 commitMessage,
122 featureBranch,123 featureBranch,
123 filePaths,124 filePaths,
124 }: {125 }: {
126 actionId: string;
125 action: GitStackedAction;127 action: GitStackedAction;
126 commitMessage?: string;128 commitMessage?: string;
127 featureBranch?: boolean;129 featureBranch?: boolean;
...@@ -130,6 +132,7 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -130,6 +132,7 @@ export function gitRunStackedActionMutationOptions(input: {
130 const api = ensureNativeApi();132 const api = ensureNativeApi();
131 if (!input.cwd) throw new Error("Git action is unavailable.");133 if (!input.cwd) throw new Error("Git action is unavailable.");
132 return api.git.runStackedAction({134 return api.git.runStackedAction({
135 actionId,
133 cwd: input.cwd,136 cwd: input.cwd,
134 action,137 action,
135 ...(commitMessage ? { commitMessage } : {}),138 ...(commitMessage ? { commitMessage } : {}),
apps/web/src/wsNativeApi.test.ts+39
...@@ -239,9 +239,11 @@ describe("wsNativeApi", () => {...@@ -239,9 +239,11 @@ describe("wsNativeApi", () => {
239 const api = createWsNativeApi();239 const api = createWsNativeApi();
240 const onTerminalEvent = vi.fn();240 const onTerminalEvent = vi.fn();
241 const onDomainEvent = vi.fn();241 const onDomainEvent = vi.fn();
242 const onActionProgress = vi.fn();
242243
243 api.terminal.onEvent(onTerminalEvent);244 api.terminal.onEvent(onTerminalEvent);
244 api.orchestration.onDomainEvent(onDomainEvent);245 api.orchestration.onDomainEvent(onDomainEvent);
246 api.git.onActionProgress(onActionProgress);
245247
246 const terminalEvent = {248 const terminalEvent = {
247 threadId: "thread-1",249 threadId: "thread-1",
...@@ -274,11 +276,28 @@ describe("wsNativeApi", () => {...@@ -274,11 +276,28 @@ describe("wsNativeApi", () => {
274 },276 },
275 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>;277 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>;
276 emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent);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 });
277287
278 expect(onTerminalEvent).toHaveBeenCalledTimes(1);288 expect(onTerminalEvent).toHaveBeenCalledTimes(1);
279 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);289 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);
280 expect(onDomainEvent).toHaveBeenCalledTimes(1);290 expect(onDomainEvent).toHaveBeenCalledTimes(1);
281 expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);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 });
283302
284 it("wraps orchestration dispatch commands in the command envelope", async () => {303 it("wraps orchestration dispatch commands in the command envelope", async () => {
...@@ -320,6 +339,26 @@ describe("wsNativeApi", () => {...@@ -320,6 +339,26 @@ describe("wsNativeApi", () => {
320 });339 });
321 });340 });
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
323 it("forwards full-thread diff requests to the orchestration websocket method", async () => {362 it("forwards full-thread diff requests to the orchestration websocket method", async () => {
324 requestMock.mockResolvedValue({ diff: "patch" });363 requestMock.mockResolvedValue({ diff: "patch" });
325 const { createWsNativeApi } = await import("./wsNativeApi");364 const { createWsNativeApi } = await import("./wsNativeApi");
apps/web/src/wsNativeApi.ts+20-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 type GitActionProgressEvent,
2 ORCHESTRATION_WS_CHANNELS,3 ORCHESTRATION_WS_CHANNELS,
3 ORCHESTRATION_WS_METHODS,4 ORCHESTRATION_WS_METHODS,
4 type ContextMenuItem,5 type ContextMenuItem,
...@@ -15,6 +16,7 @@ import { WsTransport } from "./wsTransport";...@@ -15,6 +16,7 @@ import { WsTransport } from "./wsTransport";
15let instance: { api: NativeApi; transport: WsTransport } | null = null;16let instance: { api: NativeApi; transport: WsTransport } | null = null;
16const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();17const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
17const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();18const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
19const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>();
1820
19/**21/**
20 * Subscribe to the server welcome message. If a welcome was already received22 * Subscribe to the server welcome message. If a welcome was already received
...@@ -87,6 +89,16 @@ export function createWsNativeApi(): NativeApi {...@@ -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 });
90102
91 const api: NativeApi = {103 const api: NativeApi = {
92 dialogs: {104 dialogs: {
...@@ -135,7 +147,8 @@ export function createWsNativeApi(): NativeApi {...@@ -135,7 +147,8 @@ export function createWsNativeApi(): NativeApi {
135 git: {147 git: {
136 pull: (input) => transport.request(WS_METHODS.gitPull, input),148 pull: (input) => transport.request(WS_METHODS.gitPull, input),
137 status: (input) => transport.request(WS_METHODS.gitStatus, input),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 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),152 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
140 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),153 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
141 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),154 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
...@@ -145,6 +158,12 @@ export function createWsNativeApi(): NativeApi {...@@ -145,6 +158,12 @@ export function createWsNativeApi(): NativeApi {
145 resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input),158 resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input),
146 preparePullRequestThread: (input) =>159 preparePullRequestThread: (input) =>
147 transport.request(WS_METHODS.gitPreparePullRequestThread, input),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 contextMenu: {168 contextMenu: {
150 show: async <T extends string>(169 show: async <T extends string>(
apps/web/src/wsTransport.test.ts+47
...@@ -206,4 +206,51 @@ describe("WsTransport", () => {...@@ -206,4 +206,51 @@ describe("WsTransport", () => {
206 await expect(requestPromise).resolves.toEqual({ projects: [] });206 await expect(requestPromise).resolves.toEqual({ projects: [] });
207 transport.dispose();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,13 +14,17 @@ type PushListener<C extends WsPushChannel> = (message: WsPushMessage<C>) => void
14interface PendingRequest {14interface PendingRequest {
15 resolve: (result: unknown) => void;15 resolve: (result: unknown) => void;
16 reject: (error: Error) => void;16 reject: (error: Error) => void;
17 timeout: ReturnType<typeof setTimeout>;17 timeout: ReturnType<typeof setTimeout> | null;
18}18}
1919
20interface SubscribeOptions {20interface SubscribeOptions {
21 readonly replayLatest?: boolean;21 readonly replayLatest?: boolean;
22}22}
2323
24interface RequestOptions {
25 readonly timeoutMs?: number | null;
26}
27
24type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed";28type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed";
2529
26const REQUEST_TIMEOUT_MS = 60_000;30const REQUEST_TIMEOUT_MS = 60_000;
...@@ -72,7 +76,11 @@ export class WsTransport {...@@ -72,7 +76,11 @@ export class WsTransport {
72 this.connect();76 this.connect();
73 }77 }
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> {
76 if (typeof method !== "string" || method.length === 0) {84 if (typeof method !== "string" || method.length === 0) {
77 throw new Error("Request method is required");85 throw new Error("Request method is required");
78 }86 }
...@@ -83,10 +91,14 @@ export class WsTransport {...@@ -83,10 +91,14 @@ export class WsTransport {
83 const encoded = JSON.stringify(message);91 const encoded = JSON.stringify(message);
8492
85 return new Promise<T>((resolve, reject) => {93 return new Promise<T>((resolve, reject) => {
86 const timeout = setTimeout(() => {94 const timeoutMs = options?.timeoutMs === undefined ? REQUEST_TIMEOUT_MS : options.timeoutMs;
87 this.pending.delete(id);95 const timeout =
88 reject(new Error(`Request timed out: ${method}`));96 timeoutMs === null
89 }, REQUEST_TIMEOUT_MS);97 ? null
98 : setTimeout(() => {
99 this.pending.delete(id);
100 reject(new Error(`Request timed out: ${method}`));
101 }, timeoutMs);
90102
91 this.pending.set(id, {103 this.pending.set(id, {
92 resolve: resolve as (result: unknown) => void,104 resolve: resolve as (result: unknown) => void,
...@@ -146,7 +158,9 @@ export class WsTransport {...@@ -146,7 +158,9 @@ export class WsTransport {
146 this.reconnectTimer = null;158 this.reconnectTimer = null;
147 }159 }
148 for (const pending of this.pending.values()) {160 for (const pending of this.pending.values()) {
149 clearTimeout(pending.timeout);161 if (pending.timeout !== null) {
162 clearTimeout(pending.timeout);
163 }
150 pending.reject(new Error("Transport disposed"));164 pending.reject(new Error("Transport disposed"));
151 }165 }
152 this.pending.clear();166 this.pending.clear();
...@@ -177,6 +191,14 @@ export class WsTransport {...@@ -177,6 +191,14 @@ export class WsTransport {
177 ws.addEventListener("close", () => {191 ws.addEventListener("close", () => {
178 if (this.ws === ws) {192 if (this.ws === ws) {
179 this.ws = null;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 if (this.disposed) {203 if (this.disposed) {
182 this.state = "disposed";204 this.state = "disposed";
...@@ -224,7 +246,9 @@ export class WsTransport {...@@ -224,7 +246,9 @@ export class WsTransport {
224 return;246 return;
225 }247 }
226248
227 clearTimeout(pending.timeout);249 if (pending.timeout !== null) {
250 clearTimeout(pending.timeout);
251 }
228 this.pending.delete(message.id);252 this.pending.delete(message.id);
229253
230 if (message.error) {254 if (message.error) {
packages/contracts/src/git.test.ts+15
...@@ -4,6 +4,7 @@ import { Schema } from "effect";...@@ -4,6 +4,7 @@ import { Schema } from "effect";
4import {4import {
5 GitCreateWorktreeInput,5 GitCreateWorktreeInput,
6 GitPreparePullRequestThreadInput,6 GitPreparePullRequestThreadInput,
7 GitRunStackedActionInput,
7 GitResolvePullRequestResult,8 GitResolvePullRequestResult,
8} from "./git";9} from "./git";
910
...@@ -11,6 +12,7 @@ const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInpu...@@ -11,6 +12,7 @@ const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInpu
11const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(12const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(
12 GitPreparePullRequestThreadInput,13 GitPreparePullRequestThreadInput,
13);14);
15const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput);
14const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);16const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);
1517
16describe("GitCreateWorktreeInput", () => {18describe("GitCreateWorktreeInput", () => {
...@@ -56,3 +58,16 @@ describe("GitResolvePullRequestResult", () => {...@@ -56,3 +58,16 @@ describe("GitResolvePullRequestResult", () => {
56 expect(parsed.pullRequest.headBranch).toBe("feature/pr-threads");58 expect(parsed.pullRequest.headBranch).toBe("feature/pr-threads");
57 });59 });
58});60});
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;...@@ -8,6 +8,20 @@ const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
88
9export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]);9export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]);
10export type GitStackedAction = typeof GitStackedAction.Type;10export 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;
11const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]);25const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]);
12const GitPushStepStatus = Schema.Literals([26const GitPushStepStatus = Schema.Literals([
13 "pushed",27 "pushed",
...@@ -58,6 +72,7 @@ export const GitPullInput = Schema.Struct({...@@ -58,6 +72,7 @@ export const GitPullInput = Schema.Struct({
58export type GitPullInput = typeof GitPullInput.Type;72export type GitPullInput = typeof GitPullInput.Type;
5973
60export const GitRunStackedActionInput = Schema.Struct({74export const GitRunStackedActionInput = Schema.Struct({
75 actionId: TrimmedNonEmptyStringSchema,
61 cwd: TrimmedNonEmptyStringSchema,76 cwd: TrimmedNonEmptyStringSchema,
62 action: GitStackedAction,77 action: GitStackedAction,
63 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),78 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),
...@@ -211,3 +226,62 @@ export const GitPullResult = Schema.Struct({...@@ -211,3 +226,62 @@ export const GitPullResult = Schema.Struct({
211 upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),226 upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
212});227});
213export type GitPullResult = typeof GitPullResult.Type;228export 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 @@...@@ -1,5 +1,6 @@
1import type {1import type {
2 GitCheckoutInput,2 GitCheckoutInput,
3 GitActionProgressEvent,
3 GitCreateBranchInput,4 GitCreateBranchInput,
4 GitPreparePullRequestThreadInput,5 GitPreparePullRequestThreadInput,
5 GitPreparePullRequestThreadResult,6 GitPreparePullRequestThreadResult,
...@@ -149,6 +150,7 @@ export interface NativeApi {...@@ -149,6 +150,7 @@ export interface NativeApi {
149 pull: (input: GitPullInput) => Promise<GitPullResult>;150 pull: (input: GitPullInput) => Promise<GitPullResult>;
150 status: (input: GitStatusInput) => Promise<GitStatusResult>;151 status: (input: GitStatusInput) => Promise<GitStatusResult>;
151 runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>;152 runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>;
153 onActionProgress: (callback: (event: GitActionProgressEvent) => void) => () => void;
152 };154 };
153 contextMenu: {155 contextMenu: {
154 show: <T extends string>(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,6 +95,30 @@ it.effect("accepts typed websocket push envelopes with sequence", () =>
95 }),95 }),
96);96);
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
98it.effect("rejects push envelopes when channel payload does not match the channel schema", () =>122it.effect("rejects push envelopes when channel payload does not match the channel schema", () =>
99 Effect.gen(function* () {123 Effect.gen(function* () {
100 const result = yield* Effect.exit(124 const result = yield* Effect.exit(
packages/contracts/src/ws.ts+9
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 OrchestrationReplayEventsInput,12 OrchestrationReplayEventsInput,
13} from "./orchestration";13} from "./orchestration";
14import {14import {
15 GitActionProgressEvent,
15 GitCheckoutInput,16 GitCheckoutInput,
16 GitCreateBranchInput,17 GitCreateBranchInput,
17 GitPreparePullRequestThreadInput,18 GitPreparePullRequestThreadInput,
...@@ -80,6 +81,7 @@ export const WS_METHODS = {...@@ -80,6 +81,7 @@ export const WS_METHODS = {
80// ── Push Event Channels ──────────────────────────────────────────────81// ── Push Event Channels ──────────────────────────────────────────────
8182
82export const WS_CHANNELS = {83export const WS_CHANNELS = {
84 gitActionProgress: "git.actionProgress",
83 terminalEvent: "terminal.event",85 terminalEvent: "terminal.event",
84 serverWelcome: "server.welcome",86 serverWelcome: "server.welcome",
85 serverConfigUpdated: "server.configUpdated",87 serverConfigUpdated: "server.configUpdated",
...@@ -172,6 +174,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type;...@@ -172,6 +174,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type;
172export interface WsPushPayloadByChannel {174export interface WsPushPayloadByChannel {
173 readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload;175 readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload;
174 readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type;176 readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type;
177 readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type;
175 readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type;178 readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type;
176 readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent;179 readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent;
177}180}
...@@ -195,6 +198,10 @@ export const WsPushServerConfigUpdated = makeWsPushSchema(...@@ -195,6 +198,10 @@ export const WsPushServerConfigUpdated = makeWsPushSchema(
195 WS_CHANNELS.serverConfigUpdated,198 WS_CHANNELS.serverConfigUpdated,
196 ServerConfigUpdatedPayload,199 ServerConfigUpdatedPayload,
197);200);
201export const WsPushGitActionProgress = makeWsPushSchema(
202 WS_CHANNELS.gitActionProgress,
203 GitActionProgressEvent,
204);
198export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent);205export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent);
199export const WsPushOrchestrationDomainEvent = makeWsPushSchema(206export const WsPushOrchestrationDomainEvent = makeWsPushSchema(
200 ORCHESTRATION_WS_CHANNELS.domainEvent,207 ORCHESTRATION_WS_CHANNELS.domainEvent,
...@@ -202,6 +209,7 @@ export const WsPushOrchestrationDomainEvent = makeWsPushSchema(...@@ -202,6 +209,7 @@ export const WsPushOrchestrationDomainEvent = makeWsPushSchema(
202);209);
203210
204export const WsPushChannelSchema = Schema.Literals([211export const WsPushChannelSchema = Schema.Literals([
212 WS_CHANNELS.gitActionProgress,
205 WS_CHANNELS.serverWelcome,213 WS_CHANNELS.serverWelcome,
206 WS_CHANNELS.serverConfigUpdated,214 WS_CHANNELS.serverConfigUpdated,
207 WS_CHANNELS.terminalEvent,215 WS_CHANNELS.terminalEvent,
...@@ -212,6 +220,7 @@ export type WsPushChannelSchema = typeof WsPushChannelSchema.Type;...@@ -212,6 +220,7 @@ export type WsPushChannelSchema = typeof WsPushChannelSchema.Type;
212export const WsPush = Schema.Union([220export const WsPush = Schema.Union([
213 WsPushServerWelcome,221 WsPushServerWelcome,
214 WsPushServerConfigUpdated,222 WsPushServerConfigUpdated,
223 WsPushGitActionProgress,
215 WsPushTerminalEvent,224 WsPushTerminalEvent,
216 WsPushOrchestrationDomainEvent,225 WsPushOrchestrationDomainEvent,
217]);226]);