fix(api): persist stdout for automation-triggered script runs (#3162) - #3186
Conversation
Automation `run_script` actions queued the device command with a synthetic
`${runId}:${deviceId}:${actionIndex}` executionId. `handleScriptResult`
matches that against `script_executions.id`, a uuid column, so Postgres
raised 22P02, the throw landed in a caught console.error, and the agent's
real stdout was discarded. Script output from automations was therefore
invisible everywhere in the product.
- `executeRunScriptAction` now mints a real `script_executions` row
(org_id = the DEVICE's org, trigger_type = new 'automation' value,
automation_run_id = the run) and passes its uuid as the executionId, so
the existing result handler persists stdout/stderr/exitCode and the run
shows up in the device's script history alongside manual runs.
- Post-queue status transitions are guarded on the row still being
`pending`: the command is delivered over the WebSocket synchronously, so
a fast agent can drive the row terminal before we get back, and an
unguarded write would flip a completed row back to `queued` for the
stale-command reaper to later mark `timeout`. On actual delivery the row
goes to `running` with a start time, mirroring the manual path.
- Offline devices are checked up front rather than minting an execution row
that queueing would immediately fail — a daily automation over a fleet of
asleep laptops would otherwise add one failed row per device per run.
- `handleScriptResult` skips non-uuid executionIds with a warning instead of
throwing. The `execute_command` action still sends a synthetic id (it has
no `scripts` row) and the Go agent rejects an empty executionId, so the
id can't simply be dropped.
- The run-detail endpoint attaches per-device `scriptResults`, and the
automation run-history panel renders a collapsible stdout/stderr block.
- `resource.volume_outlier` now excludes automation executions: scheduled
machine activity with no human behind it would otherwise pin the abuse
signal for an ordinary MSP.
Closes #3162
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
e6d680b
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://9e37ca25.breeze-9te.pages.dev |
| Branch Preview URL: | https://fix-3162-automation-script-s.breeze-9te.pages.dev |
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the #3162 fix, from /pr-review-toolkit:review-pr. Correctness - Drop the synthetic executionId from the `execute_command` action entirely. The Go agent never reads it (`handleScript` keys the execution on `cmd.ID`; only `script_cancel` requires the payload field), so nothing needed it — it existed solely to feed a uuid column and crash. This removes the last producer of non-uuid executionIds and the per-device-per-run warn spam. - Remove the device-status pre-check. It read the run's device snapshot, taken once at the top of a fleet run and minutes stale, so a device that came online mid-run was silently skipped — and `execute_command` still got `queueCommandForExecution`'s live check, so the two diverged. - Discard the minted execution row when the command never reaches the queue, instead of marking it `failed`. Restores the pre-#3162 "no row" outcome for offline devices without the stale snapshot, and stops the reaper from later relabelling it `timeout` ("no response from agent") when no agent ever had it. - Wrap the queue call in try/catch: it does several DB round-trips plus a JIT decrypt, any of which can throw rather than return `{ error }`, which previously stranded the row at `pending`. - Truncate `stdout`/`stderr` in SQL (16k/8k) with explicit `*Truncated` flags. The run-detail response is re-polled every few seconds and stdout is accepted up to 5MB per execution. Silent failures - `handleScriptResult`'s catch — the exact mechanism that hid #3162 — now calls `captureException` like its SNMP sibling, as does the non-uuid guard. - The discard reports when it removes nothing, and the web parser warns when it drops every script row (a backend contract break otherwise renders as "this run queued no scripts"). UI - Render execution status: `pending`/`queued`/`running` show "waiting for the agent" rather than the empty-output placeholder. Collapsing those into "the script printed nothing" recreated the exact ambiguity #3162 was filed about. - Keep polling an expanded run while any execution is non-terminal. A run goes terminal as soon as its commands are queued, so the parent's run-list poll had already stopped by the time stdout arrived — the first view froze until the user collapsed and re-expanded the row. Comments (six were verifiably wrong) - The Go agent does NOT require executionId; the reaper does NOT join device_commands; readers filter on `automation_run_id`, they don't LEFT JOIN it; the stompable transition is `running`, not `queued`; only the delivered branch mirrors the manual path; and the abuse-signal exclusion does not de-noise the unfiltered commands axis. Corrected each, plus "below" cross-references that will rot. Tests - Pin PG_UUID_REGEX over UUID_REGEX with a uuid Postgres accepts but RFC-4122 rejects — "tightening" that guard would silently drop real output again. - Cover discard-on-queue-failure, discard-on-throw, the 0-row discard warning, and that the online/offline decision is left to queueCommandForExecution. - Assert the executions query is keyed on automation_run_id, that two actions on one device both survive grouping, that an RLS-hidden script name still yields an output row, and that oversized stdout is truncated + flagged. - Web: awaiting-vs-empty, truncation notice, and both polling directions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review run: Findings: 0 CRITICAL. 8 IMPORTANT + 6 factually-wrong comments raised → all addressed in
Tests:
Not verified locally: the RLS/integration contract suites and Known follow-ups, deliberately out of scope:
Status: review-clean, awaiting maintainer merge — gated on a real CI run once Actions recovers. |
…ivy on all open PRs (#3212) ## Why **GHSA-5p4m-2wfm-xmqj / CVE-2026-59870** — quadratic CPU consumption in js-yaml's `!!omap` resolution (3.x and 4.x), rated **HIGH**, fixed in **4.3.1** / 3.15.1. The advisory entered Trivy's vulnerability DB at ~02:25 UTC on 2026-08-07. From that moment every PR whose scan resolved after the DB refresh went red on **both** `Trivy Filesystem Scan` and `Trivy Image Scan` (Web image). PRs scanned before the refresh are still green, which is why the failure looked selective rather than global — it is not. This blocks all six remaining v0.104 PRs (#3184, #3185, #3186, #3194, #3195, #3196) and will block every future PR and main until it lands. These are genuine scan-step failures, not the GitHub Actions outage that hit the earlier batch — the jobs fail at `Run blocking Trivy filesystem scan` / `Scan Web image`, with every prior step green. ## What One `pnpm.overrides` entry. js-yaml 4.3.0 arrives transitively through the Astro / expressive-code docs toolchain; nothing in the repo depends on it directly. The override is **upper-bounded to `<5.0.0`**. Without that bound it resolves to 5.2.2, a major-version jump for `@astrojs/markdown-remark`, `@astrojs/starlight` and `@expressive-code/core`. Bounding it keeps the change a 4.3.0 → 4.3.1 patch bump, matching the convention already used for `undici` and `@babel/core`. The tree's other two js-yaml copies need no action: **3.15.1** is already the fixed 3.x release named in the advisory, and **5.2.2** is unaffected. ## Note on the diff The lockfile carries one hunk unrelated to js-yaml: `anymatch@3.1.3`'s picomatch pin moves 4.0.5 → 4.0.4. That is pre-existing drift between `package.json` and the committed lockfile on main which a fresh resolve normalizes — not something this change introduces. Left as the resolver produced it rather than hand-editing the lockfile into an inconsistent state. Co-authored-by: Todd Hebebrand <todd@lanternops.io> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #3162
The bug
Automation
run_scriptactions queued the device command with a synthetic${runId}:${deviceId}:${actionIndex}executionId.handleScriptResult(
apps/api/src/routes/agentWs.ts) matches that againstscript_executions.id— a
uuidcolumn — so Postgres raised22P02: invalid input syntax for type uuid, the throw landed in a caughtconsole.error, and the agent's realstdout was thrown away. Automation script output was invisible everywhere:
no execution row, and
automation_run_device_results.outputonly ever heldthe automation's own log lines (
"[info] Queued run_script action").The fix
Persist.
executeRunScriptActionnow mints a genuinescript_executionsrow and passes its uuid as the payload's
executionId, so the existing resulthandler persists stdout/stderr/exitCode — and inherits the terminal-status
guard, the #2434 secret redaction, and the stale-command reaper for free.
org_idis the device's org (a partner-wide automation owns no org of itsown, #2133).
trigger_typegets a new'automation'value.Correlate. New nullable bare
script_executions.automation_run_id uuid.references():schema/automations.tsalready importsschema/scripts.ts, so the reversereference would close an import cycle — mirroring the existing bare
automation_runs.config_policy_id. A side benefit is thatscript_executionsstays out of
automation_runs' FK graph, so the children-before-parentsorg-cascade ordering contract is untouched.
Render.
GET /automations/runs/:runIdattaches per-devicescriptResults,and the run-history expanded panel gains a collapsible per-script
stdout/stderr block.
Races and volume, handled
queueCommandForExecutiondelivers over theWebSocket synchronously, so a fast agent can drive the row
completed(with its stdout) before we return. Both post-queue transitions are now
guarded on the row still being
pending; without that, an unguarded writewould flip a completed row back to
queuedand the stale-command reaperwould later mark it
timeout.runningwitha
startedAt, mirroringservices/scriptExecution.ts, so the UI can derivea duration. Otherwise it's merely
queued.queueCommandForExecutionrefuses any non-onlinedevice, and automation runs do not filter by device status. Checking up front
avoids minting one
failedexecution row per offline device per run — adaily automation over a fleet of asleep laptops would otherwise drown the
device's 50-row script history and skew per-script success stats.
resource.volume_outlier'sscripts_24hcounted everyexecution per partner. One hourly automation across a fleet would now pin
that signal for a perfectly ordinary MSP, so it excludes
trigger_type = 'automation'— scheduled machine activity with no humanbehind it.
Contracts
CORE_TENANT_EXPORT_POLICYupdated — the one cascade list that fires on acolumn add to an already-registered table.
script_executionswasalready in
CORE_ORG_CASCADE_DELETE_ORDERand both device lists; no FK meansno new ordering constraint.
withSystemDbAccessContext,under which
breeze_has_org_accessshort-circuits, so the INSERT WITH CHECKpolicy passes. The new read inherits the same org policy as the existing
device-results query.
ALTER TYPE ... ADD VALUEis the only statement in migration-a-, per theper-file-transaction rule.
-b-adds the column + index. Both sort after2026-08-13-*.trigger types —
'automation'is provenance the runtime mints, neversomething an API caller can forge. The
ScriptExecutionresponse schema(openapi +
packages/shared) was widened.Verification
apps/api: 422 passed / 2 skipped across the affected suites(
automationRuntime.*,routes/automations,routes/agentWs,routes/scripts,routes/devices/scripts,abuseSignals/,db/autoMigrate), incl. 9 newautomationRuntime.runScript.test.tscases.apps/web: 107 passed (components/automations/incl. 4 new script-outputcases,
no-silent-mutations) + 96 i18n gate tests.tsc --noEmitclean forapps/api,apps/web,packages/shared.db:check-drift.Known follow-ups (deliberately out of scope)
execute_commandaction's output is still discarded — it runs ad-hoccontent with no
scriptsrow, so it can't have an execution row, and needsa different persistence target. The run-history panel will show output for
run_scriptactions and nothing forexecute_commandones.staleCommandReapertimes automation rows out on a fixed 300s rather thanthe script's own
timeoutSeconds, and doesn't reconcileautomation_run_device_results.aiToolsScripts.ts) now blend manual and automation runswith no way to split them.
🤖 Generated with Claude Code