Files
hermes-ui/scripts/harness-check.mjs
T
toor 8c1ddf415f Phase 003: remove the coding-agent and ekko-agent runtimes
Every chat session now runs through the Hermes bridge, so the three chat
chains become feature-equivalent instead of three partially-maintained
dispatch paths. Keeping the coding-agent and in-house agent runtimes alive
meant every bridge change had to be mirrored into two more runtimes that
had already drifted.

Historical rows are read, not migrated: sessions stored with the retired
source normalize to cli on load, workflow graphs naming the retired agents
keep loading and run on the bridge, and the visible-source check plus
SOURCE_LABELS still recognize the old value so those sessions stay listed.

The public surface follows the code: the OpenAPI generator and the MCP
chat-run tool schema drop the six retired entry fields so external callers
are not told about runtimes that no longer exist.

harness-check gains a RETIRED_RUNTIME scan so the residual check is a
mechanical gate instead of a manual grep, with the two surviving read-side
compat sites recorded with reasons.

Refs: docs/phases/phase_003.md
2026-10-16 13:27:50 +08:00

435 lines
14 KiB
JavaScript

#!/usr/bin/env node
import { readFile, readdir } 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/skills',
'tests/client',
'tests/server',
'tests/e2e',
'.github/workflows',
'docs/chat-chain-changes',
]) {
requireDir(dir)
}
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/skills',
'HERMES_WEB_UI_HOME',
'agent-bridge/python/',
]) {
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:`)
}
}
// --- Legacy brand scan -------------------------------------------------
// The project was renamed from ekko / hermes-web-ui to hermes-ui. A few
// legacy identifiers are deliberately preserved so existing deployments keep
// working; see ALLOWED_LEGACY_BRAND below for the reason each one survives.
const LEGACY_BRAND_PATTERNS = [/\bekko\b/i, /hermes-web-ui/i]
const SCAN_ROOTS = [
'packages/client/src',
'packages/server/src',
'packages/skills',
'tests',
'scripts',
'bin',
'.github',
]
const SCAN_FILES = [
'package.json',
'Dockerfile',
'docker-compose.yml',
'AGENTS.md',
'ARCHITECTURE.md',
'DEVELOPMENT.md',
'README.md',
'README_zh.md',
]
const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.vue', '.json', '.yml', '.yaml', '.md', '.py'])
const SCAN_SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'reference_code', '__pycache__'])
// This file necessarily spells out the patterns and allow-list entries it checks.
const SCAN_SKIP_FILES = new Set(['scripts/harness-check.mjs'])
// Every entry must state why the legacy string is still allowed.
const ALLOWED_LEGACY_BRAND = [
{
file: 'packages/server/src/db/index.ts',
reason: 'DB filenames predate the rename; renamed files would orphan data in ~/.hermes-web-ui',
},
{
file: 'bin/hermes-ui.mjs',
reason: 'CLI reads the same pre-rename DB filename as packages/server/src/db/index.ts',
},
{
file: 'packages/server/src/config.ts',
reason: 'HERMES_WEB_UI_HOME / ~/.hermes-web-ui state directory is a preserved public contract',
},
{
file: 'packages/server/src/middleware/user-auth.ts',
reason: 'legacy JWT audience must stay accepted so upgrades do not force re-login',
},
{
file: 'packages/server/src/services/hermes/skill-injector.ts',
reason: 'legacy manifest owner must stay recognized so managed skills are not re-injected',
},
{
file: 'packages/server/src/services/hermes/studio-mcp-autoinject.ts',
reason: 'legacy MCP server/command names are detected to clean up old on-disk config',
},
{
file: 'tests/server/studio-mcp-autoinject.test.ts',
reason: 'asserts legacy MCP entries are cleaned from existing on-disk config',
},
{
file: 'tests/client/markdown-rendering.test.ts',
reason: 'fixture URL is arbitrary sample data, not project branding',
},
{
file: 'tests/server/cli-port-detection.test.ts',
reason: 'temp dir names and the preserved hermes-web-ui.db filename',
},
{
file: 'tests/server/agent-bridge-python-concurrency.test.ts',
reason: 'asserts preview socket path derived from the preview endpoint env var',
},
{
file: 'packages/server/src/services/runtime-version-manager.ts',
reason: 'release download host and repo slug are the runtime-version lookup contract; replaced in Phase 008',
},
{
file: 'packages/server/src/controllers/hermes/sessions.ts',
reason: 'the visible-session-source check still lists coding_agent so sessions stored before Phase 003 stay listed',
},
{
file: 'packages/client/src/shared/session-display.ts',
reason: 'SOURCE_LABELS still maps the pre-Phase-003 coding_agent source so stored sessions render a label',
},
]
// Legacy identifiers that stay valid everywhere, because they are public
// deployment contracts rather than project branding.
const GLOBAL_ALLOWED_PATTERNS = [
/HERMES_WEB_UI[A-Z0-9_]*/,
/HERMES_WEBUI_STATE_DIR/,
/\.hermes-web-ui/,
/hermes_brightness/,
/hermes_style/,
/hermes_locale/,
]
async function collectScanFiles(target, out) {
const absolute = path.join(root, target)
const stat = await readdir(absolute, { withFileTypes: true }).catch(() => null)
if (!stat) return
for (const entry of stat) {
const relative = path.join(target, entry.name)
if (entry.isDirectory()) {
if (SCAN_SKIP_DIRS.has(entry.name)) continue
await collectScanFiles(relative, out)
} else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) {
out.push(relative)
}
}
}
function isAllowedLegacyBrand(relativePath, line) {
if (GLOBAL_ALLOWED_PATTERNS.some(pattern => pattern.test(line))) return true
const normalized = relativePath.split(path.sep).join('/')
// An allow-list entry may name a directory, in which case it covers its files.
return ALLOWED_LEGACY_BRAND.some(
entry => normalized === entry.file || normalized.startsWith(`${entry.file}/`),
)
}
const scanFiles = []
for (const target of SCAN_ROOTS) await collectScanFiles(target, scanFiles)
for (const file of SCAN_FILES) {
if (existsSync(path.join(root, file))) scanFiles.push(file)
}
const brandHits = []
for (const relativePath of scanFiles) {
if (SCAN_SKIP_FILES.has(relativePath.split(path.sep).join('/'))) continue
const text = await readFile(path.join(root, relativePath), 'utf8')
const lines = text.split(/\r?\n/)
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]
if (!LEGACY_BRAND_PATTERNS.some(pattern => pattern.test(line))) continue
if (isAllowedLegacyBrand(relativePath, line)) continue
brandHits.push(`${relativePath}:${index + 1}: ${line.trim()}`)
}
}
for (const hit of brandHits) {
fail(`Legacy brand string must be removed (ekko / hermes-web-ui): ${hit}`)
}
// --- Retired runtime scan ----------------------------------------------
// Phase 003 removed the coding-agent and in-house agent runtimes. Every chat
// session now runs through the Hermes bridge. A few identifiers survive only
// so sessions, workflows, and history rows stored before Phase 003 keep
// loading; RETIRED_RUNTIME_ALLOWED records why each one stays.
const RETIRED_RUNTIME_PATTERNS = [
/\bcoding_agent\b/,
/\bcodingAgent[A-Za-z]/,
/\bCodingAgent[A-Za-z]/,
/\bekko-agent\b/,
/\bekkoAgent[A-Za-z]/,
/\bEkkoAgent[A-Za-z]/,
]
const RETIRED_RUNTIME_ALLOWED = [
{
file: 'packages/server/src/controllers/hermes/sessions.ts',
reason: 'isVisibleWebUiSessionSource still accepts the retired source so sessions stored before Phase 003 stay listed',
},
{
file: 'packages/client/src/shared/session-display.ts',
reason: 'SOURCE_LABELS still maps the retired source so stored sessions render a label',
},
]
function isAllowedRetiredRuntime(relativePath, line) {
// Comments explain why the retired identifier still exists; only code hits matter.
const trimmed = line.trim()
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return true
const normalized = relativePath.split(path.sep).join('/')
return RETIRED_RUNTIME_ALLOWED.some(
entry => normalized === entry.file || normalized.startsWith(`${entry.file}/`),
)
}
const retiredHits = []
for (const relativePath of scanFiles) {
const normalized = relativePath.split(path.sep).join('/')
if (SCAN_SKIP_FILES.has(normalized)) continue
if (normalized.startsWith('docs/')) continue
const text = await readFile(path.join(root, relativePath), 'utf8')
const lines = text.split(/\r?\n/)
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]
if (!RETIRED_RUNTIME_PATTERNS.some(pattern => pattern.test(line))) continue
if (isAllowedRetiredRuntime(relativePath, line)) continue
retiredHits.push(`${relativePath}:${index + 1}: ${line.trim()}`)
}
}
for (const hit of retiredHits) {
fail(`Retired runtime identifier must be removed (coding-agent / ekko-agent): ${hit}`)
}
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')