From be6358a9b802ba0da05ec8da0fccaaee1c0dc948 Mon Sep 17 00:00:00 2001 From: Ali Madad Date: Wed, 5 Aug 2026 02:55:12 +0000 Subject: [PATCH] Integrate automation transport and domain status --- plugins/automations/detail-view.tsx | 27 ++- plugins/automations/src/automations.test.ts | 6 +- plugins/automations/src/cli.ts | 9 +- plugins/automations/src/data.ts | 143 +++++++------ plugins/automations/src/rpc-types.ts | 1 + .../automations/src/run-status-ui.test.tsx | 112 +++++++++++ plugins/automations/src/run-summary.ts | 11 + plugins/automations/src/run.ts | 14 +- plugins/automations/src/script-runner.ts | 7 + .../automations/src/server-harness.test.ts | 14 +- plugins/automations/src/server.ts | 6 +- plugins/automations/src/service.ts | 1 + .../automations/src/terminal-token.test.ts | 189 ++++++++++++++++++ plugins/automations/src/terminal-token.ts | 21 ++ 14 files changed, 478 insertions(+), 83 deletions(-) create mode 100644 plugins/automations/src/run-status-ui.test.tsx create mode 100644 plugins/automations/src/run-summary.ts create mode 100644 plugins/automations/src/terminal-token.test.ts create mode 100644 plugins/automations/src/terminal-token.ts diff --git a/plugins/automations/detail-view.tsx b/plugins/automations/detail-view.tsx index c1d5df40be..9876329dcc 100644 --- a/plugins/automations/detail-view.tsx +++ b/plugins/automations/detail-view.tsx @@ -63,6 +63,10 @@ import { } from "./lib/model-label"; import { AutomationProviderIcon } from "./lib/provider-icon"; import { AutomationMetadataItem } from "./metadata"; +import { + formatRunDomainLabel, + formatRunTransportLabel, +} from "./src/run-summary"; export interface AutomationRunsViewState { runs: readonly AutomationRunResponse[]; @@ -505,17 +509,17 @@ export const AUTOMATION_RUN_STATUS_VISUALS: Record< } > = { running: { - label: "Running", + label: "transport=running", icon: "Loading", className: "animate-spin text-muted-foreground", }, failed: { - label: "Failed", + label: "transport=failed", icon: "CircleX", className: "text-destructive", }, skipped: { - label: "Skipped", + label: "transport=skipped", // Not CircleDashed: icon.tsx aliases it to the same DashedLineCircleIcon as // Spinner, so a skipped run rendered an identical shape to a running one. // ArrowTurnForward is the only glyph in the map that reads as "passed @@ -524,7 +528,7 @@ export const AUTOMATION_RUN_STATUS_VISUALS: Record< className: "text-subtle-foreground", }, succeeded: { - label: "Succeeded", + label: "transport=succeeded", icon: "CircleCheck", className: "text-success", }, @@ -567,6 +571,8 @@ function RunRow({ run.runMode === "script" && (run.output !== null || run.error !== null || silent); const visual = AUTOMATION_RUN_STATUS_VISUALS[run.status]; + const transportLabel = formatRunTransportLabel(run.status); + const domainLabel = formatRunDomainLabel(run.terminalToken); const running = run.status === "running"; const openable = run.runMode === "agent" && run.threadId !== null; // The whole row is the affordance when there is a thread, so the destination @@ -619,9 +625,18 @@ function RunRow({ : "text-subtle-foreground", )} > - {running ? `${visual.label}\u2026` : (duration ?? "")} - {run.skipReason ? `${duration ? " · " : ""}${run.skipReason}` : ""} + {running ? `${transportLabel}\u2026` : transportLabel} + {duration ? ` · ${duration}` : ""} + {run.skipReason ? ` · ${run.skipReason}` : ""} + {domainLabel ? ( + + {domainLabel} + + ) : null} {openable ? ( { it("migrates stored agent automations to current permission modes", () => { - const db = createTestDb(); + const db = createTestDb(false); const insert = db.prepare( `INSERT INTO automations ( id, project_id, name, enabled, trigger_type, trigger_config, diff --git a/plugins/automations/src/cli.ts b/plugins/automations/src/cli.ts index 4a4391a5d8..672f9a766e 100644 --- a/plugins/automations/src/cli.ts +++ b/plugins/automations/src/cli.ts @@ -26,6 +26,10 @@ import { AUTOMATION_SCRIPT_TIMEOUT_DEFAULT_MS, automationScriptInterpreterSchema, } from "./rpc-types.js"; +import { + formatRunDomainLabel, + formatRunTransportLabel, +} from "./run-summary.js"; const DURATION_PATTERN = /^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/iu; @@ -523,10 +527,11 @@ function printAutomationTable(automations: AutomationResponse[]): string { function printRunTable(runs: AutomationRunResponse[]): string { return table( - ["ID", "Status", "Started", "Thread/Exit", "Detail"], + ["ID", "Transport", "Domain", "Started", "Thread/Exit", "Detail"], runs.map((run) => [ run.id, - run.status, + formatRunTransportLabel(run.status), + formatRunDomainLabel(run.terminalToken) ?? "-", formatTimestamp(run.startedAt), run.threadId ?? (run.exitCode === null ? "-" : `exit ${run.exitCode}`), run.skipReason ?? run.error ?? "-", diff --git a/plugins/automations/src/data.ts b/plugins/automations/src/data.ts index d8cefb6198..13640eec39 100644 --- a/plugins/automations/src/data.ts +++ b/plugins/automations/src/data.ts @@ -50,14 +50,14 @@ export interface AutomationRunRow { error: string | null; output: string | null; exitCode: number | null; + terminalToken: string | null; idempotencyKey: string | null; scheduledFor: number; startedAt: number; finishedAt: number | null; } -interface RawAutomationRow - extends Omit { +interface RawAutomationRow extends Omit { enabled: 0 | 1; } @@ -163,8 +163,17 @@ export const migrations = [ 'workspace-write', 'readonly' );`, + `ALTER TABLE automation_runs ADD COLUMN terminal_token TEXT;`, ]; +const automationRunSelectColumns = ` + id, automation_id AS automationId, run_mode AS runMode, + thread_id AS threadId, status, trigger, skip_reason AS skipReason, + error, output, exit_code AS exitCode, terminal_token AS terminalToken, + idempotency_key AS idempotencyKey, scheduled_for AS scheduledFor, + started_at AS startedAt, finished_at AS finishedAt +`; + export interface CreateAutomationInput { id?: string; projectId: string; @@ -194,11 +203,15 @@ function serializeExecution(execution: AutomationExecution): string { return JSON.stringify(execution); } -export function parseAutomationTrigger(triggerConfig: string): AutomationTrigger { +export function parseAutomationTrigger( + triggerConfig: string, +): AutomationTrigger { return automationTriggerSchema.parse(JSON.parse(triggerConfig)); } -export function parseAutomationExecution(execution: string): AutomationExecution { +export function parseAutomationExecution( + execution: string, +): AutomationExecution { return automationExecutionSchema.parse(JSON.parse(execution)); } @@ -239,13 +252,17 @@ export function toAutomationRunResponse( error: row.error, output: row.output, exitCode: row.exitCode, + terminalToken: row.terminalToken, scheduledFor: row.scheduledFor, startedAt: row.startedAt, finishedAt: row.finishedAt, }); } -export function createAutomation(db: Db, input: CreateAutomationInput): AutomationRow { +export function createAutomation( + db: Db, + input: CreateAutomationInput, +): AutomationRow { const now = Date.now(); const id = input.id ?? createAutomationId(); db.prepare( @@ -353,11 +370,16 @@ export function listAllAutomations(db: Db): AutomationRow[] { export function updateAutomation( db: Db, - args: { projectId: string; automationId: string; patch: UpdateAutomationInput }, + args: { + projectId: string; + automationId: string; + patch: UpdateAutomationInput; + }, ): AutomationRow | null { const existing = getAutomationForProject(db, args); if (!existing) return null; - const nextTrigger = args.patch.trigger ?? parseAutomationTrigger(existing.triggerConfig); + const nextTrigger = + args.patch.trigger ?? parseAutomationTrigger(existing.triggerConfig); const nextExecution = args.patch.execution ?? parseAutomationExecution(existing.execution); const now = Date.now(); @@ -387,7 +409,9 @@ export function updateAutomation( ? (nextExecution.targetThreadId ?? null) : null, nextRunAt: - args.patch.nextRunAt !== undefined ? args.patch.nextRunAt : existing.nextRunAt, + args.patch.nextRunAt !== undefined + ? args.patch.nextRunAt + : existing.nextRunAt, now, }); return getAutomationForProject(db, args); @@ -526,11 +550,11 @@ export function claimAutomationScheduledRun( db.prepare( `INSERT INTO automation_runs ( id, automation_id, run_mode, thread_id, status, trigger, skip_reason, - error, output, exit_code, idempotency_key, scheduled_for, started_at, - finished_at + error, output, exit_code, terminal_token, idempotency_key, + scheduled_for, started_at, finished_at ) VALUES ( @id, @automationId, @runMode, NULL, @status, 'schedule', @skipReason, - NULL, NULL, NULL, NULL, @scheduledFor, @startedAt, @finishedAt + NULL, NULL, NULL, NULL, NULL, @scheduledFor, @startedAt, @finishedAt )`, ).run({ id: runId, @@ -601,7 +625,8 @@ export function restoreAutomationAfterFailedRun( } db.prepare( `UPDATE automation_runs - SET status = 'failed', error = @error, finished_at = @now + SET status = 'failed', error = @error, terminal_token = NULL, + finished_at = @now WHERE id = @runId`, ).run({ runId: args.runId, error: args.error, now: args.now }); })(); @@ -617,33 +642,43 @@ export function closeAutomationRun( output?: string | null; exitCode?: number | null; threadId?: string | null; + terminalToken?: string | null; now: number; }, ): { run: AutomationRunRow; automationId: string } | null { return db.transaction(() => { const existing = getAutomationRun(db, args.runId); if (!existing) return null; - db.prepare( - `UPDATE automation_runs SET - status = @status, - skip_reason = @skipReason, - error = @error, - output = @output, - exit_code = @exitCode, - thread_id = CASE WHEN @hasThreadId THEN @threadId ELSE thread_id END, - finished_at = @now - WHERE id = @runId`, - ).run({ - runId: args.runId, - status: args.status, - skipReason: args.skipReason ?? null, - error: args.error ?? null, - output: args.output ?? null, - exitCode: args.exitCode ?? null, - hasThreadId: args.threadId === undefined ? 0 : 1, - threadId: args.threadId ?? null, - now: args.now, - }); + const run = optionalRunRow( + db + .prepare( + `UPDATE automation_runs SET + status = @status, + skip_reason = @skipReason, + error = @error, + output = @output, + exit_code = @exitCode, + terminal_token = @terminalToken, + thread_id = CASE WHEN @hasThreadId THEN @threadId ELSE thread_id END, + finished_at = @now + WHERE id = @runId AND status = 'running' + RETURNING ${automationRunSelectColumns}`, + ) + .get({ + runId: args.runId, + status: args.status, + skipReason: args.skipReason ?? null, + error: args.error ?? null, + output: args.output ?? null, + exitCode: args.exitCode ?? null, + terminalToken: + args.status === "succeeded" ? (args.terminalToken ?? null) : null, + hasThreadId: args.threadId === undefined ? 0 : 1, + threadId: args.threadId ?? null, + now: args.now, + }), + ); + if (!run) return { run: existing, automationId: existing.automationId }; db.prepare( `UPDATE automations SET last_run_status = @status, @@ -661,8 +696,6 @@ export function closeAutomationRun( error: args.error ?? null, now: args.now, }); - const run = getAutomationRun(db, args.runId); - if (!run) return null; return { run, automationId: run.automationId }; })(); } @@ -682,11 +715,7 @@ export function createManualRun( db .prepare( `SELECT - id, automation_id AS automationId, run_mode AS runMode, - thread_id AS threadId, status, trigger, skip_reason AS skipReason, - error, output, exit_code AS exitCode, - idempotency_key AS idempotencyKey, scheduled_for AS scheduledFor, - started_at AS startedAt, finished_at AS finishedAt + ${automationRunSelectColumns} FROM automation_runs WHERE automation_id = ? AND idempotency_key = ?`, ) @@ -698,11 +727,11 @@ export function createManualRun( db.prepare( `INSERT INTO automation_runs ( id, automation_id, run_mode, thread_id, status, trigger, skip_reason, - error, output, exit_code, idempotency_key, scheduled_for, started_at, - finished_at + error, output, exit_code, terminal_token, idempotency_key, + scheduled_for, started_at, finished_at ) VALUES ( @id, @automationId, @runMode, NULL, 'running', 'manual', NULL, - NULL, NULL, NULL, @idempotencyKey, @now, @now, NULL + NULL, NULL, NULL, NULL, @idempotencyKey, @now, @now, NULL )`, ).run({ id: runId, @@ -722,11 +751,7 @@ export function getAutomationRun(db: Db, id: string): AutomationRunRow | null { db .prepare( `SELECT - id, automation_id AS automationId, run_mode AS runMode, - thread_id AS threadId, status, trigger, skip_reason AS skipReason, - error, output, exit_code AS exitCode, - idempotency_key AS idempotencyKey, scheduled_for AS scheduledFor, - started_at AS startedAt, finished_at AS finishedAt + ${automationRunSelectColumns} FROM automation_runs WHERE id = ?`, ) .get(id), @@ -763,9 +788,7 @@ export function isAutomationSpawnedThread(db: Db, threadId: string): boolean { ) .get(threadId) !== undefined || db - .prepare( - `SELECT id FROM automation_runs WHERE thread_id = ? LIMIT 1`, - ) + .prepare(`SELECT id FROM automation_runs WHERE thread_id = ? LIMIT 1`) .get(threadId) !== undefined ); } @@ -778,11 +801,7 @@ export function getRunningAutomationRunByThread( db .prepare( `SELECT - id, automation_id AS automationId, run_mode AS runMode, - thread_id AS threadId, status, trigger, skip_reason AS skipReason, - error, output, exit_code AS exitCode, - idempotency_key AS idempotencyKey, scheduled_for AS scheduledFor, - started_at AS startedAt, finished_at AS finishedAt + ${automationRunSelectColumns} FROM automation_runs WHERE thread_id = ? AND status = 'running' ORDER BY started_at DESC @@ -804,11 +823,7 @@ export function listAutomationRuns( ? db .prepare( `SELECT - id, automation_id AS automationId, run_mode AS runMode, - thread_id AS threadId, status, trigger, skip_reason AS skipReason, - error, output, exit_code AS exitCode, - idempotency_key AS idempotencyKey, scheduled_for AS scheduledFor, - started_at AS startedAt, finished_at AS finishedAt + ${automationRunSelectColumns} FROM automation_runs WHERE automation_id = ? AND (started_at < ? OR (started_at = ? AND id < ?)) @@ -825,11 +840,7 @@ export function listAutomationRuns( : db .prepare( `SELECT - id, automation_id AS automationId, run_mode AS runMode, - thread_id AS threadId, status, trigger, skip_reason AS skipReason, - error, output, exit_code AS exitCode, - idempotency_key AS idempotencyKey, scheduled_for AS scheduledFor, - started_at AS startedAt, finished_at AS finishedAt + ${automationRunSelectColumns} FROM automation_runs WHERE automation_id = ? ORDER BY started_at DESC, id DESC diff --git a/plugins/automations/src/rpc-types.ts b/plugins/automations/src/rpc-types.ts index f548cd7807..8dfa5878a1 100644 --- a/plugins/automations/src/rpc-types.ts +++ b/plugins/automations/src/rpc-types.ts @@ -271,6 +271,7 @@ export const automationRunResponseSchema = z error: z.string().nullable(), output: z.string().nullable(), exitCode: z.number().int().nullable(), + terminalToken: z.string().nullable(), scheduledFor: z.number(), startedAt: z.number(), finishedAt: z.number().nullable(), diff --git a/plugins/automations/src/run-status-ui.test.tsx b/plugins/automations/src/run-status-ui.test.tsx new file mode 100644 index 0000000000..f9a24d7f08 --- /dev/null +++ b/plugins/automations/src/run-status-ui.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { AutomationDetailView } from "../detail-view.js"; +import type { AutomationResponse, AutomationRunResponse } from "./rpc-types.js"; + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +afterEach(cleanup); + +const automation: AutomationResponse = { + id: "auto_status", + projectId: "proj_test", + name: "Status check", + enabled: true, + trigger: { + triggerType: "schedule", + cron: "* * * * *", + timezone: "UTC", + }, + execution: { + mode: "agent", + prompt: "Run", + providerId: "codex", + model: "gpt-5", + permissionMode: "auto", + environment: { type: "project-default" }, + }, + origin: "human", + createdByThreadId: null, + nextRunAt: 2_000, + lastRunAt: 1_000, + runCount: 2, + lastRunStatus: "succeeded", + lastRunThreadId: "thr_status", + lastError: null, + createdAt: 1, + updatedAt: 2, +}; + +function run(id: string, terminalToken: string | null): AutomationRunResponse { + return { + id, + automationId: automation.id, + runMode: "agent", + threadId: `thr_${id}`, + status: "succeeded", + trigger: "schedule", + skipReason: null, + error: null, + output: null, + exitCode: null, + terminalToken, + scheduledFor: 1_000, + startedAt: 1_000, + finishedAt: 2_000, + }; +} + +describe("automation run transport and domain labels", () => { + it("renders explicit accessible labels and omits absent domains", () => { + render( + , + ); + + expect(screen.getAllByText(/^transport=succeeded/u)).toHaveLength(2); + expect(screen.getByText("domain=TASK_COMPLETE")).toBeTruthy(); + expect( + screen.getAllByRole("img", { name: "transport=succeeded" }), + ).toHaveLength(2); + expect(screen.queryAllByText(/^domain=/u)).toHaveLength(1); + }); +}); diff --git a/plugins/automations/src/run-summary.ts b/plugins/automations/src/run-summary.ts new file mode 100644 index 0000000000..e7fe520097 --- /dev/null +++ b/plugins/automations/src/run-summary.ts @@ -0,0 +1,11 @@ +import type { AutomationRunStatus } from "./rpc-types.js"; + +export function formatRunTransportLabel(status: AutomationRunStatus): string { + return `transport=${status}`; +} + +export function formatRunDomainLabel( + terminalToken: string | null, +): string | null { + return terminalToken === null ? null : `domain=${terminalToken}`; +} diff --git a/plugins/automations/src/run.ts b/plugins/automations/src/run.ts index 17a401f51c..b8f4344de0 100644 --- a/plugins/automations/src/run.ts +++ b/plugins/automations/src/run.ts @@ -280,6 +280,7 @@ export async function executeScriptRun( output: mapped.output, exitCode: mapped.exitCode, error: mapped.error, + terminalToken: mapped.terminalToken, now: Date.now(), }); } catch (error) { @@ -298,14 +299,21 @@ export async function executeScriptRun( export function closeAutomationRunForSettledThread( bb: Pick, db: Db, - args: { threadId: string; status: "idle" | "failed"; error?: string | null }, + args: { + threadId: string; + status: "succeeded" | "failed"; + error?: string | null; + terminalToken?: string | null; + }, ): void { const run = getRunningAutomationRunByThread(db, args.threadId); if (!run) return; const closed = closeAutomationRun(db, { runId: run.id, - status: args.status === "idle" ? "succeeded" : "failed", - error: args.status === "idle" ? null : (args.error ?? "Turn failed"), + status: args.status, + error: args.status === "succeeded" ? null : (args.error ?? "Turn failed"), + terminalToken: + args.status === "succeeded" ? (args.terminalToken ?? null) : null, threadId: args.threadId, now: Date.now(), }); diff --git a/plugins/automations/src/script-runner.ts b/plugins/automations/src/script-runner.ts index e9f3607980..edfddcf699 100644 --- a/plugins/automations/src/script-runner.ts +++ b/plugins/automations/src/script-runner.ts @@ -6,6 +6,7 @@ import { AUTOMATION_SCRIPT_TIMEOUT_MAX_MS, type AutomationScriptInterpreter, } from "./rpc-types.js"; +import { extractTerminalToken } from "./terminal-token.js"; import { resolveAutomationScriptPath, resolveDefaultInterpreter, @@ -71,6 +72,7 @@ export interface ScriptRunOutcome { exitCode: number | null; error: string | null; skipReason: string | null; + terminalToken: string | null; } export function mapScriptResultToRun(result: ScriptRunResult): ScriptRunOutcome { @@ -81,6 +83,7 @@ export function mapScriptResultToRun(result: ScriptRunResult): ScriptRunOutcome exitCode: null, error: "Script timed out", skipReason: null, + terminalToken: null, }; } if (result.exitCode !== 0) { @@ -90,6 +93,7 @@ export function mapScriptResultToRun(result: ScriptRunResult): ScriptRunOutcome exitCode: result.exitCode, error: `Script exited with code ${result.exitCode}`, skipReason: null, + terminalToken: null, }; } if (result.output.trim().length === 0) { @@ -99,6 +103,7 @@ export function mapScriptResultToRun(result: ScriptRunResult): ScriptRunOutcome exitCode: 0, error: null, skipReason: "empty output", + terminalToken: null, }; } if (isWakeAgentSuppressed(result.output)) { @@ -108,6 +113,7 @@ export function mapScriptResultToRun(result: ScriptRunResult): ScriptRunOutcome exitCode: 0, error: null, skipReason: "wakeAgent false", + terminalToken: null, }; } return { @@ -116,6 +122,7 @@ export function mapScriptResultToRun(result: ScriptRunResult): ScriptRunOutcome exitCode: 0, error: null, skipReason: null, + terminalToken: extractTerminalToken(result.output), }; } diff --git a/plugins/automations/src/server-harness.test.ts b/plugins/automations/src/server-harness.test.ts index bde8227439..8434e5864c 100644 --- a/plugins/automations/src/server-harness.test.ts +++ b/plugins/automations/src/server-harness.test.ts @@ -850,7 +850,7 @@ describe("automations server plugin harness", () => { await harness.emitThreadEvent("thread.idle", { thread: makeThreadResponse({ id: "thr_spawned", projectId: PROJECT_ID }), - lastAssistantText: null, + lastAssistantText: "work complete\nTASK_COMPLETE\n", }); const closedRuns = automationRunListResponseSchema.parse( await harness.callRpc("automations_runs", { @@ -861,7 +861,19 @@ describe("automations server plugin harness", () => { expect(closedRuns[0]).toMatchObject({ status: "succeeded", threadId: "thr_spawned", + terminalToken: "TASK_COMPLETE", }); + const cliRuns = await harness.runCli([ + "runs", + automation.id, + "--project", + PROJECT_ID, + ]); + expect(cliRuns.exitCode).toBe(0); + expect(cliRuns.stdout).toContain("Transport"); + expect(cliRuns.stdout).toContain("Domain"); + expect(cliRuns.stdout).toContain("transport=succeeded"); + expect(cliRuns.stdout).toContain("domain=TASK_COMPLETE"); expect(signalKinds(host)).toEqual( expect.arrayContaining([ "automations-changed", diff --git a/plugins/automations/src/server.ts b/plugins/automations/src/server.ts index 6f1bff6bad..4618c1513c 100644 --- a/plugins/automations/src/server.ts +++ b/plugins/automations/src/server.ts @@ -10,6 +10,7 @@ import { import { registerAutomationCli } from "./cli.js"; import { createAutomationService } from "./service.js"; import { sleep, sweepDueAutomations, SWEEP_INTERVAL_MS } from "./sweep.js"; +import { extractTerminalToken } from "./terminal-token.js"; function resolveServerUrl(): string { return process.env.BB_SERVER_URL?.trim() || "http://127.0.0.1:38886"; @@ -31,10 +32,11 @@ export default async function plugin(bb: BbPluginApi) { bb.rpc.register(automationRpcContract, createRpcHandlers(service)); registerAutomationCli({ bb, service }); - bb.events.on("thread.idle", ({ thread }) => { + bb.events.on("thread.idle", ({ thread, lastAssistantText }) => { closeAutomationRunForSettledThread(bb, db, { threadId: thread.id, - status: "idle", + status: "succeeded", + terminalToken: extractTerminalToken(lastAssistantText), }); }); bb.events.on("thread.failed", ({ thread, error }) => { diff --git a/plugins/automations/src/service.ts b/plugins/automations/src/service.ts index 539c62676d..3bfc18bfbd 100644 --- a/plugins/automations/src/service.ts +++ b/plugins/automations/src/service.ts @@ -671,6 +671,7 @@ export function createAutomationService(args: { runId: run.id, status: "failed", error: error instanceof Error ? error.message : String(error), + terminalToken: null, now: Date.now(), }); }; diff --git a/plugins/automations/src/terminal-token.test.ts b/plugins/automations/src/terminal-token.test.ts new file mode 100644 index 0000000000..e231310028 --- /dev/null +++ b/plugins/automations/src/terminal-token.test.ts @@ -0,0 +1,189 @@ +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; +import { + closeAutomationRun, + createAutomation, + createManualRun, + listAutomationRuns, + migrations, + type Db, +} from "./data.js"; +import { mapScriptResultToRun } from "./script-runner.js"; +import { extractTerminalToken } from "./terminal-token.js"; + +function migrateByIndex(db: Db, statements: readonly string[]): void { + db.exec( + "CREATE TABLE IF NOT EXISTS _bb_migrations (id INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)", + ); + const applied = new Set( + db + .prepare<[], { id: number }>("SELECT id FROM _bb_migrations") + .all() + .map((row) => row.id), + ); + const record = db.prepare( + "INSERT INTO _bb_migrations (id, applied_at) VALUES (?, ?)", + ); + db.transaction(() => { + statements.forEach((statement, index) => { + if (applied.has(index)) return; + db.exec(statement); + record.run(index, 1); + }); + })(); +} + +function createMigratedDb(): Db { + const db = new Database(":memory:"); + migrateByIndex(db, migrations); + return db; +} + +function createAgentAutomation(db: Db): void { + createAutomation(db, { + id: "auto_status", + projectId: "proj_test", + name: "Status", + enabled: true, + trigger: { + triggerType: "schedule", + cron: "* * * * *", + timezone: "UTC", + }, + runMode: "agent", + execution: { + mode: "agent", + prompt: "Run", + providerId: "codex", + model: "gpt-5", + permissionMode: "auto", + environment: { type: "project-default" }, + }, + origin: "human", + createdByThreadId: null, + nextRunAt: 1_000, + }); +} + +describe("terminal token extraction", () => { + it("extracts only a generic strict final non-empty line", () => { + expect(extractTerminalToken("work\nTASK_COMPLETE\n\n")).toBe( + "TASK_COMPLETE", + ); + expect(extractTerminalToken("work\r\nDOMAIN_42\r\n \r\n")).toBe( + "DOMAIN_42", + ); + expect(extractTerminalToken("TASK_COMPLETE\nmore output")).toBeNull(); + expect(extractTerminalToken("work\ntask_complete")).toBeNull(); + expect(extractTerminalToken("work\nTASK-COMPLETE")).toBeNull(); + expect(extractTerminalToken(`work\n${"A".repeat(129)}`)).toBeNull(); + expect(extractTerminalToken(null)).toBeNull(); + }); + + it("stores a token only for successful script transport", () => { + expect( + mapScriptResultToRun({ + exitCode: 0, + output: "detail\nTASK_COMPLETE\n", + timedOut: false, + }), + ).toMatchObject({ status: "succeeded", terminalToken: "TASK_COMPLETE" }); + expect( + mapScriptResultToRun({ + exitCode: 2, + output: "detail\nTASK_COMPLETE\n", + timedOut: false, + }), + ).toMatchObject({ status: "failed", terminalToken: null }); + expect( + mapScriptResultToRun({ + exitCode: 0, + output: "detail\nTASK_COMPLETE\n", + timedOut: true, + }), + ).toMatchObject({ status: "failed", terminalToken: null }); + expect( + mapScriptResultToRun({ + exitCode: 0, + output: 'detail\n{"wakeAgent": false}\n', + timedOut: false, + }), + ).toMatchObject({ status: "skipped", terminalToken: null }); + }); +}); + +describe("terminal token storage", () => { + it("suppresses non-success tokens and keeps running and legacy values null", () => { + const db = createMigratedDb(); + createAgentAutomation(db); + const running = createManualRun(db, { + automationId: "auto_status", + runMode: "agent", + now: 1, + }).run; + const failed = createManualRun(db, { + automationId: "auto_status", + runMode: "agent", + now: 2, + }).run; + const skipped = createManualRun(db, { + automationId: "auto_status", + runMode: "agent", + now: 3, + }).run; + + closeAutomationRun(db, { + runId: failed.id, + status: "failed", + terminalToken: "TASK_COMPLETE", + now: 4, + }); + closeAutomationRun(db, { + runId: skipped.id, + status: "skipped", + terminalToken: "TASK_COMPLETE", + now: 5, + }); + + expect( + listAutomationRuns(db, { automationId: "auto_status", limit: 10 }), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: running.id, terminalToken: null }), + expect.objectContaining({ id: failed.id, terminalToken: null }), + expect.objectContaining({ id: skipped.id, terminalToken: null }), + ]), + ); + }); + + it("makes close idempotent and preserves the first final state", () => { + const db = createMigratedDb(); + createAgentAutomation(db); + const run = createManualRun(db, { + automationId: "auto_status", + runMode: "agent", + now: 1, + }).run; + + const first = closeAutomationRun(db, { + runId: run.id, + status: "succeeded", + terminalToken: "TASK_COMPLETE", + now: 2, + }); + const duplicate = closeAutomationRun(db, { + runId: run.id, + status: "failed", + error: "late failure", + terminalToken: "LATE_FAILURE", + now: 3, + }); + + expect(first?.run).toMatchObject({ + status: "succeeded", + terminalToken: "TASK_COMPLETE", + finishedAt: 2, + }); + expect(duplicate?.run).toEqual(first?.run); + }); +}); diff --git a/plugins/automations/src/terminal-token.ts b/plugins/automations/src/terminal-token.ts new file mode 100644 index 0000000000..6385503f76 --- /dev/null +++ b/plugins/automations/src/terminal-token.ts @@ -0,0 +1,21 @@ +const TERMINAL_TOKEN_MAX_LENGTH = 128; +const BARE_TERMINAL_TOKEN = /^[A-Z][A-Z0-9_]*$/u; + +export function extractTerminalToken( + text: string | null | undefined, +): string | null { + if (text === null || text === undefined) return null; + const lines = text.replace(/\r\n?|\n/gu, "\n").split("\n"); + let index = lines.length - 1; + while (index >= 0 && lines[index]?.trim().length === 0) index -= 1; + const line = lines[index]?.trim(); + if ( + line === undefined || + line.length === 0 || + line.length > TERMINAL_TOKEN_MAX_LENGTH || + !BARE_TERMINAL_TOKEN.test(line) + ) { + return null; + } + return line; +}