Add a runtime helper and gate module loading so preflight/doctor/ping run in Node without pulling Bare-only modules, while preserving Bare paths for headless flows and worker-attached startup. Co-authored-by: Cursor <[email protected]>
410 lines
13 KiB
JavaScript
Executable File
410 lines
13 KiB
JavaScript
Executable File
#!/usr/bin/env bare
|
|
'use strict'
|
|
|
|
const { isBareRuntime } = require('../runtime')
|
|
const fs = isBareRuntime() ? require('bare-fs') : require('fs')
|
|
const path = isBareRuntime() ? require('bare-path') : require('path')
|
|
const { resolveAgentctlAddress, resolveLogPath } = require('../resolve')
|
|
|
|
function loadRuntimeClientApi () {
|
|
const { AgentClient } = require('../client')
|
|
return { AgentClient }
|
|
}
|
|
|
|
function loadRuntimeHeadlessApi () {
|
|
const { HeadlessSession, PearcordMeshCluster } = require('..')
|
|
return { HeadlessSession, PearcordMeshCluster }
|
|
}
|
|
|
|
function usage () {
|
|
console.log(`pearcord-agentctl — control a running Pearcord worker or run headless IPC
|
|
|
|
Usage:
|
|
agentctl preflight
|
|
agentctl doctor [--host HOST] [--port PORT]
|
|
agentctl ping [--host HOST] [--port PORT] [--retries N] [--retry-delay MS] [--wait-listening]
|
|
agentctl wait-ready [--host HOST] [--port PORT] [--timeout MS]
|
|
agentctl view [--light] [--host HOST] [--port PORT]
|
|
agentctl ipc '<json>' [--host HOST] [--port PORT]
|
|
agentctl wait '<match-json>' [--timeout MS] [--host HOST] [--port PORT]
|
|
agentctl wait-event '<match-json>' [--timeout MS] [--since MS]
|
|
agentctl log-tail [--lines N] [--host HOST] [--port PORT]
|
|
agentctl run <scenario.json> [--timeout MS] [--host HOST] [--port PORT]
|
|
agentctl headless run <scenario.json>
|
|
agentctl headless mesh run <scenario.json> [--peers N]
|
|
agentctl headless ipc '<json>'
|
|
agentctl mesh run <scenario.json> [--peers N]
|
|
|
|
Environment:
|
|
PEARCORD_AGENTCTL=1 Enable TCP server in apps/pearcord worker
|
|
PEARCORD_AGENTCTL_HOST Default 127.0.0.1
|
|
PEARCORD_AGENTCTL_PORT Default 39482
|
|
PEARCORD_AGENTCTL_TOKEN Optional shared secret for TCP ops
|
|
PEARCORD_STORAGE Storage root (~/.config/pearcord)
|
|
|
|
Start Pearcord with agentctl:
|
|
PEARCORD_AGENTCTL=1 pear run
|
|
`)
|
|
}
|
|
|
|
function parseArgs (argv) {
|
|
const out = {
|
|
_: [],
|
|
host: null,
|
|
port: null,
|
|
light: false,
|
|
timeout: 15000,
|
|
lines: 40,
|
|
retries: 3,
|
|
retryDelay: 500,
|
|
waitListening: false
|
|
}
|
|
for (let i = 2; i < argv.length; i++) {
|
|
const a = argv[i]
|
|
if (a === '--host') out.host = argv[++i]
|
|
else if (a === '--port') out.port = Number(argv[++i])
|
|
else if (a === '--light') out.light = true
|
|
else if (a === '--timeout') out.timeout = Number(argv[++i])
|
|
else if (a === '--lines') out.lines = Number(argv[++i])
|
|
else if (a === '--retries') out.retries = Math.max(1, Number(argv[++i]) || 1)
|
|
else if (a === '--retry-delay') out.retryDelay = Math.max(50, Number(argv[++i]) || 500)
|
|
else if (a === '--wait-listening') out.waitListening = true
|
|
else if (a === '-h' || a === '--help') out.help = true
|
|
else out._.push(a)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function clientFromArgs (args) {
|
|
const { AgentClient } = loadRuntimeClientApi()
|
|
const def = resolveAgentctlAddress()
|
|
return new AgentClient({
|
|
host: args.host || def.host,
|
|
port: args.port || def.port
|
|
})
|
|
}
|
|
|
|
function sleep (ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
function classifyCliError (err) {
|
|
const msg = String(err?.message || err || '')
|
|
if (msg.includes('Cannot find module')) return 'EMODULE_MISSING'
|
|
if (msg.includes('startup timeout')) return 'ESTARTUP_TIMEOUT'
|
|
if (msg.includes('connect timeout')) return 'ECONN_TIMEOUT'
|
|
if (msg.includes('request timeout')) return 'EREQUEST_TIMEOUT'
|
|
if (msg.includes('ECONNREFUSED') || msg.includes('connection refused')) return 'ECONN_REFUSED'
|
|
if (msg.includes('EHOSTUNREACH')) return 'EHOST_UNREACHABLE'
|
|
return 'EUNKNOWN'
|
|
}
|
|
|
|
function remediationFor (code, details = {}) {
|
|
if (code === 'EMODULE_MISSING') {
|
|
return [
|
|
'Install local module links first:',
|
|
'cd ../../modules/pearcord-platform && npm install',
|
|
'cd ../pearcord-agentctl && npm install'
|
|
]
|
|
}
|
|
if (code === 'ECONN_REFUSED') {
|
|
const hints = [
|
|
'Ensure Pearcord is running with agentctl enabled:',
|
|
'PEARCORD_AGENTCTL=1 pear run'
|
|
]
|
|
if (!process.env.PEARCORD_AGENTCTL) {
|
|
hints.push('Current shell does not set PEARCORD_AGENTCTL=1.')
|
|
}
|
|
hints.push(`Verify host/port (${details.host}:${details.port}) and retry with --host/--port if needed.`)
|
|
return hints
|
|
}
|
|
if (code === 'ECONN_TIMEOUT' || code === 'EREQUEST_TIMEOUT') {
|
|
return [
|
|
'Worker startup is still in progress or blocked.',
|
|
'Retry with --wait-listening or increase --timeout.',
|
|
'Check logs with: agentctl log-tail --lines 120'
|
|
]
|
|
}
|
|
if (code === 'ESTARTUP_TIMEOUT') {
|
|
return [
|
|
'Agentctl server did not become reachable before timeout.',
|
|
'Launch worker with PEARCORD_AGENTCTL=1 pear run and retry wait-ready.',
|
|
'Check logs with: agentctl log-tail --lines 120'
|
|
]
|
|
}
|
|
if (code === 'EHOST_UNREACHABLE') {
|
|
return ['The host is unreachable; verify --host value and local network config.']
|
|
}
|
|
return ['Inspect pearcord.log and retry with --wait-listening.']
|
|
}
|
|
|
|
function printCliError (err, details = {}) {
|
|
const code = classifyCliError(err)
|
|
const payload = {
|
|
ok: false,
|
|
errorCode: code,
|
|
message: String(err?.message || err || 'unknown error'),
|
|
hints: remediationFor(code, details)
|
|
}
|
|
console.error(JSON.stringify(payload, null, 2))
|
|
}
|
|
|
|
function runPreflightChecks () {
|
|
const required = [
|
|
'pearcord-platform',
|
|
'pearcord-ui-flow',
|
|
'pearcord-log',
|
|
'pearcord-guild-sidecar'
|
|
]
|
|
const results = required.map((name) => {
|
|
try {
|
|
require.resolve(name)
|
|
return { name, ok: true }
|
|
} catch (err) {
|
|
return { name, ok: false, error: err?.message || String(err) }
|
|
}
|
|
})
|
|
const failed = results.filter((r) => !r.ok)
|
|
const out = { ok: failed.length === 0, checks: results }
|
|
if (failed.length) {
|
|
out.errorCode = 'EMODULE_MISSING'
|
|
out.hints = remediationFor('EMODULE_MISSING')
|
|
}
|
|
return out
|
|
}
|
|
|
|
function doctorFromArgs (args) {
|
|
const def = resolveAgentctlAddress()
|
|
const host = args.host || def.host
|
|
const port = args.port || def.port
|
|
const listeningEvidence = findListeningEvidence(host, port)
|
|
return {
|
|
ok: true,
|
|
mode: process.env.PEARCORD_AGENTCTL === '1' ? 'attached-enabled' : 'attached-disabled',
|
|
expectedRuntime: {
|
|
host,
|
|
port,
|
|
tokenMode: process.env.PEARCORD_AGENTCTL_TOKEN ? 'required' : 'off'
|
|
},
|
|
listeningEvidence,
|
|
env: {
|
|
PEARCORD_AGENTCTL: process.env.PEARCORD_AGENTCTL || null,
|
|
PEARCORD_AGENTCTL_HOST: process.env.PEARCORD_AGENTCTL_HOST || null,
|
|
PEARCORD_AGENTCTL_PORT: process.env.PEARCORD_AGENTCTL_PORT || null,
|
|
PEARCORD_AGENTCTL_TOKEN: process.env.PEARCORD_AGENTCTL_TOKEN ? '[set]' : null
|
|
}
|
|
}
|
|
}
|
|
|
|
function findListeningEvidence (host, port) {
|
|
const logPath = resolveLogPath()
|
|
let text = ''
|
|
try {
|
|
text = fs.readFileSync(logPath, 'utf8')
|
|
} catch {
|
|
return { seen: false, logPath, reason: 'log-not-found' }
|
|
}
|
|
const lines = text.split('\n')
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const line = lines[i]
|
|
if (!line || !line.includes('agentctl listening')) continue
|
|
const hostMatch = line.includes(`"host":"${host}"`) || line.includes(`host":"${host}"`)
|
|
const portMatch = line.includes(`"port":${port}`) || line.includes(`port":${port}`)
|
|
return {
|
|
seen: true,
|
|
hostMatches: hostMatch,
|
|
portMatches: portMatch,
|
|
matchesExpected: !!hostMatch && !!portMatch,
|
|
logPath,
|
|
line: line.trim()
|
|
}
|
|
}
|
|
return { seen: false, logPath, reason: 'no-agentctl-listening-line' }
|
|
}
|
|
|
|
async function pingWithRetry (client, args) {
|
|
const attempts = args.waitListening ? Math.max(args.retries, 8) : args.retries
|
|
let lastErr = null
|
|
for (let i = 1; i <= attempts; i++) {
|
|
try {
|
|
const res = await client.ping()
|
|
return { ...res, attempts: i }
|
|
} catch (err) {
|
|
lastErr = err
|
|
if (i < attempts) await sleep(Math.min(args.retryDelay * i, 2000))
|
|
}
|
|
}
|
|
throw lastErr
|
|
}
|
|
|
|
async function main () {
|
|
const args = parseArgs(process.argv)
|
|
if (args.help || !args._.length) {
|
|
usage()
|
|
process.exit(args.help ? 0 : 1)
|
|
}
|
|
const cmd = args._[0]
|
|
if (cmd === 'preflight') {
|
|
const out = runPreflightChecks()
|
|
console.log(JSON.stringify(out, null, 2))
|
|
if (!out.ok) process.exit(2)
|
|
return
|
|
}
|
|
if (cmd === 'doctor') {
|
|
console.log(JSON.stringify(doctorFromArgs(args), null, 2))
|
|
return
|
|
}
|
|
|
|
if (cmd === 'headless') {
|
|
const { HeadlessSession, PearcordMeshCluster } = loadRuntimeHeadlessApi()
|
|
const sub = args._[1]
|
|
if (sub === 'mesh') {
|
|
const meshSub = args._[2]
|
|
if (meshSub !== 'run') throw new Error('use: headless mesh run <scenario.json>')
|
|
const file = args._[3]
|
|
if (!file) throw new Error('scenario.json path required')
|
|
const cluster = new PearcordMeshCluster({ peerCount: 3 })
|
|
try {
|
|
const result = await cluster.runScenario(path.resolve(file))
|
|
console.log(JSON.stringify({ ok: true, result }, null, 2))
|
|
} finally {
|
|
await cluster.close()
|
|
}
|
|
return
|
|
}
|
|
const session = new HeadlessSession()
|
|
await session.start()
|
|
try {
|
|
if (sub === 'run') {
|
|
const file = args._[2]
|
|
if (!file) throw new Error('scenario.json path required')
|
|
const results = await session.runScenario(path.resolve(file))
|
|
console.log(JSON.stringify({ ok: true, steps: results.length, view: session.lastView }, null, 2))
|
|
return
|
|
}
|
|
if (sub === 'ipc') {
|
|
const raw = args._[2]
|
|
if (!raw) throw new Error('ipc json required')
|
|
const payload = JSON.parse(raw)
|
|
const view = await session.ipc(payload)
|
|
console.log(JSON.stringify({ ok: true, view }, null, 2))
|
|
return
|
|
}
|
|
throw new Error(`unknown headless subcommand: ${sub}`)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
}
|
|
|
|
if (cmd === 'mesh') {
|
|
const { PearcordMeshCluster } = loadRuntimeHeadlessApi()
|
|
const meshSub = args._[1]
|
|
if (meshSub !== 'run') throw new Error('use: mesh run <scenario.json>')
|
|
const file = args._[2]
|
|
if (!file) throw new Error('scenario.json path required')
|
|
const cluster = new PearcordMeshCluster({ peerCount: 3 })
|
|
try {
|
|
const result = await cluster.runScenario(path.resolve(file))
|
|
console.log(JSON.stringify({ ok: true, result }, null, 2))
|
|
} finally {
|
|
await cluster.close()
|
|
}
|
|
return
|
|
}
|
|
|
|
const client = clientFromArgs(args)
|
|
try {
|
|
if (cmd === 'wait-ready') {
|
|
const deadline = Date.now() + Math.max(args.timeout, 1000)
|
|
let attempts = 0
|
|
while (Date.now() < deadline) {
|
|
attempts++
|
|
try {
|
|
const res = await client.ping()
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
ready: true,
|
|
attempts,
|
|
host: client.host,
|
|
port: client.port,
|
|
tokenMode: client.token ? 'required' : 'off',
|
|
response: res
|
|
}, null, 2))
|
|
return
|
|
} catch {
|
|
await sleep(400)
|
|
}
|
|
}
|
|
throw new Error('agentctl startup timeout: worker did not become ready')
|
|
}
|
|
if (cmd === 'ping') {
|
|
const res = await pingWithRetry(client, args)
|
|
console.log(JSON.stringify({
|
|
...res,
|
|
host: client.host,
|
|
port: client.port,
|
|
tokenMode: client.token ? 'required' : 'off'
|
|
}, null, 2))
|
|
return
|
|
}
|
|
if (cmd === 'view') {
|
|
const view = await client.view(args.light)
|
|
console.log(JSON.stringify({ ok: true, view }, null, 2))
|
|
return
|
|
}
|
|
if (cmd === 'ipc') {
|
|
const raw = args._[1]
|
|
if (!raw) throw new Error('ipc json required')
|
|
const payload = JSON.parse(raw)
|
|
const res = await client.ipc(payload)
|
|
console.log(JSON.stringify(res, null, 2))
|
|
return
|
|
}
|
|
if (cmd === 'wait') {
|
|
const raw = args._[1]
|
|
if (!raw) throw new Error('match json required')
|
|
const match = JSON.parse(raw)
|
|
const view = await client.wait(match, args.timeout, args.light)
|
|
console.log(JSON.stringify({ ok: true, view }, null, 2))
|
|
return
|
|
}
|
|
if (cmd === 'log-tail') {
|
|
const res = await client.logTail(args.lines)
|
|
console.log(JSON.stringify(res, null, 2))
|
|
return
|
|
}
|
|
if (cmd === 'wait-event') {
|
|
const raw = args._[1]
|
|
if (!raw) throw new Error('match json required')
|
|
const match = JSON.parse(raw)
|
|
const event = await client.waitEvent(match, args.timeout, 0)
|
|
console.log(JSON.stringify({ ok: true, event }, null, 2))
|
|
return
|
|
}
|
|
if (cmd === 'run') {
|
|
const file = args._[1]
|
|
if (!file) throw new Error('scenario.json path required')
|
|
const res = await client.run(path.resolve(file), args.timeout || 120000)
|
|
console.log(JSON.stringify(res, null, 2))
|
|
return
|
|
}
|
|
throw new Error(`unknown command: ${cmd}`)
|
|
} finally {
|
|
client.close()
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
const argv = typeof process !== 'undefined' && Array.isArray(process.argv)
|
|
? process.argv
|
|
: ['agentctl']
|
|
const args = parseArgs(argv)
|
|
const def = resolveAgentctlAddress()
|
|
printCliError(err, {
|
|
host: args.host || def.host,
|
|
port: args.port || def.port
|
|
})
|
|
if (typeof process !== 'undefined' && typeof process.exit === 'function') process.exit(1)
|
|
throw err
|
|
})
|