Files
TJWaterAgent/scripts/migrate-session-identity.ts
T
jiang 94529cb141
Agent CI/CD / docker-image (push) Failing after 35s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s
feat(api): expose REST-only agent routes
2026-07-30 20:38:52 +08:00

425 lines
12 KiB
TypeScript

import { createHash } from "node:crypto";
import {
copyFile,
mkdir,
readdir,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
type JsonRecord = Record<string, unknown>;
type Options = {
backupDir: string;
dataDir: string;
dryRun: boolean;
fromActorKey: string;
fromProjectId?: string;
fromProjectKey?: string;
fromUserId?: string;
metadataDir: string;
memoryDir: string;
resultRefsDir: string;
toActorKey: string;
toProjectId?: string;
toProjectKey?: string;
toUserId?: string;
transcriptsDir: string;
};
type Change = {
detail: string;
file: string;
kind: "metadata" | "transcript" | "result-ref" | "memory";
};
const usage = `Usage:
bun scripts/migrate-session-identity.ts --from-user-id <old> --to-user-id <new> [--write]
bun scripts/migrate-session-identity.ts --from-actor-key <old> --to-actor-key <new> [--write]
Optional:
--from-project-id <old> Match old project id
--to-project-id <new> Rewrite project id
--from-project-key <old> Match old project key
--to-project-key <new> Rewrite project key
--data-dir <dir> Default: ./data
--backup-dir <dir> Default: <data-dir>/backup/session-identity-migration/<timestamp>
--write Apply changes. Without this, dry-run only.
Examples:
bun scripts/migrate-session-identity.ts \\
--from-user-id d1acbc3d-cf62-4049-9939-95e4feb37296 \\
--to-actor-key actor-49a0275a85079f9d
bun scripts/migrate-session-identity.ts \\
--from-actor-key actor-da98d99c20c386e7 \\
--to-actor-key actor-49a0275a85079f9d \\
--write
`;
const main = async () => {
const options = parseOptions(process.argv.slice(2));
const changes: Change[] = [];
changes.push(...(await migrateMetadata(options)));
changes.push(...(await migrateTranscripts(options)));
changes.push(...(await migrateResultRefs(options)));
changes.push(...(await migrateUserMemory(options)));
printSummary(options, changes);
};
const parseOptions = (args: string[]): Options => {
const values = new Map<string, string | boolean>();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
throw new Error(`unexpected argument: ${arg}\n\n${usage}`);
}
const key = arg.slice(2);
if (key === "help" || key === "h") {
console.log(usage);
process.exit(0);
}
if (key === "write") {
values.set(key, true);
continue;
}
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`missing value for --${key}\n\n${usage}`);
}
values.set(key, value);
index += 1;
}
const dataDir = stringOption(values, "data-dir") ?? "./data";
const fromUserId = stringOption(values, "from-user-id");
const toUserId = stringOption(values, "to-user-id");
const fromActorKey = stringOption(values, "from-actor-key") ?? actorKeyFromUserId(fromUserId);
const toActorKey = stringOption(values, "to-actor-key") ?? actorKeyFromUserId(toUserId);
const fromProjectId = stringOption(values, "from-project-id");
const toProjectId = stringOption(values, "to-project-id");
const fromProjectKey =
stringOption(values, "from-project-key") ?? projectKeyFromProjectId(fromProjectId);
const toProjectKey =
stringOption(values, "to-project-key") ?? projectKeyFromProjectId(toProjectId);
if (!fromActorKey || !toActorKey) {
throw new Error(
"provide --from-user-id/--to-user-id or --from-actor-key/--to-actor-key\n\n" +
usage,
);
}
if (fromActorKey === toActorKey && fromProjectKey === toProjectKey) {
throw new Error("source and target identity are identical");
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupDir =
stringOption(values, "backup-dir") ??
join(dataDir, "backup", "session-identity-migration", timestamp);
return {
backupDir,
dataDir,
dryRun: !values.has("write"),
fromActorKey,
fromProjectId,
fromProjectKey,
fromUserId,
metadataDir: join(dataDir, "session-metadata"),
memoryDir: join(dataDir, "memory"),
resultRefsDir: join(dataDir, "result-refs"),
toActorKey,
toProjectId,
toProjectKey,
toUserId,
transcriptsDir: join(dataDir, "session-transcripts"),
};
};
const stringOption = (values: Map<string, string | boolean>, key: string) => {
const value = values.get(key);
return typeof value === "string" && value.trim() ? value.trim() : undefined;
};
const actorKeyFromUserId = (userId?: string) =>
userId ? scopedKey("actor", userId) : undefined;
const projectKeyFromProjectId = (projectId?: string) =>
projectId ? scopedKey("project", projectId) : undefined;
const scopedKey = (prefix: string, value: string) =>
`${prefix}-${createHash("sha256").update(value.trim()).digest("hex").slice(0, 16)}`;
const migrateMetadata = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.metadataDir)) {
const record = await readJson(file);
if (!record || record.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
continue;
}
if (options.fromProjectId && record.projectId !== options.fromProjectId) {
continue;
}
const next: JsonRecord = {
...record,
actorKey: options.toActorKey,
...(options.toUserId ? { ownerUserId: options.toUserId } : {}),
...(options.toProjectId ? { projectId: options.toProjectId } : {}),
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
await writeJsonWithBackup(options, file, next);
changes.push({
detail: `${record.actorKey} -> ${next.actorKey}`,
file,
kind: "metadata",
});
}
return changes;
};
const migrateTranscripts = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.transcriptsDir)) {
const transcript = await readJson(file);
if (!transcript || transcript.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && transcript.projectKey !== options.fromProjectKey) {
continue;
}
const next: JsonRecord = {
...transcript,
actorKey: options.toActorKey,
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
const nextPath = join(
dirname(file),
`${next.actorKey}__${next.projectKey}__${next.sessionId}.json`,
);
if (nextPath !== file && (await exists(nextPath))) {
throw new Error(`refusing to overwrite existing transcript: ${nextPath}`);
}
await writeJsonWithBackup(options, file, next);
if (nextPath !== file) {
await renameWithBackup(options, file, nextPath);
}
changes.push({
detail: `${basename(file)} -> ${basename(nextPath)}`,
file,
kind: "transcript",
});
}
return changes;
};
const migrateResultRefs = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.resultRefsDir)) {
const record = await readJson(file);
if (!record || record.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
continue;
}
if (options.fromProjectId && record.projectId !== options.fromProjectId) {
continue;
}
const next: JsonRecord = {
...record,
actorKey: options.toActorKey,
...(options.toProjectId ? { projectId: options.toProjectId } : {}),
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
await writeJsonWithBackup(options, file, next);
changes.push({
detail: `${record.actorKey} -> ${next.actorKey}`,
file,
kind: "result-ref",
});
}
return changes;
};
const migrateUserMemory = async (options: Options): Promise<Change[]> => {
const fromFile = join(options.memoryDir, "users", `${options.fromActorKey}.md`);
if (!(await exists(fromFile))) {
return [];
}
const toFile = join(options.memoryDir, "users", `${options.toActorKey}.md`);
if (fromFile === toFile) {
return [];
}
if (await exists(toFile)) {
const [fromContent, toContent] = await Promise.all([
readFile(fromFile, "utf8"),
readFile(toFile, "utf8"),
]);
const merged = mergeMemoryMarkdown(toContent, fromContent);
await writeTextWithBackup(options, toFile, merged);
await removeWithBackup(options, fromFile);
} else {
await renameWithBackup(options, fromFile, toFile);
}
return [
{
detail: `${basename(fromFile)} -> ${basename(toFile)}`,
file: fromFile,
kind: "memory",
},
];
};
const mergeMemoryMarkdown = (target: string, source: string) => {
const lines = target.split("\n");
const existing = new Set(lines.map((line) => line.trim()).filter(Boolean));
for (const line of source.split("\n")) {
const normalized = line.trim();
if (!normalized || existing.has(normalized)) {
continue;
}
lines.push(line);
existing.add(normalized);
}
return `${lines.join("\n").replace(/\n+$/u, "")}\n`;
};
const listJsonFiles = async (dir: string) => {
try {
const names = await readdir(dir);
return names
.filter((name) => name.endsWith(".json"))
.map((name) => join(dir, name));
} catch (error) {
if (isErrno(error, "ENOENT")) {
return [];
}
throw error;
}
};
const readJson = async (file: string): Promise<JsonRecord | null> => {
try {
const value = JSON.parse(await readFile(file, "utf8")) as unknown;
return isRecord(value) ? value : null;
} catch (error) {
if (isErrno(error, "ENOENT")) {
return null;
}
throw error;
}
};
const writeJsonWithBackup = async (
options: Options,
file: string,
value: JsonRecord,
) => {
await writeTextWithBackup(options, file, `${JSON.stringify(value, null, 2)}\n`);
};
const writeTextWithBackup = async (
options: Options,
file: string,
content: string,
) => {
if (options.dryRun) {
return;
}
await backupFile(options, file);
await writeFile(file, content, "utf8");
};
const renameWithBackup = async (options: Options, from: string, to: string) => {
if (options.dryRun) {
return;
}
await backupFile(options, from);
await mkdir(dirname(to), { recursive: true });
await rename(from, to);
};
const removeWithBackup = async (options: Options, file: string) => {
if (options.dryRun) {
return;
}
await backupFile(options, file);
await rm(file, { force: true });
};
const backupFile = async (options: Options, file: string) => {
const relativePath = relative(process.cwd(), file);
const target = join(options.backupDir, relativePath);
await mkdir(dirname(target), { recursive: true });
await copyFile(file, target);
};
const exists = async (file: string) => {
try {
await stat(file);
return true;
} catch (error) {
if (isErrno(error, "ENOENT")) {
return false;
}
throw error;
}
};
const isRecord = (value: unknown): value is JsonRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isErrno = (error: unknown, code: string) =>
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === code;
const printSummary = (options: Options, changes: Change[]) => {
const counts = changes.reduce<Record<string, number>>((acc, change) => {
acc[change.kind] = (acc[change.kind] ?? 0) + 1;
return acc;
}, {});
console.log(options.dryRun ? "DRY RUN: no files changed" : "Migration applied");
console.log(`from actor: ${options.fromActorKey}`);
console.log(`to actor: ${options.toActorKey}`);
if (options.fromProjectKey || options.toProjectKey) {
console.log(`project: ${options.fromProjectKey ?? "*"} -> ${options.toProjectKey ?? "(unchanged)"}`);
}
if (!options.dryRun) {
console.log(`backup dir: ${options.backupDir}`);
}
console.log(`changes: ${changes.length}`);
for (const [kind, count] of Object.entries(counts)) {
console.log(` ${kind}: ${count}`);
}
for (const change of changes.slice(0, 40)) {
console.log(`- [${change.kind}] ${change.detail}`);
}
if (changes.length > 40) {
console.log(`... ${changes.length - 40} more`);
}
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});