- 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
1208 lines
33 KiB
TypeScript
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;
|
|
});
|