Files
stack/scripts/migrate-brain.ts
Jason Woltje e576fefd22
Some checks failed
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline failed
feat(scripts): add jarvis-brain data migration script
- Full typed migration utility at scripts/migrate-brain.ts
- CLI flags: --brain-path, --workspace-id, --user-id, --apply
- Status/priority/domain mapping for brain -> Mosaic enums
- Dry-run validation report (counts, mapping issues, missing refs)
- Apply mode with Prisma inserts, idempotency via metadata.brainId
- Validates: 95 tasks, 106 projects, 13 domains, 0 parse issues

Refs: MS21-MIG-001
2026-02-28 11:46:39 -06:00

1208 lines
33 KiB
TypeScript

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 <workspace-uuid> --user-id <user-uuid> [--brain-path <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<string, unknown> {
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<string, unknown>,
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<string[]> {
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<unknown> {
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<string, unknown>): 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<string>();
const taskSeen = new Set<string>();
const domainSources = new Map<string, Set<string>>();
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<void> {
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<ApplySummary> {
await ensureWorkspaceAndUserExist(prisma, options.workspaceId, options.userId);
return prisma.$transaction(async (tx) => {
const domainIdBySlug = new Map<string, string>();
const projectIdByBrainId = new Map<string, string>();
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<LoadedTaskFile[]> {
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<LoadedProjectFile[]> {
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<void> {
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;
});