import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath, pathToFileURL } from 'node:url'; const skillDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const runtimeDir = path.resolve(process.env.KSF_ADAPT_DIR || '/opt/data/ksf-adapt-training-20261031'); const r7Dir = path.resolve(process.env.KSF_R7_SKILL_DIR || '/opt/data/ksf-baseline/r7/unpacked/skill'); const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex'); const base = { skill: 'ksf-workshop-runtime', invocationId: crypto.randomUUID(), at: new Date().toISOString(), entry: fileURLToPath(import.meta.url), entrySha256: hash(fs.readFileSync(fileURLToPath(import.meta.url))), }; const fail = (code, message, details) => { throw Object.assign(new Error(message), { code, details }); }; const object = value => value && typeof value === 'object' && !Array.isArray(value); function argsOf(argv) { const [command, ...rest] = argv; if (!['status', 'demo', 'connection', 'ingest', 'report-preview', 'report-submit'].includes(command)) { fail('USAGE', '用法:ksf.mjs status|demo|connection|ingest|report-preview|report-submit [--input 文件] [--request-id 消息标记]'); } const args = { command }; for (let i = 0; i < rest.length; i += 2) { const key = rest[i]; if (!['--input', '--request-id'].includes(key) || !rest[i + 1] || rest[i + 1].startsWith('--') || args[key] != null) { fail('USAGE', '参数名称、参数值或重复参数不正确'); } args[key] = rest[i + 1]; } if (args['--request-id'] && !/^[\p{L}\p{N}_:@.-]{1,100}$/u.test(args['--request-id'])) fail('USAGE', '消息标记只接受100字以内的文字、数字和 _ : @ . -'); if (['ingest', 'report-preview', 'report-submit'].includes(command) !== Boolean(args['--input'])) fail('USAGE', '写入命令必须有 --input;只读命令不接受 --input'); return args; } function verifyTree(root, entries) { return Object.entries(entries).map(([file, expected]) => { const target = path.resolve(root, file); if (!target.startsWith(root + path.sep)) fail('BAD_LOCK', '指纹清单包含越界路径'); let actual = null; try { actual = hash(fs.readFileSync(target)); } catch { /* 缺失与不符都阻止加载 */ } return { file, ok: actual === expected, ...(actual === expected ? {} : { expected, actual }) }; }); } function verifyInstallation() { const lock = JSON.parse(fs.readFileSync(path.join(skillDir, 'runtime-lock.json'), 'utf8')); const runtime = verifyTree(runtimeDir, lock.runtime), r7 = verifyTree(r7Dir, lock.r7); const errors = [...runtime.filter(x => !x.ok).map(x => ({ tree: 'runtime', ...x })), ...r7.filter(x => !x.ok).map(x => ({ tree: 'r7', ...x }))]; return { verified: !errors.length, runtimeDir, r7Dir, runtimeFiles: { passed: runtime.filter(x => x.ok).length, total: runtime.length }, r7Files: { passed: r7.filter(x => x.ok).length, total: r7.length }, errors }; } function requireService() { if (!process.env.KSF_SERVICE_TOKEN?.trim()) fail('MISSING_SERVICE_TOKEN', '未配置 KSF_SERVICE_TOKEN;请在命令进程环境中注入,勿发送到聊天'); } function readTask(file, allowed) { let task; try { task = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); } catch { fail('INVALID_INPUT', '输入文件无法读取或不是有效 JSON'); } if (!object(task)) fail('INVALID_INPUT', '任务根节点必须是对象'); if (Object.keys(task).some(key => !allowed.includes(key))) fail('INVALID_INPUT', '任务包含本命令不接受的根字段,请按技能中的输入约定提供'); return task; } async function moduleAt(file) { return import(pathToFileURL(path.join(runtimeDir, file)).href); } async function reportTask(file, command) { const task = readTask(file, ['reportDir', 'seriesKey', 'version', 'reportType', 'dataMode', 'stageCode', 'artifactSubtype', 'previewId']); const codes = await moduleAt('client/contract-constants.mjs'); if (typeof task.reportDir !== 'string' || !path.isAbsolute(task.reportDir) || typeof task.seriesKey !== 'string' || !task.seriesKey.trim() || !Number.isInteger(task.version) || task.version < 1 || !codes.REPORT_TYPES.includes(task.reportType) || !codes.DATA_MODES.includes(task.dataMode) || !codes.STAGE_CODES.includes(task.stageCode) || (task.reportType === codes.AUXILIARY_TYPE && !task.artifactSubtype)) { fail('INVALID_REPORT', '报告目录、系列键、版本、报告类型、数据模式、阶段或附属类型不完整/不合法'); } if (!fs.existsSync(path.join(task.reportDir, 'report.html'))) fail('MISSING_REPORT', '报告目录中没有 report.html'); if (command === 'report-submit' && task.dataMode === 'sample') fail('SAMPLE_DISPLAY_ONLY', '本轮培训 sample 报告只作展示,不能提交到正式看板;不得更改数据标签绕过'); if (command === 'report-submit' && (typeof task.previewId !== 'string' || !task.previewId)) fail('MISSING_PREVIEW', '须提供服务端返回的 previewId,且由员工在确认页完成确认'); requireService(); const bridge = await moduleAt('client/report-bridge.mjs'); if (command === 'report-preview') { const preview = await bridge.previewReport(task); return { ok: true, operation: 'preview-created', employeeActionRequired: true, previewId: preview.previewId, confirmationUrl: preview.confirmationUrl, reportIdentity: preview.reportIdentity, dataMode: task.dataMode, ingested: false, published: false }; } const result = await bridge.submitConfirmedReport({ ...task, skillDir: r7Dir }); return { ok: !result.fatal, operation: 'report-submit', steps: result.steps, jobId: result.artifacts?.jobId, artifactId: result.artifacts?.artifactId, cardId: result.artifacts?.cardId, guard: result.guard, ingested: result.steps?.import === 'ok', published: result.steps?.publish === 'ok', ...(result.fatal ? { error: { code: result.fatal.code, step: result.fatal.step, message: result.fatal.reason } } : {}) }; } async function run(args) { const installation = verifyInstallation(); if (args.command === 'status') return { ok: installation.verified, readOnly: true, networkRequests: 0, installation, serviceTokenConfigured: Boolean(process.env.KSF_SERVICE_TOKEN?.trim()), connectionVerified: false, note: '本次仅检查本地安装;凭据是否有效需另跑 connection', }; if (!installation.verified) fail('INSTALLATION_MISMATCH', '文件缺失或指纹不符,未加载连接模块、未发出网络请求', installation); if (args.command === 'demo') { const metadata = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'training-demo/报告样例/metadata.json'), 'utf8')); return { ok: true, readOnly: true, networkRequests: 0, dataMode: 'sample', report: { localFile: path.join(runtimeDir, 'training-demo/报告样例/report.html'), url: 'https://s3.fmode.cn/user/DpPsnlBm0K/ksf-training-20261031/report-demo/index.html', sha256: metadata.reportContentHash, sizeBytes: metadata.sizeBytes, preparedBeforeConversation: true, hasRealResearchEvidence: false, publishedToDashboard: false }, dashboard: ['https://kangshifu.fmode.cn/', 'https://kangshifu.fmode.cn/pool', 'https://kangshifu.fmode.cn/stages', 'https://kangshifu.fmode.cn/report'], linksCheckedThisRun: false, note: '这是预先准备的样例;链接来自既有培训配置,本命令未访问网页或核查现有看板记录' }; } if (args.command.startsWith('report-')) return reportTask(args['--input'], args.command); requireService(); if (args.command === 'connection') { const { createGuardedClient } = await moduleAt('client/guarded-client.mjs'); const contract = await createGuardedClient({ sessionToken: '' }).invoke('ksfGetIngestContract', {}); if (!Array.isArray(contract?.allowedClasses) || !contract.allowedClasses.length) fail('CONTRACT_MISSING', '未读到受限身份合同'); return { ok: true, readOnly: true, identity: 'restricted-service', schemaVersion: contract.schemaVersion, release: contract.release, allowedClassCount: contract.allowedClasses.length, employeeAccountsProvisioned: contract.capabilities?.employeeAccountsProvisioned }; } const task = readTask(args['--input'], ['requestKey', 'source', 'runKey', 'run', 'records', 'producer']); const { ingestCollectedRecords } = await moduleAt('client/collection-bridge.mjs'); const result = await ingestCollectedRecords(task); return { ok: true, operation: 'collection-ingested', sourceId: result.sourceId, runId: result.runId, jobId: result.jobId, status: result.status, counts: result.counts, items: result.items.map(({ itemKey, status, objectId }) => ({ itemKey, status, objectId })), published: false }; } function redact(value) { let text = JSON.stringify(value, null, 2); for (const key of ['KSF_SERVICE_TOKEN', 'KSF_PARSE_SESSION_TOKEN', 'KSF_PARSE_MASTER_KEY']) { const secret = process.env[key]; if (secret) text = text.split(JSON.stringify(secret).slice(1, -1)).join('[已隐藏]'); } return text; } try { const args = argsOf(process.argv.slice(2)); base.command = args.command; if (args['--request-id']) base.requestId = args['--request-id']; const result = await run(args); console.log(redact({ ...base, ...result })); if (!result.ok) process.exitCode = 1; } catch (error) { console.error(redact({ ...base, ok: false, error: { code: typeof error.code === 'string' ? error.code : 'EXECUTION_FAILED', message: error.message || '执行失败', ...(error.details ? { details: error.details } : {}), } })); process.exitCode = 1; }