ksf.mjs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import { fileURLToPath, pathToFileURL } from 'node:url';
  5. const skillDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  6. const runtimeDir = path.resolve(process.env.KSF_ADAPT_DIR || '/opt/data/ksf-adapt-training-20261031');
  7. const r7Dir = path.resolve(process.env.KSF_R7_SKILL_DIR || '/opt/data/ksf-baseline/r7/unpacked/skill');
  8. const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
  9. const base = {
  10. skill: 'ksf-workshop-runtime', invocationId: crypto.randomUUID(), at: new Date().toISOString(),
  11. entry: fileURLToPath(import.meta.url), entrySha256: hash(fs.readFileSync(fileURLToPath(import.meta.url))),
  12. };
  13. const fail = (code, message, details) => { throw Object.assign(new Error(message), { code, details }); };
  14. const object = value => value && typeof value === 'object' && !Array.isArray(value);
  15. function argsOf(argv) {
  16. const [command, ...rest] = argv;
  17. if (!['status', 'demo', 'connection', 'ingest', 'report-preview', 'report-submit'].includes(command)) {
  18. fail('USAGE', '用法:ksf.mjs status|demo|connection|ingest|report-preview|report-submit [--input 文件] [--request-id 消息标记]');
  19. }
  20. const args = { command };
  21. for (let i = 0; i < rest.length; i += 2) {
  22. const key = rest[i];
  23. if (!['--input', '--request-id'].includes(key) || !rest[i + 1] || rest[i + 1].startsWith('--') || args[key] != null) {
  24. fail('USAGE', '参数名称、参数值或重复参数不正确');
  25. }
  26. args[key] = rest[i + 1];
  27. }
  28. if (args['--request-id'] && !/^[\p{L}\p{N}_:@.-]{1,100}$/u.test(args['--request-id'])) fail('USAGE', '消息标记只接受100字以内的文字、数字和 _ : @ . -');
  29. if (['ingest', 'report-preview', 'report-submit'].includes(command) !== Boolean(args['--input'])) fail('USAGE', '写入命令必须有 --input;只读命令不接受 --input');
  30. return args;
  31. }
  32. function verifyTree(root, entries) {
  33. return Object.entries(entries).map(([file, expected]) => {
  34. const target = path.resolve(root, file);
  35. if (!target.startsWith(root + path.sep)) fail('BAD_LOCK', '指纹清单包含越界路径');
  36. let actual = null;
  37. try { actual = hash(fs.readFileSync(target)); } catch { /* 缺失与不符都阻止加载 */ }
  38. return { file, ok: actual === expected, ...(actual === expected ? {} : { expected, actual }) };
  39. });
  40. }
  41. function verifyInstallation() {
  42. const lock = JSON.parse(fs.readFileSync(path.join(skillDir, 'runtime-lock.json'), 'utf8'));
  43. const runtime = verifyTree(runtimeDir, lock.runtime), r7 = verifyTree(r7Dir, lock.r7);
  44. const errors = [...runtime.filter(x => !x.ok).map(x => ({ tree: 'runtime', ...x })),
  45. ...r7.filter(x => !x.ok).map(x => ({ tree: 'r7', ...x }))];
  46. return { verified: !errors.length, runtimeDir, r7Dir,
  47. runtimeFiles: { passed: runtime.filter(x => x.ok).length, total: runtime.length },
  48. r7Files: { passed: r7.filter(x => x.ok).length, total: r7.length }, errors };
  49. }
  50. function requireService() {
  51. if (!process.env.KSF_SERVICE_TOKEN?.trim()) fail('MISSING_SERVICE_TOKEN', '未配置 KSF_SERVICE_TOKEN;请在命令进程环境中注入,勿发送到聊天');
  52. }
  53. function readTask(file, allowed) {
  54. let task;
  55. try { task = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
  56. catch { fail('INVALID_INPUT', '输入文件无法读取或不是有效 JSON'); }
  57. if (!object(task)) fail('INVALID_INPUT', '任务根节点必须是对象');
  58. if (Object.keys(task).some(key => !allowed.includes(key))) fail('INVALID_INPUT', '任务包含本命令不接受的根字段,请按技能中的输入约定提供');
  59. return task;
  60. }
  61. async function moduleAt(file) {
  62. return import(pathToFileURL(path.join(runtimeDir, file)).href);
  63. }
  64. async function reportTask(file, command) {
  65. const task = readTask(file, ['reportDir', 'seriesKey', 'version', 'reportType', 'dataMode', 'stageCode', 'artifactSubtype', 'previewId']);
  66. const codes = await moduleAt('client/contract-constants.mjs');
  67. if (typeof task.reportDir !== 'string' || !path.isAbsolute(task.reportDir)
  68. || typeof task.seriesKey !== 'string' || !task.seriesKey.trim()
  69. || !Number.isInteger(task.version) || task.version < 1
  70. || !codes.REPORT_TYPES.includes(task.reportType) || !codes.DATA_MODES.includes(task.dataMode)
  71. || !codes.STAGE_CODES.includes(task.stageCode)
  72. || (task.reportType === codes.AUXILIARY_TYPE && !task.artifactSubtype)) {
  73. fail('INVALID_REPORT', '报告目录、系列键、版本、报告类型、数据模式、阶段或附属类型不完整/不合法');
  74. }
  75. if (!fs.existsSync(path.join(task.reportDir, 'report.html'))) fail('MISSING_REPORT', '报告目录中没有 report.html');
  76. if (command === 'report-submit' && task.dataMode === 'sample') fail('SAMPLE_DISPLAY_ONLY', '本轮培训 sample 报告只作展示,不能提交到正式看板;不得更改数据标签绕过');
  77. if (command === 'report-submit' && (typeof task.previewId !== 'string' || !task.previewId)) fail('MISSING_PREVIEW', '须提供服务端返回的 previewId,且由员工在确认页完成确认');
  78. requireService();
  79. const bridge = await moduleAt('client/report-bridge.mjs');
  80. if (command === 'report-preview') {
  81. const preview = await bridge.previewReport(task);
  82. return { ok: true, operation: 'preview-created', employeeActionRequired: true,
  83. previewId: preview.previewId, confirmationUrl: preview.confirmationUrl,
  84. reportIdentity: preview.reportIdentity, dataMode: task.dataMode, ingested: false, published: false };
  85. }
  86. const result = await bridge.submitConfirmedReport({ ...task, skillDir: r7Dir });
  87. return { ok: !result.fatal, operation: 'report-submit', steps: result.steps,
  88. jobId: result.artifacts?.jobId, artifactId: result.artifacts?.artifactId,
  89. cardId: result.artifacts?.cardId, guard: result.guard,
  90. ingested: result.steps?.import === 'ok', published: result.steps?.publish === 'ok',
  91. ...(result.fatal ? { error: { code: result.fatal.code, step: result.fatal.step, message: result.fatal.reason } } : {}) };
  92. }
  93. async function run(args) {
  94. const installation = verifyInstallation();
  95. if (args.command === 'status') return {
  96. ok: installation.verified, readOnly: true, networkRequests: 0, installation,
  97. serviceTokenConfigured: Boolean(process.env.KSF_SERVICE_TOKEN?.trim()),
  98. connectionVerified: false, note: '本次仅检查本地安装;凭据是否有效需另跑 connection',
  99. };
  100. if (!installation.verified) fail('INSTALLATION_MISMATCH', '文件缺失或指纹不符,未加载连接模块、未发出网络请求', installation);
  101. if (args.command === 'demo') {
  102. const metadata = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'training-demo/报告样例/metadata.json'), 'utf8'));
  103. return { ok: true, readOnly: true, networkRequests: 0, dataMode: 'sample',
  104. report: { localFile: path.join(runtimeDir, 'training-demo/报告样例/report.html'),
  105. url: 'https://s3.fmode.cn/user/DpPsnlBm0K/ksf-training-20261031/report-demo/index.html',
  106. sha256: metadata.reportContentHash, sizeBytes: metadata.sizeBytes,
  107. preparedBeforeConversation: true, hasRealResearchEvidence: false, publishedToDashboard: false },
  108. dashboard: ['https://kangshifu.fmode.cn/', 'https://kangshifu.fmode.cn/pool',
  109. 'https://kangshifu.fmode.cn/stages', 'https://kangshifu.fmode.cn/report'],
  110. linksCheckedThisRun: false, note: '这是预先准备的样例;链接来自既有培训配置,本命令未访问网页或核查现有看板记录' };
  111. }
  112. if (args.command.startsWith('report-')) return reportTask(args['--input'], args.command);
  113. requireService();
  114. if (args.command === 'connection') {
  115. const { createGuardedClient } = await moduleAt('client/guarded-client.mjs');
  116. const contract = await createGuardedClient({ sessionToken: '' }).invoke('ksfGetIngestContract', {});
  117. if (!Array.isArray(contract?.allowedClasses) || !contract.allowedClasses.length) fail('CONTRACT_MISSING', '未读到受限身份合同');
  118. return { ok: true, readOnly: true, identity: 'restricted-service', schemaVersion: contract.schemaVersion,
  119. release: contract.release, allowedClassCount: contract.allowedClasses.length,
  120. employeeAccountsProvisioned: contract.capabilities?.employeeAccountsProvisioned };
  121. }
  122. const task = readTask(args['--input'], ['requestKey', 'source', 'runKey', 'run', 'records', 'producer']);
  123. const { ingestCollectedRecords } = await moduleAt('client/collection-bridge.mjs');
  124. const result = await ingestCollectedRecords(task);
  125. return { ok: true, operation: 'collection-ingested', sourceId: result.sourceId, runId: result.runId,
  126. jobId: result.jobId, status: result.status, counts: result.counts,
  127. items: result.items.map(({ itemKey, status, objectId }) => ({ itemKey, status, objectId })), published: false };
  128. }
  129. function redact(value) {
  130. let text = JSON.stringify(value, null, 2);
  131. for (const key of ['KSF_SERVICE_TOKEN', 'KSF_PARSE_SESSION_TOKEN', 'KSF_PARSE_MASTER_KEY']) {
  132. const secret = process.env[key];
  133. if (secret) text = text.split(JSON.stringify(secret).slice(1, -1)).join('[已隐藏]');
  134. }
  135. return text;
  136. }
  137. try {
  138. const args = argsOf(process.argv.slice(2));
  139. base.command = args.command;
  140. if (args['--request-id']) base.requestId = args['--request-id'];
  141. const result = await run(args);
  142. console.log(redact({ ...base, ...result }));
  143. if (!result.ok) process.exitCode = 1;
  144. } catch (error) {
  145. console.error(redact({ ...base, ok: false, error: {
  146. code: typeof error.code === 'string' ? error.code : 'EXECUTION_FAILED',
  147. message: error.message || '执行失败', ...(error.details ? { details: error.details } : {}),
  148. } }));
  149. process.exitCode = 1;
  150. }