Files
prop-ai-hr/scripts/stage-legacy-knowledge.mjs
T

186 lines
7.5 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export function parseArgs(argv) {
const options = {
baseUrl: process.env.AIHR_LEGACY_BASE_URL || 'https://wygj-api.localhost',
token: process.env.AIHR_LEGACY_TOKEN,
clientid: process.env.AIHR_LEGACY_CLIENT_ID,
batchSize: 50,
afterAttachmentId: 0,
maxBatches: 1,
manifest: 'tmp/legacy-knowledge-stage-manifest.json',
execute: false
};
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (value === '--base-url') options.baseUrl = argv[++index];
else if (value === '--token') options.token = argv[++index];
else if (value === '--clientid') options.clientid = argv[++index];
else if (value === '--batch-size') options.batchSize = Number(argv[++index]);
else if (value === '--after-attachment-id') options.afterAttachmentId = Number(argv[++index]);
else if (value === '--max-batches') options.maxBatches = Number(argv[++index]);
else if (value === '--manifest') options.manifest = argv[++index];
else if (value === '--execute') options.execute = true;
else if (value === '--dry-run') options.execute = false;
else throw new Error(`Unknown option: ${value}`);
}
if (!Number.isInteger(options.batchSize) || options.batchSize < 20 || options.batchSize > 100) {
throw new Error('--batch-size must be an integer between 20 and 100');
}
if (!Number.isSafeInteger(options.afterAttachmentId) || options.afterAttachmentId < 0) {
throw new Error('--after-attachment-id must be a non-negative integer');
}
if (!Number.isInteger(options.maxBatches) || options.maxBatches < 1) {
throw new Error('--max-batches must be a positive integer');
}
if (options.execute && !options.token) {
throw new Error('--token or AIHR_LEGACY_TOKEN is required with --execute');
}
return options;
}
async function readManifest(file) {
try {
const parsed = JSON.parse(await fs.readFile(file, 'utf8'));
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.batches)) {
throw new Error('unsupported manifest schema');
}
return parsed;
} catch (error) {
if (error.code === 'ENOENT') return null;
throw new Error(`Cannot read manifest ${file}: ${error.message}`);
}
}
async function writeManifest(file, manifest) {
const absolute = path.resolve(file);
await fs.mkdir(path.dirname(absolute), { recursive: true });
const temporary = `${absolute}.${process.pid}.tmp`;
await fs.writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
await fs.rename(temporary, absolute);
}
function headers(options) {
const result = { accept: 'application/json' };
if (options.token) {
result.Authorization = options.token.startsWith('Bearer ') ? options.token : `Bearer ${options.token}`;
}
if (options.clientid) result.clientid = options.clientid;
return result;
}
async function stageBatch(options, cursor, fetchImpl) {
const url = new URL('/api/knowledge/quality/legacy/stage', `${options.baseUrl.replace(/\/$/, '')}/`);
url.searchParams.set('afterAttachmentId', String(cursor));
url.searchParams.set('limit', String(options.batchSize));
const response = await fetchImpl(url, { method: 'POST', headers: headers(options) });
const body = await response.json();
if (!response.ok || body.code !== 200 || !body.data) {
throw new Error(`legacy stage failed with HTTP ${response.status} code ${body.code ?? '<missing>'}`);
}
return body.data;
}
async function previewBatch(options, cursor, fetchImpl) {
const url = new URL('/api/knowledge/quality/legacy/preview', `${options.baseUrl.replace(/\/$/, '')}/`);
url.searchParams.set('afterAttachmentId', String(cursor));
url.searchParams.set('limit', String(options.batchSize));
const response = await fetchImpl(url, { method: 'GET', headers: headers(options) });
const body = await response.json();
if (!response.ok || body.code !== 200 || !body.data) {
throw new Error(`legacy preview failed with HTTP ${response.status} code ${body.code ?? '<missing>'}`);
}
return body.data;
}
export async function run(options, dependencies = {}) {
const fetchImpl = dependencies.fetchImpl || globalThis.fetch;
const now = dependencies.now || (() => new Date());
const previous = await readManifest(options.manifest);
const resumeCursor = previous?.status === 'IN_PROGRESS' || previous?.status === 'FAILED'
? Number(previous.cursor || 0)
: options.afterAttachmentId;
if (!options.execute) {
const preview = options.token ? await previewBatch(options, resumeCursor, fetchImpl) : null;
return {
mode: 'DRY_RUN',
baseUrl: options.baseUrl,
batchSize: options.batchSize,
maxBatches: options.maxBatches,
cursor: resumeCursor,
manifest: path.resolve(options.manifest),
preview,
note: preview
? 'Authenticated preview completed without mutation. Add --execute to stage data into review or quarantine.'
: 'Offline validation only. Pass --token for a read-only server preview; add --execute to stage data.'
};
}
const manifest = previous && previous.status !== 'COMPLETE'
? previous
: { schemaVersion: 1, status: 'IN_PROGRESS', cursor: resumeCursor, batches: [] };
manifest.status = 'IN_PROGRESS';
manifest.updatedAt = now().toISOString();
await writeManifest(options.manifest, manifest);
let cursor = Number(manifest.cursor || resumeCursor);
for (let batchNumber = 0; batchNumber < options.maxBatches; batchNumber += 1) {
try {
const result = await stageBatch(options, cursor, fetchImpl);
const entry = {
sequence: manifest.batches.length + 1,
startedAfterAttachmentId: cursor,
discovered: result.discovered,
staged: result.staged,
reparsed: result.reparsed,
quarantined: result.quarantined,
stagedAssetIds: result.stagedAssetIds || [],
failedAttachmentIds: result.failedAttachmentIds || [],
nextCursor: result.nextCursor,
moreAvailable: Boolean(result.moreAvailable),
completedAt: now().toISOString()
};
manifest.batches.push(entry);
if (entry.failedAttachmentIds.length > 0) {
manifest.status = 'FAILED';
manifest.lastError = `Batch contains ${entry.failedAttachmentIds.length} failed attachment(s)`;
manifest.cursor = cursor;
await writeManifest(options.manifest, manifest);
throw new Error(`${manifest.lastError}; cursor was not advanced`);
}
cursor = Number(result.nextCursor || cursor);
manifest.cursor = cursor;
manifest.status = result.moreAvailable ? 'IN_PROGRESS' : 'COMPLETE';
delete manifest.lastError;
await writeManifest(options.manifest, manifest);
if (!result.moreAvailable || result.discovered === 0) break;
} catch (error) {
if (manifest.status !== 'FAILED') {
manifest.status = 'FAILED';
manifest.lastError = error.message;
manifest.cursor = cursor;
manifest.updatedAt = now().toISOString();
await writeManifest(options.manifest, manifest);
}
throw error;
}
}
return {
mode: 'EXECUTE',
status: manifest.status,
cursor: manifest.cursor,
batches: manifest.batches.length,
manifest: path.resolve(options.manifest)
};
}
if (fileURLToPath(import.meta.url) === process.argv[1]) {
run(parseArgs(process.argv.slice(2)))
.then((result) => console.log(JSON.stringify(result, null, 2)))
.catch((error) => { console.error(error.message); process.exitCode = 1; });
}