Skip to content

fix(api): persist stdout for automation-triggered script runs (#3162) - #3186

Merged
ToddHebebrand merged 4 commits into
mainfrom
fix/3162-automation-script-stdout
Aug 7, 2026
Merged

fix(api): persist stdout for automation-triggered script runs (#3162)#3186
ToddHebebrand merged 4 commits into
mainfrom
fix/3162-automation-script-stdout

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

Closes #3162

The bug

Automation run_script actions queued the device command with a synthetic
${runId}:${deviceId}:${actionIndex} executionId. handleScriptResult
(apps/api/src/routes/agentWs.ts) matches that against script_executions.id
— a uuid column — so Postgres raised 22P02: invalid input syntax for type uuid, the throw landed in a caught console.error, and the agent's real
stdout was thrown away. Automation script output was invisible everywhere:
no execution row, and automation_run_device_results.output only ever held
the automation's own log lines ("[info] Queued run_script action").

The fix

Persist. executeRunScriptAction now mints a genuine script_executions
row and passes its uuid as the payload's executionId, so the existing result
handler persists stdout/stderr/exitCode — and inherits the terminal-status
guard, the #2434 secret redaction, and the stale-command reaper for free.
org_id is the device's org (a partner-wide automation owns no org of its
own, #2133). trigger_type gets a new 'automation' value.

Correlate. New nullable bare script_executions.automation_run_id uuid

  • partial index. Deliberately no FK / no Drizzle .references():
    schema/automations.ts already imports schema/scripts.ts, so the reverse
    reference would close an import cycle — mirroring the existing bare
    automation_runs.config_policy_id. A side benefit is that script_executions
    stays out of automation_runs' FK graph, so the children-before-parents
    org-cascade ordering contract is untouched.

Render. GET /automations/runs/:runId attaches per-device scriptResults,
and the run-history expanded panel gains a collapsible per-script
stdout/stderr block.

Races and volume, handled

  • Terminal-status stomp. queueCommandForExecution delivers over the
    WebSocket 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 write
    would flip a completed row back to queued and the stale-command reaper
    would later mark it timeout.
  • Delivery vs. enqueue. On actual delivery the row goes to running with
    a startedAt, mirroring services/scriptExecution.ts, so the UI can derive
    a duration. Otherwise it's merely queued.
  • Offline devices. queueCommandForExecution refuses any non-online
    device, and automation runs do not filter by device status. Checking up front
    avoids minting one failed execution row per offline device per run — a
    daily automation over a fleet of asleep laptops would otherwise drown the
    device's 50-row script history and skew per-script success stats.
  • Abuse signal. resource.volume_outlier's scripts_24h counted every
    execution 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 human
    behind it.

Contracts

  • CORE_TENANT_EXPORT_POLICY updated — the one cascade list that fires on a
    column add to an already-registered table. script_executions was
    already in CORE_ORG_CASCADE_DELETE_ORDER and both device lists; no FK means
    no new ordering constraint.
  • RLS unaffected: automation runs execute inside withSystemDbAccessContext,
    under which breeze_has_org_access short-circuits, so the INSERT WITH CHECK
    policy passes. The new read inherits the same org policy as the existing
    device-results query.
  • ALTER TYPE ... ADD VALUE is the only statement in migration -a-, per the
    per-file-transaction rule. -b- adds the column + index. Both sort after
    2026-08-13-*.
  • The execute-script request schema still accepts only the original four
    trigger types — 'automation' is provenance the runtime mints, never
    something an API caller can forge. The ScriptExecution response 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 new automationRuntime.runScript.test.ts cases.
  • apps/web: 107 passed (components/automations/ incl. 4 new script-output
    cases, no-silent-mutations) + 96 i18n gate tests.
  • tsc --noEmit clean for apps/api, apps/web, packages/shared.
  • Not run locally (no DB): the RLS/integration contract suites and
    db:check-drift.

Known follow-ups (deliberately out of scope)

  • The execute_command action's output is still discarded — it runs ad-hoc
    content with no scripts row, so it can't have an execution row, and needs
    a different persistence target. The run-history panel will show output for
    run_script actions and nothing for execute_command ones.
  • staleCommandReaper times automation rows out on a fixed 300s rather than
    the script's own timeoutSeconds, and doesn't reconcile
    automation_run_device_results.
  • Per-script stats (aiToolsScripts.ts) now blend manual and automation runs
    with no way to split them.

🤖 Generated with Claude Code

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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

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

View logs

Todd Hebebrand and others added 2 commits August 6, 2026 12:39
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>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Review run: /pr-review-toolkit:review-pr — code-reviewer, silent-failure-hunter, pr-test-analyzer, comment-analyzer (plus one independent design review before the PR was opened).

Findings: 0 CRITICAL. 8 IMPORTANT + 6 factually-wrong comments raised → all addressed in ebdb0a7; 0 outstanding. The substantive ones:

  • execute_command kept a synthetic executionId for a reason that turned out to be false. I had commented that the Go agent requires the field; it does not — handleScript keys the execution on cmd.ID, and only script_cancel reads executionId. So the field is now dropped entirely, removing the last producer of non-uuid ids and the per-device-per-run warn spam.
  • The offline pre-check was wrong. It read the run's device snapshot (taken once at the top of a fleet run, so minutes stale), silently skipping devices that came online mid-run — and diverging from execute_command, which still got the live check. Replaced: the row is minted, and discarded if the command never reaches the queue. Same "no row for an offline device" outcome, no stale read.
  • A queue throw stranded the row. queueCommandForExecution does several DB round-trips plus a JIT decrypt and can throw rather than return {error}, leaving the row at pending for the reaper to relabel timeout — "no response from agent", when no agent ever had it. Now wrapped.
  • The run-detail response inlined uncapped stdout. It is re-polled every few seconds while a run is live and stdout is accepted up to 5MB per execution. Now truncated in SQL (16k/8k) with explicit stdoutTruncated/stderrTruncated flags.
  • The UI would have shipped the same ambiguity the issue is about. A pending/queued/running execution rendered the empty-output placeholder — indistinguishable from "the script printed nothing". And because a run goes terminal as soon as its commands are queued, the parent's poll had already stopped by the time stdout arrived, so the first view froze until you collapsed and re-expanded the row. Both fixed.
  • The catch that hid this bug is no longer blind. handleScriptResult's catch now calls captureException like its SNMP sibling.
  • Six of my own comments were verifiably wrong (agent requirement, reaper joining device_commands, "LEFT JOIN" vs equality filter, queued vs running as the stompable state, "mirrors the manual path", and the abuse-signal rationale — the commands_24h axis is unfiltered, so excluding automations from the scripts axis does not de-noise the signal). All corrected.

Tests: apps/api 426 passed / 2 skipped across the affected suites (automationRuntime.*, routes/automations, routes/agentWs, routes/scripts, routes/devices/scripts, abuseSignals/, db/autoMigrate). apps/web 207 passed (components/automations/, i18n gates, no-silent-mutations). tsc --noEmit clean for apps/api, apps/web, packages/shared.

⚠️ CI has not run — GitHub Actions is in a major outage. The pull_request trigger dropped for this branch (no workflow run exists), gh workflow run returns HTTP 500, and other PRs' runs are queued 1h+. gh pr checks shows only Cloudflare Pages, which reads misleadingly like a short green list. Please confirm a real CI run before merging.

Not verified locally: the RLS/integration contract suites and db:check-drift — they need a live DB, and the Postgres containers on this machine belong to other worktrees. CORE_TENANT_EXPORT_POLICY gained automation_run_id (the one cascade contract that fires on a column add), and that suite is non-blocking on PRs but required on main, so it's worth running before merge.

Known follow-ups, deliberately out of scope:

  • execute_command output is still discarded — ad-hoc content with no scripts row, so it can't have an execution row and needs a different target.
  • A BullMQ execute-run retry re-executes actions and mints duplicate execution rows (executeAutomationRunInner has no run-status short-circuit). Pre-existing for device_commands, but these rows are now user-visible.
  • staleCommandReaper times automation rows out on a fixed 300s rather than the script's own timeoutSeconds, and doesn't reconcile automation_run_device_results.

Status: review-clean, awaiting maintainer merge — gated on a real CI run once Actions recovers.

@ToddHebebrand ToddHebebrand reopened this Aug 7, 2026
ToddHebebrand added a commit that referenced this pull request Aug 7, 2026
…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>
@ToddHebebrand
ToddHebebrand merged commit cf35c49 into main Aug 7, 2026
57 checks passed
@ToddHebebrand
ToddHebebrand deleted the fix/3162-automation-script-stdout branch August 7, 2026 16:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[API] Automation-triggered script runs never persist stdout — synthetic executionId fails the UUID match and the result update is silently dropped

1 participant