feat(agentctl): wait-event op, DM/voice scenario steps, voiceJoined matcher
Add TCP wait-event for sidecar payloads, notifySidecar event log, headless joinFirstVoice/openDmPeer steps, and voiceJoined wait spec (fixes boolean myVoiceChannelId mismatch). Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -63,6 +63,8 @@ One JSON object per line (newline-delimited).
|
|||||||
| `ipc` | `payload` | UI IPC object (`type`, …) |
|
| `ipc` | `payload` | UI IPC object (`type`, …) |
|
||||||
| `view` | `light?` | Current platform view |
|
| `view` | `light?` | Current platform view |
|
||||||
| `wait` | `match`, `timeoutMs?`, `light?` | Block until view matches partial spec |
|
| `wait` | `match`, `timeoutMs?`, `light?` | Block until view matches partial spec |
|
||||||
|
| `wait-event` | `match`, `timeoutMs?`, `since?` | Block until sidecar `error` / `log` event |
|
||||||
|
| `run` | `scenario` | Run scenario JSON on attached worker |
|
||||||
| `log-tail` | `lines?` | Last N lines of `pearcord.log` |
|
| `log-tail` | `lines?` | Last N lines of `pearcord.log` |
|
||||||
| `shutdown` | — | Close agentctl server |
|
| `shutdown` | — | Close agentctl server |
|
||||||
|
|
||||||
@@ -77,8 +79,11 @@ One JSON object per line (newline-delimited).
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{ "event": "state", "view": { ... } }
|
{ "event": "state", "view": { ... } }
|
||||||
|
{ "event": "sidecar", "payload": { "type": "error", "message": "..." }, "at": 1734567890123 }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Set `PEARCORD_AGENTCTL_TOKEN` to require the same secret on every request when the server is enabled.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
| Variable | Default | Role |
|
| Variable | Default | Role |
|
||||||
@@ -100,7 +105,7 @@ See [docs/AGENTCTL.md](../../docs/AGENTCTL.md) for full reference, security note
|
|||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Binds **localhost only** — not exposed to the LAN.
|
- Binds **localhost only** — not exposed to the LAN.
|
||||||
- No authentication yet; do not enable on shared machines without firewall rules.
|
- Optional `PEARCORD_AGENTCTL_TOKEN` shared secret; still localhost-only.
|
||||||
- Can execute any UI IPC type (moderation, guild admin, etc.) — same power as the logged-in user session.
|
- Can execute any UI IPC type (moderation, guild admin, etc.) — same power as the logged-in user session.
|
||||||
|
|
||||||
## Smoke tests
|
## Smoke tests
|
||||||
@@ -108,9 +113,14 @@ See [docs/AGENTCTL.md](../../docs/AGENTCTL.md) for full reference, security note
|
|||||||
```bash
|
```bash
|
||||||
cd apps/pearcord
|
cd apps/pearcord
|
||||||
npm run test:agentctl-headless
|
npm run test:agentctl-headless
|
||||||
|
npm run test:agentctl-slash
|
||||||
|
npm run test:agentctl-dm-voice
|
||||||
|
npm run test:agentctl-wait-event
|
||||||
npm run test:agentctl-server-wire
|
npm run test:agentctl-server-wire
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Scenario step extras (headless + attached `run`): `joinFirstVoice`, `openDmPeer`, `messagesMin`, `messageContentIncludes` on `wait`.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- `pearcord-platform`, `pearcord-ui-flow`, `pearcord-log`
|
- `pearcord-platform`, `pearcord-ui-flow`, `pearcord-log`
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ function matchView (view, spec) {
|
|||||||
if (!hay.includes(String(expected))) return false
|
if (!hay.includes(String(expected))) return false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (key === 'voiceJoined') {
|
||||||
|
if (expected && !view?.myVoiceChannelId) return false
|
||||||
|
if (!expected && view?.myVoiceChannelId) return false
|
||||||
|
continue
|
||||||
|
}
|
||||||
const actual = getPath(view, key)
|
const actual = getPath(view, key)
|
||||||
if (
|
if (
|
||||||
expected &&
|
expected &&
|
||||||
@@ -49,7 +54,25 @@ function matchView (view, spec) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} evt `{ event: 'sidecar', payload: { type, ... } }`
|
||||||
|
* @param {object} spec `{ sidecarType?: 'error'|'log', level?: string, messageIncludes?: string }`
|
||||||
|
*/
|
||||||
|
function matchSidecarEvent (evt, spec) {
|
||||||
|
if (!spec || typeof spec !== 'object') return true
|
||||||
|
if (evt?.event !== 'sidecar') return false
|
||||||
|
const payload = evt.payload || {}
|
||||||
|
if (spec.sidecarType && payload.type !== spec.sidecarType) return false
|
||||||
|
if (spec.level && payload.record?.level !== spec.level) return false
|
||||||
|
if (spec.messageIncludes) {
|
||||||
|
const msg = payload.message || payload.record?.msg || ''
|
||||||
|
if (!String(msg).includes(String(spec.messageIncludes))) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getPath,
|
getPath,
|
||||||
matchView
|
matchView,
|
||||||
|
matchSidecarEvent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Usage:
|
|||||||
agentctl view [--light] [--host HOST] [--port PORT]
|
agentctl view [--light] [--host HOST] [--port PORT]
|
||||||
agentctl ipc '<json>' [--host HOST] [--port PORT]
|
agentctl ipc '<json>' [--host HOST] [--port PORT]
|
||||||
agentctl wait '<match-json>' [--timeout MS] [--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 log-tail [--lines N] [--host HOST] [--port PORT]
|
||||||
agentctl run <scenario.json> [--timeout MS] [--host HOST] [--port PORT]
|
agentctl run <scenario.json> [--timeout MS] [--host HOST] [--port PORT]
|
||||||
agentctl headless run <scenario.json>
|
agentctl headless run <scenario.json>
|
||||||
@@ -120,6 +121,14 @@ async function main () {
|
|||||||
console.log(JSON.stringify(res, null, 2))
|
console.log(JSON.stringify(res, null, 2))
|
||||||
return
|
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') {
|
if (cmd === 'run') {
|
||||||
const file = args._[1]
|
const file = args._[1]
|
||||||
if (!file) throw new Error('scenario.json path required')
|
if (!file) throw new Error('scenario.json path required')
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ class AgentClient {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async waitEvent (match, timeoutMs = 15000, since = 0) {
|
||||||
|
const res = await this.request(
|
||||||
|
'wait-event',
|
||||||
|
{ match, timeoutMs, since },
|
||||||
|
timeoutMs + 2000
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error(res.error || 'wait-event failed')
|
||||||
|
return res.event
|
||||||
|
}
|
||||||
|
|
||||||
drainEvents () {
|
drainEvents () {
|
||||||
const out = this._events.slice()
|
const out = this._events.slice()
|
||||||
this._events.length = 0
|
this._events.length = 0
|
||||||
|
|||||||
+17
@@ -100,6 +100,23 @@ class HeadlessSession {
|
|||||||
const view = await this.ipc(step.ipc)
|
const view = await this.ipc(step.ipc)
|
||||||
results.push({ ipc: step.ipc, view })
|
results.push({ ipc: step.ipc, view })
|
||||||
}
|
}
|
||||||
|
if (step.joinFirstVoice) {
|
||||||
|
const ch = (this._lastView?.channels || []).find(
|
||||||
|
(c) => c.type === 'voice' || c.type === 'stage'
|
||||||
|
)
|
||||||
|
if (!ch) throw new Error('no voice or stage channel in view')
|
||||||
|
const view = await this.ipc({ type: 'join-voice', channelId: ch.id })
|
||||||
|
results.push({ joinFirstVoice: true, channelId: ch.id, view })
|
||||||
|
}
|
||||||
|
if (step.openDmPeer) {
|
||||||
|
const peer = step.openDmPeer
|
||||||
|
const view = await this.ipc({
|
||||||
|
type: 'open-dm',
|
||||||
|
peerUserId: peer.userId,
|
||||||
|
peerDisplayName: peer.displayName || 'DM Peer'
|
||||||
|
})
|
||||||
|
results.push({ openDmPeer: peer, view })
|
||||||
|
}
|
||||||
if (step.wait) {
|
if (step.wait) {
|
||||||
const view = await this.wait(step.wait, step.timeoutMs)
|
const view = await this.wait(step.wait, step.timeoutMs)
|
||||||
results.push({ wait: step.wait, view })
|
results.push({ wait: step.wait, view })
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const net = require('bare-tcp')
|
|||||||
const fs = require('bare-fs')
|
const fs = require('bare-fs')
|
||||||
const { createLogger } = require('pearcord-log')
|
const { createLogger } = require('pearcord-log')
|
||||||
const { parseLine, encodeLine } = require('./protocol')
|
const { parseLine, encodeLine } = require('./protocol')
|
||||||
const { matchView } = require('./assert')
|
const { matchView, matchSidecarEvent } = require('./assert')
|
||||||
const { resolveAgentctlAddress, resolveLogPath } = require('./resolve')
|
const { resolveAgentctlAddress, resolveLogPath } = require('./resolve')
|
||||||
|
|
||||||
class AgentServer {
|
class AgentServer {
|
||||||
@@ -25,6 +25,9 @@ class AgentServer {
|
|||||||
this._clients = new Set()
|
this._clients = new Set()
|
||||||
this._lastView = null
|
this._lastView = null
|
||||||
this._address = null
|
this._address = null
|
||||||
|
/** @type {Array<object>} */
|
||||||
|
this._eventLog = []
|
||||||
|
this._eventLogMax = 200
|
||||||
}
|
}
|
||||||
|
|
||||||
get lastView () {
|
get lastView () {
|
||||||
@@ -58,6 +61,17 @@ class AgentServer {
|
|||||||
this.broadcast({ event: 'state', view: this._lastView })
|
this.broadcast({ event: 'state', view: this._lastView })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifySidecar (payload) {
|
||||||
|
const evt = { event: 'sidecar', payload, at: Date.now() }
|
||||||
|
this._pushEvent(evt)
|
||||||
|
this.broadcast(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
_pushEvent (evt) {
|
||||||
|
this._eventLog.push(evt)
|
||||||
|
while (this._eventLog.length > this._eventLogMax) this._eventLog.shift()
|
||||||
|
}
|
||||||
|
|
||||||
async listen (addr = resolveAgentctlAddress()) {
|
async listen (addr = resolveAgentctlAddress()) {
|
||||||
const self = this
|
const self = this
|
||||||
this._server = net.createServer((socket) => self._onConnection(socket))
|
this._server = net.createServer((socket) => self._onConnection(socket))
|
||||||
@@ -128,6 +142,25 @@ class AgentServer {
|
|||||||
await this.refreshView(false)
|
await this.refreshView(false)
|
||||||
results.push({ ipc: step.ipc, ok: true })
|
results.push({ ipc: step.ipc, ok: true })
|
||||||
}
|
}
|
||||||
|
if (step.joinFirstVoice) {
|
||||||
|
const ch = (this._lastView?.channels || []).find(
|
||||||
|
(c) => c.type === 'voice' || c.type === 'stage'
|
||||||
|
)
|
||||||
|
if (!ch) throw new Error('no voice or stage channel in view')
|
||||||
|
await this.dispatchIpc({ type: 'join-voice', channelId: ch.id })
|
||||||
|
await this.refreshView(false)
|
||||||
|
results.push({ joinFirstVoice: true, channelId: ch.id })
|
||||||
|
}
|
||||||
|
if (step.openDmPeer) {
|
||||||
|
const peer = step.openDmPeer
|
||||||
|
await this.dispatchIpc({
|
||||||
|
type: 'open-dm',
|
||||||
|
peerUserId: peer.userId,
|
||||||
|
peerDisplayName: peer.displayName || 'DM Peer'
|
||||||
|
})
|
||||||
|
await this.refreshView(false)
|
||||||
|
results.push({ openDmPeer: peer })
|
||||||
|
}
|
||||||
if (step.wait) {
|
if (step.wait) {
|
||||||
const timeoutMs = Number(step.timeoutMs) || 15000
|
const timeoutMs = Number(step.timeoutMs) || 15000
|
||||||
const light = !!step.light
|
const light = !!step.light
|
||||||
@@ -223,6 +256,30 @@ class AgentServer {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.op === 'wait-event') {
|
||||||
|
const timeoutMs = Number(req.timeoutMs) || 15000
|
||||||
|
const spec = req.match || {}
|
||||||
|
const since = Number(req.since) || 0
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
for (const evt of this._eventLog) {
|
||||||
|
if ((evt.at || 0) >= since && matchSidecarEvent(evt, spec)) {
|
||||||
|
this._reply(socket, { ...base, ok: true, event: evt })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await sleep(50)
|
||||||
|
for (const evt of this._eventLog) {
|
||||||
|
if ((evt.at || 0) >= since && matchSidecarEvent(evt, spec)) {
|
||||||
|
this._reply(socket, { ...base, ok: true, event: evt })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._reply(socket, { ...base, ok: false, error: 'wait-event timeout' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (req.op === 'run') {
|
if (req.op === 'run') {
|
||||||
const scenarioPath = req.scenario
|
const scenarioPath = req.scenario
|
||||||
if (!scenarioPath) {
|
if (!scenarioPath) {
|
||||||
|
|||||||
Reference in New Issue
Block a user