authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:14:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:14:55-07:00
log834e5db45843d8b5da4fbfe1999fbaa4c58e117b
tree6ac17380d4bea5227ad6408b91a8bf3adf7a1831
parent8ce627ba91a6c4e5b336bc50c2fa06ebf4f4ade6
signature Signed by PGP key B5690EEEBB952194

[codex] structure project CLI failures (#3339)

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

2 files changed, 118 insertions(+), 43 deletions(-)

apps/server/src/cli/project.test.ts created+32
......@@ -0,0 +1,32 @@
1import { assert, it } from "@effect/vitest";
2
3import { EnvironmentInternalError } from "@t3tools/contracts";
4
5import { ProjectCommandError } from "./project.ts";
6
7it("maps declared server failures into structural project command errors", () => {
8 const cause = new EnvironmentInternalError({
9 code: "internal_error",
10 reason: "orchestration_snapshot_failed",
11 traceId: "trace-123",
12 });
13
14 const error = ProjectCommandError.fromLiveServerRequest(cause);
15
16 assert.strictEqual(error.operation, "callLiveServer");
17 assert.strictEqual(error.code, "internal_error");
18 assert.strictEqual(error.traceId, "trace-123");
19 assert.strictEqual(error.message, "Server request failed (internal_error, trace trace-123).");
20 assert.strictEqual(error.cause, cause);
21});
22
23it("preserves unexpected server failures without deriving the message from them", () => {
24 const cause = new Error("credential abc123 was rejected");
25
26 const error = ProjectCommandError.fromLiveServerRequest(cause);
27
28 assert.strictEqual(error.operation, "callLiveServer");
29 assert.strictEqual(error.detail, "Failed to call the running server.");
30 assert.strictEqual(error.message, "Failed to call the running server.");
31 assert.strictEqual(error.cause, cause);
32});
apps/server/src/cli/project.ts+86-43
......@@ -9,11 +9,9 @@ import {
99} from "@t3tools/contracts";
1010import * as Console from "effect/Console";
1111import * as Crypto from "effect/Crypto";
12import * as Data from "effect/Data";
1312import * as DateTime from "effect/DateTime";
1413import * as Duration from "effect/Duration";
1514import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
1715import * as FileSystem from "effect/FileSystem";
1816import * as Layer from "effect/Layer";
1917import * as Option from "effect/Option";
......@@ -52,16 +50,64 @@ type ProjectCliDispatchCommand = Extract<
5250 { type: "project.create" | "project.meta.update" | "project.delete" }
5351>;
5452
55class ProjectCommandError extends Data.TaggedError("ProjectCommandError")<{
56 readonly message: string;
57}> {}
53const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
54const ProjectCommandOperation = Schema.Literals([
55 "generateProjectCommandId",
56 "callLiveServer",
57 "validateProjectTitle",
58 "resolveProjectTarget",
59 "addProject",
60]);
61
62export class ProjectCommandError extends Schema.TaggedErrorClass<ProjectCommandError>()(
63 "ProjectCommandError",
64 {
65 operation: ProjectCommandOperation,
66 detail: Schema.String,
67 code: Schema.optional(Schema.String),
68 traceId: Schema.optional(Schema.String),
69 status: Schema.optional(Schema.Number),
70 cause: Schema.optional(Schema.Defect()),
71 },
72) {
73 static fromLiveServerRequest(cause: unknown): ProjectCommandError {
74 if (isEnvironmentHttpCommonError(cause)) {
75 return new ProjectCommandError({
76 operation: "callLiveServer",
77 detail: `Server request failed (${cause.code}, trace ${cause.traceId}).`,
78 code: cause.code,
79 traceId: cause.traceId,
80 cause,
81 });
82 }
83 if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
84 return new ProjectCommandError({
85 operation: "callLiveServer",
86 detail: `Server request failed with undeclared status ${cause.response.status}.`,
87 status: cause.response.status,
88 cause,
89 });
90 }
91 return new ProjectCommandError({
92 operation: "callLiveServer",
93 detail: "Failed to call the running server.",
94 cause,
95 });
96 }
97
98 override get message(): string {
99 return this.detail;
100 }
101}
58102
59103const projectCommandUuid = Crypto.Crypto.pipe(
60104 Effect.flatMap((crypto) => crypto.randomUUIDv4),
61105 Effect.mapError(
62 () =>
106 (cause) =>
63107 new ProjectCommandError({
64 message: "Failed to generate a project command identifier.",
108 operation: "generateProjectCommandId",
109 detail: "Failed to generate a project command identifier.",
110 cause,
65111 }),
66112 ),
67113);
......@@ -75,7 +121,6 @@ const ProjectCliRuntimeLive = Layer.mergeAll(
75121);
76122
77123const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);
78const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
79124const withProjectCliSessionToken = <A, E, R>(
80125 environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"],
81126 run: (token: string) => Effect.Effect<A, E, R>,
......@@ -92,28 +137,6 @@ const withProjectCliSessionToken = <A, E, R>(
92137const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
93138 effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));
94139
95const failLiveServerRequest = (cause: unknown) => {
96 if (isEnvironmentHttpCommonError(cause)) {
97 return Effect.fail(
98 new ProjectCommandError({
99 message: `Server request failed (${cause.code}, trace ${cause.traceId}).`,
100 }),
101 );
102 }
103 if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
104 return Effect.fail(
105 new ProjectCommandError({
106 message: `Server request failed with undeclared status ${cause.response.status}.`,
107 }),
108 );
109 }
110 return Effect.fail(
111 new ProjectCommandError({
112 message: `Failed to call running server: ${String(cause)}.`,
113 }),
114 );
115};
116
117140const makeLiveServerClient = (origin: string) =>
118141 HttpApiClient.make(EnvironmentHttpApi, {
119142 baseUrl: origin,
......@@ -135,7 +158,10 @@ const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (
135158 if (trimmed.length > 0) {
136159 return trimmed;
137160 }
138 return yield* new ProjectCommandError({ message: "Project title cannot be empty." });
161 return yield* new ProjectCommandError({
162 operation: "validateProjectTitle",
163 detail: "Project title cannot be empty.",
164 });
139165 }
140166
141167 const path = yield* Path.Path;
......@@ -149,7 +175,10 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (
149175}) {
150176 const trimmedIdentifier = input.identifier.trim();
151177 if (trimmedIdentifier.length === 0) {
152 return yield* new ProjectCommandError({ message: "Project identifier cannot be empty." });
178 return yield* new ProjectCommandError({
179 operation: "resolveProjectTarget",
180 detail: "Project identifier cannot be empty.",
181 });
153182 }
154183
155184 const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null);
......@@ -162,12 +191,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (
162191 } satisfies ProjectMutationTarget;
163192 }
164193
165 const normalizedWorkspaceRootResult = yield* Effect.exit(
194 const normalizedWorkspaceRootResult = yield* Effect.result(
166195 normalizeWorkspaceRootForProjectCommand(trimmedIdentifier),
167196 );
168 const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult)
169 ? normalizedWorkspaceRootResult.value
170 : null;
197 const normalizedWorkspaceRoot =
198 normalizedWorkspaceRootResult._tag === "Success" ? normalizedWorkspaceRootResult.success : null;
171199
172200 const exactWorkspaceMatch =
173201 normalizedWorkspaceRoot === null
......@@ -177,7 +205,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (
177205 const resolved = exactWorkspaceMatch;
178206 if (!resolved) {
179207 return yield* new ProjectCommandError({
180 message: `No active project found for '${trimmedIdentifier}'.`,
208 operation: "resolveProjectTarget",
209 detail: `No active project found for '${trimmedIdentifier}'.`,
210 ...(normalizedWorkspaceRootResult._tag === "Failure"
211 ? { cause: normalizedWorkspaceRootResult.failure }
212 : {}),
181213 });
182214 }
183215
......@@ -194,7 +226,10 @@ const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
194226 return yield* client.orchestration.snapshot({
195227 headers: { authorization: `Bearer ${bearerToken}` },
196228 });
197 }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest));
229 }).pipe(
230 withProjectCliLiveServerTimeout,
231 Effect.mapError(ProjectCommandError.fromLiveServerRequest),
232 );
198233
199234const dispatchLiveOrchestrationCommand = (
200235 origin: string,
......@@ -207,7 +242,10 @@ const dispatchLiveOrchestrationCommand = (
207242 headers: { authorization: `Bearer ${bearerToken}` },
208243 payload: command,
209244 } as Parameters<typeof client.orchestration.dispatch>[0]);
210 }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest));
245 }).pipe(
246 withProjectCliLiveServerTimeout,
247 Effect.mapError(ProjectCommandError.fromLiveServerRequest),
248 );
211249
212250const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
213251 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
......@@ -232,11 +270,15 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu
232270 ),
233271 );
234272
235 const attempted = yield* Effect.exit(attempt);
236 if (Exit.isSuccess(attempted)) {
237 return Option.some(attempted.value);
273 const attempted = yield* Effect.result(attempt);
274 if (attempted._tag === "Success") {
275 return Option.some(attempted.success);
238276 }
239277
278 yield* Effect.logDebug("Failed to connect to the persisted project CLI server.", {
279 origin: runtimeState.value.origin,
280 cause: attempted.failure,
281 });
240282 yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);
241283 return Option.none<{ readonly origin: string }>();
242284 },
......@@ -335,7 +377,8 @@ const projectAddCommand = Command.make("add", {
335377 );
336378 if (existingProject) {
337379 return yield* new ProjectCommandError({
338 message: `An active project already exists for '${workspaceRoot}'.`,
380 operation: "addProject",
381 detail: `An active project already exists for '${workspaceRoot}'.`,
339382 });
340383 }
341384