/** * TASK-0126 — the page-generation EXECUTOR. * * Runs a validated {@link GeneratePlan} SEQUENTIALLY through the existing * domain services (Page / Block / Database / Relation) with the REQUESTING * USER's identity — every call re-checks permissions server-side (AGENTS #7 / * #19: the frontend is never trusted, the executor never elevates). It keeps * a `ref → real id` map (the model's plan is ref-based, see * `packages/ai/src/schemas/generate.ts`) and a `createdRefs` ROLLBACK SET * (every resource this run created). A failing step stops execution, rolls * back the partial tree (only what THIS run created — user-owned content is * preserved), and re-throws with `failedStep`. * * **Permission-context hook (the spec's Security unit surface):** before every * domain call the executor passes its own requester context and asserts it is * the REQUESTER's (not elevated). A mocked service call with an elevated * context fails this assertion (see `assertPermissionContext`). * * **Cancellation + timeout:** the executor runs under an `AbortSignal` (the * SSE client-abort and the total-timeout watchdog both set it). Each step * checks the signal first; on abort it rolls back and throws `GenerationAborted`. */ import type { PrismaClient } from "@prisma/client"; import type { Request } from "express"; import { ApiError } from "../../../common/errors.js"; import { PageService } from "../../pages/page.service.js"; import { BlockService } from "../../blocks/block.service.js"; import { DatabaseService } from "../../databases/database.service.js"; import { DatabaseQueryService } from "../../databases/query.service.js"; import { logger } from "../../../common/logger.js"; import { BLOCK_TYPES, type BlockType } from "../../../constants.js"; import { validateCreateDatabase } from "../db-prompts.js"; import type { CreatePageCall, CreateDatabaseCall, CreateRelationCall, GenerateBlockPayload, GeneratePlan, InsertBlocksCall, } from "@factory/ai"; /** The four tool names the executor knows (a plan step's `name`). */ export type StepName = "createPage" | "insertBlocks" | "createDatabase" | "createRelation"; /** One resource the run created (the rollback set element). */ export interface CreatedRef { kind: "page" | "database" | "relation"; /** The page id (for a page / a database's host page). */ pageId?: string; /** The database id (kind === "database"). */ databaseId?: string; /** The relation id (kind === "relation"). */ relationId?: string; /** The source database the relation lives on (for deleteRelation). */ sourceDatabaseId?: string; } /** What the executor reports on success (the `done` frame's `created`). */ export interface GeneratedResult { pageId: string; created: { pages: string[]; blocks: number; databases: string[]; relations: number }; } /** Thrown when the abort signal fired (client cancel / timeout watchdog). */ export class GenerationAborted extends Error { constructor(readonly reason: "cancelled" | "timeout") { super(`generation ${reason}`); this.name = "GenerationAborted"; } } /** Thrown when a step fails (after the executor has rolled back). */ export class GenerationStepFailed extends Error { constructor(readonly failedStep: StepName, readonly error: unknown) { super(`generation failed at step ${failedStep}: ${String((error as Error)?.message ?? error)}`); this.name = "GenerationStepFailed"; } } /** The requester context the executor passes to every domain call. */ export interface ExecutorContext { workspaceId: string; userId: string; /** The raw request (carries `req.auth` for the domain services' audit + * permission re-check). Always the REQUESTER's — never elevated. */ req: Request; /** An optional assertion hook: called with the context before each domain * call; throws if the context is not the requester's (the unit seam for * "an elevated context fails the assertion hook"). */ assertContext?: (ctx: { workspaceId: string; userId: string; req: unknown }) => void; /** The caller's abort signal (client cancel + the total-timeout watchdog). */ signal: AbortSignal; /** Emit a progress frame (the service forwards it to the SSE stream). */ onStep: (name: StepName, status: "started" | "done", ref?: string) => void; /** The expected (requester) userId the permission hook checks against. */ expectedUserId?: string; } export interface ExecutorServices { pages: PageService; blocks: BlockService; databases: DatabaseService; records: DatabaseQueryService; /** The Prisma client (the property-name → id lookup for sample records). */ prisma: PrismaClient; } /** A plan ref's resolved resources (built as the plan runs). */ interface RefState { pageId?: string; databaseId?: string; blockIds: string[]; } export class GenerationExecutor { private readonly ctx: ExecutorContext; /** The model's ref → resolved real ids (the ref-resolution map). */ private readonly refMap = new Map(); /** The rollback set (created in run order). */ private readonly created: CreatedRef[] = []; /** The first page the plan created (the `done.pageId`). */ private rootPageId: string | null = null; /** Blocks appended to the root page via an omitted-`pageRef` `insertBlocks` * (the root may carry a NAMED ref, so they can't land in the `__root__` * RefState; tracked here so the "every created block is tracked" invariant * holds even when the first page is named). */ private readonly rootBlockIds: string[] = []; private blocksCreated = 0; private cancelled = false; constructor(private readonly svc: ExecutorServices, ctx: ExecutorContext) { this.ctx = ctx; } /** The root (first) page id, once known (the `done.pageId`). */ get rootPage(): string | null { return this.rootPageId; } /** Run the plan in order; returns the generated tree on success. */ async execute(plan: GeneratePlan, parentId?: string | null): Promise { for (const call of plan.tools) { const name: StepName = call.name; this.ctx.onStep(name, "started"); let ref: string | undefined; try { this.checkAborted(); switch (call.name) { case "createPage": ref = await this.runCreatePage(call.args, parentId); break; case "insertBlocks": ref = await this.runInsertBlocks(call.args); break; case "createDatabase": ref = await this.runCreateDatabase(call.args); break; case "createRelation": ref = await this.runCreateRelation(call.args); break; } this.ctx.onStep(name, "done", ref); } catch (err) { // ANY failure (a step error OR an abort) rolls back the partial tree; // an abort re-throws as GenerationAborted (the service emits // `cancelled`/timeout), a step error as GenerationStepFailed. await this.rollback(); if (err instanceof GenerationAborted) throw err; throw new GenerationStepFailed(name, err); } } const pageId = this.rootPageId; if (!pageId) throw new GenerationStepFailed("createPage", new Error("the plan created no page")); return { pageId, created: { pages: this.created.filter((c) => c.kind === "page").map((c) => c.pageId!).filter(Boolean), blocks: this.blocksCreated, databases: this.created.filter((c) => c.kind === "database").map((c) => c.databaseId!).filter(Boolean), relations: this.created.filter((c) => c.kind === "relation").length, }, }; } /** Abort check (client cancel / the total-timeout watchdog). */ private checkAborted(): void { if (this.ctx.signal.aborted) { this.cancelled = true; throw new GenerationAborted(this.ctx.signal.reason === "timeout" ? "timeout" : "cancelled"); } } /** Every domain call goes through here: the permission-context hook fires * first (an elevated context throws), then the signal is re-checked. */ private assertPermissionContext(): void { const ctx = { workspaceId: this.ctx.workspaceId, userId: this.ctx.userId, req: this.ctx.req }; // The executor's context is ALWAYS the requester's. If a test seam (or a // bug) swaps in an elevated userId, the hook catches it — the spec's // "a mocked service call with an elevated context fails the assertion hook". if (this.ctx.expectedUserId != null && this.ctx.userId !== this.ctx.expectedUserId) { throw ApiError.forbidden("Generation may not run with an elevated context.", [ { reason: "elevated_context" }, ]); } this.ctx.assertContext?.(ctx); this.checkAborted(); } private resolveRef(ref: string | undefined, what: string): RefState { const key = ref ?? "__root__"; const st = this.refMap.get(key); if (!st) { throw ApiError.unprocessable( `Plan references ${what} "${ref ?? "(root)"}" before it is created.`, [{ field: "ref", issue: `unknown_ref:${ref ?? "root"}` }], ); } return st; } /** `createPage` — the first one is the root; later ones nest under the root. * An optional inline `blocks` list is inserted under the page (the same * sequential path as `insertBlocks`). */ private async runCreatePage(args: CreatePageCall, endpointParentId?: string | null): Promise { this.assertPermissionContext(); // The endpoint's `parentId` (a real id) is injected into the FIRST page // only; later createPage calls nest under the root (the plan's own tree). const isRoot = this.rootPageId == null; const parentId = isRoot ? endpointParentId ?? null : this.rootPageId; const refKey = args.ref ?? "__root__"; const created = await this.svc.pages.create( this.ctx.workspaceId, this.ctx.userId, { title: args.title, ...(parentId ? { parentId } : {}), type: "page" }, this.ctx.req, ); const pageId = (created as { id: string }).id; if (isRoot) this.rootPageId = pageId; const state: RefState = { pageId, blockIds: [] }; this.refMap.set(refKey, state); this.created.push({ kind: "page", pageId }); // Inline blocks (the plan may carry the page's content on the create step). if (args.blocks && args.blocks.length > 0) { const ids = await this.insertBlockList(pageId, args.blocks); state.blockIds.push(...ids); } return pageId; } /** Shared block-insert path (createPage inline blocks + insertBlocks): each * block is created through BlockService (EDIT re-checked per call), chained * after the previous (the first lands at the page's natural end). */ private async insertBlockList(pageId: string, blocks: GenerateBlockPayload[]): Promise { let afterBlockId: string | undefined; const ids: string[] = []; for (const b of blocks) { this.assertPermissionContext(); const type = (b.type as string).toUpperCase(); if (!(BLOCK_TYPES as readonly string[]).includes(type)) { throw ApiError.unprocessable(`Plan step insertBlocks uses an unknown block type "${b.type}".`, [ { field: "type", issue: `unknown_block_type:${b.type}` }, ]); } const created = await this.svc.blocks.create( pageId, this.ctx.userId, { type: type as BlockType, content: b.content, properties: {}, ...(afterBlockId ? { afterBlockId } : {}) }, this.ctx.req, ); const blockId = (created as { id: string }).id; ids.push(blockId); afterBlockId = blockId; this.blocksCreated += 1; } return ids; } /** `insertBlocks` — appends the blocks under the host page. When `pageRef` * is omitted the host is the FIRST page the plan created (the schema * contract, `InsertBlocksCallSchema`). That page may carry a NAMED ref, so * an omitted pageRef must not go through the ref map — it resolves to the * root page id directly (recorded decision: a model that named its first * page `p` and then wrote an omitted pageRef previously failed with * "references page (root) before it is created"). */ private async runInsertBlocks(args: InsertBlocksCall): Promise { if (args.pageRef === undefined) { const pageId = this.rootPageId; if (!pageId) throw ApiError.unprocessable("Plan step insertBlocks has no host page (it runs before any createPage).", [{ field: "pageRef", issue: "no_host_page" }]); const ids = await this.insertBlockList(pageId, args.blocks); this.rootBlockIds.push(...ids); return pageId; } const host = this.resolveRef(args.pageRef, "page"); if (!host.pageId) throw ApiError.unprocessable(`Plan step insertBlocks references "${args.pageRef}" which is not a page.`, [{ field: "pageRef", issue: "not_a_page" }]); const pageId = host.pageId; const ids = await this.insertBlockList(pageId, args.blocks); host.blockIds.push(...ids); return pageId; } /** `createDatabase` — a child page (type:"database") under the host page, * then its properties + sample records (the domain services). The plan's * args are normalized by TASK-0124's `validateCreateDatabase` (the allowlist * + the seeded Title/Status re-declaration check + option-id generation + * sample-record value coercion) BEFORE any write (AGENTS #4 reuse). */ private async runCreateDatabase(args: CreateDatabaseCall): Promise { this.assertPermissionContext(); // Normalize + validate (reuses the proven TASK-0124 path; a re-declared // Title/Status or a non-generatable type is rejected pre-write). const parsed = validateCreateDatabase(args as unknown as Record); if (!parsed.ok) { // A plain Error — `execute`'s catch wraps it into GenerationStepFailed // ("createDatabase") + rolls back the partial tree. throw new Error(parsed.feedback); } const normalized = parsed.value; const host = args.pageRef ? this.resolveRef(args.pageRef, "page") : { pageId: this.rootPageId ?? undefined, blockIds: [] }; const hostPageId = host.pageId ?? this.rootPageId; if (!hostPageId) throw ApiError.unprocessable("Plan step createDatabase has no host page (declare pageRef).", [{ field: "pageRef", issue: "no_host_page" }]); const refKey = args.ref ?? "db"; const created = await this.svc.pages.create( this.ctx.workspaceId, this.ctx.userId, { title: normalized.title, parentId: hostPageId, type: "database" }, this.ctx.req, ); const page = created as { id: string; databaseId?: string }; const databaseId = page.databaseId!; if (!databaseId) throw ApiError.unprocessable("The database page was created without a database id.", [{ field: "databaseId", issue: "missing" }]); this.refMap.set(refKey, { pageId: page.id, databaseId, blockIds: [] }); this.created.push({ kind: "database", pageId: page.id, databaseId }); // Properties (write-ready: type → enum, options with ids). for (const p of normalized.properties) { this.assertPermissionContext(); await this.svc.databases.createProperty( databaseId, this.ctx.userId, { name: p.name, type: p.type, ...(p.options ? { config: { options: p.options } } : { config: {} }), ...(p.required !== undefined ? { required: p.required } : {}), }, this.ctx.req, ); } // Sample records (property NAME → id; the seeded Title comes from the // record's Title key — already coerced by validateCreateDatabase). if (normalized.sampleRecords.length > 0) { const allProps = await this.svc.prisma.databaseProperty.findMany({ where: { databaseId, deletedAt: null }, select: { id: true, name: true }, }); const idByName = new Map(allProps.map((ap) => [ap.name.toLowerCase(), ap.id])); for (const rec of normalized.sampleRecords) { this.assertPermissionContext(); const title = typeof rec.Title === "string" ? rec.Title : undefined; const properties: Record = {}; for (const [name, value] of Object.entries(rec)) { if (name === "Title") continue; const pid = idByName.get(name.toLowerCase()); if (pid) properties[pid] = value; } await this.svc.records.createRecord(databaseId, this.ctx.userId, { title, properties, links: {} }, this.ctx.req); } } return databaseId; } /** `createRelation` — links the plan's own database to a target database * the plan also generated (both refs resolve to real database ids). */ private async runCreateRelation(args: CreateRelationCall): Promise { this.assertPermissionContext(); const src = this.resolveRef(args.databaseRef, "database"); const tgt = this.resolveRef(args.targetPageRef, "database"); if (!src.databaseId) throw ApiError.unprocessable(`Plan step createRelation references "${args.databaseRef}" which is not a database.`, [{ field: "databaseRef", issue: "not_a_database" }]); if (!tgt.databaseId) throw ApiError.unprocessable(`Plan step createRelation references "${args.targetPageRef}" which is not a database.`, [{ field: "targetPageRef", issue: "not_a_database" }]); const created = await this.svc.databases.createRelation( src.databaseId, this.ctx.userId, { name: args.name, targetDatabaseId: tgt.databaseId, twoWay: true, cardinality: "MANY_TO_MANY" }, this.ctx.req, ); const relationId = (created as { id: string }).id; this.created.push({ kind: "relation", relationId, sourceDatabaseId: src.databaseId }); return relationId; } /** * Roll back the partial tree (only what THIS run created, in reverse * creation order). Uses the domain delete paths: * - relations → `databases.deleteRelation` (EDIT-level); * - databases → `databases.remove` (hard, cascades its page + records + * blocks; FULL_ACCESS — falls back to `pages.trash` for an * EDIT-only user so the tree is at least hidden); * - pages → `pages.trash` (EDIT-level, cascades descendant pages). * Best-effort: a single rollback failure is logged, not thrown (the primary * failure is already in flight). User-owned content is NEVER touched. */ async rollback(): Promise { if (this.created.length === 0) return; const reversed = [...this.created].reverse(); for (const ref of reversed) { try { if (ref.kind === "relation" && ref.relationId && ref.sourceDatabaseId) { await this.svc.databases.deleteRelation(ref.sourceDatabaseId, ref.relationId, this.ctx.userId, this.ctx.req); } else if (ref.kind === "database" && ref.databaseId) { try { await this.svc.databases.remove(ref.databaseId, this.ctx.userId, this.ctx.req); } catch { // EDIT-only (no FULL_ACCESS) → soft-trash the database page so the // partial tree is hidden (the normal user-deletion path). if (ref.pageId) await this.svc.pages.trash(ref.pageId, this.ctx.userId, this.ctx.req); } } else if (ref.kind === "page" && ref.pageId) { await this.svc.pages.trash(ref.pageId, this.ctx.userId, this.ctx.req); } } catch (err) { logger.warn(`generation rollback failed for ${ref.kind}:${ref.pageId ?? ref.databaseId ?? ref.relationId}: ${(err as Error).message}`); } } // Reset (a retried run starts clean; the idempotency key keeps it from // double-creating the SAME logical generation). this.created.length = 0; this.refMap.clear(); this.rootPageId = null; this.rootBlockIds.length = 0; this.blocksCreated = 0; } }