Skip to content
Source
Sign inSign up

project.ts

577 lines18.9 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});
315export type ProjectListIgnoredFilesInput = typeof ProjectListIgnoredFilesInput.Type;
316​
317export const ProjectIgnoredFile = Schema.Struct({
318 path: TrimmedNonEmptyString,
319 byteLength: NonNegativeInt,
320});
321export type ProjectIgnoredFile = typeof ProjectIgnoredFile.Type;
322​
323export const ProjectListIgnoredFilesResult = Schema.Struct({
324 files: Schema.Array(ProjectIgnoredFile),
325 truncated: Schema.Boolean,
326});
327export type ProjectListIgnoredFilesResult = typeof ProjectListIgnoredFilesResult.Type;
328​
329export const ProjectEntriesFailure = Schema.Literals([
330 "workspace_root_not_found",
331 "workspace_root_create_failed",
332 "workspace_root_stat_failed",
333 "workspace_root_not_directory",
334 "search_index_create_failed",
335 "search_index_scan_timed_out",
336 "search_index_search_failed",
337 "directory_list_failed",
338]);
339export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type;
340​
341type ProjectEntriesFailureContext = {
342 readonly failure: ProjectEntriesFailure;
343 readonly normalizedCwd?: string;
344 readonly timeout?: string;
345 readonly detail?: string;
346 readonly cause?: unknown;
347};
348​
349function decodedProjectErrorMessage(props: object): string | undefined {
350 if (!("message" in props)) return undefined;
351 return typeof props.message === "string" ? props.message : undefined;
353​
354export class ProjectSearchEntriesError extends Schema.TaggedError<ProjectSearchEntriesError>()(
355 "ProjectSearchEntriesError",
356 {
357 cwd: Schema.optional(TrimmedNonEmptyString),
358 queryLength: Schema.optional(NonNegativeInt),
359 limit: Schema.optional(PositiveInt),
360 failure: Schema.optional(ProjectEntriesFailure),
361 normalizedCwd: Schema.optional(TrimmedNonEmptyString),
362 timeout: Schema.optional(TrimmedNonEmptyString),
363 detail: Schema.optional(TrimmedNonEmptyString),
364 message: TrimmedNonEmptyString,
365 cause: Schema.optional(Schema.Defect()),
366 },
367) {
368 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
369 constructor(
370 props: ProjectEntriesFailureContext & {
371 readonly cwd: string;
372 readonly queryLength: number;
373 readonly limit: number;
374 },
375 ) {
376 super({
377 ...props,
378 message:
379 decodedProjectErrorMessage(props) ??
380 `Failed to search workspace entries in '${props.cwd}'.`,
381 } as any);
382 }
384​
385export class ProjectSearchContentsError extends Schema.TaggedError<ProjectSearchContentsError>()(
386 "ProjectSearchContentsError",
387 {
388 cwd: Schema.optional(TrimmedNonEmptyString),
389 queryLength: Schema.optional(NonNegativeInt),
390 limit: Schema.optional(PositiveInt),
391 failure: Schema.optional(ProjectEntriesFailure),
392 normalizedCwd: Schema.optional(TrimmedNonEmptyString),
393 timeout: Schema.optional(TrimmedNonEmptyString),
394 detail: Schema.optional(TrimmedNonEmptyString),
395 message: TrimmedNonEmptyString,
396 cause: Schema.optional(Schema.Defect()),
397 },
398) {
399 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
400 constructor(
401 props: ProjectEntriesFailureContext & {
402 readonly cwd: string;
403 readonly queryLength: number;
404 readonly limit: number;
405 },
406 ) {
407 super({
408 ...props,
409 message:
410 decodedProjectErrorMessage(props) ??
411 `Failed to search workspace contents in '${props.cwd}'.`,
412 } as any);
413 }
415​
416export class ProjectListEntriesError extends Schema.TaggedError<ProjectListEntriesError>()(
417 "ProjectListEntriesError",
418 {
419 cwd: Schema.optional(TrimmedNonEmptyString),
420 failure: Schema.optional(ProjectEntriesFailure),
421 normalizedCwd: Schema.optional(TrimmedNonEmptyString),
422 timeout: Schema.optional(TrimmedNonEmptyString),
423 detail: Schema.optional(TrimmedNonEmptyString),
424 message: TrimmedNonEmptyString,
425 cause: Schema.optional(Schema.Defect()),
426 },
427) {
428 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
429 constructor(props: ProjectEntriesFailureContext & { readonly cwd: string }) {
430 super({
431 ...props,
432 message:
433 decodedProjectErrorMessage(props) ?? `Failed to list workspace entries in '${props.cwd}'.`,
434 } as any);
435 }
437​
438export const ProjectReadFileInput = Schema.Struct({
439 cwd: TrimmedNonEmptyString,
440 // Workspace-relative or absolute host path; only workspace-relative paths are writable.
441 relativePath: Schema.NonEmptyString.check(Schema.isMaxLength(PROJECT_READ_FILE_PATH_MAX_LENGTH)),
442 // Optional files can be absent without failing the read.
443 allowMissing: Schema.optional(Schema.Boolean),
444});
445export type ProjectReadFileInput = typeof ProjectReadFileInput.Type;
446​
447export const ProjectReadFileResult = Schema.Struct({
448 relativePath: Schema.NonEmptyString,
449 contents: Schema.String,
450 byteLength: NonNegativeInt,
451 truncated: Schema.Boolean,
452 // Omitted by older servers; false only for an absent optional file.
453 exists: Schema.optional(Schema.Boolean),
454});
455export type ProjectReadFileResult = typeof ProjectReadFileResult.Type;
456​
457export const ProjectFileFailure = Schema.Literals([
458 "workspace_path_outside_root",
459 "resolved_path_outside_root",
460 "path_not_file",
461 "binary_file",
462 "operation_failed",
463]);
464export type ProjectFileFailure = typeof ProjectFileFailure.Type;
465​
466export const ProjectFileOperation = Schema.Literals([
467 "realpath-workspace-root",
468 "realpath-target",
469 "open",
470 "stat",
471 "read",
472 "close",
473 "make-directory",
474 "write-file",
475]);
476export type ProjectFileOperation = typeof ProjectFileOperation.Type;
477​
478type ProjectFileFailureContext = {
479 readonly cwd: string;
480 readonly relativePath: string;
481 readonly failure: ProjectFileFailure;
482 readonly resolvedPath?: string;
483 readonly resolvedWorkspaceRoot?: string;
484 readonly operation?: ProjectFileOperation;
485 readonly operationPath?: string;
486 readonly cause?: unknown;
487};
488​
489export class ProjectReadFileError extends Schema.TaggedError<ProjectReadFileError>()(
490 "ProjectReadFileError",
491 {
492 cwd: Schema.optional(TrimmedNonEmptyString),
493 relativePath: Schema.optional(Schema.NonEmptyString),
494 failure: Schema.optional(ProjectFileFailure),
495 resolvedPath: Schema.optional(Schema.NonEmptyString),
496 resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString),
497 operation: Schema.optional(ProjectFileOperation),
498 operationPath: Schema.optional(Schema.NonEmptyString),
499 message: TrimmedNonEmptyString,
500 cause: Schema.optional(Schema.Defect()),
501 },
502) {
503 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
504 constructor(props: ProjectFileFailureContext) {
505 super({
506 ...props,
507 message:
508 decodedProjectErrorMessage(props) ??
509 `Failed to read workspace file '${props.relativePath}' in '${props.cwd}'.`,
510 } as any);
511 }
513​
514export const ProjectWriteFileInput = Schema.Struct({
515 cwd: TrimmedNonEmptyString,
516 relativePath: Schema.NonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)),
517 contents: Schema.String,
518});
519export type ProjectWriteFileInput = typeof ProjectWriteFileInput.Type;
520​
521export const ProjectWriteFileResult = Schema.Struct({
522 relativePath: Schema.NonEmptyString,
523});
524export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;
525​
526export const ProjectEnsureScratchResult = Schema.Struct({
527 projectId: ProjectId,
528});
529export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type;
530​
531export const ProjectCreateNewInput = Schema.Struct({
532 name: TrimmedNonEmptyString.check(Schema.isMaxLength(200)),
533});
534export type ProjectCreateNewInput = typeof ProjectCreateNewInput.Type;
535​
536export const ProjectCreateNewResult = Schema.Struct({
537 projectId: ProjectId,
538 workspaceRoot: TrimmedNonEmptyString,
539 /** The project and its files exist either way. */
540 commitError: Schema.optionalKey(TrimmedNonEmptyString),
541});
542export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type;
543​
544export const ProjectCreateFromThreadInput = Schema.Struct({
545 threadId: ThreadId,
546});
547export type ProjectCreateFromThreadInput = typeof ProjectCreateFromThreadInput.Type;
548​
549export const ProjectCreateFromThreadResult = Schema.Struct({
550 projectId: ProjectId,
551});
552export type ProjectCreateFromThreadResult = typeof ProjectCreateFromThreadResult.Type;
553​
554export class ProjectWriteFileError extends Schema.TaggedError<ProjectWriteFileError>()(
555 "ProjectWriteFileError",
556 {
557 cwd: Schema.optional(TrimmedNonEmptyString),
558 relativePath: Schema.optional(Schema.NonEmptyString),
559 failure: Schema.optional(ProjectFileFailure),
560 resolvedPath: Schema.optional(Schema.NonEmptyString),
561 resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString),
562 operation: Schema.optional(ProjectFileOperation),
563 operationPath: Schema.optional(Schema.NonEmptyString),
564 message: TrimmedNonEmptyString,
565 cause: Schema.optional(Schema.Defect()),
566 },
567) {
568 // @effect-diagnostics-next-line overriddenSchemaConstructor:off
569 constructor(props: ProjectFileFailureContext) {
570 super({
571 ...props,
572 message:
573 decodedProjectErrorMessage(props) ??
574 `Failed to write workspace file '${props.relativePath}' in '${props.cwd}'.`,
575 } as any);
576 }