From b2b70cfa3e572bcc3f826dc4d127f756d94f0982 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 1 Jun 2026 20:11:25 +0200 Subject: [PATCH 01/11] fix: make migration 144 idempotent --- .../versions/144_add_automation_tables.py | 96 +++++++++++++------ 1 file changed, 66 insertions(+), 30 deletions(-) diff --git a/surfsense_backend/alembic/versions/144_add_automation_tables.py b/surfsense_backend/alembic/versions/144_add_automation_tables.py index 39f927417..296c33585 100644 --- a/surfsense_backend/alembic/versions/144_add_automation_tables.py +++ b/surfsense_backend/alembic/versions/144_add_automation_tables.py @@ -25,34 +25,60 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: - # ENUM types (PostgreSQL requires types created before tables that use them) + # Guard every object so the migration is safe to re-run after a partial + # apply (the types/tables outlive a failed run that never advanced + # alembic_version). Types must precede the tables that reference them. op.execute( """ - CREATE TYPE automation_status AS ENUM ( - 'active', 'paused', 'archived' - ); + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'automation_status' + ) THEN + CREATE TYPE automation_status AS ENUM ( + 'active', 'paused', 'archived' + ); + END IF; + END + $$; """ ) op.execute( """ - CREATE TYPE automation_trigger_type AS ENUM ( - 'schedule', 'manual' - ); + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'automation_trigger_type' + ) THEN + CREATE TYPE automation_trigger_type AS ENUM ( + 'schedule', 'manual' + ); + END IF; + END + $$; """ ) op.execute( """ - CREATE TYPE automation_run_status AS ENUM ( - 'pending', 'running', 'succeeded', 'failed', - 'cancelled', 'timed_out' - ); + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'automation_run_status' + ) THEN + CREATE TYPE automation_run_status AS ENUM ( + 'pending', 'running', 'succeeded', 'failed', + 'cancelled', 'timed_out' + ); + END IF; + END + $$; """ ) # automations — the editable, versioned automation definition op.execute( """ - CREATE TABLE automations ( + CREATE TABLE IF NOT EXISTS automations ( id SERIAL PRIMARY KEY, search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE, @@ -69,19 +95,25 @@ def upgrade() -> None: """ ) op.execute( - "CREATE INDEX ix_automations_search_space_id ON automations(search_space_id);" + "CREATE INDEX IF NOT EXISTS ix_automations_search_space_id ON automations(search_space_id);" ) op.execute( - "CREATE INDEX ix_automations_created_by_user_id ON automations(created_by_user_id);" + "CREATE INDEX IF NOT EXISTS ix_automations_created_by_user_id ON automations(created_by_user_id);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automations_status ON automations(status);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automations_created_at ON automations(created_at);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automations_updated_at ON automations(updated_at);" ) - op.execute("CREATE INDEX ix_automations_status ON automations(status);") - op.execute("CREATE INDEX ix_automations_created_at ON automations(created_at);") - op.execute("CREATE INDEX ix_automations_updated_at ON automations(updated_at);") # automation_triggers — one row per (automation, trigger-instance) pair op.execute( """ - CREATE TABLE automation_triggers ( + CREATE TABLE IF NOT EXISTS automation_triggers ( id SERIAL PRIMARY KEY, automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE, @@ -96,20 +128,22 @@ def upgrade() -> None: """ ) op.execute( - "CREATE INDEX ix_automation_triggers_automation_id ON automation_triggers(automation_id);" - ) - op.execute("CREATE INDEX ix_automation_triggers_type ON automation_triggers(type);") - op.execute( - "CREATE INDEX ix_automation_triggers_enabled ON automation_triggers(enabled);" + "CREATE INDEX IF NOT EXISTS ix_automation_triggers_automation_id ON automation_triggers(automation_id);" ) op.execute( - "CREATE INDEX ix_automation_triggers_created_at ON automation_triggers(created_at);" + "CREATE INDEX IF NOT EXISTS ix_automation_triggers_type ON automation_triggers(type);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automation_triggers_enabled ON automation_triggers(enabled);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automation_triggers_created_at ON automation_triggers(created_at);" ) # Partial index for the schedule tick: only enabled schedule triggers # with a scheduled next fire are ever scanned for due rows. op.execute( """ - CREATE INDEX ix_automation_triggers_due + CREATE INDEX IF NOT EXISTS ix_automation_triggers_due ON automation_triggers (next_fire_at) WHERE enabled = true AND type = 'schedule' @@ -120,7 +154,7 @@ def upgrade() -> None: # automation_runs — the immutable per-fire execution record op.execute( """ - CREATE TABLE automation_runs ( + CREATE TABLE IF NOT EXISTS automation_runs ( id SERIAL PRIMARY KEY, automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE, @@ -140,14 +174,16 @@ def upgrade() -> None: """ ) op.execute( - "CREATE INDEX ix_automation_runs_automation_id ON automation_runs(automation_id);" + "CREATE INDEX IF NOT EXISTS ix_automation_runs_automation_id ON automation_runs(automation_id);" ) op.execute( - "CREATE INDEX ix_automation_runs_trigger_id ON automation_runs(trigger_id);" + "CREATE INDEX IF NOT EXISTS ix_automation_runs_trigger_id ON automation_runs(trigger_id);" ) - op.execute("CREATE INDEX ix_automation_runs_status ON automation_runs(status);") op.execute( - "CREATE INDEX ix_automation_runs_created_at ON automation_runs(created_at);" + "CREATE INDEX IF NOT EXISTS ix_automation_runs_status ON automation_runs(status);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automation_runs_created_at ON automation_runs(created_at);" ) From c752bdd4fbf2062f9ad31dbd7cfc35cc23a76926 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 1 Jun 2026 20:23:11 +0200 Subject: [PATCH 02/11] docs: clarify automation checkpointer TODO --- .../builtin/agent_task/dependencies.py | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/surfsense_backend/app/automations/actions/builtin/agent_task/dependencies.py b/surfsense_backend/app/automations/actions/builtin/agent_task/dependencies.py index e3736cc95..4ef8c52bf 100644 --- a/surfsense_backend/app/automations/actions/builtin/agent_task/dependencies.py +++ b/surfsense_backend/app/automations/actions/builtin/agent_task/dependencies.py @@ -85,23 +85,16 @@ async def build_dependencies( connector_service, firecrawl_api_key = await setup_connector_and_firecrawl( session, search_space_id=search_space_id ) - # Quick fix: use an in-memory checkpointer for automation runs. + # Per-task InMemorySaver: the shared Postgres checkpointer's connection + # pool binds connections to the loop that opened them, but Celery uses a + # fresh loop per task, so the next task hangs 30s on a dead-loop connection + # (`PoolTimeout`). InMemorySaver has no pool and dies with the task — fine + # while runs are one-shot (the checkpoint only spans one graph execution). # - # The shared Postgres checkpointer caches DB connections in a - # module-level pool. Each cached connection is bound to the asyncio - # loop that opened it. Celery throws away the loop after every task, - # so the pool ends up full of connections pointing to a dead loop, - # and the next Celery task (running on a fresh loop) can't use any - # of them — it hangs 30s and fails with - # `PoolTimeout: couldn't get a connection after 30.00 sec`. - # - # InMemorySaver has no cached connections, no loop binding — each - # Celery task creates one and drops it on exit. - # - # TODO(checkpointer): proper fix is to dispose the checkpointer - # pool around each Celery task in `run_async_celery_task`, the same - # way `_dispose_shared_db_engine` already does for the SQLAlchemy - # pool. Then this site can switch back to the shared checkpointer. + # TODO(checkpointer): when runs need durability (crash-resume or HITL + # interrupt/resume across tasks), dispose the checkpointer pool around each + # Celery task in `run_async_celery_task` — as `_dispose_shared_db_engine` + # already does for the SQLAlchemy pool — then use the shared checkpointer. checkpointer = InMemorySaver() return AgentDependencies( llm=llm, From ec2b57bfb328ca7480ccad02d86b9b09d14e2473 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 1 Jun 2026 21:02:16 +0200 Subject: [PATCH 03/11] feat(db): add automation_runs to zero_publication (thin column list) Resyncs zero_publication via the canonical ALTER PUBLICATION ... SET TABLE pattern (mirrors migration 143) to publish a thin live view of automation_runs for dashboard real-time updates. Heavy JSONB fields (definition_snapshot, inputs, output, artifacts, error) stay on REST. --- ...add_automation_runs_to_zero_publication.py | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 surfsense_backend/alembic/versions/148_add_automation_runs_to_zero_publication.py diff --git a/surfsense_backend/alembic/versions/148_add_automation_runs_to_zero_publication.py b/surfsense_backend/alembic/versions/148_add_automation_runs_to_zero_publication.py new file mode 100644 index 000000000..1b25be753 --- /dev/null +++ b/surfsense_backend/alembic/versions/148_add_automation_runs_to_zero_publication.py @@ -0,0 +1,177 @@ +"""add automation_runs to zero_publication with thin column list + +Publishes ``automation_runs`` so the dashboard can replace polling with a +live run status + per-step ticker. Only the columns the list and ticker +read are exposed (``id, automation_id, trigger_id, status, step_results, +started_at, finished_at, created_at``); heavy JSONB +(``definition_snapshot``, ``inputs``, ``output``, ``artifacts``, ``error``) +stays on REST and is fetched lazily on detail expand. + +Uses the canonical ``ALTER PUBLICATION ... SET TABLE`` + ``COMMENT`` +bookend pattern (see migration 143) -- the shape Zero ``>=1.0`` requires +to fire its schema-change hook. Existing tables are re-emitted unchanged. + +Revision ID: 148 +Revises: 147 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "148" +down_revision: str | None = "147" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +PUBLICATION_NAME = "zero_publication" + +# Mirrors migration 143. Kept in sync explicitly: any change to these lists +# must be re-emitted in a new resync migration with COMMENT bookends. +DOCUMENT_COLS = [ + "id", + "title", + "document_type", + "search_space_id", + "folder_id", + "created_by_id", + "status", + "created_at", + "updated_at", +] + +USER_COLS = [ + "id", + "pages_limit", + "pages_used", + "premium_credit_micros_limit", + "premium_credit_micros_used", +] + +# Thin set: status + lightweight progress only. Heavy JSONB stays on REST. +AUTOMATION_RUN_COLS = [ + "id", + "automation_id", + "trigger_id", + "status", + "step_results", + "started_at", + "finished_at", + "created_at", +] + + +def _has_zero_version(conn, table: str) -> bool: + return ( + conn.execute( + sa.text( + "SELECT 1 FROM information_schema.columns " + "WHERE table_name = :tbl AND column_name = '_0_version'" + ), + {"tbl": table}, + ).fetchone() + is not None + ) + + +def _build_set_table_ddl( + *, documents_has_zero_ver: bool, user_has_zero_ver: bool +) -> str: + doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else []) + user_cols = USER_COLS + (['"_0_version"'] if user_has_zero_ver else []) + doc_col_list = ", ".join(doc_cols) + user_col_list = ", ".join(user_cols) + run_col_list = ", ".join(AUTOMATION_RUN_COLS) + return ( + f"ALTER PUBLICATION {PUBLICATION_NAME} SET TABLE " + f"notifications, " + f"documents ({doc_col_list}), " + f"folders, " + f"search_source_connectors, " + f"new_chat_messages, " + f"chat_comments, " + f"chat_session_state, " + f'"user" ({user_col_list}), ' + f"automation_runs ({run_col_list})" + ) + + +def upgrade() -> None: + conn = op.get_bind() + + exists = conn.execute( + sa.text("SELECT 1 FROM pg_publication WHERE pubname = :name"), + {"name": PUBLICATION_NAME}, + ).fetchone() + if not exists: + return + + documents_has_zero_ver = _has_zero_version(conn, "documents") + user_has_zero_ver = _has_zero_version(conn, "user") + + # COMMENT-ALTER-COMMENT trio must be one transaction so Zero observes + # them as one schema-change event. Matches the SAVEPOINT pattern used + # in migrations 117 / 139 / 140 / 143. + tx = conn.begin_nested() if conn.in_transaction() else conn.begin() + with tx: + conn.execute( + sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-148-resync'") + ) + conn.execute( + sa.text( + _build_set_table_ddl( + documents_has_zero_ver=documents_has_zero_ver, + user_has_zero_ver=user_has_zero_ver, + ) + ) + ) + conn.execute( + sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-148-resync'") + ) + + +def downgrade() -> None: + """Re-emit migration 143's shape (no automation_runs).""" + conn = op.get_bind() + + exists = conn.execute( + sa.text("SELECT 1 FROM pg_publication WHERE pubname = :name"), + {"name": PUBLICATION_NAME}, + ).fetchone() + if not exists: + return + + documents_has_zero_ver = _has_zero_version(conn, "documents") + user_has_zero_ver = _has_zero_version(conn, "user") + + doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else []) + user_cols = USER_COLS + (['"_0_version"'] if user_has_zero_ver else []) + doc_col_list = ", ".join(doc_cols) + user_col_list = ", ".join(user_cols) + ddl = ( + f"ALTER PUBLICATION {PUBLICATION_NAME} SET TABLE " + f"notifications, " + f"documents ({doc_col_list}), " + f"folders, " + f"search_source_connectors, " + f"new_chat_messages, " + f"chat_comments, " + f"chat_session_state, " + f'"user" ({user_col_list})' + ) + + tx = conn.begin_nested() if conn.in_transaction() else conn.begin() + with tx: + conn.execute( + sa.text( + f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-148-downgrade'" + ) + ) + conn.execute(sa.text(ddl)) + conn.execute( + sa.text( + f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-148-downgrade'" + ) + ) From c64781252d297ae5c19f81f3d0cadeb8c8c92dc5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 1 Jun 2026 21:02:20 +0200 Subject: [PATCH 04/11] feat(zero): define automationRunTable schema --- surfsense_web/zero/schema/automations.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 surfsense_web/zero/schema/automations.ts diff --git a/surfsense_web/zero/schema/automations.ts b/surfsense_web/zero/schema/automations.ts new file mode 100644 index 000000000..4d6ebfac7 --- /dev/null +++ b/surfsense_web/zero/schema/automations.ts @@ -0,0 +1,18 @@ +import { json, number, string, table } from "@rocicorp/zero"; + +// Thin live row: status + per-step progress only. Heavy fields +// (definition_snapshot, inputs, output, artifacts, error) stay on REST +// (`GET /automations/{id}/runs/{run_id}`) and load on detail expand. +// Mirrors the publication shape in migration 148. +export const automationRunTable = table("automation_runs") + .columns({ + id: number(), + automationId: number().from("automation_id"), + triggerId: number().optional().from("trigger_id"), + status: string(), + stepResults: json().from("step_results"), + startedAt: number().optional().from("started_at"), + finishedAt: number().optional().from("finished_at"), + createdAt: number().from("created_at"), + }) + .primaryKey("id"); From a458a56e53dbab9a290552beb8a6435542e9b14f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 1 Jun 2026 21:02:25 +0200 Subject: [PATCH 05/11] feat(zero): register automationRunTable in schema --- surfsense_web/zero/schema/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/surfsense_web/zero/schema/index.ts b/surfsense_web/zero/schema/index.ts index 3cca0f24a..d6731e371 100644 --- a/surfsense_web/zero/schema/index.ts +++ b/surfsense_web/zero/schema/index.ts @@ -1,4 +1,5 @@ import { createBuilder, createSchema, relationships } from "@rocicorp/zero"; +import { automationRunTable } from "./automations"; import { chatCommentTable, chatSessionStateTable, newChatMessageTable } from "./chat"; import { documentTable, searchSourceConnectorTable } from "./documents"; import { folderTable } from "./folders"; @@ -36,6 +37,7 @@ export const schema = createSchema({ chatCommentTable, chatSessionStateTable, userTable, + automationRunTable, ], relationships: [chatCommentRelationships, newChatMessageRelationships], }); From 9dbe864d6a0f3339e0c6495717094a08cf7ba9cc Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 1 Jun 2026 21:02:30 +0200 Subject: [PATCH 06/11] feat(zero): add runs-by-automation query --- surfsense_web/zero/queries/automations.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 surfsense_web/zero/queries/automations.ts diff --git a/surfsense_web/zero/queries/automations.ts b/surfsense_web/zero/queries/automations.ts new file mode 100644 index 000000000..27b274acc --- /dev/null +++ b/surfsense_web/zero/queries/automations.ts @@ -0,0 +1,14 @@ +import { defineQuery } from "@rocicorp/zero"; +import { z } from "zod"; +import { zql } from "../schema/index"; + +// Mirrors chat byThread: client passes the parent id, the REST route still +// authorizes via `automation_id -> search_space`. No search_space_id on the +// table by design. +export const automationRunQueries = { + byAutomation: defineQuery( + z.object({ automationId: z.number() }), + ({ args: { automationId } }) => + zql.automation_runs.where("automationId", automationId).orderBy("createdAt", "desc") + ), +}; From c73f7ef03a4cb7f530845844625cf2f2ff6fd946 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 1 Jun 2026 21:02:34 +0200 Subject: [PATCH 07/11] feat(zero): register automationRuns queries --- surfsense_web/zero/queries/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/surfsense_web/zero/queries/index.ts b/surfsense_web/zero/queries/index.ts index fbf1bd76e..fe711f5d3 100644 --- a/surfsense_web/zero/queries/index.ts +++ b/surfsense_web/zero/queries/index.ts @@ -1,4 +1,5 @@ import { defineQueries } from "@rocicorp/zero"; +import { automationRunQueries } from "./automations"; import { chatSessionQueries, commentQueries, messageQueries } from "./chat"; import { connectorQueries, documentQueries } from "./documents"; import { folderQueries } from "./folders"; @@ -14,4 +15,5 @@ export const queries = defineQueries({ comments: commentQueries, chatSession: chatSessionQueries, user: userQueries, + automationRuns: automationRunQueries, }); From 69eb64db08d16384522ce2f63bde46a347048fec Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 1 Jun 2026 21:02:40 +0200 Subject: [PATCH 08/11] refactor(automations): source runs list from Zero useAutomationRuns now reads from the zero_publication thin column set and adapts rows to LiveRunSummary (RunSummary + step_results). The detail hook stays on REST for the heavy fields. --- surfsense_web/hooks/use-automation-runs.ts | 99 ++++++++++++++++++---- 1 file changed, 83 insertions(+), 16 deletions(-) diff --git a/surfsense_web/hooks/use-automation-runs.ts b/surfsense_web/hooks/use-automation-runs.ts index c91c7bd6e..69e51ddc6 100644 --- a/surfsense_web/hooks/use-automation-runs.ts +++ b/surfsense_web/hooks/use-automation-runs.ts @@ -1,42 +1,109 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import type { Run, RunListResponse } from "@/contracts/types/automation.types"; +import { useQuery as useZeroQuery } from "@rocicorp/zero/react"; +import { useQuery as useReactQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import type { Run, RunStepResult, RunSummary } from "@/contracts/types/automation.types"; import { automationsApiService } from "@/lib/apis/automations-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queries } from "@/zero/queries"; const DEFAULT_LIMIT = 50; -const DEFAULT_OFFSET = 0; + +/** + * Thin live row sourced from Zero. Strict superset of {@link RunSummary} — + * existing consumers that only look at the summary fields keep working, + * while the run detail panel can read ``step_results`` directly for the + * live step ticker without a second REST round-trip. + */ +export interface LiveRunSummary extends RunSummary { + step_results: RunStepResult[]; +} export interface UseAutomationRunsOptions { limit?: number; - offset?: number; - enabled?: boolean; } -/** Paginated run history for one automation. Newest-first per backend. */ +interface UseAutomationRunsResult { + data: { items: LiveRunSummary[]; total: number } | undefined; + isLoading: boolean; + error: Error | null; +} + +/** + * Live run history for one automation, newest-first. Sourced from Zero's + * thin ``automation_runs`` publication so status and per-step progress + * tick in real time without polling. Heavy fields (output, artifacts, + * inputs, error, definition_snapshot) are still fetched lazily via + * {@link useAutomationRun}. + */ export function useAutomationRuns( automationId: number | undefined, - { limit = DEFAULT_LIMIT, offset = DEFAULT_OFFSET, enabled = true }: UseAutomationRunsOptions = {} -) { - return useQuery({ - queryKey: cacheKeys.automations.runs(automationId ?? 0, limit, offset), - queryFn: () => automationsApiService.listRuns(automationId as number, { limit, offset }), - enabled: enabled && !!automationId, - staleTime: 30_000, - }); + { limit = DEFAULT_LIMIT }: UseAutomationRunsOptions = {} +): UseAutomationRunsResult { + const [rows, result] = useZeroQuery( + queries.automationRuns.byAutomation({ automationId: automationId ?? -1 }) + ); + + const items = useMemo(() => { + if (!automationId) return []; + return rows.slice(0, limit).map(toLiveRunSummary); + }, [automationId, rows, limit]); + + const total = automationId ? rows.length : 0; + + // Pre-hydration window: nothing visible AND Zero hasn't confirmed + // completeness yet. After the first sync (even an empty set) we stop + // showing the skeleton so the empty-state copy can take over. + const isLoading = !!automationId && result.type !== "complete" && rows.length === 0; + + return { + data: automationId ? { items, total } : undefined, + isLoading, + error: null, + }; } -/** Single run with the full snapshot, step results, output and artifacts. */ +/** + * Full run record (definition snapshot, inputs, output, artifacts, error). + * Stays on REST: these fields are large and largely static after the run + * finishes, so they're not worth replicating to every connected client. + */ export function useAutomationRun( automationId: number | undefined, runId: number | undefined, options: { enabled?: boolean } = {} ) { const { enabled = true } = options; - return useQuery({ + return useReactQuery({ queryKey: cacheKeys.automations.run(automationId ?? 0, runId ?? 0), queryFn: () => automationsApiService.getRun(automationId as number, runId as number), enabled: enabled && !!automationId && !!runId, staleTime: 30_000, }); } + +interface ZeroAutomationRunRow { + id: number; + automationId: number; + triggerId?: number | null; + status: string; + stepResults: unknown; + startedAt?: number | null; + finishedAt?: number | null; + createdAt: number; +} + +/** Adapt a Zero camelCase row (epoch ms timestamps) to the snake_case + * ISO-string ``RunSummary`` shape the existing UI already consumes. */ +function toLiveRunSummary(row: ZeroAutomationRunRow): LiveRunSummary { + return { + id: row.id, + automation_id: row.automationId, + trigger_id: row.triggerId ?? null, + status: row.status as RunSummary["status"], + started_at: row.startedAt ? new Date(row.startedAt).toISOString() : null, + finished_at: row.finishedAt ? new Date(row.finishedAt).toISOString() : null, + created_at: new Date(row.createdAt).toISOString(), + step_results: Array.isArray(row.stepResults) ? (row.stepResults as RunStepResult[]) : [], + }; +} From d8db3159d6aae6a2357f53d99a2ebf5fa906bdf8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 1 Jun 2026 21:02:47 +0200 Subject: [PATCH 09/11] feat(automations): forward live status and steps to run details panel --- .../[automation_id]/components/run-row.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx index 3f6a39c35..b48230e3f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx @@ -1,21 +1,21 @@ "use client"; import { ChevronDown, ChevronRight, Hand } from "lucide-react"; import { useState } from "react"; -import type { RunSummary } from "@/contracts/types/automation.types"; +import type { LiveRunSummary } from "@/hooks/use-automation-runs"; import { formatDuration } from "@/lib/automations/run-duration"; import { formatRelativeDate } from "@/lib/format-date"; import { RunDetailsPanel } from "./run-details-panel"; import { RunStatusBadge } from "./run-status-badge"; interface RunRowProps { - run: RunSummary; + run: LiveRunSummary; automationId: number; } /** - * One run row. Click to expand → fetches the full run and shows the - * details panel inline. State is local to each row so multiple panels - * can be open at once (or none). + * One run row. Click to expand → renders the details panel inline. + * Status and step_results come live from the parent's Zero query; the + * panel itself only fetches the heavy REST fields on first expand. */ export function RunRow({ run, automationId }: RunRowProps) { const [open, setOpen] = useState(false); @@ -47,7 +47,14 @@ export function RunRow({ run, automationId }: RunRowProps) { - {open && } + {open && ( + + )} ); } From ca66bff02b42f251c0dfc1c68c62ef80cbc8d83d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 1 Jun 2026 21:02:54 +0200 Subject: [PATCH 10/11] feat(automations): render live step ticker, defer REST until terminal Step results now render from the synced Zero row so the panel ticks forward as the run progresses. The REST getRun call is gated on the run reaching a terminal status, since output/artifacts/error are only written at terminal mark. --- .../components/run-details-panel.tsx | 108 ++++++++++-------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx index 164f156e5..1a54ac0e5 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx @@ -15,7 +15,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; -import type { RunStepResult } from "@/contracts/types/automation.types"; +import type { RunStatus, RunStepResult } from "@/contracts/types/automation.types"; import { useAutomationRun } from "@/hooks/use-automation-runs"; import { cn } from "@/lib/utils"; import { RunStepResultCard } from "./run-step-result-card"; @@ -23,44 +23,46 @@ import { RunStepResultCard } from "./run-step-result-card"; interface RunDetailsPanelProps { automationId: number; runId: number; + /** Live step entries from Zero; rendered while the run is in-flight and + * also kept as the authoritative source once it finishes. */ + liveSteps: RunStepResult[]; + /** Live run status from Zero. Used to hide diagnostic sections that + * only make sense after the run reaches a terminal state. */ + liveStatus: RunStatus; } /** - * Expanded view of a single run. Fetches lazily — the parent only renders - * this once the row is opened, so the list view stays cheap. + * Expanded view of a single run. Steps render immediately from the live + * Zero row so the panel updates as the run progresses; the heavy REST + * payload (output, artifacts, resolved inputs, run-level error) is + * fetched lazily and merged in when it arrives. * - * We surface the run outcome readably: a run-level error first (when - * present), then per-step cards that render the agent's markdown - * ``final_message`` directly, and finally the structural artifacts/inputs. - * The full ``definition_snapshot`` is omitted because it usually mirrors the - * live definition — surfacing it would dominate the panel without informing + * Surfacing order is outcome-first: a run-level error (when present), + * then per-step cards that render the agent's markdown ``final_message`` + * directly, and finally the structural artifacts/inputs. The full + * ``definition_snapshot`` is omitted because it usually mirrors the live + * definition — surfacing it would dominate the panel without informing * what the user is trying to learn ("did this work? what did it do?"). */ -export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) { - const { data: run, isLoading, error } = useAutomationRun(automationId, runId); +export function RunDetailsPanel({ + automationId, + runId, + liveSteps, + liveStatus, +}: RunDetailsPanelProps) { + const isTerminal = liveStatus !== "pending" && liveStatus !== "running"; + // Defer the REST round-trip until the run can actually carry heavy + // fields — output/artifacts/error are only written at terminal mark. + const { data: run, isLoading, error } = useAutomationRun(automationId, runId, { + enabled: isTerminal, + }); - if (isLoading) { - return ( -
- - -
- ); - } - - if (error || !run) { - return ( -
- Couldn't load run details{error?.message ? `: ${error.message}` : "."} -
- ); - } - - const runError = run.error && Object.keys(run.error).length > 0 ? run.error : null; - const hasOutput = run.output && Object.keys(run.output).length > 0; - const hasInputs = Object.keys(run.inputs ?? {}).length > 0; - const steps = run.step_results as RunStepResult[]; - const hasDiagnostics = run.artifacts.length > 0 || hasInputs; + const runError = run?.error && Object.keys(run.error).length > 0 ? run.error : null; + const hasOutput = !!run?.output && Object.keys(run.output).length > 0; + const hasInputs = !!run && Object.keys(run.inputs ?? {}).length > 0; + const hasDiagnostics = !!run && (run.artifacts.length > 0 || hasInputs); + const heavyLoading = isTerminal && isLoading && !run; + const heavyError = isTerminal && !!error; return (
@@ -72,30 +74,40 @@ export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) { ) : null} -
- {steps.length === 0 ? ( -

No steps recorded.

+
+ {liveSteps.length === 0 ? ( +

+ {isTerminal ? "No steps recorded." : "Waiting for first step…"} +

) : (
- {steps.map((step, index) => ( + {liveSteps.map((step, index) => ( ))}
)}
- {hasDiagnostics ? : null} - - {run.artifacts.length > 0 ? ( -
- -
- ) : null} - - {hasInputs ? ( -
- -
+ {heavyLoading ? ( + + ) : heavyError ? ( +

+ Couldn't load run details{error?.message ? `: ${error.message}` : "."} +

+ ) : hasDiagnostics ? ( + <> + + {run && run.artifacts.length > 0 ? ( +
+ +
+ ) : null} + {hasInputs ? ( +
+ +
+ ) : null} + ) : null}
); From 00ee6d04ee94e5ec4f81ec31c288378e63b1b341 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 1 Jun 2026 21:02:58 +0200 Subject: [PATCH 11/11] docs(zero-sync): list automation_runs in synced tables --- surfsense_web/content/docs/how-to/zero-sync.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_web/content/docs/how-to/zero-sync.mdx b/surfsense_web/content/docs/how-to/zero-sync.mdx index 1a7762f23..7007e6637 100644 --- a/surfsense_web/content/docs/how-to/zero-sync.mdx +++ b/surfsense_web/content/docs/how-to/zero-sync.mdx @@ -104,6 +104,7 @@ Zero syncs the following tables for real-time features: | `new_chat_messages` | Live chat message sync for shared chats | | `chat_comments` | Real-time comment threads on AI responses | | `chat_session_state` | Collaboration indicators (who is typing) | +| `automation_runs` | Live run status and per-step progress (thin column set; heavy fields stay on REST) | ## Troubleshooting