harness-check.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. #!/usr/bin/env node
  2. import { readFile } from 'node:fs/promises'
  3. import { existsSync } from 'node:fs'
  4. import { execFileSync } from 'node:child_process'
  5. import path from 'node:path'
  6. const root = process.cwd()
  7. const failures = []
  8. function fail(message) {
  9. failures.push(message)
  10. }
  11. async function readText(relativePath) {
  12. return readFile(path.join(root, relativePath), 'utf8')
  13. }
  14. function requireFile(relativePath) {
  15. if (!existsSync(path.join(root, relativePath))) {
  16. fail(`Missing required harness file: ${relativePath}`)
  17. }
  18. }
  19. function requireDir(relativePath) {
  20. if (!existsSync(path.join(root, relativePath))) {
  21. fail(`Missing required project directory: ${relativePath}`)
  22. }
  23. }
  24. function gitLines(args) {
  25. try {
  26. return execFileSync('git', args, {
  27. cwd: root,
  28. encoding: 'utf8',
  29. stdio: ['ignore', 'pipe', 'ignore'],
  30. })
  31. .split(/\r?\n/)
  32. .map(line => line.trim())
  33. .filter(Boolean)
  34. } catch {
  35. return []
  36. }
  37. }
  38. function changedFilesFromGit() {
  39. const files = new Set()
  40. for (const file of gitLines(['diff', '--name-only'])) files.add(file)
  41. for (const file of gitLines(['diff', '--name-only', '--cached'])) files.add(file)
  42. for (const file of gitLines(['ls-files', '--others', '--exclude-standard'])) files.add(file)
  43. const baseRef = process.env.GITHUB_BASE_REF
  44. if (baseRef) {
  45. const baseCandidates = [`origin/${baseRef}`, baseRef]
  46. let foundPrBase = false
  47. for (const base of baseCandidates) {
  48. const diff = gitLines(['diff', '--name-only', `${base}...HEAD`])
  49. if (diff.length > 0) {
  50. foundPrBase = true
  51. for (const file of diff) files.add(file)
  52. break
  53. }
  54. }
  55. if (process.env.GITHUB_ACTIONS === 'true' && !foundPrBase && files.size === 0) {
  56. fail(`Unable to inspect PR diff against ${baseRef}; build checkout must fetch full history`)
  57. }
  58. } else {
  59. const upstream = gitLines(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])[0]
  60. if (upstream) {
  61. for (const file of gitLines(['diff', '--name-only', `${upstream}...HEAD`])) files.add(file)
  62. }
  63. }
  64. return [...files].sort()
  65. }
  66. function isChatSessionChainFile(file) {
  67. return file === 'packages/client/src/api/hermes/chat.ts'
  68. || file === 'packages/client/src/api/hermes/group-chat.ts'
  69. || file === 'packages/client/src/api/hermes/sessions.ts'
  70. || file === 'packages/client/src/stores/hermes/group-chat.ts'
  71. || file === 'packages/client/src/stores/hermes/chat.ts'
  72. || file === 'packages/server/src/controllers/hermes/sessions.ts'
  73. || file === 'packages/server/src/db/hermes/session-store.ts'
  74. || file === 'packages/server/src/routes/hermes/group-chat.ts'
  75. || file.startsWith('packages/client/src/components/hermes/group-chat/')
  76. || file.startsWith('packages/client/src/components/hermes/chat/')
  77. || file.startsWith('packages/server/src/lib/context-compressor/')
  78. || file.startsWith('packages/server/src/services/hermes/context-engine/')
  79. || file.startsWith('packages/server/src/services/hermes/group-chat/')
  80. || file.startsWith('packages/server/src/services/hermes/run-chat/')
  81. || file.startsWith('packages/server/src/services/hermes/agent-bridge/')
  82. }
  83. function isChatChainChangeFragment(file) {
  84. return file.startsWith('docs/chat-chain-changes/')
  85. && file.endsWith('.md')
  86. && path.basename(file) !== 'README.md'
  87. }
  88. for (const file of [
  89. 'AGENTS.md',
  90. 'ARCHITECTURE.md',
  91. 'DEVELOPMENT.md',
  92. 'docs/harness/README.md',
  93. 'docs/harness/validation.md',
  94. 'docs/harness/worktree-runbook.md',
  95. 'docs/harness/pr-review.md',
  96. 'docs/chat-chain-changes/README.md',
  97. ]) {
  98. requireFile(file)
  99. }
  100. for (const dir of [
  101. 'packages/client/src',
  102. 'packages/server/src',
  103. 'packages/desktop',
  104. 'packages/desktop/build/icons',
  105. 'tests/client',
  106. 'tests/server',
  107. 'tests/e2e',
  108. '.github/workflows',
  109. 'docs/chat-chain-changes',
  110. ]) {
  111. requireDir(dir)
  112. }
  113. for (const icon of [
  114. 'packages/desktop/build/icon.png',
  115. 'packages/desktop/build/icon.icns',
  116. 'packages/desktop/build/icon.ico',
  117. 'packages/desktop/build/icons/16x16.png',
  118. 'packages/desktop/build/icons/32x32.png',
  119. 'packages/desktop/build/icons/48x48.png',
  120. 'packages/desktop/build/icons/64x64.png',
  121. 'packages/desktop/build/icons/128x128.png',
  122. 'packages/desktop/build/icons/256x256.png',
  123. 'packages/desktop/build/icons/512x512.png',
  124. ]) {
  125. requireFile(icon)
  126. }
  127. const agents = await readText('AGENTS.md')
  128. const agentLines = agents.trimEnd().split(/\r?\n/)
  129. if (agentLines.length > 120) {
  130. fail(`AGENTS.md should stay short; found ${agentLines.length} lines, expected <= 120`)
  131. }
  132. for (const requiredLink of [
  133. 'DEVELOPMENT.md',
  134. 'ARCHITECTURE.md',
  135. 'docs/harness/README.md',
  136. 'docs/harness/validation.md',
  137. 'docs/harness/worktree-runbook.md',
  138. 'docs/harness/pr-review.md',
  139. ]) {
  140. if (!agents.includes(requiredLink)) {
  141. fail(`AGENTS.md must link to ${requiredLink}`)
  142. }
  143. }
  144. const packageJson = JSON.parse(await readText('package.json'))
  145. for (const scriptName of [
  146. 'harness:check',
  147. 'test',
  148. 'test:coverage',
  149. 'test:e2e',
  150. 'build',
  151. ]) {
  152. if (!packageJson.scripts?.[scriptName]) {
  153. fail(`package.json is missing script: ${scriptName}`)
  154. }
  155. }
  156. const architecture = await readText('ARCHITECTURE.md')
  157. for (const phrase of [
  158. 'packages/client/src',
  159. 'packages/server/src',
  160. 'packages/desktop',
  161. 'HERMES_WEB_UI_HOME',
  162. 'fail_on_unmatched_files: true',
  163. ]) {
  164. if (!architecture.includes(phrase)) {
  165. fail(`ARCHITECTURE.md should document: ${phrase}`)
  166. }
  167. }
  168. const buildWorkflow = await readText('.github/workflows/build.yml')
  169. if (!buildWorkflow.includes('npm run harness:check')) {
  170. fail('Build workflow must run npm run harness:check')
  171. }
  172. if (!buildWorkflow.includes('fetch-depth: 0')) {
  173. fail('Build workflow checkout must use fetch-depth: 0 so harness:check can inspect PR diffs')
  174. }
  175. const chatSessionsDoc = await readText('docs/cli-chat-sessions.md')
  176. for (const phrase of [
  177. '最后重建时间',
  178. '维护要求',
  179. '最近链路变更记录',
  180. 'docs/chat-chain-changes/',
  181. '每个 PR 一个变更片段',
  182. 'packages/server/src/services/hermes/agent-bridge/',
  183. 'packages/server/src/services/hermes/group-chat/',
  184. 'packages/server/src/lib/context-compressor/',
  185. '任何改动都算 Chat 链路改动',
  186. ]) {
  187. if (!chatSessionsDoc.includes(phrase)) {
  188. fail(`docs/cli-chat-sessions.md must document chat chain maintenance rule: ${phrase}`)
  189. }
  190. }
  191. const changedFiles = changedFilesFromGit()
  192. const changedChatChainFiles = changedFiles.filter(
  193. file => !isChatChainChangeFragment(file)
  194. && file !== 'docs/chat-chain-changes/README.md'
  195. && file !== 'docs/cli-chat-sessions.md'
  196. && isChatSessionChainFile(file),
  197. )
  198. const changedChatChainFragments = changedFiles.filter(isChatChainChangeFragment)
  199. if (changedChatChainFiles.length > 0 && changedChatChainFragments.length === 0) {
  200. fail(
  201. [
  202. 'Chat session chain changed without adding a docs/chat-chain-changes/*.md fragment.',
  203. 'Add one fragment with date, PR/commit, touched feature, and behavior impact.',
  204. `Changed chain files: ${changedChatChainFiles.join(', ')}`,
  205. ].join(' '),
  206. )
  207. }
  208. for (const file of changedChatChainFragments) {
  209. if (!existsSync(path.join(root, file))) {
  210. fail(`Chat chain change fragment was removed instead of added/updated: ${file}`)
  211. continue
  212. }
  213. const fragment = await readText(file)
  214. for (const marker of ['date:', 'feature:', 'impact:']) {
  215. if (!fragment.includes(marker)) {
  216. fail(`Chat chain change fragment ${file} must include frontmatter field: ${marker}`)
  217. }
  218. }
  219. if (!fragment.includes('pr:') && !fragment.includes('commit:')) {
  220. fail(`Chat chain change fragment ${file} must include either pr: or commit:`)
  221. }
  222. }
  223. const desktopReleaseWorkflow = await readText('.github/workflows/desktop-release.yml')
  224. const desktopManualBuildWorkflow = await readText('.github/workflows/desktop-manual-build.yml')
  225. const desktopMacUpdateManifestWorkflow = await readText('.github/workflows/desktop-mac-update-manifest.yml')
  226. const desktopRuntimeWorkflow = await readText('.github/workflows/desktop-runtime.yml')
  227. const webuiReleaseWorkflow = await readText('.github/workflows/webui-release.yml')
  228. const dockerPublishWorkflow = await readText('.github/workflows/docker-publish.yml')
  229. const electronBuilderConfig = await readText('packages/desktop/electron-builder.yml')
  230. const desktopMacEntitlements = await readText('packages/desktop/build/entitlements.mac.plist')
  231. const desktopMacInheritedEntitlements = await readText('packages/desktop/build/entitlements.mac.inherit.plist')
  232. const desktopPackageJson = await readText('packages/desktop/package.json')
  233. const desktopInstallHermes = await readText('packages/desktop/scripts/install-hermes.mjs')
  234. const desktopHermesPatches = await readText('packages/desktop/scripts/apply-hermes-patches.mjs')
  235. const desktopWebuiServer = await readText('packages/desktop/src/main/webui-server.ts')
  236. const desktopMain = await readText('packages/desktop/src/main/index.ts')
  237. const desktopUpdater = await readText('packages/desktop/src/main/updater.ts')
  238. const desktopInstallerScript = await readText('packages/desktop/build/installer.nsh')
  239. const desktopRuntimeManager = await readText('packages/desktop/src/main/runtime-manager.ts')
  240. const desktopPaths = await readText('packages/desktop/src/main/paths.ts')
  241. const desktopRuntimeAssetName = await readText('packages/desktop/scripts/runtime-asset-name.mjs')
  242. if (!desktopReleaseWorkflow.includes('files: ${{ matrix.artifact_files }}')) {
  243. fail('desktop-release.yml must upload matrix-specific artifact_files')
  244. }
  245. if (desktopReleaseWorkflow.includes('types: [published]')) {
  246. fail('desktop-release.yml must not run full desktop packaging on every published GitHub Release')
  247. }
  248. if (!desktopReleaseWorkflow.includes('gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --latest')) {
  249. fail('desktop-release.yml must mark successful full desktop releases as GitHub latest')
  250. }
  251. for (const [file, text] of [
  252. ['webui-release.yml', webuiReleaseWorkflow],
  253. ['docker-publish.yml', dockerPublishWorkflow],
  254. ]) {
  255. if (!text.includes('release:') || !text.includes('types: [published]')) {
  256. fail(`${file} must keep running on published GitHub Releases`)
  257. }
  258. if (!text.includes('gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --latest=false')) {
  259. fail(`${file} must keep published GitHub Releases out of latest`)
  260. }
  261. }
  262. if (!webuiReleaseWorkflow.includes('make_latest: false')) {
  263. fail('webui-release.yml must not mark release uploads as GitHub latest')
  264. }
  265. if (!electronBuilderConfig.includes('icon: build/icons')) {
  266. fail('electron-builder.yml must configure the Linux icon set')
  267. }
  268. for (const entitlementFile of ['build/entitlements.mac.plist', 'build/entitlements.mac.inherit.plist']) {
  269. if (!electronBuilderConfig.includes(entitlementFile)) {
  270. fail(`electron-builder.yml must configure ${entitlementFile}`)
  271. }
  272. }
  273. for (const [file, text] of [
  274. ['entitlements.mac.plist', desktopMacEntitlements],
  275. ['entitlements.mac.inherit.plist', desktopMacInheritedEntitlements],
  276. ]) {
  277. if (!text.includes('<key>com.apple.security.device.audio-input</key>')) {
  278. fail(`${file} must allow microphone audio input`)
  279. }
  280. }
  281. for (const target of ['target_os: darwin', 'target_os: win32', 'target_os: linux']) {
  282. if (!desktopReleaseWorkflow.includes(target)) {
  283. fail(`desktop-release.yml is missing matrix target ${target}`)
  284. }
  285. }
  286. for (const expectedGlob of ['*.dmg', '*.exe', '*.AppImage']) {
  287. if (!desktopReleaseWorkflow.includes(expectedGlob)) {
  288. fail(`desktop-release.yml is missing expected artifact glob ${expectedGlob}`)
  289. }
  290. }
  291. if (!desktopReleaseWorkflow.includes('fail_on_unmatched_files: true')) {
  292. fail('desktop-release.yml must keep fail_on_unmatched_files: true')
  293. }
  294. function workflowCaseBody(text, caseLabel) {
  295. const start = text.indexOf(`${caseLabel})`)
  296. if (start < 0) fail(`desktop-manual-build.yml is missing ${caseLabel} case`)
  297. const end = text.indexOf(';;', start)
  298. if (end < 0) fail(`desktop-manual-build.yml ${caseLabel} case is missing terminator`)
  299. return text.slice(start, end)
  300. }
  301. for (const macCase of ['darwin-arm64', 'darwin-x64']) {
  302. const body = workflowCaseBody(desktopManualBuildWorkflow, macCase)
  303. if (body.includes('latest*.yml')) {
  304. fail(`desktop-manual-build.yml must not publish single-arch macOS update manifests from ${macCase}`)
  305. }
  306. for (const glob of ['*.dmg.blockmap', '*.zip.blockmap']) {
  307. if (!body.includes(glob)) {
  308. fail(`desktop-manual-build.yml ${macCase} must keep uploading ${glob}`)
  309. }
  310. }
  311. }
  312. for (const phrase of [
  313. 'mac-update-manifest:',
  314. "if: needs.validate.outputs.target_os == 'darwin' && github.event.inputs.release_tag != ''",
  315. 'Both macOS architectures are not available yet; leaving latest-mac.yml unchanged.',
  316. 'gh release upload "$TAG" /tmp/latest-mac.yml',
  317. ]) {
  318. if (!desktopManualBuildWorkflow.includes(phrase)) {
  319. fail(`desktop-manual-build.yml must include macOS manifest repair behavior: ${phrase}`)
  320. }
  321. }
  322. if (!desktopMacUpdateManifestWorkflow.includes('Repair macOS Update Manifest')) {
  323. fail('desktop-mac-update-manifest.yml must provide a manual macOS manifest repair workflow')
  324. }
  325. if (!desktopMacUpdateManifestWorkflow.includes("gh release download \"$TAG\"") || !desktopMacUpdateManifestWorkflow.includes('/tmp/latest-mac.yml')) {
  326. fail('desktop-mac-update-manifest.yml must generate latest-mac.yml from release assets')
  327. }
  328. if (!desktopMacUpdateManifestWorkflow.includes('gh release upload "$TAG" /tmp/latest-mac.yml')) {
  329. fail('desktop-mac-update-manifest.yml must upload the merged latest-mac.yml to the release')
  330. }
  331. for (const phrase of [
  332. 'resources/python/${os}-${arch}',
  333. 'resources/node/${os}-${arch}',
  334. 'resources/git/${os}-${arch}',
  335. ]) {
  336. if (electronBuilderConfig.includes(phrase)) {
  337. fail(`electron-builder.yml must not bundle desktop runtime resource: ${phrase}`)
  338. }
  339. }
  340. for (const phrase of [
  341. '"fetch:node"',
  342. '"fetch:git"',
  343. '"prepare:runtime"',
  344. '"package:runtime"',
  345. '"runtime:asset-name"',
  346. ]) {
  347. if (!desktopPackageJson.includes(phrase)) {
  348. fail(`packages/desktop/package.json must support runtime package publishing: ${phrase}`)
  349. }
  350. }
  351. for (const phrase of [
  352. 'steps.check.outputs.missing',
  353. 'npm --prefix packages/desktop run prepare:runtime',
  354. 'npm --prefix packages/desktop run package:runtime',
  355. ]) {
  356. if (!desktopRuntimeWorkflow.includes(phrase)) {
  357. fail(`desktop-runtime.yml must build and publish missing runtime package assets: ${phrase}`)
  358. }
  359. }
  360. if (!desktopRuntimeAssetName.includes('hermes-runtime-hermes-agent-')) {
  361. fail('runtime asset naming must include hermes-agent version')
  362. }
  363. for (const phrase of [
  364. 'websockets',
  365. 'agent-browser@^0.26.0',
  366. 'HERMES_CHROME_FOR_TESTING_VERSION',
  367. '149.0.7827.55',
  368. 'pinChromeForTestingBundle',
  369. 'chromeForTestingPlatform',
  370. 'AGENT_BROWSER_HOME',
  371. 'AGENT_BROWSER_EXECUTABLE_PATH',
  372. 'PLAYWRIGHT_BROWSERS_PATH',
  373. 'ms-playwright',
  374. 'removeBrokenDashboardAuthPlugin',
  375. ]) {
  376. if (!desktopInstallHermes.includes(phrase)) {
  377. fail(`install-hermes.mjs must bundle Hermes browser runtime support: ${phrase}`)
  378. }
  379. }
  380. for (const phrase of [
  381. 'from pathlib import Path',
  382. 'browser stdout decode fallback is incomplete',
  383. 'def _hermes_read_browser_output',
  384. 'dingtalk AI Card webhook patches are incomplete',
  385. "plugins', 'platforms', 'dingtalk', 'adapter.py",
  386. "gateway', 'platforms', 'dingtalk.py",
  387. 'sitecustomize hidden subprocess patch marker exists',
  388. 'python compile check',
  389. ]) {
  390. if (!desktopHermesPatches.includes(phrase)) {
  391. fail(`apply-hermes-patches.mjs must keep browser stdout fallback complete: ${phrase}`)
  392. }
  393. }
  394. for (const phrase of [
  395. 'bundledAgentBrowserHome',
  396. 'AGENT_BROWSER_HOME',
  397. 'bundledNodeBin',
  398. 'HERMES_AGENT_NODE',
  399. 'HERMES_AGENT_GIT',
  400. 'PLAYWRIGHT_BROWSERS_PATH',
  401. 'ms-playwright',
  402. ]) {
  403. if (!desktopWebuiServer.includes(phrase)) {
  404. fail(`desktop webui server must expose bundled browser runtime: ${phrase}`)
  405. }
  406. }
  407. if (desktopWebuiServer.includes('bundledBrowserExecutable()')) {
  408. fail('desktop webui server must let agent-browser resolve the bundled browser from AGENT_BROWSER_HOME')
  409. }
  410. for (const phrase of [
  411. 'requestSingleInstanceLock(QUIT_EXISTING ? { quit: true } : undefined)',
  412. 'hasQuitRequest(additionalData)',
  413. ]) {
  414. if (!desktopMain.includes(phrase)) {
  415. fail(`desktop main process must forward --quit to an existing app instance: ${phrase}`)
  416. }
  417. }
  418. for (const phrase of [
  419. 'HERMES_STUDIO_EXE',
  420. 'Get-CimInstance Win32_Process',
  421. 'CloseMainWindow()',
  422. 'Stop-Process -Id',
  423. ]) {
  424. if (!desktopInstallerScript.includes(phrase)) {
  425. fail(`desktop installer must close stale Hermes Studio processes by installed executable path: ${phrase}`)
  426. }
  427. }
  428. for (const phrase of [
  429. 'https://download.ekkolearnai.com/latest',
  430. 'https://github.com/EKKOLearnAI/hermes-studio/releases/latest/download',
  431. 'checkForUpdatesWithFallback()',
  432. ]) {
  433. if (!desktopUpdater.includes(phrase)) {
  434. fail(`desktop updater must check Cloudflare first and keep GitHub as fallback: ${phrase}`)
  435. }
  436. }
  437. if (desktopUpdater.includes('fetch(')) {
  438. fail('desktop updater must not make custom fetch requests to resolve the latest release tag')
  439. }
  440. for (const phrase of [
  441. 'HERMES_DESKTOP_RUNTIME_URL',
  442. 'HERMES_DESKTOP_RUNTIME_BASE_URL',
  443. 'runtime-manifest.json',
  444. ]) {
  445. if (!desktopRuntimeManager.includes(phrase)) {
  446. fail(`desktop runtime manager must support downloadable runtime packages: ${phrase}`)
  447. }
  448. }
  449. if (!desktopPaths.includes('HERMES_DESKTOP_RUNTIME_DIR')) {
  450. fail('desktop paths must allow HERMES_DESKTOP_RUNTIME_DIR override')
  451. }
  452. if (failures.length > 0) {
  453. console.error('Harness check failed:')
  454. for (const failure of failures) {
  455. console.error(`- ${failure}`)
  456. }
  457. process.exit(1)
  458. }
  459. console.log('Harness check passed')
粤ICP备19079148号