1
23import type { PrismaClient } from "@prisma/client";
24import type { Request } from "express";
25import { ApiError } from "../../../common/errors.js";
26import { PageService } from "../../pages/page.service.js";
27import { BlockService } from "../../blocks/block.service.js";
28import { DatabaseService } from "../../databases/database.service.js";
29import { DatabaseQueryService } from "../../databases/query.service.js";
30import { logger } from "../../../common/logger.js";
31import { BLOCK_TYPES, type BlockType } from "../../../constants.js";
32import { validateCreateDatabase } from "../db-prompts.js";
33import type {
34 CreatePageCall,
35 CreateDatabaseCall,
36 CreateRelationCall,
37 GenerateBlockPayload,
38 GeneratePlan,
39 InsertBlocksCall,
40} from "@factory/ai";
41
42
43export type StepName = "createPage" | "insertBlocks" | "createDatabase" | "createRelation";
44
45
46export interface CreatedRef {
47 kind: "page" | "database" | "relation";
48
49 pageId?: string;
50
51 databaseId?: string;
52
53 relationId?: string;
54
55 sourceDatabaseId?: string;
56}
57
58
59export interface GeneratedResult {
60 pageId: string;
61 created: { pages: string[]; blocks: number; databases: string[]; relations: number };
62}
63
64
65export class GenerationAborted extends Error {
66 constructor(readonly reason: "cancelled" | "timeout") {
67 super(`generation ${reason}`);
68 this.name = "GenerationAborted";
69 }
70}
71
72
73export class GenerationStepFailed extends Error {
74 constructor(readonly failedStep: StepName, readonly error: unknown) {
75 super(`generation failed at step ${failedStep}: ${String((error as Error)?.message ?? error)}`);
76 this.name = "GenerationStepFailed";
77 }
78}
79
80
81export interface ExecutorContext {
82 workspaceId: string;
83 userId: string;
84
86 req: Request;
87
90 assertContext?: (ctx: { workspaceId: string; userId: string; req: unknown }) => void;
91
92 signal: AbortSignal;
93
94 onStep: (name: StepName, status: "started" | "done", ref?: string) => void;
95
96 expectedUserId?: string;
97}
98
99export interface ExecutorServices {
100 pages: PageService;
101 blocks: BlockService;
102 databases: DatabaseService;
103 records: DatabaseQueryService;
104
105 prisma: PrismaClient;
106}
107
108
109interface RefState {
110 pageId?: string;
111 databaseId?: string;
112 blockIds: string[];
113}
114
115export class GenerationExecutor {
116 private readonly ctx: ExecutorContext;
117
118 private readonly refMap = new Map<string, RefState>();
119
120 private readonly created: CreatedRef[] = [];
121
122 private rootPageId: string | null = null;
123
127 private readonly rootBlockIds: string[] = [];
128 private blocksCreated = 0;
129 private cancelled = false;
130
131 constructor(private readonly svc: ExecutorServices, ctx: ExecutorContext) {
132 this.ctx = ctx;
133 }
134
135
136 get rootPage(): string | null {
137 return this.rootPageId;
138 }
139
140
141 async execute(plan: GeneratePlan, parentId?: string | null): Promise<GeneratedResult> {
142 for (const call of plan.tools) {
143 const name: StepName = call.name;
144 this.ctx.onStep(name, "started");
145 let ref: string | undefined;
146 try {
147 this.checkAborted();
148 switch (call.name) {
149 case "createPage":
150 ref = await this.runCreatePage(call.args, parentId);
151 break;
152 case "insertBlocks":
153 ref = await this.runInsertBlocks(call.args);
154 break;
155 case "createDatabase":
156 ref = await this.runCreateDatabase(call.args);
157 break;
158 case "createRelation":
159 ref = await this.runCreateRelation(call.args);
160 break;
161 }
162 this.ctx.onStep(name, "done", ref);
163 } catch (err) {
164
165
166
167 await this.rollback();
168 if (err instanceof GenerationAborted) throw err;
169 throw new GenerationStepFailed(name, err);
170 }
171 }
172 const pageId = this.rootPageId;
173 if (!pageId) throw new GenerationStepFailed("createPage", new Error("the plan created no page"));
174 return {
175 pageId,
176 created: {
177 pages: this.created.filter((c) => c.kind === "page").map((c) => c.pageId!).filter(Boolean),
178 blocks: this.blocksCreated,
179 databases: this.created.filter((c) => c.kind === "database").map((c) => c.databaseId!).filter(Boolean),
180 relations: this.created.filter((c) => c.kind === "relation").length,
181 },
182 };
183 }
184
185
186 private checkAborted(): void {
187 if (this.ctx.signal.aborted) {
188 this.cancelled = true;
189 throw new GenerationAborted(this.ctx.signal.reason === "timeout" ? "timeout" : "cancelled");
190 }
191 }
192
193
195 private assertPermissionContext(): void {
196 const ctx = { workspaceId: this.ctx.workspaceId, userId: this.ctx.userId, req: this.ctx.req };
197
198
199
200 if (this.ctx.expectedUserId != null && this.ctx.userId !== this.ctx.expectedUserId) {
201 throw ApiError.forbidden("Generation may not run with an elevated context.", [
202 { reason: "elevated_context" },
203 ]);
204 }
205 this.ctx.assertContext?.(ctx);
206 this.checkAborted();
207 }
208
209 private resolveRef(ref: string | undefined, what: string): RefState {
210 const key = ref ?? "__root__";
211 const st = this.refMap.get(key);
212 if (!st) {
213 throw ApiError.unprocessable(
214 `Plan references ${what} "${ref ?? "(root)"}" before it is created.`,
215 [{ field: "ref", issue: `unknown_ref:${ref ?? "root"}` }],
216 );
217 }
218 return st;
219 }
220
221
224 private async runCreatePage(args: CreatePageCall, endpointParentId?: string | null): Promise<string> {
225 this.assertPermissionContext();
226
227
228 const isRoot = this.rootPageId == null;
229 const parentId = isRoot ? endpointParentId ?? null : this.rootPageId;
230 const refKey = args.ref ?? "__root__";
231 const created = await this.svc.pages.create(
232 this.ctx.workspaceId,
233 this.ctx.userId,
234 { title: args.title, ...(parentId ? { parentId } : {}), type: "page" },
235 this.ctx.req,
236 );
237 const pageId = (created as { id: string }).id;
238 if (isRoot) this.rootPageId = pageId;
239 const state: RefState = { pageId, blockIds: [] };
240 this.refMap.set(refKey, state);
241 this.created.push({ kind: "page", pageId });
242
243 if (args.blocks && args.blocks.length > 0) {
244 const ids = await this.insertBlockList(pageId, args.blocks);
245 state.blockIds.push(...ids);
246 }
247 return pageId;
248 }
249
250
253 private async insertBlockList(pageId: string, blocks: GenerateBlockPayload[]): Promise<string[]> {
254 let afterBlockId: string | undefined;
255 const ids: string[] = [];
256 for (const b of blocks) {
257 this.assertPermissionContext();
258 const type = (b.type as string).toUpperCase();
259 if (!(BLOCK_TYPES as readonly string[]).includes(type)) {
260 throw ApiError.unprocessable(`Plan step insertBlocks uses an unknown block type "${b.type}".`, [
261 { field: "type", issue: `unknown_block_type:${b.type}` },
262 ]);
263 }
264 const created = await this.svc.blocks.create(
265 pageId,
266 this.ctx.userId,
267 { type: type as BlockType, content: b.content, properties: {}, ...(afterBlockId ? { afterBlockId } : {}) },
268 this.ctx.req,
269 );
270 const blockId = (created as { id: string }).id;
271 ids.push(blockId);
272 afterBlockId = blockId;
273 this.blocksCreated += 1;
274 }
275 return ids;
276 }
277
278
285 private async runInsertBlocks(args: InsertBlocksCall): Promise<string> {
286 if (args.pageRef === undefined) {
287 const pageId = this.rootPageId;
288 if (!pageId) throw ApiError.unprocessable("Plan step insertBlocks has no host page (it runs before any createPage).", [{ field: "pageRef", issue: "no_host_page" }]);
289 const ids = await this.insertBlockList(pageId, args.blocks);
290 this.rootBlockIds.push(...ids);
291 return pageId;
292 }
293 const host = this.resolveRef(args.pageRef, "page");
294 if (!host.pageId) throw ApiError.unprocessable(`Plan step insertBlocks references "${args.pageRef}" which is not a page.`, [{ field: "pageRef", issue: "not_a_page" }]);
295 const pageId = host.pageId;
296 const ids = await this.insertBlockList(pageId, args.blocks);
297 host.blockIds.push(...ids);
298 return pageId;
299 }
300
301
306 private async runCreateDatabase(args: CreateDatabaseCall): Promise<string> {
307 this.assertPermissionContext();
308
309
310 const parsed = validateCreateDatabase(args as unknown as Record<string, unknown>);
311 if (!parsed.ok) {
312
313
314 throw new Error(parsed.feedback);
315 }
316 const normalized = parsed.value;
317 const host = args.pageRef ? this.resolveRef(args.pageRef, "page") : { pageId: this.rootPageId ?? undefined, blockIds: [] };
318 const hostPageId = host.pageId ?? this.rootPageId;
319 if (!hostPageId) throw ApiError.unprocessable("Plan step createDatabase has no host page (declare pageRef).", [{ field: "pageRef", issue: "no_host_page" }]);
320 const refKey = args.ref ?? "db";
321 const created = await this.svc.pages.create(
322 this.ctx.workspaceId,
323 this.ctx.userId,
324 { title: normalized.title, parentId: hostPageId, type: "database" },
325 this.ctx.req,
326 );
327 const page = created as { id: string; databaseId?: string };
328 const databaseId = page.databaseId!;
329 if (!databaseId) throw ApiError.unprocessable("The database page was created without a database id.", [{ field: "databaseId", issue: "missing" }]);
330 this.refMap.set(refKey, { pageId: page.id, databaseId, blockIds: [] });
331 this.created.push({ kind: "database", pageId: page.id, databaseId });
332
333
334 for (const p of normalized.properties) {
335 this.assertPermissionContext();
336 await this.svc.databases.createProperty(
337 databaseId,
338 this.ctx.userId,
339 {
340 name: p.name,
341 type: p.type,
342 ...(p.options ? { config: { options: p.options } } : { config: {} }),
343 ...(p.required !== undefined ? { required: p.required } : {}),
344 },
345 this.ctx.req,
346 );
347 }
348
349
350 if (normalized.sampleRecords.length > 0) {
351 const allProps = await this.svc.prisma.databaseProperty.findMany({
352 where: { databaseId, deletedAt: null },
353 select: { id: true, name: true },
354 });
355 const idByName = new Map(allProps.map((ap) => [ap.name.toLowerCase(), ap.id]));
356 for (const rec of normalized.sampleRecords) {
357 this.assertPermissionContext();
358 const title = typeof rec.Title === "string" ? rec.Title : undefined;
359 const properties: Record<string, unknown> = {};
360 for (const [name, value] of Object.entries(rec)) {
361 if (name === "Title") continue;
362 const pid = idByName.get(name.toLowerCase());
363 if (pid) properties[pid] = value;
364 }
365 await this.svc.records.createRecord(databaseId, this.ctx.userId, { title, properties, links: {} }, this.ctx.req);
366 }
367 }
368 return databaseId;
369 }
370
371
373 private async runCreateRelation(args: CreateRelationCall): Promise<string> {
374 this.assertPermissionContext();
375 const src = this.resolveRef(args.databaseRef, "database");
376 const tgt = this.resolveRef(args.targetPageRef, "database");
377 if (!src.databaseId) throw ApiError.unprocessable(`Plan step createRelation references "${args.databaseRef}" which is not a database.`, [{ field: "databaseRef", issue: "not_a_database" }]);
378 if (!tgt.databaseId) throw ApiError.unprocessable(`Plan step createRelation references "${args.targetPageRef}" which is not a database.`, [{ field: "targetPageRef", issue: "not_a_database" }]);
379 const created = await this.svc.databases.createRelation(
380 src.databaseId,
381 this.ctx.userId,
382 { name: args.name, targetDatabaseId: tgt.databaseId, twoWay: true, cardinality: "MANY_TO_MANY" },
383 this.ctx.req,
384 );
385 const relationId = (created as { id: string }).id;
386 this.created.push({ kind: "relation", relationId, sourceDatabaseId: src.databaseId });
387 return relationId;
388 }
389
390
401 async rollback(): Promise<void> {
402 if (this.created.length === 0) return;
403 const reversed = [...this.created].reverse();
404 for (const ref of reversed) {
405 try {
406 if (ref.kind === "relation" && ref.relationId && ref.sourceDatabaseId) {
407 await this.svc.databases.deleteRelation(ref.sourceDatabaseId, ref.relationId, this.ctx.userId, this.ctx.req);
408 } else if (ref.kind === "database" && ref.databaseId) {
409 try {
410 await this.svc.databases.remove(ref.databaseId, this.ctx.userId, this.ctx.req);
411 } catch {
412
413
414 if (ref.pageId) await this.svc.pages.trash(ref.pageId, this.ctx.userId, this.ctx.req);
415 }
416 } else if (ref.kind === "page" && ref.pageId) {
417 await this.svc.pages.trash(ref.pageId, this.ctx.userId, this.ctx.req);
418 }
419 } catch (err) {
420 logger.warn(`generation rollback failed for ${ref.kind}:${ref.pageId ?? ref.databaseId ?? ref.relationId}: ${(err as Error).message}`);
421 }
422 }
423
424
425 this.created.length = 0;
426 this.refMap.clear();
427 this.rootPageId = null;
428 this.rootBlockIds.length = 0;
429 this.blocksCreated = 0;
430 }
431}
432