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