feat(web): automations contracts, API client, atoms and hooks

Foundation for the v1 automations UI. Mirrors backend Pydantic schemas
into Zod and wires the data layer end-to-end so feature surfaces can
be built on top.

contracts/types/automation.types.ts:
- AutomationStatus, TriggerType, RunStatus enums.
- AutomationDefinition envelope (PlanStep, TriggerSpec, Execution,
  Metadata, Inputs).
- AutomationCreate/Update/Detail/Summary/List + listParams.
- TriggerCreate/Update/Detail.
- RunSummary/Detail/List + runListParams.

lib/apis/automations-api.service.ts:
- list/get/create/update/delete automations.
- add/update/remove triggers (sub-resource).
- list/get runs (read-only sub-resource).
- safeParse on every write, 204-safe deletes.

atoms/automations/:
- automationsListAtom (active search space, first page).
- 6 mutation atoms with toast + cache invalidation.

hooks/:
- use-automations.ts wraps the list atom.
- use-automation.ts: parameterized detail by id.
- use-automation-runs.ts: useAutomationRuns + useAutomationRun.

lib/query-client/cache-keys.ts: automations namespace (list, detail,
runs, run) keyed by (id, limit, offset) where relevant.

Smoke: zod round-trip OK on backend-shape payloads (Automation,
AutomationCreate, Trigger, Run); typecheck clean for new files;
biome clean.
This commit is contained in:
CREDO23 2026-05-28 00:55:57 +02:00
parent d48bb2033b
commit b18a5fdca9
8 changed files with 548 additions and 0 deletions

View file

@ -0,0 +1,127 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
AutomationCreateRequest,
AutomationUpdateRequest,
TriggerCreateRequest,
TriggerUpdateRequest,
} from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
// Cache invalidation strategy:
// - Automation writes invalidate the search-space list + the touched detail.
// - Trigger writes only invalidate the parent automation detail (triggers
// come back inline in AutomationDetail).
// We deliberately invalidate the whole "automations" prefix on the list side
// because list is keyed by (searchSpaceId, limit, offset) and we don't track
// the active pagination in this layer.
function invalidateList(searchSpaceId: number) {
queryClient.invalidateQueries({ queryKey: ["automations", "list", searchSpaceId] });
}
function invalidateDetail(automationId: number) {
queryClient.invalidateQueries({
queryKey: cacheKeys.automations.detail(automationId),
});
}
export const createAutomationMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: AutomationCreateRequest) => {
return automationsApiService.createAutomation(request);
},
onSuccess: (_, variables) => {
invalidateList(variables.search_space_id);
toast.success("Automation created");
},
onError: (error: Error) => {
console.error("Error creating automation:", error);
toast.error("Failed to create automation");
},
}));
export const updateAutomationMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (vars: { automationId: number; patch: AutomationUpdateRequest }) => {
return automationsApiService.updateAutomation(vars.automationId, vars.patch);
},
onSuccess: (automation, vars) => {
invalidateDetail(vars.automationId);
invalidateList(automation.search_space_id);
toast.success("Automation updated");
},
onError: (error: Error) => {
console.error("Error updating automation:", error);
toast.error("Failed to update automation");
},
}));
export const deleteAutomationMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (vars: { automationId: number; searchSpaceId: number }) => {
await automationsApiService.deleteAutomation(vars.automationId);
return vars;
},
onSuccess: (vars) => {
invalidateList(vars.searchSpaceId);
invalidateDetail(vars.automationId);
toast.success("Automation deleted");
},
onError: (error: Error) => {
console.error("Error deleting automation:", error);
toast.error("Failed to delete automation");
},
}));
export const addTriggerMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
return automationsApiService.addTrigger(vars.automationId, vars.payload);
},
onSuccess: (_, vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger added");
},
onError: (error: Error) => {
console.error("Error adding trigger:", error);
toast.error("Failed to add trigger");
},
}));
export const updateTriggerMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (vars: {
automationId: number;
triggerId: number;
patch: TriggerUpdateRequest;
}) => {
return automationsApiService.updateTrigger(vars.automationId, vars.triggerId, vars.patch);
},
onSuccess: (_, vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger updated");
},
onError: (error: Error) => {
console.error("Error updating trigger:", error);
toast.error("Failed to update trigger");
},
}));
export const removeTriggerMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (vars: { automationId: number; triggerId: number }) => {
await automationsApiService.removeTrigger(vars.automationId, vars.triggerId);
return vars;
},
onSuccess: (vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger removed");
},
onError: (error: Error) => {
console.error("Error removing trigger:", error);
toast.error("Failed to remove trigger");
},
}));

View file

