Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit e29f590

Browse files
committed
feat(fn-20.4): approvals dashboard + worker API protocol
Adds the UI + worker-side half of the approval system that consumes T3's backend CLI and daemon endpoints. - frontend/src/pages/Approvals.tsx: new dashboard page rendering pending approvals as cards with kind/task badges, expandable JSON payloads, and Approve/Reject buttons (with reason textarea). SWR polls every 5s and WS subscribes to ApprovalCreated/ApprovalResolved events. - App.tsx, Layout.tsx: register /approvals route + nav entry with keyboard shortcut "V". - types.ts: add ApprovalCreated/ApprovalResolved to FlowEventType. - agents/worker.md: migrate "Need file access:" and "Need mutation:" to use flowctl approval create + approval show --wait --timeout 600 as the preferred path; keep SendMessage summary-prefix as documented fallback for no-daemon/non-Teams scenarios. - skills/flow-code-work/SKILL.md: document the two-tier protocol. - scripts/teams_e2e_test.sh: add approval-API scenario (create, list, approve, show --wait, reject). Task: fn-20-abf-borrowed-enhancements-archetypes.4
1 parent 069b2e5 commit e29f590

7 files changed

Lines changed: 351 additions & 17 deletions

File tree

agents/worker.md

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,40 @@ SendMessage(to: "coordinator", summary: "Blocked: <TASK_ID>",
7878
```
7979

8080
4. **File access request** — when you need a file not in OWNED_FILES:
81-
```
82-
SendMessage(to: "coordinator", summary: "Need file access: <file>",
83-
message: "Access request for <TASK_ID>.\nFile: <path>\nReason: <why needed>\nCurrent owner: <task-id>")
84-
```
81+
82+
**Preferred path (daemon running):** use the approval API instead of SendMessage:
83+
```bash
84+
APPROVAL_ID=$($FLOWCTL approval create --task <TASK_ID> --kind file_access \
85+
--payload '{"files": ["<path>"], "reason": "<why needed>", "current_owner": "<task-id>"}' \
86+
--json | jq -r .id)
87+
$FLOWCTL approval show "$APPROVAL_ID" --wait --timeout 600 --json
88+
```
89+
- On `status: approved` → proceed with the edit.
90+
- On `status: rejected` → emit a `Blocked:` summary and skip the file.
91+
- On timeout → note in completion evidence and continue with alternative approach.
92+
93+
**Fallback (no daemon, non-Teams mode):**
94+
```
95+
SendMessage(to: "coordinator", summary: "Need file access: <file>",
96+
message: "Access request for <TASK_ID>.\nFile: <path>\nReason: <why needed>\nCurrent owner: <task-id>")
97+
```
98+
Wait for "Access granted:" or "Access denied:" summary-prefix response.
8599

86100
5. **Mutation request** — when the task should be split, skipped, or dependencies changed:
87-
```
88-
SendMessage(to: "coordinator", summary: "Need mutation: <TASK_ID>",
89-
message: "Task <TASK_ID> needs structural change.\nType: split | skip | dep_change\nDetails: <why the mutation is needed>\nSuggested action: <split into N parts | skip because X | remove dep on Y>")
90-
```
101+
102+
**Preferred path (daemon running):**
103+
```bash
104+
APPROVAL_ID=$($FLOWCTL approval create --task <TASK_ID> --kind mutation \
105+
--payload '{"type": "split|skip|dep_change", "details": "<why>", "action": "<suggested>"}' \
106+
--json | jq -r .id)
107+
$FLOWCTL approval show "$APPROVAL_ID" --wait --timeout 600 --json
108+
```
109+
110+
**Fallback (no daemon, non-Teams mode):**
111+
```
112+
SendMessage(to: "coordinator", summary: "Need mutation: <TASK_ID>",
113+
message: "Task <TASK_ID> needs structural change.\nType: split | skip | dep_change\nDetails: <why the mutation is needed>\nSuggested action: <split into N parts | skip because X | remove dep on Y>")
114+
```
91115

92116
**Team Lead → Worker messages (you receive these):**
93117

@@ -269,14 +293,21 @@ If more files remain (tests, docs, config), repeat: parallel read → checkpoint
269293
1. Is this file in `OWNED_FILES`?
270294
- **YES** → proceed with the edit
271295
- **NO****STOP. Do NOT edit the file.** Instead:
272-
1. Send a file access request:
296+
1. Request approval via the API (preferred when daemon is running):
297+
```bash
298+
APPROVAL_ID=$($FLOWCTL approval create --task <TASK_ID> --kind file_access \
299+
--payload '{"files": ["<path>"], "reason": "<why>", "current_owner": "<task-id>"}' \
300+
--json | jq -r .id)
301+
$FLOWCTL approval show "$APPROVAL_ID" --wait --timeout 600 --json
302+
```
303+
Fallback (no daemon): send a SendMessage summary-prefix request:
273304
```
274305
SendMessage(to: "coordinator", summary: "Need file access: <file>",
275306
message: "Access request for <TASK_ID>.\nFile: <path>\nReason: <why needed>\nCurrent owner: <task-id if known>")
276307
```
277-
2. Wait for "Access granted:" or "Access denied:" response
278-
3. If no response within 60s, skip the file and note it in your completion evidence
279-
4. On "Access denied:", find an alternative approach that stays within your owned files
308+
2. Wait for `status: approved`/`rejected` (API) or "Access granted:"/"Access denied:" (fallback)
309+
3. If timeout, skip the file and note it in your completion evidence
310+
4. On rejected/denied, find an alternative approach that stays within your owned files
280311

281312
**This is not optional.** Do not bypass this check even if you believe the lock system will catch violations. Self-enforcement is the primary guard; hooks are the backup.
282313
<!-- /section:team -->
@@ -332,7 +363,7 @@ Continue until guard passes. There is no retry limit — this is not a retry loo
332363
- Return early with `SPEC_CONFLICT` status (see Phase 2 spec conflict protocol)
333364
- In Teams mode, send a `Spec conflict` message to the coordinator
334365

335-
**Teams mode constraint:** When `TEAM_MODE=true`, only fix files in `OWNED_FILES`. If the failure is caused by a file you don't own, send a `Need file access` message and wait for a response. If access is denied or times out, note the issue in your completion summary.
366+
**Teams mode constraint:** When `TEAM_MODE=true`, only fix files in `OWNED_FILES`. If the failure is caused by a file you don't own, request access via `flowctl approval create --kind file_access` + `approval show --wait` (or fallback `Need file access:` SendMessage), then wait for a resolution. If access is rejected or times out, note the issue in your completion summary.
336367
337368
### Step 2: Review your own diff
338369
```bash
@@ -592,8 +623,8 @@ If you catch yourself thinking any of these, stop and follow the correct action:
592623

593624
| Thought | Reality |
594625
|---------|---------|
595-
| "I need to edit a file not in OWNED_FILES" | Send "Need file access:" and WAIT. Do not edit. |
596-
| "The coordinator isn't responding" | Wait 60s. If no response, skip the file and note it in evidence. |
626+
| "I need to edit a file not in OWNED_FILES" | Create a `flowctl approval create --kind file_access` (or fallback "Need file access:" message) and WAIT. Do not edit. |
627+
| "The coordinator isn't responding" | `approval show --wait --timeout 600` blocks ≤10min; on fallback path wait 60s. If timeout, skip the file and note it in evidence. |
597628
| "I'll just edit it, the lock check will catch it" | Don't rely on hooks. Self-enforce OWNED_FILES. |
598629
| "TEAM_MODE doesn't matter for this task" | If TEAM_MODE=true is set, follow the protocol. Always. |
599630
| "It's a small edit, nobody will notice" | Ownership violations break parallel safety for everyone. |

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import EpicDetail from "./pages/EpicDetail";
77
import DagView from "./pages/DagView";
88
import Agents from "./pages/Agents";
99
import Memory from "./pages/Memory";
10+
import Approvals from "./pages/Approvals";
1011
import Settings from "./pages/Settings";
1112
import Replay from "./pages/Replay";
1213
import CommandPalette from "./components/CommandPalette";
@@ -44,6 +45,7 @@ export default function App() {
4445
<Route path="/dag/:id" element={<DagView />} />
4546
<Route path="/agents" element={<Agents />} />
4647
<Route path="/memory" element={<Memory />} />
48+
<Route path="/approvals" element={<Approvals />} />
4749
<Route path="/settings" element={<Settings />} />
4850
<Route path="/replay/:id" element={<Replay />} />
4951
</Route>

frontend/src/components/Layout.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
LayoutDashboard,
55
Bot,
66
Brain,
7+
CheckSquare,
78
Settings,
89
ChevronRight,
910
Menu,
@@ -23,6 +24,7 @@ const navItems = [
2324
{ to: "/", label: "Dashboard", icon: LayoutDashboard, shortcut: "D" },
2425
{ to: "/agents", label: "Agents", icon: Bot, shortcut: "A" },
2526
{ to: "/memory", label: "Memory", icon: Brain, shortcut: "M" },
27+
{ to: "/approvals", label: "Approvals", icon: CheckSquare, shortcut: "V" },
2628
{ to: "/settings", label: "Settings", icon: Settings, shortcut: "S" },
2729
];
2830

@@ -125,6 +127,10 @@ export default function Layout() {
125127
e.preventDefault();
126128
navigate("/memory");
127129
break;
130+
case "v":
131+
e.preventDefault();
132+
navigate("/approvals");
133+
break;
128134
case "s":
129135
e.preventDefault();
130136
navigate("/settings");

frontend/src/lib/types.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,33 @@ export interface EpicUpdated {
2828
// Heartbeat has no data (content is null in serde)
2929
export type Heartbeat = null;
3030

31+
export interface ApprovalCreated {
32+
id: string;
33+
task_id: string;
34+
}
35+
36+
export interface ApprovalResolved {
37+
id: string;
38+
status: string;
39+
}
40+
3141
export type FlowEventType =
3242
| "TaskStatusChanged"
3343
| "DagMutated"
3444
| "AgentLog"
3545
| "EpicUpdated"
36-
| "Heartbeat";
46+
| "Heartbeat"
47+
| "ApprovalCreated"
48+
| "ApprovalResolved";
3749

3850
export type FlowEventData =
3951
| TaskStatusChanged
4052
| DagMutated
4153
| AgentLog
4254
| EpicUpdated
43-
| Heartbeat;
55+
| Heartbeat
56+
| ApprovalCreated
57+
| ApprovalResolved;
4458

4559
/** Envelope sent over WebSocket: TimestampedEvent from Rust */
4660
export interface TimestampedEvent {

frontend/src/pages/Approvals.tsx

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { useEffect, useState } from "react";
2+
import useSWR from "swr";
3+
import { toast } from "sonner";
4+
import { swrFetcher, apiPost, ApiRequestError } from "../lib/api";
5+
import { connectEvents } from "../lib/ws";
6+
7+
interface Approval {
8+
id: string;
9+
task_id: string;
10+
kind: "file_access" | "mutation" | "generic";
11+
payload: unknown;
12+
status: "pending" | "approved" | "rejected";
13+
created_at: number;
14+
resolved_at: number | null;
15+
resolver: string | null;
16+
reason: string | null;
17+
}
18+
19+
function kindBadge(kind: string): { color: string; label: string } {
20+
switch (kind) {
21+
case "file_access":
22+
return { color: "bg-info/20 text-info", label: "File access" };
23+
case "mutation":
24+
return { color: "bg-purple-500/20 text-purple-400", label: "Mutation" };
25+
case "generic":
26+
return { color: "bg-bg-tertiary text-text-secondary", label: "Generic" };
27+
default:
28+
return { color: "bg-bg-tertiary text-text-muted", label: kind };
29+
}
30+
}
31+
32+
function formatTimestamp(epochSeconds: number): string {
33+
return new Date(epochSeconds * 1000).toLocaleString();
34+
}
35+
36+
export default function Approvals() {
37+
const { data, error, isLoading, mutate } = useSWR<Approval[]>(
38+
"/approvals?status=pending",
39+
swrFetcher,
40+
{ refreshInterval: 5000 },
41+
);
42+
43+
const [rejectingId, setRejectingId] = useState<string | null>(null);
44+
const [rejectReason, setRejectReason] = useState("");
45+
const [expandedId, setExpandedId] = useState<string | null>(null);
46+
const [busyId, setBusyId] = useState<string | null>(null);
47+
48+
useEffect(() => {
49+
const conn = connectEvents();
50+
const refresh = () => {
51+
mutate();
52+
};
53+
conn.on("ApprovalCreated", refresh);
54+
conn.on("ApprovalResolved", refresh);
55+
return () => conn.close();
56+
}, [mutate]);
57+
58+
const approvals = data ?? [];
59+
60+
async function handleApprove(id: string) {
61+
setBusyId(id);
62+
try {
63+
await apiPost(`/approvals/${id}/approve`, {});
64+
toast.success(`Approved ${id}`);
65+
mutate();
66+
} catch (e) {
67+
const msg =
68+
e instanceof ApiRequestError ? e.serverMessage : String(e);
69+
toast.error(`Approve failed: ${msg}`);
70+
} finally {
71+
setBusyId(null);
72+
}
73+
}
74+
75+
async function handleReject(id: string) {
76+
setBusyId(id);
77+
try {
78+
await apiPost(`/approvals/${id}/reject`, {
79+
reason: rejectReason || null,
80+
});
81+
toast.success(`Rejected ${id}`);
82+
setRejectingId(null);
83+
setRejectReason("");
84+
mutate();
85+
} catch (e) {
86+
const msg =
87+
e instanceof ApiRequestError ? e.serverMessage : String(e);
88+
toast.error(`Reject failed: ${msg}`);
89+
} finally {
90+
setBusyId(null);
91+
}
92+
}
93+
94+
return (
95+
<div className="flex flex-col h-full gap-4">
96+
<h1 className="text-2xl font-bold">Approvals</h1>
97+
98+
{isLoading && <p className="text-text-muted text-sm">Loading...</p>}
99+
{error && (
100+
<p className="text-error text-sm">Failed to load approvals.</p>
101+
)}
102+
103+
{!isLoading && approvals.length === 0 && (
104+
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-center">
105+
<div className="text-4xl opacity-40">OK</div>
106+
<p className="text-text-primary text-sm font-medium">
107+
No pending approvals
108+
</p>
109+
<p className="text-text-muted text-xs max-w-md">
110+
Workers will request approval here when they need access to files
111+
outside their owned set or want to mutate the task DAG.
112+
</p>
113+
</div>
114+
)}
115+
116+
<div className="flex-1 overflow-auto space-y-3">
117+
{approvals.map((approval) => {
118+
const badge = kindBadge(approval.kind);
119+
const isExpanded = expandedId === approval.id;
120+
const isRejecting = rejectingId === approval.id;
121+
const isBusy = busyId === approval.id;
122+
return (
123+
<div
124+
key={approval.id}
125+
className="rounded-md border border-border bg-bg-secondary p-4 space-y-3"
126+
>
127+
<div className="flex items-center gap-2 flex-wrap">
128+
<span
129+
className={`px-2 py-0.5 rounded text-xs font-medium ${badge.color}`}
130+
>
131+
{badge.label}
132+
</span>
133+
<span className="px-2 py-0.5 rounded text-xs font-medium bg-accent/20 text-accent">
134+
{approval.task_id}
135+
</span>
136+
<span className="text-xs text-text-muted ml-auto">
137+
{formatTimestamp(approval.created_at)}
138+
</span>
139+
</div>
140+
141+
<div>
142+
<button
143+
type="button"
144+
onClick={() =>
145+
setExpandedId(isExpanded ? null : approval.id)
146+
}
147+
className="text-xs text-text-muted hover:text-text-primary"
148+
>
149+
{isExpanded ? "Hide" : "Show"} payload
150+
</button>
151+
{isExpanded && (
152+
<pre className="mt-2 p-2 rounded bg-bg-tertiary text-xs text-text-primary overflow-auto">
153+
{JSON.stringify(approval.payload, null, 2)}
154+
</pre>
155+
)}
156+
</div>
157+
158+
{isRejecting ? (
159+
<div className="space-y-2">
160+
<textarea
161+
value={rejectReason}
162+
onChange={(e) => setRejectReason(e.target.value)}
163+
placeholder="Reason (optional)..."
164+
className="w-full px-3 py-2 rounded-md bg-bg-tertiary border border-border text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-border-accent"
165+
rows={2}
166+
/>
167+
<div className="flex gap-2">
168+
<button
169+
type="button"
170+
disabled={isBusy}
171+
onClick={() => handleReject(approval.id)}
172+
className="px-3 py-1.5 rounded-md bg-error/20 text-error text-sm font-medium hover:bg-error/30 disabled:opacity-50"
173+
>
174+
Confirm reject
175+
</button>
176+
<button
177+
type="button"
178+
disabled={isBusy}
179+
onClick={() => {
180+
setRejectingId(null);
181+
setRejectReason("");
182+
}}
183+
className="px-3 py-1.5 rounded-md bg-bg-tertiary text-text-secondary text-sm hover:bg-bg-tertiary/70 disabled:opacity-50"
184+
>
185+
Cancel
186+
</button>
187+
</div>
188+
</div>
189+
) : (
190+
<div className="flex gap-2">
191+
<button
192+
type="button"
193+
disabled={isBusy}
194+
onClick={() => handleApprove(approval.id)}
195+
className="px-3 py-1.5 rounded-md bg-success/20 text-success text-sm font-medium hover:bg-success/30 disabled:opacity-50"
196+
>
197+
Approve
198+
</button>
199+
<button
200+
type="button"
201+
disabled={isBusy}
202+
onClick={() => {
203+
setRejectingId(approval.id);
204+
setRejectReason("");
205+
}}
206+
className="px-3 py-1.5 rounded-md bg-error/20 text-error text-sm font-medium hover:bg-error/30 disabled:opacity-50"
207+
>
208+
Reject
209+
</button>
210+
</div>
211+
)}
212+
</div>
213+
);
214+
})}
215+
</div>
216+
</div>
217+
);
218+
}

0 commit comments

Comments
 (0)