Files
nexu-io--open-design/scripts/backfill-failed-runs-with-artifacts.ts
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

186 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
// One-shot backfill: fix project runs that produced an artifact on disk but
// were mis-recorded as `failed` (run_status='failed') because the agent
// process exited non-zero during teardown after the deliverable already
// landed. See classifyChatRunCloseStatus in apps/daemon/src/server.ts — going
// forward the daemon classifies these as `succeeded`; this script repairs the
// rows written before that fix.
//
// A row is repaired ONLY when the failed message itself recorded a produced
// file (messages.produced_files_json) whose `*.artifact.json` sidecar still
// exists on disk under <dataDir>/projects/<id>/. This correlates the repair to
// the specific message/run that emitted the artifact — NOT to project-wide
// artifact existence. Project directories are long-lived and accumulate
// sidecars from many unrelated conversations/runs, so flipping every failed
// row in a project on the strength of one unrelated artifact would reclassify
// genuine failures (an irreversible data-integrity bug). Mirrors the daemon's
// `artifactProducedThisRun` carve-out: the artifact must belong to THIS run.
//
// Usage:
// node --experimental-strip-types scripts/backfill-failed-runs-with-artifacts.ts --dry-run
// node --experimental-strip-types scripts/backfill-failed-runs-with-artifacts.ts --data-dir /path/to/.od --dry-run
// node --experimental-strip-types scripts/backfill-failed-runs-with-artifacts.ts --data-dir /path/to/.od
//
// STOP THE DAEMON FIRST so the WAL is flushed and this does not race a write.
// --dry-run prints the rows it would flip without writing. Re-running is
// idempotent (only rows still 'failed' are touched).
import { createRequire } from 'node:module';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..');
interface BackfillDatabase {
prepare: (sql: string) => {
all: (...args: unknown[]) => unknown[];
run: (...args: unknown[]) => { changes: number };
};
transaction: <T>(fn: (arg: T) => number) => (arg: T) => number;
close: () => void;
}
// better-sqlite3 is a native module owned by the daemon workspace; resolve it
// from there (matching scripts/seed-test-projects.ts) rather than the root.
function loadBetterSqlite(): new (filename: string) => BackfillDatabase {
const daemonRequire = createRequire(path.join(REPO_ROOT, 'apps', 'daemon', 'package.json'));
return daemonRequire('better-sqlite3') as new (filename: string) => BackfillDatabase;
}
function parseArgs(argv: string[]): { dataDir: string; dryRun: boolean } {
let dataDir = process.env.OD_DATA_DIR?.trim() || './.od';
let dryRun = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === undefined) continue;
if (arg === '--dry-run') dryRun = true;
else if (arg === '--data-dir') {
const next = argv[i + 1];
if (!next) throw new Error('--data-dir requires a path');
dataDir = next;
i++;
} else if (arg.startsWith('--data-dir=')) {
dataDir = arg.slice('--data-dir='.length);
}
}
return { dataDir: path.resolve(dataDir), dryRun };
}
// A ProjectFile entry as persisted in messages.produced_files_json. Only the
// name/path is needed to locate its sidecar; everything else is ignored.
interface ProducedFileRef {
name?: unknown;
path?: unknown;
}
function parseProducedFiles(raw: unknown): ProducedFileRef[] {
if (typeof raw !== 'string' || !raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as ProducedFileRef[]) : [];
} catch {
return []; // malformed JSON — treat as "no produced files", not fatal
}
}
// True when THIS message recorded a produced file whose `<name>.artifact.json`
// sidecar still exists on disk. Resolved per file relative to the project dir,
// so a sidecar from an unrelated run in the same project never counts.
function messageProducedArtifact(
projectsRoot: string,
projectId: string,
producedFilesJson: unknown,
): boolean {
const files = parseProducedFiles(producedFilesJson);
if (files.length === 0) return false;
const dir = path.join(projectsRoot, projectId);
for (const file of files) {
const rel = typeof file.path === 'string' && file.path
? file.path
: typeof file.name === 'string'
? file.name
: undefined;
if (!rel) continue;
// Reject path traversal / absolute paths; the sidecar must live inside the
// project dir next to the produced file.
const resolved = path.resolve(dir, `${rel}.artifact.json`);
const dirWithSep = dir.endsWith(path.sep) ? dir : dir + path.sep;
if (resolved !== dir && !resolved.startsWith(dirWithSep)) continue;
try {
if (fs.statSync(resolved).isFile()) return true;
} catch {
// sidecar absent — keep checking the message's other produced files
}
}
return false;
}
function main() {
const { dataDir, dryRun } = parseArgs(process.argv.slice(2));
const dbPath = path.join(dataDir, 'app.sqlite');
const projectsRoot = path.join(dataDir, 'projects');
if (!fs.existsSync(dbPath)) {
throw new Error(`No sqlite db at ${dbPath} (is --data-dir correct?)`);
}
const Database = loadBetterSqlite();
const db = new Database(dbPath);
try {
const rows = db
.prepare(
`SELECT m.id AS messageId,
c.project_id AS projectId,
m.produced_files_json AS producedFilesJson
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE m.run_status = 'failed'`,
)
.all() as Array<{ messageId: string; projectId: string; producedFilesJson: unknown }>;
const toFlip = rows.filter((row) =>
messageProducedArtifact(projectsRoot, row.projectId, row.producedFilesJson),
);
console.log(
`Found ${rows.length} failed run row(s); ${toFlip.length} recorded a produced file with an artifact sidecar on disk.`,
);
const byProject = new Map<string, number>();
for (const row of toFlip) {
byProject.set(row.projectId, (byProject.get(row.projectId) ?? 0) + 1);
}
for (const [projectId, count] of byProject) {
console.log(` ${projectId} (${count} row${count === 1 ? '' : 's'})`);
}
if (toFlip.length === 0) {
console.log('Nothing to repair.');
return;
}
if (dryRun) {
console.log('\n--dry-run: no changes written. Re-run without --dry-run to apply.');
return;
}
const update = db.prepare(
`UPDATE messages SET run_status = 'succeeded' WHERE id = ? AND run_status = 'failed'`,
);
const flip = db.transaction((items: Array<{ messageId: string }>) => {
let changed = 0;
for (const item of items) {
changed += update.run(item.messageId).changes;
}
return changed;
});
const changed = flip(toFlip);
console.log(`\nRepaired ${changed} row(s): run_status failed -> succeeded.`);
} finally {
db.close();
}
}
main();