framework(core): split console runner into host + pluggable agent modules
Phase 1 of the standalone agent framework. Pure refactor, behaviour identical:
- host.mjs: shared runtime (route registry, one runClaudeJson/runClaudeStream
replacing 5 inline spawns, build/git/phpCli, SSE job model, scheduler, core
/ping /auth /logout)
- agents/{in-page-console,project-builder,web-designer,qa}.mjs: each exports
register(host), owns its routes/triggers/persona
- server.mjs: bootstrap that loads host + registers agents
- web-designer exposes host.drainTasks; qa autofix kicks it
Verified: QA pass, Web Designer queue task, heartbeat, all endpoints.
2026-07-24 11:50:26 +00:00
/ * *
* QA agent — a deterministic HTTP crawler that tests the live static site ( links ,
* images , forms , API , SEO / meta , a11y ) , stores findings via the qa - * . php CLI , and
* a thin LLM step writes a summary . Read - only against the site . Auto - fixes safe
* findings by kicking the Web Designer queue ( host . drainTasks ) . Owns : / q a / r u n +
* an in - process heartbeat .
* /
import { writeFileSync , rmSync } from "node:fs" ;
2026-07-24 11:57:54 +00:00
import { site } from "../site.mjs" ;
framework(core): split console runner into host + pluggable agent modules
Phase 1 of the standalone agent framework. Pure refactor, behaviour identical:
- host.mjs: shared runtime (route registry, one runClaudeJson/runClaudeStream
replacing 5 inline spawns, build/git/phpCli, SSE job model, scheduler, core
/ping /auth /logout)
- agents/{in-page-console,project-builder,web-designer,qa}.mjs: each exports
register(host), owns its routes/triggers/persona
- server.mjs: bootstrap that loads host + registers agents
- web-designer exposes host.drainTasks; qa autofix kicks it
Verified: QA pass, Web Designer queue task, heartbeat, all endpoints.
2026-07-24 11:50:26 +00:00
let H ;
2026-07-24 11:57:54 +00:00
const QA _BASE = site . url ;
const QA _UA = ` Mozilla/5.0 (compatible; AstroAgentQA/1.0; + ${ site . url } ) ` ;
const QA _STATIC _ROUTES = site . qaRoutes ;
framework(core): split console runner into host + pluggable agent modules
Phase 1 of the standalone agent framework. Pure refactor, behaviour identical:
- host.mjs: shared runtime (route registry, one runClaudeJson/runClaudeStream
replacing 5 inline spawns, build/git/phpCli, SSE job model, scheduler, core
/ping /auth /logout)
- agents/{in-page-console,project-builder,web-designer,qa}.mjs: each exports
register(host), owns its routes/triggers/persona
- server.mjs: bootstrap that loads host + registers agents
- web-designer exposes host.drainTasks; qa autofix kicks it
Verified: QA pass, Web Designer queue task, heartbeat, all endpoints.
2026-07-24 11:50:26 +00:00
const QA _AUTOFIX = ( process . env . QA _AUTOFIX ? ? "1" ) !== "0" ;
const QA _HEARTBEAT _MIN = Number ( process . env . QA _HEARTBEAT _MIN || 60 ) ;
let qaRunning = false ;
async function probe ( url , { method = "GET" , readBody = false , timeout = 12000 , headers = { } , body = null } = { } ) {
const ctrl = new AbortController ( ) ;
const t = setTimeout ( ( ) => ctrl . abort ( ) , timeout ) ;
try {
const r = await fetch ( url , { method , redirect : "follow" , signal : ctrl . signal , headers : { "user-agent" : QA _UA , ... headers } , body } ) ;
let text = null ;
if ( readBody ) text = await r . text ( ) ;
else { try { await r . body ? . cancel ( ) ; } catch { } }
return { status : r . status , ok : r . ok , finalUrl : r . url , text } ;
} catch ( e ) {
return { status : 0 , ok : false , error : e . name === "AbortError" ? "timeout" : ( e . message || "network error" ) } ;
} finally { clearTimeout ( t ) ; }
}
async function pMap ( items , concurrency , fn ) {
const out = [ ] ; let i = 0 ;
const workers = Array . from ( { length : Math . min ( concurrency , items . length || 1 ) } , async ( ) => {
while ( i < items . length ) { const idx = i ++ ; out [ idx ] = await fn ( items [ idx ] , idx ) ; }
} ) ;
await Promise . all ( workers ) ;
return out ;
}
const qaNorm = ( p ) => { p = String ( p ) . split ( "#" ) [ 0 ] . split ( "?" ) [ 0 ] ; if ( p . length > 1 ) p = p . replace ( /\/+$/ , "" ) ; return p || "/" ; } ;
const qaAbs = ( href , pagePath ) => { try { return new URL ( href , QA _BASE + pagePath ) . href ; } catch { return null ; } } ;
const qaGrabAll = ( re , html ) => [ ... String ( html ) . matchAll ( re ) ] . map ( ( m ) => m [ 1 ] ) ;
const qaFirst = ( re , html ) => { const m = String ( html ) . match ( re ) ; return m ? m [ 1 ] . trim ( ) : "" ; } ;
function qaAltIssues ( html ) {
const out = [ ] ;
for ( const m of String ( html ) . matchAll ( /<img\b[^>]*>/gi ) ) {
const tag = m [ 0 ] ;
const src = ( tag . match ( /\bsrc=["']([^"']+)["']/i ) || [ ] ) [ 1 ] ;
if ( ! src ) continue ;
const withVal = tag . match ( /\salt\s*=\s*["']([^"']*)["']/i ) ;
if ( withVal ) { if ( withVal [ 1 ] . trim ( ) === "" ) out . push ( { src , kind : "empty" } ) ; }
else if ( /\salt(\s|>|\/)/i . test ( tag ) ) out . push ( { src , kind : "empty" } ) ;
else out . push ( { src , kind : "missing" } ) ;
}
return out ;
}
async function runQa ( ) {
const findings = [ ] ;
const add = ( check _type , severity , url , detail , fix _hint = null ) => findings . push ( { check _type , severity , url , detail , fix _hint } ) ;
let dynamic = [ ] ;
try { dynamic = JSON . parse ( ( await H . phpCli ( [ "api/cli/qa-routes.php" ] ) ) . out || "[]" ) ; } catch { }
const routes = [ ... new Set ( [ ... QA _STATIC _ROUTES , ... dynamic ] . map ( qaNorm ) ) ] ;
const pages = await pMap ( routes , 6 , async ( path ) => ( { path , r : await probe ( QA _BASE + path , { readBody : true } ) } ) ) ;
const fetched = new Map ( ) ;
const titles = new Map ( ) ;
const internal = new Set ( ) ;
const images = new Set ( ) ;
const external = new Map ( ) ;
for ( const { path , r } of pages ) {
fetched . set ( path , r . ok ) ;
if ( ! r . ok ) {
add ( "page" , "error" , path , ` Returns ${ r . status || r . error } instead of 200. ` ,
` ${ path } returns ${ r . status || r . error } instead of 200. Investigate why the page fails to render and fix it. ` ) ;
continue ;
}
const html = r . text || "" ;
const title = qaFirst ( /<title[^>]*>([^<]*)<\/title>/i , html ) ;
const desc = qaFirst ( /<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i , html ) ;
const canon = qaFirst ( /<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i , html ) ;
if ( ! title ) add ( "seo" , "warning" , path , "Missing <title>." , ` ${ path } has no <title>. Add a page-specific title via its BaseLayout props. ` ) ;
else { if ( ! titles . has ( title ) ) titles . set ( title , [ ] ) ; titles . get ( title ) . push ( path ) ; }
if ( ! desc ) add ( "seo" , "warning" , path , "Missing meta description." , ` ${ path } has no meta description. Add a page-specific description via its BaseLayout props. ` ) ;
2026-07-24 11:57:54 +00:00
if ( canon ) { try { const h = new URL ( canon ) . host ; if ( h && h !== site . host ) add ( "seo" , "error" , path , ` Canonical points to ${ h } . ` , ` ${ path } canonical points to ${ h } instead of ${ site . host } . Fix the site URL / canonical. ` ) ; } catch { } }
framework(core): split console runner into host + pluggable agent modules
Phase 1 of the standalone agent framework. Pure refactor, behaviour identical:
- host.mjs: shared runtime (route registry, one runClaudeJson/runClaudeStream
replacing 5 inline spawns, build/git/phpCli, SSE job model, scheduler, core
/ping /auth /logout)
- agents/{in-page-console,project-builder,web-designer,qa}.mjs: each exports
register(host), owns its routes/triggers/persona
- server.mjs: bootstrap that loads host + registers agents
- web-designer exposes host.drainTasks; qa autofix kicks it
Verified: QA pass, Web Designer queue task, heartbeat, all endpoints.
2026-07-24 11:50:26 +00:00
if ( ! /<html[^>]+lang=/i . test ( html ) ) add ( "a11y" , "warning" , path , "<html> has no lang attribute." , ` ${ path } <html> tag has no lang attribute. Add lang="en". ` ) ;
for ( const href of qaGrabAll ( /<a\b[^>]*\bhref=["']([^"']+)["']/gi , html ) ) {
if ( /^(mailto:|tel:|javascript:|#|data:)/i . test ( href ) ) continue ;
const u = qaAbs ( href , path ) ; if ( ! u ) continue ;
const noHash = u . split ( "#" ) [ 0 ] ;
if ( noHash . startsWith ( QA _BASE ) ) internal . add ( noHash ) ;
else if ( /^https?:\/\//i . test ( noHash ) && ! external . has ( noHash ) ) external . set ( noHash , path ) ;
}
for ( const src of qaGrabAll ( /<img\b[^>]*\bsrc=["']([^"']+)["']/gi , html ) ) { const u = qaAbs ( src , path ) ; if ( u && /^https?:/i . test ( u ) ) images . add ( u . split ( "#" ) [ 0 ] ) ; }
for ( const a of qaAltIssues ( html ) ) {
if ( a . kind === "empty" ) add ( "a11y" , "warning" , path , ` Empty alt: ${ a . src } ` ,
` On ${ path } , the image " ${ a . src } " has an empty alt attribute. If it conveys meaning (a photo, cover, or screenshot), add descriptive alt text that explains what it shows; leave it empty only if it is purely decorative. ` ) ;
else add ( "a11y" , "warning" , path , ` Missing alt: ${ a . src } ` ,
` On ${ path } , the image " ${ a . src } " has no alt attribute. Add descriptive alt text that explains what it shows. ` ) ;
}
}
for ( const [ title , paths ] of titles ) {
if ( paths . length > 1 ) add ( "seo" , "warning" , paths . join ( ", " ) , ` ${ paths . length } pages share the title " ${ title } ". ` ,
` These pages share one <title> (" ${ title } "): ${ paths . join ( ", " ) } . Give each a distinct, page-specific title. ` ) ;
}
const internalPaths = [ ... new Set ( [ ... internal ] . map ( ( u ) => qaNorm ( u . replace ( QA _BASE , "" ) || "/" ) ) ) ] ;
const toCheck = internalPaths . filter ( ( p ) => ! fetched . has ( p ) ) ;
await pMap ( toCheck , 8 , async ( p ) => {
const r = await probe ( QA _BASE + p , { } ) ;
if ( ! r . ok ) add ( "link" , "error" , p , ` Broken internal link ( ${ r . status || r . error } ). ` ,
` An internal link points to ${ p } , which returns ${ r . status || r . error } . Find that link in the page source and fix the URL or remove the link. ` ) ;
} ) ;
await pMap ( [ ... images ] , 8 , async ( u ) => {
const r = await probe ( u , { } ) ;
if ( ! r . ok ) add ( "image" , "error" , u , ` Image returns ${ r . status || r . error } . ` ,
` The image ${ u } returns ${ r . status || r . error } . Fix the image path or replace the image. ` ) ;
} ) ;
await pMap ( [ ... external . keys ( ) ] , 6 , async ( u ) => {
const r = await probe ( u , { method : "GET" , timeout : 12000 } ) ;
const clearlyBad = r . status === 404 || r . status === 410 || ( r . status === 0 && r . error && r . error !== "timeout" ) ;
if ( clearlyBad ) add ( "external" , "warning" , u , ` External link may be broken ( ${ r . status || r . error } ); found on ${ external . get ( u ) } . ` ,
` The external link ${ u } (on ${ external . get ( u ) } ) appears broken ( ${ r . status || r . error } ). Verify it and update or remove it. ` ) ;
} ) ;
const cHeaders = { "content-type" : "application/json" , origin : QA _BASE , referer : QA _BASE + "/contact" } ;
const hp = await probe ( QA _BASE + "/api/contact/submit" , {
method : "POST" , readBody : true , headers : cHeaders ,
2026-07-24 11:57:54 +00:00
body : JSON . stringify ( { name : "QA Bot" , email : site . qaEmail , subject : "other" , message : "QA honeypot probe — please ignore." , company : "qa-honeypot" } ) ,
framework(core): split console runner into host + pluggable agent modules
Phase 1 of the standalone agent framework. Pure refactor, behaviour identical:
- host.mjs: shared runtime (route registry, one runClaudeJson/runClaudeStream
replacing 5 inline spawns, build/git/phpCli, SSE job model, scheduler, core
/ping /auth /logout)
- agents/{in-page-console,project-builder,web-designer,qa}.mjs: each exports
register(host), owns its routes/triggers/persona
- server.mjs: bootstrap that loads host + registers agents
- web-designer exposes host.drainTasks; qa autofix kicks it
Verified: QA pass, Web Designer queue task, heartbeat, all endpoints.
2026-07-24 11:50:26 +00:00
} ) ;
if ( hp . status !== 200 ) add ( "form" , "error" , "/api/contact/submit" , ` Contact honeypot probe returned ${ hp . status || hp . error } (expected 200). ` ,
` POST /api/contact/submit returned ${ hp . status || hp . error } instead of 200 for a probe. The contact form endpoint may be broken — check api/public/controllers/contact.php. ` ) ;
const val = await probe ( QA _BASE + "/api/contact/submit" , {
method : "POST" , readBody : true , headers : cHeaders ,
2026-07-24 11:57:54 +00:00
body : JSON . stringify ( { name : "QA" , email : site . qaEmail , subject : "other" , message : "hi" } ) ,
framework(core): split console runner into host + pluggable agent modules
Phase 1 of the standalone agent framework. Pure refactor, behaviour identical:
- host.mjs: shared runtime (route registry, one runClaudeJson/runClaudeStream
replacing 5 inline spawns, build/git/phpCli, SSE job model, scheduler, core
/ping /auth /logout)
- agents/{in-page-console,project-builder,web-designer,qa}.mjs: each exports
register(host), owns its routes/triggers/persona
- server.mjs: bootstrap that loads host + registers agents
- web-designer exposes host.drainTasks; qa autofix kicks it
Verified: QA pass, Web Designer queue task, heartbeat, all endpoints.
2026-07-24 11:50:26 +00:00
} ) ;
if ( val . status !== 422 ) add ( "form" , "warning" , "/api/contact/submit" , ` Validation probe returned ${ val . status || val . error } (expected 422 for a too-short message). ` ,
` POST /api/contact/submit did not reject an invalid submission (got ${ val . status || val . error } , expected 422). Server-side validation may be off. ` ) ;
const health = await probe ( QA _BASE + "/api/health" , { readBody : true } ) ;
let dbOk = false ; try { const j = JSON . parse ( health . text || "{}" ) ; dbOk = ( j . data ? . db ? ? j . db ) === "connected" ; } catch { }
if ( health . status !== 200 || ! dbOk ) add ( "health" , "error" , "/api/health" , ` Status ${ health . status || health . error } , db ${ dbOk ? "connected" : "not connected" } . ` ,
` /api/health returned ${ health . status || health . error } ${ dbOk ? "" : " and the database is not connected" } . The API or database may be down. ` ) ;
const sm = await probe ( QA _BASE + "/sitemap.xml" , { readBody : true } ) ;
if ( sm . ok ) {
const locs = new Set ( qaGrabAll ( /<loc>([^<]+)<\/loc>/gi , sm . text || "" ) . map ( ( l ) => qaNorm ( l . replace ( QA _BASE , "" ) ) ) ) ;
for ( const p of routes ) if ( ! locs . has ( p ) ) add ( "sitemap" , "warning" , p , "Not listed in sitemap.xml." ,
` ${ p } is not in sitemap.xml. Add it in app/src/pages/sitemap.xml.js so search engines can find it. ` ) ;
} else {
add ( "sitemap" , "warning" , "/sitemap.xml" , ` sitemap.xml returned ${ sm . status || sm . error } . ` , ` /sitemap.xml is unreachable ( ${ sm . status || sm . error } ). Check app/src/pages/sitemap.xml.js. ` ) ;
}
const counts = { error : 0 , warning : 0 , info : 0 , pages : pages . length , links : toCheck . length , images : images . size , external : external . size } ;
for ( const f of findings ) counts [ f . severity ] = ( counts [ f . severity ] || 0 ) + 1 ;
return { findings , counts } ;
}
async function qaTriage ( findings , counts ) {
const top = [
... findings . filter ( ( f ) => f . severity === "error" ) . slice ( 0 , 12 ) ,
... findings . filter ( ( f ) => f . severity === "warning" ) . slice ( 0 , 12 ) ,
] ;
const lines = top . map ( ( f ) => ` - [ ${ f . severity } ] ${ f . check _type } ${ f . url } : ${ f . detail } ` ) . join ( "\n" ) || "(no issues found)" ;
const fallback = ` ${ counts . error } error(s) and ${ counts . warning } warning(s) across ${ counts . pages } pages. ` ;
const ask = [
2026-07-24 11:57:54 +00:00
` You are the QA agent for ${ site . host } . A crawler just tested the live site. Consult your qa skill. ` ,
framework(core): split console runner into host + pluggable agent modules
Phase 1 of the standalone agent framework. Pure refactor, behaviour identical:
- host.mjs: shared runtime (route registry, one runClaudeJson/runClaudeStream
replacing 5 inline spawns, build/git/phpCli, SSE job model, scheduler, core
/ping /auth /logout)
- agents/{in-page-console,project-builder,web-designer,qa}.mjs: each exports
register(host), owns its routes/triggers/persona
- server.mjs: bootstrap that loads host + registers agents
- web-designer exposes host.drainTasks; qa autofix kicks it
Verified: QA pass, Web Designer queue task, heartbeat, all endpoints.
2026-07-24 11:50:26 +00:00
` Counts: ${ counts . error } errors, ${ counts . warning } warnings across ${ counts . pages } pages. ` ,
"Top findings:" , lines ,
"" ,
"Write a 2-4 sentence plain-English summary for the site owner: overall health, the most important things to fix first, and whether anything is urgent. No preamble — just the summary." ,
] . join ( "\n" ) ;
const { ok , result } = await H . runClaudeJson ( { prompt : ask , tools : "Read Skill" } ) ;
const s = ok && typeof result === "string" ? result . trim ( ) . slice ( 0 , 800 ) : "" ;
return s || fallback ;
}
async function runQaFlow ( trigger ) {
if ( qaRunning ) return ;
qaRunning = true ;
let runId = 0 ;
try {
const start = await H . phpCli ( [ "api/cli/qa-start.php" , ` --trigger= ${ trigger } ` ] ) ;
runId = JSON . parse ( start . out || "{}" ) . run _id || 0 ;
if ( ! runId ) throw new Error ( "could not open a QA run" ) ;
const { findings , counts } = await runQa ( ) ;
const summary = ( trigger === "manual" || counts . error > 0 )
? await qaTriage ( findings , counts )
: ` ${ counts . error } error(s) and ${ counts . warning } warning(s) across ${ counts . pages } pages. ` ;
const tmp = ` /tmp/qa- ${ runId } .json ` ;
writeFileSync ( tmp , JSON . stringify ( findings ) ) ;
await H . phpCli ( [ "api/cli/qa-finish.php" , ` --run= ${ runId } ` , "--status=done" , ` --summary= ${ summary } ` , ` --counts= ${ JSON . stringify ( counts ) } ` , ` --findings-file= ${ tmp } ` ] ) ;
rmSync ( tmp , { force : true } ) ;
if ( QA _AUTOFIX ) {
const af = await H . phpCli ( [ "api/cli/qa-autofix.php" , ` --run= ${ runId } ` ] ) ;
let queued = 0 ; try { queued = JSON . parse ( af . out || "{}" ) . queued || 0 ; } catch { }
if ( queued > 0 && H . drainTasks ) H . drainTasks ( ) ; // fire-and-forget
}
} catch ( e ) {
if ( runId ) await H . phpCli ( [ "api/cli/qa-finish.php" , ` --run= ${ runId } ` , "--status=failed" , ` --summary=QA run failed: ${ String ( ( e && e . message ) || e ) . slice ( 0 , 180 ) } ` ] ) ;
} finally {
qaRunning = false ;
}
}
export function register ( host ) {
H = host ;
host . route ( "POST" , "/qa/run" , async ( req , res ) => {
if ( qaRunning ) return H . json ( res , 429 , { error : "A QA run is already in progress." } ) ;
const body = await H . readBody ( req ) ;
const trigger = body . trigger === "scheduled" ? "scheduled" : "manual" ;
runQaFlow ( trigger ) ; // fire-and-forget
H . json ( res , 200 , { ok : true } ) ;
} ) ;
if ( QA _HEARTBEAT _MIN > 0 ) {
host . everyMinutes ( QA _HEARTBEAT _MIN , ( ) => runQaFlow ( "scheduled" ) , { bootDelayMs : 90_000 } ) ;
console . log ( ` [qa] heartbeat every ${ QA _HEARTBEAT _MIN } min ` ) ;
}
}