feat(api): expose REST-only agent routes
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { generateAgentOpenApi } from "../src/contracts/openapi.js";
|
||||
|
||||
const canonicalJson = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
||||
const document = generateAgentOpenApi();
|
||||
const payload = canonicalJson(document);
|
||||
const sha256 = createHash("sha256").update(payload).digest("hex");
|
||||
const manifestPayload = canonicalJson({
|
||||
contract_version: "1.0.0",
|
||||
contracts: {
|
||||
agent: {
|
||||
file: "agent-v1.openapi.json",
|
||||
sha256,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
const currentContract = await readFile(
|
||||
"contracts/agent-v1.openapi.json",
|
||||
"utf8",
|
||||
);
|
||||
const currentManifest = await readFile("contracts/manifest.json", "utf8");
|
||||
if (currentContract !== payload || currentManifest !== manifestPayload) {
|
||||
throw new Error(
|
||||
"Agent OpenAPI contract is stale: run `bun run contract:generate`",
|
||||
);
|
||||
}
|
||||
console.log(`validated Agent OpenAPI contract (${sha256})`);
|
||||
} else {
|
||||
await mkdir("contracts", { recursive: true });
|
||||
await writeFile("contracts/agent-v1.openapi.json", payload, "utf8");
|
||||
await writeFile("contracts/manifest.json", manifestPayload, "utf8");
|
||||
console.log(`generated Agent OpenAPI contract (${sha256})`);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user