1import * as Schema from "effect/Schema";
2import * as SchemaTransformation from "effect/SchemaTransformation";
3import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";
4import { ModelSelection } from "./modelSelection.ts";
5import {
6 CommandId,
7 ForwardCompatibleUnion,
8 isUnknownUnionMember,
9 IsoDateTime,
10 NonNegativeInt,
11 PositiveInt,
12 ProjectId,
13 TrimmedNonEmptyString,
14 TrimmedString,
15 type UnknownUnionMember,
16} from "./baseSchemas.ts";
17
18const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
19const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500;
20const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;
21const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512;
22
23export const ProjectScriptIcon = Schema.Literals([
24 "play",
25 "test",
26 "lint",
27 "configure",
28 "build",
29 "debug",
30]);
31export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
32
33export const ProjectScript = Schema.Struct({
34 id: TrimmedNonEmptyString,
35 name: TrimmedNonEmptyString,
36 command: TrimmedNonEmptyString,
37 icon: ProjectScriptIcon,
38 runOnWorktreeCreate: Schema.Boolean,
39 /** Run in the thread's worktree each time the thread settles. */
40 runOnSettle: Schema.optional(Schema.Boolean),
41 /** Start the agent while setup runs unless explicitly disabled. */
42 async: Schema.optional(Schema.Boolean),
43 previewUrl: Schema.optional(TrimmedNonEmptyString),
44 autoOpenPreview: Schema.optional(Schema.Boolean),
45});
46export type ProjectScript = typeof ProjectScript.Type;
47
48export const ProjectIconColor = Schema.Literals([
49 "gray",
50 "red",
51 "orange",
52 "amber",
53 "yellow",
54 "lime",
55 "green",
56 "emerald",
57 "teal",
58 "cyan",
59 "sky",
60 "blue",
61 "indigo",
62 "violet",
63 "purple",
64 "fuchsia",
65 "pink",
66 "rose",
67]);
68export type ProjectIconColor = typeof ProjectIconColor.Type;
69
70const ProjectLucideIconName = TrimmedNonEmptyString.check(
71 Schema.isMaxLength(64),
72 Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
73);
74
75const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));
76
77// Grapheme-count validation belongs to the server command boundary, not snapshot decoding.
78export const ProjectMonogramText = TrimmedNonEmptyString.check(
79 Schema.isMaxLength(32),
80 Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u),
81);
82
83const ProjectLucideIcon = Schema.Struct({
84 kind: Schema.Literal("lucide"),
85 name: ProjectLucideIconName,
86 color: ProjectIconColor,
87});
88const ProjectEmojiIcon = Schema.Struct({
89 kind: Schema.Literal("emoji"),
90 emoji: ProjectEmoji,
91});
92const ProjectMonogramIcon = Schema.Struct({
93 kind: Schema.Literal("monogram"),
94 text: ProjectMonogramText,
95 color: ProjectIconColor,
96});
97/** A workspace-relative image a project may use as its favicon. */
98export const ProjectFaviconPath = TrimmedNonEmptyString.check(
99 Schema.isMaxLength(1024),
100 Schema.isPattern(/\.(?:avif|gif|icns|ico|jpe?g|png|svg|webp)$/i),
101);
102export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;
103
104/** A project's chosen icon, as clients set it. */
105export const ProjectIconOverride = Schema.Union([
106 ProjectLucideIcon,
107 ProjectEmojiIcon,
108 ProjectMonogramIcon,
109]);
110export type ProjectIconOverride = typeof ProjectIconOverride.Type;
111
112/**
113 * Before v2, servers sent a monogram as a lucide icon carrying its text, so
114 * older clients showed a folder. Icons stored then, and v2 servers released
115 * before this change, still use that shape; it is read here, never written.
116 */
117const ProjectLucideIconWithLegacyMonogram = Schema.Struct({
118 ...ProjectLucideIcon.fields,
119 monogramText: Schema.optional(ProjectMonogramText),
120 monogram: Schema.optional(ProjectMonogramText),
121});
122const projectIconMembers = [
123 ProjectLucideIconWithLegacyMonogram,
124 ProjectEmojiIcon,
125 ProjectMonogramIcon,
126] as const;
127type ProjectIconMember = (typeof projectIconMembers)[number]["Type"];
128
129const fromLegacyMonogram = (icon: ProjectIconMember): ProjectIconOverride => {
130 if (icon.kind !== "lucide") return icon;
131 const text = icon.monogramText ?? icon.monogram;
132 return text === undefined
133 ? { kind: "lucide", name: icon.name, color: icon.color }
134 : { kind: "monogram", text, color: icon.color };
135};
136
137/** An icon the server stores; it reads legacy monograms and writes the plain shape. */
138export const StoredProjectIcon = Schema.Union(projectIconMembers).pipe(
139 Schema.decodeTo(
140 Schema.toType(ProjectIconOverride),
141 SchemaTransformation.transform<ProjectIconOverride, ProjectIconMember>({
142 decode: fromLegacyMonogram,
143 encode: (icon) => icon,
144 }),
145 ),
146);
147
148/**
149 * An icon as clients receive it. A kind from a newer server decodes as no
150 * override, so the client shows the project's default icon.
151 */
152export const ReceivedProjectIcon = ForwardCompatibleUnion(projectIconMembers, "kind").pipe(
153 Schema.decodeTo(
154 Schema.NullOr(Schema.toType(ProjectIconOverride)),
155 SchemaTransformation.transform<
156 ProjectIconOverride | null,
157 ProjectIconMember | UnknownUnionMember<"kind">
158 >({
159 decode: (icon) => (isUnknownUnionMember(icon) ? null : fromLegacyMonogram(icon)),
160 encode: (icon) => icon as ProjectIconMember,
161 }),
162 ),
163);
164
165export const Project = Schema.Struct({
166 id: ProjectId,
167 title: TrimmedNonEmptyString,
168 workspaceRoot: TrimmedNonEmptyString,
169 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
170 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
171 projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)),
172 defaultModelSelection: Schema.NullOr(ModelSelection),
173 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
174 // Opt-in because background sync performs network I/O and may move the checkout.
175 autoPull: Schema.optional(Schema.Boolean),
176 scripts: Schema.Array(ProjectScript),
177 createdAt: IsoDateTime,
178 updatedAt: IsoDateTime,
179 deletedAt: Schema.NullOr(IsoDateTime),
180});
181export type Project = typeof Project.Type;
182
183export const ProjectSnapshot = Schema.Struct({
184 projects: Schema.Array(Project),
185 updatedAt: IsoDateTime,
186});
187export type ProjectSnapshot = typeof ProjectSnapshot.Type;
188
189export const ProjectChange = Schema.Union([
190 Schema.Struct({ type: Schema.Literal("project.upserted"), project: Project }),
191 Schema.Struct({
192 type: Schema.Literal("project.deleted"),
193 projectId: ProjectId,
194 deletedAt: IsoDateTime,
195 }),
196]);
197export type ProjectChange = typeof ProjectChange.Type;
198
199export const ProjectCreatePayload = Schema.Struct({
200 title: TrimmedNonEmptyString,
201 workspaceRoot: TrimmedNonEmptyString,
202 createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean),
203 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
204 scripts: Schema.optional(Schema.Array(ProjectScript)),
205});
206export type ProjectCreatePayload = typeof ProjectCreatePayload.Type;
207
208export const ProjectUpdatePayload = Schema.Struct({
209 title: Schema.optional(TrimmedNonEmptyString),
210 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
211 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
212 autoPull: Schema.optional(Schema.Boolean),
213 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
214 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
215 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
216 scripts: Schema.optional(Schema.Array(ProjectScript)),
217});
218export type ProjectUpdatePayload = typeof ProjectUpdatePayload.Type;
219
220export const ProjectMutation = Schema.Union([
221 Schema.Struct({
222 type: Schema.Literal("project.create"),
223 commandId: CommandId,
224 projectId: ProjectId,
225 ...ProjectCreatePayload.fields,
226 }),
227 Schema.Struct({
228 type: Schema.Literal("project.update"),
229 commandId: CommandId,
230 projectId: ProjectId,
231 ...ProjectUpdatePayload.fields,
232 }),
233 Schema.Struct({
234 type: Schema.Literal("project.delete"),
235 commandId: CommandId,
236 projectId: ProjectId,
237 force: Schema.optional(Schema.Boolean),
238 }),
239]);
240export type ProjectMutation = typeof ProjectMutation.Type;
241
242export class ProjectMutationError extends Schema.TaggedError<ProjectMutationError>()(
243 "ProjectMutationError",
244 {
245 commandId: CommandId,
246 message: Schema.String,
247 cause: Schema.optional(Schema.Defect()),
248 },
249) {}
250
251export const ProjectEntryKind = Schema.Literals(["file", "directory"]);
252export type ProjectEntryKind = typeof ProjectEntryKind.Type;
253
254export const ProjectSearchEntriesInput = Schema.Struct({
255 cwd: TrimmedNonEmptyString,
256 // An empty query is a bounded browse: the index returns frecency-ordered
257 // entries, which the file picker uses for its initial results.
258 query: TrimmedString.check(Schema.isMaxLength(256)),
259 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)),
260 kind: Schema.optional(ProjectEntryKind),
261 imageOnly: Schema.optional(Schema.Boolean),
262});
263export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
264
265export const ProjectEntry = Schema.Struct({
266 path: TrimmedNonEmptyString,
267 kind: ProjectEntryKind,
268 ignored: Schema.optional(Schema.Boolean),
269});
270export type ProjectEntry = typeof ProjectEntry.Type;
271
272export const ProjectSearchEntriesResult = Schema.Struct({
273 entries: Schema.Array(ProjectEntry),
274 truncated: Schema.Boolean,
275});
276export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
277
278export const ProjectSearchContentsInput = Schema.Struct({
279 cwd: TrimmedNonEmptyString,
280 // Whitespace is significant in content queries (" foo", regex trailing
281 // spaces), so the query is deliberately not trimmed on the wire.
282 query: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)),
283 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_CONTENTS_MAX_LIMIT)),
284 caseSensitive: Schema.Boolean,
285 wholeWord: Schema.Boolean,
286 useRegex: Schema.Boolean,
287});
288export type ProjectSearchContentsInput = typeof ProjectSearchContentsInput.Type;
289
290export const ProjectContentMatchRange = Schema.Struct({
291 start: NonNegativeInt,
292 end: NonNegativeInt,
293});
294export type ProjectContentMatchRange = typeof ProjectContentMatchRange.Type;
295
296export const ProjectContentMatch = Schema.Struct({
297 path: TrimmedNonEmptyString,
298 lineNumber: PositiveInt,
299 lineContent: Schema.String,
300 matchRanges: Schema.Array(ProjectContentMatchRange),
301});
302export type ProjectContentMatch = typeof ProjectContentMatch.Type;
303
304export const ProjectSearchContentsResult = Schema.Struct({
305 matches: Schema.Array(ProjectContentMatch),
306 truncated: Schema.Boolean,
307 regexFallbackError: Schema.optional(Schema.String),
308});
309export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Type;
310
311export const ProjectListEntriesInput = Schema.Struct({
312 cwd: TrimmedNonEmptyString,
313 // Present for immediate filesystem children, including ignored entries; empty means root.
314 // Omitted preserves the indexed recursive listing used by older clients.
315 directoryPath: Schema.optional(TrimmedString),
316});
317export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type;
318
319export const ProjectListEntriesResult = Schema.Struct({
320 entries: Schema.Array(ProjectEntry),
321 truncated: Schema.Boolean,
322});
323export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type;
324
325export const ProjectEntriesFailure = Schema.Literals([
326 "workspace_root_not_found",
327 "workspace_root_create_failed",
328 "workspace_root_stat_failed",
329 "workspace_root_not_directory",
330 "search_index_create_failed",
331 "search_index_scan_timed_out",
332 "search_index_search_failed",
333 "directory_list_failed",
334]);
335export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type;
336
337type ProjectEntriesFailureContext = {
338 readonly failure: ProjectEntriesFailure;
339 readonly normalizedCwd?: string;
340 readonly timeout?: string;
341 readonly detail?: string;
342 readonly cause?: unknown;
343};
344
345function decodedProjectErrorMessage(props: object): string | undefined {
346 if (!("message" in props)) return undefined;
347 return typeof props.message === "string" ? props.message : undefined;
348}
349
350export class ProjectSearchEntriesError extends Schema.TaggedError<ProjectSearchEntriesError>()(
351 "ProjectSearchEntriesError",
352 {
353 cwd: Schema.optional(TrimmedNonEmptyString),
354 queryLength: Schema.optional(NonNegativeInt),
355 limit: Schema.optional(PositiveInt),
356 failure: Schema.optional(ProjectEntriesFailure),
357 normalizedCwd: Schema.optional(TrimmedNonEmptyString),
358 timeout: Schema.optional(TrimmedNonEmptyString),
359 detail: Schema.optional(TrimmedNonEmptyString),
360 message: TrimmedNonEmptyString,
361 cause: Schema.optional(Schema.Defect()),
362 },
363) {
364 // The structured fields are optional on the wire so newer peers can decode legacy message-only
365 // failures. New application code must provide them through this constructor.
366 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
367 constructor(
368 props: ProjectEntriesFailureContext & {
369 readonly cwd: string;
370 readonly queryLength: number;
371 readonly limit: number;
372 },
373 ) {
374 super({
375 ...props,
376 message:
377 decodedProjectErrorMessage(props) ??
378 `Failed to search workspace entries in '${props.cwd}'.`,
379 } as any);
380 }
381}
382
383export class ProjectSearchContentsError extends Schema.TaggedError<ProjectSearchContentsError>()(
384 "ProjectSearchContentsError",
385 {
386 cwd: Schema.optional(TrimmedNonEmptyString),
387 queryLength: Schema.optional(NonNegativeInt),
388 limit: Schema.optional(PositiveInt),
389 failure: Schema.optional(ProjectEntriesFailure),
390 normalizedCwd: Schema.optional(TrimmedNonEmptyString),
391 timeout: Schema.optional(TrimmedNonEmptyString),
392 detail: Schema.optional(TrimmedNonEmptyString),
393 message: TrimmedNonEmptyString,
394 cause: Schema.optional(Schema.Defect()),
395 },
396) {
397 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
398 constructor(
399 props: ProjectEntriesFailureContext & {
400 readonly cwd: string;
401 readonly queryLength: number;
402 readonly limit: number;
403 },
404 ) {
405 super({
406 ...props,
407 message:
408 decodedProjectErrorMessage(props) ??
409 `Failed to search workspace contents in '${props.cwd}'.`,
410 } as any);
411 }
412}
413
414export class ProjectListEntriesError extends Schema.TaggedError<ProjectListEntriesError>()(
415 "ProjectListEntriesError",
416 {
417 cwd: Schema.optional(TrimmedNonEmptyString),
418 failure: Schema.optional(ProjectEntriesFailure),
419 normalizedCwd: Schema.optional(TrimmedNonEmptyString),
420 timeout: Schema.optional(TrimmedNonEmptyString),
421 detail: Schema.optional(TrimmedNonEmptyString),
422 message: TrimmedNonEmptyString,
423 cause: Schema.optional(Schema.Defect()),
424 },
425) {
426 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
427 constructor(props: ProjectEntriesFailureContext & { readonly cwd: string }) {
428 super({
429 ...props,
430 message:
431 decodedProjectErrorMessage(props) ?? `Failed to list workspace entries in '${props.cwd}'.`,
432 } as any);
433 }
434}
435
436export const ProjectReadFileInput = Schema.Struct({
437 cwd: TrimmedNonEmptyString,
438 // Workspace-relative, or an absolute host path for a file outside the
439 // workspace. Only workspace-relative paths can be written back.
440 relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_READ_FILE_PATH_MAX_LENGTH)),
441});
442export type ProjectReadFileInput = typeof ProjectReadFileInput.Type;
443
444export const ProjectWatchInput = Schema.Struct({
445 ...ProjectReadFileInput.fields,
446 directory: Schema.Boolean,
447});
448export type ProjectWatchInput = typeof ProjectWatchInput.Type;
449
450export const ProjectReadFileResult = Schema.Struct({
451 relativePath: TrimmedNonEmptyString,
452 contents: Schema.String,
453 byteLength: NonNegativeInt,
454 truncated: Schema.Boolean,
455});
456export type ProjectReadFileResult = typeof ProjectReadFileResult.Type;
457
458export const ProjectFileFailure = Schema.Literals([
459 "workspace_path_outside_root",
460 "resolved_path_outside_root",
461 "path_not_file",
462 "binary_file",
463 "operation_failed",
464]);
465export type ProjectFileFailure = typeof ProjectFileFailure.Type;
466
467export const ProjectFileOperation = Schema.Literals([
468 "realpath-workspace-root",
469 "realpath-target",
470 "open",
471 "stat",
472 "read",
473 "close",
474 "make-directory",
475 "write-file",
476 "watch",
477]);
478export type ProjectFileOperation = typeof ProjectFileOperation.Type;
479
480type ProjectFileFailureContext = {
481 readonly cwd: string;
482 readonly relativePath: string;
483 readonly failure: ProjectFileFailure;
484 readonly resolvedPath?: string;
485 readonly resolvedWorkspaceRoot?: string;
486 readonly operation?: ProjectFileOperation;
487 readonly operationPath?: string;
488 readonly cause?: unknown;
489};
490
491export class ProjectReadFileError extends Schema.TaggedError<ProjectReadFileError>()(
492 "ProjectReadFileError",
493 {
494 cwd: Schema.optional(TrimmedNonEmptyString),
495 relativePath: Schema.optional(TrimmedNonEmptyString),
496 failure: Schema.optional(ProjectFileFailure),
497 resolvedPath: Schema.optional(TrimmedNonEmptyString),
498 resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString),
499 operation: Schema.optional(ProjectFileOperation),
500 operationPath: Schema.optional(TrimmedNonEmptyString),
501 message: TrimmedNonEmptyString,
502 cause: Schema.optional(Schema.Defect()),
503 },
504) {
505 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
506 constructor(props: ProjectFileFailureContext) {
507 super({
508 ...props,
509 message:
510 decodedProjectErrorMessage(props) ??
511 `Failed to read workspace file '${props.relativePath}' in '${props.cwd}'.`,
512 } as any);
513 }
514}
515
516export const ProjectWriteFileInput = Schema.Struct({
517 cwd: TrimmedNonEmptyString,
518 relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)),
519 contents: Schema.String,
520});
521export type ProjectWriteFileInput = typeof ProjectWriteFileInput.Type;
522
523export const ProjectWriteFileResult = Schema.Struct({
524 relativePath: TrimmedNonEmptyString,
525});
526export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;
527
528/** The environment's Scratch project, created on first request. */
529export const ProjectEnsureScratchResult = Schema.Struct({
530 projectId: ProjectId,
531});
532export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type;
533
534/** A project started from just a name, in a new folder the server makes. */
535export const ProjectCreateNewInput = Schema.Struct({
536 name: TrimmedNonEmptyString.check(Schema.isMaxLength(200)),
537});
538export type ProjectCreateNewInput = typeof ProjectCreateNewInput.Type;
539
540export const ProjectCreateNewResult = Schema.Struct({
541 projectId: ProjectId,
542 workspaceRoot: TrimmedNonEmptyString,
543 /** Why the first commit failed. The project and its files exist either way. */
544 commitError: Schema.optionalKey(TrimmedNonEmptyString),
545});
546export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type;
547
548export class ProjectWriteFileError extends Schema.TaggedError<ProjectWriteFileError>()(
549 "ProjectWriteFileError",
550 {
551 cwd: Schema.optional(TrimmedNonEmptyString),
552 relativePath: Schema.optional(TrimmedNonEmptyString),
553 failure: Schema.optional(ProjectFileFailure),
554 resolvedPath: Schema.optional(TrimmedNonEmptyString),
555 resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString),
556 operation: Schema.optional(ProjectFileOperation),
557 operationPath: Schema.optional(TrimmedNonEmptyString),
558 message: TrimmedNonEmptyString,
559 cause: Schema.optional(Schema.Defect()),
560 },
561) {
562 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
563 constructor(props: ProjectFileFailureContext) {
564 super({
565 ...props,
566 message:
567 decodedProjectErrorMessage(props) ??
568 `Failed to write workspace file '${props.relativePath}' in '${props.cwd}'.`,
569 } as any);
570 }
571}