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) {...@@ -126,12 +126,6 @@ function resolveScope(scope: SourceControlActionScope) {
126 };126 };
127}127}
128128
129function unavailableResult(message: string) {
130 return AsyncResult.failure<never, VcsActionUnavailableError>(
131 Cause.fail(new VcsActionUnavailableError({ message })),
132 );
133}
134
135export function useSourceControlActionRunning(129export function useSourceControlActionRunning(
136 scope: SourceControlActionScope,130 scope: SourceControlActionScope,
137 kinds: ReadonlyArray<SourceControlActionKind>,131 kinds: ReadonlyArray<SourceControlActionKind>,
...@@ -149,7 +143,15 @@ export function useVcsInitAction(scope: SourceControlActionScope) {...@@ -149,7 +143,15 @@ export function useVcsInitAction(scope: SourceControlActionScope) {
149 const action = useCallback(async () => {143 const action = useCallback(async () => {
150 const target = resolveScope(scope);144 const target = resolveScope(scope);
151 if (target === null) {145 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 );
153 }155 }
154 return init({156 return init({
155 environmentId: target.environmentId,157 environmentId: target.environmentId,
...@@ -172,7 +174,15 @@ export function useVcsPullAction(scope: SourceControlActionScope) {...@@ -172,7 +174,15 @@ export function useVcsPullAction(scope: SourceControlActionScope) {
172 const action = useCallback(async () => {174 const action = useCallback(async () => {
173 const target = resolveScope(scope);175 const target = resolveScope(scope);
174 if (target === null) {176 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 );
176 }186 }
177 return pull({187 return pull({
178 environmentId: target.environmentId,188 environmentId: target.environmentId,
...@@ -211,7 +221,15 @@ export function useGitStackedAction(scope: SourceControlActionScope) {...@@ -211,7 +221,15 @@ export function useGitStackedAction(scope: SourceControlActionScope) {
211 onProgress?: (event: GitActionProgressEvent) => void;221 onProgress?: (event: GitActionProgressEvent) => void;
212 }) => {222 }) => {
213 if (resolveScope(scope) === null) {223 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 );
215 }233 }
216 return runStackedAction({234 return runStackedAction({
217 actionId: input.actionId,235 actionId: input.actionId,
...@@ -257,7 +275,15 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi...@@ -257,7 +275,15 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi
257 }) => {275 }) => {
258 const target = resolveScope(scope);276 const target = resolveScope(scope);
259 if (target === null) {277 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 );
261 }287 }
262 return publishRepository({288 return publishRepository({
263 environmentId: target.environmentId,289 environmentId: target.environmentId,
...@@ -286,7 +312,15 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop...@@ -286,7 +312,15 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop
286 async (input: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => {312 async (input: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => {
287 const target = resolveScope(scope);313 const target = resolveScope(scope);
288 if (target === null) {314 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 );
290 }324 }
291 return preparePullRequestThread({325 return preparePullRequestThread({
292 environmentId: target.environmentId,326 environmentId: target.environmentId,
packages/client-runtime/src/state/vcsAction.test.ts+141
...@@ -7,6 +7,7 @@ import { describe, expect, it } from "@effect/vitest";...@@ -7,6 +7,7 @@ import { describe, expect, it } from "@effect/vitest";
7import * as Cause from "effect/Cause";7import * as Cause from "effect/Cause";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
10import * as Schema from "effect/Schema";
10import * as Stream from "effect/Stream";11import * as Stream from "effect/Stream";
11import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";12import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
1213
...@@ -21,12 +22,18 @@ import {...@@ -21,12 +22,18 @@ import {
21 EMPTY_VCS_ACTION_STATE,22 EMPTY_VCS_ACTION_STATE,
22 getVcsActionTargetKey,23 getVcsActionTargetKey,
23 normalizeVcsActionProgressEvent,24 normalizeVcsActionProgressEvent,
25 parseVcsActionTargetKey,
26 VcsActionMissingTerminalEventError,
27 VcsActionRemoteFailureError,
28 VcsActionTargetKeyParseError,
29 VcsActionUnavailableError,
24} from "./vcsAction.ts";30} from "./vcsAction.ts";
2531
26const actionId = "action-123";32const actionId = "action-123";
27const action = "commit_push" as const;33const action = "commit_push" as const;
28const cwd = "/repo";34const cwd = "/repo";
29const environmentId = EnvironmentId.make("environment-1");35const environmentId = EnvironmentId.make("environment-1");
36const isVcsActionUnavailableError = Schema.is(VcsActionUnavailableError);
30const result: GitRunStackedActionResult = {37const result: GitRunStackedActionResult = {
31 action,38 action,
32 branch: {39 branch: {
...@@ -57,6 +64,28 @@ function progress<T extends GitActionProgressEvent>(event: T): T {...@@ -57,6 +64,28 @@ function progress<T extends GitActionProgressEvent>(event: T): T {
57}64}
5865
59describe("vcsActionState", () => {66describe("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
60 it("projects phase and hook progress without owning the async operation", () => {89 it("projects phase and hook progress without owning the async operation", () => {
61 const initial = beginVcsActionState({90 const initial = beginVcsActionState({
62 operation: "run_change_request",91 operation: "run_change_request",
...@@ -254,6 +283,7 @@ describe("vcsActionState", () => {...@@ -254,6 +283,7 @@ describe("vcsActionState", () => {
254 target,283 target,
255 transportActionId,284 transportActionId,
256 actionId,285 actionId,
286 action,
257 onProgress: (event) =>287 onProgress: (event) =>
258 Effect.sync(() => {288 Effect.sync(() => {
259 observed.push(event);289 observed.push(event);
...@@ -266,6 +296,85 @@ describe("vcsActionState", () => {...@@ -266,6 +296,85 @@ describe("vcsActionState", () => {
266 }),296 }),
267 );297 );
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
269 it("keys mutation ownership by environment and cwd", () => {378 it("keys mutation ownership by environment and cwd", () => {
270 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<379 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
271 EnvironmentRegistry,380 EnvironmentRegistry,
...@@ -286,6 +395,38 @@ describe("vcsActionState", () => {...@@ -286,6 +395,38 @@ describe("vcsActionState", () => {
286 registry.dispose();395 registry.dispose();
287 });396 });
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
289 it("tracks finite mutations without letting an older completion clear newer state", async () => {430 it("tracks finite mutations without letting an older completion clear newer state", async () => {
290 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<431 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
291 EnvironmentRegistry,432 EnvironmentRegistry,
packages/client-runtime/src/state/vcsAction.ts+120-43
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1import {1import {
2 EnvironmentId,2 EnvironmentId,
3 type EnvironmentId as EnvironmentIdType,3 type EnvironmentId as EnvironmentIdType,
4 GitActionProgressPhase,
4 type GitActionProgressEvent,5 type GitActionProgressEvent,
5 type GitRunStackedActionInput,6 type GitRunStackedActionInput,
6 type GitRunStackedActionResult,7 type GitRunStackedActionResult,
7 type GitStackedAction,8 GitStackedAction,
8 WS_METHODS,9 WS_METHODS,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";11import * as Cause from "effect/Cause";
...@@ -24,16 +25,18 @@ import {...@@ -24,16 +25,18 @@ import {
24} from "./runtime.ts";25} from "./runtime.ts";
25import { vcsCommandScheduler } from "./vcsCommandScheduler.ts";26import { vcsCommandScheduler } from "./vcsCommandScheduler.ts";
2627
27export type VcsActionOperation =28export const VcsActionOperation = Schema.Literals([
28 | "refresh_status"29 "refresh_status",
29 | "run_change_request"30 "run_change_request",
30 | "pull"31 "pull",
31 | "switch_ref"32 "switch_ref",
32 | "create_ref"33 "create_ref",
33 | "create_worktree"34 "create_worktree",
34 | "init"35 "init",
35 | "publish_repository"36 "publish_repository",
36 | "prepare_pull_request_thread";37 "prepare_pull_request_thread",
38]);
39export type VcsActionOperation = typeof VcsActionOperation.Type;
3740
38export interface VcsActionState {41export interface VcsActionState {
39 readonly isRunning: boolean;42 readonly isRunning: boolean;
...@@ -77,16 +80,66 @@ export interface RunVcsStackedActionInput {...@@ -77,16 +80,66 @@ export interface RunVcsStackedActionInput {
77export class VcsActionUnavailableError extends Schema.TaggedErrorClass<VcsActionUnavailableError>()(80export class VcsActionUnavailableError extends Schema.TaggedErrorClass<VcsActionUnavailableError>()(
78 "VcsActionUnavailableError",81 "VcsActionUnavailableError",
79 {82 {
80 message: Schema.String,83 operation: VcsActionOperation,
84 environmentId: Schema.NullOr(EnvironmentId),
85 cwd: Schema.NullOr(Schema.String),
81 },86 },
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>()(111export class VcsActionMissingTerminalEventError extends Schema.TaggedErrorClass<VcsActionMissingTerminalEventError>()(
85 "VcsActionExecutionError",112 "VcsActionMissingTerminalEventError",
86 {113 {
87 message: Schema.String,114 actionId: Schema.String,
115 transportActionId: Schema.String,
116 action: GitStackedAction,
117 environmentId: EnvironmentId,
118 cwd: Schema.String,
88 },119 },
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
91export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({144export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({
92 isRunning: false,145 isRunning: false,
...@@ -104,6 +157,9 @@ export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({...@@ -104,6 +157,9 @@ export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({
104157
105const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe());158const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe());
106let nextLocalActionId = 0;159let nextLocalActionId = 0;
160const decodeVcsActionTargetKey = Schema.decodeUnknownSync(
161 Schema.Tuple([EnvironmentId, Schema.String]),
162);
107163
108export const vcsActionStateAtom = Atom.family((key: string) => {164export const vcsActionStateAtom = Atom.family((key: string) => {
109 return Atom.make(EMPTY_VCS_ACTION_STATE).pipe(165 return Atom.make(EMPTY_VCS_ACTION_STATE).pipe(
...@@ -124,12 +180,13 @@ export function getVcsActionTargetKey(target: VcsActionTarget): string | null {...@@ -124,12 +180,13 @@ export function getVcsActionTargetKey(target: VcsActionTarget): string | null {
124 return JSON.stringify([target.environmentId, target.cwd]);180 return JSON.stringify([target.environmentId, target.cwd]);
125}181}
126182
127function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget {183export function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget {
128 const [environmentId, cwd] = JSON.parse(key) as [string, string];184 try {
129 return {185 const [environmentId, cwd] = decodeVcsActionTargetKey(JSON.parse(key));
130 environmentId: EnvironmentId.make(environmentId),186 return { environmentId, cwd };
131 cwd,187 } catch (cause) {
132 };188 throw new VcsActionTargetKeyParseError({ keyLength: key.length, cause });
189 }
133}190}
134191
135export function getVcsActionStateAtom(target: VcsActionTarget) {192export function getVcsActionStateAtom(target: VcsActionTarget) {
...@@ -200,6 +257,7 @@ export function consumeVcsActionProgress<E, R>(...@@ -200,6 +257,7 @@ export function consumeVcsActionProgress<E, R>(
200 readonly target: ResolvedVcsActionTarget;257 readonly target: ResolvedVcsActionTarget;
201 readonly transportActionId: string;258 readonly transportActionId: string;
202 readonly actionId: string;259 readonly actionId: string;
260 readonly action: GitStackedAction;
203 readonly onProgress: (event: GitActionProgressEvent) => Effect.Effect<void>;261 readonly onProgress: (event: GitActionProgressEvent) => Effect.Effect<void>;
204 },262 },
205): Effect.Effect<GitRunStackedActionResult, E | VcsActionExecutionError, R> {263): Effect.Effect<GitRunStackedActionResult, E | VcsActionExecutionError, R> {
...@@ -226,15 +284,25 @@ export function consumeVcsActionProgress<E, R>(...@@ -226,15 +284,25 @@ export function consumeVcsActionProgress<E, R>(
226 return Effect.succeed(terminalEvent.result);284 return Effect.succeed(terminalEvent.result);
227 }285 }
228 if (terminalEvent?.kind === "action_failed") {286 if (terminalEvent?.kind === "action_failed") {
229 return Effect.fail(287 return Effect.fail<VcsActionExecutionError>(
230 new VcsActionExecutionError({288 new VcsActionRemoteFailureError({
231 message: terminalEvent.message,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,
232 }),296 }),
233 );297 );
234 }298 }
235 return Effect.fail(299 return Effect.fail<VcsActionExecutionError>(
236 new VcsActionExecutionError({300 new VcsActionMissingTerminalEventError({
237 message: "Source control action ended without a result.",301 actionId: input.actionId,
302 transportActionId: input.transportActionId,
303 action: input.action,
304 environmentId: input.target.environmentId,
305 cwd: input.target.cwd,
238 }),306 }),
239 );307 );
240 }),308 }),
...@@ -339,18 +407,25 @@ export function applyVcsActionProgressEvent(...@@ -339,18 +407,25 @@ export function applyVcsActionProgressEvent(
339export function createVcsActionManager<R, E>(407export function createVcsActionManager<R, E>(
340 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,408 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
341) {409) {
342 const unavailableTargetKey = "vcs-action-target:unavailable";
343 const runStackedActionCommands = new Map<410 const runStackedActionCommands = new Map<
344 string,411 string,
345 AtomCommand<RunVcsStackedActionInput, GitRunStackedActionResult, unknown>412 AtomCommand<RunVcsStackedActionInput, GitRunStackedActionResult, unknown>
346 >();413 >();
347 const getRunStackedActionCommand = (key: string) => {414 const getRunStackedActionCommand = (requestedTarget: VcsActionTarget) => {
348 const existing = runStackedActionCommands.get(key);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);
349 if (existing !== undefined) {424 if (existing !== undefined) {
350 return existing;425 return existing;
351 }426 }
352 const target = key === unavailableTargetKey ? null : parseVcsActionTargetKey(key);427 const target = targetKey === null ? null : parseVcsActionTargetKey(targetKey);
353 const stateAtom = target === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key);428 const stateAtom = targetKey === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(targetKey);
354 const command = createRuntimeCommand<429 const command = createRuntimeCommand<
355 EnvironmentRegistry | R,430 EnvironmentRegistry | R,
356 E,431 E,
...@@ -358,14 +433,16 @@ export function createVcsActionManager<R, E>(...@@ -358,14 +433,16 @@ export function createVcsActionManager<R, E>(
358 GitRunStackedActionResult,433 GitRunStackedActionResult,
359 unknown434 unknown
360 >(runtime, {435 >(runtime, {
361 label: `vcs-action:run-stacked:${key}`,436 label: `vcs-action:run-stacked:${commandKey}`,
362 scheduler: vcsCommandScheduler,437 scheduler: vcsCommandScheduler,
363 concurrency: { mode: "serial", key: () => key },438 concurrency: { mode: "serial", key: () => commandKey },
364 execute: (input: RunVcsStackedActionInput, registry) => {439 execute: (input: RunVcsStackedActionInput, registry) => {
365 if (target === null) {440 if (target === null) {
366 return Effect.fail(441 return Effect.fail(
367 new VcsActionUnavailableError({442 new VcsActionUnavailableError({
368 message: "Source control action is unavailable.",443 operation: "run_change_request",
444 environmentId: requestedTarget.environmentId,
445 cwd: requestedTarget.cwd,
369 }),446 }),
370 );447 );
371 }448 }
...@@ -396,6 +473,7 @@ export function createVcsActionManager<R, E>(...@@ -396,6 +473,7 @@ export function createVcsActionManager<R, E>(
396 target,473 target,
397 transportActionId,474 transportActionId,
398 actionId: input.actionId,475 actionId: input.actionId,
476 action: input.action,
399 onProgress: (event) =>477 onProgress: (event) =>
400 Effect.sync(() => {478 Effect.sync(() => {
401 const current = registry.get(stateAtom);479 const current = registry.get(stateAtom);
...@@ -427,7 +505,7 @@ export function createVcsActionManager<R, E>(...@@ -427,7 +505,7 @@ export function createVcsActionManager<R, E>(
427 );505 );
428 },506 },
429 });507 });
430 runStackedActionCommands.set(key, command);508 runStackedActionCommands.set(commandKey, command);
431 return command;509 return command;
432 };510 };
433511
...@@ -446,10 +524,7 @@ export function createVcsActionManager<R, E>(...@@ -446,10 +524,7 @@ export function createVcsActionManager<R, E>(
446524
447 return {525 return {
448 stateAtom: getVcsActionStateAtom,526 stateAtom: getVcsActionStateAtom,
449 runStackedAction: (target: VcsActionTarget) => {527 runStackedAction: (target: VcsActionTarget) => getRunStackedActionCommand(target),
450 const key = getVcsActionTargetKey(target);
451 return getRunStackedActionCommand(key ?? unavailableTargetKey);
452 },
453 track: async <A, E>(528 track: async <A, E>(
454 registry: AtomRegistry.AtomRegistry,529 registry: AtomRegistry.AtomRegistry,
455 target: VcsActionTarget,530 target: VcsActionTarget,
...@@ -461,7 +536,9 @@ export function createVcsActionManager<R, E>(...@@ -461,7 +536,9 @@ export function createVcsActionManager<R, E>(
461 return AsyncResult.failure<never, VcsActionUnavailableError>(536 return AsyncResult.failure<never, VcsActionUnavailableError>(
462 Cause.fail(537 Cause.fail(
463 new VcsActionUnavailableError({538 new VcsActionUnavailableError({
464 message: "Source control action is unavailable.",539 operation: input.operation,
540 environmentId: target.environmentId,
541 cwd: target.cwd,
465 }),542 }),
466 ),543 ),
467 );544 );