| author | |
| committer | |
| log | ff0f702c7cb5006acf72b63edf5c0df7686f60c4 |
| tree | d43e48ec67793bfbb76c514d0054dc303ad3fe0f |
| parent | 1cbe8a7a90cb7b942ede116e744a71d45da8fdb0 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 128 | 128 | ||
| 129 | function unavailableResult(message: string) { | ||
| 130 | return AsyncResult.failure<never, VcsActionUnavailableError>( | ||
| 131 | Cause.fail(new VcsActionUnavailableError({ message })), | ||
| 132 | ); | ||
| 133 | } | ||
| 134 | |||
| 135 | export function useSourceControlActionRunning( | 129 | export 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"; |
| 7 | import * as Cause from "effect/Cause"; | 7 | import * as Cause from "effect/Cause"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as Schema from "effect/Schema"; | ||
| 10 | import * as Stream from "effect/Stream"; | 11 | import * as Stream from "effect/Stream"; |
| 11 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | 12 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 12 | 13 | ||
| ... | @@ -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"; |
| 25 | 31 | ||
| 26 | const actionId = "action-123"; | 32 | const actionId = "action-123"; |
| 27 | const action = "commit_push" as const; | 33 | const action = "commit_push" as const; |
| 28 | const cwd = "/repo"; | 34 | const cwd = "/repo"; |
| 29 | const environmentId = EnvironmentId.make("environment-1"); | 35 | const environmentId = EnvironmentId.make("environment-1"); |
| 36 | const isVcsActionUnavailableError = Schema.is(VcsActionUnavailableError); | ||
| 30 | const result: GitRunStackedActionResult = { | 37 | const 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 | } |
| 58 | 65 | ||
| 59 | describe("vcsActionState", () => { | 66 | describe("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 | ); |
| 268 | 298 | ||
| 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 | }); |
| 288 | 397 | ||
| 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 10 | import * as Cause from "effect/Cause"; | 11 | import * as Cause from "effect/Cause"; |
| ... | @@ -24,16 +25,18 @@ import { | ... | @@ -24,16 +25,18 @@ import { |
| 24 | } from "./runtime.ts"; | 25 | } from "./runtime.ts"; |
| 25 | import { vcsCommandScheduler } from "./vcsCommandScheduler.ts"; | 26 | import { vcsCommandScheduler } from "./vcsCommandScheduler.ts"; |
| 26 | 27 | ||
| 27 | export type VcsActionOperation = | 28 | export 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 | ]); | ||
| 39 | export type VcsActionOperation = typeof VcsActionOperation.Type; | ||
| 37 | 40 | ||
| 38 | export interface VcsActionState { | 41 | export interface VcsActionState { |
| 39 | readonly isRunning: boolean; | 42 | readonly isRunning: boolean; |
| ... | @@ -77,16 +80,66 @@ export interface RunVcsStackedActionInput { | ... | @@ -77,16 +80,66 @@ export interface RunVcsStackedActionInput { |
| 77 | export class VcsActionUnavailableError extends Schema.TaggedErrorClass<VcsActionUnavailableError>()( | 80 | export 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 | |||
| 93 | export 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 | } | ||
| 83 | 110 | ||
| 84 | export class VcsActionExecutionError extends Schema.TaggedErrorClass<VcsActionExecutionError>()( | 111 | export 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 | |||
| 126 | export 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 | |||
| 138 | export const VcsActionExecutionError = Schema.Union([ | ||
| 139 | VcsActionRemoteFailureError, | ||
| 140 | VcsActionMissingTerminalEventError, | ||
| 141 | ]); | ||
| 142 | export type VcsActionExecutionError = typeof VcsActionExecutionError.Type; | ||
| 90 | 143 | ||
| 91 | export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({ | 144 | export 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>({ |
| 104 | 157 | ||
| 105 | const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe()); | 158 | const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe()); |
| 106 | let nextLocalActionId = 0; | 159 | let nextLocalActionId = 0; |
| 160 | const decodeVcsActionTargetKey = Schema.decodeUnknownSync( | ||
| 161 | Schema.Tuple([EnvironmentId, Schema.String]), | ||
| 162 | ); | ||
| 107 | 163 | ||
| 108 | export const vcsActionStateAtom = Atom.family((key: string) => { | 164 | export 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 | } |
| 126 | 182 | ||
| 127 | function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget { | 183 | export 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 | } |
| 134 | 191 | ||
| 135 | export function getVcsActionStateAtom(target: VcsActionTarget) { | 192 | export 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( |
| 339 | export function createVcsActionManager<R, E>( | 407 | export 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 | unknown | 434 | 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 | }; |
| 433 | 511 | ||
| ... | @@ -446,10 +524,7 @@ export function createVcsActionManager<R, E>( | ... | @@ -446,10 +524,7 @@ export function createVcsActionManager<R, E>( |
| 446 | 524 | ||
| 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 | ); |