Skip to content

Commit 4dda02c

Browse files
authored
Merge pull request #1443 from CREDO23/feature-automations
[Feat] Automation V1 — Scheduled Agent Tasks, Created via Chat (HITL) or JSON
2 parents b645c3f + 958bf9f commit 4dda02c

219 files changed

Lines changed: 13821 additions & 55 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""Add automation tables (automations, automation_triggers, automation_runs)
2+
3+
Revision ID: 144
4+
Revises: 143
5+
Create Date: 2026-05-26
6+
7+
Adds the three tables that back the v1 automation engine, plus the
8+
three PostgreSQL ENUM types they reference. Matches the SQLAlchemy
9+
models under ``app.automations.persistence.models`` and the v1 data
10+
model in ``automation-design-plan.md`` §9.
11+
12+
v1 ships these three tables only. ``domain_events`` is deferred to
13+
Phase 3 with the event trigger; ``mcp_connections`` / ``mcp_tools``
14+
are deferred to Phase 4 with the MCP integration.
15+
"""
16+
17+
from collections.abc import Sequence
18+
19+
from alembic import op
20+
21+
revision: str = "144"
22+
down_revision: str | None = "143"
23+
branch_labels: str | Sequence[str] | None = None
24+
depends_on: str | Sequence[str] | None = None
25+
26+
27+
def upgrade() -> None:
28+
# ENUM types (PostgreSQL requires types created before tables that use them)
29+
op.execute(
30+
"""
31+
CREATE TYPE automation_status AS ENUM (
32+
'active', 'paused', 'archived'
33+
);
34+
"""
35+
)
36+
op.execute(
37+
"""
38+
CREATE TYPE automation_trigger_type AS ENUM (
39+
'schedule', 'manual'
40+
);
41+
"""
42+
)
43+
op.execute(
44+
"""
45+
CREATE TYPE automation_run_status AS ENUM (
46+
'pending', 'running', 'succeeded', 'failed',
47+
'cancelled', 'timed_out'
48+
);
49+
"""
50+
)
51+
52+
# automations — the editable, versioned automation definition
53+
op.execute(
54+
"""
55+
CREATE TABLE automations (
56+
id SERIAL PRIMARY KEY,
57+
search_space_id INTEGER NOT NULL
58+
REFERENCES searchspaces(id) ON DELETE CASCADE,
59+
created_by_user_id UUID
60+
REFERENCES "user"(id) ON DELETE SET NULL,
61+
name VARCHAR(200) NOT NULL,
62+
description TEXT,
63+
status automation_status NOT NULL DEFAULT 'active',
64+
definition JSONB NOT NULL,
65+
version INTEGER NOT NULL DEFAULT 1,
66+
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
67+
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
68+
);
69+
"""
70+
)
71+
op.execute(
72+
"CREATE INDEX ix_automations_search_space_id ON automations(search_space_id);"
73+
)
74+
op.execute(
75+
"CREATE INDEX ix_automations_created_by_user_id ON automations(created_by_user_id);"
76+
)
77+
op.execute("CREATE INDEX ix_automations_status ON automations(status);")
78+
op.execute("CREATE INDEX ix_automations_created_at ON automations(created_at);")
79+
op.execute("CREATE INDEX ix_automations_updated_at ON automations(updated_at);")
80+
81+
# automation_triggers — one row per (automation, trigger-instance) pair
82+
op.execute(
83+
"""
84+
CREATE TABLE automation_triggers (
85+
id SERIAL PRIMARY KEY,
86+
automation_id INTEGER NOT NULL
87+
REFERENCES automations(id) ON DELETE CASCADE,
88+
type automation_trigger_type NOT NULL,
89+
params JSONB NOT NULL,
90+
static_inputs JSONB NOT NULL DEFAULT '{}'::jsonb,
91+
enabled BOOLEAN NOT NULL DEFAULT true,
92+
last_fired_at TIMESTAMP WITH TIME ZONE,
93+
next_fire_at TIMESTAMP WITH TIME ZONE,
94+
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
95+
);
96+
"""
97+
)
98+
op.execute(
99+
"CREATE INDEX ix_automation_triggers_automation_id ON automation_triggers(automation_id);"
100+
)
101+
op.execute(
102+
"CREATE INDEX ix_automation_triggers_type ON automation_triggers(type);"
103+
)
104+
op.execute(
105+
"CREATE INDEX ix_automation_triggers_enabled ON automation_triggers(enabled);"
106+
)
107+
op.execute(
108+
"CREATE INDEX ix_automation_triggers_created_at ON automation_triggers(created_at);"
109+
)
110+
# Partial index for the schedule tick: only enabled schedule triggers
111+
# with a scheduled next fire are ever scanned for due rows.
112+
op.execute(
113+
"""
114+
CREATE INDEX ix_automation_triggers_due
115+
ON automation_triggers (next_fire_at)
116+
WHERE enabled = true
117+
AND type = 'schedule'
118+
AND next_fire_at IS NOT NULL;
119+
"""
120+
)
121+
122+
# automation_runs — the immutable per-fire execution record
123+
op.execute(
124+
"""
125+
CREATE TABLE automation_runs (
126+
id SERIAL PRIMARY KEY,
127+
automation_id INTEGER NOT NULL
128+
REFERENCES automations(id) ON DELETE CASCADE,
129+
trigger_id INTEGER
130+
REFERENCES automation_triggers(id) ON DELETE SET NULL,
131+
status automation_run_status NOT NULL DEFAULT 'pending',
132+
definition_snapshot JSONB NOT NULL,
133+
inputs JSONB NOT NULL DEFAULT '{}'::jsonb,
134+
step_results JSONB NOT NULL DEFAULT '[]'::jsonb,
135+
output JSONB,
136+
artifacts JSONB NOT NULL DEFAULT '[]'::jsonb,
137+
error JSONB,
138+
started_at TIMESTAMP WITH TIME ZONE,
139+
finished_at TIMESTAMP WITH TIME ZONE,
140+
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
141+
);
142+
"""
143+
)
144+
op.execute(
145+
"CREATE INDEX ix_automation_runs_automation_id ON automation_runs(automation_id);"
146+
)
147+
op.execute(
148+
"CREATE INDEX ix_automation_runs_trigger_id ON automation_runs(trigger_id);"
149+
)
150+
op.execute("CREATE INDEX ix_automation_runs_status ON automation_runs(status);")
151+
op.execute(
152+
"CREATE INDEX ix_automation_runs_created_at ON automation_runs(created_at);"
153+
)
154+
155+
156+
def downgrade() -> None:
157+
op.execute("DROP INDEX IF EXISTS ix_automation_runs_created_at;")
158+
op.execute("DROP INDEX IF EXISTS ix_automation_runs_status;")
159+
op.execute("DROP INDEX IF EXISTS ix_automation_runs_trigger_id;")
160+
op.execute("DROP INDEX IF EXISTS ix_automation_runs_automation_id;")
161+
op.execute("DROP TABLE IF EXISTS automation_runs;")
162+
163+
op.execute("DROP INDEX IF EXISTS ix_automation_triggers_due;")
164+
op.execute("DROP INDEX IF EXISTS ix_automation_triggers_created_at;")
165+
op.execute("DROP INDEX IF EXISTS ix_automation_triggers_enabled;")
166+
op.execute("DROP INDEX IF EXISTS ix_automation_triggers_type;")
167+
op.execute("DROP INDEX IF EXISTS ix_automation_triggers_automation_id;")
168+
op.execute("DROP TABLE IF EXISTS automation_triggers;")
169+
170+
op.execute("DROP INDEX IF EXISTS ix_automations_updated_at;")
171+
op.execute("DROP INDEX IF EXISTS ix_automations_created_at;")
172+
op.execute("DROP INDEX IF EXISTS ix_automations_status;")
173+
op.execute("DROP INDEX IF EXISTS ix_automations_created_by_user_id;")
174+
op.execute("DROP INDEX IF EXISTS ix_automations_search_space_id;")
175+
op.execute("DROP TABLE IF EXISTS automations;")
176+
177+
op.execute("DROP TYPE IF EXISTS automation_run_status;")
178+
op.execute("DROP TYPE IF EXISTS automation_trigger_type;")
179+
op.execute("DROP TYPE IF EXISTS automation_status;")
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Add automations permissions to existing Editor/Viewer roles
2+
3+
Revision ID: 145
4+
Revises: 144
5+
Create Date: 2026-05-27
6+
7+
Owners already have ``*`` and need no backfill. Custom (non-system) roles
8+
are left untouched on purpose: workspace admins manage those explicitly.
9+
"""
10+
11+
from collections.abc import Sequence
12+
13+
from sqlalchemy import text
14+
15+
from alembic import op
16+
17+
revision: str = "145"
18+
down_revision: str | None = "144"
19+
branch_labels: str | Sequence[str] | None = None
20+
depends_on: str | Sequence[str] | None = None
21+
22+
23+
_EDITOR_PERMISSIONS = (
24+
"automations:create",
25+
"automations:read",
26+
"automations:update",
27+
"automations:execute",
28+
)
29+
_VIEWER_PERMISSIONS = ("automations:read",)
30+
31+
32+
def upgrade():
33+
connection = op.get_bind()
34+
35+
for permission in _EDITOR_PERMISSIONS:
36+
connection.execute(
37+
text(
38+
"""
39+
UPDATE search_space_roles
40+
SET permissions = array_append(permissions, :permission)
41+
WHERE name = 'Editor'
42+
AND NOT (:permission = ANY(permissions))
43+
"""
44+
),
45+
{"permission": permission},
46+
)
47+
48+
for permission in _VIEWER_PERMISSIONS:
49+
connection.execute(
50+
text(
51+
"""
52+
UPDATE search_space_roles
53+
SET permissions = array_append(permissions, :permission)
54+
WHERE name = 'Viewer'
55+
AND NOT (:permission = ANY(permissions))
56+
"""
57+
),
58+
{"permission": permission},
59+
)
60+
61+
62+
def downgrade():
63+
connection = op.get_bind()
64+
65+
for permission in _EDITOR_PERMISSIONS:
66+
connection.execute(
67+
text(
68+
"""
69+
UPDATE search_space_roles
70+
SET permissions = array_remove(permissions, :permission)
71+
WHERE name = 'Editor'
72+
"""
73+
),
74+
{"permission": permission},
75+
)
76+
77+
for permission in _VIEWER_PERMISSIONS:
78+
connection.execute(
79+
text(
80+
"""
81+
UPDATE search_space_roles
82+
SET permissions = array_remove(permissions, :permission)
83+
WHERE name = 'Viewer'
84+
"""
85+
),
86+
{"permission": permission},
87+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""``create_automation`` — description + few-shot examples."""
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
- `create_automation` — Draft and author a new automation. You describe the
2+
user's intent; a focused drafter inside the tool turns it into the full
3+
automation JSON; the user sees a preview on an approval card and chooses
4+
approve or reject. All three phases happen in a single tool call.
5+
- Call when the user wants SurfSense to do something on its own: anything
6+
recurring or scheduled ("every morning…", "each Monday…", "weekly
7+
recap…").
8+
- Args:
9+
- `intent` (string): restate the user's request **concretely**, in one
10+
paragraph. Cover three things:
11+
- **What** should run (the action: summarize, recap, post, draft, …).
12+
- **When** it should run (schedule + timezone if the user mentioned one;
13+
otherwise leave the timezone for the drafter to default to UTC).
14+
- **Static values** the automation needs (folder ids, channel names,
15+
project keys, parent page ids, …) — list them with their values.
16+
If the user did NOT supply one the automation needs, say so
17+
explicitly ("the Notion parent page id was not specified") so the
18+
drafter leaves a placeholder.
19+
- Do NOT prompt the user to confirm before calling — the approval card
20+
IS the confirmation. The card shows a structured preview plus the raw
21+
JSON; it offers approve/reject only. If the user wants changes after
22+
seeing the draft, they reply in chat and you call this tool again with
23+
a refined `intent` — that's the edit path.
24+
- Returns:
25+
- `{status: "saved", automation_id, name}` — confirm briefly to the
26+
user ("Saved as automation #N — runs <when>."). Don't dump JSON back.
27+
- `{status: "rejected", message}` — the user declined on the card.
28+
Acknowledge once ("Understood, I didn't create it.") and stop. Do
29+
NOT retry or pitch variants without a fresh user request.
30+
- `{status: "invalid", issues, raw?}` — drafting/validation failed
31+
before the card was shown. Read the issues, refine your `intent`
32+
with the missing details, call again.
33+
- `{status: "error", message}` — surface the message verbatim and
34+
offer to retry.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<example>
2+
user: "Every weekday at 9am, summarize new documents in folder 12 and post the summary to Slack channel #daily-digest."
3+
→ create_automation(intent="Every weekday at 09:00 UTC, summarize documents added to folder_id=12 since the last run, then post the summary to Slack channel '#daily-digest'. Static inputs: folder_id=12, slack_channel='#daily-digest'.")
4+
tool returns: {"status": "saved", "automation_id": 42, "name": "Daily folder 12 digest"}
5+
(Reply briefly: "Saved as automation #42 — runs weekdays at 9am UTC.")
6+
</example>
7+
8+
<example>
9+
user: "Once a week on Mondays at 7am Paris time, draft a Notion page recapping last week's Jira tickets in project CORE."
10+
→ create_automation(intent="Every Monday at 07:00 Europe/Paris, read last week's Jira issues in project CORE, then draft a Notion page recapping them. Static inputs: jira_project_key='CORE'. The user did NOT specify which Notion page the recap should sit under — leave notion_parent_page_id as a placeholder.")
11+
tool returns: {"status": "saved", "automation_id": 51, "name": "Weekly CORE Jira recap"}
12+
(Reply: "Saved as automation #51. I left the Notion parent page id as a placeholder — set it on the automation before next Monday.")
13+
</example>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""``create_automation`` — author + persist an automation via a HITL card."""
2+
3+
from __future__ import annotations
4+
5+
from .create import create_create_automation_tool
6+
7+
__all__ = ["create_create_automation_tool"]

0 commit comments

Comments
 (0)