From 1024a577601ee10aed62e2b0eed34344414f4d1e Mon Sep 17 00:00:00 2001 From: Victor Garcia <211279486+Steel-tech@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:46:12 -0600 Subject: [PATCH 01/10] feat: add local read-only suite console and QA scenario --- console/README.md | 91 ++++++++++++ console/example.json | 6 + console/model.mjs | 252 +++++++++++++++++++++++++++++++++ console/public/app.js | 123 ++++++++++++++++ console/public/favicon.svg | 1 + console/public/index.html | 18 +++ console/public/style.css | 1 + console/scenarios/console.json | 22 +++ console/server.mjs | 94 ++++++++++++ console/tests/console.test.mjs | 137 ++++++++++++++++++ package.json | 13 ++ 11 files changed, 758 insertions(+) create mode 100644 console/README.md create mode 100644 console/example.json create mode 100644 console/model.mjs create mode 100644 console/public/app.js create mode 100644 console/public/favicon.svg create mode 100644 console/public/index.html create mode 100644 console/public/style.css create mode 100644 console/scenarios/console.json create mode 100644 console/server.mjs create mode 100644 console/tests/console.test.mjs create mode 100644 package.json diff --git a/console/README.md b/console/README.md new file mode 100644 index 0000000..9f598fb --- /dev/null +++ b/console/README.md @@ -0,0 +1,91 @@ +# Herdr Console + +A dependency-free local read-only application, separate from the public suite +website. Node 20+ and Git are required. Run from the suite checkout: + +```sh +npm run console -- --demo +# Or select actual repositories and observation files: +npm run console -- --config /absolute/private/console.json +``` + +Open the exact `http://127.0.0.1:4317` address printed by the server. Use +`--port 4320` if needed. Stop with Ctrl+C. `--check` prints a JSON snapshot and +exits without opening a server. No install/build step or paid service is needed. + +`example.json` selects this checkout. Copy and edit it in a private location. +Paths resolve relative to the configuration file. Select 1–20 Git worktree roots: + +```json +{ + "schemaVersion": 1, + "herdrBinary": "/absolute/path/to/herdr", + "projects": [{ + "id": "app", + "name": "My application", + "path": "/absolute/project", + "swarmManifest": "/private/run-workspace.json", + "conductorStatus": "/private/conductor-status.json", + "guardAudit": "/private/audit.jsonl", + "qaReport": "/private/qa-run/result.json", + "pullRequestStatus": "/private/pr-status.tsv" + }] +} +``` + +All fields except `id`, `name`, `path`, and the top-level version/projects are +optional. Do not commit private configuration, reports, or screenshots. The +Console reads only explicit paths, does not discover runs recursively, and +does not invoke plugin actions. Readiness probes are version commands, not an +installation or authenticated live-workflow check. Source versions come from +the suite's pinned `data/plugins.json`; Conductor requires exactly Herdr 0.7.5. + +## Observation contracts + +- **Git:** current branch, commit, worktree count, and changed-file count. An + unborn repository or unavailable path displays an error, not an empty success. +- **Swarm:** existing run manifest, matched to the configured repository root. + Slot status is recorded metadata, not proof of live agent activity. +- **Conductor:** save JSON from its existing `scripts/stage1-runtime.mjs status` + in the correct Herdr workspace/repository. The Console matches its physical + Git repository key and displays the saved lifecycle and worker statuses. It + never derives authority from a partial journal. Refresh status before acting. +- **Guard:** counts from one configured audit JSONL file. Raw command text, + messages, and matched lines are omitted. Counts do not prove enforcement. +- **Browser QA:** the new Browser runner's `result.json`, schemaVersion 1 and + kind `herdr-browser-qa`. A current result must match clean HEAD and report a + clean, unchanged checkout. Screenshot/diagnostic files stay in the runner's + private output directory; this server does not serve arbitrary artifacts. +- **GitHub handoff:** save Swarm `scripts/harvest-step.sh pr-status ` output + to the configured TSV file. The single `ci_status` JSON record supplies the + PR URL and CI summary. Reported commit must match the configured clean HEAD + for current status. This can intentionally be stale when reviewing a slot + from the base worktree. Refresh through Swarm for new GitHub information. + +Each observed file shows its modification time. Refresh re-reads observations +with a five-second cache, never polls or mutates the plugins. Missing, +unrecognized, oversized, foreign, or malformed inputs show as unavailable. +Observation files must be regular, non-symlink files of at most 2 MiB; one Guard +file may contain at most 10,000 entries. These are cooperative local summaries, +not authenticated attestations. Copy buttons only copy existing commands. + +The server binds only to IPv4 loopback, checks Host/Origin, requires a custom +header for the data endpoint, serves only exact asset routes, and permits only +GET. Do not reverse-proxy or expose it publicly. It trusts the local OS user, +selected executable, configured repositories, and their parent directories. + +## Build and verify + +```sh +npm run build +npm test +# With the demo server above running and herdr-browser checked out beside this repo: +node ../herdr-browser/bin/qa.mjs run --config console/scenarios/console.json \ + --repo . --output /private/new-console-qa-run --json +``` + +The output directory must not exist, its parent must exist, and it must be +outside the tested repository. The saved scenario exercises project filtering, +evidence display, and command controls at desktop and mobile sizes. Demo QA +proves that interface against synthetic observations; real-Git and HTTP tests +cover the data adapter and read-only server boundaries separately. diff --git a/console/example.json b/console/example.json new file mode 100644 index 0000000..d0d4404 --- /dev/null +++ b/console/example.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "projects": [ + { "id": "suite", "name": "Herdr Suite", "path": ".." } + ] +} diff --git a/console/model.mjs b/console/model.mjs new file mode 100644 index 0000000..3ef7612 --- /dev/null +++ b/console/model.mjs @@ -0,0 +1,252 @@ +import { open, realpath, readFile, stat } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { dirname, resolve, isAbsolute } from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { createHash } from 'node:crypto'; + +const exec = promisify(execFile); +const MAX_BYTES = 2 * 1024 * 1024; +const SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/; +const text = (v, max = 180) => typeof v === 'string' ? v.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, max) : ''; +const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); +const count = (v) => Number.isSafeInteger(v) && v >= 0 && v <= 1_000_000 ? v : null; + +export async function boundedRead(path) { + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const stat = await file.stat(); + if (!stat.isFile() || stat.size > MAX_BYTES) throw new Error('Expected a regular file under 2 MiB'); + const buffer = Buffer.alloc(MAX_BYTES + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await file.read(buffer, length, buffer.length - length, null); + if (!bytesRead) break; + length += bytesRead; + } + if (length > MAX_BYTES) throw new Error('File exceeds 2 MiB'); + return { content: buffer.subarray(0, length).toString('utf8'), observedAt: stat.mtime.toISOString() }; + } finally { await file.close(); } +} + +export async function loadConfig(path) { + const base = dirname(resolve(path)); + const config = JSON.parse((await boundedRead(path)).content); + if (!object(config) || config.schemaVersion !== 1 || !Array.isArray(config.projects) || + config.projects.length < 1 || config.projects.length > 20 || + Object.keys(config).some(k => !['schemaVersion', 'projects', 'herdrBinary'].includes(k))) { + throw new Error('Config requires schemaVersion: 1 and 1–20 projects'); + } + const ids = new Set(); + const paths = ['path', 'swarmManifest', 'conductorStatus', 'guardAudit', 'qaReport', 'pullRequestStatus']; + const projects = config.projects.map(p => { + if (!object(p) || !/^[a-z0-9][a-z0-9-]{0,47}$/.test(p.id) || ids.has(p.id) || + typeof p.name !== 'string' || !p.name.trim() || p.name.length > 100 || + typeof p.path !== 'string' || + Object.keys(p).some(k => !['id', 'name', ...paths].includes(k))) { + throw new Error('Each project needs a unique slug id, name, path, and only supported fields'); + } + ids.add(p.id); + const result = { id: p.id, name: text(p.name, 100) }; + for (const key of paths) if (p[key] !== undefined) { + if (typeof p[key] !== 'string' || !p[key] || p[key].length > 4096 || /[\x00-\x1f\x7f]/.test(p[key])) { + throw new Error(`Invalid ${key} path`); + } + result[key] = resolve(base, p[key]); + } + return result; + }); + if (config.herdrBinary !== undefined && (typeof config.herdrBinary !== 'string' || + !isAbsolute(config.herdrBinary) || /[\x00-\x1f\x7f]/.test(config.herdrBinary))) { + throw new Error('herdrBinary must be an absolute executable path'); + } + return { projects, herdrBinary: config.herdrBinary || 'herdr' }; +} + +// Drop inherited Git overrides so a configured project cannot be redirected to another repository. +export function gitEnvironment() { + return { ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_'))), + GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }; +} + +async function command(file, args, options = {}) { + const { stdout } = await exec(file, args, { timeout: 5000, maxBuffer: MAX_BYTES, ...options }); + return stdout; +} + +async function git(path, args) { + return command('git', ['-C', path, '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', ...args], { env: gitEnvironment() }); +} + +export async function inspectGit(path) { + const physical = await realpath(path); + const root = (await git(physical, ['rev-parse', '--show-toplevel'])).trim(); + if (await realpath(root) !== physical) throw new Error('Project path must be a Git worktree root'); + const [head, branch, status, worktrees, common] = await Promise.all([ + git(physical, ['rev-parse', '--verify', 'HEAD']), + git(physical, ['branch', '--show-current']), + git(physical, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']), + git(physical, ['worktree', 'list', '--porcelain', '-z']), + git(physical, ['rev-parse', '--path-format=absolute', '--git-common-dir']) + ]); + const commit = head.trim(); + if (!SHA.test(commit)) throw new Error('Project has no valid HEAD'); + // Rename records contain a second NUL-delimited path; filenames are deliberately not exposed. + const records = status.split('\0'); + let changedFiles = 0; + for (let i = 0; i < records.length; i++) if (records[i]) { + changedFiles++; + if (/^[RC]|^.[RC]/.test(records[i])) i++; + } + const commonPath = await realpath(common.trim()); + const identity = await stat(commonPath, { bigint: true }); + const repositoryKey = createHash('sha256').update(`herdr-conductor-repository-v1\0${commonPath}\0${identity.dev}\0${identity.ino}`).digest('hex'); + return { commit, branch: text(branch.trim() || 'Detached HEAD'), dirty: Boolean(status), changedFiles, + worktrees: worktrees.split('\0').filter(s => s.startsWith('worktree ')).length, root: physical, repositoryKey }; +} + +async function observation(path, parse) { + if (!path) return { status: 'not_configured' }; + try { + const { content, observedAt } = await boundedRead(path); + return { status: 'observed', observedAt, ...await parse(content) }; + } catch { + return { status: 'unavailable', message: 'Cannot read a supported, bounded observation. Check the configured file.' }; + } +} + +export function parseQA(content, current) { + const q = JSON.parse(content); + if (!object(q) || q.schemaVersion !== 1 || q.kind !== 'herdr-browser-qa' || + !['passed', 'failed'].includes(q.status) || !object(q.git) || !SHA.test(q.git.commit) || + typeof q.git.dirty !== 'boolean' || typeof q.git.changedDuringRun !== 'boolean' || + !object(q.summary) || !object(q.scenario?.policy) || + ['failOnConsoleError','failOnPageError','failOnFailedRequest'].some(k => typeof q.scenario.policy[k] !== 'boolean') || + !Array.isArray(q.runs) || q.runs.length > 20 || + !Number.isFinite(Date.parse(q.finishedAt))) throw new Error('Unsupported QA report'); + const summary = Object.fromEntries(['viewports', 'passed', 'failed', 'assertions', 'consoleErrors', 'pageErrors', 'failedRequests'].map(k => [k, count(q.summary[k])])); + if (Object.values(summary).some(n => n === null) || summary.viewports < q.runs.length || + summary.passed + summary.failed !== summary.viewports || summary.viewports < 1) throw new Error('Invalid QA summary'); + const policy = q.scenario.policy; + const strict = policy.failOnConsoleError && policy.failOnPageError && policy.failOnFailedRequest; + const inconsistent = q.status === 'passed' && (summary.failed > 0 || summary.viewports !== q.runs.length || q.cleanup?.status !== 'passed' || + (policy.failOnConsoleError && summary.consoleErrors > 0) || + (policy.failOnPageError && summary.pageErrors > 0) || + (policy.failOnFailedRequest && summary.failedRequests > 0) || q.runs.some(r => r.status !== 'passed')); + if (inconsistent) throw new Error('Contradictory QA report'); + const fresh = current && q.git.commit === current.commit && !q.git.dirty && !q.git.changedDuringRun && !current.dirty; + return { outcome: q.status, commit: q.git.commit, finishedAt: q.finishedAt, + scenario: text(q.scenario?.name), validationPolicy: strict ? 'strict' : 'relaxed', freshness: fresh ? 'matches_clean_head' : 'not_current_clean_head', + summary, artifactCount: q.runs.reduce((n, r) => n + (Array.isArray(r.artifacts) ? r.artifacts.length : 0), 0), + viewports: q.runs.map(r => ({ name: text(r.viewport?.name), status: text(r.status) })) }; +} + +export function parsePullRequest(content, current) { + const lines = content.trim().split('\n'); + const records = lines.filter(line => line.startsWith('ci_status\t')); + if (records.length !== 1) throw new Error('Expected one Swarm CI status record'); + const value = JSON.parse(records[0].slice('ci_status\t'.length)); + if (!object(value) || value.schema_version !== 1 || !SHA.test(value.local_head_sha)) throw new Error('Invalid PR status'); + if (value.status === 'no_pr') return { outcome: 'no_pr' }; + if (!['passed','failed','pending','not_run','unknown'].includes(value.status) || + !SHA.test(value.head_sha) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository) || + !Number.isSafeInteger(value.number) || value.number < 1 || count(value.check_count) === null || + !['OPEN','CLOSED','MERGED'].includes(value.state) || typeof value.draft !== 'boolean') throw new Error('Unsupported PR status'); + const url = `https://github.com/${value.repository}/pull/${value.number}`; + if (value.url !== url) throw new Error('Foreign PR URL'); + return { outcome:value.status, url, number:value.number, repository:value.repository, draft:value.draft, state:value.state, + commit:value.head_sha, checks:value.check_count, + freshness: current && current.commit === value.head_sha && value.local_head_sha === value.head_sha && !current.dirty ? 'matches_clean_head' : 'not_current_clean_head' }; +} + +export async function inspectProject(p) { + const result = { id: p.id, name: p.name, path: p.path }; + try { result.git = await inspectGit(p.path); } + catch { result.error = 'Repository unavailable, uncommitted, or not a worktree root. Check the configured path.'; } + const [swarm, conductor, guard, qa, pullRequest] = await Promise.all([ + observation(p.swarmManifest, async content => { + const run = JSON.parse(content); + if (!object(run) || typeof run.run_id !== 'string' || typeof run.repo_root !== 'string' || + !Array.isArray(run.slots) || run.slots.length > 100 || !result.git || + await realpath(run.repo_root) !== result.git.root) throw new Error('Foreign Swarm run'); + return { run: text(run.run_id), slots: run.slots.map(s => { + if (!object(s) || !['pending','running','failed','settled','merged','skipped','archived'].includes(s.status)) throw new Error('Unsupported slot'); + return { slot: text(String(s.slot)), label: text(s.label), state: s.status, branch: text(s.branch) }; + }) }; + }), + observation(p.conductorStatus, content => { + const run = JSON.parse(content); + if (!object(run) || typeof run.run !== 'string' || typeof run.lifecycle !== 'string' || + !Array.isArray(run.workers) || run.workers.length > 100 || !SHA.test(run.fork_sha) || + !result.git || run.repository_key !== result.git.repositoryKey) throw new Error('Unsupported or foreign Conductor status'); + return { run: text(run.run), lifecycle: text(run.lifecycle), workspace: text(run.workspace_id), + binding: 'operator_selected_snapshot', workers: run.workers.map(w => ({ role: text(w.role), state: text(w.status) })), + nextOperations: Array.isArray(run.legal_next_operations) ? run.legal_next_operations.filter(v => ['status','harvest','preview','apply','stand-down'].includes(v)) : [] }; + }), + observation(p.guardAudit, content => { + const counts = { audit: 0, alert: 0, interrupt: 0, other: 0 }; + const lines = content.split('\n').filter(line => line.trim()); + if (lines.length > 10000) throw new Error('Audit too large'); + for (const line of lines) { + const entry = JSON.parse(line); + if (!object(entry)) throw new Error('Invalid audit entry'); + counts[Object.hasOwn(counts, entry.severity) ? entry.severity : 'other']++; + } + return { events: lines.length, counts }; + }), + observation(p.qaReport, content => parseQA(content, result.git)), + observation(p.pullRequestStatus, content => parsePullRequest(content, result.git)) + ]); + return { ...result, swarm, conductor, guard, qa, pullRequest }; +} + +const parseVersion = (s) => /(?:^|\s|v)(\d+\.\d+\.\d+)(?:\b|$)/.exec(s)?.[1] || null; +export function compatibility(plugin, version) { + if (!version) return 'unknown'; + if (plugin.slug === 'conductor') return version === '0.7.5' ? 'version_match' : 'incompatible'; + const actual = version.split('.').map(Number), minimum = plugin.min_herdr_version.split('.').map(Number); + let comparison = 0; + for (let i = 0; i < 3 && !comparison; i++) comparison = actual[i] - minimum[i]; + if (comparison < 0) return 'incompatible'; + return plugin.tested_herdr_versions.includes(version) ? 'version_match' : 'not_tested'; +} + +export async function health(herdrBinary) { + const probes = [['Node', process.execPath], ['Git', 'git'], ['Herdr', herdrBinary], ['agent-browser', 'agent-browser']]; + const tools = await Promise.all(probes.map(async ([name, binary]) => { + try { + const version = parseVersion(await command(binary, ['--version'])); + return { name, version, status: version ? 'available' : 'unknown' }; + } catch { return { name, version: null, status: 'unavailable' }; } + })); + const evidence = JSON.parse(await readFile(new URL('../data/plugins.json', import.meta.url), 'utf8')); + const herdr = tools.find(t => t.name === 'Herdr').version; + return { tools, selectedHerdr: herdrBinary, evidenceDate: evidence.evidence_date, + plugins: evidence.plugins.map(p => ({ name: p.name, slug: p.slug, version: p.version, + minimum: p.min_herdr_version, compatibility: compatibility(p, herdr), source: p.repository })) }; +} + +export async function snapshot(config) { + const readinessPromise = health(config.herdrBinary); + const projects = []; + // Bound both Git process fan-out and observation buffers across large configurations. + for (let i = 0; i < config.projects.length; i += 4) { + projects.push(...await Promise.all(config.projects.slice(i, i + 4).map(inspectProject))); + } + const readiness = await readinessPromise; + return { schemaVersion: 1, generatedAt: new Date().toISOString(), demo: false, readiness, projects }; +} + +export function demoSnapshot() { + const now = new Date().toISOString(), commit = 'a'.repeat(40); + return { schemaVersion: 1, generatedAt: now, demo: true, + readiness: { selectedHerdr: 'Demo runtime', evidenceDate: '2026-09-14', + tools: ['Node','Git','Herdr','agent-browser'].map(name => ({ name, version: name === 'Herdr' ? '0.7.5' : 'Demo', status: 'available' })), + plugins: [{name:'Browser',version:'0.8.0',compatibility:'version_match'}, {name:'Swarm',version:'0.4.0',compatibility:'version_match'}, {name:'Guard',version:'0.2.0',compatibility:'version_match'}, {name:'Conductor',version:'0.4.0',compatibility:'version_match'}] }, + projects: [{id:'demo',name:'Checkout improvements',path:'/demo/storefront', + git:{commit,branch:'feature/checkout',dirty:false,changedFiles:0,worktrees:3}, + swarm:{status:'observed',observedAt:now,run:'demo-run',slots:[{slot:'1',label:'Builder',state:'settled',branch:'swarm/demo/1'},{slot:'2',label:'Alternative',state:'running',branch:'swarm/demo/2'}]}, + conductor:{status:'not_configured'},pullRequest:{status:'not_configured'},guard:{status:'observed',observedAt:now,events:3,counts:{audit:2,alert:1,interrupt:0,other:0}}, + qa:{status:'observed',observedAt:now,outcome:'passed',commit,finishedAt:now,scenario:'Checkout journey',validationPolicy:'strict',freshness:'matches_clean_head',summary:{viewports:2,passed:2,failed:0,assertions:6,consoleErrors:0,pageErrors:0,failedRequests:0},artifactCount:2,viewports:[{name:'desktop',status:'passed'},{name:'mobile',status:'passed'}]} + }] }; +} diff --git a/console/public/app.js b/console/public/app.js new file mode 100644 index 0000000..f8a2060 --- /dev/null +++ b/console/public/app.js @@ -0,0 +1,123 @@ +const $ = selector => document.querySelector(selector); +let data; +const labels = { version_match:'Version match', not_tested:'Not tested on this version', incompatible:'Version mismatch', unknown:'Unknown', available:'Available', unavailable:'Unavailable', not_configured:'Not connected', matches_clean_head:'Matches clean HEAD', not_current_clean_head:'Not current clean HEAD' }; +function element(tag, value, className) { const e = document.createElement(tag); if (value !== undefined) e.textContent = value; if (className) e.className = className; return e; } +function badge(value, tone = '') { return element('span', labels[value] || value, `badge ${tone}`); } +function time(value) { const d = new Date(value); return Number.isNaN(d.valueOf()) ? 'Unknown time' : d.toLocaleString(); } +function row(parent, label, value) { const line = element('div', undefined, 'detail-row'); line.append(element('span', label, 'muted'), element('span', String(value))); parent.append(line); } +function panel(title, observation) { + const p = element('section', undefined, 'panel'); p.append(element('h3', title)); + if (observation.status !== 'observed') p.append(element('p', observation.message || 'Not connected. Add its observation file to your Console configuration.', 'muted')); + else p.append(element('p', `File updated ${time(observation.observedAt)}`, 'footnote')); + return p; +} +function command(parent, label, value) { + const group = element('div', undefined, 'command'); + const code = element('code', value), button = element('button', 'Copy'); + button.type = 'button'; button.setAttribute('aria-label', `Copy ${label} command`); + button.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(value); button.textContent = 'Copied'; } + catch { button.textContent = 'Select command to copy'; } + }); + group.append(element('p', label), code, button); parent.append(group); +} +function renderHealth() { + const root = $('#health'); root.replaceChildren(); + for (const tool of data.readiness.tools) { + const card = element('div', undefined, 'health-card'); + card.append(element('span', tool.name, 'muted'), element('strong', tool.version || 'Not available'), badge(tool.status, tool.status === 'available' ? 'good' : 'warn')); + root.append(card); + } + const line = element('div', undefined, 'compatibility'); + for (const plugin of data.readiness.plugins) line.append(badge(`${plugin.name} ${plugin.version} · ${labels[plugin.compatibility]}`, plugin.compatibility === 'version_match' ? '' : 'warn')); + root.append(line); + $('#compatibility-note').textContent = `Pinned source versions · evidence ${data.readiness.evidenceDate}. Version matching does not verify installation, agent authentication, or live integration. Selected Herdr: ${data.readiness.selectedHerdr}`; +} +function projectCard(p) { + const card = element('article', undefined, 'project-card'); + const heading = element('div', undefined, 'project-heading'); + const title = element('div'); title.append(element('h3', p.name), element('p', p.path, 'path')); + heading.append(title, badge(p.error ? 'Unavailable' : p.git.dirty ? 'Uncommitted changes' : 'Clean worktree', p.error || p.git.dirty ? 'warn' : 'good')); card.append(heading); + if (p.error) card.append(element('p', p.error, 'error')); + else { + const stats = element('div', undefined, 'stats'); + row(stats, 'Branch', p.git.branch); row(stats, 'Commit', p.git.commit.slice(0, 12)); row(stats, 'Changed files', p.git.changedFiles); row(stats, 'Worktrees', p.git.worktrees); card.append(stats); + } + const details = element('details'); details.open = true; + details.append(element('summary', 'Run status & validation evidence')); + const grid = element('div', undefined, 'evidence-grid'); + const swarm = panel('Swarm', p.swarm); + if (p.swarm.status === 'observed') { + row(swarm, 'Run', p.swarm.run); + for (const slot of p.swarm.slots) row(swarm, `${slot.slot} · ${slot.label || slot.branch}`, slot.state); + swarm.append(element('p', 'Manifest observations; agent activity may have changed.', 'footnote')); + } + const conductor = panel('Conductor', p.conductor); + if (p.conductor.status === 'observed') { + row(conductor, 'Saved lifecycle', p.conductor.lifecycle); row(conductor, 'Run', p.conductor.run); + for (const worker of p.conductor.workers) row(conductor, worker.role, worker.state); + conductor.append(element('p', 'Operator-selected saved status; refresh Conductor status before acting.', 'footnote')); + } + const guard = panel('Guard', p.guard); + if (p.guard.status === 'observed') { + row(guard, 'Recorded events', p.guard.events); + row(guard, 'Alerts / interrupt requests', `${p.guard.counts.alert} / ${p.guard.counts.interrupt}`); + guard.append(element('p', 'Advisory counts. Raw commands and log messages stay out of this view.', 'footnote')); + } + const qa = panel('Browser QA', p.qa); + if (p.qa.status === 'observed') { + qa.append(badge(p.qa.outcome, p.qa.outcome === 'passed' && p.qa.freshness === 'matches_clean_head' && p.qa.validationPolicy === 'strict' ? 'good' : 'warn')); + qa.append(badge(p.qa.freshness, p.qa.freshness === 'matches_clean_head' ? '' : 'warn')); + if (p.qa.validationPolicy !== 'strict') qa.append(badge('Relaxed failure policy', 'warn')); + row(qa, 'Scenario', p.qa.scenario); row(qa, 'Tested commit', p.qa.commit.slice(0, 12)); + row(qa, 'Completed', time(p.qa.finishedAt)); + row(qa, 'Viewports / assertions', `${p.qa.summary.viewports} / ${p.qa.summary.assertions}`); + row(qa, 'Console / page / request errors', `${p.qa.summary.consoleErrors} / ${p.qa.summary.pageErrors} / ${p.qa.summary.failedRequests}`); + row(qa, 'Artifacts retained', p.qa.artifactCount); + for (const view of p.qa.viewports) row(qa, view.name, view.status); + qa.append(element('p', 'Report supplied by the local runner. Screenshots and raw diagnostics remain in its private output directory.', 'footnote')); + } + const pr = panel('GitHub handoff', p.pullRequest); + if (p.pullRequest.status === 'observed') { + if (p.pullRequest.outcome === 'no_pr') pr.append(element('p', 'No matching pull request in this saved observation.', 'muted')); + else { + const link = element('a', `${p.pullRequest.repository} #${p.pullRequest.number}`); + link.href = p.pullRequest.url; link.target = '_blank'; link.rel = 'noopener noreferrer'; pr.append(link); + row(pr, 'PR state', `${p.pullRequest.state}${p.pullRequest.draft ? ' · draft' : ''}`); + row(pr, 'CI / check count', `${p.pullRequest.outcome} / ${p.pullRequest.checks}`); + pr.append(badge(p.pullRequest.freshness, p.pullRequest.freshness === 'matches_clean_head' ? '' : 'warn')); + pr.append(element('p', 'Saved GitHub status. Refresh through Swarm before making a merge decision.', 'footnote')); + } + } + grid.append(swarm, conductor, qa, guard, pr); details.append(grid); card.append(details); + const next = element('details', undefined, 'next-actions'); next.append(element('summary', 'Next steps in your project workspace')); + next.append(element('p', 'Copy a command to the intended Herdr workspace. These controls only copy text; commands retain the plugin’s own previews and approvals.', 'muted')); + command(next, 'Inspect Swarm status', 'herdr plugin action invoke status --plugin structupath.swarm'); + command(next, 'Inspect Conductor status (Herdr 0.7.5)', 'herdr plugin action invoke status --plugin structupath.conductor'); + command(next, 'Review Swarm harvest / GitHub handoff', 'herdr plugin action invoke harvest --plugin structupath.swarm'); + card.append(next); return card; +} +function renderProjects() { + if (!data) return; + const query = $('#filter').value.toLowerCase().trim(); + const projects = data.projects.filter(p => `${p.name} ${p.git?.branch || ''}`.toLowerCase().includes(query)); + $('#projects').replaceChildren(...projects.map(projectCard)); + if (!projects.length) $('#projects').append(element('p', 'No projects match your search.', 'muted')); +} +async function refresh() { + $('#refresh').disabled = true; + $('#notice').textContent = 'Reading project status…'; + try { + const response = await fetch('/api/snapshot', { headers: { 'X-Herdr-Console': '1' }, cache: 'no-store' }); + if (!response.ok) throw new Error('Snapshot unavailable. Check the Console terminal and refresh.'); + data = await response.json(); renderHealth(); renderProjects(); + $('#updated').textContent = `Read ${time(data.generatedAt)}`; + $('#notice').textContent = data.demo ? 'DEMO — illustrative data only. Restart with --config to view your projects.' : `${data.projects.length} configured project${data.projects.length === 1 ? '' : 's'} · observations refresh when you ask.`; + $('#notice').className = data.demo ? 'demo' : ''; + } catch (error) { + $('#notice').textContent = `${error.message} Previously displayed data may be stale.`; $('#notice').className = 'error'; + } finally { $('#refresh').disabled = false; } +} +$('#refresh').addEventListener('click', refresh); +$('#filter').addEventListener('input', renderProjects); +refresh(); diff --git a/console/public/favicon.svg b/console/public/favicon.svg new file mode 100644 index 0000000..a1ec8ef --- /dev/null +++ b/console/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/console/public/index.html b/console/public/index.html new file mode 100644 index 0000000..3802f11 --- /dev/null +++ b/console/public/index.html @@ -0,0 +1,18 @@ + + + + + Herdr Console + + + +
H herdr consoleLOCAL · READ ONLY
+
+

