stagent 0.1.7 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -47
- package/dist/cli.js +16 -24
- package/package.json +1 -1
- package/public/readme/cost-usage-list.png +0 -0
- package/public/readme/dashboard-bulk-select.png +0 -0
- package/public/readme/dashboard-card-edit.png +0 -0
- package/public/readme/dashboard-create-form-ai-applied.png +0 -0
- package/public/readme/dashboard-create-form-ai-assist.png +0 -0
- package/public/readme/dashboard-create-form-empty.png +0 -0
- package/public/readme/dashboard-create-form-filled.png +0 -0
- package/public/readme/dashboard-filtered.png +0 -0
- package/public/readme/dashboard-list.png +0 -0
- package/public/readme/dashboard-sorted.png +0 -0
- package/public/readme/dashboard-workflow-confirm.png +0 -0
- package/public/readme/documents-grid.png +0 -0
- package/public/readme/documents-list.png +0 -0
- package/public/readme/home-below-fold.png +0 -0
- package/public/readme/home-list.png +0 -0
- package/public/readme/inbox-list.png +0 -0
- package/public/readme/monitor-list.png +0 -0
- package/public/readme/profiles-list.png +0 -0
- package/public/readme/projects-detail.png +0 -0
- package/public/readme/projects-list.png +0 -0
- package/public/readme/schedules-list.png +0 -0
- package/public/readme/settings-list.png +0 -0
- package/public/readme/workflows-list.png +0 -0
- package/src/app/api/documents/route.ts +21 -2
- package/src/app/api/tasks/route.ts +16 -3
- package/src/app/api/uploads/route.ts +17 -3
- package/src/app/api/workflows/from-assist/route.ts +143 -0
- package/src/app/dashboard/page.tsx +24 -2
- package/src/app/globals.css +34 -0
- package/src/app/tasks/new/page.tsx +10 -2
- package/src/app/workflows/from-assist/page.tsx +35 -0
- package/src/components/projects/project-card.tsx +47 -35
- package/src/components/tasks/__tests__/kanban-board-persistence.test.tsx +124 -0
- package/src/components/tasks/__tests__/task-create-panel.test.tsx +58 -0
- package/src/components/tasks/ai-assist-panel.tsx +80 -21
- package/src/components/tasks/kanban-board.tsx +201 -5
- package/src/components/tasks/kanban-column.tsx +156 -5
- package/src/components/tasks/task-card.tsx +201 -44
- package/src/components/tasks/task-create-panel.tsx +42 -2
- package/src/components/tasks/task-detail-view.tsx +58 -1
- package/src/components/tasks/task-edit-dialog.tsx +277 -0
- package/src/components/workflows/workflow-confirmation-view.tsx +447 -0
- package/src/hooks/__tests__/use-persisted-state.test.ts +57 -0
- package/src/hooks/use-persisted-state.ts +40 -0
- package/src/lib/agents/claude-agent.ts +17 -7
- package/src/lib/agents/profiles/__tests__/suggest.test.ts +67 -0
- package/src/lib/agents/profiles/suggest.ts +36 -0
- package/src/lib/agents/runtime/claude-sdk.ts +20 -6
- package/src/lib/agents/runtime/claude.ts +59 -11
- package/src/lib/agents/runtime/openai-codex.ts +14 -1
- package/src/lib/agents/runtime/task-assist-types.ts +12 -2
- package/src/lib/data/__tests__/clear.test.ts +42 -0
- package/src/lib/data/clear.ts +3 -0
- package/src/lib/db/bootstrap.ts +17 -32
- package/src/lib/documents/cleanup.ts +3 -2
- package/src/lib/notifications/permissions.ts +7 -1
- package/src/lib/workflows/__tests__/assist-builder.test.ts +255 -0
- package/src/lib/workflows/assist-builder.ts +248 -0
- package/src/lib/workflows/assist-session.ts +78 -0
- package/src/lib/workflows/engine.ts +48 -3
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Label } from "@/components/ui/label";
|
|
8
|
+
import { Badge } from "@/components/ui/badge";
|
|
9
|
+
import {
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from "@/components/ui/select";
|
|
16
|
+
import {
|
|
17
|
+
ArrowUp,
|
|
18
|
+
ArrowDown,
|
|
19
|
+
Star,
|
|
20
|
+
Trash2,
|
|
21
|
+
Loader2,
|
|
22
|
+
GitBranch,
|
|
23
|
+
ListOrdered,
|
|
24
|
+
Settings,
|
|
25
|
+
} from "lucide-react";
|
|
26
|
+
import { toast } from "sonner";
|
|
27
|
+
import { FormSectionCard } from "@/components/shared/form-section-card";
|
|
28
|
+
import type { TaskAssistResponse } from "@/lib/agents/runtime/task-assist-types";
|
|
29
|
+
import type { WorkflowPattern } from "@/lib/workflows/types";
|
|
30
|
+
import { suggestProfileForStep } from "@/lib/agents/profiles/suggest";
|
|
31
|
+
import {
|
|
32
|
+
loadAssistState,
|
|
33
|
+
clearAssistState,
|
|
34
|
+
saveTaskFormState,
|
|
35
|
+
} from "@/lib/workflows/assist-session";
|
|
36
|
+
|
|
37
|
+
const PATTERN_OPTIONS: { value: WorkflowPattern; label: string; description: string }[] = [
|
|
38
|
+
{ value: "sequence", label: "Sequence", description: "Steps run one after another" },
|
|
39
|
+
{ value: "planner-executor", label: "Planner → Executor", description: "First step plans, rest execute" },
|
|
40
|
+
{ value: "checkpoint", label: "Checkpoint", description: "Steps pause for human approval" },
|
|
41
|
+
{ value: "parallel", label: "Parallel", description: "Independent steps run concurrently" },
|
|
42
|
+
{ value: "loop", label: "Loop", description: "Single step repeats iteratively" },
|
|
43
|
+
{ value: "swarm", label: "Swarm", description: "Mayor coordinates workers" },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
interface StepItem {
|
|
47
|
+
title: string;
|
|
48
|
+
description: string;
|
|
49
|
+
profile: string;
|
|
50
|
+
requiresApproval?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ProfileOption {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface WorkflowConfirmationViewProps {
|
|
59
|
+
projects: { id: string; name: string }[];
|
|
60
|
+
profiles: ProfileOption[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function WorkflowConfirmationView({
|
|
64
|
+
projects,
|
|
65
|
+
profiles,
|
|
66
|
+
}: WorkflowConfirmationViewProps) {
|
|
67
|
+
const router = useRouter();
|
|
68
|
+
const [workflowName, setWorkflowName] = useState("");
|
|
69
|
+
const [pattern, setPattern] = useState<WorkflowPattern>("sequence");
|
|
70
|
+
const [selectedProjectId, setSelectedProjectId] = useState("");
|
|
71
|
+
const [steps, setSteps] = useState<StepItem[]>([]);
|
|
72
|
+
const [maxIterations, setMaxIterations] = useState(5);
|
|
73
|
+
const [workerConcurrencyLimit, setWorkerConcurrencyLimit] = useState(2);
|
|
74
|
+
const [submitting, setSubmitting] = useState(false);
|
|
75
|
+
const [priority, setPriority] = useState(2);
|
|
76
|
+
const [assignedAgent, setAssignedAgent] = useState<string | undefined>();
|
|
77
|
+
const [loaded, setLoaded] = useState(false);
|
|
78
|
+
|
|
79
|
+
// Load state from sessionStorage on mount
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const state = loadAssistState();
|
|
82
|
+
if (!state) {
|
|
83
|
+
router.replace("/tasks/new");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { assistResult, formState } = state;
|
|
88
|
+
const recommended = assistResult.recommendedPattern;
|
|
89
|
+
const validPattern = recommended === "single" ? "sequence" : (recommended as WorkflowPattern);
|
|
90
|
+
|
|
91
|
+
setPattern(validPattern);
|
|
92
|
+
setWorkflowName(`Workflow: ${formState.title}`);
|
|
93
|
+
setSelectedProjectId(formState.projectId);
|
|
94
|
+
setPriority(parseInt(formState.priority, 10) || 2);
|
|
95
|
+
setAssignedAgent(formState.assignedAgent || undefined);
|
|
96
|
+
setMaxIterations(assistResult.suggestedLoopConfig?.maxIterations ?? 5);
|
|
97
|
+
setWorkerConcurrencyLimit(assistResult.suggestedSwarmConfig?.workerConcurrencyLimit ?? 2);
|
|
98
|
+
|
|
99
|
+
const profileIds = profiles.map((p) => p.id);
|
|
100
|
+
|
|
101
|
+
const newSteps: StepItem[] = [
|
|
102
|
+
{
|
|
103
|
+
title: formState.title,
|
|
104
|
+
description: formState.description,
|
|
105
|
+
profile: formState.agentProfile || "auto",
|
|
106
|
+
},
|
|
107
|
+
...assistResult.breakdown.map((sub) => ({
|
|
108
|
+
title: sub.title,
|
|
109
|
+
description: sub.description,
|
|
110
|
+
profile: sub.suggestedProfile || suggestProfileForStep(sub.title, sub.description, profileIds),
|
|
111
|
+
requiresApproval: sub.requiresApproval,
|
|
112
|
+
})),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
setSteps(newSteps);
|
|
116
|
+
setLoaded(true);
|
|
117
|
+
}, [router, profiles]);
|
|
118
|
+
|
|
119
|
+
function navigateBackToTask() {
|
|
120
|
+
// Save form state so task form can restore
|
|
121
|
+
const state = loadAssistState();
|
|
122
|
+
if (state) {
|
|
123
|
+
saveTaskFormState(state.formState);
|
|
124
|
+
}
|
|
125
|
+
clearAssistState();
|
|
126
|
+
router.push("/tasks/new?restore=1");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function moveStep(index: number, direction: -1 | 1) {
|
|
130
|
+
if (index === 0) return;
|
|
131
|
+
const newIndex = index + direction;
|
|
132
|
+
if (newIndex < 1 || newIndex >= steps.length) return;
|
|
133
|
+
const newSteps = [...steps];
|
|
134
|
+
[newSteps[index], newSteps[newIndex]] = [newSteps[newIndex], newSteps[index]];
|
|
135
|
+
setSteps(newSteps);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function removeStep(index: number) {
|
|
139
|
+
if (index === 0 || steps.length <= 2) return;
|
|
140
|
+
setSteps(steps.filter((_, i) => i !== index));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function updateStepProfile(index: number, profile: string) {
|
|
144
|
+
const newSteps = [...steps];
|
|
145
|
+
newSteps[index] = { ...newSteps[index], profile };
|
|
146
|
+
setSteps(newSteps);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function handleSubmit(executeImmediately: boolean) {
|
|
150
|
+
if (!workflowName.trim() || steps.length < 2) return;
|
|
151
|
+
setSubmitting(true);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const definitionSteps = steps.map((step, i) => ({
|
|
155
|
+
id: `step_${i + 1}`,
|
|
156
|
+
name: step.title,
|
|
157
|
+
prompt: step.description,
|
|
158
|
+
agentProfile: step.profile === "auto" ? undefined : step.profile,
|
|
159
|
+
requiresApproval: pattern === "checkpoint" ? step.requiresApproval : undefined,
|
|
160
|
+
...(pattern === "parallel" && i === steps.length - 1
|
|
161
|
+
? { dependsOn: steps.slice(0, -1).map((_, j) => `step_${j + 1}`) }
|
|
162
|
+
: {}),
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
const definition: Record<string, unknown> = {
|
|
166
|
+
pattern,
|
|
167
|
+
steps: definitionSteps,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (pattern === "loop") {
|
|
171
|
+
definition.loopConfig = {
|
|
172
|
+
maxIterations,
|
|
173
|
+
agentProfile: steps[0]?.profile === "auto" ? undefined : steps[0]?.profile,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (pattern === "swarm") {
|
|
178
|
+
definition.swarmConfig = { workerConcurrencyLimit };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const res = await fetch("/api/workflows/from-assist", {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/json" },
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
name: workflowName.trim(),
|
|
186
|
+
projectId: selectedProjectId || undefined,
|
|
187
|
+
definition,
|
|
188
|
+
priority,
|
|
189
|
+
assignedAgent: assignedAgent || undefined,
|
|
190
|
+
executeImmediately,
|
|
191
|
+
parentTask: {
|
|
192
|
+
title: steps[0].title,
|
|
193
|
+
description: steps[0].description,
|
|
194
|
+
agentProfile: steps[0].profile === "auto" ? undefined : steps[0].profile,
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
const data = await res.json().catch(() => null);
|
|
201
|
+
toast.error(data?.error ?? "Failed to create workflow");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const data = await res.json();
|
|
206
|
+
toast.success(
|
|
207
|
+
executeImmediately
|
|
208
|
+
? `Task created with workflow (${data.taskIds.length} steps)`
|
|
209
|
+
: `Task created with draft workflow (${data.taskIds.length} steps)`,
|
|
210
|
+
{
|
|
211
|
+
action: {
|
|
212
|
+
label: "View workflow",
|
|
213
|
+
onClick: () => window.open(`/workflows/${data.workflow.id}`, "_self"),
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
clearAssistState();
|
|
218
|
+
router.push("/dashboard");
|
|
219
|
+
} catch {
|
|
220
|
+
toast.error("Network error — could not create workflow");
|
|
221
|
+
} finally {
|
|
222
|
+
setSubmitting(false);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const isReorderDisabled = pattern === "parallel";
|
|
227
|
+
|
|
228
|
+
if (!loaded) {
|
|
229
|
+
return (
|
|
230
|
+
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
|
231
|
+
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
|
232
|
+
Loading workflow data...
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<div className="space-y-6">
|
|
239
|
+
<div>
|
|
240
|
+
<h1 className="text-2xl font-bold">Create Workflow from AI Assist</h1>
|
|
241
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
242
|
+
Review and customize the AI-suggested workflow before creating it.
|
|
243
|
+
</p>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{/* Workflow Identity */}
|
|
247
|
+
<FormSectionCard icon={GitBranch} title="Workflow Identity">
|
|
248
|
+
<div className="space-y-3">
|
|
249
|
+
<div className="space-y-1.5">
|
|
250
|
+
<Label htmlFor="workflow-name">Name</Label>
|
|
251
|
+
<Input
|
|
252
|
+
id="workflow-name"
|
|
253
|
+
value={workflowName}
|
|
254
|
+
onChange={(e) => setWorkflowName(e.target.value)}
|
|
255
|
+
placeholder="Workflow name"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div className="grid grid-cols-2 gap-3">
|
|
260
|
+
<div className="space-y-1.5">
|
|
261
|
+
<Label>Pattern</Label>
|
|
262
|
+
<Select value={pattern} onValueChange={(v) => setPattern(v as WorkflowPattern)}>
|
|
263
|
+
<SelectTrigger>
|
|
264
|
+
<SelectValue />
|
|
265
|
+
</SelectTrigger>
|
|
266
|
+
<SelectContent>
|
|
267
|
+
{PATTERN_OPTIONS.map((opt) => (
|
|
268
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
269
|
+
{opt.label}
|
|
270
|
+
</SelectItem>
|
|
271
|
+
))}
|
|
272
|
+
</SelectContent>
|
|
273
|
+
</Select>
|
|
274
|
+
<p className="text-xs text-muted-foreground">
|
|
275
|
+
{PATTERN_OPTIONS.find((o) => o.value === pattern)?.description}
|
|
276
|
+
</p>
|
|
277
|
+
</div>
|
|
278
|
+
<div className="space-y-1.5">
|
|
279
|
+
<Label>Project</Label>
|
|
280
|
+
<Select
|
|
281
|
+
value={selectedProjectId || "none"}
|
|
282
|
+
onValueChange={(v) => setSelectedProjectId(v === "none" ? "" : v)}
|
|
283
|
+
>
|
|
284
|
+
<SelectTrigger>
|
|
285
|
+
<SelectValue placeholder="None" />
|
|
286
|
+
</SelectTrigger>
|
|
287
|
+
<SelectContent>
|
|
288
|
+
<SelectItem value="none">None</SelectItem>
|
|
289
|
+
{projects.map((p) => (
|
|
290
|
+
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
|
291
|
+
))}
|
|
292
|
+
</SelectContent>
|
|
293
|
+
</Select>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
</FormSectionCard>
|
|
298
|
+
|
|
299
|
+
{/* Steps */}
|
|
300
|
+
<FormSectionCard icon={ListOrdered} title={`Steps (${steps.length})`}>
|
|
301
|
+
<div className="space-y-2">
|
|
302
|
+
{steps.map((step, i) => (
|
|
303
|
+
<div
|
|
304
|
+
key={i}
|
|
305
|
+
className="flex items-center gap-2 p-2.5 rounded-md border bg-card text-sm"
|
|
306
|
+
>
|
|
307
|
+
<span className="text-xs text-muted-foreground w-5 shrink-0">
|
|
308
|
+
{i + 1}.
|
|
309
|
+
</span>
|
|
310
|
+
{i === 0 && (
|
|
311
|
+
<Badge variant="outline" className="text-xs shrink-0 px-1">
|
|
312
|
+
<Star className="h-2.5 w-2.5" />
|
|
313
|
+
</Badge>
|
|
314
|
+
)}
|
|
315
|
+
<div className="flex-1 min-w-0">
|
|
316
|
+
<span className="font-medium block truncate">{step.title}</span>
|
|
317
|
+
{step.description && (
|
|
318
|
+
<span className="text-xs text-muted-foreground block truncate">
|
|
319
|
+
{step.description}
|
|
320
|
+
</span>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
{/* Profile selector */}
|
|
325
|
+
<Select
|
|
326
|
+
value={step.profile}
|
|
327
|
+
onValueChange={(v) => updateStepProfile(i, v)}
|
|
328
|
+
>
|
|
329
|
+
<SelectTrigger className="w-32 h-7 text-xs shrink-0">
|
|
330
|
+
<SelectValue />
|
|
331
|
+
</SelectTrigger>
|
|
332
|
+
<SelectContent>
|
|
333
|
+
<SelectItem value="auto">Auto</SelectItem>
|
|
334
|
+
{profiles.map((p) => (
|
|
335
|
+
<SelectItem key={p.id} value={p.id}>
|
|
336
|
+
{p.name}
|
|
337
|
+
</SelectItem>
|
|
338
|
+
))}
|
|
339
|
+
</SelectContent>
|
|
340
|
+
</Select>
|
|
341
|
+
|
|
342
|
+
{/* Reorder buttons */}
|
|
343
|
+
{!isReorderDisabled && (
|
|
344
|
+
<div className="flex flex-col gap-0.5">
|
|
345
|
+
<button
|
|
346
|
+
type="button"
|
|
347
|
+
onClick={() => moveStep(i, -1)}
|
|
348
|
+
disabled={i <= 1}
|
|
349
|
+
className="p-0.5 rounded hover:bg-muted disabled:opacity-30"
|
|
350
|
+
aria-label="Move step up"
|
|
351
|
+
>
|
|
352
|
+
<ArrowUp className="h-3 w-3" />
|
|
353
|
+
</button>
|
|
354
|
+
<button
|
|
355
|
+
type="button"
|
|
356
|
+
onClick={() => moveStep(i, 1)}
|
|
357
|
+
disabled={i === 0 || i === steps.length - 1}
|
|
358
|
+
className="p-0.5 rounded hover:bg-muted disabled:opacity-30"
|
|
359
|
+
aria-label="Move step down"
|
|
360
|
+
>
|
|
361
|
+
<ArrowDown className="h-3 w-3" />
|
|
362
|
+
</button>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
|
|
366
|
+
{/* Remove button */}
|
|
367
|
+
{i > 0 && steps.length > 2 && (
|
|
368
|
+
<button
|
|
369
|
+
type="button"
|
|
370
|
+
onClick={() => removeStep(i)}
|
|
371
|
+
className="p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
|
372
|
+
aria-label={`Remove step ${step.title}`}
|
|
373
|
+
>
|
|
374
|
+
<Trash2 className="h-3 w-3" />
|
|
375
|
+
</button>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
))}
|
|
379
|
+
</div>
|
|
380
|
+
</FormSectionCard>
|
|
381
|
+
|
|
382
|
+
{/* Pattern-specific config */}
|
|
383
|
+
{(pattern === "loop" || pattern === "swarm") && (
|
|
384
|
+
<FormSectionCard icon={Settings} title="Configuration">
|
|
385
|
+
{pattern === "loop" && (
|
|
386
|
+
<div className="space-y-1.5">
|
|
387
|
+
<Label htmlFor="max-iterations">Max Iterations</Label>
|
|
388
|
+
<Input
|
|
389
|
+
id="max-iterations"
|
|
390
|
+
type="number"
|
|
391
|
+
min={1}
|
|
392
|
+
max={50}
|
|
393
|
+
value={maxIterations}
|
|
394
|
+
onChange={(e) => setMaxIterations(parseInt(e.target.value, 10) || 5)}
|
|
395
|
+
className="w-32"
|
|
396
|
+
/>
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
{pattern === "swarm" && (
|
|
400
|
+
<div className="space-y-1.5">
|
|
401
|
+
<Label htmlFor="concurrency-limit">Worker Concurrency Limit</Label>
|
|
402
|
+
<Input
|
|
403
|
+
id="concurrency-limit"
|
|
404
|
+
type="number"
|
|
405
|
+
min={1}
|
|
406
|
+
max={5}
|
|
407
|
+
value={workerConcurrencyLimit}
|
|
408
|
+
onChange={(e) => setWorkerConcurrencyLimit(parseInt(e.target.value, 10) || 2)}
|
|
409
|
+
className="w-32"
|
|
410
|
+
/>
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground opacity-50 mt-2">
|
|
414
|
+
<input type="checkbox" disabled />
|
|
415
|
+
<span>Save as Blueprint (coming soon)</span>
|
|
416
|
+
</div>
|
|
417
|
+
</FormSectionCard>
|
|
418
|
+
)}
|
|
419
|
+
|
|
420
|
+
{/* Action bar */}
|
|
421
|
+
<div className="flex justify-end gap-2 pt-2">
|
|
422
|
+
<Button
|
|
423
|
+
variant="ghost"
|
|
424
|
+
onClick={navigateBackToTask}
|
|
425
|
+
disabled={submitting}
|
|
426
|
+
>
|
|
427
|
+
Dismiss
|
|
428
|
+
</Button>
|
|
429
|
+
<Button
|
|
430
|
+
variant="outline"
|
|
431
|
+
onClick={() => handleSubmit(false)}
|
|
432
|
+
disabled={submitting || !workflowName.trim() || steps.length < 2}
|
|
433
|
+
>
|
|
434
|
+
{submitting ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : null}
|
|
435
|
+
Accept
|
|
436
|
+
</Button>
|
|
437
|
+
<Button
|
|
438
|
+
onClick={() => handleSubmit(true)}
|
|
439
|
+
disabled={submitting || !workflowName.trim() || steps.length < 2}
|
|
440
|
+
>
|
|
441
|
+
{submitting ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : null}
|
|
442
|
+
Accept & Run
|
|
443
|
+
</Button>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { renderHook, act } from "@testing-library/react";
|
|
2
|
+
import { usePersistedState } from "@/hooks/use-persisted-state";
|
|
3
|
+
|
|
4
|
+
describe("usePersistedState", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
localStorage.clear();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns default value when localStorage is empty", () => {
|
|
10
|
+
const { result } = renderHook(() => usePersistedState("test-key", "default"));
|
|
11
|
+
expect(result.current[0]).toBe("default");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("reads stored value from localStorage on mount", () => {
|
|
15
|
+
localStorage.setItem("test-key", "stored-value");
|
|
16
|
+
const { result } = renderHook(() => usePersistedState("test-key", "default"));
|
|
17
|
+
// After effect runs, value should be the stored one
|
|
18
|
+
expect(result.current[0]).toBe("stored-value");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("writes to localStorage when setter is called", () => {
|
|
22
|
+
const { result } = renderHook(() => usePersistedState("test-key", "default"));
|
|
23
|
+
|
|
24
|
+
act(() => {
|
|
25
|
+
result.current[1]("new-value");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(result.current[0]).toBe("new-value");
|
|
29
|
+
expect(localStorage.getItem("test-key")).toBe("new-value");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("handles localStorage errors gracefully", () => {
|
|
33
|
+
const origGetItem = Storage.prototype.getItem;
|
|
34
|
+
Storage.prototype.getItem = () => {
|
|
35
|
+
throw new Error("Quota exceeded");
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const { result } = renderHook(() => usePersistedState("test-key", "fallback"));
|
|
39
|
+
expect(result.current[0]).toBe("fallback");
|
|
40
|
+
|
|
41
|
+
Storage.prototype.getItem = origGetItem;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("maintains independent values for different keys", () => {
|
|
45
|
+
const { result: hookA } = renderHook(() => usePersistedState("key-a", "a-default"));
|
|
46
|
+
const { result: hookB } = renderHook(() => usePersistedState("key-b", "b-default"));
|
|
47
|
+
|
|
48
|
+
act(() => {
|
|
49
|
+
hookA.current[1]("a-updated");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(hookA.current[0]).toBe("a-updated");
|
|
53
|
+
expect(hookB.current[0]).toBe("b-default");
|
|
54
|
+
expect(localStorage.getItem("key-a")).toBe("a-updated");
|
|
55
|
+
expect(localStorage.getItem("key-b")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* useState wrapper that persists to localStorage.
|
|
7
|
+
* Reads from storage in useEffect to avoid SSR hydration mismatch.
|
|
8
|
+
*/
|
|
9
|
+
export function usePersistedState<T extends string>(
|
|
10
|
+
key: string,
|
|
11
|
+
defaultValue: T
|
|
12
|
+
): [T, (value: T) => void] {
|
|
13
|
+
const [value, setValue] = useState<T>(defaultValue);
|
|
14
|
+
|
|
15
|
+
// Restore from localStorage on mount (client-only)
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
try {
|
|
18
|
+
const stored = localStorage.getItem(key);
|
|
19
|
+
if (stored !== null) {
|
|
20
|
+
setValue(stored as T);
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// localStorage unavailable (e.g. private browsing)
|
|
24
|
+
}
|
|
25
|
+
}, [key]);
|
|
26
|
+
|
|
27
|
+
const setPersisted = useCallback(
|
|
28
|
+
(next: T) => {
|
|
29
|
+
setValue(next);
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(key, next);
|
|
32
|
+
} catch {
|
|
33
|
+
// localStorage unavailable
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
[key]
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return [value, setPersisted];
|
|
40
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { db } from "@/lib/db";
|
|
3
4
|
import { tasks, projects, agentLogs, notifications } from "@/lib/db/schema";
|
|
4
5
|
import { eq } from "drizzle-orm";
|
|
@@ -49,11 +50,13 @@ interface TaskUsageState extends UsageSnapshot {
|
|
|
49
50
|
scheduleId?: string | null;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
behavior: "allow"
|
|
54
|
-
updatedInput
|
|
55
|
-
message
|
|
56
|
-
}
|
|
53
|
+
const toolPermissionResponseSchema = z.object({
|
|
54
|
+
behavior: z.enum(["allow", "deny"]),
|
|
55
|
+
updatedInput: z.unknown().optional(),
|
|
56
|
+
message: z.string().optional(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
type ToolPermissionResponse = z.infer<typeof toolPermissionResponseSchema>;
|
|
57
60
|
|
|
58
61
|
const inFlightPermissionRequests = new Map<
|
|
59
62
|
string,
|
|
@@ -149,8 +152,15 @@ async function waitForToolPermissionResponse(
|
|
|
149
152
|
|
|
150
153
|
if (notification?.response) {
|
|
151
154
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
const parsed = JSON.parse(notification.response);
|
|
156
|
+
const validated = toolPermissionResponseSchema.safeParse(parsed);
|
|
157
|
+
if (validated.success) {
|
|
158
|
+
return validated.data;
|
|
159
|
+
}
|
|
160
|
+
console.error("[claude-agent] Invalid permission response shape:", validated.error.message);
|
|
161
|
+
return { behavior: "deny", message: "Invalid response format" };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error("[claude-agent] Failed to parse permission response:", err);
|
|
154
164
|
return { behavior: "deny", message: "Invalid response format" };
|
|
155
165
|
}
|
|
156
166
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { suggestProfileForStep } from "../suggest";
|
|
3
|
+
|
|
4
|
+
const ALL_PROFILES = [
|
|
5
|
+
"general",
|
|
6
|
+
"researcher",
|
|
7
|
+
"code-reviewer",
|
|
8
|
+
"document-writer",
|
|
9
|
+
"devops-engineer",
|
|
10
|
+
"data-analyst",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
describe("suggestProfileForStep", () => {
|
|
14
|
+
it("suggests researcher for research-related tasks", () => {
|
|
15
|
+
expect(
|
|
16
|
+
suggestProfileForStep("Research API patterns", "Investigate best practices", ALL_PROFILES)
|
|
17
|
+
).toBe("researcher");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("suggests code-reviewer for review tasks", () => {
|
|
21
|
+
expect(
|
|
22
|
+
suggestProfileForStep("Security audit", "Review code for vulnerabilities", ALL_PROFILES)
|
|
23
|
+
).toBe("code-reviewer");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("suggests document-writer for writing tasks", () => {
|
|
27
|
+
expect(
|
|
28
|
+
suggestProfileForStep("Write documentation", "Document the API endpoints", ALL_PROFILES)
|
|
29
|
+
).toBe("document-writer");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("suggests devops-engineer for deployment tasks", () => {
|
|
33
|
+
expect(
|
|
34
|
+
suggestProfileForStep("Deploy to production", "Set up CI pipeline and infrastructure", ALL_PROFILES)
|
|
35
|
+
).toBe("devops-engineer");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("suggests data-analyst for data tasks", () => {
|
|
39
|
+
expect(
|
|
40
|
+
suggestProfileForStep("Analyze metrics", "Aggregate data and create statistics", ALL_PROFILES)
|
|
41
|
+
).toBe("data-analyst");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns "auto" when no keywords match', () => {
|
|
45
|
+
expect(
|
|
46
|
+
suggestProfileForStep("Fix the thing", "Make it work", ALL_PROFILES)
|
|
47
|
+
).toBe("auto");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("only suggests from available profiles", () => {
|
|
51
|
+
expect(
|
|
52
|
+
suggestProfileForStep("Research API patterns", "Investigate", ["general"])
|
|
53
|
+
).toBe("auto");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("picks highest-scoring profile when multiple match", () => {
|
|
57
|
+
// "review" + "security" + "vulnerability" = 3 hits for code-reviewer
|
|
58
|
+
// "investigate" = 1 hit for researcher
|
|
59
|
+
expect(
|
|
60
|
+
suggestProfileForStep(
|
|
61
|
+
"Security review",
|
|
62
|
+
"Investigate vulnerability audit",
|
|
63
|
+
ALL_PROFILES
|
|
64
|
+
)
|
|
65
|
+
).toBe("code-reviewer");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight keyword-based profile suggestion.
|
|
3
|
+
* Used as fallback when AI doesn't suggest a profile for a step.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const KEYWORD_MAP: Record<string, string[]> = {
|
|
7
|
+
researcher: ["research", "search", "investigate", "explore", "find", "discover", "analyze data", "survey"],
|
|
8
|
+
"code-reviewer": ["review", "audit", "security", "vulnerability", "lint", "inspect", "code quality"],
|
|
9
|
+
"document-writer": ["write", "document", "report", "summarize", "draft", "compose", "blog", "article"],
|
|
10
|
+
"devops-engineer": ["deploy", "ci", "infrastructure", "pipeline", "docker", "kubernetes", "terraform", "monitor"],
|
|
11
|
+
"data-analyst": ["analyze", "data", "statistics", "metrics", "chart", "visualization", "dashboard", "aggregate"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function suggestProfileForStep(
|
|
15
|
+
title: string,
|
|
16
|
+
description: string,
|
|
17
|
+
availableProfileIds: string[]
|
|
18
|
+
): string {
|
|
19
|
+
const text = `${title} ${description}`.toLowerCase();
|
|
20
|
+
let bestProfile = "auto";
|
|
21
|
+
let bestScore = 0;
|
|
22
|
+
|
|
23
|
+
for (const [profileId, keywords] of Object.entries(KEYWORD_MAP)) {
|
|
24
|
+
if (!availableProfileIds.includes(profileId)) continue;
|
|
25
|
+
let score = 0;
|
|
26
|
+
for (const keyword of keywords) {
|
|
27
|
+
if (text.includes(keyword)) score++;
|
|
28
|
+
}
|
|
29
|
+
if (score > bestScore) {
|
|
30
|
+
bestScore = score;
|
|
31
|
+
bestProfile = profileId;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return bestScore >= 1 ? bestProfile : "auto";
|
|
36
|
+
}
|