mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): audit legacy shadow terminal windows
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const sqlite3 = require('sqlite3');
|
||||
const { QueryTypes, Sequelize } = require('sequelize');
|
||||
const {
|
||||
LegacySequelizeShadowTerminalDifferenceSource,
|
||||
} = require('../back/runtime/adapters/legacy-sequelize/legacyShadowTerminalDifferenceSource');
|
||||
const {
|
||||
LegacyShadowTerminalDifferenceAuditor,
|
||||
} = require('../back/runtime/application/legacyShadowTerminalDifferenceAuditor');
|
||||
const {
|
||||
createLegacyLogArtifactId,
|
||||
} = require('../back/runtime/compatibility/legacyTaskRevision');
|
||||
|
||||
const SUPPORTED_ORIGINS = new Set([
|
||||
'boot',
|
||||
'manual',
|
||||
'scheduled_node',
|
||||
'scheduled_system',
|
||||
'script',
|
||||
'subscription',
|
||||
'system',
|
||||
]);
|
||||
|
||||
function numericArgument(name, value, minimum, maximum) {
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new Error(`${name} must be an integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new Error(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = {
|
||||
database: path.resolve(process.cwd(), 'data/db/database.sqlite'),
|
||||
profile: 'edge',
|
||||
projectId: 'default',
|
||||
origins: [],
|
||||
minimumSettlingAgeMs: 5 * 60_000,
|
||||
correlationToleranceMs: 2_000,
|
||||
json: false,
|
||||
failOnDifference: false,
|
||||
};
|
||||
for (const argument of argv) {
|
||||
if (argument === '--') continue;
|
||||
if (argument === '--json') {
|
||||
options.json = true;
|
||||
} else if (argument === '--fail-on-difference') {
|
||||
options.failOnDifference = true;
|
||||
} else if (argument.startsWith('--database=')) {
|
||||
const value = argument.slice('--database='.length);
|
||||
if (!value) throw new Error('--database must not be empty');
|
||||
options.database = path.resolve(value);
|
||||
} else if (argument.startsWith('--profile=')) {
|
||||
const value = argument.slice('--profile='.length);
|
||||
if (value !== 'edge' && value !== 'standalone') {
|
||||
throw new Error('--profile must be edge or standalone');
|
||||
}
|
||||
options.profile = value;
|
||||
} else if (argument.startsWith('--project=')) {
|
||||
const value = argument.slice('--project='.length);
|
||||
if (!value || value.length > 128) {
|
||||
throw new Error('--project length must be between 1 and 128');
|
||||
}
|
||||
options.projectId = value;
|
||||
} else if (argument.startsWith('--origin=')) {
|
||||
const values = argument
|
||||
.slice('--origin='.length)
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (values.length === 0) throw new Error('--origin must not be empty');
|
||||
for (const value of values) {
|
||||
if (!SUPPORTED_ORIGINS.has(value)) {
|
||||
throw new Error(`unsupported Shadow origin: ${value}`);
|
||||
}
|
||||
if (!options.origins.includes(value)) options.origins.push(value);
|
||||
}
|
||||
} else if (argument.startsWith('--window-start-ms=')) {
|
||||
options.windowStartMs = numericArgument(
|
||||
'--window-start-ms',
|
||||
argument.slice('--window-start-ms='.length),
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
} else if (argument.startsWith('--window-end-ms=')) {
|
||||
options.windowEndMs = numericArgument(
|
||||
'--window-end-ms',
|
||||
argument.slice('--window-end-ms='.length),
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
} else if (argument.startsWith('--observed-at-ms=')) {
|
||||
options.observedAtMs = numericArgument(
|
||||
'--observed-at-ms',
|
||||
argument.slice('--observed-at-ms='.length),
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
} else if (argument.startsWith('--minimum-settling-age-ms=')) {
|
||||
options.minimumSettlingAgeMs = numericArgument(
|
||||
'--minimum-settling-age-ms',
|
||||
argument.slice('--minimum-settling-age-ms='.length),
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
} else if (argument.startsWith('--correlation-tolerance-ms=')) {
|
||||
options.correlationToleranceMs = numericArgument(
|
||||
'--correlation-tolerance-ms',
|
||||
argument.slice('--correlation-tolerance-ms='.length),
|
||||
0,
|
||||
60_000,
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Unsupported argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
if (options.origins.length === 0) {
|
||||
throw new Error('at least one --origin is required');
|
||||
}
|
||||
if (options.windowStartMs === undefined) {
|
||||
throw new Error('--window-start-ms is required');
|
||||
}
|
||||
if (options.windowEndMs === undefined) {
|
||||
throw new Error('--window-end-ms is required');
|
||||
}
|
||||
if (options.windowStartMs >= options.windowEndMs) {
|
||||
throw new Error('measurement window must be non-empty');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function assertRequiredSchema(database) {
|
||||
const rows = await database.query(
|
||||
`SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name IN ('Runs', 'RunAttempts', 'RunningInstances')`,
|
||||
{ type: QueryTypes.SELECT },
|
||||
);
|
||||
const names = new Set(rows.map((row) => row.name));
|
||||
for (const required of ['Runs', 'RunAttempts', 'RunningInstances']) {
|
||||
if (!names.has(required)) {
|
||||
throw new Error(
|
||||
`Database is not ready for Legacy Shadow terminal audit: missing ${required}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const columns = await database
|
||||
.getQueryInterface()
|
||||
.describeTable('RunningInstances');
|
||||
for (const required of ['run_id', 'attempt_id']) {
|
||||
if (!columns[required]) {
|
||||
throw new Error(
|
||||
`Database is not ready for Legacy Shadow terminal audit: missing RunningInstances.${required}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderText(report) {
|
||||
const lines = [
|
||||
'QingLong 3.0 Legacy Shadow terminal audit',
|
||||
`profile: ${report.profile}`,
|
||||
`window: [${report.window.startInclusiveMs}, ${report.window.endExclusiveMs})`,
|
||||
`window closed: ${report.window.closed}`,
|
||||
`coverage: ${report.coverage.direction} (${report.coverage.legacyWithoutShadow})`,
|
||||
`assessment: ${report.assessment}`,
|
||||
`pages/scanned: ${report.pages}/${report.scanned}`,
|
||||
`remaining: ${report.remaining}`,
|
||||
`evidence complete: ${report.evidenceComplete}`,
|
||||
];
|
||||
for (const [category, count] of Object.entries(report.counts)) {
|
||||
if (count > 0) lines.push(`${category}: ${count}`);
|
||||
}
|
||||
if (report.terminalAgreementPermille !== undefined) {
|
||||
lines.push(
|
||||
`terminal agreement: ${report.terminalAgreementPermille}/1000`,
|
||||
`fully comparable: ${report.fullyComparablePermille}/1000`,
|
||||
);
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
||||
if (nodeMajor < 24) {
|
||||
throw new Error(
|
||||
'Legacy Shadow terminal audit requires Node.js 24 or newer',
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(options.database)) {
|
||||
throw new Error(`Database does not exist: ${options.database}`);
|
||||
}
|
||||
const database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: options.database,
|
||||
logging: false,
|
||||
dialectOptions: { mode: sqlite3.OPEN_READONLY },
|
||||
pool: { max: 1, min: 0, idle: 1_000, acquire: 5_000 },
|
||||
});
|
||||
try {
|
||||
await assertRequiredSchema(database);
|
||||
const auditor = new LegacyShadowTerminalDifferenceAuditor(
|
||||
new LegacySequelizeShadowTerminalDifferenceSource(
|
||||
database,
|
||||
createLegacyLogArtifactId,
|
||||
),
|
||||
);
|
||||
const report = await auditor.run(options);
|
||||
process.stdout.write(
|
||||
options.json ? `${JSON.stringify(report)}\n` : renderText(report),
|
||||
);
|
||||
if (options.failOnDifference && report.assessment !== 'matched') {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertRequiredSchema,
|
||||
parseArguments,
|
||||
renderText,
|
||||
};
|
||||
Reference in New Issue
Block a user