authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:52:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:52:20-07:00
logff0f702c7cb5006acf72b63edf5c0df7686f60c4
treed43e48ec67793bfbb76c514d0054dc303ad3fe0f
parent1cbe8a7a90cb7b942ede116e744a71d45da8fdb0
signature Signed by PGP key B5690EEEBB952194

[codex] Structure client VCS action errors (#3263)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 306 insertions(+), 54 deletions(-)

apps/web/src/state/sourceControlActions.ts+45-11
......@@ -126,12 +126,6 @@ function resolveScope(scope: SourceControlActionScope) {
126126 };
127127}
128128
129function unavailableResult(message: string) {
130 return AsyncResult.failure<never, VcsActionUnavailableError>(
131 Cause.fail(new VcsActionUnavailableError({ message })),
132 );
133}
134
135129export function useSourceControlActionRunning(
136130 scope: SourceControlActionScope,
137131 kinds: ReadonlyArray<SourceControlActionKind>,
......@@ -149,7 +143,15 @@ export function useVcsInitAction(scope: SourceControlActionScope) {
149143 const action = useCallback(async () => {
150144 const target = resolveScope(scope);
151145 if (target === null) {
152 return unavailableResult("Git init is unavailable.");
146 return AsyncResult.failure<never, VcsActionUnavailableError>(
147 Cause.fail(
148 new VcsActionUnavailableError({
149 operation: "init",
150 environmentId: scope.environmentId,
151 cwd: scope.cwd,
152 }),
153 ),
154 );
153155 }
154156 return init({
155157 environmentId: target.environmentId,
......@@ -172,7 +174,15 @@ export function useVcsPullAction(scope: SourceControlActionScope) {
172174 const action = useCallback(async () => {
173175 const target = resolveScope(scope);
174176 if (target === null) {
175 return unavailableResult("Git pull is unavailable.");
177 return AsyncResult.failure<never, VcsActionUnavailableError>(
178 Cause.fail(
179 new VcsActionUnavailableError({
180 operation: "pull",
181 environmentId: scope.environmentId,
182 cwd: scope.cwd,
183 }),
184 ),
185 );
176186 }
177187 return pull({
178188 environmentId: target.environmentId,
......@@ -211,7 +221,15 @@ export function useGitStackedAction(scope: SourceControlActionScope) {
211221 onProgress?: (event: GitActionProgressEvent) => void;
212222 }) => {
213223 if (resolveScope(scope) === null) {
214 return unavailableResult("Git action is unavailable.");
224 return AsyncResult.failure<never, VcsActionUnavailableError>(
225 Cause.fail(
226 new VcsActionUnavailableError({
227 operation: "run_change_request",
228 environmentId: scope.environmentId,
229 cwd: scope.cwd,
230 }),
231 ),
232 );
215233 }
216234 return runStackedAction({
217235 actionId: input.actionId,
......@@ -257,7 +275,15 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi
257275 }) => {
258276 const target = resolveScope(scope);
259277 if (target === null) {
260 return unavailableResult("Repository publishing is unavailable.");
278 return AsyncResult.failure<never, VcsActionUnavailableError>(
279 Cause.fail(
280 new VcsActionUnavailableError({
281 operation: "publish_repository",
282 environmentId: scope.environmentId,
283 cwd: scope.cwd,
284 }),
285 ),
286 );
261287 }
262288 return publishRepository({
263289 environmentId: target.environmentId,
......@@ -286,7 +312,15 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop
286312 async (input: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => {
287313 const target = resolveScope(scope);
288314 if (target === null) {
289 return unavailableResult("Pull request thread preparation is unavailable.");
315 return AsyncResult.failure<never, VcsActionUnavailableError>(
316 Cause.fail(
317 new VcsActionUnavailableError({
318 operation: "prepare_pull_request_thread",
319 environmentId: scope.environmentId,
320 cwd: scope.cwd,
321 }),
322 ),
323 );
290324 }
291325 return preparePullRequestThread({
292326 environmentId: target.environmentId,
packages/client-runtime/src/state/vcsAction.test.ts+141
......@@ -7,6 +7,7 @@ import { describe, expect, it } from "@effect/vitest";
77import * as Cause from "effect/Cause";
88import * as Effect from "effect/Effect";
99import * as Layer from "effect/Layer";
10import * as Schema from "effect/Schema";
1011import * as Stream from "effect/Stream";
1112import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
1213
......@@ -21,12 +22,18 @@ import {
2122 EMPTY_VCS_ACTION_STATE,
2223 getVcsActionTargetKey,
2324 normalizeVcsActionProgressEvent,
25 parseVcsActionTargetKey,
26 VcsActionMissingTerminalEventError,
27 VcsActionRemoteFailureError,
28 VcsActionTargetKeyParseError,
29 VcsActionUnavailableError,
2430} from "./vcsAction.ts";
2531
2632const actionId = "action-123";
2733const action = "commit_push" as const;
2834const cwd = "/repo";
2935const environmentId = EnvironmentId.make("environment-1");
36const isVcsActionUnavailableError = Schema.is(VcsActionUnavailableError);
3037const result: GitRunStackedActionResult = {
3138 action,
3239 branch: {
......@@ -57,6 +64,28 @@ function progress<T extends GitActionProgressEvent>(event: T): T {
5764}
5865
5966describe("vcsActionState", () => {
67 it("preserves malformed target key diagnostics and the native cause without copying the key", () => {
68 const key = "not-json-with-credential=do-not-log";
69 let error: unknown;
70
71 try {
72 parseVcsActionTargetKey(key);
73 } catch (cause) {
74 error = cause;
75 }
76
77 expect(error).toBeInstanceOf(VcsActionTargetKeyParseError);
78 expect(error).toMatchObject({ keyLength: key.length, cause: expect.any(SyntaxError) });
79 expect(error).not.toHaveProperty("key");
80 expect((error as Error).message).not.toContain(key);
81 });
82
83 it("rejects invalid target key shapes", () => {
84 const key = JSON.stringify([environmentId]);
85
86 expect(() => parseVcsActionTargetKey(key)).toThrowError(VcsActionTargetKeyParseError);
87 });
88
6089 it("projects phase and hook progress without owning the async operation", () => {
6190 const initial = beginVcsActionState({
6291 operation: "run_change_request",
......@@ -254,6 +283,7 @@ describe("vcsActionState", () => {
254283 target,
255284 transportActionId,
256285 actionId,
286 action,
257287 onProgress: (event) =>
258288 Effect.sync(() => {
259289 observed.push(event);
......@@ -266,6 +296,85 @@ describe("vcsActionState", () => {
266296 }),
267297 );
268298
299 it.effect("retains structural remote failure context without copying the remote payload", () =>
300 Effect.gen(function* () {
301 const target = { environmentId, cwd };
302 const transportActionId = createVcsActionTransportId(target, actionId);
303 const remoteMessage = "The remote rejected the push with credential=do-not-log.";
304 const error = yield* consumeVcsActionProgress(
305 Stream.fromIterable<GitActionProgressEvent>([
306 {
307 actionId: transportActionId,
308 action,
309 cwd,
310 kind: "action_failed",
311 phase: "push",
312 message: remoteMessage,
313 },
314 ]),
315 {
316 target,
317 transportActionId,
318 actionId,
319 action,
320 onProgress: () => Effect.void,
321 },
322 ).pipe(Effect.flip);
323
324 expect(error).toBeInstanceOf(VcsActionRemoteFailureError);
325 expect(error).toMatchObject({
326 actionId,
327 transportActionId,
328 action,
329 environmentId,
330 cwd,
331 phase: "push",
332 remoteMessageLength: remoteMessage.length,
333 });
334 expect(error).not.toHaveProperty("detail");
335 expect(error.message).toBe("Source control action 'commit_push' failed during push.");
336 expect(error.message).not.toContain(remoteMessage);
337 }),
338 );
339
340 it.effect("reports a missing terminal event as a protocol failure", () =>
341 Effect.gen(function* () {
342 const target = { environmentId, cwd };
343 const transportActionId = createVcsActionTransportId(target, actionId);
344 const error = yield* consumeVcsActionProgress(
345 Stream.fromIterable<GitActionProgressEvent>([
346 {
347 actionId: transportActionId,
348 action,
349 cwd,
350 kind: "phase_started",
351 phase: "commit",
352 label: "Committing...",
353 },
354 ]),
355 {
356 target,
357 transportActionId,
358 actionId,
359 action,
360 onProgress: () => Effect.void,
361 },
362 ).pipe(Effect.flip);
363
364 expect(error).toBeInstanceOf(VcsActionMissingTerminalEventError);
365 expect(error).toMatchObject({
366 actionId,
367 transportActionId,
368 action,
369 environmentId,
370 cwd,
371 });
372 expect(error.message).toBe(
373 "Source control action 'commit_push' ended without a terminal result.",
374 );
375 }),
376 );
377
269378 it("keys mutation ownership by environment and cwd", () => {
270379 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
271380 EnvironmentRegistry,
......@@ -286,6 +395,38 @@ describe("vcsActionState", () => {
286395 registry.dispose();
287396 });
288397
398 it("retains the incomplete target and operation when tracking is unavailable", async () => {
399 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
400 EnvironmentRegistry,
401 never
402 >;
403 const manager = createVcsActionManager(runtime);
404 const registry = AtomRegistry.make();
405 const result = await manager.track(
406 registry,
407 { environmentId, cwd: null },
408 { operation: "pull", label: "Pulling latest changes" },
409 async () => AsyncResult.success(undefined),
410 );
411
412 expect(AsyncResult.isFailure(result)).toBe(true);
413 if (AsyncResult.isFailure(result)) {
414 const error = Cause.squash(result.cause);
415 expect(error).toBeInstanceOf(VcsActionUnavailableError);
416 if (!isVcsActionUnavailableError(error)) {
417 throw error;
418 }
419 expect(error).toMatchObject({
420 operation: "pull",
421 environmentId,
422 cwd: null,
423 });
424 expect(error.message).toBe("Source control operation 'pull' is unavailable.");
425 }
426
427 registry.dispose();
428 });
429
289430 it("tracks finite mutations without letting an older completion clear newer state", async () => {
290431 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
291432 EnvironmentRegistry,
packages/client-runtime/src/state/vcsAction.ts+120-43
......@@ -1,10 +1,11 @@
11import {
22 EnvironmentId,
33 type EnvironmentId as EnvironmentIdType,
4 GitActionProgressPhase,
45 type GitActionProgressEvent,
56 type GitRunStackedActionInput,
67 type GitRunStackedActionResult,
7 type GitStackedAction,
8 GitStackedAction,
89 WS_METHODS,
910} from "@t3tools/contracts";
1011import * as Cause from "effect/Cause";
......@@ -24,16 +25,18 @@ import {
2425} from "./runtime.ts";
2526import { vcsCommandScheduler } from "./vcsCommandScheduler.ts";
2627
27export type VcsActionOperation =
28 | "refresh_status"
29 | "run_change_request"
30 | "pull"
31 | "switch_ref"
32 | "create_ref"
33 | "create_worktree"
34 | "init"
35 | "publish_repository"
36 | "prepare_pull_request_thread";
28export const VcsActionOperation = Schema.Literals([
29 "refresh_status",
30 "run_change_request",
31 "pull",
32 "switch_ref",
33 "create_ref",
34 "create_worktree",
35 "init",
36 "publish_repository",
37 "prepare_pull_request_thread",
38]);
39export type VcsActionOperation = typeof VcsActionOperation.Type;
3740
3841export interface VcsActionState {
3942 readonly isRunning: boolean;
......@@ -77,16 +80,66 @@ export interface RunVcsStackedActionInput {
7780export class VcsActionUnavailableError extends Schema.TaggedErrorClass<VcsActionUnavailableError>()(
7881 "VcsActionUnavailableError",
7982 {
80 message: Schema.String,
83 operation: VcsActionOperation,
84 environmentId: Schema.NullOr(EnvironmentId),
85 cwd: Schema.NullOr(Schema.String),
8186 },
82) {}
87) {
88 override get message(): string {
89 return `Source control operation '${this.operation.replaceAll("_", " ")}' is unavailable.`;
90 }
91}
92
93export class VcsActionRemoteFailureError extends Schema.TaggedErrorClass<VcsActionRemoteFailureError>()(
94 "VcsActionRemoteFailureError",
95 {
96 actionId: Schema.String,
97 transportActionId: Schema.String,
98 action: GitStackedAction,
99 environmentId: EnvironmentId,
100 cwd: Schema.String,
101 phase: Schema.NullOr(GitActionProgressPhase),
102 remoteMessageLength: Schema.Number,
103 },
104) {
105 override get message(): string {
106 const phase = this.phase === null ? "execution" : this.phase;
107 return `Source control action '${this.action}' failed during ${phase}.`;
108 }
109}
83110
84export class VcsActionExecutionError extends Schema.TaggedErrorClass<VcsActionExecutionError>()(
85 "VcsActionExecutionError",
111export class VcsActionMissingTerminalEventError extends Schema.TaggedErrorClass<VcsActionMissingTerminalEventError>()(
112 "VcsActionMissingTerminalEventError",
86113 {
87 message: Schema.String,
114 actionId: Schema.String,
115 transportActionId: Schema.String,
116 action: GitStackedAction,
117 environmentId: EnvironmentId,
118 cwd: Schema.String,
88119 },
89) {}
120) {
121 override get message(): string {
122 return `Source control action '${this.action}' ended without a terminal result.`;
123 }
124}
125
126export class VcsActionTargetKeyParseError extends Schema.TaggedErrorClass<VcsActionTargetKeyParseError>()(
127 "VcsActionTargetKeyParseError",
128 {
129 keyLength: Schema.Number,
130 cause: Schema.Defect(),
131 },
132) {
133 override get message(): string {
134 return `Invalid source control action target key (${this.keyLength} characters).`;
135 }
136}
137
138export const VcsActionExecutionError = Schema.Union([
139 VcsActionRemoteFailureError,
140 VcsActionMissingTerminalEventError,
141]);
142export type VcsActionExecutionError = typeof VcsActionExecutionError.Type;
90143
91144export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({
92145 isRunning: false,
......@@ -104,6 +157,9 @@ export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({
104157
105158const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe());
106159let nextLocalActionId = 0;
160const decodeVcsActionTargetKey = Schema.decodeUnknownSync(
161 Schema.Tuple([EnvironmentId, Schema.String]),
162);
107163
108164export const vcsActionStateAtom = Atom.family((key: string) => {
109165 return Atom.make(EMPTY_VCS_ACTION_STATE).pipe(
......@@ -124,12 +180,13 @@ export function getVcsActionTargetKey(target: VcsActionTarget): string | null {
124180 return JSON.stringify([target.environmentId, target.cwd]);
125181}
126182
127function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget {
128 const [environmentId, cwd] = JSON.parse(key) as [string, string];
129 return {
130 environmentId: EnvironmentId.make(environmentId),
131 cwd,
132 };
183export function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget {
184 try {
185 const [environmentId, cwd] = decodeVcsActionTargetKey(JSON.parse(key));
186 return { environmentId, cwd };
187 } catch (cause) {
188 throw new VcsActionTargetKeyParseError({ keyLength: key.length, cause });
189 }
133190}
134191
135192export function getVcsActionStateAtom(target: VcsActionTarget) {
......@@ -200,6 +257,7 @@ export function consumeVcsActionProgress<E, R>(
200257 readonly target: ResolvedVcsActionTarget;
201258 readonly transportActionId: string;
202259 readonly actionId: string;
260 readonly action: GitStackedAction;
203261 readonly onProgress: (event: GitActionProgressEvent) => Effect.Effect<void>;
204262 },
205263): Effect.Effect<GitRunStackedActionResult, E | VcsActionExecutionError, R> {
......@@ -226,15 +284,25 @@ export function consumeVcsActionProgress<E, R>(
226284 return Effect.succeed(terminalEvent.result);
227285 }
228286 if (terminalEvent?.kind === "action_failed") {
229 return Effect.fail(
230 new VcsActionExecutionError({
231 message: terminalEvent.message,
287 return Effect.fail<VcsActionExecutionError>(
288 new VcsActionRemoteFailureError({
289 actionId: input.actionId,
290 transportActionId: input.transportActionId,
291 action: terminalEvent.action,
292 environmentId: input.target.environmentId,
293 cwd: input.target.cwd,
294 phase: terminalEvent.phase,
295 remoteMessageLength: terminalEvent.message.length,
232296 }),
233297 );
234298 }
235 return Effect.fail(
236 new VcsActionExecutionError({
237 message: "Source control action ended without a result.",
299 return Effect.fail<VcsActionExecutionError>(
300 new VcsActionMissingTerminalEventError({
301 actionId: input.actionId,
302 transportActionId: input.transportActionId,
303 action: input.action,
304 environmentId: input.target.environmentId,
305 cwd: input.target.cwd,
238306 }),
239307 );
240308 }),
......@@ -339,18 +407,25 @@ export function applyVcsActionProgressEvent(
339407export function createVcsActionManager<R, E>(
340408 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
341409) {
342 const unavailableTargetKey = "vcs-action-target:unavailable";
343410 const runStackedActionCommands = new Map<
344411 string,
345412 AtomCommand<RunVcsStackedActionInput, GitRunStackedActionResult, unknown>
346413 >();
347 const getRunStackedActionCommand = (key: string) => {
348 const existing = runStackedActionCommands.get(key);
414 const getRunStackedActionCommand = (requestedTarget: VcsActionTarget) => {
415 const targetKey = getVcsActionTargetKey(requestedTarget);
416 const commandKey =
417 targetKey ??
418 JSON.stringify([
419 "vcs-action-target:unavailable",
420 requestedTarget.environmentId,
421 requestedTarget.cwd,
422 ]);
423 const existing = runStackedActionCommands.get(commandKey);
349424 if (existing !== undefined) {
350425 return existing;
351426 }
352 const target = key === unavailableTargetKey ? null : parseVcsActionTargetKey(key);
353 const stateAtom = target === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key);
427 const target = targetKey === null ? null : parseVcsActionTargetKey(targetKey);
428 const stateAtom = targetKey === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(targetKey);
354429 const command = createRuntimeCommand<
355430 EnvironmentRegistry | R,
356431 E,
......@@ -358,14 +433,16 @@ export function createVcsActionManager<R, E>(
358433 GitRunStackedActionResult,
359434 unknown
360435 >(runtime, {
361 label: `vcs-action:run-stacked:${key}`,
436 label: `vcs-action:run-stacked:${commandKey}`,
362437 scheduler: vcsCommandScheduler,
363 concurrency: { mode: "serial", key: () => key },
438 concurrency: { mode: "serial", key: () => commandKey },
364439 execute: (input: RunVcsStackedActionInput, registry) => {
365440 if (target === null) {
366441 return Effect.fail(
367442 new VcsActionUnavailableError({
368 message: "Source control action is unavailable.",
443 operation: "run_change_request",
444 environmentId: requestedTarget.environmentId,
445 cwd: requestedTarget.cwd,
369446 }),
370447 );
371448 }
......@@ -396,6 +473,7 @@ export function createVcsActionManager<R, E>(
396473 target,
397474 transportActionId,
398475 actionId: input.actionId,
476 action: input.action,
399477 onProgress: (event) =>
400478 Effect.sync(() => {
401479 const current = registry.get(stateAtom);
......@@ -427,7 +505,7 @@ export function createVcsActionManager<R, E>(
427505 );
428506 },
429507 });
430 runStackedActionCommands.set(key, command);
508 runStackedActionCommands.set(commandKey, command);
431509 return command;
432510 };
433511
......@@ -446,10 +524,7 @@ export function createVcsActionManager<R, E>(
446524
447525 return {
448526 stateAtom: getVcsActionStateAtom,
449 runStackedAction: (target: VcsActionTarget) => {
450 const key = getVcsActionTargetKey(target);
451 return getRunStackedActionCommand(key ?? unavailableTargetKey);
452 },
527 runStackedAction: (target: VcsActionTarget) => getRunStackedActionCommand(target),
453528 track: async <A, E>(
454529 registry: AtomRegistry.AtomRegistry,
455530 target: VcsActionTarget,
......@@ -461,7 +536,9 @@ export function createVcsActionManager<R, E>(
461536 return AsyncResult.failure<never, VcsActionUnavailableError>(
462537 Cause.fail(
463538 new VcsActionUnavailableError({
464 message: "Source control action is unavailable.",
539 operation: input.operation,
540 environmentId: target.environmentId,
541 cwd: target.cwd,
465542 }),
466543 ),
467544 );