186 lines
7.5 KiB
JavaScript
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; });
|
|
}
|