Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
import { execFileSync } from 'child_process'
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
|
|
const state = vi.hoisted(() => ({
|
|
db: null as DatabaseSync | null,
|
|
appHome: '',
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/db/index', () => ({
|
|
getDb: () => state.db,
|
|
isSqliteAvailable: () => Boolean(state.db),
|
|
jsonDelete: vi.fn(),
|
|
jsonGet: vi.fn(),
|
|
jsonGetAll: vi.fn(() => ({})),
|
|
jsonSet: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/config', () => ({
|
|
config: {
|
|
appHome: state.appHome,
|
|
},
|
|
}))
|
|
|
|
function git(cwd: string, args: string[]): void {
|
|
execFileSync('git', args, { cwd, stdio: 'ignore' })
|
|
}
|
|
|
|
describe('workspace diff tracker', () => {
|
|
let root: string
|
|
let repo: string
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules()
|
|
root = mkdtempSync(join(tmpdir(), 'hermes-workspace-diff-'))
|
|
state.appHome = join(root, 'home')
|
|
state.db = new DatabaseSync(join(root, 'diffs.db'))
|
|
const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas')
|
|
initAllHermesTables()
|
|
|
|
repo = join(root, 'repo')
|
|
mkdirSync(repo)
|
|
git(repo, ['init'])
|
|
git(repo, ['config', 'user.email', 'test@example.com'])
|
|
git(repo, ['config', 'user.name', 'Test User'])
|
|
writeFileSync(join(repo, 'dirty.txt'), 'committed\n')
|
|
writeFileSync(join(repo, 'changed.txt'), 'old\n')
|
|
git(repo, ['add', '.'])
|
|
git(repo, ['commit', '-m', 'initial'])
|
|
})
|
|
|
|
afterEach(() => {
|
|
state.db?.close()
|
|
state.db = null
|
|
rmSync(root, { recursive: true, force: true })
|
|
})
|
|
|
|
it('records only files changed during the run when the repo was already dirty', async () => {
|
|
const {
|
|
completeWorkspaceRunCheckpoint,
|
|
startWorkspaceRunCheckpoint,
|
|
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
|
|
|
|
writeFileSync(join(repo, 'dirty.txt'), 'preexisting dirty change\n')
|
|
startWorkspaceRunCheckpoint({
|
|
sessionId: 'session-1',
|
|
runId: 'run-1',
|
|
workspace: repo,
|
|
})
|
|
|
|
writeFileSync(join(repo, 'changed.txt'), 'new\n')
|
|
const change = completeWorkspaceRunCheckpoint({
|
|
sessionId: 'session-1',
|
|
runId: 'run-1',
|
|
workspace: repo,
|
|
})
|
|
|
|
expect(change).not.toBeNull()
|
|
expect(change?.change_id).toMatch(/^run:run-1:/)
|
|
expect(change?.files.map(file => file.path)).toEqual(['changed.txt'])
|
|
expect(change?.files[0]).toMatchObject({
|
|
change_type: 'modified',
|
|
additions: 1,
|
|
deletions: 1,
|
|
binary: false,
|
|
})
|
|
expect(change?.files[0].patch).toBeUndefined()
|
|
|
|
const { getWorkspaceRunChangeFile, listWorkspaceRunChangesForSession } = await import('../../packages/server/src/db/hermes/workspace-run-changes-store')
|
|
const detail = getWorkspaceRunChangeFile('session-1', change!.change_id, change!.files[0].id)
|
|
expect(detail?.patch).toContain('-old')
|
|
expect(detail?.patch).toContain('+new')
|
|
|
|
startWorkspaceRunCheckpoint({
|
|
sessionId: 'session-1',
|
|
runId: 'run-1',
|
|
workspace: repo,
|
|
})
|
|
writeFileSync(join(repo, 'changed.txt'), 'newer\n')
|
|
const secondChange = completeWorkspaceRunCheckpoint({
|
|
sessionId: 'session-1',
|
|
runId: 'run-1',
|
|
workspace: repo,
|
|
})
|
|
|
|
expect(secondChange).not.toBeNull()
|
|
expect(secondChange?.change_id).toMatch(/^run:run-1:/)
|
|
expect(secondChange?.change_id).not.toBe(change?.change_id)
|
|
|
|
const savedChanges = listWorkspaceRunChangesForSession('session-1')
|
|
expect(savedChanges).toHaveLength(2)
|
|
expect(savedChanges.map(saved => saved.change_id)).toEqual(expect.arrayContaining([
|
|
change!.change_id,
|
|
secondChange!.change_id,
|
|
]))
|
|
})
|
|
|
|
it('records added, modified, and deleted files in non-git workspaces', async () => {
|
|
const {
|
|
completeWorkspaceRunCheckpoint,
|
|
startWorkspaceRunCheckpoint,
|
|
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
|
|
|
|
const workspace = join(root, 'plain-workspace')
|
|
mkdirSync(workspace)
|
|
writeFileSync(join(workspace, 'deleted.txt'), 'remove me\n')
|
|
writeFileSync(join(workspace, 'old.txt'), 'old\n')
|
|
writeFileSync(join(workspace, 'unchanged.txt'), 'same\n')
|
|
|
|
startWorkspaceRunCheckpoint({
|
|
sessionId: 'session-plain',
|
|
runId: 'run-plain',
|
|
workspace,
|
|
})
|
|
|
|
rmSync(join(workspace, 'deleted.txt'))
|
|
writeFileSync(join(workspace, 'added.txt'), 'added\n')
|
|
writeFileSync(join(workspace, 'old.txt'), 'new\n')
|
|
const change = completeWorkspaceRunCheckpoint({
|
|
sessionId: 'session-plain',
|
|
runId: 'run-plain',
|
|
workspace,
|
|
})
|
|
|
|
expect(change).not.toBeNull()
|
|
expect(change?.workspace_kind).toBe('filesystem')
|
|
expect(change?.files.map(file => file.path)).toEqual(['added.txt', 'deleted.txt', 'old.txt'])
|
|
expect(change?.files.map(file => [file.path, file.change_type])).toEqual([
|
|
['added.txt', 'added'],
|
|
['deleted.txt', 'deleted'],
|
|
['old.txt', 'modified'],
|
|
])
|
|
|
|
const { getWorkspaceRunChangeFile } = await import('../../packages/server/src/db/hermes/workspace-run-changes-store')
|
|
const modified = change!.files.find(file => file.path === 'old.txt')!
|
|
const detail = getWorkspaceRunChangeFile('session-plain', change!.change_id, modified.id)
|
|
expect(detail?.patch).toContain('-old')
|
|
expect(detail?.patch).toContain('+new')
|
|
})
|
|
|
|
it('skips empty zero-byte file changes in non-git workspaces', async () => {
|
|
const {
|
|
completeWorkspaceRunCheckpoint,
|
|
startWorkspaceRunCheckpoint,
|
|
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
|
|
|
|
const workspace = join(root, 'plain-empty-file')
|
|
mkdirSync(workspace)
|
|
|
|
startWorkspaceRunCheckpoint({
|
|
sessionId: 'session-empty',
|
|
runId: 'run-empty',
|
|
workspace,
|
|
})
|
|
|
|
writeFileSync(join(workspace, 'empty.txt'), '')
|
|
const emptyOnlyChange = completeWorkspaceRunCheckpoint({
|
|
sessionId: 'session-empty',
|
|
runId: 'run-empty',
|
|
workspace,
|
|
})
|
|
|
|
expect(emptyOnlyChange).toBeNull()
|
|
|
|
startWorkspaceRunCheckpoint({
|
|
sessionId: 'session-empty',
|
|
runId: 'run-non-empty',
|
|
workspace,
|
|
})
|
|
|
|
writeFileSync(join(workspace, 'non-empty.txt'), 'content\n')
|
|
const nonEmptyChange = completeWorkspaceRunCheckpoint({
|
|
sessionId: 'session-empty',
|
|
runId: 'run-non-empty',
|
|
workspace,
|
|
})
|
|
|
|
expect(nonEmptyChange).not.toBeNull()
|
|
expect(nonEmptyChange?.files.map(file => file.path)).toEqual(['non-empty.txt'])
|
|
})
|
|
|
|
it('does not save zero-line diffs that are not covered by filename filters', async () => {
|
|
const {
|
|
completeWorkspaceRunCheckpoint,
|
|
startWorkspaceRunCheckpoint,
|
|
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
|
|
|
|
const workspace = join(root, 'plain-zero-line-diff')
|
|
mkdirSync(workspace)
|
|
writeFileSync(join(workspace, '.global-cache'), Buffer.from([0, 1, 2, 3]))
|
|
|
|
startWorkspaceRunCheckpoint({
|
|
sessionId: 'session-zero-line',
|
|
runId: 'run-zero-line',
|
|
workspace,
|
|
})
|
|
|
|
writeFileSync(join(workspace, '.global-cache'), Buffer.from([0, 4, 5, 6]))
|
|
writeFileSync(join(workspace, 'notes.md'), 'visible change\n')
|
|
const change = completeWorkspaceRunCheckpoint({
|
|
sessionId: 'session-zero-line',
|
|
runId: 'run-zero-line',
|
|
workspace,
|
|
})
|
|
|
|
expect(change).not.toBeNull()
|
|
expect(change?.files.map(file => file.path)).toEqual(['notes.md'])
|
|
expect(state.db?.prepare('SELECT COUNT(*) AS count FROM workspace_run_change_files WHERE additions = 0 AND deletions = 0').get()).toEqual({ count: 0 })
|
|
})
|
|
|
|
it('skips SQLite WAL and SHM sidecar files in non-git workspaces', async () => {
|
|
const {
|
|
completeWorkspaceRunCheckpoint,
|
|
startWorkspaceRunCheckpoint,
|
|
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
|
|
|
|
const workspace = join(root, 'plain-sqlite-sidecars')
|
|
mkdirSync(workspace)
|
|
|
|
startWorkspaceRunCheckpoint({
|
|
sessionId: 'session-sqlite-sidecars',
|
|
runId: 'run-sqlite-sidecars',
|
|
workspace,
|
|
})
|
|
|
|
writeFileSync(join(workspace, 'state.db-wal'), Buffer.alloc(32 * 1024, 0))
|
|
writeFileSync(join(workspace, 'state.db-shm'), Buffer.alloc(32 * 1024, 0))
|
|
writeFileSync(join(workspace, 'cache.sqlite-wal'), Buffer.alloc(32 * 1024, 0))
|
|
writeFileSync(join(workspace, 'cache.sqlite-shm'), Buffer.alloc(32 * 1024, 0))
|
|
writeFileSync(join(workspace, 'notes.md'), 'visible change\n')
|
|
|
|
const change = completeWorkspaceRunCheckpoint({
|
|
sessionId: 'session-sqlite-sidecars',
|
|
runId: 'run-sqlite-sidecars',
|
|
workspace,
|
|
})
|
|
|
|
expect(change).not.toBeNull()
|
|
expect(change?.files.map(file => file.path)).toEqual(['notes.md'])
|
|
})
|
|
|
|
it('skips SQLite WAL and SHM sidecar files in git workspaces', async () => {
|
|
const {
|
|
completeWorkspaceRunCheckpoint,
|
|
startWorkspaceRunCheckpoint,
|
|
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
|
|
|
|
startWorkspaceRunCheckpoint({
|
|
sessionId: 'session-git-sqlite-sidecars',
|
|
runId: 'run-git-sqlite-sidecars',
|
|
workspace: repo,
|
|
})
|
|
|
|
writeFileSync(join(repo, 'state.db-wal'), Buffer.alloc(32 * 1024, 0))
|
|
writeFileSync(join(repo, 'state.db-shm'), Buffer.alloc(32 * 1024, 0))
|
|
writeFileSync(join(repo, 'cache.sqlite-wal'), Buffer.alloc(32 * 1024, 0))
|
|
writeFileSync(join(repo, 'cache.sqlite-shm'), Buffer.alloc(32 * 1024, 0))
|
|
writeFileSync(join(repo, 'notes.md'), 'visible change\n')
|
|
|
|
const change = completeWorkspaceRunCheckpoint({
|
|
sessionId: 'session-git-sqlite-sidecars',
|
|
runId: 'run-git-sqlite-sidecars',
|
|
workspace: repo,
|
|
})
|
|
|
|
expect(change).not.toBeNull()
|
|
expect(change?.files.map(file => file.path)).toEqual(['notes.md'])
|
|
})
|
|
|
|
it('records newly created ordinary files even when many unchanged files already exist in non-git workspaces', async () => {
|
|
const {
|
|
completeWorkspaceRunCheckpoint,
|
|
startWorkspaceRunCheckpoint,
|
|
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
|
|
|
|
const workspace = join(root, 'plain-many-existing-files')
|
|
mkdirSync(workspace)
|
|
for (let index = 0; index < 120; index += 1) {
|
|
writeFileSync(join(workspace, `existing-${index.toString().padStart(3, '0')}.txt`), 'unchanged\n')
|
|
}
|
|
|
|
startWorkspaceRunCheckpoint({
|
|
sessionId: 'session-many-existing-files',
|
|
runId: 'run-many-existing-files',
|
|
workspace,
|
|
})
|
|
|
|
writeFileSync(join(workspace, 'README-visible.md'), '# visible new file\n')
|
|
writeFileSync(join(workspace, 'notes-visible.txt'), 'plain text visible\n')
|
|
writeFileSync(join(workspace, 'config-visible.json'), '{"visible": true}\n')
|
|
writeFileSync(join(workspace, 'state.db-wal'), Buffer.alloc(32 * 1024, 0))
|
|
writeFileSync(join(workspace, 'state.db-shm'), Buffer.alloc(32 * 1024, 0))
|
|
writeFileSync(join(workspace, 'cache.sqlite-wal'), Buffer.alloc(32 * 1024, 0))
|
|
writeFileSync(join(workspace, 'cache.sqlite-shm'), Buffer.alloc(32 * 1024, 0))
|
|
|
|
const change = completeWorkspaceRunCheckpoint({
|
|
sessionId: 'session-many-existing-files',
|
|
runId: 'run-many-existing-files',
|
|
workspace,
|
|
})
|
|
|
|
expect(change).not.toBeNull()
|
|
expect(change?.files).toHaveLength(3)
|
|
expect(change?.files.map(file => file.path)).toEqual(expect.arrayContaining([
|
|
'README-visible.md',
|
|
'config-visible.json',
|
|
'notes-visible.txt',
|
|
]))
|
|
})
|
|
|
|
it('skips common language dependency and build directories in non-git workspaces', async () => {
|
|
const {
|
|
completeWorkspaceRunCheckpoint,
|
|
startWorkspaceRunCheckpoint,
|
|
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
|
|
|
|
const workspace = join(root, 'plain-with-language-artifacts')
|
|
mkdirSync(join(workspace, 'src'), { recursive: true })
|
|
mkdirSync(join(workspace, 'node_modules'), { recursive: true })
|
|
mkdirSync(join(workspace, '__pycache__'), { recursive: true })
|
|
mkdirSync(join(workspace, 'target'), { recursive: true })
|
|
mkdirSync(join(workspace, 'vendor'), { recursive: true })
|
|
mkdirSync(join(workspace, '.terraform'), { recursive: true })
|
|
writeFileSync(join(workspace, 'src', 'app.py'), 'old\n')
|
|
writeFileSync(join(workspace, 'node_modules', 'ignored.js'), 'before\n')
|
|
writeFileSync(join(workspace, '__pycache__', 'ignored.pyc'), 'before\n')
|
|
writeFileSync(join(workspace, 'target', 'ignored.class'), 'before\n')
|
|
writeFileSync(join(workspace, 'vendor', 'ignored.php'), 'before\n')
|
|
writeFileSync(join(workspace, '.terraform', 'ignored.tfstate'), 'before\n')
|
|
|
|
startWorkspaceRunCheckpoint({
|
|
sessionId: 'session-ignore',
|
|
runId: 'run-ignore',
|
|
workspace,
|
|
})
|
|
|
|
writeFileSync(join(workspace, 'src', 'app.py'), 'new\n')
|
|
writeFileSync(join(workspace, 'node_modules', 'ignored.js'), 'after\n')
|
|
writeFileSync(join(workspace, '__pycache__', 'ignored.pyc'), 'after\n')
|
|
writeFileSync(join(workspace, 'target', 'ignored.class'), 'after\n')
|
|
writeFileSync(join(workspace, 'vendor', 'ignored.php'), 'after\n')
|
|
writeFileSync(join(workspace, '.terraform', 'ignored.tfstate'), 'after\n')
|
|
const change = completeWorkspaceRunCheckpoint({
|
|
sessionId: 'session-ignore',
|
|
runId: 'run-ignore',
|
|
workspace,
|
|
})
|
|
|
|
expect(change).not.toBeNull()
|
|
expect(change?.files.map(file => file.path)).toEqual(['src/app.py'])
|
|
})
|
|
})
|