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 {...@@ -9,11 +9,9 @@ import {
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import * as Console from "effect/Console";10import * as Console from "effect/Console";
11import * as Crypto from "effect/Crypto";11import * as Crypto from "effect/Crypto";
12import * as Data from "effect/Data";
13import * as DateTime from "effect/DateTime";12import * as DateTime from "effect/DateTime";
14import * as Duration from "effect/Duration";13import * as Duration from "effect/Duration";
15import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
17import * as FileSystem from "effect/FileSystem";15import * as FileSystem from "effect/FileSystem";
18import * as Layer from "effect/Layer";16import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";17import * as Option from "effect/Option";
...@@ -52,16 +50,64 @@ type ProjectCliDispatchCommand = Extract<...@@ -52,16 +50,64 @@ type ProjectCliDispatchCommand = Extract<
52 { type: "project.create" | "project.meta.update" | "project.delete" }50 { type: "project.create" | "project.meta.update" | "project.delete" }
53>;51>;
5452
55class ProjectCommandError extends Data.TaggedError("ProjectCommandError")<{53const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
56 readonly message: string;54const ProjectCommandOperation = Schema.Literals([
57}> {}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
59const projectCommandUuid = Crypto.Crypto.pipe(103const projectCommandUuid = Crypto.Crypto.pipe(
60 Effect.flatMap((crypto) => crypto.randomUUIDv4),104 Effect.flatMap((crypto) => crypto.randomUUIDv4),
61 Effect.mapError(105 Effect.mapError(
62 () =>106 (cause) =>
63 new ProjectCommandError({107 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,
65 }),111 }),
66 ),112 ),
67);113);
...@@ -75,7 +121,6 @@ const ProjectCliRuntimeLive = Layer.mergeAll(...@@ -75,7 +121,6 @@ const ProjectCliRuntimeLive = Layer.mergeAll(
75);121);
76122
77const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);123const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);
78const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
79const withProjectCliSessionToken = <A, E, R>(124const withProjectCliSessionToken = <A, E, R>(
80 environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"],125 environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"],
81 run: (token: string) => Effect.Effect<A, E, R>,126 run: (token: string) => Effect.Effect<A, E, R>,
...@@ -92,28 +137,6 @@ const withProjectCliSessionToken = <A, E, R>(...@@ -92,28 +137,6 @@ const withProjectCliSessionToken = <A, E, R>(
92const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>137const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
93 effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));138 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
117const makeLiveServerClient = (origin: string) =>140const makeLiveServerClient = (origin: string) =>
118 HttpApiClient.make(EnvironmentHttpApi, {141 HttpApiClient.make(EnvironmentHttpApi, {
119 baseUrl: origin,142 baseUrl: origin,
...@@ -135,7 +158,10 @@ const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (...@@ -135,7 +158,10 @@ const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (
135 if (trimmed.length > 0) {158 if (trimmed.length > 0) {
136 return trimmed;159 return trimmed;
137 }160 }
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 });
139 }165 }
140166
141 const path = yield* Path.Path;167 const path = yield* Path.Path;
...@@ -149,7 +175,10 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (...@@ -149,7 +175,10 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (
149}) {175}) {
150 const trimmedIdentifier = input.identifier.trim();176 const trimmedIdentifier = input.identifier.trim();
151 if (trimmedIdentifier.length === 0) {177 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 });
153 }182 }
154183
155 const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null);184 const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null);
...@@ -162,12 +191,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (...@@ -162,12 +191,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (
162 } satisfies ProjectMutationTarget;191 } satisfies ProjectMutationTarget;
163 }192 }
164193
165 const normalizedWorkspaceRootResult = yield* Effect.exit(194 const normalizedWorkspaceRootResult = yield* Effect.result(
166 normalizeWorkspaceRootForProjectCommand(trimmedIdentifier),195 normalizeWorkspaceRootForProjectCommand(trimmedIdentifier),
167 );196 );
168 const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult)197 const normalizedWorkspaceRoot =
169 ? normalizedWorkspaceRootResult.value198 normalizedWorkspaceRootResult._tag === "Success" ? normalizedWorkspaceRootResult.success : null;
170 : null;
171199
172 const exactWorkspaceMatch =200 const exactWorkspaceMatch =
173 normalizedWorkspaceRoot === null201 normalizedWorkspaceRoot === null
...@@ -177,7 +205,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (...@@ -177,7 +205,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (
177 const resolved = exactWorkspaceMatch;205 const resolved = exactWorkspaceMatch;
178 if (!resolved) {206 if (!resolved) {
179 return yield* new ProjectCommandError({207 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 : {}),
181 });213 });
182 }214 }
183215
...@@ -194,7 +226,10 @@ const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>...@@ -194,7 +226,10 @@ const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
194 return yield* client.orchestration.snapshot({226 return yield* client.orchestration.snapshot({
195 headers: { authorization: `Bearer ${bearerToken}` },227 headers: { authorization: `Bearer ${bearerToken}` },
196 });228 });
197 }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest));229 }).pipe(
230 withProjectCliLiveServerTimeout,
231 Effect.mapError(ProjectCommandError.fromLiveServerRequest),
232 );
198233
199const dispatchLiveOrchestrationCommand = (234const dispatchLiveOrchestrationCommand = (
200 origin: string,235 origin: string,
...@@ -207,7 +242,10 @@ const dispatchLiveOrchestrationCommand = (...@@ -207,7 +242,10 @@ const dispatchLiveOrchestrationCommand = (
207 headers: { authorization: `Bearer ${bearerToken}` },242 headers: { authorization: `Bearer ${bearerToken}` },
208 payload: command,243 payload: command,
209 } as Parameters<typeof client.orchestration.dispatch>[0]);244 } as Parameters<typeof client.orchestration.dispatch>[0]);
210 }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest));245 }).pipe(
246 withProjectCliLiveServerTimeout,
247 Effect.mapError(ProjectCommandError.fromLiveServerRequest),
248 );
211249
212const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {250const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
213 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;251 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
...@@ -232,11 +270,15 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu...@@ -232,11 +270,15 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu
232 ),270 ),
233 );271 );
234272
235 const attempted = yield* Effect.exit(attempt);273 const attempted = yield* Effect.result(attempt);
236 if (Exit.isSuccess(attempted)) {274 if (attempted._tag === "Success") {
237 return Option.some(attempted.value);275 return Option.some(attempted.success);
238 }276 }
239277
278 yield* Effect.logDebug("Failed to connect to the persisted project CLI server.", {
279 origin: runtimeState.value.origin,
280 cause: attempted.failure,
281 });
240 yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);282 yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);
241 return Option.none<{ readonly origin: string }>();283 return Option.none<{ readonly origin: string }>();
242 },284 },
...@@ -335,7 +377,8 @@ const projectAddCommand = Command.make("add", {...@@ -335,7 +377,8 @@ const projectAddCommand = Command.make("add", {
335 );377 );
336 if (existingProject) {378 if (existingProject) {
337 return yield* new ProjectCommandError({379 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}'.`,
339 });382 });
340 }383 }
341384