mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-11 19:20:16 +00:00
498 lines
17 KiB
JavaScript
498 lines
17 KiB
JavaScript
#!/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 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 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')
|