Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions apps/web/src/state/sourceControlActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,6 @@ function resolveScope(scope: SourceControlActionScope) {
};
}

function unavailableResult(message: string) {
return AsyncResult.failure<never, VcsActionUnavailableError>(
Cause.fail(new VcsActionUnavailableError({ message })),
);
}

export function useSourceControlActionRunning(
scope: SourceControlActionScope,
kinds: ReadonlyArray<SourceControlActionKind>,
Expand All @@ -149,7 +143,15 @@ export function useVcsInitAction(scope: SourceControlActionScope) {
const action = useCallback(async () => {
const target = resolveScope(scope);
if (target === null) {
return unavailableResult("Git init is unavailable.");
return AsyncResult.failure<never, VcsActionUnavailableError>(
Cause.fail(
new VcsActionUnavailableError({
operation: "init",
environmentId: scope.environmentId,
cwd: scope.cwd,
}),
),
);
}
return init({
environmentId: target.environmentId,
Expand All @@ -172,7 +174,15 @@ export function useVcsPullAction(scope: SourceControlActionScope) {
const action = useCallback(async () => {
const target = resolveScope(scope);
if (target === null) {
return unavailableResult("Git pull is unavailable.");
return AsyncResult.failure<never, VcsActionUnavailableError>(
Cause.fail(
new VcsActionUnavailableError({
operation: "pull",
environmentId: scope.environmentId,
cwd: scope.cwd,
}),
),
);
}
return pull({
environmentId: target.environmentId,
Expand Down Expand Up @@ -211,7 +221,15 @@ export function useGitStackedAction(scope: SourceControlActionScope) {
onProgress?: (event: GitActionProgressEvent) => void;
}) => {
if (resolveScope(scope) === null) {
return unavailableResult("Git action is unavailable.");
return AsyncResult.failure<never, VcsActionUnavailableError>(
Cause.fail(
new VcsActionUnavailableError({
operation: "run_change_request",
environmentId: scope.environmentId,
cwd: scope.cwd,
}),
),
);
}
return runStackedAction({
actionId: input.actionId,
Expand Down Expand Up @@ -257,7 +275,15 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi
}) => {
const target = resolveScope(scope);
if (target === null) {
return unavailableResult("Repository publishing is unavailable.");
return AsyncResult.failure<never, VcsActionUnavailableError>(
Cause.fail(
new VcsActionUnavailableError({
operation: "publish_repository",
environmentId: scope.environmentId,
cwd: scope.cwd,
}),
),
);
}
return publishRepository({
environmentId: target.environmentId,
Expand Down Expand Up @@ -286,7 +312,15 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop
async (input: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => {
const target = resolveScope(scope);
if (target === null) {
return unavailableResult("Pull request thread preparation is unavailable.");
return AsyncResult.failure<never, VcsActionUnavailableError>(
Cause.fail(
new VcsActionUnavailableError({
operation: "prepare_pull_request_thread",
environmentId: scope.environmentId,
cwd: scope.cwd,
}),
),
);
}
return preparePullRequestThread({
environmentId: target.environmentId,
Expand Down
141 changes: 141 additions & 0 deletions packages/client-runtime/src/state/vcsAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { describe, expect, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";

Expand All @@ -21,12 +22,18 @@ import {
EMPTY_VCS_ACTION_STATE,
getVcsActionTargetKey,
normalizeVcsActionProgressEvent,
parseVcsActionTargetKey,
VcsActionMissingTerminalEventError,
VcsActionRemoteFailureError,
VcsActionTargetKeyParseError,
VcsActionUnavailableError,
} from "./vcsAction.ts";

const actionId = "action-123";
const action = "commit_push" as const;
const cwd = "/repo";
const environmentId = EnvironmentId.make("environment-1");
const isVcsActionUnavailableError = Schema.is(VcsActionUnavailableError);
const result: GitRunStackedActionResult = {
action,
branch: {
Expand Down Expand Up @@ -57,6 +64,28 @@ function progress<T extends GitActionProgressEvent>(event: T): T {
}

describe("vcsActionState", () => {
it("preserves malformed target key diagnostics and the native cause without copying the key", () => {
const key = "not-json-with-credential=do-not-log";
let error: unknown;

try {
parseVcsActionTargetKey(key);
} catch (cause) {
error = cause;
}

expect(error).toBeInstanceOf(VcsActionTargetKeyParseError);
expect(error).toMatchObject({ keyLength: key.length, cause: expect.any(SyntaxError) });
expect(error).not.toHaveProperty("key");
expect((error as Error).message).not.toContain(key);
});

it("rejects invalid target key shapes", () => {
const key = JSON.stringify([environmentId]);

expect(() => parseVcsActionTargetKey(key)).toThrowError(VcsActionTargetKeyParseError);
});

it("projects phase and hook progress without owning the async operation", () => {
const initial = beginVcsActionState({
operation: "run_change_request",
Expand Down Expand Up @@ -254,6 +283,7 @@ describe("vcsActionState", () => {
target,
transportActionId,
actionId,
action,
onProgress: (event) =>
Effect.sync(() => {
observed.push(event);
Expand All @@ -266,6 +296,85 @@ describe("vcsActionState", () => {
}),
);

it.effect("retains structural remote failure context without copying the remote payload", () =>
Effect.gen(function* () {
const target = { environmentId, cwd };
const transportActionId = createVcsActionTransportId(target, actionId);
const remoteMessage = "The remote rejected the push with credential=do-not-log.";
const error = yield* consumeVcsActionProgress(
Stream.fromIterable<GitActionProgressEvent>([
{
actionId: transportActionId,
action,
cwd,
kind: "action_failed",
phase: "push",
message: remoteMessage,
},
]),
{
target,
transportActionId,
actionId,
action,
onProgress: () => Effect.void,
},
).pipe(Effect.flip);

expect(error).toBeInstanceOf(VcsActionRemoteFailureError);
expect(error).toMatchObject({
actionId,
transportActionId,
action,
environmentId,
cwd,
phase: "push",
remoteMessageLength: remoteMessage.length,
});
expect(error).not.toHaveProperty("detail");
expect(error.message).toBe("Source control action 'commit_push' failed during push.");
expect(error.message).not.toContain(remoteMessage);
}),
);

it.effect("reports a missing terminal event as a protocol failure", () =>
Effect.gen(function* () {
const target = { environmentId, cwd };
const transportActionId = createVcsActionTransportId(target, actionId);
const error = yield* consumeVcsActionProgress(
Stream.fromIterable<GitActionProgressEvent>([
{
actionId: transportActionId,
action,
cwd,
kind: "phase_started",
phase: "commit",
label: "Committing...",
},
]),
{
target,
transportActionId,
actionId,
action,
onProgress: () => Effect.void,
},
).pipe(Effect.flip);

expect(error).toBeInstanceOf(VcsActionMissingTerminalEventError);
expect(error).toMatchObject({
actionId,
transportActionId,
action,
environmentId,
cwd,
});
expect(error.message).toBe(
"Source control action 'commit_push' ended without a terminal result.",
);
}),
);

it("keys mutation ownership by environment and cwd", () => {
const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
EnvironmentRegistry,
Expand All @@ -286,6 +395,38 @@ describe("vcsActionState", () => {
registry.dispose();
});

it("retains the incomplete target and operation when tracking is unavailable", async () => {
const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
EnvironmentRegistry,
never
>;
const manager = createVcsActionManager(runtime);
const registry = AtomRegistry.make();
const result = await manager.track(
registry,
{ environmentId, cwd: null },
{ operation: "pull", label: "Pulling latest changes" },
async () => AsyncResult.success(undefined),
);

expect(AsyncResult.isFailure(result)).toBe(true);
if (AsyncResult.isFailure(result)) {
const error = Cause.squash(result.cause);
expect(error).toBeInstanceOf(VcsActionUnavailableError);
if (!isVcsActionUnavailableError(error)) {
throw error;
}
expect(error).toMatchObject({
operation: "pull",
environmentId,
cwd: null,
});
expect(error.message).toBe("Source control operation 'pull' is unavailable.");
}

registry.dispose();
});

it("tracks finite mutations without letting an older completion clear newer state", async () => {
const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
EnvironmentRegistry,
Expand Down
Loading
Loading