| 1 | import { |
| 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"; |
| 12 | import * as DateTime from "effect/DateTime"; |
| 13 | import * as Result from "effect/Result"; |
| 14 | import * as Schema from "effect/Schema"; |
| 15 | |
| 16 | import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts"; |
| 17 | import type { ProjectRow } from "./ProjectStore.ts"; |
| 18 | |
| 19 | export 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 | |
| 28 | export 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 | |
| 42 | export interface ProjectDeleteCommand { |
| 43 | readonly type: "project.delete"; |
| 44 | readonly commandId: CommandId; |
| 45 | readonly projectId: ProjectId; |
| 46 | } |
| 47 | |
| 48 | export type ProjectCommand = ProjectCreateCommand | ProjectMetaUpdateCommand | ProjectDeleteCommand; |
| 49 | |
| 50 | export 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. */ |
| 63 | export 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 | |
| 75 | export 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 | |
| 87 | export const ProjectCommandRejection = Schema.Union([ |
| 88 | ProjectCommandInvariantError, |
| 89 | ProjectCommandMissingProjectError, |
| 90 | ProjectWorkspaceConflictError, |
| 91 | ]); |
| 92 | export type ProjectCommandRejection = typeof ProjectCommandRejection.Type; |
| 93 | |
| 94 | const 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 | */ |
| 99 | export const encodeProjectCommandRejection = Schema.encodeSync(ProjectCommandRejectionJson); |
| 100 | /** None for receipts that predate structured rejections. */ |
| 101 | export const decodeProjectCommandRejection = Schema.decodeUnknownOption( |
| 102 | ProjectCommandRejectionJson, |
| 103 | ); |
| 104 | |
| 105 | export 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 | |
| 112 | const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); |
| 113 | const 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 | */ |
| 119 | export 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 | } |