| // Trusted-master automation only. The workflow supplies the requested mode and | |
| // model credential; this module writes only validated files/state for the selected managed locale. | |
| const assert = require('node:assert/strict'); | |
| const fs = require('node:fs'); | |
| const path = require('node:path'); | |
| const {execFileSync} = require('node:child_process'); | |
| const {hash, identity, languageName, pipelineLocale} = require('./state.cjs'); | |
| const {checkedTask, managedPath, safeFile} = require('./translation-engine.cjs'); | |
| const {codexAdapter} = require('./codex-adapter.cjs'); | |
| const {prepare, applyCandidates, readState, stagedBaseline, statePathForRecord, targetFor} = require('./translate-docs.cjs'); | |
| const {markerPrefixFor} = require('./automation-naming.cjs'); | |
| const {checkMaster, pageFiles} = require('./master-drift.cjs'); | |
| const root = path.resolve(__dirname, '../..'); | |
| const allowedKinds = new Set(['translate_new', 'translate_backlog', 'update']); | |
| function relativeSource(task, scope) { | |
| const group = scope.groups.find(item => item.pluginId === task.pluginId && item.version === task.version); | |
| assert(group && task.sourcePath.startsWith(`${group.sourceRoot}/`), 'Invalid source root for pairing'); | |
| return task.sourcePath.slice(group.sourceRoot.length + 1); | |
| } | |
| function pairFor(task, tasks, scope) { | |
| if (task.pluginId !== 'default' || !['current', '4.x'].includes(task.version)) return null; | |
| const otherVersion = task.version === 'current' ? '4.x' : 'current'; | |
| const matches = tasks.filter(other => other.pluginId === 'default' && other.version === otherVersion | |
| && other.docId === task.docId && other.sourceHash === task.sourceHash | |
| && other.chineseSourceBlob===task.chineseSourceBlob | |
| && relativeSource(other, scope) === relativeSource(task, scope)); | |
| assert(matches.length <= 1, 'Ambiguous Dev/4.x source pair'); | |
| const partner = matches[0]; | |
| return partner && allowedKinds.has(partner.kind) && !partner.activePr && !partner.requiresHumanReview | |
| ? partner : null; | |
| } | |
| function machineOwned(task, state) { | |
| const existing = state.data.pages.find(item => identity(item) === identity(task)); | |
| return require('./source-revisions.cjs').canGenerate(existing); | |
| } | |
| function copyPair(primary, partner, scope, state, rootDir = root) { | |
| managedPath(scope, partner); | |
| assert.equal(primary.task.sourceHash, partner.sourceHash, 'Paired source hash differs'); | |
| const targetFile = safeFile(rootDir, partner.targetPath); | |
| const previousTarget = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, 'utf8') : null; | |
| const existing = state.data.pages.find(item => identity(item) === identity(partner)); | |
| assert(!previousTarget && !existing, 'Existing paired translations must be updated against their own baseline'); | |
| const source = fs.readFileSync(safeFile(rootDir, partner.sourcePath), 'utf8'); | |
| assert.equal(hash(source), partner.sourceHash, 'Paired source changed'); | |
| assert.equal(execFileSync('git', ['show', `HEAD:${partner.sourcePath}`], {cwd: rootDir, encoding: 'utf8'}), source, | |
| 'Paired source has uncommitted changes'); | |
| const record = {...primary.record, pluginId: partner.pluginId, version: partner.version, | |
| docId: partner.docId, sourcePath: partner.sourcePath, targetPath: partner.targetPath, | |
| sourceHash: partner.sourceHash}; | |
| if(require('./source-revisions.cjs').enabled(rootDir))record.sourceRevisions=require('./source-revisions.cjs').snapshot(rootDir,record,record.sourceCommit); | |
| return {...primary, task: partner, targetFile, previousTarget, record, | |
| baseline: stagedBaseline(state, partner, previousTarget), | |
| requests: 0, reusedFrom: primary.task.id}; | |
| } | |
| function bodyFor(manifest) { | |
| const rows = manifest.candidates.map(item => { | |
| const review = item.generation; | |
| const mode = review?.mode === 'incremental' | |
| ? `incremental (${(review.sourceChange.ratio * 100).toFixed(1)}%)` | |
| : review?.reason ? `full (${review.reason})` : 'model call'; | |
| return `| ${item.kind} | ${item.version} | [\`${item.sourcePath}\`](https://github.com/apache/doris-website/blob/${manifest.sourceCommit}/${item.sourcePath}) | \`${item.sourceHash.slice(0, 12)}\` | ${item.reusedFrom ? `copied from \`${item.reusedFrom}\`` : mode} |`; | |
| }); | |
| const failures = manifest.failures.length ? manifest.failures.map(item => `- \`${item.id}\` (${item.sourcePath}): ${item.error}`).join('\n') : 'None.'; | |
| const warnings = manifest.candidates.filter(item => item.generation?.acceptedWithWarnings) | |
| .map(item => { | |
| const detail = item.generation.warnings.map(warning => `${warning.check}: ${warning.message}`) | |
| .join('; ').replace(/\s+/g, ' '); | |
| return `- \`${item.sourcePath}\`: third attempt retained for review. ` | |
| + detail.slice(0, 1500) + (detail.length > 1500 ? '…' : ''); | |
| }); | |
| const master = manifest.master?.moved ? [`Master moved to \`${manifest.master.head}\` during generation; ` | |
| + `none of its ${manifest.master.changedFiles} changed files affect this batch.`] : []; | |
| return [`## ${manifest.language} translation candidate`, '', | |
| `Source commit: \`${manifest.sourceCommit}\``, ...master, | |
| `Model: \`${manifest.model}\`; glossary: \`${manifest.glossaryVersion}\``, | |
| `Review status: machine. ${manifest.language} proofreading and the normal PR checks are still required.`, | |
| '', '| Action | Version | English source | SHA-256 prefix | Generation |', | |
| '| --- | --- | --- | --- | --- |', ...rows, '', '## Failed or skipped tasks', '', failures, '', | |
| '## Candidates retained with warnings', '', ...(warnings.length ? warnings : ['None.']), '', | |
| '## Validation', '', 'Updates at or below 30% translate only changed Markdown/MDX blocks and preserve other target blocks.', | |
| 'Change size is the sum of max(removed, added) non-empty English lines per diff hunk, divided by the larger old/new line count.', | |
| 'New documents, larger updates, glossary changes or baselines that cannot be aligned use full translation.', | |
| 'Generation uses at most three attempts per document. Deletions and code-only updates may need no model call.', | |
| 'Each retry receives the earlier validation errors. Third-attempt content issues are retained as warnings above.', | |
| 'Full attempt history and diagnostics are in generation.json and the page state records.', | |
| 'Committed source hashes, managed paths and target/state hashes are checked before applying candidates.', | |
| 'Master is checked before each model call and before publishing: unrelated merges are allowed, a page changed on master is dropped, and a translation configuration or tooling change discards the batch.', | |
| 'The publisher runs the shared PR translation-state, syntax and internal-link checks before pushing.', | |
| 'The normal PR build and human proofreading are still required before merging.', | |
| '', `${markerPrefixFor(manifest.language)}PENDING -->`, ''].join('\n'); | |
| } | |
| async function generate({plan, reconciliation, maxPages = 10, target, adapter, glossary, rootDir = root, | |
| ensureFresh = (sourceCommit, pages) => checkMaster({root: rootDir, sourceCommit, locale: target.locale, pages}), | |
| output}) { | |
| assert(Number.isInteger(maxPages) && maxPages > 0 && maxPages <= 20, 'Invalid page limit'); | |
| assert(plan.schemaVersion === 1 && plan.sourceCommit === execFileSync('git', ['rev-parse', 'HEAD'], | |
| {cwd: rootDir, encoding: 'utf8'}).trim(), 'Plan does not match trusted checkout'); | |
| const scope = JSON.parse(fs.readFileSync(path.join(rootDir, `translations/scope/${target.locale}.json`), 'utf8')); | |
| const state = readState(rootDir, target.locale); | |
| const blocked = new Set(reconciliation.blockedTargets); | |
| const active = new Set((reconciliation.active?.candidates ?? []).map(identity)); | |
| const candidates = [], failures = [], consumed = new Set(); | |
| // Master is checked before the first model call, before each page and after | |
| // the last one, so no model call goes to a page master has already changed. | |
| const pages = new Map(); | |
| for (const task of plan.selected.map(id => plan.tasks.find(item => item.id === id)).filter(Boolean)) { | |
| for (const item of [task, pairFor(task, plan.tasks, scope)].filter(Boolean)) | |
| pages.set(item.id, pageFiles(item, target.locale)); | |
| } | |
| const stale = new Set(); | |
| let drift = {moved: false}; | |
| const refresh = () => { | |
| drift = ensureFresh(plan.sourceCommit, pages) ?? drift; | |
| for (const id of drift.affected ?? []) stale.add(id); | |
| }; | |
| const staleFailure = task => ({id: task.id, sourcePath: task.sourcePath, | |
| error: 'master changed this page after the source commit; it stays pending for the next run'}); | |
| refresh(); | |
| for (const id of plan.selected) { | |
| if (candidates.length >= maxPages) break; | |
| const task = plan.tasks.find(item => item.id === id); | |
| if (!task || consumed.has(id) || task.activePr || active.has(identity(task)) | |
| || blocked.has(task.targetPath)) continue; | |
| consumed.add(id); | |
| if (!machineOwned(task, state)) { | |
| failures.push({id, sourcePath: task.sourcePath, error: 'Human maintained target requires manual review'}); | |
| continue; | |
| } | |
| let partner = pairFor(task, plan.tasks, scope); | |
| if (partner && (active.has(identity(partner)) || blocked.has(partner.targetPath) || !machineOwned(partner, state) | |
| || state.data.pages.some(record => identity(record) === identity(partner)) | |
| || candidates.length + 2 > maxPages)) partner = null; | |
| // Outside the per-page catch: a batch-wide change must stop the run. | |
| refresh(); | |
| if (stale.has(id)) { | |
| failures.push(staleFailure(task)); | |
| continue; | |
| } | |
| try { | |
| checkedTask(plan, id, scope, rootDir); | |
| const primary = await prepare({root: rootDir, plan, taskId: id, scope, state, adapter, glossary, target}); | |
| candidates.push(primary); | |
| if (partner && !stale.has(partner.id)) try { | |
| const copy = copyPair(primary, partner, scope, state, rootDir); | |
| candidates.push(copy); | |
| consumed.add(partner.id); | |
| } catch (error) { | |
| failures.push({id: partner.id, sourcePath: partner.sourcePath, error: error.message}); | |
| } | |
| } catch (error) { | |
| failures.push({id, sourcePath: task.sourcePath, error: error.message}); | |
| } | |
| } | |
| let discarded = null; | |
| if (candidates.length) { | |
| try {refresh();} catch (error) {discarded = error;} | |
| // A copy shares its primary's translation, so it goes with it. | |
| const dropped = candidates.filter(item => stale.has(item.task.id) || stale.has(item.reusedFrom)); | |
| for (const item of dropped) failures.push(staleFailure(item.task)); | |
| candidates.splice(0, candidates.length, ...candidates.filter(item => !dropped.includes(item))); | |
| } | |
| const manifest = {schemaVersion: 1, sourceCommit: plan.sourceCommit, provider: 'codex', | |
| locale: target.locale, language: target.language, | |
| model: adapter.model, glossaryVersion: glossary.version, liveCall: adapter.usedTokens > 0, | |
| liveVerified: false, usedTokens: adapter.usedTokens, | |
| ...(drift.head ? {master: {head: drift.head, moved: drift.moved, changedFiles: drift.files.length}} : {}), | |
| ...(discarded ? {discarded: discarded.message} : {}), | |
| candidates: candidates.map(item => ({id: item.task.id, kind: item.task.kind, | |
| pluginId: item.task.pluginId, version: item.task.version, docId: item.task.docId, | |
| sourcePath: item.task.sourcePath, sourceHash: item.task.sourceHash, | |
| targetPath: item.task.targetPath, targetHash: item.record.targetHash, | |
| statePath: statePathForRecord(state, item.record), | |
| reusedFrom: item.reusedFrom || null, requests: item.requests, | |
| ...(item.record.generation ? {generation: item.record.generation} : {})})), failures}; | |
| fs.mkdirSync(output, {recursive: true}); | |
| fs.writeFileSync(path.join(output, 'generation.json'), JSON.stringify(manifest, null, 2) + '\n'); | |
| if (discarded) throw discarded; | |
| if (candidates.length) { | |
| for (const item of candidates) checkedTask({...plan, selected: [...plan.selected, item.task.id]}, | |
| item.task.id, scope, rootDir); | |
| for (const item of candidates) { | |
| const candidateFile = path.join(output, 'candidates', item.task.targetPath); | |
| fs.mkdirSync(path.dirname(candidateFile), {recursive: true}); | |
| fs.writeFileSync(candidateFile, item.target); | |
| } | |
| applyCandidates(state, candidates); | |
| fs.writeFileSync(path.join(output, 'pr-body.md'), bodyFor(manifest)); | |
| } | |
| return manifest; | |
| } | |
| function allowGeneration(env) { | |
| if (env.I18N_MODE === 'validate') { | |
| return env.GITHUB_EVENT_NAME === 'workflow_dispatch' | |
| && /\.(mdx?|tsx?)$/.test(env.I18N_PATH || '') && env.I18N_MAX_PAGES === '1'; | |
| } | |
| return env.I18N_MODE === 'generate'; | |
| } | |
| async function main() { | |
| const output = process.argv[2]; | |
| assert(output && process.argv.length === 3, 'Usage: workflow-generate.cjs <scan-directory>'); | |
| assert(allowGeneration(process.env), 'Generation requires generate mode or valid one-page validation'); | |
| const plan = JSON.parse(fs.readFileSync(path.join(output, 'plan.json'), 'utf8')); | |
| if (process.env.I18N_MODE === 'validate') { | |
| assert(plan.selected.length === 1 && plan.tasks.find(task => task.id === plan.selected[0])?.sourcePath === process.env.I18N_PATH, | |
| 'Validation mode must select exactly one source file'); | |
| } | |
| const reconciliation = JSON.parse(fs.readFileSync(path.join(output, 'open-pr-reconciliation.json'), 'utf8')); | |
| const locale = pipelineLocale(root); | |
| const target = targetFor(root, locale); | |
| const glossary = JSON.parse(fs.readFileSync(path.join(root, `translations/glossary/${locale}.json`), 'utf8')); | |
| assert(process.env.CODEX_BIN && fs.existsSync(process.env.CODEX_BIN), 'Pinned Codex runtime is required'); | |
| const adapter = codexAdapter({model: process.env.I18N_CODEX_MODEL || 'gpt-6-sol', language: languageName(root, locale), locale, | |
| codexBin: process.env.CODEX_BIN, codexHome: process.env.CODEX_HOME}); | |
| const manifest = await generate({plan, reconciliation, maxPages: Number(process.env.I18N_MAX_PAGES || '10'), | |
| target, adapter, glossary, output}); | |
| if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, | |
| `created=${manifest.candidates.length > 0}\n`); | |
| if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, | |
| `\nGenerated ${manifest.candidates.length} ${manifest.language} page candidates at ${manifest.sourceCommit}; ` | |
| + `${manifest.failures.length} failed or need manual handling. No translation is merged yet.\n` | |
| + (manifest.master?.moved ? `Master moved to ${manifest.master.head} meanwhile; none of its ` | |
| + `${manifest.master.changedFiles} changed files affect the remaining candidates.\n` : '')); | |
| process.stdout.write(JSON.stringify({candidates: manifest.candidates.length, | |
| failures: manifest.failures.length, usedTokens: manifest.usedTokens}) + '\n'); | |
| if (process.env.I18N_MODE === 'validate' && manifest.candidates.length === 0) | |
| process.exitCode = 1; | |
| } | |
| if (require.main === module) main().catch(error => {console.error(error.message); process.exitCode = 1;}); | |
| module.exports = {pairFor, machineOwned, copyPair, bodyFor, generate, allowGeneration}; |