Skip to content
Source
Sign inSign up
Open History to see the last commit

ProjectService.ts

652 lines23.8 KB

1import {
2 CommandId,
3 type OrchestrationProjectShell,
4 ProjectId,
5 type Project,
6 type ProjectCreatePayload,
7 type ProjectUpdatePayload,
8 type ProjectSnapshot,
9 type ThreadId,
10} from "@t3tools/contracts";
11import * as KeyedLock from "@t3tools/shared/KeyedLock";
12import * as Context from "effect/Context";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Result from "effect/Result";
19import * as Schema from "effect/Schema";
20​
21import { withCommandMetrics } from "../observability/Metrics.ts";
22import * as EventSink from "../orchestration-v2/EventSink.ts";
23import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
24import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts";
25import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
26import {
27 decodeProjectCommandRejection,
28 encodeProjectCommandRejection,
29 planProjectCommand,
30 type ProjectCommand,
31} from "../orchestration-v2/ProjectCommands.ts";
32import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
33import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
34import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
35import * as ServerSettings from "../serverSettings.ts";
36import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts";
37import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
38import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
39​
40export interface ProjectCreateInput extends ProjectCreatePayload {
41 readonly commandId: CommandId;
42 readonly projectId: ProjectId;
44​
45export interface ProjectUpdateInput extends ProjectUpdatePayload {
46 readonly commandId: CommandId;
47 readonly projectId: ProjectId;
49​
50export interface ProjectBootstrapInput extends ProjectCreateInput {}
51​
52export interface ProjectDeleteInput {
53 readonly commandId: CommandId;
54 readonly projectId: ProjectId;
55 readonly force?: boolean;
57​
58export class ProjectNotFoundError extends Schema.TaggedError<ProjectNotFoundError>()(
59 "ProjectNotFoundError",
60 { projectId: ProjectId },
61) {
62 override get message(): string {
63 return `Project ${this.projectId} was not found.`;
64 }
66​
67export class ProjectConflictError extends Schema.TaggedError<ProjectConflictError>()(
68 "ProjectConflictError",
69 {
70 projectId: ProjectId,
71 workspaceRoot: Schema.String,
72 conflictingProjectId: ProjectId,
73 },
74) {
75 override get message(): string {
76 return `Workspace ${this.workspaceRoot} already belongs to project ${this.conflictingProjectId}.`;
77 }
79​
80export class ProjectNotEmptyError extends Schema.TaggedError<ProjectNotEmptyError>()(
81 "ProjectNotEmptyError",
82 { projectId: ProjectId },
83) {
84 override get message(): string {
85 return `Project ${this.projectId} is not empty.`;
86 }
88​
89/** A change the project's rules refuse, such as an invalid script ID: the caller's input is wrong, nothing failed. */
90export class ProjectInvalidError extends Schema.TaggedError<ProjectInvalidError>()(
91 "ProjectInvalidError",
92 { projectId: ProjectId, detail: Schema.String },
93) {
94 override get message(): string {
95 return this.detail;
96 }
98​
99export class ProjectOperationError extends Schema.TaggedError<ProjectOperationError>()(
100 "ProjectOperationError",
101 {
102 operation: Schema.Literals([
103 "normalize-workspace",
104 "read-project",
105 "list-projects",
106 "list-threads",
107 "delete-thread",
108 "dispatch-project-command",
109 ]),
110 projectId: Schema.optional(ProjectId),
111 workspaceRoot: Schema.optional(Schema.String),
112 cause: Schema.Defect(),
113 },
114) {
115 override get message(): string {
116 return `Project operation '${this.operation}' failed${this.projectId === undefined ? "" : ` for ${this.projectId}`}.`;
117 }
119​
120export type ProjectServiceError =
121 | ProjectNotFoundError
122 | ProjectConflictError
123 | ProjectNotEmptyError
124 | ProjectInvalidError
125 | ProjectOperationError;
126​
127export class ProjectService extends Context.Service<
128 ProjectService,
129 {
130 readonly create: (input: ProjectCreateInput) => Effect.Effect<Project, ProjectServiceError>;
131 readonly bootstrap: (
132 input: ProjectBootstrapInput,
133 ) => Effect.Effect<
134 { readonly project: Project; readonly created: boolean },
135 ProjectServiceError
136 >;
137 readonly update: (input: ProjectUpdateInput) => Effect.Effect<Project, ProjectServiceError>;
138 readonly delete: (input: ProjectDeleteInput) => Effect.Effect<Project, ProjectServiceError>;
139 readonly getById: (
140 projectId: ProjectId,
141 options?: { readonly includeDeleted?: boolean },
142 ) => Effect.Effect<Option.Option<Project>, ProjectOperationError>;
143 readonly getByWorkspaceRoot: (
144 workspaceRoot: string,
145 options?: { readonly includeDeleted?: boolean },
146 ) => Effect.Effect<Option.Option<Project>, ProjectOperationError>;
147 /**
148 * Where to scan `cwd`'s workspace inventory. A thread's removed managed worktree scans its
149 * project, which turn start recreates the worktree from; anything else, or a failure, is `cwd`.
150 */
151 readonly workspaceScanDirectory: (cwd: string) => Effect.Effect<string>;
152 readonly snapshot: Effect.Effect<ProjectSnapshot, ProjectOperationError>;
153 /**
154 * A page of active projects in creation order, each with its repository
155 * identity resolved, so a caller can match projects by repository.
156 */
157 readonly listPage: (input: {
158 readonly cursor: number;
159 readonly limit: number;
160 }) => Effect.Effect<
161 { readonly projects: ReadonlyArray<Project>; readonly nextCursor: number | null },
162 ProjectOperationError
163 >;
164 readonly getShell: (
165 projectId: ProjectId,
166 ) => Effect.Effect<Option.Option<OrchestrationProjectShell>, ProjectOperationError>;
167 readonly listShells: (options?: {
168 readonly projectIds?: ReadonlyArray<ProjectId>;
169 }) => Effect.Effect<ReadonlyArray<OrchestrationProjectShell>, ProjectOperationError>;
170 }
171>()("t3/project/ProjectService") {}
172​
173export const make = Effect.gen(function* () {
174 const projects = yield* ProjectStore.ProjectStoreV2;
175 const projectEnrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService;
176 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
177 const threadProjections = yield* ProjectionStore.ProjectionStoreV2;
178 const eventSink = yield* EventSink.EventSinkV2;
179 const idAllocator = yield* IdAllocator.IdAllocatorV2;
180 const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
181 const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor;
182 const serverSettings = yield* ServerSettings.ServerSettingsService;
183 const fileSystem = yield* FileSystem.FileSystem;
184 const isManagedWorktreePath = yield* makeIsManagedWorktreePath;
185 const projectLocks = yield* KeyedLock.make<ProjectId>();
186 const workspaceLocks = yield* KeyedLock.make<string>();
187​
188 const toProject = (
189 row: ProjectStore.ProjectRow,
190 enrichment: ProjectEnrichmentService.ProjectEnrichment | null,
191 ): Project => ({
192 id: row.projectId,
193 title: row.title,
194 workspaceRoot: row.workspaceRoot,
195 repositoryIdentity: enrichment?.repositoryIdentity ?? null,
196 faviconPath: row.faviconPath ?? enrichment?.faviconPath ?? null,
197 defaultModelSelection: row.defaultModelSelection,
198 defaultThreadEnvMode: row.defaultThreadEnvMode,
199 autoPull: row.autoPull,
200 projectIcon: row.projectIcon,
201 scripts: row.scripts,
202 pinnedAt: row.pinnedAt,
203 archivedAt: row.archivedAt,
204 createdAt: row.createdAt,
205 updatedAt: row.updatedAt,
206 deletedAt: row.deletedAt,
207 });
208​
209 const hydrate = Effect.fn("ProjectService.hydrate")(function* (row: ProjectStore.ProjectRow) {
210 const enrichment =
211 row.deletedAt === null
212 ? yield* projectEnrichment.getAvailable(row.workspaceRoot)
213 : yield* projectEnrichment.peek(row.workspaceRoot);
214 return toProject(row, enrichment);
215 });
216​
217 const readRow = (projectId: ProjectId, options?: { readonly includeDeleted?: boolean }) =>
218 projects
219 .get(projectId, options)
220 .pipe(
221 Effect.mapError(
222 (cause) => new ProjectOperationError({ operation: "read-project", projectId, cause }),
223 ),
224 );
225​
226 const normalizeWorkspaceRoot = (input: {
227 readonly projectId?: ProjectId;
228 readonly workspaceRoot: string;
229 readonly createIfMissing?: boolean;
230 }) =>
231 workspacePaths
232 .normalizeWorkspaceRoot(input.workspaceRoot, {
233 createIfMissing: input.createIfMissing ?? false,
234 })
235 .pipe(
236 Effect.mapError(
237 (cause) =>
238 new ProjectOperationError({
239 operation: "normalize-workspace",
240 ...(input.projectId === undefined ? {} : { projectId: input.projectId }),
241 workspaceRoot: input.workspaceRoot,
242 cause,
243 }),
244 ),
245 );
246​
247 const commit = Effect.fn("ProjectService.commit")(function* (command: ProjectCommand) {
248 const { projectId } = command;
249 const dispatchError = (cause: unknown) =>
250 new ProjectOperationError({ operation: "dispatch-project-command", projectId, cause });
251 const workspaceRoot = command.type === "project.delete" ? undefined : command.workspaceRoot;
252 const planAndCommit = Effect.gen(function* () {
253 const project = Option.getOrUndefined(yield* readRow(projectId, { includeDeleted: true }));
254 const workspaceOwner =
255 workspaceRoot === undefined
256 ? undefined
257 : Option.getOrUndefined(
258 yield* projects
259 .findActiveByWorkspaceRoot(workspaceRoot)
260 .pipe(Effect.mapError(dispatchError)),
261 );
262 const now = yield* DateTime.now;
263 const eventId = yield* idAllocator.allocate
264 .event({ commandId: command.commandId })
265 .pipe(Effect.mapError(dispatchError));
266 const planned = planProjectCommand({
267 command,
268 state: { project, workspaceOwner },
269 eventId,
270 now,
271 });
272 if (Result.isSuccess(planned)) {
273 const { receipt } = yield* eventSink.commitProjectCommand({
274 commandId: command.commandId,
275 projectId,
276 commandType: command.type,
277 acceptedAt: now,
278 event: planned.success,
279 });
280 return receipt;
281 }
282 return yield* eventSink.commitRejectedProjectCommand({
283 commandId: command.commandId,
284 projectId,
285 commandType: command.type,
286 rejectedAt: now,
287 error: encodeProjectCommandRejection(planned.failure),
288 });
289 });
290 const receipt = yield* projectLocks
291 .withLock(
292 projectId,
293 workspaceRoot === undefined
294 ? planAndCommit
295 : workspaceLocks.withLock(workspaceRoot, planAndCommit),
296 )
297 .pipe(Effect.mapError(dispatchError));
298 if (receipt.projectId !== projectId || receipt.commandType !== command.type) {
299 return yield* dispatchError(
300 `Command ${command.commandId} was already used by ${receipt.commandType} for ${receipt.projectId}.`,
301 );
302 }
303 // Retries re-plan against their own effects; the first receipt determines the outcome.
304 if (receipt.status === "accepted") return;
305 const rejection = Option.getOrUndefined(decodeProjectCommandRejection(receipt.error));
306 switch (rejection?._tag) {
307 case "ProjectWorkspaceConflictError":
308 return yield* new ProjectConflictError({
309 projectId,
310 workspaceRoot: rejection.workspaceRoot,
311 conflictingProjectId: rejection.conflictingProjectId,
312 });
313 case "ProjectCommandMissingProjectError":
314 return yield* new ProjectNotFoundError({ projectId });
315 case "ProjectCommandInvariantError":
316 return yield* new ProjectInvalidError({ projectId, detail: rejection.detail });
317 default:
318 return yield* dispatchError(
319 rejection ?? receipt.error ?? "The command was previously rejected.",
320 );
321 }
322 }, withCommandMetrics);
323​
324 const readCommitted = Effect.fn("ProjectService.readCommitted")(function* (projectId: ProjectId) {
325 const row = yield* readRow(projectId, { includeDeleted: true });
326 if (Option.isNone(row)) {
327 return yield* new ProjectOperationError({
328 operation: "read-project",
329 projectId,
330 cause: "The accepted project command did not produce a project row.",
331 });
332 }
333 return yield* hydrate(row.value);
334 });
335​
336 const getById: ProjectService["Service"]["getById"] = Effect.fn("ProjectService.getById")(
337 function* (projectId, options) {
338 const row = yield* readRow(projectId, options);
339 return Option.isNone(row) ? Option.none() : Option.some(yield* hydrate(row.value));
340 },
341 );
342​
343 const workspaceScanDirectory = Effect.fn("ProjectService.workspaceScanDirectory")(
344 function* (cwd: string) {
345 if (yield* fileSystem.exists(cwd).pipe(Effect.orElseSucceed(() => true))) return cwd;
346 if (!(yield* isManagedWorktreePath(cwd))) return cwd;
347 const snapshot = yield* Effect.flatten(
348 threadProjections.readShellSnapshot({ worktreePath: cwd }),
349 );
350 const thread = [...snapshot.threads, ...snapshot.archivedThreads].find(
351 (candidate) => candidate.branch !== null,
352 );
353 if (thread === undefined) return cwd;
354 return Option.getOrUndefined(yield* readRow(thread.projectId))?.workspaceRoot ?? cwd;
355 },
356 (effect, cwd) => Effect.orElseSucceed(effect, () => cwd),
357 );
358​
359 const getByWorkspaceRoot: ProjectService["Service"]["getByWorkspaceRoot"] = Effect.fn(
360 "ProjectService.getByWorkspaceRoot",
361 )(function* (workspaceRoot, options) {
362 const normalized = yield* normalizeWorkspaceRoot({ workspaceRoot });
363 const row = yield* (
364 options?.includeDeleted === true
365 ? projects
366 .list({ includeDeleted: true })
367 .pipe(
368 Effect.map((rows) =>
369 Option.fromUndefinedOr(rows.find((row) => row.workspaceRoot === normalized)),
370 ),
371 )
372 : projects.findActiveByWorkspaceRoot(normalized)
373 ).pipe(
374 Effect.mapError((cause) => new ProjectOperationError({ operation: "list-projects", cause })),
375 );
376 return Option.isNone(row) ? Option.none() : Option.some(yield* hydrate(row.value));
377 });
378​
379 const create: ProjectService["Service"]["create"] = Effect.fn("ProjectService.create")(
380 function* (input) {
381 const workspaceRoot = yield* normalizeWorkspaceRoot({
382 projectId: input.projectId,
383 workspaceRoot: input.workspaceRoot,
384 createIfMissing: input.createWorkspaceRootIfMissing ?? false,
385 });
386 yield* commit({
387 type: "project.create",
388 commandId: input.commandId,
389 projectId: input.projectId,
390 title: input.title,
391 workspaceRoot,
392 ...(input.scripts === undefined ? {} : { scripts: input.scripts }),
393 });
394 yield* projectEnrichment.invalidate([workspaceRoot]);
395 return yield* readCommitted(input.projectId);
396 },
397 );
398​
399 const update: ProjectService["Service"]["update"] = Effect.fn("ProjectService.update")(
400 function* (input) {
401 const existing = yield* readRow(input.projectId);
402 if (Option.isNone(existing)) {
403 return yield* new ProjectNotFoundError({ projectId: input.projectId });
404 }
405 const previousRoot = existing.value.workspaceRoot;
406 const workspaceRoot =
407 input.workspaceRoot === undefined
408 ? previousRoot
409 : yield* normalizeWorkspaceRoot({
410 projectId: input.projectId,
411 workspaceRoot: input.workspaceRoot,
412 });
413 yield* commit({
414 type: "project.meta.update",
415 commandId: input.commandId,
416 projectId: input.projectId,
417 ...(input.title === undefined ? {} : { title: input.title }),
418 ...(workspaceRoot === previousRoot ? {} : { workspaceRoot }),
419 ...(input.defaultModelSelection === undefined
420 ? {}
421 : { defaultModelSelection: input.defaultModelSelection }),
422 ...(input.autoPull === undefined ? {} : { autoPull: input.autoPull }),
423 ...(input.projectIcon === undefined ? {} : { projectIcon: input.projectIcon }),
424 ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }),
425 ...(input.defaultThreadEnvMode === undefined
426 ? {}
427 : { defaultThreadEnvMode: input.defaultThreadEnvMode }),
428 ...(input.scripts === undefined ? {} : { scripts: input.scripts }),
429 ...(input.pinned === undefined ? {} : { pinned: input.pinned }),
430 ...(input.archived === undefined ? {} : { archived: input.archived }),
431 });
432 if (workspaceRoot !== previousRoot || input.faviconPath !== undefined) {
433 yield* projectEnrichment.invalidate([previousRoot, workspaceRoot]);
434 }
435 return yield* readCommitted(input.projectId);
436 },
437 );
438​
439 const bootstrap: ProjectService["Service"]["bootstrap"] = Effect.fn("ProjectService.bootstrap")(
440 function* (input) {
441 const existing = yield* getByWorkspaceRoot(input.workspaceRoot);
442 if (Option.isSome(existing)) return { project: existing.value, created: false };
443 return { project: yield* create(input), created: true };
444 },
445 );
446​
447 const deleteChildThread = Effect.fn("ProjectService.deleteChildThread")(function* (
448 input: ProjectDeleteInput,
449 threadId: ThreadId,
450 ) {
451 yield* legacyImporter.ensureTranscript(threadId);
452 const projection = yield* threadProjections.getThreadRecords(threadId, [
453 "runs",
454 "attempts",
455 "nodes",
456 "runtimeRequests",
457 "subagents",
458 "providerSessions",
459 ]);
460 if (projection.thread.deletedAt !== null || projection.thread.projectId !== input.projectId) {
461 return;
462 }
463 const command = {
464 type: "thread.delete" as const,
465 commandId: CommandId.make(`${input.commandId}:delete-thread:${threadId}`),
466 threadId,
467 };
468 const now = yield* DateTime.now;
469 const plan = yield* planThreadDeletion({
470 command,
471 projection,
472 attachmentIds: yield* threadProjections.getThreadAttachmentIds(threadId),
473 now,
474 idAllocator,
475 });
476 const committed = yield* eventSink.commitCommand({
477 commandId: command.commandId,
478 commandType: command.type,
479 threadId,
480 acceptedAt: now,
481 events: plan.events,
482 effects: plan.effects,
483 });
484 if (
485 committed.receipt.threadId !== command.threadId ||
486 committed.receipt.commandType !== command.type
487 ) {
488 return yield* Effect.fail("The thread deletion command ID belongs to a different command.");
489 }
490 if (committed.receipt.status === "rejected") {
491 return yield* Effect.fail(
492 committed.receipt.error ?? "Thread deletion was previously rejected.",
493 );
494 }
495 });
496​
497 const deleteChildThreads = Effect.fn("ProjectService.deleteChildThreads")(function* (
498 input: ProjectDeleteInput,
499 ) {
500 const { projectId } = input;
501 const snapshot = yield* threadProjections
502 .getShellSnapshot({ projectId })
503 .pipe(
504 Effect.mapError(
505 (cause) => new ProjectOperationError({ operation: "list-threads", projectId, cause }),
506 ),
507 );
508 // Newest first: a source deleted while its fork lives keeps the pages that fork shows.
509 const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].sort(
510 (a, b) => DateTime.toEpochMillis(b.createdAt) - DateTime.toEpochMillis(a.createdAt),
511 );
512 if (projectThreads.length > 0 && input.force !== true) {
513 return yield* new ProjectNotEmptyError({ projectId });
514 }
515 // Delete children durably before the project so a failed cascade can be retried.
516 yield* Effect.forEach(
517 projectThreads,
518 (thread) =>
519 threadCommands
520 .withLock(thread.id, deleteChildThread(input, thread.id))
521 .pipe(
522 Effect.mapError(
523 (cause) =>
524 new ProjectOperationError({ operation: "delete-thread", projectId, cause }),
525 ),
526 ),
527 { concurrency: 1, discard: true },
528 );
529 });
530​
531 // Best-effort cleanup releases disabled instances kept alive by overrides; crashes have no replay to revisit deletion.
532 const clearProjectSettingsOverrides = (projectId: ProjectId) =>
533 serverSettings.updateSettings({ projectSettingsOverrides: { [projectId]: null } }).pipe(
534 Effect.asVoid,
535 Effect.catchCause((cause) =>
536 Effect.logWarning(
537 `Failed to clear settings overrides for deleted project ${projectId}.`,
538 cause,
539 ),
540 ),
541 );
542​
543 const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")(
544 function* (input) {
545 const { projectId } = input;
546 // Read deleted rows so retries replay receipts and other command ids fail as not found.
547 const existing = yield* readRow(projectId, { includeDeleted: true });
548 if (Option.isNone(existing)) {
549 return yield* new ProjectNotFoundError({ projectId });
550 }
551​
552 const isFirstDelete = existing.value.deletedAt === null;
553 if (isFirstDelete) {
554 yield* deleteChildThreads(input);
555 }
556 yield* commit({ type: "project.delete", commandId: input.commandId, projectId });
557 if (isFirstDelete) {
558 yield* clearProjectSettingsOverrides(projectId);
559 }
560 yield* projectEnrichment.invalidate([existing.value.workspaceRoot]);
561 return yield* readCommitted(projectId);
562 },
563 );
564​
565 const enrichShell = (shell: OrchestrationProjectShell) =>
566 projectEnrichment.getAvailable(shell.workspaceRoot).pipe(
567 Effect.map((enrichment) => ({
568 ...shell,
569 repositoryIdentity: enrichment.repositoryIdentity,
570 })),
571 );
572​
573 const getShell: ProjectService["Service"]["getShell"] = Effect.fn("ProjectService.getShell")(
574 function* (projectId) {
575 const shell = yield* projects
576 .getShell(projectId)
577 .pipe(
578 Effect.mapError(
579 (cause) => new ProjectOperationError({ operation: "read-project", projectId, cause }),
580 ),
581 );
582 return Option.isNone(shell) ? shell : Option.some(yield* enrichShell(shell.value));
583 },
584 );
585​
586 const listShells: ProjectService["Service"]["listShells"] = Effect.fn(
587 "ProjectService.listShells",
588 )(function* (options) {
589 const shells = yield* projects
590 .listShells(options)
591 .pipe(
592 Effect.mapError(
593 (cause) => new ProjectOperationError({ operation: "list-projects", cause }),
594 ),
595 );
596 return yield* Effect.forEach(shells, enrichShell, { concurrency: 16 });
597 });
598​
599 const listRows = projects
600 .list()
601 .pipe(
602 Effect.mapError((cause) => new ProjectOperationError({ operation: "list-projects", cause })),
603 );
604​
605 const snapshot = Effect.gen(function* () {
606 const rows = yield* listRows;
607 const hydrated = yield* Effect.forEach(rows, hydrate, { concurrency: 8 });
608 return {
609 projects: hydrated,
610 updatedAt: DateTime.formatIso(yield* DateTime.now),
611 } satisfies ProjectSnapshot;
612 });
613​
614 const listPage: ProjectService["Service"]["listPage"] = Effect.fn("ProjectService.listPage")(
615 function* ({ cursor, limit }) {
616 const rows = (yield* listRows).filter((project) => project.deletedAt === null);
617 const projects = yield* Effect.forEach(
618 rows.slice(cursor, cursor + limit),
619 (row) =>
620 hydrate(row).pipe(
621 Effect.filterOrElse(
622 (project) => project.repositoryIdentity !== null,
623 (project) =>
624 projectEnrichment
625 .readRepositoryIdentity(project.workspaceRoot)
626 .pipe(Effect.map((repositoryIdentity) => ({ ...project, repositoryIdentity }))),
627 ),
628 ),
629 { concurrency: 8 },
630 );
631 return { projects, nextCursor: cursor + limit < rows.length ? cursor + limit : null };
632 },
633 );
634​
635 return ProjectService.of({
636 create,
637 bootstrap,
638 update,
639 delete: deleteProject,
640 getById,
641 getByWorkspaceRoot,
642 workspaceScanDirectory,
643 snapshot,
644 listPage,
645 getShell,
646 listShells,
647 });
648});
649​
650export const layer = Layer.effect(ProjectService, make).pipe(
651 Layer.provide(ThreadCommandExecutor.layer),
652);