Skip to content
Source
Sign inSign up

ScratchThreadPromotion.ts

163 lines6.3 KB

1import {
2 CommandId,
3 type OrchestrationV2ThreadShell,
4 ProjectId,
5 type ThreadId,
6} from "@t3tools/contracts";
7import * as Context from "effect/Context";
8import * as SecureRandom from "effect/crypto/SecureRandom";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as Path from "effect/Path";
14import * as Schema from "effect/Schema";
15​
16import { threadHasUnfinishedWork } from "../orchestration-v2/Orchestrator.ts";
17import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
18import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
19import * as ManagedProjectFolders from "./ManagedProjectFolders.ts";
20import * as ProjectService from "./ProjectService.ts";
21​
22export class ScratchThreadPromotionError extends Schema.TaggedError<ScratchThreadPromotionError>()(
23 "ScratchThreadPromotionError",
24 {
25 reason: Schema.String,
26 cause: Schema.optional(Schema.Defect()),
27 },
28) {
29 override get message(): string {
30 return this.reason;
31 }
33​
34export class ScratchThreadPromotion extends Context.Service<
35 ScratchThreadPromotion,
36 {
37 /**
38 * Turns a No project thread's folder into a project, in place, and moves every
39 * thread working in that folder into it. Running it again finishes a partial move.
40 */
41 readonly promote: (input: {
42 readonly threadId: ThreadId;
43 }) => Effect.Effect<{ readonly projectId: ProjectId }, ScratchThreadPromotionError>;
44 }
45>()("t3/project/ScratchThreadPromotion") {}
46​
47const make = Effect.gen(function* () {
48 const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders;
49 const projects = yield* ProjectService.ProjectService;
50 const threads = yield* ThreadManagementService.ThreadManagementService;
51 const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;
52 const random = yield* SecureRandom.SecureRandom;
53 const path = yield* Path.Path;
54 const fileSystem = yield* FileSystem.FileSystem;
55 const realPath = (folder: string) =>
56 fileSystem.realPath(folder).pipe(Effect.orElseSucceed(() => folder));
57​
58 const fail = (reason: string) => (cause: unknown) =>
59 new ScratchThreadPromotionError({ reason, cause });
60​
61 const promote: ScratchThreadPromotion["Service"]["promote"] = Effect.fn(
62 "ScratchThreadPromotion.promote",
63 )(function* (input) {
64 const thread = yield* threads.getThreadShell(input.threadId).pipe(
65 Effect.mapError(fail("Could not read the thread.")),
66 Effect.filterOrFail(
67 (shell) => shell !== null,
68 () => new ScratchThreadPromotionError({ reason: "Thread not found." }),
69 ),
70 );
71 const project = yield* projects
72 .getById(thread.projectId)
73 .pipe(Effect.mapError(fail("Could not read the thread's project.")));
74 const folder = thread.worktreePath ?? Option.getOrUndefined(project)?.workspaceRoot;
75 const scratchRoot = Option.getOrUndefined(yield* managedFolders.scratchRoot);
76 const isScratch = yield* managedFolders.isScratchProject(thread.projectId);
77 // A thread already rooted at its folder is a partial earlier promotion; finish it.
78 const alreadyPromoted =
79 Option.isSome(project) && project.value.workspaceRoot === folder && !isScratch;
80 if (
81 folder === undefined ||
82 scratchRoot === undefined ||
83 (yield* realPath(path.dirname(folder))) !== (yield* realPath(scratchRoot)) ||
84 !(isScratch || alreadyPromoted)
85 ) {
86 return yield* new ScratchThreadPromotionError({
87 reason: "Only a No project thread with its own folder can become a project.",
88 });
89 }
90​
91 const snapshot = yield* Effect.flatten(
92 threads.readShellSnapshot({ worktreePath: folder }),
93 ).pipe(Effect.mapError(fail("Could not read the threads in this folder.")));
94 const moveSet = new Map<ThreadId, OrchestrationV2ThreadShell>();
95 if (isScratch) moveSet.set(thread.id, thread);
96 for (const candidate of [...snapshot.threads, ...snapshot.archivedThreads]) {
97 if (yield* managedFolders.isScratchProject(candidate.projectId)) {
98 moveSet.set(candidate.id, candidate);
99 }
100 }
101​
102 for (const candidate of moveSet.values()) {
103 const records = yield* threads
104 .getThreadRecords(candidate.id, ["runs", "runtimeRequests"], { turnItemTypes: [] })
105 .pipe(Effect.mapError(fail(`Could not read "${candidate.title}".`)));
106 if (threadHasUnfinishedWork(records)) {
107 return yield* new ScratchThreadPromotionError({
108 reason: `"${candidate.title}" is still working. Finish or stop it first.`,
109 });
110 }
111 }
112​
113 return yield* Effect.uninterruptible(
114 Effect.gen(function* () {
115 const projectId = yield* createProject(thread.title, folder);
116 yield* scheduledTasks
117 .moveThreadTasks({ threadIds: [...moveSet.keys()], projectId })
118 .pipe(Effect.mapError(fail("Could not move the thread's scheduled tasks.")));
119 for (const candidate of moveSet.values()) {
120 const commandId = CommandId.make(`scratch-promotion:${yield* randomId}`);
121 yield* threads
122 .dispatch({
123 type: "thread.project.adopt-folder",
124 commandId,
125 threadId: candidate.id,
126 projectId,
127 })
128 .pipe(Effect.mapError(fail(`Could not move "${candidate.title}". Try again.`)));
129 }
130 return { projectId };
131 }),
132 );
133 });
134​
135 const randomId = random.uuidV4();
136​
137 // bootstrap returns the project that already owns the folder, so retries reuse it.
138 const createProject = Effect.fn("ScratchThreadPromotion.createProject")(function* (
139 title: string,
140 workspaceRoot: string,
141 ) {
142 const id = yield* randomId;
143 return yield* projects
144 .bootstrap({
145 commandId: CommandId.make(`scratch-promotion-project:${id}`),
146 projectId: ProjectId.make(id),
147 title,
148 workspaceRoot,
149 })
150 .pipe(
151 Effect.map(({ project }) => project.id),
152 // bootstrap reads before locking; racing creates return the winner in a conflict.
153 Effect.catchTags({
154 ProjectConflictError: (conflict) => Effect.succeed(conflict.conflictingProjectId),
155 }),
156 Effect.mapError(fail("Could not create the project.")),
157 );
158 });
159​
160 return ScratchThreadPromotion.of({ promote });
161});
162​
163export const layer = Layer.effect(ScratchThreadPromotion, make);