authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 18:19:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 18:19:07-07:00
log23ab75e84ab0680f6e71bf08dfb4b832d4bc4c87
treeb69a552a0b6519760d2b624ed80b0f39791ef4b0
parent0488e47d9c0d0ca42595341b15c1d187ea7107c8
signature Signed by PGP key B5690EEEBB952194

[codex] Split project command failures (#3459)

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

2 files changed, 147 insertions(+), 53 deletions(-)

apps/server/src/cli/project.test.ts+9-4
...@@ -2,7 +2,11 @@ import { assert, it } from "@effect/vitest";...@@ -2,7 +2,11 @@ import { assert, it } from "@effect/vitest";
22
3import { EnvironmentInternalError } from "@t3tools/contracts";3import { EnvironmentInternalError } from "@t3tools/contracts";
44
5import { ProjectCommandError } from "./project.ts";5import {
6 ProjectLiveServerDeclaredResponseError,
7 ProjectLiveServerRequestError,
8 projectCommandErrorFromLiveServerRequest,
9} from "./project.ts";
610
7it("maps declared server failures into structural project command errors", () => {11it("maps declared server failures into structural project command errors", () => {
8 const cause = new EnvironmentInternalError({12 const cause = new EnvironmentInternalError({
...@@ -11,8 +15,9 @@ it("maps declared server failures into structural project command errors", () =>...@@ -11,8 +15,9 @@ it("maps declared server failures into structural project command errors", () =>
11 traceId: "trace-123",15 traceId: "trace-123",
12 });16 });
1317
14 const error = ProjectCommandError.fromLiveServerRequest(cause);18 const error = projectCommandErrorFromLiveServerRequest(cause);
1519
20 assert.instanceOf(error, ProjectLiveServerDeclaredResponseError);
16 assert.strictEqual(error.operation, "callLiveServer");21 assert.strictEqual(error.operation, "callLiveServer");
17 assert.strictEqual(error.code, "internal_error");22 assert.strictEqual(error.code, "internal_error");
18 assert.strictEqual(error.traceId, "trace-123");23 assert.strictEqual(error.traceId, "trace-123");
...@@ -23,10 +28,10 @@ it("maps declared server failures into structural project command errors", () =>...@@ -23,10 +28,10 @@ it("maps declared server failures into structural project command errors", () =>
23it("preserves unexpected server failures without deriving the message from them", () => {28it("preserves unexpected server failures without deriving the message from them", () => {
24 const cause = new Error("credential abc123 was rejected");29 const cause = new Error("credential abc123 was rejected");
2530
26 const error = ProjectCommandError.fromLiveServerRequest(cause);31 const error = projectCommandErrorFromLiveServerRequest(cause);
2732
33 assert.instanceOf(error, ProjectLiveServerRequestError);
28 assert.strictEqual(error.operation, "callLiveServer");34 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.");35 assert.strictEqual(error.message, "Failed to call the running server.");
31 assert.strictEqual(error.cause, cause);36 assert.strictEqual(error.cause, cause);
32});37});
apps/server/src/cli/project.ts+138-49
...@@ -51,62 +51,148 @@ type ProjectCliDispatchCommand = Extract<...@@ -51,62 +51,148 @@ type ProjectCliDispatchCommand = Extract<
51>;51>;
5252
53const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);53const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
54const ProjectCommandOperation = Schema.Literals([
55 "generateProjectCommandId",
56 "callLiveServer",
57 "validateProjectTitle",
58 "resolveProjectTarget",
59 "addProject",
60]);
6154
62export class ProjectCommandError extends Schema.TaggedErrorClass<ProjectCommandError>()(55export class ProjectCommandIdGenerationError extends Schema.TaggedErrorClass<ProjectCommandIdGenerationError>()(
63 "ProjectCommandError",56 "ProjectCommandIdGenerationError",
57 {
58 operation: Schema.Literal("generateProjectCommandId"),
59 cause: Schema.Defect(),
60 },
61) {
62 override get message(): string {
63 return "Failed to generate a project command identifier.";
64 }
65}
66
67export class ProjectLiveServerDeclaredResponseError extends Schema.TaggedErrorClass<ProjectLiveServerDeclaredResponseError>()(
68 "ProjectLiveServerDeclaredResponseError",
69 {
70 operation: Schema.Literal("callLiveServer"),
71 code: Schema.String,
72 traceId: Schema.String,
73 cause: Schema.Defect(),
74 },
75) {
76 override get message(): string {
77 return `Server request failed (${this.code}, trace ${this.traceId}).`;
78 }
79}
80
81export class ProjectLiveServerUndeclaredStatusError extends Schema.TaggedErrorClass<ProjectLiveServerUndeclaredStatusError>()(
82 "ProjectLiveServerUndeclaredStatusError",
83 {
84 operation: Schema.Literal("callLiveServer"),
85 status: Schema.Int,
86 cause: Schema.Defect(),
87 },
88) {
89 override get message(): string {
90 return `Server request failed with undeclared status ${this.status}.`;
91 }
92}
93
94export class ProjectLiveServerRequestError extends Schema.TaggedErrorClass<ProjectLiveServerRequestError>()(
95 "ProjectLiveServerRequestError",
96 {
97 operation: Schema.Literal("callLiveServer"),
98 cause: Schema.Defect(),
99 },
100) {
101 override get message(): string {
102 return "Failed to call the running server.";
103 }
104}
105
106export class ProjectTitleEmptyError extends Schema.TaggedErrorClass<ProjectTitleEmptyError>()(
107 "ProjectTitleEmptyError",
108 {
109 operation: Schema.Literal("validateProjectTitle"),
110 title: Schema.String,
111 },
112) {
113 override get message(): string {
114 return "Project title cannot be empty.";
115 }
116}
117
118export class ProjectIdentifierEmptyError extends Schema.TaggedErrorClass<ProjectIdentifierEmptyError>()(
119 "ProjectIdentifierEmptyError",
120 {
121 operation: Schema.Literal("resolveProjectTarget"),
122 identifier: Schema.String,
123 },
124) {
125 override get message(): string {
126 return "Project identifier cannot be empty.";
127 }
128}
129
130export class ProjectNotFoundError extends Schema.TaggedErrorClass<ProjectNotFoundError>()(
131 "ProjectNotFoundError",
64 {132 {
65 operation: ProjectCommandOperation,133 operation: Schema.Literal("resolveProjectTarget"),
66 detail: Schema.String,134 identifier: Schema.String,
67 code: Schema.optional(Schema.String),135 normalizedWorkspaceRoot: Schema.optional(Schema.String),
68 traceId: Schema.optional(Schema.String),136 activeProjectCount: Schema.Number,
69 status: Schema.optional(Schema.Number),
70 cause: Schema.optional(Schema.Defect()),137 cause: Schema.optional(Schema.Defect()),
71 },138 },
72) {139) {
73 static fromLiveServerRequest(cause: unknown): ProjectCommandError {140 override get message(): string {
74 if (isEnvironmentHttpCommonError(cause)) {141 return `No active project found for '${this.identifier}'.`;
75 return new ProjectCommandError({142 }
76 operation: "callLiveServer",143}
77 detail: `Server request failed (${cause.code}, trace ${cause.traceId}).`,144
78 code: cause.code,145export class ProjectAlreadyExistsError extends Schema.TaggedErrorClass<ProjectAlreadyExistsError>()(
79 traceId: cause.traceId,146 "ProjectAlreadyExistsError",
80 cause,147 {
81 });148 operation: Schema.Literal("addProject"),
82 }149 projectId: ProjectId,
83 if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {150 workspaceRoot: Schema.String,
84 return new ProjectCommandError({151 },
85 operation: "callLiveServer",152) {
86 detail: `Server request failed with undeclared status ${cause.response.status}.`,153 override get message(): string {
87 status: cause.response.status,154 return `An active project already exists for '${this.workspaceRoot}'.`;
88 cause,155 }
89 });156}
90 }157
91 return new ProjectCommandError({158export const ProjectCommandError = Schema.Union([
159 ProjectCommandIdGenerationError,
160 ProjectLiveServerDeclaredResponseError,
161 ProjectLiveServerUndeclaredStatusError,
162 ProjectLiveServerRequestError,
163 ProjectTitleEmptyError,
164 ProjectIdentifierEmptyError,
165 ProjectNotFoundError,
166 ProjectAlreadyExistsError,
167]);
168export type ProjectCommandError = typeof ProjectCommandError.Type;
169
170export function projectCommandErrorFromLiveServerRequest(cause: unknown): ProjectCommandError {
171 if (isEnvironmentHttpCommonError(cause)) {
172 return new ProjectLiveServerDeclaredResponseError({
92 operation: "callLiveServer",173 operation: "callLiveServer",
93 detail: "Failed to call the running server.",174 code: cause.code,
175 traceId: cause.traceId,
94 cause,176 cause,
95 });177 });
96 }178 }
97179 if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
98 override get message(): string {180 return new ProjectLiveServerUndeclaredStatusError({
99 return this.detail;181 operation: "callLiveServer",
182 status: cause.response.status,
183 cause,
184 });
100 }185 }
186
187 return new ProjectLiveServerRequestError({ operation: "callLiveServer", cause });
101}188}
102189
103const projectCommandUuid = Crypto.Crypto.pipe(190const projectCommandUuid = Crypto.Crypto.pipe(
104 Effect.flatMap((crypto) => crypto.randomUUIDv4),191 Effect.flatMap((crypto) => crypto.randomUUIDv4),
105 Effect.mapError(192 Effect.mapError(
106 (cause) =>193 (cause) =>
107 new ProjectCommandError({194 new ProjectCommandIdGenerationError({
108 operation: "generateProjectCommandId",195 operation: "generateProjectCommandId",
109 detail: "Failed to generate a project command identifier.",
110 cause,196 cause,
111 }),197 }),
112 ),198 ),
...@@ -158,9 +244,9 @@ const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (...@@ -158,9 +244,9 @@ const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (
158 if (trimmed.length > 0) {244 if (trimmed.length > 0) {
159 return trimmed;245 return trimmed;
160 }246 }
161 return yield* new ProjectCommandError({247 return yield* new ProjectTitleEmptyError({
162 operation: "validateProjectTitle",248 operation: "validateProjectTitle",
163 detail: "Project title cannot be empty.",249 title: explicitTitle,
164 });250 });
165 }251 }
166252
...@@ -175,9 +261,9 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (...@@ -175,9 +261,9 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (
175}) {261}) {
176 const trimmedIdentifier = input.identifier.trim();262 const trimmedIdentifier = input.identifier.trim();
177 if (trimmedIdentifier.length === 0) {263 if (trimmedIdentifier.length === 0) {
178 return yield* new ProjectCommandError({264 return yield* new ProjectIdentifierEmptyError({
179 operation: "resolveProjectTarget",265 operation: "resolveProjectTarget",
180 detail: "Project identifier cannot be empty.",266 identifier: input.identifier,
181 });267 });
182 }268 }
183269
...@@ -204,9 +290,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (...@@ -204,9 +290,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (
204290
205 const resolved = exactWorkspaceMatch;291 const resolved = exactWorkspaceMatch;
206 if (!resolved) {292 if (!resolved) {
207 return yield* new ProjectCommandError({293 return yield* new ProjectNotFoundError({
208 operation: "resolveProjectTarget",294 operation: "resolveProjectTarget",
209 detail: `No active project found for '${trimmedIdentifier}'.`,295 identifier: trimmedIdentifier,
296 activeProjectCount: activeProjects.length,
297 ...(normalizedWorkspaceRoot === null ? {} : { normalizedWorkspaceRoot }),
210 ...(normalizedWorkspaceRootResult._tag === "Failure"298 ...(normalizedWorkspaceRootResult._tag === "Failure"
211 ? { cause: normalizedWorkspaceRootResult.failure }299 ? { cause: normalizedWorkspaceRootResult.failure }
212 : {}),300 : {}),
...@@ -228,7 +316,7 @@ const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>...@@ -228,7 +316,7 @@ const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
228 });316 });
229 }).pipe(317 }).pipe(
230 withProjectCliLiveServerTimeout,318 withProjectCliLiveServerTimeout,
231 Effect.mapError(ProjectCommandError.fromLiveServerRequest),319 Effect.mapError(projectCommandErrorFromLiveServerRequest),
232 );320 );
233321
234const dispatchLiveOrchestrationCommand = (322const dispatchLiveOrchestrationCommand = (
...@@ -244,7 +332,7 @@ const dispatchLiveOrchestrationCommand = (...@@ -244,7 +332,7 @@ const dispatchLiveOrchestrationCommand = (
244 } as Parameters<typeof client.orchestration.dispatch>[0]);332 } as Parameters<typeof client.orchestration.dispatch>[0]);
245 }).pipe(333 }).pipe(
246 withProjectCliLiveServerTimeout,334 withProjectCliLiveServerTimeout,
247 Effect.mapError(ProjectCommandError.fromLiveServerRequest),335 Effect.mapError(projectCommandErrorFromLiveServerRequest),
248 );336 );
249337
250const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {338const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
...@@ -376,9 +464,10 @@ const projectAddCommand = Command.make("add", {...@@ -376,9 +464,10 @@ const projectAddCommand = Command.make("add", {
376 (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot,464 (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot,
377 );465 );
378 if (existingProject) {466 if (existingProject) {
379 return yield* new ProjectCommandError({467 return yield* new ProjectAlreadyExistsError({
380 operation: "addProject",468 operation: "addProject",
381 detail: `An active project already exists for '${workspaceRoot}'.`,469 projectId: existingProject.id,
470 workspaceRoot,
382 });471 });
383 }472 }
384473