Skip to content

Commit 7a62982

Browse files
committed
Merge: coherent workflows — validator + richer ops + multi-instruction edit agent
2 parents b0a0fc3 + cc7aec6 commit 7a62982

15 files changed

Lines changed: 1506 additions & 28 deletions

app/api/workflow/edit/route.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { z } from "zod";
22

33
import { ApprovalWorkflow } from "@/lib/approval-workflow";
44
import { checkRateLimit, clientIpFrom } from "@/lib/ratelimit";
5-
import { proposeEdit } from "@/lib/workflow-edit";
6-
import { anthropicEditModel } from "@/lib/workflow-edit-model";
5+
import { runEditAgent } from "@/lib/workflow-edit-agent";
6+
import { anthropicPlanModel } from "@/lib/workflow-edit-model";
77

88
/**
99
* POST /api/workflow/edit — propose a conversational edit to an approval workflow.
@@ -49,13 +49,13 @@ export const POST = async (request: Request): Promise<Response> => {
4949
}
5050

5151
try {
52-
const { proposed, op, changes } = await proposeEdit(
53-
anthropicEditModel,
52+
// The agent plans an ordered list of ops, applies them, and self-corrects
53+
// against the validator until the workflow is sound (or its step budget).
54+
const { proposed, changes, reason } = await runEditAgent(
55+
anthropicPlanModel,
5456
workflow,
5557
instruction,
5658
);
57-
// `op.reason` (only present on a `none`) explains why nothing changed.
58-
const reason = op.op === "none" ? op.reason : null;
5959
return Response.json({ proposed, changes, reason });
6060
} catch {
6161
// A validation failure (the model produced an invalid graph) or a model error

components/onboarding.tsx

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useEffect, useRef, useState } from "react";
44

55
import { Badge } from "@/components/ui/badge";
6-
import { BambooHrIcon } from "@/components/ui/brand-icon";
6+
import {
7+
BambooHrIcon,
8+
SlackIcon,
9+
NetSuiteIcon,
10+
JiraIcon,
11+
} from "@/components/ui/brand-icon";
712
import { Button } from "@/components/ui/button";
813
import { Card, CardHeader, CardTitle, Eyebrow } from "@/components/ui/card";
914
import { WorkflowEditor } from "@/components/workflow-editor";
@@ -135,11 +140,7 @@ export const Onboarding = () => {
135140
<Card className="flex flex-col overflow-hidden">
136141
<CardHeader>
137142
<CardTitle>Derived approval workflow</CardTitle>
138-
{state.status === "done" && (
139-
<span className="hidden text-[11px] text-faint sm:inline">
140-
edit in plain language · approve to apply
141-
</span>
142-
)}
143+
{state.status === "done" && <WhatCanIChange />}
143144
</CardHeader>
144145
<div className="flex-1 overflow-hidden p-5">
145146
{state.status === "done" ? (
@@ -158,6 +159,110 @@ export const Onboarding = () => {
158159
);
159160
};
160161

162+
/**
163+
* A clickable "What can I change?" helper that opens a popover listing the edits
164+
* the plain-language editor actually supports — so the capabilities are
165+
* discoverable without overclaiming. (The editor maps your sentence to one of
166+
* these structured ops; it's natural-language editing, not an open-ended agent.)
167+
*/
168+
const EDIT_ACTIONS: {
169+
icon: React.ReactNode;
170+
title: string;
171+
example: string;
172+
}[] = [
173+
{
174+
icon: <span className="text-[11px]"></span>,
175+
title: "Add an approval gate",
176+
example: "“Require a CFO sign-off above $50k”",
177+
},
178+
{
179+
icon: <SlackIcon size={14} />,
180+
title: "Add a Slack notification",
181+
example: "“Notify on Slack when a bill posts”",
182+
},
183+
{
184+
icon: <JiraIcon size={14} />,
185+
title: "Open a Jira ticket",
186+
example: "“Open a Jira ticket for IT bills”",
187+
},
188+
{
189+
icon: <NetSuiteIcon size={14} />,
190+
title: "Post to NetSuite",
191+
example: "“Post approved bills to NetSuite”",
192+
},
193+
{
194+
icon: <span className="text-[11px]">$</span>,
195+
title: "Change a threshold",
196+
example: "“Lower the director threshold to $10k”",
197+
},
198+
{
199+
icon: <span className="text-[11px]">@</span>,
200+
title: "Change an approver",
201+
example: "“Make Jordan Ellis the director approver”",
202+
},
203+
{
204+
icon: <span className="text-[11px]"></span>,
205+
title: "Remove a step",
206+
example: "“Drop the department review”",
207+
},
208+
];
209+
210+
const WhatCanIChange = () => {
211+
const [open, setOpen] = useState(false);
212+
const ref = useRef<HTMLDivElement>(null);
213+
useEffect(() => {
214+
if (!open) return;
215+
const onDoc = (e: MouseEvent) => {
216+
if (ref.current && !ref.current.contains(e.target as globalThis.Node))
217+
setOpen(false);
218+
};
219+
document.addEventListener("mousedown", onDoc);
220+
return () => document.removeEventListener("mousedown", onDoc);
221+
}, [open]);
222+
223+
return (
224+
<div ref={ref} className="relative">
225+
<button
226+
onClick={() => setOpen((o) => !o)}
227+
className="inline-flex items-center gap-1.5 rounded-full bg-subtle px-2.5 py-1 text-[11.5px] font-medium text-muted ring-1 ring-inset ring-line-strong transition-colors hover:text-ink"
228+
>
229+
<span className="grid size-3.5 place-items-center rounded-full bg-ink text-[9px] font-bold text-white">
230+
?
231+
</span>
232+
What can I change?
233+
</button>
234+
{open && (
235+
<div className="absolute right-0 z-20 mt-2 w-80 rounded-xl bg-surface p-2 shadow-lift ring-1 ring-inset ring-line">
236+
<p className="px-2 pb-1.5 pt-1 text-[11px] leading-snug text-faint">
237+
Describe a change in plain language; it proposes a rewrite and you
238+
review the diff before anything applies.
239+
</p>
240+
<ul className="space-y-0.5">
241+
{EDIT_ACTIONS.map((a) => (
242+
<li
243+
key={a.title}
244+
className="flex items-start gap-2.5 rounded-lg px-2 py-1.5 hover:bg-subtle"
245+
>
246+
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-md bg-subtle text-muted ring-1 ring-inset ring-line-strong">
247+
{a.icon}
248+
</span>
249+
<span className="min-w-0">
250+
<span className="block text-[12.5px] font-medium text-ink">
251+
{a.title}
252+
</span>
253+
<span className="block text-[11px] italic leading-snug text-faint">
254+
{a.example}
255+
</span>
256+
</span>
257+
</li>
258+
))}
259+
</ul>
260+
</div>
261+
)}
262+
</div>
263+
);
264+
};
265+
161266
/** Initials for an avatar chip ("Riley Carter" → "RC"). */
162267
const initials = (name: string): string =>
163268
name

