import { readdir, readFile } from "node:fs/promises"; import { homedir } from "node:os"; import * as path from "node:path"; import * as process from "node:process"; import { ActivityAction, EntityType, Prisma, PrismaClient, ProjectStatus, TaskPriority, TaskStatus, } from "../apps/api/node_modules/@prisma/client"; const DEFAULT_BRAIN_PATH = "~/src/jarvis-brain"; const TASKS_SUBDIR = path.join("data", "tasks"); const PROJECTS_SUBDIR = path.join("data", "projects"); const PREVIEW_LIMIT = 3; const LOG_LIMIT = 20; interface CliOptions { brainPath: string; workspaceId: string; userId: string; apply: boolean; } interface BrainTaskRecord { id: string; title: string; domain: string | null; project: string | null; related: string[]; priority: string | null; status: string | null; progress: number | null; due: string | null; blocks: string[]; blocked_by: string[]; assignee: string | null; created: string | null; updated: string | null; notes: string | null; notes_nontechnical: string | null; } interface BrainTaskFile { version: string; domain: string; tasks: BrainTaskRecord[]; } type BrainProjectPriority = number | string | null; interface BrainProjectRecord { id: string; name: string; description: string | null; domain: string | null; status: string | null; priority: BrainProjectPriority; progress: number | null; repo: string | null; branch: string | null; current_milestone: string | null; next_milestone: string | null; blocker: string | null; owner: string | null; docs_path: string | null; created: string | null; updated: string | null; target_date: string | null; notes: string | null; notes_nontechnical: string | null; parent: string | null; } interface BrainProjectFile { version: string; project: BrainProjectRecord; tasks: BrainTaskRecord[]; } interface LoadedTaskFile { version: string; domain: string; sourceFile: string; tasks: BrainTaskRecord[]; } interface LoadedProjectFile { version: string; sourceFile: string; project: BrainProjectRecord; } interface PreparedDomain { slug: string; name: string; metadata: Prisma.InputJsonValue; } interface PreparedProject { brainId: string; name: string; description: string | null; status: ProjectStatus; domainSlug: string | null; startDate: Date | null; endDate: Date | null; createdAt: Date; updatedAt: Date; metadata: Prisma.InputJsonValue; } interface PreparedTask { brainId: string; title: string; description: string | null; status: TaskStatus; priority: TaskPriority; dueDate: Date | null; projectBrainId: string | null; domainSlug: string | null; createdAt: Date; updatedAt: Date; completedAt: Date | null; metadata: Prisma.InputJsonValue; } interface MigrationPlan { domains: PreparedDomain[]; projects: PreparedProject[]; tasks: PreparedTask[]; } interface ValidationReport { taskFilesRead: number; projectFilesRead: number; tasksParsed: number; projectsParsed: number; uniqueDomainCount: number; duplicateTaskIds: string[]; duplicateProjectIds: string[]; mappingIssues: string[]; dateIssues: string[]; parseIssues: string[]; missingProjectRefs: string[]; } interface ApplySummary { domainsCreated: number; domainsSkipped: number; projectsCreated: number; projectsSkipped: number; tasksCreated: number; tasksSkipped: number; tasksWithMissingProject: number; activityLogsCreated: number; } function printUsage(): void { console.log( [ "Usage:", " pnpm migrate-brain --workspace-id --user-id [--brain-path ] [--apply]", "", "Flags:", " --brain-path Path to jarvis-brain root (default: ~/src/jarvis-brain)", " --workspace-id Target workspace UUID (required)", " --user-id Creator/user UUID for imported records (required)", " --apply Execute writes (default is dry-run)", ].join("\n") ); } function expandHomePath(inputPath: string): string { if (inputPath === "~") { return homedir(); } if (inputPath.startsWith("~/")) { return path.join(homedir(), inputPath.slice(2)); } return inputPath; } function parseCliArgs(args: string[]): CliOptions { let brainPath = DEFAULT_BRAIN_PATH; let workspaceId: string | null = null; let userId: string | null = null; let apply = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--apply") { apply = true; continue; } if (arg === "--help" || arg === "-h") { printUsage(); process.exit(0); } if (arg.startsWith("--brain-path=")) { brainPath = arg.slice("--brain-path=".length); continue; } if (arg === "--brain-path") { const value = args[index + 1]; if (!value) { throw new Error("Missing value for --brain-path"); } brainPath = value; index += 1; continue; } if (arg.startsWith("--workspace-id=")) { workspaceId = arg.slice("--workspace-id=".length); continue; } if (arg === "--workspace-id") { const value = args[index + 1]; if (!value) { throw new Error("Missing value for --workspace-id"); } workspaceId = value; index += 1; continue; } if (arg.startsWith("--user-id=")) { userId = arg.slice("--user-id=".length); continue; } if (arg === "--user-id") { const value = args[index + 1]; if (!value) { throw new Error("Missing value for --user-id"); } userId = value; index += 1; continue; } throw new Error(`Unknown flag: ${arg}`); } if (!workspaceId || !userId) { throw new Error("Both --workspace-id and --user-id are required"); } return { brainPath: path.resolve(expandHomePath(brainPath)), workspaceId, userId, apply, }; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function asString(value: unknown): string | null { return typeof value === "string" ? value : null; } function asNullableString(value: unknown): string | null { if (value === null || value === undefined) { return null; } return typeof value === "string" ? value : null; } function asNullableNumber(value: unknown): number | null { if (value === null || value === undefined) { return null; } return typeof value === "number" && Number.isFinite(value) ? value : null; } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; } const parsed: string[] = []; for (const item of value) { if (typeof item === "string") { parsed.push(item); } } return parsed; } function asStringOrNumber(value: unknown): BrainProjectPriority { if (value === null || value === undefined) { return null; } if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string") { return value; } return null; } function ensureRequiredString( record: Record, fieldName: string, context: string, parseIssues: string[] ): string | null { const value = asString(record[fieldName]); if (!value || value.trim().length === 0) { parseIssues.push(`${context}: required field "${fieldName}" missing or invalid`); return null; } return value; } function parseTaskRecord( rawRecord: unknown, context: string, parseIssues: string[] ): BrainTaskRecord | null { if (!isRecord(rawRecord)) { parseIssues.push(`${context}: task entry is not an object`); return null; } const id = ensureRequiredString(rawRecord, "id", context, parseIssues); const title = ensureRequiredString(rawRecord, "title", context, parseIssues); if (!id || !title) { return null; } return { id, title, domain: asNullableString(rawRecord.domain), project: asNullableString(rawRecord.project), related: asStringArray(rawRecord.related), priority: asNullableString(rawRecord.priority), status: asNullableString(rawRecord.status), progress: asNullableNumber(rawRecord.progress), due: asNullableString(rawRecord.due), blocks: asStringArray(rawRecord.blocks), blocked_by: asStringArray(rawRecord.blocked_by), assignee: asNullableString(rawRecord.assignee), created: asNullableString(rawRecord.created), updated: asNullableString(rawRecord.updated), notes: asNullableString(rawRecord.notes), notes_nontechnical: asNullableString(rawRecord.notes_nontechnical), }; } function parseTaskFile( rawFile: unknown, sourceFile: string, parseIssues: string[] ): LoadedTaskFile | null { if (!isRecord(rawFile)) { parseIssues.push(`${sourceFile}: file payload is not an object`); return null; } const version = ensureRequiredString(rawFile, "version", sourceFile, parseIssues); const domain = ensureRequiredString(rawFile, "domain", sourceFile, parseIssues); const rawTasks = rawFile.tasks; if (!version || !domain) { return null; } if (!Array.isArray(rawTasks)) { parseIssues.push(`${sourceFile}: "tasks" is missing or not an array`); return null; } const tasks: BrainTaskRecord[] = []; for (let index = 0; index < rawTasks.length; index += 1) { const parsed = parseTaskRecord(rawTasks[index], `${sourceFile} task[${index}]`, parseIssues); if (parsed) { tasks.push(parsed); } } return { version, domain, sourceFile, tasks, }; } function parseProjectRecord( rawRecord: unknown, context: string, parseIssues: string[] ): BrainProjectRecord | null { if (!isRecord(rawRecord)) { parseIssues.push(`${context}: "project" is not an object`); return null; } const id = ensureRequiredString(rawRecord, "id", context, parseIssues); const name = ensureRequiredString(rawRecord, "name", context, parseIssues); if (!id || !name) { return null; } return { id, name, description: asNullableString(rawRecord.description), domain: asNullableString(rawRecord.domain), status: asNullableString(rawRecord.status), priority: asStringOrNumber(rawRecord.priority), progress: asNullableNumber(rawRecord.progress), repo: asNullableString(rawRecord.repo), branch: asNullableString(rawRecord.branch), current_milestone: asNullableString(rawRecord.current_milestone), next_milestone: asNullableString(rawRecord.next_milestone), blocker: asNullableString(rawRecord.blocker), owner: asNullableString(rawRecord.owner), docs_path: asNullableString(rawRecord.docs_path), created: asNullableString(rawRecord.created), updated: asNullableString(rawRecord.updated), target_date: asNullableString(rawRecord.target_date), notes: asNullableString(rawRecord.notes), notes_nontechnical: asNullableString(rawRecord.notes_nontechnical), parent: asNullableString(rawRecord.parent), }; } function parseProjectFile( rawFile: unknown, sourceFile: string, parseIssues: string[] ): LoadedProjectFile | null { if (!isRecord(rawFile)) { parseIssues.push(`${sourceFile}: file payload is not an object`); return null; } const version = ensureRequiredString(rawFile, "version", sourceFile, parseIssues); if (!version) { return null; } const project = parseProjectRecord(rawFile.project, `${sourceFile} project`, parseIssues); if (!project) { return null; } return { version, sourceFile, project, }; } async function listJsonFiles(directoryPath: string): Promise { const entries = await readdir(directoryPath, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) .map((entry) => path.join(directoryPath, entry.name)) .sort((left, right) => left.localeCompare(right)); } async function readJsonFile(filePath: string): Promise { const fileContents = await readFile(filePath, "utf8"); return JSON.parse(fileContents) as unknown; } function normalizeKey(value: string | null | undefined): string { return value?.trim().toLowerCase() ?? ""; } function mapTaskStatus(rawStatus: string | null): { status: TaskStatus; issue: string | null } { const statusKey = normalizeKey(rawStatus); switch (statusKey) { case "done": return { status: TaskStatus.COMPLETED, issue: null }; case "in-progress": return { status: TaskStatus.IN_PROGRESS, issue: null }; case "backlog": case "pending": case "scheduled": case "not-started": case "planned": return { status: TaskStatus.NOT_STARTED, issue: null }; case "blocked": case "on-hold": return { status: TaskStatus.PAUSED, issue: null }; case "cancelled": return { status: TaskStatus.ARCHIVED, issue: null }; default: return { status: TaskStatus.NOT_STARTED, issue: `Unknown task status "${rawStatus ?? "null"}" mapped to NOT_STARTED`, }; } } function mapTaskPriority(rawPriority: string | null): { priority: TaskPriority; issue: string | null; } { const priorityKey = normalizeKey(rawPriority); switch (priorityKey) { case "critical": case "high": return { priority: TaskPriority.HIGH, issue: null }; case "medium": return { priority: TaskPriority.MEDIUM, issue: null }; case "low": return { priority: TaskPriority.LOW, issue: null }; default: return { priority: TaskPriority.MEDIUM, issue: `Unknown task priority "${rawPriority ?? "null"}" mapped to MEDIUM`, }; } } function mapProjectStatus(rawStatus: string | null): { status: ProjectStatus; issue: string | null; } { const statusKey = normalizeKey(rawStatus); switch (statusKey) { case "active": case "in-progress": return { status: ProjectStatus.ACTIVE, issue: null }; case "backlog": case "planning": return { status: ProjectStatus.PLANNING, issue: null }; case "paused": case "blocked": return { status: ProjectStatus.PAUSED, issue: null }; case "archived": case "maintenance": return { status: ProjectStatus.ARCHIVED, issue: null }; default: return { status: ProjectStatus.PLANNING, issue: `Unknown project status "${rawStatus ?? "null"}" mapped to PLANNING`, }; } } function normalizeDate( rawValue: string | null, context: string, dateIssues: string[] ): Date | null { if (!rawValue) { return null; } const trimmed = rawValue.trim(); if (trimmed.length === 0) { return null; } const value = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T00:00:00.000Z` : trimmed; const parsedDate = new Date(value); if (Number.isNaN(parsedDate.getTime())) { dateIssues.push(`${context}: invalid date "${rawValue}"`); return null; } return parsedDate; } function asJsonValue(value: Record): Prisma.InputJsonValue { return value as Prisma.InputJsonValue; } function normalizeDomain(rawDomain: string | null | undefined): string | null { if (!rawDomain) { return null; } const trimmed = rawDomain.trim(); if (trimmed.length === 0) { return null; } const slug = trimmed .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return slug.length > 0 ? slug : null; } function truncate(items: string[], limit = LOG_LIMIT): string[] { return items.length <= limit ? items : items.slice(0, limit); } function buildMigrationPlan( taskFiles: LoadedTaskFile[], projectFiles: LoadedProjectFile[], parseIssues: string[], importTimestamp: string ): { plan: MigrationPlan; report: ValidationReport } { const report: ValidationReport = { taskFilesRead: taskFiles.length, projectFilesRead: projectFiles.length, tasksParsed: taskFiles.reduce((accumulator, file) => accumulator + file.tasks.length, 0), projectsParsed: projectFiles.length, uniqueDomainCount: 0, duplicateTaskIds: [], duplicateProjectIds: [], mappingIssues: [], dateIssues: [], parseIssues, missingProjectRefs: [], }; const projectSeen = new Set(); const taskSeen = new Set(); const domainSources = new Map>(); const registerDomain = (rawDomain: string | null | undefined): string | null => { const slug = normalizeDomain(rawDomain); if (!slug) { return null; } const canonical = rawDomain?.trim() ?? slug; const existing = domainSources.get(slug); if (existing) { existing.add(canonical); } else { domainSources.set(slug, new Set([canonical])); } return slug; }; const projects: PreparedProject[] = []; for (const file of projectFiles) { const brainProject = file.project; if (projectSeen.has(brainProject.id)) { report.duplicateProjectIds.push( `${brainProject.id} (duplicate in ${file.sourceFile}; keeping first instance)` ); continue; } projectSeen.add(brainProject.id); const mappedStatus = mapProjectStatus(brainProject.status); if (mappedStatus.issue) { report.mappingIssues.push(`project ${brainProject.id}: ${mappedStatus.issue}`); } const domainSlug = registerDomain(brainProject.domain); const createdAt = normalizeDate( brainProject.created, `project ${brainProject.id}.created`, report.dateIssues ) ?? new Date(); const updatedAt = normalizeDate( brainProject.updated, `project ${brainProject.id}.updated`, report.dateIssues ) ?? createdAt; const startDate = normalizeDate( brainProject.created, `project ${brainProject.id}.startDate`, report.dateIssues ) ?? null; const endDate = normalizeDate( brainProject.target_date, `project ${brainProject.id}.target_date`, report.dateIssues ); const metadata = asJsonValue({ source: "jarvis-brain", brainId: brainProject.id, brainDomain: brainProject.domain, rawStatus: brainProject.status, rawPriority: brainProject.priority, progress: brainProject.progress, repo: brainProject.repo, branch: brainProject.branch, currentMilestone: brainProject.current_milestone, nextMilestone: brainProject.next_milestone, blocker: brainProject.blocker, owner: brainProject.owner, docsPath: brainProject.docs_path, targetDate: brainProject.target_date, notes: brainProject.notes, notesNonTechnical: brainProject.notes_nontechnical, parent: brainProject.parent, sourceFile: file.sourceFile, sourceVersion: file.version, importedAt: importTimestamp, }); projects.push({ brainId: brainProject.id, name: brainProject.name, description: brainProject.description, status: mappedStatus.status, domainSlug, startDate, endDate, createdAt, updatedAt, metadata, }); } const tasks: PreparedTask[] = []; for (const file of taskFiles) { registerDomain(file.domain); for (const brainTask of file.tasks) { if (taskSeen.has(brainTask.id)) { report.duplicateTaskIds.push( `${brainTask.id} (duplicate in ${file.sourceFile}; keeping first instance)` ); continue; } taskSeen.add(brainTask.id); const mappedStatus = mapTaskStatus(brainTask.status); if (mappedStatus.issue) { report.mappingIssues.push(`task ${brainTask.id}: ${mappedStatus.issue}`); } const mappedPriority = mapTaskPriority(brainTask.priority); if (mappedPriority.issue) { report.mappingIssues.push(`task ${brainTask.id}: ${mappedPriority.issue}`); } const domainSlug = registerDomain(brainTask.domain ?? file.domain); const projectBrainId = brainTask.project?.trim() ? brainTask.project.trim() : null; if (projectBrainId && !projectSeen.has(projectBrainId)) { report.missingProjectRefs.push( `task ${brainTask.id}: referenced project "${projectBrainId}" not found in project files` ); } const createdAt = normalizeDate(brainTask.created, `task ${brainTask.id}.created`, report.dateIssues) ?? new Date(); const updatedAt = normalizeDate(brainTask.updated, `task ${brainTask.id}.updated`, report.dateIssues) ?? createdAt; const dueDate = normalizeDate(brainTask.due, `task ${brainTask.id}.due`, report.dateIssues); const completedAt = mappedStatus.status === TaskStatus.COMPLETED ? updatedAt : null; const metadata = asJsonValue({ source: "jarvis-brain", brainId: brainTask.id, brainDomain: brainTask.domain ?? file.domain, brainProjectId: projectBrainId, rawStatus: brainTask.status, rawPriority: brainTask.priority, related: brainTask.related, blocks: brainTask.blocks, blockedBy: brainTask.blocked_by, assignee: brainTask.assignee, progress: brainTask.progress, notes: brainTask.notes, notesNonTechnical: brainTask.notes_nontechnical, sourceFile: file.sourceFile, sourceVersion: file.version, importedAt: importTimestamp, }); tasks.push({ brainId: brainTask.id, title: brainTask.title, description: brainTask.notes, status: mappedStatus.status, priority: mappedPriority.priority, dueDate, projectBrainId, domainSlug, createdAt, updatedAt, completedAt, metadata, }); } } const domains: PreparedDomain[] = []; for (const [slug, rawValues] of Array.from(domainSources.entries())) { const sourceValues = [...rawValues].sort((left, right) => left.localeCompare(right)); const domainName = sourceValues[0] ?? slug; domains.push({ slug, name: domainName, metadata: asJsonValue({ source: "jarvis-brain", brainId: domainName, sourceValues, importedAt: importTimestamp, }), }); } domains.sort((left, right) => left.slug.localeCompare(right.slug)); report.uniqueDomainCount = domains.length; return { plan: { domains, projects, tasks, }, report, }; } function printValidationReport( options: CliOptions, plan: MigrationPlan, report: ValidationReport ): void { console.log(""); console.log("=== jarvis-brain migration validation ==="); console.log(`Mode: ${options.apply ? "APPLY" : "DRY-RUN"}`); console.log(`Brain path: ${options.brainPath}`); console.log(`Workspace ID: ${options.workspaceId}`); console.log(`User ID: ${options.userId}`); console.log(""); console.log("Counts:"); console.log(`- Task files read: ${report.taskFilesRead}`); console.log(`- Project files read: ${report.projectFilesRead}`); console.log(`- Parsed tasks: ${report.tasksParsed}`); console.log(`- Parsed projects: ${report.projectsParsed}`); console.log(`- Unique domains: ${report.uniqueDomainCount}`); console.log(""); console.log("Planned inserts:"); console.log(`- Domains: ${plan.domains.length}`); console.log(`- Projects: ${plan.projects.length}`); console.log(`- Tasks: ${plan.tasks.length}`); console.log(""); console.log("Validation issues:"); console.log(`- Parse issues: ${report.parseIssues.length}`); console.log(`- Date issues: ${report.dateIssues.length}`); console.log(`- Mapping issues: ${report.mappingIssues.length}`); console.log(`- Missing project references: ${report.missingProjectRefs.length}`); console.log(`- Duplicate project IDs: ${report.duplicateProjectIds.length}`); console.log(`- Duplicate task IDs: ${report.duplicateTaskIds.length}`); const printIssueSection = (label: string, values: string[]): void => { if (values.length === 0) { return; } console.log(""); console.log(`${label} (showing up to ${LOG_LIMIT}):`); for (const issue of truncate(values)) { console.log(`- ${issue}`); } }; printIssueSection("Parse issues", report.parseIssues); printIssueSection("Date issues", report.dateIssues); printIssueSection("Mapping issues", report.mappingIssues); printIssueSection("Missing project references", report.missingProjectRefs); printIssueSection("Duplicate project IDs", report.duplicateProjectIds); printIssueSection("Duplicate task IDs", report.duplicateTaskIds); console.log(""); console.log(`Insert payload preview (first ${PREVIEW_LIMIT} each):`); console.log( JSON.stringify( { domains: plan.domains.slice(0, PREVIEW_LIMIT), projects: plan.projects.slice(0, PREVIEW_LIMIT), tasks: plan.tasks.slice(0, PREVIEW_LIMIT), }, null, 2 ) ); } async function ensureWorkspaceAndUserExist( prisma: PrismaClient, workspaceId: string, userId: string ): Promise { const [workspace, user] = await Promise.all([ prisma.workspace.findUnique({ where: { id: workspaceId }, select: { id: true }, }), prisma.user.findUnique({ where: { id: userId }, select: { id: true }, }), ]); if (!workspace) { throw new Error(`Workspace not found for --workspace-id=${workspaceId}`); } if (!user) { throw new Error(`User not found for --user-id=${userId}`); } } function buildCreatedActivityDetails( source: "domain" | "project" | "task", brainId: string, importTimestamp: string ): Prisma.InputJsonValue { return asJsonValue({ source: "jarvis-brain", migrationEntity: source, brainId, importedAt: importTimestamp, }); } async function applyMigration( prisma: PrismaClient, options: CliOptions, plan: MigrationPlan, importTimestamp: string ): Promise { await ensureWorkspaceAndUserExist(prisma, options.workspaceId, options.userId); return prisma.$transaction(async (tx) => { const domainIdBySlug = new Map(); const projectIdByBrainId = new Map(); let domainsCreated = 0; let domainsSkipped = 0; let projectsCreated = 0; let projectsSkipped = 0; let tasksCreated = 0; let tasksSkipped = 0; let tasksWithMissingProject = 0; let activityLogsCreated = 0; for (const domain of plan.domains) { const existingDomain = await tx.domain.findUnique({ where: { workspaceId_slug: { workspaceId: options.workspaceId, slug: domain.slug, }, }, select: { id: true }, }); if (existingDomain) { domainIdBySlug.set(domain.slug, existingDomain.id); domainsSkipped += 1; continue; } const createdDomain = await tx.domain.create({ data: { workspaceId: options.workspaceId, name: domain.name, slug: domain.slug, metadata: domain.metadata, }, select: { id: true }, }); domainIdBySlug.set(domain.slug, createdDomain.id); domainsCreated += 1; await tx.activityLog.create({ data: { workspaceId: options.workspaceId, userId: options.userId, action: ActivityAction.CREATED, entityType: EntityType.DOMAIN, entityId: createdDomain.id, details: buildCreatedActivityDetails("domain", domain.name, importTimestamp), }, }); activityLogsCreated += 1; } for (const project of plan.projects) { const existingProject = await tx.project.findFirst({ where: { workspaceId: options.workspaceId, metadata: { path: ["brainId"], equals: project.brainId, }, }, select: { id: true }, }); if (existingProject) { projectIdByBrainId.set(project.brainId, existingProject.id); projectsSkipped += 1; continue; } const createdProject = await tx.project.create({ data: { workspaceId: options.workspaceId, name: project.name, description: project.description, status: project.status, startDate: project.startDate, endDate: project.endDate, creatorId: options.userId, domainId: project.domainSlug ? (domainIdBySlug.get(project.domainSlug) ?? null) : null, metadata: project.metadata, createdAt: project.createdAt, updatedAt: project.updatedAt, }, select: { id: true }, }); projectIdByBrainId.set(project.brainId, createdProject.id); projectsCreated += 1; await tx.activityLog.create({ data: { workspaceId: options.workspaceId, userId: options.userId, action: ActivityAction.CREATED, entityType: EntityType.PROJECT, entityId: createdProject.id, details: buildCreatedActivityDetails("project", project.brainId, importTimestamp), }, }); activityLogsCreated += 1; } for (const task of plan.tasks) { const existingTask = await tx.task.findFirst({ where: { workspaceId: options.workspaceId, metadata: { path: ["brainId"], equals: task.brainId, }, }, select: { id: true }, }); if (existingTask) { tasksSkipped += 1; continue; } const projectId = task.projectBrainId !== null ? (projectIdByBrainId.get(task.projectBrainId) ?? null) : null; if (task.projectBrainId && !projectId) { tasksWithMissingProject += 1; } const createdTask = await tx.task.create({ data: { workspaceId: options.workspaceId, title: task.title, description: task.description, status: task.status, priority: task.priority, dueDate: task.dueDate, creatorId: options.userId, projectId, domainId: task.domainSlug ? (domainIdBySlug.get(task.domainSlug) ?? null) : null, metadata: task.metadata, createdAt: task.createdAt, updatedAt: task.updatedAt, completedAt: task.completedAt, }, select: { id: true }, }); tasksCreated += 1; await tx.activityLog.create({ data: { workspaceId: options.workspaceId, userId: options.userId, action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: createdTask.id, details: buildCreatedActivityDetails("task", task.brainId, importTimestamp), }, }); activityLogsCreated += 1; } return { domainsCreated, domainsSkipped, projectsCreated, projectsSkipped, tasksCreated, tasksSkipped, tasksWithMissingProject, activityLogsCreated, }; }); } async function loadTaskFiles( taskDirectory: string, parseIssues: string[], rootPath: string ): Promise { const files = await listJsonFiles(taskDirectory); const loadedFiles: LoadedTaskFile[] = []; for (const filePath of files) { const payload = await readJsonFile(filePath); const sourceFile = path.relative(rootPath, filePath); const parsed = parseTaskFile(payload, sourceFile, parseIssues); if (parsed) { loadedFiles.push(parsed); } } return loadedFiles; } async function loadProjectFiles( projectDirectory: string, parseIssues: string[], rootPath: string ): Promise { const files = await listJsonFiles(projectDirectory); const loadedFiles: LoadedProjectFile[] = []; for (const filePath of files) { const payload = await readJsonFile(filePath); const sourceFile = path.relative(rootPath, filePath); const parsed = parseProjectFile(payload, sourceFile, parseIssues); if (parsed) { loadedFiles.push(parsed); } } return loadedFiles; } async function main(): Promise { const options = parseCliArgs(process.argv.slice(2)); const importTimestamp = new Date().toISOString(); const tasksPath = path.join(options.brainPath, TASKS_SUBDIR); const projectsPath = path.join(options.brainPath, PROJECTS_SUBDIR); const parseIssues: string[] = []; const [taskFiles, projectFiles] = await Promise.all([ loadTaskFiles(tasksPath, parseIssues, options.brainPath), loadProjectFiles(projectsPath, parseIssues, options.brainPath), ]); const { plan, report } = buildMigrationPlan( taskFiles, projectFiles, parseIssues, importTimestamp ); printValidationReport(options, plan, report); if (!options.apply) { console.log(""); console.log("Dry-run complete. Re-run with --apply to write records."); return; } const prisma = new PrismaClient(); try { const summary = await applyMigration(prisma, options, plan, importTimestamp); console.log(""); console.log("Apply complete:"); console.log(`- Domains created/skipped: ${summary.domainsCreated}/${summary.domainsSkipped}`); console.log( `- Projects created/skipped: ${summary.projectsCreated}/${summary.projectsSkipped}` ); console.log(`- Tasks created/skipped: ${summary.tasksCreated}/${summary.tasksSkipped}`); console.log(`- Tasks with missing project links: ${summary.tasksWithMissingProject}`); console.log(`- Activity logs created: ${summary.activityLogsCreated}`); } finally { await prisma.$disconnect(); } } main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); console.error(`Migration failed: ${message}`); printUsage(); process.exitCode = 1; });