@ -0,0 +1,31 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
// First page of the active search space's automations.
// Detail + paginated/parameterized reads live in hooks (see use-automation.ts,
// use-automation-runs.ts) so atoms stay tied to "current scope" and don't
// proliferate atom families for every (id, limit, offset) tuple.
const DEFAULT_LIMIT = 50;
const DEFAULT_OFFSET = 0;
export const automationsListAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.automations.list(Number(searchSpaceId ?? 0), DEFAULT_LIMIT, DEFAULT_OFFSET),
enabled: !!searchSpaceId,
staleTime: 60 * 1000,
queryFn: async () => {
if (!searchSpaceId) {
return { items: [], total: 0 };
}
return automationsApiService.listAutomations({
search_space_id: Number(searchSpaceId),
limit: DEFAULT_LIMIT,
offset: DEFAULT_OFFSET,
});
},
};
});

View file

@ -0,0 +1,193 @@
import { z } from "zod";
// =============================================================================
// Enums — mirror app/automations/persistence/enums/*
// =============================================================================
export const automationStatus = z.enum(["active", "paused", "archived"]);
export type AutomationStatus = z.infer<typeof automationStatus>;
export const triggerType = z.enum(["schedule", "manual"]);
export type TriggerType = z.infer<typeof triggerType>;
export const runStatus = z.enum([
"pending",
"running",
"succeeded",
"failed",
"cancelled",
"timed_out",
]);
export type RunStatus = z.infer<typeof runStatus>;
// =============================================================================
// Definition envelope — mirror app/automations/schemas/definition/*
// =============================================================================
export const planStep = z.object({
step_id: z.string().min(1),
action: z.string().min(1),
when: z.string().nullable().optional(),
params: z.record(z.string(), z.any()).default({}),
output_as: z.string().nullable().optional(),
max_retries: z.number().int().min(0).nullable().optional(),
timeout_seconds: z.number().int().positive().nullable().optional(),
});
export type PlanStep = z.infer<typeof planStep>;
export const definitionTriggerSpec = z.object({
type: z.string().min(1),
params: z.record(z.string(), z.any()).default({}),
});
export type DefinitionTriggerSpec = z.infer<typeof definitionTriggerSpec>;
export const execution = z.object({
timeout_seconds: z.number().int().positive().default(600),
max_retries: z.number().int().min(0).default(2),
retry_backoff: z.enum(["exponential", "linear", "none"]).default("exponential"),
concurrency: z.enum(["drop_if_running", "queue", "always"]).default("drop_if_running"),
on_failure: z.array(planStep).default([]),
});
export type Execution = z.infer<typeof execution>;
// Backend ``Metadata`` is ``extra="allow"`` — keep ``tags`` typed, accept arbitrary keys.
export const metadata = z.object({ tags: z.array(z.string()).default([]) }).catchall(z.any());
export type Metadata = z.infer<typeof metadata>;
// Backend ``Inputs`` serializes its ``schema_`` field as ``schema`` (alias).
export const inputs = z.object({
schema: z.record(z.string(), z.any()),
});
export type Inputs = z.infer<typeof inputs>;
export const automationDefinition = z.object({
schema_version: z.string().default("1.0"),
name: z.string().min(1).max(200),
goal: z.string().nullable().optional(),
inputs: inputs.nullable().optional(),
triggers: z.array(definitionTriggerSpec).default([]),
plan: z.array(planStep).min(1),
execution: execution.default(execution.parse({})),
metadata: metadata.default(metadata.parse({})),
});
export type AutomationDefinition = z.infer<typeof automationDefinition>;
// =============================================================================
// Triggers (sub-resource) — mirror app/automations/schemas/api/trigger.py
// =============================================================================
export const triggerCreateRequest = z.object({
type: triggerType,
params: z.record(z.string(), z.any()).default({}),
static_inputs: z.record(z.string(), z.any()).default({}),
enabled: z.boolean().default(true),
});
export type TriggerCreateRequest = z.infer<typeof triggerCreateRequest>;
export const triggerUpdateRequest = z.object({
enabled: z.boolean().nullable().optional(),
params: z.record(z.string(), z.any()).nullable().optional(),
static_inputs: z.record(z.string(), z.any()).nullable().optional(),
});
export type TriggerUpdateRequest = z.infer<typeof triggerUpdateRequest>;
export const trigger = z.object({
id: z.number(),
type: triggerType,
params: z.record(z.string(), z.any()),
static_inputs: z.record(z.string(), z.any()),
enabled: z.boolean(),
last_fired_at: z.string().nullable().optional(),
next_fire_at: z.string().nullable().optional(),
created_at: z.string(),
});
export type Trigger = z.infer<typeof trigger>;
// =============================================================================
// Automations — mirror app/automations/schemas/api/automation.py
// =============================================================================
export const automationCreateRequest = z.object({
search_space_id: z.number(),
name: z.string().min(1).max(200),
description: z.string().nullable().optional(),
definition: automationDefinition,
triggers: z.array(triggerCreateRequest).default([]),
});
export type AutomationCreateRequest = z.infer<typeof automationCreateRequest>;
export const automationUpdateRequest = z.object({
name: z.string().min(1).max(200).nullable().optional(),
description: z.string().nullable().optional(),
status: automationStatus.nullable().optional(),
definition: automationDefinition.nullable().optional(),
});
export type AutomationUpdateRequest = z.infer<typeof automationUpdateRequest>;
export const automationSummary = z.object({
id: z.number(),
search_space_id: z.number(),
name: z.string(),
description: z.string().nullable().optional(),
status: automationStatus,
version: z.number(),
created_at: z.string(),
updated_at: z.string(),
});
export type AutomationSummary = z.infer<typeof automationSummary>;
export const automation = automationSummary.extend({
definition: automationDefinition,
triggers: z.array(trigger).default([]),
});
export type Automation = z.infer<typeof automation>;
export const automationListResponse = z.object({
items: z.array(automationSummary),
total: z.number(),
});
export type AutomationListResponse = z.infer<typeof automationListResponse>;
export const automationListParams = z.object({
search_space_id: z.number(),
limit: z.number().int().min(1).max(200).default(50),
offset: z.number().int().min(0).default(0),
});
export type AutomationListParams = z.infer<typeof automationListParams>;
// =============================================================================
// Runs (sub-resource) — mirror app/automations/schemas/api/run.py
// =============================================================================
export const runSummary = z.object({
id: z.number(),
automation_id: z.number(),
trigger_id: z.number().nullable().optional(),
status: runStatus,
started_at: z.string().nullable().optional(),
finished_at: z.string().nullable().optional(),
created_at: z.string(),
});
export type RunSummary = z.infer<typeof runSummary>;
export const run = runSummary.extend({
definition_snapshot: z.record(z.string(), z.any()),
inputs: z.record(z.string(), z.any()),
step_results: z.array(z.record(z.string(), z.any())),
output: z.record(z.string(), z.any()).nullable().optional(),
artifacts: z.array(z.record(z.string(), z.any())),
error: z.record(z.string(), z.any()).nullable().optional(),
});
export type Run = z.infer<typeof run>;
export const runListResponse = z.object({
items: z.array(runSummary),
total: z.number(),
});
export type RunListResponse = z.infer<typeof runListResponse>;
export const runListParams = z.object({
limit: z.number().int().min(1).max(200).default(50),
offset: z.number().int().min(0).default(0),
});
export type RunListParams = z.infer<typeof runListParams>;

View file

@ -0,0 +1,42 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { Run, RunListResponse } from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
const DEFAULT_LIMIT = 50;
const DEFAULT_OFFSET = 0;
export interface UseAutomationRunsOptions {
limit?: number;
offset?: number;
enabled?: boolean;
}
/** Paginated run history for one automation. Newest-first per backend. */
export function useAutomationRuns(
automationId: number | undefined,
{ limit = DEFAULT_LIMIT, offset = DEFAULT_OFFSET, enabled = true }: UseAutomationRunsOptions = {}
) {
return useQuery<RunListResponse, Error>({
queryKey: cacheKeys.automations.runs(automationId ?? 0, limit, offset),
queryFn: () => automationsApiService.listRuns(automationId as number, { limit, offset }),
enabled: enabled && !!automationId,
staleTime: 30_000,
});
}
/** Single run with the full snapshot, step results, output and artifacts. */
export function useAutomationRun(
automationId: number | undefined,
runId: number | undefined,
options: { enabled?: boolean } = {}
) {
const { enabled = true } = options;
return useQuery<Run, Error>({
queryKey: cacheKeys.automations.run(automationId ?? 0, runId ?? 0),
queryFn: () => automationsApiService.getRun(automationId as number, runId as number),
enabled: enabled && !!automationId && !!runId,
staleTime: 30_000,
});
}

View file

@ -0,0 +1,19 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { Automation } from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
/**
* Fetch a single automation with its definition and triggers.
* Lives outside the jotai atom layer because it's keyed by id, not by the
* "current scope" the atom layer assumes.
*/
export function useAutomation(automationId: number | undefined) {
return useQuery<Automation, Error>({
queryKey: cacheKeys.automations.detail(automationId ?? 0),
queryFn: () => automationsApiService.getAutomation(automationId as number),
enabled: !!automationId,
staleTime: 60_000,
});
}

View file

@ -0,0 +1,24 @@
"use client";
import { useAtomValue } from "jotai";
import { automationsListAtom } from "@/atoms/automations/automations-query.atoms";
/**
* List automations in the active search space (first page).
* Pagination knobs live in detail/list hooks below; v1 surfaces only the
* first page since automation counts are expected to be small.
*/
export function useAutomations() {
const { data, isLoading, error, refetch } = useAutomationsRaw();
return {
automations: data?.items ?? [],
total: data?.total ?? 0,
loading: isLoading,
error,
refresh: refetch,
};
}
// Exposed for callers that prefer the raw react-query result shape.
export function useAutomationsRaw() {
return useAtomValue(automationsListAtom);
}

View 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();

View file

@ -126,4 +126,14 @@ export const cacheKeys = {
batchUnreadCounts: (searchSpaceId: number | null) => batchUnreadCounts: (searchSpaceId: number | null) =>
["notifications", "unread-counts-batch", searchSpaceId] as const, ["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,
},
}; };