8000 feat(automations): an automation's runs, what's coming up, its variables and costs, in tabs by adeelahmad · Pull Request #600 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ listed under [In progress](#in-progress), and what comes next under [Planned](#p
Lens, with a yes in the chat it told you in; agents can decline an approval but never give one. Each automation has
limits and budgets, and agents make, try and run them over MCP and in the assistant, or draft one from a sentence. An
automation's workflow is drawn and tried on the workflow canvas, with its actions in the palette, and the studio
lists your automations and builds one in When, If and Then, tested on a recent activity before you save it.
lists your automations, builds one in When, If and Then (tested on a recent activity before you save it), and shows
its runs, what's coming up, its variables and its costs.
([Processing](docs/processing.md#workflows-and-the-canvas),
[converting assets](docs/processing.md#converting-assets), [notifications](docs/notifications.md),
[automations](docs/automations.md), [approvals](docs/automations.md#approvals), [schedules](docs/schedules.md),
Expand Down Expand Up @@ -275,8 +276,6 @@ These are open pull requests, not yet part of Lens:

Next, and not in a pull request yet; each links to where it is tracked:

- **The automations studio:** each automation's runs, what's coming up, its variables and its costs, in tabs
([automations](docs/automations.md)).
- **Timeline:** what each item caused, and Approve and Decline on waiting approvals ([timeline](docs/timeline.md)).
- **Notes:** nesting a note under its project or area without a click, pages in the graph, and wiki addresses for
topics, recordings and the other things that have pages ([notes](docs/notes.md)).
Expand Down
18 changes: 15 additions & 3 deletions docs/automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ merged is called built.
| Automations for agents: the MCP tools and the assistant's, switching on and running now by approval | built |
| Describing an automation in words: a draft from a sentence (`POST /api/v1/automations/draft`) | built |
| The studio: the list at `/automations` and the When, If and Then builder, with a test panel ([The studio](#the-studio)) | built |
| The studio's tabs for an automation's runs, what's coming up, its variables and its costs | planned |
| An automation's page: its runs, what's coming up, its variables and its costs, in tabs ([An automation's page](#an-automations-page)) | built |

## The studio

Expand Down Expand Up @@ -86,6 +86,19 @@ row or action they are about.
An automation whose workflow branches (a Decide node, or an action after a condition) can't be read as a list:
the builder says so and opens it in the [canvas](#on-the-canvas), where When and If still edit.

### An automation's page

An automation's page has its switch, Run now and five tabs (`?tab=` in the address opens one):

- **Edit**: the builder.
- **Runs**: newest first, 20 at a time with Older runs for the next page. Each reads how it went, what started it,
what its actions did and what it cost; opening one shows each action's outcome and the run's log. A run held over a
budget offers Run it once or Skip. Run now opens this tab.
- **Coming up**: its next schedule times, `at` times and actions that wait, soonest first.
- **Variables**: what it keeps between runs ([Saved variables](#saved-variables)). Edit a value as text or JSON and
press Enter; Clear is undone from its toast; a new name and value are added with Enter.
- **Costs**: its budget and its activity: the calls it made and what they cost.

## Contract

Every part of the studio builds on the shapes below. A change to one of them comes in a small pull request of its
Expand Down Expand Up @@ -1305,8 +1318,7 @@ held run until you pick.
message the person's template wrote, so they stay when the run read outside text.
- Variables and templates: secrets as variables (sealed, never rendered into prompts); typed variable declarations
with defaults per automation; a time zone of each person's own, rather than the triggers'; wrapping outside text
the same way in `tool_nodes.ask_model` (pipeline workflows) and extensions' prompt tools; variables in the
Settings page of an automation (the studio, M11); an activity when a person changes a variable; marking a variable
the same way in `tool_nodes.ask_model` (pipeline workflows) and extensions' prompt tools; an activity when a person changes a variable; marking a variable
from outside text only when the value itself came from it, not whenever the run read some; removing an
automation's variables when it is deleted (M6); a preview that runs the nodes before the template's (dry) rather
than standing in for their outputs; the picker listing a saved prompt template's output schema for the node that
Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ request, to another check that proves the same thing; loosening a requirement or
| Agents make, try and run automations over MCP and in the assistant, or draft one from a sentence | [test_automation_tools.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_automation_tools.py) `test_agents_save_try_and_run_automations_over_mcp`, `test_the_assistant_saves_at_once_and_asks_to_switch_on_or_run`; [test_automations.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_automations.py) `test_an_automation_is_drafted_from_a_sentence` |
| An automation's workflow is drawn and tried on the canvas: its actions in the palette, their settings drawn from each action's params, Decide's ports and the leaf rule | [workflow-canvas.test.ts](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/workflow-canvas.test.ts) `give a decide node a port per option, then unsure`, `keep the leaf rule: nothing after an action that reaches outside Lens or waits`, `say what the backend would reject in their settings`; [automation-nodes-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/automation-nodes-ui.test.tsx) `sets an action up from the catalog, its params drawn from its schema`; [schema-form.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/schema-form.test.tsx) `opens the variable picker on {{ and puts the pick in with Enter` |
| In the web app, automations are listed with what sets each off in words, and built in When, If and Then with a test on a recent activity, problems next to their fields | [automations-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/automations-ui.test.tsx) `the list reads each automation, and Delete is undone from its toast`, `the builder adds a trigger with a condition and an action, then saves it switched off`, `the test panel runs the draft on a recent activity and says what it would do`, `API problems go next to what they are about`; [test_automations.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_automations.py) `test_lists_read_the_triggers_in_words_and_say_when_it_runs_next` |
| An automation's page shows its runs a page at a time with each one's log, what's coming up, its variables to edit and its costs | [automation-detail.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/automation-detail.test.tsx) `Runs pages back with before, and a run opens to what it did and its log`, `a run held over a budget can run once or be skipped`, `Coming up lists its times, and says why when there are none`, `Variables set from the keyboard, and Clear is undone from its toast` |

## See what it costs

Expand Down
169 changes: 169 additions & 0 deletions nextjs-frontend/__tests__/automation-detail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import type { ReactNode } from "react";

import { Automations } from "@/app/openapi-client";
import { AutomationPage } from "@/components/automations/automation-page";
import { RunsTab, UpcomingTab, VariablesTab, runCounts, varFrom, varText } from "@/components/automations/detail-tabs";
import { ToastProvider } from "@/components/ui/toast";
import { TooltipProvider } from "@/components/ui/tooltip";

jest.mock("@/app/openapi-client", () => ({
Automations: {
listActions: jest.fn(),
listRuns: jest.fn(),
getRun: jest.fn(),
decideHeldRun: jest.fn(),
upcoming: jest.fn(),
getAutomationVars: jest.fn(),
putAutomationVars: jest.fn(),
getAutomation: jest.fn(),
runAutomation: jest.fn(),
enableAutomation: jest.fn(),
},
}));
jest.mock("next/navigation", () => ({ useRouter: () => ({ push: jest.fn() }) }));
jest.mock("next-auth/react", () => ({ useSession: () => ({ data: { accessToken: "t" } }) }));

const ok = (data: unknown) => Promise.resolve({ data, response: { ok: true, status: 200 } });
const m = (f: unknown) => f as jest.Mock;

function wrap(ui: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<TooltipProvider>
<ToastProvider>{ui}</ToastProvider>
</TooltipProvider>
</QueryClientProvider>,
);
}

const run = (id: number, more: Record<string, unknown> = {}) => ({
id,
automation: 7,
status: "done",
trigger: "email",
started_at: "2026-10-18T06:00:00Z",
counts: { done: 1 },
...more,
});

beforeEach(() => {
m(Automations.listActions).mockReturnValue(
ok([{ name: "notify", label: "Send a message", group: "Messages", params: {}, effect: "depends" }]),
);
});

test("a run reads what it did in a few words, and a variable edits as one line of JSON", () => {
expect(runCounts({ counts: { done: 3, waiting: 1, failed: 0 } })).toBe("3 done, 1 waiting");
expect(runCounts({ counts: {}, why: "over its budget" })).toBe("over its budget");
expect(runCounts({ counts: { done: 1 }, error: "boom" })).toBe("boom");
expect(varText({ seen: 2 })).toBe('{"seen":2}');
expect(varText("plain")).toBe("plain");
expect(varFrom("[1, 2]")).toEqual([1, 2]);
expect(varFrom("not json")).toBe("not json");
});

test("Runs pages back with before, and a run opens to what it did and its log", async () => {
m(Automations.listRuns)
.mockReturnValueOnce(
ok({ runs: [run(9), run(8, { status: "failed", error: "The address didn’t answer" })], next: 8 }),
)
.mockReturnValueOnce(ok({ runs: [run(5)], next: null }));
m(Automations.getRun).mockReturnValue(
ok({
...run(9),
log: ["notify: sent"],
intents: [{ action: "notify", status: "done" }],
trace: {},
}),
);
wrap(<RunsTab aid={7} />);
const list = await screen.findByRole("list", { name: "Runs" });
expect(within(list).getByText("The address didn’t answer")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Older runs" }));
await waitFor(() => expect(within(list).getAllByRole("listitem")).toHaveLength(3));
expect(m(Automations.listRuns).mock.calls[1][0].query).toEqual({ before: 8, limit: 20 });
expect(screen.queryByRole("button", { name: "Older runs" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Run 9" }));
expect(await screen.findByText("notify: sent")).toBeInTheDocument();
expect(
within(screen.getByRole("list", { name: "What it did" })).getByText("Send a message: done"),
).toBeInTheDocument();
});

test("a run held over a budget can run once or be skipped", async () => {
m(Automations.listRuns).mockReturnValue(ok({ runs: [run(4, { status: "held", counts: {} })], next: null }));
m(Automations.decideHeldRun).mockReturnValue(ok({ ok: true }));
wrap(<RunsTab aid={7} />);
fireEvent.click(await screen.findByRole("button", { name: "Run it once" }));
await waitFor(() =>
expect(m(Automations.decideHeldRun).mock.calls[0][0]).toMatchObject({ path: { run: 4 }, body: { run: true } }),
);
});

test("Coming up lists its times, and says why when there are none", async () => {
m(Automations.upcoming).mockReturnValueOnce(
ok({
times: [
{ key: "sched-7-daily", at: "2026-10-19T07:00:00Z", description: "every day at 07:00", trigger: "daily" },
],
}),
);
const { unmount } = wrap(<UpcomingTab aid={7} enabled />);
expect(await screen.findByText("every day at 07:00")).toBeInTheDocument();
unmount();
m(Automations.upcoming).mockReturnValueOnce(ok({ times: [] }));
wrap(<UpcomingTab aid={7} enabled={false} />);
expect(await screen.findByText(/It’s off/)).toBeInTheDocument();
});

test("Variables set from the keyboard, and Clear is undone from its toast", async () => {
const vars = { vars: [{ name: "seen", value: 2, updated_at: "2026-10-18T06:00:00Z" }] };
m(Automations.getAutomationVars).mockReturnValue(ok(vars));
m(Automations.putAutomationVars).mockReturnValue(ok(vars));
wrap(<VariablesTab aid={7} />);
const box = await screen.findByLabelText("Value of seen");
fireEvent.change(box, { target: { value: "3" } });
fireEvent.keyDown(box, { key: "Enter" });
await waitFor(() =>
expect(m(Automations.putAutomationVars).mock.calls[0][0]).toMatchObject({
path: { aid: 7 },
body: { vars: { seen: 3 } },
}),
);
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
await waitFor(() => expect(m(Automations.putAutomationVars).mock.calls[1][0].body).toEqual({ vars: { seen: null } }));
fireEvent.click(await screen.findByRole("button", { name: "Undo" }));
await waitFor(() => expect(m(Automations.putAutomationVars).mock.calls[2][0].body).toEqual({ vars: { seen: 2 } }));
fireEvent.change(screen.getByLabelText("New variable name"), { target: { value: "trips" } });
fireEvent.change(screen.getByLabelText("New variable value"), { target: { value: '["Rome"]' } });
fireEvent.keyDown(screen.getByLabelText("New variable value"), { key: "Enter" });
await waitFor(() =>
expect(m(Automations.putAutomationVars).mock.calls[3][0].body).toEqual({ vars: { trips: ["Rome"] } }),
);
});

test("the page opens the tab its address names, and Run now shows the runs", async () => {
m(Automations.getAutomation).mockReturnValue(
ok({
id: 7,
name: "School emails",
enabled: true,
triggers: [],
join: { mode: "any" },
graph: { nodes: [], edges: [] },
}),
);
m(Automations.upcoming).mockReturnValue(ok({ times: [] }));
m(Automations.listRuns).mockReturnValue(ok({ runs: [run(9)], next: null }));
m(Automations.runAutomation).mockReturnValue(ok({ run: 10, status: "done" }));
wrap(<AutomationPage id={7} tab="upcoming" />);
expect(await screen.findByRole("tab", { name: "Coming up" })).toHaveAttribute("aria-selected", "true");
expect(await screen.findByText("Nothing coming up")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Run now" }));
expect(await screen.findByRole("list", { name: "Runs" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Runs" })).toHaveAttribute("aria-selected", "true");
});
13 changes: 10 additions & 3 deletions nextjs-frontend/app/(app)/automations/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@ import { AutomationPage } from "@/components/automations/automation-page";

export const metadata: Metadata = { title: "Automation" };

/** One automation: the builder, and what it has done. */
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
/** One automation: the builder, and what it has done (`?tab=runs|upcoming|variables|costs` opens a tab). */
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ tab?: string }>;
}) {
const { id } = await params;
const { tab } = await searchParams;
const n = Number(id);
if (!Number.isInteger(n) || n < 1) notFound();
return <AutomationPage id={n} />;
return <AutomationPage id={n} tab={tab} />;
}
42 changes: 36 additions & 6 deletions nextjs-frontend/components/automations/automation-page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

/** One automation: its name, on or off, Run now, and the builder; a new one starts from the builder alone. */
/** One automation: its name, on or off, Run now, and tabs for the builder, its runs, what's coming up, its variables
* and its costs; a new one starts from the builder alone. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, Zap } from "lucide-react";
import Link from "next/link";
Expand All @@ -9,10 +10,12 @@ import { useEffect, useState } from "react";

import { Automations } from "@/app/openapi-client";
import { AutomationBuilder, type Hook } from "@/components/automations/automation-builder";
import { CostsTab, RunsTab, UpcomingTab, VariablesTab } from "@/components/automations/detail-tabs";
import { runStatus } from "@/components/automations/model";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/field";
import { EmptyState, Skeleton } from "@/components/ui/states";
import { Tabs } from "@/components/ui/tabs";
import { useToast } from "@/components/ui/toast";
import { ApiError, data, useApiClient } from "@/lib/api/browser";

Expand Down Expand Up @@ -51,7 +54,11 @@ export function NewAutomationPage() {
);
}

export function AutomationPage({ id }: { id: number }) {
export const TABS = ["edit", "runs", "upcoming", "variables", "costs"] as const;
export type Tab = (typeof TABS)[number];

export function AutomationPage({ id, tab: first }: { id: number; tab?: string }) {
const [tab, setTab] = useState<Tab>(TABS.includes(first as Tab) ? (first as Tab) : "edit");
const client = useApiClient();
const qc = useQueryClient();
const toast = useToast();
Expand Down Expand Up @@ -84,6 +91,7 @@ export function AutomationPage({ id }: { id: number }) {
mutationFn: () => data(Automations.runAutomation({ client, path: { aid: id }, body: {} })),
onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: ["automation-runs", id] });
setTab("runs");
toast(
r.run
? { tone: "green", title: "It ran", body: runStatus(r.status).label }
Expand Down Expand Up @@ -143,11 +151,33 @@ export function AutomationPage({ id }: { id: number }) {
))}
</div>
)}
<AutomationBuilder
key={`${a.id}-${a.updated_at}`}
automation={a}
got) => got.length && setHooks(got)}
<Tabs
aria-label="Automation"
value={tab}
=> setTab(v as Tab)}
items={[
{ value: "edit", label: "Edit" },
{ value: "runs", label: "Runs" },
{ value: "upcoming", label: "Coming up" },
{ value: "variables", label: "Variables" },
{ value: "costs", label: "Costs" },
]}
/>
{tab === "runs" ? (
<RunsTab aid={a.id} />
) : tab === "upcoming" ? (
<UpcomingTab aid={a.id} enabled={a.enabled} />
) : tab === "variables" ? (
<VariablesTab aid={a.id} />
) : tab === "costs" ? (
<CostsTab aid={a.id} />
) : (
<AutomationBuilder
key={`${a.id}-${a.updated_at}`}
automation={a}
got) => got.length && setHooks(got)}
/>
)}
</div>
);
}
Loading
Loading
0