YOUR WORKSPACE

Projects, in view.

Review progress and evidence. Keep each plugin in control of its own work.

+

Reading your configured projects…

+

Environment

+

Projects

+
Local observations are review aids, not approval or authenticated evidence. Refresh reads files; it never starts agents, pushes branches, or changes plugin state.
+
+ + diff --git a/console/public/style.css b/console/public/style.css new file mode 100644 index 0000000..6d4e1b2 --- /dev/null +++ b/console/public/style.css @@ -0,0 +1 @@ +:root{color-scheme:dark;--bg:#0c1116;--panel:#131b23;--line:#29343f;--text:#e9edf0;--muted:#9aaaba;--accent:#f59036;--good:#7bdbb4;--warn:#ffd18b;font:15px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}a{color:inherit}button,input{font:inherit}button{padding:10px 16px;border:1px solid var(--line);border-radius:8px;color:var(--text);background:#1d2a36;cursor:pointer}button:hover{border-color:var(--accent)}button:disabled{opacity:.6;cursor:wait}:focus-visible{outline:2px solid var(--accent);outline-offset:4px}header{min-height:72px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:16px max(24px,calc((100% - 1200px)/2));gap:16px}.brand{display:flex;gap:9px;align-items:center;text-decoration:none;white-space:nowrap}.brand strong{font-weight:500;color:var(--muted)}.mark{display:grid;place-items:center;width:28px;height:28px;background:var(--accent);color:#18212a;border-radius:6px;font-weight:800}.local,.eyebrow{font-size:11px;font-weight:700;letter-spacing:.14em;color:var(--accent)}main{max-width:1248px;margin:auto;padding:40px 24px}h1{font-size:clamp(28px,4vw,42px);line-height:1.2;margin:8px 0 12px;letter-spacing:-.04em}h2{font-size:20px;margin:0}h3{font-size:18px;margin:0 0 8px}p{margin:8px 0}.muted,.footnote,.path{color:var(--muted)}.footnote{font-size:12px;overflow-wrap:anywhere}.path{font:12px/1.6 ui-monospace,monospace;overflow-wrap:anywhere}.page-heading,.section-heading,.project-heading{display:flex;justify-content:space-between;align-items:center;gap:20px}.page-heading{margin-bottom:20px}.page-heading button{flex-shrink:0}#notice{min-height:28px;font-size:13px}.demo,.error{border-left:3px solid var(--accent);background:#241d16;padding:12px;color:var(--warn)}main>section{margin-top:32px}.section-heading{margin-bottom:16px}.health-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.health-card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px;display:flex;align-items:flex-start;flex-direction:column;gap:7px}.health-card strong{font-size:20px;overflow-wrap:anywhere;max-width:100%}.compatibility{grid-column:1/-1;display:flex;gap:8px;flex-wrap:wrap}.badge{display:inline-block;padding:3px 8px;border:1px solid var(--line);background:#18232d;border-radius:5px;font-size:11px;line-height:1.7;color:#cad4de;overflow-wrap:anywhere;max-width:100%}.badge.good{color:var(--good);background:#122b26;border-color:#285344}.badge.warn{color:var(--warn);background:#302619;border-color:#634a2a}.search{display:flex;align-items:center;gap:12px;font-size:12px;color:var(--muted)}input{background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:9px 12px;color:var(--text);max-width:220px;width:100%}.project-card{border:1px solid var(--line);border-radius:12px;background:var(--panel);margin-bottom:20px;padding:24px}.project-heading>div{min-width:0}.project-heading>.badge{flex-shrink:0}.stats{display:grid;grid-template-columns:2fr 1.2fr 1fr 1fr;gap:20px;margin:20px 0}.stats .detail-row{display:flex;flex-direction:column;align-items:flex-start;gap:3px}.detail-row{display:flex;justify-content:space-between;gap:16px;margin:9px 0;font-size:13px}.detail-row>span{overflow-wrap:anywhere;min-width:0}.detail-row>span:last-child{text-align:right}.evidence-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}.panel{border:1px solid var(--line);border-radius:8px;padding:18px;min-width:0}.panel h3{font-size:15px}.panel>.badge{margin:3px 4px 3px 0}summary{cursor:pointer;font-weight:600;font-size:13px;padding:10px 0}details{border-top:1px solid var(--line)}.next-actions{margin-top:20px}.command{position:relative;background:var(--bg);padding:12px;margin-top:10px;border-radius:6px;display:grid;grid-template-columns:1fr auto;gap:8px}.command p{grid-column:1/-1;font-size:12px;color:var(--muted);margin:0}.command code{font-size:12px;overflow-wrap:anywhere;align-self:center;min-width:0}.command button{font-size:12px;padding:6px 12px}footer{font-size:12px;color:var(--muted);border-top:1px solid var(--line);padding-top:24px;margin-top:40px}.skip{position:fixed;left:16px;top:-80px;z-index:2;background:var(--accent);color:#111;padding:12px}.skip:focus{top:12px}@media(max-width:700px){header{padding:16px}.local{font-size:9px}main{padding:28px 16px}.page-heading,.section-heading{align-items:flex-start;flex-direction:column;gap:12px}.health-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.project-card{padding:16px}.project-heading{align-items:flex-start;flex-direction:column;gap:8px}.stats{grid-template-columns:1fr 1fr;gap:12px}.evidence-grid{grid-template-columns:1fr}.search{width:100%}.search input{max-width:none;flex:1;min-width:0}.command{grid-template-columns:1fr}.command button{justify-self:start}} diff --git a/console/scenarios/console.json b/console/scenarios/console.json new file mode 100644 index 0000000..09b1249 --- /dev/null +++ b/console/scenarios/console.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "name": "Console project review", + "baseUrl": "http://127.0.0.1:4317", + "viewports": [ + { "name": "desktop", "width": 1440, "height": 1000 }, + { "name": "mobile", "width": 390, "height": 844 } + ], + "steps": [ + { "type": "navigate", "path": "/" }, + { "type": "waitFor", "selector": ".project-card" }, + { "type": "assertTitle", "contains": "Herdr Console" }, + { "type": "assertText", "selector": "#notice", "contains": "DEMO" }, + { "type": "assertText", "selector": "#projects", "contains": "Checkout improvements" }, + { "type": "fill", "selector": "#filter", "value": "not-a-project" }, + { "type": "assertText", "selector": "#projects", "contains": "No projects match" }, + { "type": "fill", "selector": "#filter", "value": "checkout" }, + { "type": "click", "selector": ".next-actions summary" }, + { "type": "assertVisible", "selector": "button[aria-label='Copy Inspect Swarm status command']" }, + { "type": "screenshot", "name": "project-review" } + ] +} diff --git a/console/server.mjs b/console/server.mjs new file mode 100644 index 0000000..fdc480d --- /dev/null +++ b/console/server.mjs @@ -0,0 +1,94 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import { loadConfig, snapshot, demoSnapshot } from './model.mjs'; + +const ASSETS = new Map([ + ['/', ['index.html', 'text/html; charset=utf-8']], + ['/app.js', ['app.js', 'text/javascript; charset=utf-8']], + ['/favicon.svg', ['favicon.svg', 'image/svg+xml']], + ['/style.css', ['style.css', 'text/css; charset=utf-8']] +]); + +export function createConsoleServer(getSnapshot) { + let pending; + let cached; + let cachedAt = 0; + const server = createServer(async (req, res) => { + const host = `127.0.0.1:${server.address().port}`; + const origin = `http://${host}`; + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); + res.setHeader('Content-Security-Policy', "default-src 'self'; connect-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"); + const send = (status, type, body) => { res.writeHead(status, { 'Content-Type': type }); res.end(body); }; + if (req.headers.host !== host || (req.headers.origin && req.headers.origin !== origin) || + (req.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(req.headers['sec-fetch-site']))) { + return send(403, 'text/plain', 'Use the local Console address printed at startup.'); + } + if (req.method !== 'GET') return send(405, 'text/plain', 'Read-only Console: GET only.'); + // Exact routes only: no filesystem paths or project selections come from a request. + if (req.url === '/api/snapshot') { + if (req.headers['x-herdr-console'] !== '1') return send(403, 'text/plain', 'Open the local Console to read project data.'); + try { + if (!cached || Date.now() - cachedAt >= 5000) { + pending ||= Promise.resolve().then(getSnapshot).then(value => { + cached = value; cachedAt = Date.now(); return value; + }).finally(() => { pending = undefined; }); + await pending; + } + return send(200, 'application/json; charset=utf-8', JSON.stringify(cached)); + } catch { return send(503, 'application/json', JSON.stringify({ error: 'Snapshot unavailable. Check configured tools and files, then refresh.' })); } + } + const asset = ASSETS.get(req.url); + if (!asset) return send(404, 'text/plain', 'Not found'); + try { send(200, asset[1], await readFile(new URL(`public/${asset[0]}`, import.meta.url))); } + catch { send(500, 'text/plain', 'Console asset unavailable'); } + }); + server.requestTimeout = 10000; + server.headersTimeout = 10000; + server.timeout = 30000; + return server; +} + +export function parseArgs(args) { + const result = { port: 4317, demo: false, check: false }; + const seen = new Set(); + for (let i = 0; i < args.length; i++) { + const key = args[i]; + if (seen.has(key)) throw new Error(`Duplicate option ${key}`); + seen.add(key); + if (key === '--help') return { help: true }; + if (key === '--demo' || key === '--check') result[key.slice(2)] = true; + else if (key === '--config' || key === '--port') { + const value = args[++i]; + if (!value || value.startsWith('--')) throw new Error(`${key} requires a value`); + result[key.slice(2)] = key === '--port' ? Number(value) : value; + } else throw new Error(`Unknown option ${key}`); + } + if (!Number.isInteger(result.port) || result.port < 1024 || result.port > 65535) throw new Error('Port must be 1024–65535'); + if (Boolean(result.config) === result.demo) throw new Error('Choose either --config or --demo'); + return result; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log('Herdr Console (Node 20+)\n npm run console -- --config /private/console.json [--port 4317] [--check]\n npm run console -- --demo\nRead-only. Binds to 127.0.0.1 only. No plugin operations or remote requests.'); + return; + } + const config = options.demo ? null : await loadConfig(options.config); + const get = options.demo ? demoSnapshot : () => snapshot(config); + if (options.check) { console.log(JSON.stringify(await get(), null, 2)); return; } + const server = createConsoleServer(get); + server.on('error', error => { console.error(`Console could not start (${error.code || 'server error'}). Try another --port.`); process.exitCode = 1; }); + server.listen(options.port, '127.0.0.1', () => console.log(`Herdr Console${options.demo ? ' — DEMO DATA' : ''}\nhttp://127.0.0.1:${options.port}\nRead-only. Press Ctrl+C to stop.`)); + const stop = () => { server.close(); server.closeAllConnections(); }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(error => { console.error(`Console: ${error.message}`); process.exitCode = 1; }); +} diff --git a/console/tests/console.test.mjs b/console/tests/console.test.mjs new file mode 100644 index 0000000..3611413 --- /dev/null +++ b/console/tests/console.test.mjs @@ -0,0 +1,137 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, mkdir, symlink, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { request } from 'node:http'; +import { boundedRead, loadConfig, inspectGit, inspectProject, parseQA, parsePullRequest, compatibility, demoSnapshot, gitEnvironment } from '../model.mjs'; +import { createConsoleServer, parseArgs } from '../server.mjs'; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'herdr-console-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + const repo = join(root, 'repo'); await mkdir(repo); + const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', env: { + ...gitEnvironment(), GIT_CONFIG_NOSYSTEM:'1', GIT_CONFIG_GLOBAL:'/dev/null', + GIT_AUTHOR_NAME:'Console Test',GIT_AUTHOR_EMAIL:'console@example.invalid', + GIT_COMMITTER_NAME:'Console Test',GIT_COMMITTER_EMAIL:'console@example.invalid' + }}).trim(); + git('init', '-q'); git('commit','-q','--allow-empty','-m','fixture'); + return { root, repo, git }; +} +function qa(commit) { + return { schemaVersion:1,kind:'herdr-browser-qa',status:'passed',cleanup:{status:'passed'},git:{commit,dirty:false,changedDuringRun:false}, + finishedAt:new Date().toISOString(),scenario:{name:'Homepage',policy:{failOnConsoleError:true,failOnPageError:true,failOnFailedRequest:true}},summary:{viewports:2,passed:2,failed:0,assertions:2,consoleErrors:0,pageErrors:0,failedRequests:0}, + runs:[{viewport:{name:'desktop'},status:'passed',artifacts:['desktop.png']},{viewport:{name:'mobile'},status:'passed',artifacts:['mobile.png']}] }; +} +test('configuration explicitly selects repositories and rejects unknown, duplicate and executable fields', async t => { + const { root } = await fixture(t); const file = join(root, 'config.json'); + const config = {schemaVersion:1,projects:[{id:'one',name:'First project',path:'repo'}]}; + await writeFile(file, JSON.stringify(config)); + assert.equal((await loadConfig(file)).projects[0].path, join(root, 'repo')); + for (const bad of [ {...config, command:'rm -rf /'}, {...config, projects:[config.projects[0],config.projects[0]]}, + {...config,projects:[{...config.projects[0],script:'file.mjs'}]}, {...config,herdrBinary:'shell --eval anything'} ]) { + await writeFile(file,JSON.stringify(bad)); await assert.rejects(loadConfig(file)); + } +}); +test('bounded observation refuses symlinks, directories, FIFOs and oversized data', async t => { + const {root} = await fixture(t); const file=join(root,'data'); await writeFile(file,'{}'); + const link=join(root,'link'); await symlink(file,link); + await assert.rejects(boundedRead(link)); await assert.rejects(boundedRead(root)); + const fifo=join(root,'pipe'); execFileSync('mkfifo',[fifo]); await assert.rejects(boundedRead(fifo)); + await writeFile(file,Buffer.alloc(2*1024*1024+1)); await assert.rejects(boundedRead(file)); +}); +test('read-only Git inspection counts changes, handles detached HEAD and leaves index unchanged', async t => { + const {repo,git}=await fixture(t); + await writeFile(join(repo,'a.txt'),'one'); git('add','a.txt'); git('commit','-qm','file'); + const index=await readFile(join(repo,'.git/index')); + let result=await inspectGit(repo); assert.equal(result.dirty,false); assert.equal(result.worktrees,1); + await writeFile(join(repo,'a.txt'),'two'); await writeFile(join(repo,'new file.txt'),'three'); + result=await inspectGit(repo); assert.equal(result.changedFiles,2); assert.equal(result.dirty,true); + assert.deepEqual(await readFile(join(repo,'.git/index')),index); + git('checkout','--detach','-q'); assert.equal((await inspectGit(repo)).branch,'Detached HEAD'); + await mkdir(join(repo,'child')); await assert.rejects(inspectGit(join(repo,'child'))); +}); +test('Git environment strips inherited repository and config redirections', () => { + const old=process.env.GIT_DIR; process.env.GIT_DIR='/foreign'; + try { assert.equal(gitEnvironment().GIT_DIR,undefined); assert.equal(gitEnvironment().GIT_OPTIONAL_LOCKS,'0'); } + finally { if (old === undefined) delete process.env.GIT_DIR; else process.env.GIT_DIR=old; } +}); +test('QA freshness requires exact clean unchanged HEAD and rejects contradictory success', () => { + const commit='a'.repeat(40), report=qa(commit), current={commit,dirty:false}; + assert.equal(parseQA(JSON.stringify(report),current).freshness,'matches_clean_head'); + for (const git of [{...report.git,dirty:true},{...report.git,changedDuringRun:true},{...report.git,commit:'b'.repeat(40)}]) { + assert.equal(parseQA(JSON.stringify({...report,git}),current).freshness,'not_current_clean_head'); + } + assert.equal(parseQA(JSON.stringify(report),{...current,dirty:true}).freshness,'not_current_clean_head'); + assert.throws(()=>parseQA(JSON.stringify({...report,summary:{...report.summary,failedRequests:1}}),current)); + assert.throws(()=>parseQA(JSON.stringify({...report,kind:'something-else'}),current)); + const relaxed={...report,scenario:{...report.scenario,policy:{...report.scenario.policy,failOnConsoleError:false}},summary:{...report.summary,consoleErrors:2}}; + assert.equal(parseQA(JSON.stringify(relaxed),current).validationPolicy,'relaxed'); + const earlyFailure={...report,status:'failed',runs:[],summary:{...report.summary,passed:0,failed:2,assertions:0}}; + assert.equal(parseQA(JSON.stringify(earlyFailure),current).outcome,'failed'); + assert.throws(()=>parseQA(JSON.stringify({...report,cleanup:{status:'failed'}}),current)); +}); +test('observations are projected, repository-bound, missing sources stay unknown', async t => { + const {root,repo}=await fixture(t), info=await inspectGit(repo); + const p={id:'one',name:'One',path:repo,swarmManifest:join(root,'swarm.json'),conductorStatus:join(root,'status.json'),guardAudit:join(root,'audit.jsonl'),qaReport:join(root,'qa.json')}; + await writeFile(p.swarmManifest,JSON.stringify({run_id:'r1',repo_root:repo,slots:[{slot:'1',label:'Build',status:'running',branch:'swarm/r1/1',mission:'DO NOT EXPOSE'}]})); + const conductor={run:'c1',lifecycle:'applied',repository_key:info.repositoryKey,fork_sha:info.commit,workers:[],legal_next_operations:['status','stand-down']}; + await writeFile(p.conductorStatus,JSON.stringify(conductor)); + await writeFile(p.guardAudit,JSON.stringify({severity:'alert',line:'SECRET COMMAND'})+'\n'); + await writeFile(p.qaReport,JSON.stringify(qa(info.commit))); + const result=await inspectProject(p); + assert.equal(result.swarm.status,'observed'); assert.equal(result.conductor.lifecycle,'applied'); + assert.equal(result.guard.counts.alert,1); assert.equal(result.qa.freshness,'matches_clean_head'); + assert.ok(!JSON.stringify(result).includes('SECRET COMMAND')); assert.ok(!JSON.stringify(result).includes('DO NOT EXPOSE')); + await writeFile(p.conductorStatus,JSON.stringify({...conductor,repository_key:'0'.repeat(64)})); + assert.equal((await inspectProject(p)).conductor.status,'unavailable'); + await writeFile(p.swarmManifest,'{'); assert.equal((await inspectProject(p)).swarm.status,'unavailable'); + assert.equal((await inspectProject({id:'empty',name:'Empty',path:repo})).qa.status,'not_configured'); +}); +test('version compatibility never infers newer Conductor support', () => { + const c={slug:'conductor',min_herdr_version:'0.7.5',tested_herdr_versions:['0.7.5']}; + assert.equal(compatibility(c,'0.7.5'),'version_match'); assert.equal(compatibility(c,'0.8.2'),'incompatible'); + assert.equal(compatibility({...c,slug:'swarm'},'0.8.2'),'not_tested'); + assert.equal(compatibility({...c,slug:'swarm'},'0.6.9'),'incompatible'); +}); +test('PR observations validate links, current SHA and never turn absent checks into pass', () => { + const commit='a'.repeat(40), current={commit,dirty:false}; + const p={schema_version:1,repository:'StructuPath/example',number:12,url:'https://github.com/StructuPath/example/pull/12',state:'OPEN',draft:true,head_sha:commit,local_head_sha:commit,status:'pending',check_count:1}; + const parse=v=>parsePullRequest(`ci_status\t${JSON.stringify(v)}\n`,current); + assert.equal(parse(p).freshness,'matches_clean_head'); assert.equal(parse(p).outcome,'pending'); + assert.equal(parse({...p,head_sha:'b'.repeat(40)}).freshness,'not_current_clean_head'); + assert.throws(()=>parse({...p,url:'javascript:alert(1)'})); + assert.throws(()=>parsePullRequest(`ci_status\t${JSON.stringify(p)}\nci_status\t${JSON.stringify(p)}`,current)); + assert.equal(parse({schema_version:1,status:'no_pr',local_head_sha:commit}).outcome,'no_pr'); +}); +test('CLI requires an explicit project configuration or clearly labeled demo', () => { + assert.throws(()=>parseArgs([])); assert.throws(()=>parseArgs(['--demo','--config','a'])); + assert.throws(()=>parseArgs(['--demo','--port','80'])); assert.throws(()=>parseArgs(['--host','0.0.0.0'])); + assert.equal(parseArgs(['--demo','--check']).check,true); assert.equal(demoSnapshot().demo,true); +}); +test('HTTP serves read-only same-origin data, rejects rebinding, traversal and write attempts', async t => { + let reads=0; + const server=createConsoleServer(async()=>{reads++; return demoSnapshot();}); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + t.after(()=>{server.closeAllConnections();server.close();}); + const port=server.address().port,host=`127.0.0.1:${port}`; + const get=(path,headers={},method='GET')=>new Promise((resolve,reject)=>{ + const req=request({host:'127.0.0.1',port,path,method,headers:{Host:host,...headers}},res=>{ + let body='';res.on('data',chunk=>body+=chunk);res.on('end',()=>resolve({status:res.statusCode,body,headers:res.headers})); + });req.on('error',reject);req.end(); + }); + assert.equal((await get('/')).status,200); + assert.equal((await get('/api/snapshot')).status,403); + assert.equal((await get('/api/snapshot',{'X-Herdr-Console':'1',Origin:'https://evil.test'})).status,403); + assert.equal((await get('/api/snapshot',{'X-Herdr-Console':'1',Host:'evil.test'})).status,403); + assert.equal((await get('/api/snapshot',{'X-Herdr-Console':'1','Sec-Fetch-Site':'cross-site'})).status,403); + assert.equal((await get('/api/snapshot',{'X-Herdr-Console':'1'},'POST')).status,405); + assert.equal((await get('/../console/example.json')).status,404); + assert.equal((await get('/api/snapshot?path=/etc/passwd',{'X-Herdr-Console':'1'})).status,404); + const r=await get('/api/snapshot',{'X-Herdr-Console':'1'}); + assert.equal(r.status,200); assert.equal(JSON.parse(r.body).demo,true); + assert.equal(r.headers['access-control-allow-origin'],undefined); assert.equal(r.headers['cache-control'],'no-store'); + await get('/api/snapshot',{'X-Herdr-Console':'1'}); assert.equal(reads,1); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0870a3 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "herdr-suite-console", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "scripts": { + "console": "node console/server.mjs", + "test": "node --test console/tests/*.test.mjs", + "build": "node --check console/server.mjs && node --check console/model.mjs && node --check console/public/app.js", + "validate": "npm run build && npm test && python3 scripts/build_docs.py --check && python3 scripts/check_docs.py" + } +} From 1b1898636516faf0899f229d6566403a132c2321 Mon Sep 17 00:00:00 2001 From: Victor Garcia <211279486+Steel-tech@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:46:53 -0600 Subject: [PATCH 02/10] fix: show recorded Conductor next operations in console --- console/public/app.js | 1 + 1 file changed, 1 insertion(+) diff --git a/console/public/app.js b/console/public/app.js index f8a2060..2e15cca 100644 --- a/console/public/app.js +++ b/console/public/app.js @@ -55,6 +55,7 @@ function projectCard(p) { const conductor = panel('Conductor', p.conductor); if (p.conductor.status === 'observed') { row(conductor, 'Saved lifecycle', p.conductor.lifecycle); row(conductor, 'Run', p.conductor.run); + if (p.conductor.nextOperations?.length) row(conductor, 'Recorded next operations', p.conductor.nextOperations.join(', ')); for (const worker of p.conductor.workers) row(conductor, worker.role, worker.state); conductor.append(element('p', 'Operator-selected saved status; refresh Conductor status before acting.', 'footnote')); } From b1155051a552084d2a35a9908717efa18087ff9a Mon Sep 17 00:00:00 2001 From: Victor Garcia <211279486+Steel-tech@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:50:38 -0600 Subject: [PATCH 03/10] fix: validate QA evidence consistency and bound console observations --- console/README.md | 2 ++ console/model.mjs | 15 +++++++++++++-- console/public/app.js | 2 +- console/server.mjs | 1 + console/tests/console.test.mjs | 9 ++++++--- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/console/README.md b/console/README.md index 9f598fb..ee961cd 100644 --- a/console/README.md +++ b/console/README.md @@ -56,6 +56,8 @@ the suite's pinned `data/plugins.json`; Conductor requires exactly Herdr 0.7.5. kind `herdr-browser-qa`. A current result must match clean HEAD and report a clean, unchanged checkout. Screenshot/diagnostic files stay in the runner's private output directory; this server does not serve arbitrary artifacts. + Relaxed error policies are labeled explicitly. Contradictory success reports + are unavailable; a genuine failed or interrupted run still displays failure. - **GitHub handoff:** save Swarm `scripts/harvest-step.sh pr-status ` output to the configured TSV file. The single `ci_status` JSON record supplies the PR URL and CI summary. Reported commit must match the configured clean HEAD diff --git a/console/model.mjs b/console/model.mjs index 3ef7612..8d7ce40 100644 --- a/console/model.mjs +++ b/console/model.mjs @@ -120,7 +120,7 @@ export function parseQA(content, current) { if (!object(q) || q.schemaVersion !== 1 || q.kind !== 'herdr-browser-qa' || !['passed', 'failed'].includes(q.status) || !object(q.git) || !SHA.test(q.git.commit) || typeof q.git.dirty !== 'boolean' || typeof q.git.changedDuringRun !== 'boolean' || - !object(q.summary) || !object(q.scenario?.policy) || + !object(q.summary) || !['passed','failed'].includes(q.cleanup?.status) || !object(q.scenario?.policy) || ['failOnConsoleError','failOnPageError','failOnFailedRequest'].some(k => typeof q.scenario.policy[k] !== 'boolean') || !Array.isArray(q.runs) || q.runs.length > 20 || !Number.isFinite(Date.parse(q.finishedAt))) throw new Error('Unsupported QA report'); @@ -129,7 +129,18 @@ export function parseQA(content, current) { summary.passed + summary.failed !== summary.viewports || summary.viewports < 1) throw new Error('Invalid QA summary'); const policy = q.scenario.policy; const strict = policy.failOnConsoleError && policy.failOnPageError && policy.failOnFailedRequest; + const validRuns = q.runs.every(r => object(r) && ['passed','failed'].includes(r.status) && + ['steps','artifacts','consoleErrors','pageErrors','failedRequests'].every(k => Array.isArray(r[k]))); + if (!validRuns) throw new Error('Invalid QA viewport runs'); + const actual = { + assertions: q.runs.reduce((n,r) => n + r.steps.filter(s => typeof s?.type === 'string' && s.type.startsWith('assert')).length, 0), + consoleErrors: q.runs.reduce((n,r) => n + r.consoleErrors.length, 0), + pageErrors: q.runs.reduce((n,r) => n + r.pageErrors.length, 0), + failedRequests: q.runs.reduce((n,r) => n + r.failedRequests.length, 0) + }; const inconsistent = q.status === 'passed' && (summary.failed > 0 || summary.viewports !== q.runs.length || q.cleanup?.status !== 'passed' || + q.git.changedDuringRun || Boolean(q.error) || Object.keys(actual).some(k => actual[k] !== summary[k]) || + q.runs.some(r => r.steps.some(s => s?.status !== 'passed') || r.error || r.evidenceError || r.telemetryError || r.cleanupError || (policy.failOnFailedRequest && r.unresolvedRequests !== 0)) || (policy.failOnConsoleError && summary.consoleErrors > 0) || (policy.failOnPageError && summary.pageErrors > 0) || (policy.failOnFailedRequest && summary.failedRequests > 0) || q.runs.some(r => r.status !== 'passed')); @@ -200,7 +211,7 @@ export async function inspectProject(p) { return { ...result, swarm, conductor, guard, qa, pullRequest }; } -const parseVersion = (s) => /(?:^|\s|v)(\d+\.\d+\.\d+)(?:\b|$)/.exec(s)?.[1] || null; +const parseVersion = (s) => /(?:^|\s|v)(\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?)(?:\s|$)/.exec(s)?.[1] || null; export function compatibility(plugin, version) { if (!version) return 'unknown'; if (plugin.slug === 'conductor') return version === '0.7.5' ? 'version_match' : 'incompatible'; diff --git a/console/public/app.js b/console/public/app.js index 2e15cca..2cd407c 100644 --- a/console/public/app.js +++ b/console/public/app.js @@ -43,7 +43,7 @@ function projectCard(p) { const stats = element('div', undefined, 'stats'); row(stats, 'Branch', p.git.branch); row(stats, 'Commit', p.git.commit.slice(0, 12)); row(stats, 'Changed files', p.git.changedFiles); row(stats, 'Worktrees', p.git.worktrees); card.append(stats); } - const details = element('details'); details.open = true; + const details = element('details'); details.open = data.projects.length === 1; details.append(element('summary', 'Run status & validation evidence')); const grid = element('div', undefined, 'evidence-grid'); const swarm = panel('Swarm', p.swarm); diff --git a/console/server.mjs b/console/server.mjs index fdc480d..943bb5e 100644 --- a/console/server.mjs +++ b/console/server.mjs @@ -73,6 +73,7 @@ export function parseArgs(args) { } async function main() { + if (Number(process.versions.node.split('.')[0]) < 20) throw new Error('Node 20 or newer is required'); const options = parseArgs(process.argv.slice(2)); if (options.help) { console.log('Herdr Console (Node 20+)\n npm run console -- --config /private/console.json [--port 4317] [--check]\n npm run console -- --demo\nRead-only. Binds to 127.0.0.1 only. No plugin operations or remote requests.'); diff --git a/console/tests/console.test.mjs b/console/tests/console.test.mjs index 3611413..1c0b330 100644 --- a/console/tests/console.test.mjs +++ b/console/tests/console.test.mjs @@ -23,7 +23,7 @@ async function fixture(t) { function qa(commit) { return { schemaVersion:1,kind:'herdr-browser-qa',status:'passed',cleanup:{status:'passed'},git:{commit,dirty:false,changedDuringRun:false}, finishedAt:new Date().toISOString(),scenario:{name:'Homepage',policy:{failOnConsoleError:true,failOnPageError:true,failOnFailedRequest:true}},summary:{viewports:2,passed:2,failed:0,assertions:2,consoleErrors:0,pageErrors:0,failedRequests:0}, - runs:[{viewport:{name:'desktop'},status:'passed',artifacts:['desktop.png']},{viewport:{name:'mobile'},status:'passed',artifacts:['mobile.png']}] }; + runs:['desktop','mobile'].map(name => ({viewport:{name},status:'passed',steps:[{type:'assertTitle',status:'passed'}],artifacts:[`${name}.png`],consoleErrors:[],pageErrors:[],failedRequests:[],unresolvedRequests:0})) }; } test('configuration explicitly selects repositories and rejects unknown, duplicate and executable fields', async t => { const { root } = await fixture(t); const file = join(root, 'config.json'); @@ -61,17 +61,20 @@ test('Git environment strips inherited repository and config redirections', () = test('QA freshness requires exact clean unchanged HEAD and rejects contradictory success', () => { const commit='a'.repeat(40), report=qa(commit), current={commit,dirty:false}; assert.equal(parseQA(JSON.stringify(report),current).freshness,'matches_clean_head'); - for (const git of [{...report.git,dirty:true},{...report.git,changedDuringRun:true},{...report.git,commit:'b'.repeat(40)}]) { + for (const git of [{...report.git,dirty:true},{...report.git,commit:'b'.repeat(40)}]) { assert.equal(parseQA(JSON.stringify({...report,git}),current).freshness,'not_current_clean_head'); } assert.equal(parseQA(JSON.stringify(report),{...current,dirty:true}).freshness,'not_current_clean_head'); assert.throws(()=>parseQA(JSON.stringify({...report,summary:{...report.summary,failedRequests:1}}),current)); assert.throws(()=>parseQA(JSON.stringify({...report,kind:'something-else'}),current)); - const relaxed={...report,scenario:{...report.scenario,policy:{...report.scenario.policy,failOnConsoleError:false}},summary:{...report.summary,consoleErrors:2}}; + const relaxed={...report,scenario:{...report.scenario,policy:{...report.scenario.policy,failOnConsoleError:false}},summary:{...report.summary,consoleErrors:2},runs:report.runs.map(r=>({...r,consoleErrors:[{message:'expected'}]}))}; assert.equal(parseQA(JSON.stringify(relaxed),current).validationPolicy,'relaxed'); const earlyFailure={...report,status:'failed',runs:[],summary:{...report.summary,passed:0,failed:2,assertions:0}}; assert.equal(parseQA(JSON.stringify(earlyFailure),current).outcome,'failed'); assert.throws(()=>parseQA(JSON.stringify({...report,cleanup:{status:'failed'}}),current)); + assert.throws(()=>parseQA(JSON.stringify({...report,git:{...report.git,changedDuringRun:true}}),current)); + assert.throws(()=>parseQA(JSON.stringify({...report,runs:[]}),current)); + assert.throws(()=>parseQA(JSON.stringify({...report,runs:report.runs.map(r=>({...r,status:'failed'}))}),current)); }); test('observations are projected, repository-bound, missing sources stay unknown', async t => { const {root,repo}=await fixture(t), info=await inspectGit(repo); From b3fb3b650be4fe18d9ca8ae60b1ac7f2c7682c1d Mon Sep 17 00:00:00 2001 From: Victor Garcia <211279486+Steel-tech@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:50:51 -0600 Subject: [PATCH 04/10] fix: handle readiness failures while inspecting projects --- console/model.mjs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/console/model.mjs b/console/model.mjs index 8d7ce40..a886609 100644 --- a/console/model.mjs +++ b/console/model.mjs @@ -238,13 +238,15 @@ export async function health(herdrBinary) { } export async function snapshot(config) { - const readinessPromise = health(config.herdrBinary); - const projects = []; - // Bound both Git process fan-out and observation buffers across large configurations. - for (let i = 0; i < config.projects.length; i += 4) { - projects.push(...await Promise.all(config.projects.slice(i, i + 4).map(inspectProject))); - } - const readiness = await readinessPromise; + const inspectProjects = async () => { + const projects = []; + // Bound both Git process fan-out and observation buffers across large configurations. + for (let i = 0; i < config.projects.length; i += 4) { + projects.push(...await Promise.all(config.projects.slice(i, i + 4).map(inspectProject))); + } + return projects; + }; + const [readiness, projects] = await Promise.all([health(config.herdrBinary), inspectProjects()]); return { schemaVersion: 1, generatedAt: new Date().toISOString(), demo: false, readiness, projects }; } From 87e9a41db52e663e1054eb98b71ffe1913ad7acd Mon Sep 17 00:00:00 2001 From: Victor Garcia <211279486+Steel-tech@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:52:01 -0600 Subject: [PATCH 05/10] Document local Console and repeatable review workflows --- README.md | 23 +++++- data/plugins.json | 6 +- docs-src/Browser.md | 53 +++++++++++-- docs-src/Console.md | 78 ++++++++++++++++++ docs-src/Home.md | 18 +++-- docs-src/Swarm.md | 29 +++++++ docs-src/_Sidebar.md | 2 + docs-src/site-qa.json | 27 +++++++ docs/browser/index.html | 43 ++++++++-- docs/conductor/index.html | 3 + docs/console/index.html | 163 ++++++++++++++++++++++++++++++++++++++ docs/guard/index.html | 3 + docs/index.html | 23 ++++-- docs/swarm/index.html | 22 ++++- index.html | 57 +++++++++++-- llms.txt | 14 +++- scripts/build_docs.py | 12 ++- 17 files changed, 538 insertions(+), 38 deletions(-) create mode 100644 docs-src/Console.md create mode 100644 docs-src/site-qa.json create mode 100644 docs/console/index.html diff --git a/README.md b/README.md index 09f5f9c..0bd253b 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,18 @@ Canonical, reviewable documentation for the StructuPath Herdr Suite, published a The product surface is organized by workflow readiness: -- **Explore / Swarm** is the ready first workflow: bounded parallel candidates, reviewable worktrees, and operator-selected harvest. +- **Explore / Swarm** is the ready first workflow: bounded parallel candidates, reviewable worktrees, operator-selected harvest, and explicit GitHub draft PR handoff with validation summaries. - **Deliver / Conductor** is an attended Stage 3 lifecycle: task-bound roles, exact-SHA gates, operator approval receipts, single-local-ref apply, and archival stand-down on exactly Herdr 0.7.5. -- **Browser** provides Chromium launch, external attach, shared sessions, and agent-browser recording. **Guard** provides text policy and an optional fail-open harness reporter. +- **Browser** provides Chromium launch, external attach, shared sessions, agent-browser recording, and repeatable desktop/mobile QA. **Guard** provides text policy and an optional fail-open harness reporter. The four plugins can be installed together, but they do not form one automatic runtime pipeline. +## Local Console + +With Node.js 20 or newer, run `npm run console -- --demo` for a labeled fixture dashboard. For real projects, use `npm run console -- --config /absolute/private/console.json`; add `--check` to print a snapshot and exit. The server binds to `127.0.0.1:4317` by default. + +Console reads explicit project paths and optional saved Swarm, Conductor, Guard, and Browser QA observations. It shows tool health, Git state, evidence freshness, and copyable next commands. It has no mutating controls. The public website serves documentation, not a remote controller; Console is a local app, not a fifth plugin. See the [Console guide](https://herdr.structupath.ai/docs/console/) for configuration and evidence limits. + ## Source and publishing contract - Author documentation in `docs-src/`. @@ -35,6 +41,19 @@ python3 scripts/build_docs.py python3 scripts/build_docs.py --check python3 scripts/check_docs.py python3 -m py_compile scripts/build_docs.py scripts/check_docs.py +npm run validate ``` +`npm run validate` also checks Console syntax and runs its real-Git adapter and HTTP boundary tests. + +For repeatable browser checks, serve this checkout with `python3 -m http.server 4381 --bind 127.0.0.1`, then use the Browser QA runner from its trusted checkout: + +```bash +node /trusted/herdr-browser/bin/qa.mjs run \ + --config docs-src/site-qa.json --repo . \ + --output /private/new-site-qa-run --json +``` + +The saved scenario checks landing-to-Console navigation and all plugin guides at desktop and mobile viewport sizes. Commit changes before collecting evidence intended to identify a clean candidate. Output must be outside the repository in a new directory with an existing parent; review screenshots separately for layout. See the [Browser guide](https://herdr.structupath.ai/docs/browser/) for prerequisites and evidence limits. + See [`docs/pages-cutover.md`](docs/pages-cutover.md) for the source-of-truth and publishing contract. diff --git a/data/plugins.json b/data/plugins.json index 117639d..712bef8 100644 --- a/data/plugins.json +++ b/data/plugins.json @@ -7,13 +7,13 @@ "name": "Browser", "id": "structupath.browser", "repository": "https://github.com/StructuPath/herdr-browser", - "commit": "5fc6a9a52b4f817f21a531c288f36c3820bf93ca", - "version": "0.7.0", + "commit": "e897f277b44d2a846049f270d84d77adc95d3728", + "version": "0.8.0", "min_herdr_version": "0.7.0", "tested_herdr_versions": [ "0.7.4" ], - "manifest_sha256": "975685f637d75b2f4b0151b7cecbb3eaa1fb3b8849c13a356e3ebfb5a90fea8a", + "manifest_sha256": "6b8a05b2b7f513803ec68bfa1f6413350e038b5355e1287a4ca2d3d9fdc4c432", "actions": [ "open", "close", diff --git a/docs-src/Browser.md b/docs-src/Browser.md index e59c2c5..58153c2 100644 --- a/docs-src/Browser.md +++ b/docs-src/Browser.md @@ -1,6 +1,6 @@ # 🌐 Browser (`structupath.browser`) -**Supporting visibility capability.** Browser lets you launch Chrome or Chromium inside a Herdr pane, attach to an existing automation browser, or share a coding agent's [agent-browser](https://github.com/vercel-labs/agent-browser) session. Choose the connection that matches your workflow; agent-browser is only required for shared sessions and recording. +**Supporting visibility capability.** Browser lets you launch Chrome or Chromium inside a Herdr pane, attach to an existing automation browser, or share a coding agent's [agent-browser](https://github.com/vercel-labs/agent-browser) session. Choose the connection that matches your workflow; agent-browser is required for shared sessions, recording, and standalone repeatable QA. Repo: [StructuPath/herdr-browser](https://github.com/StructuPath/herdr-browser) · Detailed reference: the repo [README](https://github.com/StructuPath/herdr-browser#readme) @@ -8,12 +8,12 @@ Repo: [StructuPath/herdr-browser](https://github.com/StructuPath/herdr-browser) | Field | Value | | --- | --- | -| Plugin release | `0.7.0` | +| Plugin release | `0.8.0` | | Minimum Herdr | `0.7.0` | | Explicitly tested Herdr | `0.7.4` | -| Evidence commit | `5fc6a9a52b4f817f21a531c288f36c3820bf93ca` | +| Evidence commit | `e897f277b44d2a846049f270d84d77adc95d3728` | -The [pinned source](https://github.com/StructuPath/herdr-browser/tree/952e1601006cce2ca45edef56676d18a1f016151) includes the merged session-preservation and stream-recovery fixes. The manifest version remains 0.7.0; the commit identifies the exact implementation behind this guide. +The [pinned source](https://github.com/StructuPath/herdr-browser/tree/e897f277b44d2a846049f270d84d77adc95d3728) adds standalone repeatable QA while retaining the session-preservation and stream-recovery fixes. The manifest version is 0.8.0; the commit identifies the exact implementation behind this guide. ## Choose a browser connection @@ -115,6 +115,49 @@ The default shared session is `herdr-ws-`. Sessions and persistent | `j` / `k`, Space, wheel | Scroll | | Esc / `q` | Cancel the prompt / quit the viewer | +## Repeatable desktop and mobile QA + +The standalone QA runner executes a saved scenario in a fresh, private agent-browser session. It needs Node.js 20 or newer, Git with a committed SHA-1 HEAD, and agent-browser 0.33.0 or newer with Chromium installed; it does not require a running Herdr server. Install the browser dependency with `npm install -g agent-browser` and `agent-browser install`. See the [pinned QA contract](https://github.com/StructuPath/herdr-browser/blob/e897f277b44d2a846049f270d84d77adc95d3728/docs/qa.md) for complete step fields, bounds, and failure handling. + +Save and commit a scenario such as `.herdr-browser-qa.json` in the project you are testing: + +```json +{ + "schemaVersion": 1, + "name": "Home page smoke", + "baseUrl": "http://127.0.0.1:3000", + "viewports": [ + {"name": "desktop", "width": 1440, "height": 900}, + {"name": "mobile", "width": 390, "height": 844} + ], + "steps": [ + {"type": "navigate", "path": "/"}, + {"type": "assertVisible", "selector": "h1"}, + {"type": "assertText", "selector": "h1", "contains": "Welcome"}, + {"type": "screenshot", "name": "home"} + ] +} +``` + +Replace the heading assertion with text your app must show. Build and serve the intended candidate yourself, then run from a trusted Browser checkout: + +```bash +node bin/qa.mjs check --config /project/.herdr-browser-qa.json +node bin/qa.mjs run --config /project/.herdr-browser-qa.json \ + --repo /project --output /private/new-qa-run \ + --base-url http://127.0.0.1:3000 --json +``` + +The output directory must be new, outside the tested repository, with an existing parent. Omitting `--output` creates a private temporary directory. `check` validates the scenario without running a browser. `run` emits `result.json`, screenshots, and bounded diagnostics. It closes its own session and does not reuse your shared session or profile. + +Scenarios support `navigate`, `click`, `fill`, `waitFor`, `assertText`, `assertVisible`, `assertUrl`, `assertTitle`, and `screenshot`; they do not accept arbitrary JavaScript or shell commands. Configure 1–4 named viewports and 1–40 steps. Mobile means viewport geometry, not touch, user-agent, or physical-device emulation. + +The top-level `failOnConsoleError`, `failOnPageError`, and `failOnFailedRequest` options default to `true`. Results record these choices in `scenario.policy`. Disabling one relaxes the test: Console labels the policy as relaxed, and Swarm's direct QA evidence import requires all three enabled. + +The result has `schemaVersion: 1`, `kind: "herdr-browser-qa"`, overall `status`, Git commit/branch/dirty state and `changedDuringRun`, summary counts, and per-viewport `runs`. Assertions, console errors, page errors, and failed requests provide evidence for the configured scenario. They do not certify untested behavior, accessibility, security, or production readiness. A recorded Git commit does not prove the served app was built from that commit. A dirty tree or changes during the run weaken the association with the candidate. + +Review private screenshots and diagnostics before sharing them. [Console](Console) can read the explicit `result.json` path and compare its recorded Git identity with the project. [Swarm](Swarm) can include a bounded validation summary in an operator-requested draft PR handoff; evidence never approves or merges a candidate automatically. + ## Troubleshooting and development checks - **No Chromium found:** install Chrome/Chromium or configure its executable path. @@ -132,4 +175,4 @@ npm run validate npm run test:integration ``` -Doctor checks prerequisites without launching browsers or contacting endpoints; it does not verify engine downloads, Herdr's version, or endpoint reachability. The integration command requires Node 22+, local Chromium, and agent-browser with its engine installed, and fails rather than skipping missing browser prerequisites. See the pinned [readiness assessment](https://github.com/StructuPath/herdr-browser/blob/952e1601006cce2ca45edef56676d18a1f016151/docs/readiness.md) for remaining recommendations. +Doctor checks prerequisites without launching browsers or contacting endpoints; it does not verify engine downloads, Herdr's version, or endpoint reachability. The integration command requires Node 22+, local Chromium, and agent-browser with its engine installed, and fails rather than skipping missing browser prerequisites. See the pinned [readiness assessment](https://github.com/StructuPath/herdr-browser/blob/e897f277b44d2a846049f270d84d77adc95d3728/docs/readiness.md) for remaining recommendations. diff --git a/docs-src/Console.md b/docs-src/Console.md new file mode 100644 index 0000000..fe6e04a --- /dev/null +++ b/docs-src/Console.md @@ -0,0 +1,78 @@ +# Local Console + +Console 0.1.0 brings explicitly configured projects, tool health, saved run observations, and browser QA evidence into one read-only local dashboard. It is a local app in the suite website repository, not a fifth Herdr plugin. The public website at `herdr.structupath.ai` serves documentation; it cannot inspect or control your projects. + +## Start locally + +Install Node.js 20 or newer and Git, then clone the suite site repository. Console has no package dependencies. + +```bash +git clone https://github.com/StructuPath/herdr-suite-site.git +cd herdr-suite-site +npm run console -- --demo +``` + +Open the loopback address printed by the command, normally `http://127.0.0.1:4317`. Demo mode uses clearly labeled fixtures, not observations of your machine. Stop the server with Ctrl+C. + +For your projects, save a private configuration file outside the published site: + +```json +{ + "schemaVersion": 1, + "projects": [ + { + "id": "app", + "name": "My app", + "path": "/absolute/path/to/app" + } + ] +} +``` + +```bash +npm run console -- --config /absolute/private/console.json +npm run console -- --config /absolute/private/console.json --port 4318 +npm run console -- --config /absolute/private/console.json --check +``` + +`--check` prints the snapshot as JSON and exits. The server binds only to `127.0.0.1`. Configure 1–20 Git worktree roots; relative paths resolve from the configuration file's directory. Console reads explicit paths; it does not recursively discover repositories or look for credentials. The optional top-level `herdrBinary` selects an explicit Herdr executable. + +## Add saved observations + +Each project can optionally name these files. Omit any source you do not use; example paths are placeholders, not files Console creates. + +| Project field | Expected source | What the dashboard can show | +| --- | --- | --- | +| `swarmManifest` | Explicit Swarm run manifest JSON | Saved run and slot metadata, not authoritative lifecycle or live agent activity | +| `conductorStatus` | Saved JSON from Conductor's existing status command | Observed lifecycle, workers, legal next operations, and file modification time | +| `guardAudit` | Explicit Guard audit JSONL | Event counts from the supplied audit file | +| `qaReport` | Browser QA `result.json` | Test summary, recorded Git identity, freshness, and artifact counts | +| `pullRequestStatus` | Saved Swarm `pr-status` TSV output | Validated GitHub PR link, CI summary, and head freshness | + +For Conductor, run its status command yourself in the correct Herdr workspace and repository, using the trusted plugin checkout and a private output path: + +```bash +node /trusted/herdr-conductor/scripts/stage1-runtime.mjs status > /absolute/private/conductor-status.json +``` + +Point `conductorStatus` at that saved file. Console checks its repository key against the project's current Git common directory and rejects a status observation for another repository. It does not reconstruct lifecycle from journals. + +For GitHub PR observations, save Swarm's existing tab-separated output in the correct configured run context: + +```bash +bash /trusted/herdr-swarm/scripts/harvest-step.sh pr-status 1 > /absolute/private/pr-status.tsv +``` + +Set `pullRequestStatus` to that file. It expects the `ci_status` tab-separated JSON record, not raw JSON. The recorded head must match the configured clean repository to be labeled current; a base worktree can legitimately differ from a slot's PR head. Refresh through Swarm to observe new CI results. A saved passing status cannot authorize a merge. + +Refreshing the dashboard rereads configured files with a five-second cache; an old file remains a saved observation even after refresh. File modification times are shown. Observation files must be regular, non-symlink files of at most 2 MiB, with at most 10,000 entries in a Guard audit file. Missing, foreign, malformed, or oversized observations show as unavailable, not as successful checks. + +## Read the evidence before acting + +The project overview reports Git branch, commit, dirty state, and worktree count. Tool health covers the selected Herdr binary, Node, Git, and agent-browser. Compatibility advice comes from the site's pinned plugin records: it is not proof that every plugin works on the selected runtime. In particular, Conductor's supported runtime remains exactly Herdr 0.7.5. + +QA freshness compares the report's Git commit with the current repository and checks dirty state and changes observed during the test. A matching commit does not prove that a web server served a build from that commit. Review the [Browser QA workflow](Browser), assertions, and private artifacts before accepting a candidate. + +Next commands are suggestions you can copy and review. Console does not start agents, run QA, merge, push, create pull requests, approve Conductor operations, or edit state. Use [Swarm](Swarm) for the explicit PR handoff and [Conductor](Conductor) for attended lifecycle operations. Approval stays with the operator. + +Keep the configuration, saved observations, and QA artifacts private. Project paths and reports can contain sensitive information; the local dashboard is not a sharing or remote administration service. It displays artifact counts but does not serve arbitrary screenshots or diagnostics. Do not reverse-proxy or expose it publicly. diff --git a/docs-src/Home.md b/docs-src/Home.md index 2b88876..d9f27d5 100644 --- a/docs-src/Home.md +++ b/docs-src/Home.md @@ -7,11 +7,17 @@ contracts and approved single-ref apply. Browser and Guard provide supporting vi This repository is the canonical suite documentation source; each plugin README remains the detailed runtime reference. +## See your projects locally + +[Console](Console) is a read-only local app for project Git state, tool health, saved run observations, Browser QA summaries, and copyable next commands. From the suite site checkout, run `npm run console -- --demo` for labeled fixtures or `npm run console -- --config /absolute/private/console.json` for explicitly configured projects. It requires Node.js 20 or newer and binds to loopback only. + +The public website serves documentation, not a remote controller. Console is separate from the four plugins and does not launch agents, approve work, push, or create pull requests. + ## Start with Explore [Explore with Swarm](Swarm) when you have one bounded coding task and want to compare several implementations. Swarm records a base SHA, gives each agent a separate worktree and branch, reports concrete change counts, and lets the operator preview and harvest a selected candidate. -The operator defines the comparison criterion, reviews diffs and tests, and chooses what lands. Agents commit locally; the operator can explicitly publish a candidate branch for PR review. +The operator defines the comparison criterion, reviews diffs and tests, and chooses what lands. Agents commit locally; the operator can explicitly publish a candidate branch or request the GitHub draft PR handoff with SHA-bound validation evidence. PR and CI observation does not approve a merge. ## Deliver is attended @@ -28,14 +34,14 @@ compatibility with later versions. ## Supporting trust capabilities -- [Browser](Browser) launches local Chromium, attaches to an existing automation browser, or shares an agent-browser session. Recording is available for agent-browser sessions only. Sessions are a trusted same-user boundary, and recordings may contain sensitive content. +- [Browser](Browser) launches local Chromium, attaches to an existing automation browser, or shares an agent-browser session. Its standalone QA runner repeats saved assertions at desktop and mobile viewport sizes with private evidence. Recording is available for agent-browser sessions only. Sessions are a trusted same-user boundary, and artifacts may contain sensitive content. - [Guard](Guard) observes rendered terminal text with best-effort interrupts and offers an optional pre-execution harness reporter. Its Claude Code hook can deny reported Bash calls but fails open when unavailable; Guard is not a sandbox or protection against same-user bypass. ## Pinned compatibility evidence | Plugin | ID | Pinned release | Minimum Herdr | Explicitly tested | Purpose | | --- | --- | --- | --- | --- | --- | -| [Browser](Browser) | `structupath.browser` | `0.7.0` | `0.7.0` | `0.7.4` | Chromium launch, CDP attach, shared sessions, and agent-browser recording | +| [Browser](Browser) | `structupath.browser` | `0.8.0` | `0.7.0` | `0.7.4` | Chromium launch, CDP attach, shared sessions, recording, and repeatable QA | | [Guard](Guard) | `structupath.guard` | `0.2.0` | `0.7.5` | `0.7.5` | Text policy plus optional fail-open harness reporter | | [Swarm](Swarm) | `structupath.swarm` | `0.3.0` | `0.7.4` | `0.7.4`, `0.7.5` | Parallel candidates, review-first harvest, and PR publication | | [Conductor](Conductor) | `structupath.conductor` | `0.4.0` | `0.7.5` | `0.7.5` | Attended Stage 3: exact-SHA gates, preview, approval receipts, and single-ref apply | @@ -50,7 +56,7 @@ These plugins can be installed together, but the current runtime is not a single | --- | --- | --- | | Explore / Swarm | Worktree fan-out, change counts, previews, guarded harvest | Task bounds, candidate review, tests, slot selection, cleanup | | Deliver / Conductor | Task-bound roles, exact-SHA gates, preview, receipt-bound local-ref apply, and stand-down | Work direction, assertion review, operator receipts, conflicts, and ambiguous recovery | -| Browser | Chromium launch, external attach, and shared-session recording | Session privacy, action review, sensitive recording handling | +| Browser | Chromium launch, external attach, shared-session recording, and repeatable QA | Session privacy, assertion coverage, served build identity, artifact review | | Guard | Text matching, audit, interrupt attempts, and optional harness verdicts | Hook wiring, fail-open behavior, sandboxing, access control, log protection | Herdr plugins and write-capable agents run as the same operating-system user. Treat them as **trusted same-user principals**. Worktrees reduce accidental file collisions and make changes reviewable; they are not sandboxes and do not prevent a worker from accessing other same-user files or processes. @@ -86,6 +92,8 @@ herdr status server In each source checkout, run `npm run build` and `npm run validate` for Browser, Swarm, and Guard; Conductor uses `npm run check`. Browser, Swarm, and Guard also provide a read-only `npm run doctor` to identify missing prerequisites and the selected Herdr binary. Check `command -v herdr` and `herdr --version` when multiple installations exist. Do not assume a newer Herdr satisfies Conductor's exact 0.7.5 contract. -After an upgrade, verify action registration and run one bounded workflow in a separate test repository before larger work. Preserve reviewed Guard rules, explicitly wire any reporter hook, and review Swarm setup hooks and agent presets. Add Chromium through Browser's documented launch mode when visual QA is needed; it is already a supported capability. +After an upgrade, verify action registration and run one bounded workflow in a separate test repository before larger work. Preserve reviewed Guard rules, explicitly wire any reporter hook, and review Swarm setup hooks and agent presets. Browser already supports Chromium; add a committed QA scenario for the app's critical flow and run it against the intended candidate build at desktop and mobile viewport sizes. + +Use the build → validate → fix loop per candidate. Commit the passing candidate, collect fresh Browser QA evidence outside the repository, and review it in Console or directly. Then explicitly request Swarm's GitHub draft PR handoff, supplying that exact-SHA evidence. Inspect remote CI and head identity before considering a merge. These are operator-driven steps, not an automatic cross-plugin pipeline. For each change, repeat build, relevant tests, and workflow validation until passing, then publish a reviewable PR. Update these guides and the pinned manifest evidence together whenever versions, actions, or supported behavior change. diff --git a/docs-src/Swarm.md b/docs-src/Swarm.md index 2eaa96d..973a5b9 100644 --- a/docs-src/Swarm.md +++ b/docs-src/Swarm.md @@ -102,6 +102,35 @@ A first Explore run is successful when one chosen branch is merged without confl In Harvest, press `p`, then select a slot to publish its branch. The script equivalent is `scripts/harvest-step.sh publish ` from the installed plugin root. Publication uses an ordinary push to `origin`, or the explicit `HERDR_SWARM_PUBLISH_REMOTE` override; it never force-pushes and does not create a GitHub PR itself. +### Explicit GitHub draft handoff + +For a GitHub PR, press `g`, then select a slot in Harvest, or invoke `publish-pr` below. This selection or command authorizes both the audited-commit push and draft PR creation, with no second confirmation. It creates a draft or reuses the exact matching open PR; an existing ready-for-review PR stays ready. The five plugin actions remain unchanged. + +From the trusted installed Swarm checkout in the same configured run context: + +```bash +# Optional evidence must describe this slot's exact committed candidate. +HERDR_SWARM_VALIDATION_FILE=/absolute/private/qa/result.json \ + bash scripts/harvest-step.sh publish-pr 1 + +# Read GitHub PR/CI state without pushing or editing GitHub. +bash scripts/harvest-step.sh pr-status 1 +``` + +Authenticate `gh` first. The named remote (default `origin`) must have exactly one fetch and push URL identifying the same GitHub.com repository. Fork destinations and GitHub Enterprise are outside this first release. The recorded run base branch and selected slot branch determine PR identity. Existing ownership, fork ancestry, non-empty-work, and ordinary-push guards remain active. Only committed work travels. + +`HERDR_SWARM_VALIDATION_FILE` optionally accepts typed check statuses or a [Browser QA](Browser) `result.json` for the exact published SHA. Browser evidence must explicitly record a clean tree, no changes during the run, and all three scenario policies enabled: `failOnConsoleError`, `failOnPageError`, and `failOnFailedRequest`. Without a file, validation is `not_run`, never passed. Supplied evidence is unauthenticated; Swarm does not execute its checks. Failed or pending checks can accompany a draft. The PR includes bounded check names/statuses and identifiers, not task text, raw logs, URLs, screenshots, or local paths. + +An existing exact repository/head/base open PR is reused without changing its title, body, or state. `validation_attached: false` on reuse means its body may describe an older SHA; review and update that evidence manually. Closed/merged matches and ambiguous discovery refuse handoff. + +Imported Browser QA summaries must agree with per-run outcomes and counts. Unresolved requests or incomplete telemetry cannot produce a passing handoff check. + +A failed push creates no PR. A GitHub failure after a successful push leaves the published branch and local receipt intact. Inspect the error and retry the same command; a lost creation response triggers exact-PR rediscovery. A mismatched GitHub head refuses success and requires inspection, never a force-push correction. + +Press `c`, then a slot, for the pane's CI view. `pr-status` emits a `ci_status` tab-separated JSON record with `passed`, `failed`, `pending`, `not_run`, `unknown`, or `no_pr` status. Compare `head_sha` and `local_head_sha`: a passing remote check is not current candidate evidence when `matches_local_head` is false. Passing checks do not establish branch-protection completeness or authorize a merge. The command uses GitHub reads and the local run lock; it does not fetch, push, merge, or edit a PR. + +See the pinned source's `docs/github-handoff.md` for the exact evidence and output schemas. [Console](Console) can display an explicitly saved PR-status observation alongside project and QA evidence. + After a remote PR is merged, preview again. Harvest recognizes merge ancestry and squash merges whose tree changes are contained in the base. Prune uses ancestry, so squash-merged branches may remain for deliberate review. ## Safety and trust boundary diff --git a/docs-src/_Sidebar.md b/docs-src/_Sidebar.md index a0464e4..75f4d79 100644 --- a/docs-src/_Sidebar.md +++ b/docs-src/_Sidebar.md @@ -1,6 +1,8 @@ # Herdr Suite - [Overview](Home) +- **Local app** + - [Console](Console) - **Workflows** - [Explore · Swarm](Swarm) - [Deliver · Conductor (advanced)](Conductor) diff --git a/docs-src/site-qa.json b/docs-src/site-qa.json new file mode 100644 index 0000000..26c26cb --- /dev/null +++ b/docs-src/site-qa.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "name": "Suite documentation navigation", + "baseUrl": "http://127.0.0.1:4381", + "viewports": [ + {"name": "desktop", "width": 1440, "height": 900}, + {"name": "mobile", "width": 390, "height": 844} + ], + "steps": [ + {"type": "navigate", "path": "/"}, + {"type": "assertText", "selector": "h1", "contains": "Explore a change three ways."}, + {"type": "screenshot", "name": "landing"}, + {"type": "click", "selector": "#console a[href='/docs/console/']"}, + {"type": "assertUrl", "contains": "/docs/console/"}, + {"type": "assertText", "selector": "h1", "contains": "Local Console"}, + {"type": "assertText", "selector": "main", "contains": "conductorStatus"}, + {"type": "screenshot", "name": "console-guide"}, + {"type": "navigate", "path": "/docs/browser/"}, + {"type": "assertText", "selector": "main", "contains": "Repeatable desktop and mobile QA"}, + {"type": "navigate", "path": "/docs/swarm/"}, + {"type": "assertText", "selector": "h1", "contains": "Swarm"}, + {"type": "navigate", "path": "/docs/conductor/"}, + {"type": "assertText", "selector": "main", "contains": "exactly Herdr 0.7.5"}, + {"type": "navigate", "path": "/docs/guard/"}, + {"type": "assertText", "selector": "h1", "contains": "Guard"} + ] +} diff --git a/docs/browser/index.html b/docs/browser/index.html index ef1577a..b562fc8 100644 --- a/docs/browser/index.html +++ b/docs/browser/index.html @@ -35,6 +35,7 @@ Explore Deliver · advanced Trust + Console Docs @@ -46,7 +47,7 @@
Docs / Plugins

🌐 Browser (structupath.browser)

-

Supporting visibility capability. Browser lets you launch Chrome or Chromium inside a Herdr pane, attach to an existing automation browser, or share a coding agent's agent-browser session. Choose the connection that matches your workflow; agent-browser is only required for shared sessions and recording.

+

Supporting visibility capability. Browser lets you launch Chrome or Chromium inside a Herdr pane, attach to an existing automation browser, or share a coding agent's agent-browser session. Choose the connection that matches your workflow; agent-browser is required for shared sessions, recording, and standalone repeatable QA.

Repo: StructuPath/herdr-browser · Detailed reference: the repo README

Pinned evidence

@@ -59,7 +60,7 @@

Pinned evidence

- + @@ -71,11 +72,11 @@

Pinned evidence

- +
Plugin release0.7.00.8.0
Minimum Herdr
Evidence commit5fc6a9a52b4f817f21a531c288f36c3820bf93cae897f277b44d2a846049f270d84d77adc95d3728
-

The pinned source includes the merged session-preservation and stream-recovery fixes. The manifest version remains 0.7.0; the commit identifies the exact implementation behind this guide.

+

The pinned source adds standalone repeatable QA while retaining the session-preservation and stream-recovery fixes. The manifest version is 0.8.0; the commit identifies the exact implementation behind this guide.

Choose a browser connection

@@ -230,6 +231,36 @@

Viewer controls

+

Repeatable desktop and mobile QA

+

The standalone QA runner executes a saved scenario in a fresh, private agent-browser session. It needs Node.js 20 or newer, Git with a committed SHA-1 HEAD, and agent-browser 0.33.0 or newer with Chromium installed; it does not require a running Herdr server. Install the browser dependency with npm install -g agent-browser and agent-browser install. See the pinned QA contract for complete step fields, bounds, and failure handling.

+

Save and commit a scenario such as .herdr-browser-qa.json in the project you are testing:

+
{
+  "schemaVersion": 1,
+  "name": "Home page smoke",
+  "baseUrl": "http://127.0.0.1:3000",
+  "viewports": [
+    {"name": "desktop", "width": 1440, "height": 900},
+    {"name": "mobile", "width": 390, "height": 844}
+  ],
+  "steps": [
+    {"type": "navigate", "path": "/"},
+    {"type": "assertVisible", "selector": "h1"},
+    {"type": "assertText", "selector": "h1", "contains": "Welcome"},
+    {"type": "screenshot", "name": "home"}
+  ]
+}
+
+

Replace the heading assertion with text your app must show. Build and serve the intended candidate yourself, then run from a trusted Browser checkout:

+
node bin/qa.mjs check --config /project/.herdr-browser-qa.json
+node bin/qa.mjs run --config /project/.herdr-browser-qa.json \
+  --repo /project --output /private/new-qa-run \
+  --base-url http://127.0.0.1:3000 --json
+
+

The output directory must be new, outside the tested repository, with an existing parent. Omitting --output creates a private temporary directory. check validates the scenario without running a browser. run emits result.json, screenshots, and bounded diagnostics. It closes its own session and does not reuse your shared session or profile.

+

Scenarios support navigate, click, fill, waitFor, assertText, assertVisible, assertUrl, assertTitle, and screenshot; they do not accept arbitrary JavaScript or shell commands. Configure 1–4 named viewports and 1–40 steps. Mobile means viewport geometry, not touch, user-agent, or physical-device emulation.

+

The top-level failOnConsoleError, failOnPageError, and failOnFailedRequest options default to true. Results record these choices in scenario.policy. Disabling one relaxes the test: Console labels the policy as relaxed, and Swarm's direct QA evidence import requires all three enabled.

+

The result has schemaVersion: 1, kind: "herdr-browser-qa", overall status, Git commit/branch/dirty state and changedDuringRun, summary counts, and per-viewport runs. Assertions, console errors, page errors, and failed requests provide evidence for the configured scenario. They do not certify untested behavior, accessibility, security, or production readiness. A recorded Git commit does not prove the served app was built from that commit. A dirty tree or changes during the run weaken the association with the candidate.

+

Review private screenshots and diagnostics before sharing them. Console can read the explicit result.json path and compare its recorded Git identity with the project. Swarm can include a bounded validation summary in an operator-requested draft PR handoff; evidence never approves or merges a candidate automatically.

Troubleshooting and development checks

  • No Chromium found: install Chrome/Chromium or configure its executable path.
  • @@ -244,13 +275,15 @@

    Troubleshooting and development checks

    npm run validate npm run test:integration -

    Doctor checks prerequisites without launching browsers or contacting endpoints; it does not verify engine downloads, Herdr's version, or endpoint reachability. The integration command requires Node 22+, local Chromium, and agent-browser with its engine installed, and fails rather than skipping missing browser prerequisites. See the pinned readiness assessment for remaining recommendations.

    +

    Doctor checks prerequisites without launching browsers or contacting endpoints; it does not verify engine downloads, Herdr's version, or endpoint reachability. The integration command requires Node 22+, local Chromium, and agent-browser with its engine installed, and fails rather than skipping missing browser prerequisites. See the pinned readiness assessment for remaining recommendations.