mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
Merge pull request #1443 from CREDO23/feature-automations
[Feat] Automation V1 — Scheduled Agent Tasks, Created via Chat (HITL) or JSON
This commit is contained in:
commit
4dda02c06c
219 changed files with 13821 additions and 55 deletions
102
surfsense_web/lib/apis/automations-api.service.ts
Normal file
102
surfsense_web/lib/apis/automations-api.service.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import {
|
||||
type AutomationCreateRequest,
|
||||
type AutomationListParams,
|
||||
type AutomationUpdateRequest,
|
||||
automation,
|
||||
automationCreateRequest,
|
||||
automationListResponse,
|
||||
automationUpdateRequest,
|
||||
type RunListParams,
|
||||
run,
|
||||
runListResponse,
|
||||
type TriggerCreateRequest,
|
||||
type TriggerUpdateRequest,
|
||||
trigger,
|
||||
triggerCreateRequest,
|
||||
triggerUpdateRequest,
|
||||
} from "@/contracts/types/automation.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
const BASE = "/api/v1/automations";
|
||||
|
||||
function rejectIfInvalid<T>(
|
||||
parsed: { success: true; data: T } | { success: false; error: { issues: { message: string }[] } }
|
||||
): T {
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(
|
||||
`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
class AutomationsApiService {
|
||||
// ---- Automations ---------------------------------------------------------
|
||||
|
||||
listAutomations = async (params: AutomationListParams) => {
|
||||
const qs = new URLSearchParams({
|
||||
search_space_id: String(params.search_space_id),
|
||||
limit: String(params.limit),
|
||||
offset: String(params.offset),
|
||||
});
|
||||
return baseApiService.get(`${BASE}?${qs.toString()}`, automationListResponse);
|
||||
};
|
||||
|
||||
getAutomation = async (automationId: number) => {
|
||||
return baseApiService.get(`${BASE}/${automationId}`, automation);
|
||||
};
|
||||
|
||||
createAutomation = async (request: AutomationCreateRequest) => {
|
||||
const data = rejectIfInvalid(automationCreateRequest.safeParse(request));
|
||||
return baseApiService.post(BASE, automation, { body: data });
|
||||
};
|
||||
|
||||
updateAutomation = async (automationId: number, request: AutomationUpdateRequest) => {
|
||||
const data = rejectIfInvalid(automationUpdateRequest.safeParse(request));
|
||||
return baseApiService.patch(`${BASE}/${automationId}`, automation, { body: data });
|
||||
};
|
||||
|
||||
// Server returns 204; baseApiService now resolves to null and skips schema validation.
|
||||
deleteAutomation = async (automationId: number) => {
|
||||
return baseApiService.delete(`${BASE}/${automationId}`);
|
||||
};
|
||||
|
||||
// ---- Triggers (sub-resource) --------------------------------------------
|
||||
|
||||
addTrigger = async (automationId: number, request: TriggerCreateRequest) => {
|
||||
const data = rejectIfInvalid(triggerCreateRequest.safeParse(request));
|
||||
return baseApiService.post(`${BASE}/${automationId}/triggers`, trigger, { body: data });
|
||||
};
|
||||
|
||||
updateTrigger = async (
|
||||
automationId: number,
|
||||
triggerId: number,
|
||||
request: TriggerUpdateRequest
|
||||
) => {
|
||||
const data = rejectIfInvalid(triggerUpdateRequest.safeParse(request));
|
||||
return baseApiService.patch(`${BASE}/${automationId}/triggers/${triggerId}`, trigger, {
|
||||
body: data,
|
||||
});
|
||||
};
|
||||
|
||||
removeTrigger = async (automationId: number, triggerId: number) => {
|
||||
return baseApiService.delete(`${BASE}/${automationId}/triggers/${triggerId}`);
|
||||
};
|
||||
|
||||
// ---- Runs (sub-resource, read-only) -------------------------------------
|
||||
|
||||
listRuns = async (automationId: number, params: RunListParams) => {
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(params.limit),
|
||||
offset: String(params.offset),
|
||||
});
|
||||
return baseApiService.get(`${BASE}/${automationId}/runs?${qs.toString()}`, runListResponse);
|
||||
};
|
||||
|
||||
getRun = async (automationId: number, runId: number) => {
|
||||
return baseApiService.get(`${BASE}/${automationId}/runs/${runId}`, run);
|
||||
};
|
||||
}
|
||||
|
||||
export const automationsApiService = new AutomationsApiService();
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ZodType } from "zod";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import { getClientPlatform } from "../agent-filesystem";
|
||||
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
|
||||
import {
|
||||
|
|
@ -9,7 +10,7 @@ import {
|
|||
NetworkError,
|
||||
NotFoundError,
|
||||
} from "../error";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
enum ResponseType {
|
||||
JSON = "json",
|
||||
TEXT = "text",
|
||||
|
|
@ -122,8 +123,9 @@ class BaseApiService {
|
|||
if (contentType === "application/json" && typeof mergedOptions.body === "object") {
|
||||
fetchOptions.body = JSON.stringify(mergedOptions.body);
|
||||
} else {
|
||||
// Pass body as-is for other content types (e.g., form data, already stringified)
|
||||
fetchOptions.body = mergedOptions.body;
|
||||
// Pass body as-is for other content types (form data, already stringified).
|
||||
// Caller is responsible for passing a real BodyInit when Content-Type is not JSON.
|
||||
fetchOptions.body = mergedOptions.body as BodyInit;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,32 +212,39 @@ class BaseApiService {
|
|||
let data;
|
||||
const responseType = mergedOptions.responseType;
|
||||
|
||||
try {
|
||||
switch (responseType) {
|
||||
case ResponseType.JSON:
|
||||
data = await response.json();
|
||||
break;
|
||||
case ResponseType.TEXT:
|
||||
data = await response.text();
|
||||
break;
|
||||
case ResponseType.BLOB:
|
||||
data = await response.blob();
|
||||
break;
|
||||
case ResponseType.ARRAY_BUFFER:
|
||||
data = await response.arrayBuffer();
|
||||
break;
|
||||
// Add more cases as needed
|
||||
default:
|
||||
data = await response.json();
|
||||
if (response.status === 204) {
|
||||
// 204 No Content has no body; .json() would throw SyntaxError.
|
||||
// Leave data as null and skip schema validation below so endpoints
|
||||
// that opt out of bodies (REST-style DELETE) don't error on success.
|
||||
data = null;
|
||||
} else {
|
||||
try {
|
||||
switch (responseType) {
|
||||
case ResponseType.JSON:
|
||||
data = await response.json();
|
||||
break;
|
||||
case ResponseType.TEXT:
|
||||
data = await response.text();
|
||||
break;
|
||||
case ResponseType.BLOB:
|
||||
data = await response.blob();
|
||||
break;
|
||||
case ResponseType.ARRAY_BUFFER:
|
||||
data = await response.arrayBuffer();
|
||||
break;
|
||||
// Add more cases as needed
|
||||
default:
|
||||
data = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse response as JSON:", error);
|
||||
throw new AppError("Failed to parse response", response.status, response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse response as JSON:", error);
|
||||
throw new AppError("Failed to parse response", response.status, response.statusText);
|
||||
}
|
||||
|
||||
// Validate response
|
||||
if (responseType === ResponseType.JSON) {
|
||||
if (!responseSchema) {
|
||||
if (!responseSchema || response.status === 204) {
|
||||
return data;
|
||||
}
|
||||
const parsedData = responseSchema.safeParse(data);
|
||||
|
|
|
|||
44
surfsense_web/lib/automations/default-template.ts
Normal file
44
surfsense_web/lib/automations/default-template.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Minimal valid ``AutomationCreate`` skeleton used to seed the raw-JSON
|
||||
* create form. ``search_space_id`` is omitted on purpose — the form
|
||||
* injects it from the route so users never have to know their id.
|
||||
*
|
||||
* The shape matches the Pydantic ``AutomationCreate`` model less the
|
||||
* search_space_id field; Zod validates the merged payload before submit.
|
||||
*/
|
||||
export const DEFAULT_AUTOMATION_TEMPLATE = {
|
||||
name: "My automation",
|
||||
description: null,
|
||||
definition: {
|
||||
name: "My automation",
|
||||
goal: null,
|
||||
plan: [
|
||||
{
|
||||
step_id: "step_1",
|
||||
action: "agent_task",
|
||||
params: {
|
||||
query: "Summarize new docs added to folder 12 since the last run.",
|
||||
},
|
||||
},
|
||||
],
|
||||
execution: {
|
||||
timeout_seconds: 600,
|
||||
max_retries: 2,
|
||||
retry_backoff: "exponential",
|
||||
concurrency: "drop_if_running",
|
||||
on_failure: [],
|
||||
},
|
||||
metadata: { tags: [] },
|
||||
},
|
||||
triggers: [
|
||||
{
|
||||
type: "schedule",
|
||||
params: {
|
||||
cron: "0 9 * * 1-5",
|
||||
timezone: "UTC",
|
||||
},
|
||||
static_inputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
67
surfsense_web/lib/automations/describe-cron.ts
Normal file
67
surfsense_web/lib/automations/describe-cron.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Minimal cron describer for the 5-field patterns the SurfSense drafter LLM
|
||||
* actually produces (daily, weekdays, weekly, monthly, hourly). Falls back
|
||||
* to the raw expression when unrecognized so the user still sees something
|
||||
* honest instead of a guess.
|
||||
*
|
||||
* Lives under ``lib/automations/`` because both the dashboard slice and the
|
||||
* chat ``create_automation`` approval card render schedule descriptions —
|
||||
* keeping the helper outside either feature avoids a layering violation.
|
||||
*/
|
||||
|
||||
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
export function describeCron(cron: string): string {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return cron;
|
||||
|
||||
const [minute, hour, dom, month, dow] = parts;
|
||||
|
||||
// Daily at H:MM ("0 9 * * *")
|
||||
if (month === "*" && dom === "*" && dow === "*" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
|
||||
return `Daily at ${formatTime(hour, minute)}`;
|
||||
}
|
||||
|
||||
// Weekdays at H:MM ("0 9 * * 1-5")
|
||||
if (month === "*" && dom === "*" && dow === "1-5" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
|
||||
return `Mon–Fri at ${formatTime(hour, minute)}`;
|
||||
}
|
||||
|
||||
// Specific weekday(s) ("0 9 * * 1" or "0 9 * * 1,3,5")
|
||||
if (
|
||||
month === "*" &&
|
||||
dom === "*" &&
|
||||
/^\d+$/.test(minute) &&
|
||||
/^\d+$/.test(hour) &&
|
||||
/^[\d,]+$/.test(dow)
|
||||
) {
|
||||
const days = dow
|
||||
.split(",")
|
||||
.map((d) => DAY_NAMES[Number(d) % 7])
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
if (days) return `${days} at ${formatTime(hour, minute)}`;
|
||||
}
|
||||
|
||||
// Monthly on day N ("0 9 1 * *")
|
||||
if (
|
||||
month === "*" &&
|
||||
dow === "*" &&
|
||||
/^\d+$/.test(dom) &&
|
||||
/^\d+$/.test(hour) &&
|
||||
/^\d+$/.test(minute)
|
||||
) {
|
||||
return `Day ${dom} of each month at ${formatTime(hour, minute)}`;
|
||||
}
|
||||
|
||||
// Hourly ("0 * * * *")
|
||||
if (month === "*" && dom === "*" && dow === "*" && hour === "*" && /^\d+$/.test(minute)) {
|
||||
return minute === "0" ? "Every hour" : `Every hour at :${minute.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
return cron;
|
||||
}
|
||||
|
||||
function formatTime(hour: string, minute: string): string {
|
||||
return `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
|
||||
}
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
|
||||
import {
|
||||
differenceInDays,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isThisYear,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
|
||||
/**
|
||||
* Format a date string as a human-readable relative time
|
||||
|
|
@ -23,6 +31,36 @@ export function formatRelativeDate(dateString: string): string {
|
|||
return format(date, "MMM d, yyyy");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a future date string as a human-readable countdown.
|
||||
* - < 1 min: "Any moment"
|
||||
* - < 60 min: "in 15m"
|
||||
* - Today: "Today, 2:30 PM"
|
||||
* - Tomorrow: "Tomorrow, 2:30 PM"
|
||||
* - < 7 days: "in 3d"
|
||||
* - This year: "May 30, 2:30 PM"
|
||||
* - Older: "Jan 15, 2027"
|
||||
*
|
||||
* Mirrors {@link formatRelativeDate} but for moments strictly after now.
|
||||
* Falls back to the past-relative formatter if the timestamp is not in
|
||||
* the future (defensive — guards against stale "next_fire_at" values).
|
||||
*/
|
||||
export function formatRelativeFutureDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const minutesAhead = differenceInMinutes(date, now);
|
||||
const daysAhead = differenceInDays(date, now);
|
||||
|
||||
if (minutesAhead <= 0) return formatRelativeDate(dateString);
|
||||
if (minutesAhead < 1) return "Any moment";
|
||||
if (minutesAhead < 60) return `in ${minutesAhead}m`;
|
||||
if (isToday(date)) return `Today, ${format(date, "h:mm a")}`;
|
||||
if (isTomorrow(date)) return `Tomorrow, ${format(date, "h:mm a")}`;
|
||||
if (daysAhead < 7) return `in ${daysAhead}d`;
|
||||
if (isThisYear(date)) return format(date, "MMM d, h:mm a");
|
||||
return format(date, "MMM d, yyyy");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a thread's last-updated timestamp for the chats sidebars.
|
||||
* Example: "Mar 23, 2026 at 4:30 PM"
|
||||
|
|
|
|||
|
|
@ -126,4 +126,14 @@ export const cacheKeys = {
|
|||
batchUnreadCounts: (searchSpaceId: number | null) =>
|
||||
["notifications", "unread-counts-batch", searchSpaceId] as const,
|
||||
},
|
||||
automations: {
|
||||
// list endpoint is keyed by pagination too so distinct pages don't collide
|
||||
list: (searchSpaceId: number, limit: number, offset: number) =>
|
||||
["automations", "list", searchSpaceId, limit, offset] as const,
|
||||
detail: (automationId: number) => ["automations", "detail", automationId] as const,
|
||||
runs: (automationId: number, limit: number, offset: number) =>
|
||||
["automations", "runs", automationId, limit, offset] as const,
|
||||
run: (automationId: number, runId: number) =>
|
||||
["automations", "runs", automationId, runId] as const,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue