Add waitLogContains matcher for agentctl scenario runs.

Introduce bounded log polling in attached and headless scenario runners so startup hardening can be asserted directly from structured pearcord.log lines.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-06-02 11:01:07 -04:00
co-authored by Cursor
parent 1a40cd8b88
commit 3fb37af5ad
3 changed files with 132 additions and 11 deletions
+1 -1
View File
@@ -481,7 +481,7 @@ Bundled scenarios under `apps/pearcord/scenarios/`:
`mesh.connectGuildMeshThread(scenarioPath)` — optional `markThreadAndParentRead: true` in scenario JSON (v0.8.279). host steps + invite + `create-thread` + reply; guest must receive `threadReply` token.
Scenario step extras (headless + attached `run`): `joinFirstVoice`, `joinFirstStage`, `stageFixtureSticker`, `sendMessageWithStagedStickers`, `openDmPeer`, `selectFirstForumChannel`, `selectSecondForumChannel`, `selectFirstTextChannel`, `selectSecondTextChannel`, `selectChannelNamed`, `selectChannelOtherThanActive`, `selectParentOfActiveThread`, `selectActiveThread`, `threadName` with `selectActiveThread`, `markActiveChannelRead`, `seedInboxNotification`, `markLatestNotificationRead`, `openLatestNotification`, `seedMentionAlertForActiveChannel`, `createThreadFromLastMessage`, `markThreadAndParentRead`, `toggleReactionOnLastMessage` (emoji string or `{ emoji }`), `stageFixtureAttachment` (`{ image: true }` for PNG), `sendMessageWithStagedAttachments`, `requestPreviewForStagedAttachment`, `messagesMin`, `messageContentIncludes`, `searchResultsMin`, `searchResultsMax`, `searchResultsCached`, `searchUnchanged`, `searchRecentIncludes`, `mentionAlertCountMax`, `notificationUnreadMin`, `notificationUnreadMax`, `reactionCountMin`, `reactionsInclude`, `attachmentsOnLastMessageMin`, `stickersOnLastMessageMin`, `activeChannelName`, `parentChannelPermissions` on `wait`. `waitEvent` supports `payloadType` (e.g. `attachment-preview`).
Scenario step extras (headless + attached `run`): `joinFirstVoice`, `joinFirstStage`, `stageFixtureSticker`, `sendMessageWithStagedStickers`, `openDmPeer`, `selectFirstForumChannel`, `selectSecondForumChannel`, `selectFirstTextChannel`, `selectSecondTextChannel`, `selectChannelNamed`, `selectChannelOtherThanActive`, `selectParentOfActiveThread`, `selectActiveThread`, `threadName` with `selectActiveThread`, `markActiveChannelRead`, `seedInboxNotification`, `markLatestNotificationRead`, `openLatestNotification`, `seedMentionAlertForActiveChannel`, `createThreadFromLastMessage`, `markThreadAndParentRead`, `toggleReactionOnLastMessage` (emoji string or `{ emoji }`), `stageFixtureAttachment` (`{ image: true }` for PNG), `sendMessageWithStagedAttachments`, `requestPreviewForStagedAttachment`, `messagesMin`, `messageContentIncludes`, `searchResultsMin`, `searchResultsMax`, `searchResultsCached`, `searchUnchanged`, `searchRecentIncludes`, `mentionAlertCountMax`, `notificationUnreadMin`, `notificationUnreadMax`, `reactionCountMin`, `reactionsInclude`, `attachmentsOnLastMessageMin`, `stickersOnLastMessageMin`, `activeChannelName`, `parentChannelPermissions` on `wait`, and `waitLogContains` (`messageIncludes`/`contains` and optional `regex`/`lines`) for structured log verification. `waitEvent` supports `payloadType` (e.g. `attachment-preview`).
## Dependencies
+64
View File
@@ -112,6 +112,66 @@ class HeadlessSession {
throw new Error(`headless wait timeout: ${JSON.stringify(match)}`)
}
_tailLogLines (lines = 200) {
const cap = Math.min(Math.max(1, Number(lines) || 200), 1000)
const logPath = path.join(this.storagePath, 'pearcord.log')
let text = ''
try {
text = fs.readFileSync(logPath, 'utf8')
} catch {
return { logPath, lines: [] }
}
return {
logPath,
lines: text.split('\n').filter(Boolean).slice(-cap)
}
}
async waitLogContains (spec, timeoutMs = 15000) {
const cfg =
typeof spec === 'string'
? { messageIncludes: spec }
: spec && typeof spec === 'object'
? spec
: {}
const messageIncludes = String(cfg.messageIncludes || cfg.contains || '').trim()
const regexSource = String(cfg.regex || '').trim()
const linesCap = Math.min(Math.max(1, Number(cfg.lines) || 300), 1000)
if (!messageIncludes && !regexSource) {
throw new Error('headless wait.logContains requires messageIncludes/contains or regex')
}
let regex = null
if (regexSource) {
try {
regex = new RegExp(regexSource)
} catch (err) {
throw new Error(`headless wait.logContains invalid regex: ${err?.message || String(err)}`)
}
}
const deadline = Date.now() + timeoutMs
let lastTail = []
let logPath = path.join(this.storagePath, 'pearcord.log')
while (Date.now() < deadline) {
const tail = this._tailLogLines(linesCap)
logPath = tail.logPath
lastTail = tail.lines
for (const line of lastTail) {
const includesOk = messageIncludes ? line.includes(messageIncludes) : true
const regexOk = regex ? regex.test(line) : true
if (includesOk && regexOk) return { line, logPath }
}
await new Promise((r) => setTimeout(r, 100))
}
throw new Error(
`headless wait.logContains timeout: ${JSON.stringify({
messageIncludes: messageIncludes || null,
regex: regexSource || null,
logPath,
inspectedLines: lastTail.length
})}`
)
}
/**
* @param {Array<{ ipc?: object, wait?: object, timeoutMs?: number }>} steps
*/
@@ -506,6 +566,10 @@ class HeadlessSession {
}
results.push({ waitEvent: step.waitEvent, payload: matched })
}
if (step.waitLogContains) {
const matched = await this.waitLogContains(step.waitLogContains, step.timeoutMs)
results.push({ waitLogContains: step.waitLogContains, matched })
}
if (step.createWebhookActiveChannel) {
const spec =
step.createWebhookActiveChannel && typeof step.createWebhookActiveChannel === 'object'
+67 -10
View File
@@ -153,6 +153,66 @@ class AgentServer {
return req?.token === expected
}
_logTailLines (lines = 200) {
const cap = Math.min(Math.max(1, Number(lines) || 200), 1000)
const logPath = resolveLogPath(this.storagePath)
let text = ''
try {
text = fs.readFileSync(logPath, 'utf8')
} catch {
return { logPath, lines: [] }
}
return {
logPath,
lines: text.split('\n').filter(Boolean).slice(-cap)
}
}
async _waitLogContains (spec, timeoutMs = 15000) {
const cfg =
typeof spec === 'string'
? { messageIncludes: spec }
: spec && typeof spec === 'object'
? spec
: {}
const messageIncludes = String(cfg.messageIncludes || cfg.contains || '').trim()
const regexSource = String(cfg.regex || '').trim()
const linesCap = Math.min(Math.max(1, Number(cfg.lines) || 300), 1000)
if (!messageIncludes && !regexSource) {
throw new Error('waitLogContains requires messageIncludes/contains or regex')
}
let regex = null
if (regexSource) {
try {
regex = new RegExp(regexSource)
} catch (err) {
throw new Error(`waitLogContains invalid regex: ${err?.message || String(err)}`)
}
}
const deadline = Date.now() + timeoutMs
let lastTail = []
let logPath = resolveLogPath(this.storagePath)
while (Date.now() < deadline) {
const tail = this._logTailLines(linesCap)
logPath = tail.logPath
lastTail = tail.lines
for (const line of lastTail) {
const includesOk = messageIncludes ? line.includes(messageIncludes) : true
const regexOk = regex ? regex.test(line) : true
if (includesOk && regexOk) return { line, logPath }
}
await sleep(100)
}
throw new Error(
`wait.logContains timeout: ${JSON.stringify({
messageIncludes: messageIncludes || null,
regex: regexSource || null,
logPath,
inspectedLines: lastTail.length
})}`
)
}
async _runScenarioSteps (steps) {
const results = []
for (const step of steps || []) {
@@ -230,6 +290,11 @@ class AgentServer {
}
results.push({ waitEvent: step.waitEvent, event: matched })
}
if (step.waitLogContains) {
const timeoutMs = Number(step.timeoutMs) || 15000
const matched = await this._waitLogContains(step.waitLogContains, timeoutMs)
results.push({ waitLogContains: step.waitLogContains, matched })
}
}
return results
}
@@ -368,16 +433,8 @@ class AgentServer {
if (req.op === 'log-tail') {
const lines = Math.min(Number(req.lines) || 40, 500)
const logPath = resolveLogPath(this.storagePath)
let text = ''
try {
text = fs.readFileSync(logPath, 'utf8')
} catch {
this._reply(socket, { ...base, ok: true, lines: [], logPath })
return
}
const tail = text.split('\n').filter(Boolean).slice(-lines)
this._reply(socket, { ...base, ok: true, logPath, lines: tail })
const tail = this._logTailLines(lines)
this._reply(socket, { ...base, ok: true, logPath: tail.logPath, lines: tail.lines })
return
}