Skip to content
Closed
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
23 changes: 23 additions & 0 deletions docs/specs/2026-07-16-workflows-overhaul-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,26 @@ In chat: "make a workflow that runs every morning at 8, checks <something> with
- Version-history UI and run-detail graph overlay on the workflow pages (chat gets the graph first; the run page keeps its checkpoint list).
- Workflow marketplace/templates, import/export.
- Editing workflows from Telegram/CLI (they get gate approve/deny like everything else; authoring UX is chat + editor).

## 2026-08-10 addendum: triggers + team-owner surface in the web UI

The editor page gains a Triggers drawer (`packages/web/src/components/
workflows/triggers-drawer.tsx`) — the first UI over two trigger APIs that
were HTTP/agent-only before:

- **Webhook:** mint, copy, rotate, and delete the arbitrary-URL trigger
(decision 5). Rotate and delete confirm before acting because the URL
carries the bearer secret.
- **Schedules:** new routes `GET/POST /api/workflows/:id/schedules` and
`DELETE /api/workflows/:id/schedules/:scheduleId` over the existing
`schedule-service.ts`. The routes resolve the workflow through
`getWorkflowDefinition` first (own-rows 404 convention), and a delete
requires the schedule to belong to that workflow. The service also
carries orchestrator-prompt schedules; this surface manages only the
workflow-scoped kind.

The New-workflow dialog gains an owner picker (personal, or a team the
caller belongs to — `CreateWorkflowRequest.teamId` existed on the wire but
had no UI), and the workflows list badges team-owned rows with the team
name. `TeamSummary` gains `callerRole` so the teams settings panel can
hide mutation controls the API's `canMutateTeam` gate would 404 anyway.
18 changes: 14 additions & 4 deletions packages/api/src/routes/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,19 @@ export const teamsRouter = new Hono<AppEnv>();
async function rowToSummary(
db: AppEnv["Variables"]["providers"]["db"],
row: TeamRow,
callerUserId: string,
): Promise<TeamSummary> {
const memberCount = (await listTeamMembers(db, row.id)).length;
return { id: row.id, orgId: row.orgId, name: row.name, createdAt: row.createdAt, memberCount };
const members = await listTeamMembers(db, row.id);
const mine = members.find((m) => m.userId === callerUserId);
return {
id: row.id,
orgId: row.orgId,
name: row.name,
createdAt: row.createdAt,
memberCount: members.length,
// null = the caller is not on this team (they see it as an org admin).
callerRole: mine?.role ?? null,
};
}

function isTeamRole(v: unknown): v is TeamRole {
Expand Down Expand Up @@ -145,7 +155,7 @@ teamsRouter.get("/", async (c) => {
: (await listTeamsForUser(db, user.id)).filter((r) => r.orgId === user.orgId);

const body: ListTeamsResponse = {
teams: await Promise.all(rows.map((r) => rowToSummary(db, r))),
teams: await Promise.all(rows.map((r) => rowToSummary(db, r, user.id))),
};
return c.json(body);
});
Expand Down Expand Up @@ -184,7 +194,7 @@ teamsRouter.post("/", async (c) => {

try {
const team = await createTeam(db, { orgId: user.orgId, name: body.name, creatorUserId: user.id });
const resp: CreateTeamResponse = { team: await rowToSummary(db, team) };
const resp: CreateTeamResponse = { team: await rowToSummary(db, team, user.id) };
return c.json(resp, 201);
} catch (err) {
const mapped = handleServiceError(err);
Expand Down
91 changes: 91 additions & 0 deletions packages/api/src/routes/workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { bootTestApi, type TestApi } from "../integration/_setup.js";
import { addMember, createTeam } from "../services/teams.js";
import type {
CreateWorkflowResponse,
CreateWorkflowScheduleResponse,
ListWorkflowSchedulesResponse,
DeleteWorkflowWebhookResponse,
GetWorkflowRunResponse,
ListWorkflowRunsResponse,
Expand Down Expand Up @@ -553,3 +555,92 @@ describe("POST/GET/DELETE /api/workflows/:id/webhook", () => {
expect(body.deleted).toBe(false);
});
});

describe("GET/POST/DELETE /api/workflows/:id/schedules", () => {
it("creates a schedule and lists it for that workflow only", async () => {
api = await bootTestApi();
const a = await createWorkflow(api.baseUrl, "with-schedule");
const b = await createWorkflow(api.baseUrl, "without-schedule");

const created = await fetch(`${api.baseUrl}/api/workflows/${a.id}/schedules`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "nightly", cron: "0 9 * * *" }),
});
expect(created.status).toBe(201);
const schedule = (await created.json()) as CreateWorkflowScheduleResponse;
expect(schedule.workflowId).toBe(a.id);
expect(schedule.enabled).toBe(true);
expect(schedule.timezone).toBe("UTC");
expect(schedule.nextFireAt).toBeGreaterThan(Date.now());

const listA = await fetch(`${api.baseUrl}/api/workflows/${a.id}/schedules`);
const bodyA = (await listA.json()) as ListWorkflowSchedulesResponse;
expect(bodyA.schedules.map((s) => s.scheduleId)).toEqual([schedule.scheduleId]);

const listB = await fetch(`${api.baseUrl}/api/workflows/${b.id}/schedules`);
const bodyB = (await listB.json()) as ListWorkflowSchedulesResponse;
expect(bodyB.schedules).toEqual([]);
});

it("400s an invalid cron with the corrective error text", async () => {
api = await bootTestApi();
const created = await createWorkflow(api.baseUrl);
const res = await fetch(`${api.baseUrl}/api/workflows/${created.id}/schedules`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "bad", cron: "every day at nine" }),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("cron");
});

it("404s schedule routes on another owner's workflow", async () => {
api = await bootTestApi();
const created = await createWorkflow(api.baseUrl);
const asOther = { "x-valet-test-user-id": "test-member" };

const list = await fetch(`${api.baseUrl}/api/workflows/${created.id}/schedules`, {
headers: asOther,
});
expect(list.status).toBe(404);

const post = await fetch(`${api.baseUrl}/api/workflows/${created.id}/schedules`, {
method: "POST",
headers: { "Content-Type": "application/json", ...asOther },
body: JSON.stringify({ name: "sneaky", cron: "0 9 * * *" }),
});
expect(post.status).toBe(404);
});

it("DELETE removes only a schedule that belongs to that workflow", async () => {
api = await bootTestApi();
const a = await createWorkflow(api.baseUrl, "schedule-owner");
const b = await createWorkflow(api.baseUrl, "other-workflow");

const created = await fetch(`${api.baseUrl}/api/workflows/${a.id}/schedules`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "nightly", cron: "0 9 * * *" }),
});
const schedule = (await created.json()) as CreateWorkflowScheduleResponse;

