diff --git a/plugins/automations/detail-view.tsx b/plugins/automations/detail-view.tsx
index c1d5df40b..9876329dc 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 4a4391a5d..672f9a766 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 d8cefb619..13640eec3 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 f548cd780..8dfa5878a 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 000000000..f9a24d7f0
--- /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 000000000..e7fe52009
--- /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 17a401f51..b8f4344de 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 e9f360798..edfddcf69 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 bde822743..8434e5864 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 6f1bff6ba..4618c1513 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 539c62676..3bfc18bfb 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 000000000..e23131002
--- /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 000000000..6385503f7
--- /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;
+}