Vertical slice at /dashboard/[id]/automations/[automation_id]. Branches
in the orchestrator are: perms loading → skeleton, no-access → access
denied panel, bad id → not-found, fetch loading → skeleton, fetch
error → not-found, loaded → header + definition + triggers.
Route:
- page.tsx — server boundary; extracts both ids.
- automation-detail-content.tsx — client orchestrator.
Header:
- automation-detail-header.tsx — back link, name, status badge,
description, pause/resume + delete actions. Delete navigates back to
the list via a new onDeleted hook on DeleteAutomationDialog so the
list page (where the row just vanishes) stays unaffected.
- automation-not-found.tsx — 404/403/NaN-id panel. We don't
distinguish missing vs. forbidden in the UI.
Definition (read-only in v1):
- automation-definition-section.tsx — wrapper Card; renders goal +
tags + execution defaults + inputs schema (if present) + plan.
- plan-step-card.tsx — one step (when, output_as, retries, timeout,
params JSON).
- execution-summary.tsx — timeout / max_retries / backoff /
concurrency + on_failure step count.
- inputs-schema-preview.tsx — formatted JSON of inputs.schema; only
rendered when the definition declares inputs.
Triggers:
- automation-triggers-section.tsx — wrapper Card, "Add via chat" CTA
(creation is intent-driven, same philosophy as automations).
- trigger-card.tsx — schedule + timezone + cron, last/next fire
hints, static_inputs JSON, enable Switch and remove button.
- delete-trigger-dialog.tsx — confirm + mutation atom.
Shared:
- lib/describe-cron.ts — moved out of automation-triggers-summary.tsx
so both list and detail can describe schedules consistently
(daily/weekdays/weekly/monthly/hourly, raw cron fallback).
Loading:
- automation-detail-loading.tsx — same shell as the loaded view so the
layout doesn't jump on data arrival.
RBAC: each interactive surface is independently gated
(canUpdate/canDelete/canCreate) so the orchestrator stays thin and the
component tree is self-documenting about what each action requires.
Out of scope (later PRs):
- Editing definition / trigger params (raw-JSON path) — PR5
- Run history — PR6
The empty-state card already hosts the primary "Create via chat" CTA;
keeping the header button on the same screen showed two identical
buttons. Adds an optional ``showCreateCta`` prop to AutomationsHeader
(default true) and turns it off only in the empty branch so the card
stays the focal point.
Adds an "Automations" nav entry rendered explicitly between Inbox and
(on mobile) Documents, mirroring how those two are pulled out of the
nav list and rendered above the chat sections. The icon is Workflow
to match settings/RBAC labelling.
LayoutDataProvider:
- Adds the entry to navItems pointing at /dashboard/[id]/automations.
- Marks isActive via pathname so the row highlights on the route.
- Tags /automations as a workspace-panel page so it renders in the
centered settings-style viewport (same chrome as Team / settings).
Sidebar:
- Pulls out automationsItem alongside inboxItem and documentsItem.
- Renders it between them.
- Excludes its URL from footerNavItems so it doesn't double-render.
Page-level RBAC still gates the actual view; the sidebar entry is
always visible (consistent with Inbox/Documents which are also not
gated at the nav layer).
Anonymous (FreeLayoutDataProvider) intentionally not touched —
automations is an authenticated feature.
Vertical slice at /dashboard/[id]/automations. The page is read-only by
default; every action gates on backend automations:* permissions via a
co-located permissions hook so adding/removing surfaces stays a
one-file change.
Route:
- page.tsx — server boundary; extracts search_space_id.
- automations-content.tsx — client orchestrator (loading / no-access /
error / empty / table branches).
Components (one concern per file):
- automations-header.tsx — title + count + "Create via chat" CTA.
- automations-table.tsx + automation-row.tsx — name/status/updated
columns; row name links to detail (PR4).
- automation-status-badge.tsx — active / paused / archived pill.
- automation-row-actions.tsx — ⋯ menu with pause/resume + delete,
gated on canUpdate / canDelete. Archived rows hide the toggle.
- delete-automation-dialog.tsx — destructive confirm; mentions FK
cascade explicitly so users know triggers/runs go too.
- automations-empty-state.tsx — zero-state pointing to chat (creation
is intent-driven via the create_automation HITL tool, not a form).
- automations-loading.tsx — skeleton rows in the same shell so the
layout doesn't shift on data arrival.
- automation-triggers-summary.tsx — small cron-describer (daily,
weekdays, weekly, monthly, hourly) + timezone for the detail page.
Kept inline since v1 only registers schedule.
Hooks:
- use-automation-permissions.ts — single source of truth for the
slice's canCreate/canRead/canUpdate/canDelete/canExecute gates,
backed by myAccessAtom.
Pause/resume and delete reuse the PR2 mutation atoms, so list +
detail caches stay coherent without bespoke invalidation.
Out of scope (later PRs):
- detail route (definition viewer + triggers manager) — PR4
- raw JSON editor — PR5
- nav entry / sidebar wiring — small follow-up PR
DELETE endpoints in the automations API return 204; calling .json() on
an empty body throws SyntaxError. Treat 204 as data=null and skip
schema validation so callers can opt out of response bodies without
errors or spurious schema-mismatch warnings.
Also drops a pre-existing 'unknown → BodyInit' type error on the
non-JSON body branch via a narrow cast (caller is responsible for
passing a real BodyInit when Content-Type isn't application/json).
Backend already defined automations:create/read/update/delete/execute and
seeded them on Owner/Editor/Viewer roles, but the Settings → Roles UI was
missing the metadata to render them properly.
- backend: add PERMISSION_DESCRIPTIONS entries for the 5 automations perms so
the role editor stops falling back to "Permission for automations:create".
- frontend: add automations to CATEGORY_CONFIG (Workflow icon, slotted between
podcasts and connectors) so the role editor groups them as a real section.
- frontend: extend the three ROLE_PRESETS — Editor and Contributor get
create/read/update/execute (mirroring backend Editor); Viewer gets read.
Prep work for the automations frontend; canPerform/usePermissionGate already
handle the runtime gating, so no new hook is needed.
Single tool exposed to the main agent. The main agent passes a natural-language
`intent`; a focused drafter sub-LLM turns it into a full AutomationCreate JSON;
that JSON is surfaced via request_approval (action_type "automation_create") so
the user can edit/approve it on a frontend card; on approval the tool persists
via AutomationService. Three phases, one tool call.
Scope split:
- main agent sees only `intent: str` (no schema knowledge leaks into the calling
graph) — prompt fragments scoped accordingly.
- drafter sub-LLM owns the schema + few-shot intent→JSON examples — lives in
the generating graph's prompt (tools/automation/prompt.py).
Files:
- main_agent/tools/automation/{create.py, prompt.py, __init__.py}: new tool
+ drafter system prompt with two few-shot intent→JSON examples.
- system_prompt/prompts/tools/create_automation/{description.md, example.md}:
intent-only guidance for the main agent.
- main_agent/tools/index.py: add create_automation to the main-agent allowlist.
- new_chat/tools/registry.py: deferred-import factory to break the
multi_agent_chat ↔ registry cycle; one ToolDefinition entry.
Manual-as-a-standalone-trigger conflates "user clicks Run now" with the
trigger model and forces ad-hoc input plumbing on the caller. Remove the
unreachable surface so the tree reflects reality (schedule is the only
v1 trigger).
- Unregister `manual`: drop import from triggers/__init__.py
- Delete `app/automations/triggers/manual/`
- Drop `RunService.dispatch_manual` (RunService is now read-only)
- Drop `POST /automations/{id}/run` and `RunDispatched` schema
- Keep `TriggerType.MANUAL` Python + PG enum value (reserved, documented)
to avoid an Alembic round-trip when Run-now is redesigned
Re-apply the trim style after the prior refactor commit re-introduced
a multi-line docstring on AutomationRun.
- AutomationRun: drop the four-line docstring explaining where
per-step session ids live; move the note to a single-line inline
comment right above ``step_results`` where it's actionable.
- AutomationDefinition: drop the design-plan cross-reference; the
module docstring already establishes what the file is.
No behaviour change.
A run can contain zero, one, or N agent_task steps. A single
agent_session_id at the run level holds at most one of them, so the
column is the wrong shape for the data.
Per-step session ids (LangGraph thread/checkpoint reference for an
agent_task step) live inside step_results[i] alongside the rest of
the per-step bag (status, timings, output). Each agent step records
its own; non-agent steps record nothing. Run-level "primary session"
is a UI concern, not a schema concern.
Trade-off: trace -> run reverse lookup is now a JSONB query, not an
index hit. Usually traversal goes run -> trace; if the reverse
becomes hot we add a GIN index on step_results or a generated
column — both additive.
Changes:
- AutomationRun: drop the agent_session_id column; module docstring
notes where per-step session ids now live.
- Migration 144: drop the column from the CREATE TABLE; downgrade
unchanged.
Safe to edit migration 144 in place (vs. add 145 with ALTER ... DROP):
this branch has not shipped and the table has never existed in any
deployed database.
Cut the docstrings and Field(description=...) text across the entire
automations/ tree down to single-line intent statements, matching the
multi_agent_chat conciseness style:
- Module docstrings: one line stating what the file is.
- Class docstrings: deleted when the class name + module docstring
already cover intent; kept only where they add a constraint or
rationale not visible in the signature.
- Pydantic Field descriptions: short noun phrases / clauses, not
full sentences. Reasoning that belonged in the design plan moved
out of the code.
- Enum values: per-value docstrings replaced with terse inline
comments where the meaning isn't obvious from the name.
Behaviour is unchanged. The same 33 files, same public surface, same
imports — verified by re-running the 10-point registry smoke test and
the 8-point schema round-trip / constraint suite from commits 9 and
10.
LOC: 1180 → 691 (-42%).
Three registries under app/automations/registries/, each as its own
folder with the same SRP-per-file split (types.py for the dataclass,
store.py for the in-memory dict + register/get/all functions). All
three start empty; concrete entries land when the user signs off on
which capabilities / actions / triggers to include (step 2).
Capability (locked at v1-minimum five fields — see commit 2):
- id, description, input_schema, output_schema, handler
- CapabilityHandler = Callable[[dict[str, Any]], Awaitable[Any]]
- Frozen, slotted dataclass (immutable post-registration).
ActionDefinition (v1-trim of design plan §4):
- type, name, description, config_schema, handler
- Defers output_contract (handled per-step by agent_task's
config.output_schema), uses_capabilities (no static analysis
needed until >1 action ships), and produces_artifacts (deferred
alongside the artifact pipeline).
TriggerDefinition (declarative, no handler):
- type, description, config_schema, payload_schema
- No handler field — firing is a single dispatcher's
responsibility, not a per-trigger one.
store.py contract for all three:
- register_*: idempotent at process startup, raises on duplicate
- get_*: returns None on miss
- all_*: returns a defensive copy of the registry dict
Verified by an inline smoke test (10 checks): empty initial state,
registration and lookup work, duplicates raise, frozen dataclasses
reject mutation, snapshots are copies, handlers are awaitable.
Isolation invariant audit: grep across the full app/automations/
tree shows only three app.* imports, all of them
``from app.db import BaseModel, TimestampMixin`` in the model files.
No imports from app.agents.*, app.services.*, app.tasks.*,
app.routes.*, or any other business-logic module.
Three layers of Pydantic models under app/automations/schemas/, one
file per concern (SRP), matching the envelope in
automation-design-plan.md §5.
definition/ — the editable envelope persisted in
automations.definition:
- envelope.py AutomationDefinition (top-level shape)
- plan_step.py PlanStep (one step in the sequential plan)
- inputs.py InputsBlock (the inputs JSON Schema wrapper)
- execution.py ExecutionBlock (timeouts, retries, concurrency,
budget cap, on_failure plan)
- metadata.py MetadataBlock (tags + created_from_nl + extras)
- trigger_spec.py TriggerSpec (one entry in triggers[])
triggers/ — per-trigger config schemas, dispatched by registry on the
TriggerSpec.type discriminator:
- schedule.py ScheduleTriggerConfig(cron, timezone)
- manual.py ManualTriggerConfig() — empty in v1
actions/ — per-action config schemas, dispatched by registry on the
PlanStep.action discriminator:
- agent_task.py AgentTaskActionConfig(prompt, tools, model,
output_schema)
Design properties verified by an inline smoke test:
- The §5 worked example round-trips through model_validate_json /
model_dump_json byte-for-byte (InputsBlock uses
serialize_by_alias so the JSON key stays "schema" not
"schema_").
- Envelope rejects unknown top-level keys (extra="forbid").
- MetadataBlock tolerates unknown keys (extra="allow").
- ExecutionBlock defaults apply when the block is omitted.
- retry_backoff and concurrency are typed as Literal — bogus
values rejected at validation time.
- Per-type configs enforce their required fields (cron + timezone
on schedule; non-empty prompt on agent_task).
The envelope keeps trigger and action configs as untyped dicts on
purpose — per-type validation is a registry-driven dispatch (commit
10), keeping the envelope free of every-type-knows-every-type
coupling.
Migration 144 -> 143. Matches the SQLAlchemy models added in commit 7
and the v1 data model in automation-design-plan.md §9.
Up:
- CREATE TYPE automation_status / automation_trigger_type /
automation_run_status (PostgreSQL ENUMs created first because the
tables reference them).
- CREATE TABLE automations with FK to searchspaces (CASCADE) and
user (SET NULL); five indexes matching the SQLAlchemy model.
- CREATE TABLE automation_triggers with FK to automations
(CASCADE); four indexes.
- CREATE TABLE automation_runs with FK to automations (CASCADE) and
automation_triggers (SET NULL — null trigger_id == manual via UI);
four indexes.
Down: drops every index, table, and ENUM in reverse-dependency order
so the migration is reversible without ON DELETE side effects.
Verified: `alembic history` resolves 143 -> 144 (head) cleanly.
domain_events (Phase 3) and mcp_connections / mcp_tools (Phase 4) ship
in their own migrations when the consuming feature lands; this
migration only covers the three v1 tables.
Three enums (one file each) plus three models (one file each), all
under app/automations/persistence/. The module imports from app.db
only (Base/BaseModel/TimestampMixin and FK targets searchspaces.id /
user.id); no business-logic imports.
Enums:
- AutomationStatus: active | paused | archived
- RunStatus: pending | running | succeeded | failed | cancelled
| timed_out
- TriggerType: schedule | manual (Phase-2/3 add webhook | event)
Models:
- Automation: search_space-scoped, created_by_user_id (SET NULL),
name + description, status enum, definition JSONB, version int,
updated_at with onupdate.
- AutomationTrigger: FK → automations (CASCADE), type enum, config
JSONB, enabled bool, last_fired_at. Webhook secret_hash is omitted
until Phase 2.
- AutomationRun: FK → automations (CASCADE), nullable trigger_id
(SET NULL — null = manual via UI), status enum,
definition_snapshot for immutable history, trigger_payload /
resolved_inputs / step_results / output / artifacts / error JSONB
columns, started_at / finished_at timestamps, agent_session_id for
linking to the LangGraph trace. cost_usd column omitted until at
least one v1 capability records token-level cost.
Verified: Base.metadata exposes all three table names; columns and
enums introspect as documented; no linter errors.