// Through the WRONG workflow's path: 404, row survives.
const cross = await fetch(
`${api.baseUrl}/api/workflows/${b.id}/schedules/${schedule.scheduleId}`,
{ method: "DELETE" },
);
expect(cross.status).toBe(404);

const del = await fetch(
`${api.baseUrl}/api/workflows/${a.id}/schedules/${schedule.scheduleId}`,
{ method: "DELETE" },
);
expect(del.status).toBe(200);
const listA = await fetch(`${api.baseUrl}/api/workflows/${a.id}/schedules`);
const bodyA = (await listA.json()) as ListWorkflowSchedulesResponse;
expect(bodyA.schedules).toEqual([]);
});
});

92 changes: 92 additions & 0 deletions packages/api/src/routes/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,23 @@ import {
getWorkflowWebhook,
mintOrRotateWorkflowWebhook,
} from "../workflows/webhook-service.js";
import {
createWorkflowSchedule,
deleteWorkflowSchedule,
listWorkflowSchedules,
type WorkflowScheduleSummary,
} from "../workflows/schedule-service.js";
import { buildValidateEnvironment } from "../workflows/validation-env.js";
import type {
CancelWorkflowRunResponse,
CreateWorkflowRequest,
CreateWorkflowResponse,
CreateWorkflowScheduleRequest,
CreateWorkflowScheduleResponse,
DeleteWorkflowScheduleResponse,
DeleteWorkflowWebhookResponse,
ListWorkflowSchedulesResponse,
WorkflowScheduleWire,
GetWorkflowResponse,
GetWorkflowVersionResponse,
ListWorkflowRunsResponse,
Expand Down Expand Up @@ -241,6 +252,87 @@ workflowsRouter.delete("/:id/webhook", async (c) => {
return c.json(resp);
});

// ── Schedules (cron triggers) ─────────────────────────────────────────────
// Owner-scoped like the webhook routes above: every route resolves the
// workflow through `getWorkflowDefinition` first, so an unowned workflow
// 404s identically to a missing one. The schedule service also carries
// orchestrator-prompt schedules; this surface manages only the
// workflow-scoped kind, so every row it returns has a `workflowId`.

function toScheduleWire(s: WorkflowScheduleSummary, workflowId: string): WorkflowScheduleWire {
return {
scheduleId: s.scheduleId,
workflowId: s.workflowId ?? workflowId,
name: s.name,
cron: s.cron,
timezone: s.timezone,
enabled: s.enabled,
lastFiredAt: s.lastFiredAt,
nextFireAt: s.nextFireAt,
};
}

workflowsRouter.get("/:id/schedules", async (c) => {
const { deps, owner } = serviceCtx(c);
const id = c.req.param("id");
const summary = await getWorkflowDefinition(deps, owner, id);
if (!summary) return c.json({ error: "workflow not found" }, 404);
const schedules = await listWorkflowSchedules(deps.db, owner.orgId, id);
const resp: ListWorkflowSchedulesResponse = {
schedules: schedules.map((s) => toScheduleWire(s, id)),
};
return c.json(resp);
});

workflowsRouter.post("/:id/schedules", async (c) => {
const { deps, owner } = serviceCtx(c);
const id = c.req.param("id");
const summary = await getWorkflowDefinition(deps, owner, id);
if (!summary) return c.json({ error: "workflow not found" }, 404);

let body: CreateWorkflowScheduleRequest;
try {
body = (await c.req.json()) as CreateWorkflowScheduleRequest;
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}
if (!body.name || typeof body.name !== "string") {
return c.json({ error: "name must be a non-empty string" }, 400);
}
if (!body.cron || typeof body.cron !== "string") {
return c.json({ error: "cron must be a 5-field cron expression string" }, 400);
}
if (body.timezone !== undefined && typeof body.timezone !== "string") {
return c.json({ error: "timezone must be an IANA timezone string" }, 400);
}

const result = await createWorkflowSchedule(
deps.db,
{ id: owner.userId, orgId: owner.orgId },
{ workflowId: id, name: body.name, cron: body.cron, timezone: body.timezone, input: body.input },
);
if (!result.ok) return c.json({ error: result.error }, 400);
const resp: CreateWorkflowScheduleResponse = toScheduleWire(result.schedule, id);
return c.json(resp, 201);
});

workflowsRouter.delete("/:id/schedules/:scheduleId", async (c) => {
const { deps, owner } = serviceCtx(c);
const id = c.req.param("id");
const scheduleId = c.req.param("scheduleId");
const summary = await getWorkflowDefinition(deps, owner, id);
if (!summary) return c.json({ error: "workflow not found" }, 404);
// The service delete is org-scoped; require the schedule to belong to
// THIS workflow so one workflow's surface cannot delete another's rows.
const schedules = await listWorkflowSchedules(deps.db, owner.orgId, id);
if (!schedules.some((s) => s.scheduleId === scheduleId)) {
return c.json({ error: "schedule not found" }, 404);
}
const result = await deleteWorkflowSchedule(deps.db, owner.orgId, scheduleId);
const resp: DeleteWorkflowScheduleResponse = { deleted: result === "ok" };
return c.json(resp);
});

workflowsRouter.get("/runs/:runId", async (c) => {
const { deps, owner } = serviceCtx(c);
const resp = await getWorkflowRunDetail(deps, owner, c.req.param("runId"));
Expand Down
38 changes: 38 additions & 0 deletions packages/api/src/wire/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,10 @@ export interface TeamSummary {
name: string;
createdAt: number;
memberCount: number;
/** The caller's role on this team; null when the caller is not a member
* (org admins see every team in the org). The UI gates admin-only
* controls on this plus the caller's org role. */
callerRole: "admin" | "member" | null;
}

export interface TeamMemberSummary {
Expand Down Expand Up @@ -804,6 +808,40 @@ export interface DeleteWorkflowWebhookResponse {
deleted: boolean;
}

// Workflow schedules (cron triggers). `schedule-service.ts` also supports
// orchestrator-prompt schedules; this surface manages only the
// workflow-scoped kind, so `workflowId` is always set.
export interface WorkflowScheduleWire {
scheduleId: string;
workflowId: string;
name: string;
cron: string;
timezone: string;
enabled: boolean;
lastFiredAt: number | null;
nextFireAt: number;
}

export interface ListWorkflowSchedulesResponse {
schedules: WorkflowScheduleWire[];
}

export interface CreateWorkflowScheduleRequest {
name: string;
/** 5-field cron expression (minute hour day-of-month month day-of-week). */
cron: string;
/** IANA timezone name; defaults to UTC. */
timezone?: string;
/** Run input passed to the workflow's trigger node on each fire. */
input?: Record<string, unknown>;
}

export type CreateWorkflowScheduleResponse = WorkflowScheduleWire;

export interface DeleteWorkflowScheduleResponse {
deleted: boolean;
}

export interface GetMemoryTreeResponse {
entries: MemoryTreeEntry[];
}
Expand Down
26 changes: 26 additions & 0 deletions packages/api/test/teams-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,3 +353,29 @@ describe("teams routes", () => {
});
});
});

describe("GET /api/teams callerRole", () => {
it("reports the caller's own role, and null for an org admin who is not a member", async () => {
api = await bootTestApi();
const res = await createTeam(api.baseUrl, "Roles");
expect(res.status).toBe(201);
const { team } = (await res.json()) as CreateTeamResponse;
expect(team.callerRole).toBe("admin");

const add = await fetch(`${api.baseUrl}/api/teams/${team.id}/members`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ userId: "test-member", role: "member" }),
});
expect(add.status).toBe(201);

const asMember = await fetch(`${api.baseUrl}/api/teams`, { headers: MEMBER_HEADERS });
const memberList = (await asMember.json()) as ListTeamsResponse;
expect(memberList.teams.find((t) => t.id === team.id)?.callerRole).toBe("member");

const asAdmin = await fetch(`${api.baseUrl}/api/teams`, { headers: ADMIN_HEADERS });
const adminList = (await asAdmin.json()) as ListTeamsResponse;
expect(adminList.teams.find((t) => t.id === team.id)?.callerRole).toBe(null);
});
});

Loading
Loading