1import {
2 type CommandId,
3 type EventId,
4 MAX_SCRIPT_ID_LENGTH,
5 type ModelSelection,
6 type ProjectIconOverride,
7 ProjectId,
8 type ProjectScript,
9 SCRIPT_RUN_COMMAND_PATTERN,
10 type ThreadEnvMode,
11} from "@t3tools/contracts";
12import * as DateTime from "effect/DateTime";
13import * as Result from "effect/Result";
14import * as Schema from "effect/Schema";
15
16import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts";
17import type { ProjectRow } from "./ProjectStore.ts";
18
19export interface ProjectCreateCommand {
20 readonly type: "project.create";
21 readonly commandId: CommandId;
22 readonly projectId: ProjectId;
23 readonly title: string;
24 readonly workspaceRoot: string;
25 readonly scripts?: ReadonlyArray<ProjectScript>;
26}
27
28export interface ProjectMetaUpdateCommand {
29 readonly type: "project.meta.update";
30 readonly commandId: CommandId;
31 readonly projectId: ProjectId;
32 readonly title?: string;
33 readonly workspaceRoot?: string;
34 readonly defaultModelSelection?: ModelSelection | null;
35 readonly defaultThreadEnvMode?: ThreadEnvMode | null;
36 readonly autoPull?: boolean;
37 readonly faviconPath?: string | null;
38 readonly projectIcon?: ProjectIconOverride | null;
39 readonly scripts?: ReadonlyArray<ProjectScript>;
40}
41
42export interface ProjectDeleteCommand {
43 readonly type: "project.delete";
44 readonly commandId: CommandId;
45 readonly projectId: ProjectId;
46}
47
48export type ProjectCommand = ProjectCreateCommand | ProjectMetaUpdateCommand | ProjectDeleteCommand;
49
50export class ProjectCommandInvariantError extends Schema.TaggedError<ProjectCommandInvariantError>()(
51 "ProjectCommandInvariantError",
52 {
53 commandType: Schema.String,
54 detail: Schema.String,
55 },
56) {
57 override get message(): string {
58 return `Project command invariant failed (${this.commandType}): ${this.detail}`;
59 }
60}
61
62/** The command targets a project that does not exist or was deleted. */
63export class ProjectCommandMissingProjectError extends Schema.TaggedError<ProjectCommandMissingProjectError>()(
64 "ProjectCommandMissingProjectError",
65 {
66 commandType: Schema.String,
67 projectId: ProjectId,
68 },
69) {
70 override get message(): string {
71 return `Project '${this.projectId}' does not exist for command '${this.commandType}'.`;
72 }
73}
74
75export class ProjectWorkspaceConflictError extends Schema.TaggedError<ProjectWorkspaceConflictError>()(
76 "ProjectWorkspaceConflictError",
77 {
78 workspaceRoot: Schema.String,
79 conflictingProjectId: ProjectId,
80 },
81) {
82 override get message(): string {
83 return `Active project '${this.conflictingProjectId}' already exists for workspace root '${this.workspaceRoot}'.`;
84 }
85}
86
87export const ProjectCommandRejection = Schema.Union([
88 ProjectCommandInvariantError,
89 ProjectCommandMissingProjectError,
90 ProjectWorkspaceConflictError,
91]);
92export type ProjectCommandRejection = typeof ProjectCommandRejection.Type;
93
94const ProjectCommandRejectionJson = Schema.fromJsonString(ProjectCommandRejection);
95/**
96 * A rejected receipt stores its rejection as JSON, so a retried command id
97 * replays the same typed error even when a fresh plan would now succeed.
98 */
99export const encodeProjectCommandRejection = Schema.encodeSync(ProjectCommandRejectionJson);
100/** None for receipts that predate structured rejections. */
101export const decodeProjectCommandRejection = Schema.decodeUnknownOption(
102 ProjectCommandRejectionJson,
103);
104
105export interface ProjectCommandState {
106 /** The target project's row, including a soft-deleted one; only create sees deleted rows as taken. */
107 readonly project: ProjectRow | undefined;
108 /** The active project that holds the command's requested workspace root, if any. */
109 readonly workspaceOwner: ProjectRow | undefined;
110}
111
112const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
113const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN);
114
115/**
116 * Decide one project command against the rows it touches. The caller reads
117 * `state` under the project's lock and commits the planned event.
118 */
119export function planProjectCommand(input: {
120 readonly command: ProjectCommand;
121 readonly state: ProjectCommandState;
122 readonly eventId: EventId;
123 readonly now: DateTime.Utc;
124}): Result.Result<UnsequencedProjectEvent, ProjectCommandRejection> {
125 const { command, state } = input;
126 const invariant = (detail: string) =>
127 Result.fail(new ProjectCommandInvariantError({ commandType: command.type, detail }));
128 const missingProject = () =>
129 Result.fail(
130 new ProjectCommandMissingProjectError({
131 commandType: command.type,
132 projectId: command.projectId,
133 }),
134 );
135 const activeProject = state.project?.deletedAt === null ? state.project : undefined;
136 const requireWorkspaceAvailable = (workspaceRoot: string) =>
137 state.workspaceOwner === undefined || state.workspaceOwner.projectId === command.projectId
138 ? undefined
139 : new ProjectWorkspaceConflictError({
140 workspaceRoot,
141 conflictingProjectId: state.workspaceOwner.projectId,
142 });
143 const occurredAt = DateTime.formatIso(input.now);
144 const base = {
145 eventId: input.eventId,
146 aggregateKind: "project" as const,
147 aggregateId: command.projectId,
148 occurredAt,
149 commandId: command.commandId,
150 causationEventId: null,
151 correlationId: command.commandId,
152 metadata: {},
153 };
154
155 switch (command.type) {
156 case "project.create": {
157 if (state.project !== undefined) {
158 return invariant(
159 `Project '${command.projectId}' already exists and cannot be created twice.`,
160 );
161 }
162 const conflict = requireWorkspaceAvailable(command.workspaceRoot);
163 if (conflict !== undefined) return Result.fail(conflict);
164 return Result.succeed({
165 ...base,
166 type: "project.created",
167 payload: {
168 projectId: command.projectId,
169 title: command.title,
170 workspaceRoot: command.workspaceRoot,
171 // Project creation has no user model choice. Older clients sent an
172 // automatic seed, but only a metadata update records an explicit default.
173 defaultModelSelection: null,
174 faviconPath: null,
175 projectIcon: null,
176 scripts: command.scripts ?? [],
177 createdAt: occurredAt,
178 updatedAt: occurredAt,
179 },
180 });
181 }
182
183 case "project.meta.update": {
184 const project = activeProject;
185 if (project === undefined) return missingProject();
186 if (
187 command.projectIcon?.kind === "monogram" &&
188 Array.from(monogramSegmenter.segment(command.projectIcon.text)).length > 2
189 ) {
190 return invariant("Project monograms must contain at most two characters.");
191 }
192 if (command.scripts !== undefined) {
193 // Persisted IDs predate shortcut validation. Let users edit or remove them
194 // without allowing another invalid ID to enter the project.
195 const existingIds = new Set(project.scripts.map((script) => script.id));
196 for (const script of command.scripts) {
197 if (!existingIds.has(script.id) && !isScriptRunCommand(`script.${script.id}.run`)) {
198 // The raw ID is unbounded user input and this detail is persisted.
199 return invariant(
200 `Script IDs must be 1-${MAX_SCRIPT_ID_LENGTH} lowercase letters, digits or hyphens, starting with a letter or digit (got ${script.id.length} characters).`,
201 );
202 }
203 }
204 }
205 if (command.workspaceRoot !== undefined) {
206 const conflict = requireWorkspaceAvailable(command.workspaceRoot);
207 if (conflict !== undefined) return Result.fail(conflict);
208 }
209 return Result.succeed({
210 ...base,
211 type: "project.meta-updated",
212 payload: {
213 projectId: command.projectId,
214 ...(command.title === undefined ? {} : { title: command.title }),
215 ...(command.workspaceRoot === undefined ? {} : { workspaceRoot: command.workspaceRoot }),
216 ...(command.defaultModelSelection === undefined
217 ? {}
218 : { defaultModelSelection: command.defaultModelSelection }),
219 ...(command.defaultThreadEnvMode === undefined
220 ? {}
221 : { defaultThreadEnvMode: command.defaultThreadEnvMode }),
222 ...(command.autoPull === undefined ? {} : { autoPull: command.autoPull }),
223 ...(command.faviconPath === undefined ? {} : { faviconPath: command.faviconPath }),
224 ...(command.projectIcon === undefined ? {} : { projectIcon: command.projectIcon }),
225 ...(command.scripts === undefined ? {} : { scripts: command.scripts }),
226 updatedAt: occurredAt,
227 },
228 });
229 }
230
231 case "project.delete": {
232 if (activeProject === undefined) return missingProject();
233 // Thread children are deleted by ProjectService before this event commits.
234 return Result.succeed({
235 ...base,
236 type: "project.deleted",
237 payload: { projectId: command.projectId, deletedAt: occurredAt },
238 });
239 }
240 }
241}