Skip to content
Source
Sign inSign up

project.ts

579 lines19.1 KB

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 ThreadId,
14 TrimmedNonEmptyString,
15 TrimmedString,
16 type UnknownUnionMember,
17} from "./baseSchemas.ts";
18​
19const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
20const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500;
21export const PROJECT_SEARCH_CONTENTS_QUERY_MAX_LENGTH = 256;
22const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;
23const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512;
24​
25export const ProjectScriptIcon = Schema.Literals([
26 "play",
27 "test",
28 "lint",
29 "configure",
30 "build",
31 "debug",
32]);
33export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
34​
35export const ProjectScript = Schema.Struct({
36 id: TrimmedNonEmptyString,
37 name: TrimmedNonEmptyString,
38 command: TrimmedNonEmptyString,
39 icon: ProjectScriptIcon,
40 runOnWorktreeCreate: Schema.Boolean,
41 runOnSettle: Schema.optional(Schema.Boolean),
42 stopOnSettle: Schema.optional(Schema.Boolean),
43 async: Schema.optional(Schema.Boolean),
44 previewUrl: Schema.optional(TrimmedNonEmptyString),
45 autoOpenPreview: Schema.optional(Schema.Boolean),
46});
47export type ProjectScript = typeof ProjectScript.Type;
48​
49export const ProjectIconColor = Schema.Literals([
50 "gray",
51 "red",
52 "orange",
53 "amber",
54 "yellow",
55 "lime",
56 "green",
57 "emerald",
58 "teal",
59 "cyan",
60 "sky",
61 "blue",
62 "indigo",
63 "violet",
64 "purple",
65 "fuchsia",
66 "pink",
67 "rose",
68]);
69export type ProjectIconColor = typeof ProjectIconColor.Type;
70​
71const ProjectLucideIconName = TrimmedNonEmptyString.check(
72 Schema.isMaxLength(64),
73 Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
74);
75​
76const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));
77​
78// Grapheme-count validation belongs to the server command boundary, not snapshot decoding.
79export const ProjectMonogramText = TrimmedNonEmptyString.check(
80 Schema.isMaxLength(32),
81 Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u),
82);
83​
84const ProjectLucideIcon = Schema.Struct({
85 kind: Schema.Literal("lucide"),
86 name: ProjectLucideIconName,
87 color: ProjectIconColor,
88});
89const ProjectEmojiIcon = Schema.Struct({
90 kind: Schema.Literal("emoji"),
91 emoji: ProjectEmoji,
92});
93const ProjectMonogramIcon = Schema.Struct({
94 kind: Schema.Literal("monogram"),
95 text: ProjectMonogramText,
96 color: ProjectIconColor,
97});
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​
104export const ProjectIconOverride = Schema.Union([
105 ProjectLucideIcon,
106 ProjectEmojiIcon,
107 ProjectMonogramIcon,
108]);
109export type ProjectIconOverride = typeof ProjectIconOverride.Type;
110​
111const ProjectLucideIconWithLegacyMonogram = Schema.Struct({
112 ...ProjectLucideIcon.fields,
113 monogramText: Schema.optional(ProjectMonogramText),
114 monogram: Schema.optional(ProjectMonogramText),
115});
116const projectIconMembers = [
117 ProjectLucideIconWithLegacyMonogram,
118 ProjectEmojiIcon,
119 ProjectMonogramIcon,
120] as const;
121type ProjectIconMember = (typeof projectIconMembers)[number]["Type"];
122​
123const fromLegacyMonogram = (icon: ProjectIconMember): ProjectIconOverride => {
124 if (icon.kind !== "lucide") return icon;
125 const text = icon.monogramText ?? icon.monogram;
126 return text === undefined
127 ? { kind: "lucide", name: icon.name, color: icon.color }
128 : { kind: "monogram", text, color: icon.color };
129};
130​
131export const StoredProjectIcon = Schema.Union(projectIconMembers).pipe(
132 Schema.decodeTo(
133 Schema.toType(ProjectIconOverride),
134 SchemaTransformation.transform<ProjectIconOverride, ProjectIconMember>({
135 decode: fromLegacyMonogram,
136 encode: (icon) => icon,
137 }),
138 ),
139);
140​
141export const ReceivedProjectIcon = ForwardCompatibleUnion(projectIconMembers, "kind").pipe(
142 Schema.decodeTo(
143 Schema.NullOr(Schema.toType(ProjectIconOverride)),
144 SchemaTransformation.transform<
145 ProjectIconOverride | null,
146 ProjectIconMember | UnknownUnionMember<"kind">
147 >({
148 decode: (icon) => (isUnknownUnionMember(icon) ? null : fromLegacyMonogram(icon)),
149 encode: (icon) => icon as ProjectIconMember,
150 }),
151 ),
152);
153​
154export const Project = Schema.Struct({
155 id: ProjectId,
156 title: TrimmedNonEmptyString,
157 workspaceRoot: TrimmedNonEmptyString,
158 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
159 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
160 projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)),
161 defaultModelSelection: Schema.NullOr(ModelSelection),
162 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
163 // Opt-in because background sync performs network I/O and may move the checkout.
164 autoPull: Schema.optional(Schema.Boolean),
165 scripts: Schema.Array(ProjectScript),
166 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
167 archivedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
168 createdAt: IsoDateTime,
169 updatedAt: IsoDateTime,
170 deletedAt: Schema.NullOr(IsoDateTime),
171});
172export type Project = typeof Project.Type;
173​
174export const ProjectSnapshot = Schema.Struct({
175 projects: Schema.Array(Project),
176 updatedAt: IsoDateTime,
177});
178export type ProjectSnapshot = typeof ProjectSnapshot.Type;
179​
180export const ProjectCreatePayload = Schema.Struct({
181 title: TrimmedNonEmptyString,
182 workspaceRoot: TrimmedNonEmptyString,
183 createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean),
184 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
185 scripts: Schema.optional(Schema.Array(ProjectScript)),
186});
187export type ProjectCreatePayload = typeof ProjectCreatePayload.Type;
188​
189export const ProjectUpdatePayload = Schema.Struct({
190 title: Schema.optional(TrimmedNonEmptyString),
191 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
192 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
193 autoPull: Schema.optional(Schema.Boolean),
194 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
195 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
196 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
197 scripts: Schema.optional(Schema.Array(ProjectScript)),
198 pinned: Schema.optional(Schema.Boolean),
199 archived: Schema.optional(
200 Schema.Boolean.annotate({
201 description: "Threads and files are kept.",
202 }),
203 ),
204});
205export type ProjectUpdatePayload = typeof ProjectUpdatePayload.Type;
206​
207export const ProjectMutation = Schema.Union([
208 Schema.Struct({
209 type: Schema.Literal("project.create"),
210 commandId: CommandId,
211 projectId: ProjectId,
212 ...ProjectCreatePayload.fields,
213 }),
214 Schema.Struct({
215 type: Schema.Literal("project.update"),
216 commandId: CommandId,
217 projectId: ProjectId,
218 ...ProjectUpdatePayload.fields,
219 }),
220 Schema.Struct({
221 type: Schema.Literal("project.delete"),
222 commandId: CommandId,
223 projectId: ProjectId,
224 force: Schema.optional(Schema.Boolean),
225 }),
226]);
227export type ProjectMutation = typeof ProjectMutation.Type;
228​
229export class ProjectMutationError extends Schema.TaggedError<ProjectMutationError>()(
230 "ProjectMutationError",
231 {
232 commandId: CommandId,
233 message: Schema.String,
234 cause: Schema.optional(Schema.Defect()),
235 },
236) {}
237​
238export const ProjectEntryKind = Schema.Literals(["file", "directory"]);
239export type ProjectEntryKind = typeof ProjectEntryKind.Type;
240​
241export const ProjectSearchEntriesInput = Schema.Struct({
242 cwd: TrimmedNonEmptyString,
243 query: TrimmedString.check(Schema.isMaxLength(256)),
244 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)),
245 kind: Schema.optional(ProjectEntryKind),
246 imageOnly: Schema.optional(Schema.Boolean),
247});
248export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
249​
250export const ProjectEntry = Schema.Struct({
251 path: TrimmedNonEmptyString,
252 kind: ProjectEntryKind,
253 ignored: Schema.optional(Schema.Boolean),
254});
255export type ProjectEntry = typeof ProjectEntry.Type;
256​
257export const ProjectSearchEntriesResult = Schema.Struct({
258 entries: Schema.Array(ProjectEntry),
259 truncated: Schema.Boolean,
260});
261export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
262​
263export const ProjectSearchContentsInput = Schema.Struct({
264 cwd: TrimmedNonEmptyString,
265 // Preserve whitespace because it is significant in content queries.
266 query: Schema.String.check(
267 Schema.isNonEmpty(),
268 Schema.isMaxLength(PROJECT_SEARCH_CONTENTS_QUERY_MAX_LENGTH),
269 ),
270 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_CONTENTS_MAX_LIMIT)),
271 caseSensitive: Schema.Boolean,
272 wholeWord: Schema.Boolean,
273 useRegex: Schema.Boolean,
274});
275export type ProjectSearchContentsInput = typeof ProjectSearchContentsInput.Type;
276​
277export const ProjectContentMatchRange = Schema.Struct({
278 start: NonNegativeInt,
279 end: NonNegativeInt,
280});
281export type ProjectContentMatchRange = typeof ProjectContentMatchRange.Type;
282​
283export const ProjectContentMatch = Schema.Struct({
284 path: TrimmedNonEmptyString,
285 lineNumber: PositiveInt,
286 lineContent: Schema.String,
287 matchRanges: Schema.Array(ProjectContentMatchRange),
288});
289export type ProjectContentMatch = typeof ProjectContentMatch.Type;
290​
291export const ProjectSearchContentsResult = Schema.Struct({
292 matches: Schema.Array(ProjectContentMatch),
293 truncated: Schema.Boolean,
294 regexFallbackError: Schema.optional(Schema.String),
295});
296export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Type;
297​
298export const ProjectListEntriesInput = Schema.Struct({
299 cwd: TrimmedNonEmptyString,
300 directoryPath: Schema.optional(TrimmedString),
301});
302export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type;
303​
304export const ProjectListEntriesResult = Schema.Struct({
305 entries: Schema.Array(ProjectEntry),
306 truncated: Schema.Boolean,
307});
308export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type;
309​
310// Untracked files git ignores. Wholly ignored folders such as `node_modules`
311// are left out, so the list stays small enough to pick from.
312export const ProjectListIgnoredFilesInput = Schema.Struct({
313 cwd: TrimmedNonEmptyString,
314 /** Case-insensitive path substring, applied before the listing cap. */
315 query: Schema.optionalKey(TrimmedString.check(Schema.isMaxLength(256))),
316});
317export type ProjectListIgnoredFilesInput = typeof ProjectListIgnoredFilesInput.Type;
318​
319export const ProjectIgnoredFile = Schema.Struct({
320 path: TrimmedNonEmptyString,
321 byteLength: NonNegativeInt,
322});
323export type ProjectIgnoredFile = typeof ProjectIgnoredFile.Type;
324​
325export const ProjectListIgnoredFilesResult = Schema.Struct({
326 files: Schema.Array(ProjectIgnoredFile),
327 truncated: Schema.Boolean,
328});
329export type ProjectListIgnoredFilesResult = typeof ProjectListIgnoredFilesResult.Type;
330​
331export const ProjectEntriesFailure = Schema.Literals([
332 "workspace_root_not_found",
333 "workspace_root_create_failed",
334 "workspace_root_stat_failed",
335 "workspace_root_not_directory",
336 "search_index_create_failed",
337 "search_index_scan_timed_out",
338 "search_index_search_failed",
339 "directory_list_failed",
340]);
341export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type;
342​
343type ProjectEntriesFailureContext = {
344 readonly failure: ProjectEntriesFailure;
345 readonly normalizedCwd?: string;
346 readonly timeout?: string;
347 readonly detail?: string;
348 readonly cause?: unknown;
349};
350​
351function decodedProjectErrorMessage(props: object): string | undefined {
352 if (!("message" in props)) return undefined;
353 return typeof props.message === "string" ? props.message : undefined;
355​
356export class ProjectSearchEntriesError extends Schema.TaggedError<ProjectSearchEntriesError>()(
357 "ProjectSearchEntriesError",
358 {
359 cwd: Schema.optional(TrimmedNonEmptyString),
360 queryLength: Schema.optional(NonNegativeInt),
361 limit: Schema.optional(PositiveInt),
362 failure: Schema.optional(ProjectEntriesFailure),
363 normalizedCwd: Schema.optional(TrimmedNonEmptyString),
364 timeout: Schema.optional(TrimmedNonEmptyString),
365 detail: Schema.optional(TrimmedNonEmptyString),
366 message: TrimmedNonEmptyString,
367 cause: Schema.optional(Schema.Defect()),
368 },
369) {
370 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
371 constructor(
372 props: ProjectEntriesFailureContext & {
373 readonly cwd: string;
374 readonly queryLength: number;
375 readonly limit: number;
376 },
377 ) {
378 super({
379 ...props,
380 message:
381 decodedProjectErrorMessage(props) ??
382 `Failed to search workspace entries in '${props.cwd}'.`,
383 } as any);
384 }
386​
387export class ProjectSearchContentsError extends Schema.TaggedError<ProjectSearchContentsError>()(
388 "ProjectSearchContentsError",
389 {
390 cwd: Schema.optional(TrimmedNonEmptyString),
391 queryLength: Schema.optional(NonNegativeInt),
392 limit: Schema.optional(PositiveInt),
393 failure: Schema.optional(ProjectEntriesFailure),
394 normalizedCwd: Schema.optional(TrimmedNonEmptyString),
395 timeout: Schema.optional(TrimmedNonEmptyString),
396 detail: Schema.optional(TrimmedNonEmptyString),
397 message: TrimmedNonEmptyString,
398 cause: Schema.optional(Schema.Defect()),
399 },
400) {
401 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
402 constructor(
403 props: ProjectEntriesFailureContext & {
404 readonly cwd: string;
405 readonly queryLength: number;
406 readonly limit: number;
407 },
408 ) {
409 super({
410 ...props,
411 message:
412 decodedProjectErrorMessage(props) ??
413 `Failed to search workspace contents in '${props.cwd}'.`,
414 } as any);
415 }
417​
418export class ProjectListEntriesError extends Schema.TaggedError<ProjectListEntriesError>()(
419 "ProjectListEntriesError",
420 {
421 cwd: Schema.optional(TrimmedNonEmptyString),
422 failure: Schema.optional(ProjectEntriesFailure),
423 normalizedCwd: Schema.optional(TrimmedNonEmptyString),
424 timeout: Schema.optional(TrimmedNonEmptyString),
425 detail: Schema.optional(TrimmedNonEmptyString),
426 message: TrimmedNonEmptyString,
427 cause: Schema.optional(Schema.Defect()),
428 },
429) {
430 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
431 constructor(props: ProjectEntriesFailureContext & { readonly cwd: string }) {
432 super({
433 ...props,
434 message:
435 decodedProjectErrorMessage(props) ?? `Failed to list workspace entries in '${props.cwd}'.`,
436 } as any);
437 }
439​
440export const ProjectReadFileInput = Schema.Struct({
441 cwd: TrimmedNonEmptyString,
442 // Workspace-relative or absolute host path; only workspace-relative paths are writable.
443 relativePath: Schema.NonEmptyString.check(Schema.isMaxLength(PROJECT_READ_FILE_PATH_MAX_LENGTH)),
444 // Optional files can be absent without failing the read.
445 allowMissing: Schema.optional(Schema.Boolean),
446});
447export type ProjectReadFileInput = typeof ProjectReadFileInput.Type;
448​
449export const ProjectReadFileResult = Schema.Struct({
450 relativePath: Schema.NonEmptyString,
451 contents: Schema.String,
452 byteLength: NonNegativeInt,
453 truncated: Schema.Boolean,
454 // Omitted by older servers; false only for an absent optional file.
455 exists: Schema.optional(Schema.Boolean),
456});
457export type ProjectReadFileResult = typeof ProjectReadFileResult.Type;
458​
459export const ProjectFileFailure = Schema.Literals([
460 "workspace_path_outside_root",
461 "resolved_path_outside_root",
462 "path_not_file",
463 "binary_file",
464 "operation_failed",
465]);
466export type ProjectFileFailure = typeof ProjectFileFailure.Type;
467​
468export const ProjectFileOperation = Schema.Literals([
469 "realpath-workspace-root",
470 "realpath-target",
471 "open",
472 "stat",
473 "read",
474 "close",
475 "make-directory",
476 "write-file",
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(Schema.NonEmptyString),
496 failure: Schema.optional(ProjectFileFailure),
497 resolvedPath: Schema.optional(Schema.NonEmptyString),
498 resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString),
499 operation: Schema.optional(ProjectFileOperation),
500 operationPath: Schema.optional(Schema.NonEmptyString),
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 }
515​
516export const ProjectWriteFileInput = Schema.Struct({
517 cwd: TrimmedNonEmptyString,
518 relativePath: Schema.NonEmptyString.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: Schema.NonEmptyString,
525});
526export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;
527​
528export const ProjectEnsureScratchResult = Schema.Struct({
529 projectId: ProjectId,
530});
531export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type;
532​
533export const ProjectCreateNewInput = Schema.Struct({
534 name: TrimmedNonEmptyString.check(Schema.isMaxLength(200)),
535});
536export type ProjectCreateNewInput = typeof ProjectCreateNewInput.Type;
537​
538export const ProjectCreateNewResult = Schema.Struct({
539 projectId: ProjectId,
540 workspaceRoot: TrimmedNonEmptyString,
541 /** The project and its files exist either way. */
542 commitError: Schema.optionalKey(TrimmedNonEmptyString),
543});
544export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type;
545​
546export const ProjectCreateFromThreadInput = Schema.Struct({
547 threadId: ThreadId,
548});
549export type ProjectCreateFromThreadInput = typeof ProjectCreateFromThreadInput.Type;
550​
551export const ProjectCreateFromThreadResult = Schema.Struct({
552 projectId: ProjectId,
553});
554export type ProjectCreateFromThreadResult = typeof ProjectCreateFromThreadResult.Type;
555​
556export class ProjectWriteFileError extends Schema.TaggedError<ProjectWriteFileError>()(
557 "ProjectWriteFileError",
558 {
559 cwd: Schema.optional(TrimmedNonEmptyString),
560 relativePath: Schema.optional(Schema.NonEmptyString),
561 failure: Schema.optional(ProjectFileFailure),
562 resolvedPath: Schema.optional(Schema.NonEmptyString),
563 resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString),
564 operation: Schema.optional(ProjectFileOperation),
565 operationPath: Schema.optional(Schema.NonEmptyString),
566 message: TrimmedNonEmptyString,
567 cause: Schema.optional(Schema.Defect()),
568 },
569) {
570 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
571 constructor(props: ProjectFileFailureContext) {
572 super({
573 ...props,
574 message:
575 decodedProjectErrorMessage(props) ??
576 `Failed to write workspace file '${props.relativePath}' in '${props.cwd}'.`,
577 } as any);
578 }