| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514 |
- #!/usr/bin/env node
- import { readFile } from 'node:fs/promises'
- import { existsSync } from 'node:fs'
- import { execFileSync } from 'node:child_process'
- import path from 'node:path'
- const root = process.cwd()
- const failures = []
- function fail(message) {
- failures.push(message)
- }
- async function readText(relativePath) {
- return readFile(path.join(root, relativePath), 'utf8')
- }
- function requireFile(relativePath) {
- if (!existsSync(path.join(root, relativePath))) {
- fail(`Missing required harness file: ${relativePath}`)
- }
- }
- function requireDir(relativePath) {
- if (!existsSync(path.join(root, relativePath))) {
- fail(`Missing required project directory: ${relativePath}`)
- }
- }
- function gitLines(args) {
- try {
- return execFileSync('git', args, {
- cwd: root,
- encoding: 'utf8',
- stdio: ['ignore', 'pipe', 'ignore'],
- })
- .split(/\r?\n/)
- .map(line => line.trim())
- .filter(Boolean)
- } catch {
- return []
- }
- }
- function changedFilesFromGit() {
- const files = new Set()
- for (const file of gitLines(['diff', '--name-only'])) files.add(file)
- for (const file of gitLines(['diff', '--name-only', '--cached'])) files.add(file)
- for (const file of gitLines(['ls-files', '--others', '--exclude-standard'])) files.add(file)
- const baseRef = process.env.GITHUB_BASE_REF
- if (baseRef) {
- const baseCandidates = [`origin/${baseRef}`, baseRef]
- let foundPrBase = false
- for (const base of baseCandidates) {
- const diff = gitLines(['diff', '--name-only', `${base}...HEAD`])
- if (diff.length > 0) {
- foundPrBase = true
- for (const file of diff) files.add(file)
- break
- }
- }
- if (process.env.GITHUB_ACTIONS === 'true' && !foundPrBase && files.size === 0) {
- fail(`Unable to inspect PR diff against ${baseRef}; build checkout must fetch full history`)
- }
- } else {
- const upstream = gitLines(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])[0]
- if (upstream) {
- for (const file of gitLines(['diff', '--name-only', `${upstream}...HEAD`])) files.add(file)
- }
- }
- return [...files].sort()
- }
- function isChatSessionChainFile(file) {
- return file === 'packages/client/src/api/hermes/chat.ts'
- || file === 'packages/client/src/api/hermes/group-chat.ts'
- || file === 'packages/client/src/api/hermes/sessions.ts'
- || file === 'packages/client/src/stores/hermes/group-chat.ts'
- || file === 'packages/client/src/stores/hermes/chat.ts'
- || file === 'packages/server/src/controllers/hermes/sessions.ts'
- || file === 'packages/server/src/db/hermes/session-store.ts'
- || file === 'packages/server/src/routes/hermes/group-chat.ts'
- || file.startsWith('packages/client/src/components/hermes/group-chat/')
- || file.startsWith('packages/client/src/components/hermes/chat/')
- || file.startsWith('packages/server/src/lib/context-compressor/')
- || file.startsWith('packages/server/src/services/hermes/context-engine/')
- || file.startsWith('packages/server/src/services/hermes/group-chat/')
- || file.startsWith('packages/server/src/services/hermes/run-chat/')
- || file.startsWith('packages/server/src/services/hermes/agent-bridge/')
- }
- function isChatChainChangeFragment(file) {
- return file.startsWith('docs/chat-chain-changes/')
- && file.endsWith('.md')
- && path.basename(file) !== 'README.md'
- }
- for (const file of [
- 'AGENTS.md',
- 'ARCHITECTURE.md',
- 'DEVELOPMENT.md',
- 'docs/harness/README.md',
- 'docs/harness/validation.md',
- 'docs/harness/worktree-runbook.md',
- 'docs/harness/pr-review.md',
- 'docs/chat-chain-changes/README.md',
- ]) {
- requireFile(file)
- }
- for (const dir of [
- 'packages/client/src',
- 'packages/server/src',
- 'packages/desktop',
- 'packages/desktop/build/icons',
- 'tests/client',
- 'tests/server',
- 'tests/e2e',
- '.github/workflows',
- 'docs/chat-chain-changes',
- ]) {
- requireDir(dir)
- }
- for (const icon of [
- 'packages/desktop/build/icon.png',
- 'packages/desktop/build/icon.icns',
- 'packages/desktop/build/icon.ico',
- 'packages/desktop/build/icons/16x16.png',
- 'packages/desktop/build/icons/32x32.png',
- 'packages/desktop/build/icons/48x48.png',
- 'packages/desktop/build/icons/64x64.png',
- 'packages/desktop/build/icons/128x128.png',
- 'packages/desktop/build/icons/256x256.png',
- 'packages/desktop/build/icons/512x512.png',
- ]) {
- requireFile(icon)
- }
- const agents = await readText('AGENTS.md')
- const agentLines = agents.trimEnd().split(/\r?\n/)
- if (agentLines.length > 120) {
- fail(`AGENTS.md should stay short; found ${agentLines.length} lines, expected <= 120`)
- }
- for (const requiredLink of [
- 'DEVELOPMENT.md',
- 'ARCHITECTURE.md',
- 'docs/harness/README.md',
- 'docs/harness/validation.md',
- 'docs/harness/worktree-runbook.md',
- 'docs/harness/pr-review.md',
- ]) {
- if (!agents.includes(requiredLink)) {
- fail(`AGENTS.md must link to ${requiredLink}`)
- }
- }
- const packageJson = JSON.parse(await readText('package.json'))
- for (const scriptName of [
- 'harness:check',
- 'test',
- 'test:coverage',
- 'test:e2e',
- 'build',
- ]) {
- if (!packageJson.scripts?.[scriptName]) {
- fail(`package.json is missing script: ${scriptName}`)
- }
- }
- const architecture = await readText('ARCHITECTURE.md')
- for (const phrase of [
- 'packages/client/src',
- 'packages/server/src',
- 'packages/desktop',
- 'HERMES_WEB_UI_HOME',
- 'fail_on_unmatched_files: true',
- ]) {
- if (!architecture.includes(phrase)) {
- fail(`ARCHITECTURE.md should document: ${phrase}`)
- }
- }
- const buildWorkflow = await readText('.github/workflows/build.yml')
- if (!buildWorkflow.includes('npm run harness:check')) {
- fail('Build workflow must run npm run harness:check')
- }
- if (!buildWorkflow.includes('fetch-depth: 0')) {
- fail('Build workflow checkout must use fetch-depth: 0 so harness:check can inspect PR diffs')
- }
- const chatSessionsDoc = await readText('docs/cli-chat-sessions.md')
- for (const phrase of [
- '最后重建时间',
- '维护要求',
- '最近链路变更记录',
- 'docs/chat-chain-changes/',
- '每个 PR 一个变更片段',
- 'packages/server/src/services/hermes/agent-bridge/',
- 'packages/server/src/services/hermes/group-chat/',
- 'packages/server/src/lib/context-compressor/',
- '任何改动都算 Chat 链路改动',
- ]) {
- if (!chatSessionsDoc.includes(phrase)) {
- fail(`docs/cli-chat-sessions.md must document chat chain maintenance rule: ${phrase}`)
- }
- }
- const changedFiles = changedFilesFromGit()
- const changedChatChainFiles = changedFiles.filter(
- file => !isChatChainChangeFragment(file)
- && file !== 'docs/chat-chain-changes/README.md'
- && file !== 'docs/cli-chat-sessions.md'
- && isChatSessionChainFile(file),
- )
- const changedChatChainFragments = changedFiles.filter(isChatChainChangeFragment)
- if (changedChatChainFiles.length > 0 && changedChatChainFragments.length === 0) {
- fail(
- [
- 'Chat session chain changed without adding a docs/chat-chain-changes/*.md fragment.',
- 'Add one fragment with date, PR/commit, touched feature, and behavior impact.',
- `Changed chain files: ${changedChatChainFiles.join(', ')}`,
- ].join(' '),
- )
- }
- for (const file of changedChatChainFragments) {
- if (!existsSync(path.join(root, file))) {
- fail(`Chat chain change fragment was removed instead of added/updated: ${file}`)
- continue
- }
- const fragment = await readText(file)
- for (const marker of ['date:', 'feature:', 'impact:']) {
- if (!fragment.includes(marker)) {
- fail(`Chat chain change fragment ${file} must include frontmatter field: ${marker}`)
- }
- }
- if (!fragment.includes('pr:') && !fragment.includes('commit:')) {
- fail(`Chat chain change fragment ${file} must include either pr: or commit:`)
- }
- }
- const desktopReleaseWorkflow = await readText('.github/workflows/desktop-release.yml')
- const desktopManualBuildWorkflow = await readText('.github/workflows/desktop-manual-build.yml')
- const desktopMacUpdateManifestWorkflow = await readText('.github/workflows/desktop-mac-update-manifest.yml')
- const desktopRuntimeWorkflow = await readText('.github/workflows/desktop-runtime.yml')
- const webuiReleaseWorkflow = await readText('.github/workflows/webui-release.yml')
- const dockerPublishWorkflow = await readText('.github/workflows/docker-publish.yml')
- const electronBuilderConfig = await readText('packages/desktop/electron-builder.yml')
- const desktopMacEntitlements = await readText('packages/desktop/build/entitlements.mac.plist')
- const desktopMacInheritedEntitlements = await readText('packages/desktop/build/entitlements.mac.inherit.plist')
- const desktopPackageJson = await readText('packages/desktop/package.json')
- const desktopInstallHermes = await readText('packages/desktop/scripts/install-hermes.mjs')
- const desktopHermesPatches = await readText('packages/desktop/scripts/apply-hermes-patches.mjs')
- const desktopWebuiServer = await readText('packages/desktop/src/main/webui-server.ts')
- const desktopMain = await readText('packages/desktop/src/main/index.ts')
- const desktopUpdater = await readText('packages/desktop/src/main/updater.ts')
- const desktopInstallerScript = await readText('packages/desktop/build/installer.nsh')
- const desktopRuntimeManager = await readText('packages/desktop/src/main/runtime-manager.ts')
- const desktopPaths = await readText('packages/desktop/src/main/paths.ts')
- const desktopRuntimeAssetName = await readText('packages/desktop/scripts/runtime-asset-name.mjs')
- if (!desktopReleaseWorkflow.includes('files: ${{ matrix.artifact_files }}')) {
- fail('desktop-release.yml must upload matrix-specific artifact_files')
- }
- if (desktopReleaseWorkflow.includes('types: [published]')) {
- fail('desktop-release.yml must not run full desktop packaging on every published GitHub Release')
- }
- if (!desktopReleaseWorkflow.includes('gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --latest')) {
- fail('desktop-release.yml must mark successful full desktop releases as GitHub latest')
- }
- for (const [file, text] of [
- ['webui-release.yml', webuiReleaseWorkflow],
- ['docker-publish.yml', dockerPublishWorkflow],
- ]) {
- if (!text.includes('release:') || !text.includes('types: [published]')) {
- fail(`${file} must keep running on published GitHub Releases`)
- }
- if (!text.includes('gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --latest=false')) {
- fail(`${file} must keep published GitHub Releases out of latest`)
- }
- }
- if (!webuiReleaseWorkflow.includes('make_latest: false')) {
- fail('webui-release.yml must not mark release uploads as GitHub latest')
- }
- if (!electronBuilderConfig.includes('icon: build/icons')) {
- fail('electron-builder.yml must configure the Linux icon set')
- }
- for (const entitlementFile of ['build/entitlements.mac.plist', 'build/entitlements.mac.inherit.plist']) {
- if (!electronBuilderConfig.includes(entitlementFile)) {
- fail(`electron-builder.yml must configure ${entitlementFile}`)
- }
- }
- for (const [file, text] of [
- ['entitlements.mac.plist', desktopMacEntitlements],
- ['entitlements.mac.inherit.plist', desktopMacInheritedEntitlements],
- ]) {
- if (!text.includes('<key>com.apple.security.device.audio-input</key>')) {
- fail(`${file} must allow microphone audio input`)
- }
- }
- for (const target of ['target_os: darwin', 'target_os: win32', 'target_os: linux']) {
- if (!desktopReleaseWorkflow.includes(target)) {
- fail(`desktop-release.yml is missing matrix target ${target}`)
- }
- }
- for (const expectedGlob of ['*.dmg', '*.exe', '*.AppImage']) {
- if (!desktopReleaseWorkflow.includes(expectedGlob)) {
- fail(`desktop-release.yml is missing expected artifact glob ${expectedGlob}`)
- }
- }
- if (!desktopReleaseWorkflow.includes('fail_on_unmatched_files: true')) {
- fail('desktop-release.yml must keep fail_on_unmatched_files: true')
- }
- function workflowCaseBody(text, caseLabel) {
- const start = text.indexOf(`${caseLabel})`)
- if (start < 0) fail(`desktop-manual-build.yml is missing ${caseLabel} case`)
- const end = text.indexOf(';;', start)
- if (end < 0) fail(`desktop-manual-build.yml ${caseLabel} case is missing terminator`)
- return text.slice(start, end)
- }
- for (const macCase of ['darwin-arm64', 'darwin-x64']) {
- const body = workflowCaseBody(desktopManualBuildWorkflow, macCase)
- if (body.includes('latest*.yml')) {
- fail(`desktop-manual-build.yml must not publish single-arch macOS update manifests from ${macCase}`)
- }
- for (const glob of ['*.dmg.blockmap', '*.zip.blockmap']) {
- if (!body.includes(glob)) {
- fail(`desktop-manual-build.yml ${macCase} must keep uploading ${glob}`)
- }
- }
- }
- for (const phrase of [
- 'mac-update-manifest:',
- "if: needs.validate.outputs.target_os == 'darwin' && github.event.inputs.release_tag != ''",
- 'Both macOS architectures are not available yet; leaving latest-mac.yml unchanged.',
- 'gh release upload "$TAG" /tmp/latest-mac.yml',
- ]) {
- if (!desktopManualBuildWorkflow.includes(phrase)) {
- fail(`desktop-manual-build.yml must include macOS manifest repair behavior: ${phrase}`)
- }
- }
- if (!desktopMacUpdateManifestWorkflow.includes('Repair macOS Update Manifest')) {
- fail('desktop-mac-update-manifest.yml must provide a manual macOS manifest repair workflow')
- }
- if (!desktopMacUpdateManifestWorkflow.includes("gh release download \"$TAG\"") || !desktopMacUpdateManifestWorkflow.includes('/tmp/latest-mac.yml')) {
- fail('desktop-mac-update-manifest.yml must generate latest-mac.yml from release assets')
- }
- if (!desktopMacUpdateManifestWorkflow.includes('gh release upload "$TAG" /tmp/latest-mac.yml')) {
- fail('desktop-mac-update-manifest.yml must upload the merged latest-mac.yml to the release')
- }
- for (const phrase of [
- 'resources/python/${os}-${arch}',
- 'resources/node/${os}-${arch}',
- 'resources/git/${os}-${arch}',
- ]) {
- if (electronBuilderConfig.includes(phrase)) {
- fail(`electron-builder.yml must not bundle desktop runtime resource: ${phrase}`)
- }
- }
- for (const phrase of [
- '"fetch:node"',
- '"fetch:git"',
- '"prepare:runtime"',
- '"package:runtime"',
- '"runtime:asset-name"',
- ]) {
- if (!desktopPackageJson.includes(phrase)) {
- fail(`packages/desktop/package.json must support runtime package publishing: ${phrase}`)
- }
- }
- for (const phrase of [
- 'steps.check.outputs.missing',
- 'npm --prefix packages/desktop run prepare:runtime',
- 'npm --prefix packages/desktop run package:runtime',
- ]) {
- if (!desktopRuntimeWorkflow.includes(phrase)) {
- fail(`desktop-runtime.yml must build and publish missing runtime package assets: ${phrase}`)
- }
- }
- if (!desktopRuntimeAssetName.includes('hermes-runtime-hermes-agent-')) {
- fail('runtime asset naming must include hermes-agent version')
- }
- for (const phrase of [
- 'websockets',
- 'agent-browser@^0.26.0',
- 'HERMES_CHROME_FOR_TESTING_VERSION',
- '149.0.7827.55',
- 'pinChromeForTestingBundle',
- 'chromeForTestingPlatform',
- 'AGENT_BROWSER_HOME',
- 'AGENT_BROWSER_EXECUTABLE_PATH',
- 'PLAYWRIGHT_BROWSERS_PATH',
- 'ms-playwright',
- 'removeBrokenDashboardAuthPlugin',
- ]) {
- if (!desktopInstallHermes.includes(phrase)) {
- fail(`install-hermes.mjs must bundle Hermes browser runtime support: ${phrase}`)
- }
- }
- for (const phrase of [
- 'from pathlib import Path',
- 'browser stdout decode fallback is incomplete',
- 'def _hermes_read_browser_output',
- 'dingtalk AI Card webhook patches are incomplete',
- "plugins', 'platforms', 'dingtalk', 'adapter.py",
- "gateway', 'platforms', 'dingtalk.py",
- 'sitecustomize hidden subprocess patch marker exists',
- 'python compile check',
- ]) {
- if (!desktopHermesPatches.includes(phrase)) {
- fail(`apply-hermes-patches.mjs must keep browser stdout fallback complete: ${phrase}`)
- }
- }
- for (const phrase of [
- 'bundledAgentBrowserHome',
- 'AGENT_BROWSER_HOME',
- 'bundledNodeBin',
- 'HERMES_AGENT_NODE',
- 'HERMES_AGENT_GIT',
- 'PLAYWRIGHT_BROWSERS_PATH',
- 'ms-playwright',
- ]) {
- if (!desktopWebuiServer.includes(phrase)) {
- fail(`desktop webui server must expose bundled browser runtime: ${phrase}`)
- }
- }
- if (desktopWebuiServer.includes('bundledBrowserExecutable()')) {
- fail('desktop webui server must let agent-browser resolve the bundled browser from AGENT_BROWSER_HOME')
- }
- for (const phrase of [
- 'requestSingleInstanceLock(QUIT_EXISTING ? { quit: true } : undefined)',
- 'hasQuitRequest(additionalData)',
- ]) {
- if (!desktopMain.includes(phrase)) {
- fail(`desktop main process must forward --quit to an existing app instance: ${phrase}`)
- }
- }
- for (const phrase of [
- 'HERMES_STUDIO_EXE',
- 'Get-CimInstance Win32_Process',
- 'CloseMainWindow()',
- 'Stop-Process -Id',
- ]) {
- if (!desktopInstallerScript.includes(phrase)) {
- fail(`desktop installer must close stale Hermes Studio processes by installed executable path: ${phrase}`)
- }
- }
- for (const phrase of [
- 'https://download.ekkolearnai.com/latest',
- 'https://github.com/EKKOLearnAI/hermes-studio/releases/latest/download',
- 'checkForUpdatesWithFallback()',
- ]) {
- if (!desktopUpdater.includes(phrase)) {
- fail(`desktop updater must check Cloudflare first and keep GitHub as fallback: ${phrase}`)
- }
- }
- if (desktopUpdater.includes('fetch(')) {
- fail('desktop updater must not make custom fetch requests to resolve the latest release tag')
- }
- for (const phrase of [
- 'HERMES_DESKTOP_RUNTIME_URL',
- 'HERMES_DESKTOP_RUNTIME_BASE_URL',
- 'runtime-manifest.json',
- ]) {
- if (!desktopRuntimeManager.includes(phrase)) {
- fail(`desktop runtime manager must support downloadable runtime packages: ${phrase}`)
- }
- }
- if (!desktopPaths.includes('HERMES_DESKTOP_RUNTIME_DIR')) {
- fail('desktop paths must allow HERMES_DESKTOP_RUNTIME_DIR override')
- }
- if (failures.length > 0) {
- console.error('Harness check failed:')
- for (const failure of failures) {
- console.error(`- ${failure}`)
- }
- process.exit(1)
- }
- console.log('Harness check passed')
|