components/workflow-editor.tsx

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useMemo, useState } from "react";
44

55
import { Button } from "@/components/ui/button";
66
import { WorkflowGraph } from "@/components/workflow-graph";
77
import { API_ROUTES } from "@/lib/api-routes";
88
import type { ApprovalWorkflow, StepChange } from "@/lib/approval-workflow";
9+
import {
10+
validateWorkflow,
11+
isActivatable,
12+
type WorkflowIssue,
13+
} from "@/lib/workflow-validate";
914

1015
/**
1116
* The conversational workflow editor — the layer competitors don't have.
1217
*
13-
* You type what you want ("above $10k add a CFO approval") and the agent proposes
14-
* a rewrite. Nothing is applied until you APPROVE: the graph shows the proposal
15-
* with the diff (added / changed / removed), and you either keep it (the proposal
16-
* becomes the current workflow) or revert (discard it). The current workflow is
17-
* the only thing that would ever drive the pipeline — the proposal is preview-only.
18+
* You type what you want ("above $10k add a CFO approval") and a structured-output
19+
* model maps it to one edit op, which deterministic code applies to PROPOSE a
20+
* rewrite (it's plain-language editing, not an open-ended agent). Nothing applies
21+
* until you APPROVE: the graph shows the proposal with the diff (added / changed /
22+
* removed), and you keep it (the proposal becomes current) or revert (discard it).
23+
* The current workflow is the only thing that drives the pipeline — proposal is
24+
* preview-only.
1825
*
1926
* Holds two workflows: `current` (the approved one) and an optional `proposal`
2027
* (a pending edit). Reads POST /api/workflow/edit.
@@ -83,14 +90,30 @@ export const WorkflowEditor = ({
8390
}
8491
};
8592

93+
// Validate whatever's on screen (the proposal if pending, else the live one).
94+
// Errors block applying a proposal; warnings are surfaced but don't block.
95+
const shown = proposal?.proposed ?? current;
96+
const issues = useMemo(() => validateWorkflow(shown), [shown]);
97+
const canApply = isActivatable(issues);
98+
8699
const approve = () => {
87-
if (!proposal) return;
100+
if (!proposal || !isActivatable(validateWorkflow(proposal.proposed)))
101+
return;
88102
setCurrent(proposal.proposed); // the proposal becomes live
89103
setProposal(null);
90104
};
91105
const revert = () => {
92106
setProposal(null); // discard — current is untouched
93107
};
108+
// Reset = restore the originally-derived workflow (NOT recompute — that's the
109+
// left pane's "Re-run discovery"). Instant, deterministic.
110+
const isEdited = current !== initial;
111+
const reset = () => {
112+
setCurrent(initial);
113+
setProposal(null);
114+
setChips(suggestions);
115+
setError(null);
116+
};
94117

95118
const changedCount = proposal
96119
? proposal.changes.filter((c) => c.kind !== "unchanged").length
@@ -106,12 +129,16 @@ export const WorkflowEditor = ({
106129
<WorkflowGraph
107130
workflow={proposal.proposed}
108131
changes={proposal.changes}
132+
issues={issues}
109133
/>
110134
) : (
111-
<WorkflowGraph workflow={current} />
135+
<WorkflowGraph workflow={current} issues={issues} />
112136
)}
113137
</div>
114138

139+
{/* Validation summary — "sound" or the list of issues (errors block apply). */}
140+
<ValidationPanel issues={issues} />
141+
115142
{/* Pending-edit bar: approve / revert */}
116143
{proposal && (
117144
<div className="flex items-center justify-between gap-2 rounded-xl bg-accent-soft/60 px-3.5 py-2.5 ring-1 ring-inset ring-accent/15">
@@ -123,7 +150,12 @@ export const WorkflowEditor = ({
123150
<Button variant="ghost" size="sm" onClick={revert}>
124151
Revert
125152
</Button>
126-
<Button size="sm" onClick={approve}>
153+
<Button
154+
size="sm"
155+
onClick={approve}
156+
disabled={!canApply}
157+
title={canApply ? undefined : "Fix the errors before applying"}
158+
>
127159
Approve
128160
</Button>
129161
</div>
@@ -173,9 +205,26 @@ export const WorkflowEditor = ({
173205
value={instruction}
174206
onChange={(e) => setInstruction(e.target.value)}
175207
disabled={busy}
176-
placeholder="Tell the agent how to change the workflow…"
208+
placeholder="Describe a change in plain language…"
177209
className="h-10 flex-1 rounded-lg bg-surface px-3.5 text-[13px] text-ink shadow-card outline-none ring-1 ring-inset ring-line-strong transition-shadow placeholder:text-faint focus:ring-2 focus:ring-accent-ring disabled:opacity-60"
178210
/>
211+
{isEdited && !proposal && (
212+
<Button
213+
type="button"
214+
variant="ghost"
215+
onClick={() => {
216+
if (
217+
window.confirm(
218+
"Discard all edits and restore the workflow the agent first derived?",
219+
)
220+
)
221+
reset();
222+
}}
223+
title="Restore the originally derived workflow"
224+
>
225+
Reset
226+
</Button>
227+
)}
179228
<Button
180229
type="submit"
181230
loading={busy}
@@ -189,6 +238,56 @@ export const WorkflowEditor = ({
189238
);
190239
};
191240

241+
/**
242+
* The validation summary. "Sound" when there's nothing to flag; otherwise the list
243+
* of errors (red, block applying) and warnings (amber, control best-practices). This
244+
* is what shows the tool understands the workflow, not just draws it.
245+
*/
246+
const ValidationPanel = ({ issues }: { issues: WorkflowIssue[] }) => {
247+
if (issues.length === 0) {
248+
return (
249+
<div className="flex items-center gap-1.5 rounded-xl bg-ok-soft/50 px-3.5 py-2 text-[12px] font-medium text-ok ring-1 ring-inset ring-ok-line/60">
250+
<span aria-hidden></span> Sound — passes the approval-control checks
251+
</div>
252+
);
253+
}
254+
const errors = issues.filter((i) => i.severity === "error");
255+
const warnings = issues.filter((i) => i.severity === "warning");
256+
return (
257+
<div className="space-y-1.5 rounded-xl bg-subtle/60 px-3 py-2.5 ring-1 ring-inset ring-line">
258+
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-faint">
259+
Checks
260+
{errors.length > 0 && (
261+
<span className="rounded-full bg-danger-soft px-1.5 py-0.5 text-danger">
262+
{errors.length} to fix
263+
</span>
264+
)}
265+
{warnings.length > 0 && (
266+
<span className="rounded-full bg-warn-soft px-1.5 py-0.5 text-warn">
267+
{warnings.length} {warnings.length === 1 ? "warning" : "warnings"}
268+
</span>
269+
)}
270+
</div>
271+
<ul className="space-y-1">
272+
{issues.map((iss, i) => (
273+
<li
274+
key={`${iss.code}-${i}`}
275+
className="flex gap-2 text-[12px] leading-snug text-ink/90"
276+
>
277+
<span
278+
aria-hidden
279+
className={iss.severity === "error" ? "text-danger" : "text-warn"}
280+
>
281+
{iss.severity === "error" ? "✕" : "⚠"}
282+
</span>
283+
<span>{iss.message}</span>
284+
</li>
285+
))}
286+
</ul>
287+
</div>
288+
);
289+
};
290+
192291
/** A small four-point spark, marking the AI-generated suggestions. */
193292
const SparkIcon = () => {
194293
return (

0 commit comments

Comments
 (0)