stagent 0.1.9 → 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/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/workflows/from-assist/route.ts +143 -0
- package/src/app/dashboard/page.tsx +24 -2
- package/src/app/workflows/from-assist/page.tsx +35 -0
- package/src/components/projects/project-card.tsx +47 -35
- package/src/components/tasks/ai-assist-panel.tsx +31 -10
- package/src/components/tasks/task-card.tsx +16 -1
- package/src/components/tasks/task-create-panel.tsx +39 -0
- package/src/components/workflows/workflow-confirmation-view.tsx +447 -0
- 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.ts +36 -6
- 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/notifications/permissions.ts +6 -2
- 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 +46 -1
|
@@ -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,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
|
+
}
|
|
@@ -4,7 +4,7 @@ import { tasks } from "@/lib/db/schema";
|
|
|
4
4
|
import { eq } from "drizzle-orm";
|
|
5
5
|
import { updateAuthStatus, getAuthEnv } from "@/lib/settings/auth";
|
|
6
6
|
import { getExecution, removeExecution } from "@/lib/agents/execution-manager";
|
|
7
|
-
import { getProfile } from "@/lib/agents/profiles/registry";
|
|
7
|
+
import { getProfile, listProfiles } from "@/lib/agents/profiles/registry";
|
|
8
8
|
import { resolveProfileRuntimePayload } from "@/lib/agents/profiles/compatibility";
|
|
9
9
|
import { executeClaudeTask, resumeClaudeTask } from "@/lib/agents/claude-agent";
|
|
10
10
|
import { getRuntimeCapabilities, getRuntimeCatalogEntry } from "./catalog";
|
|
@@ -23,13 +23,41 @@ import {
|
|
|
23
23
|
type UsageSnapshot,
|
|
24
24
|
} from "@/lib/usage/ledger";
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
function buildTaskAssistSystemPrompt(profileIds: string[]): string {
|
|
27
|
+
const profileList = profileIds.length > 0
|
|
28
|
+
? `Available agent profiles: ${profileIds.join(", ")}\nUse "auto" if unsure which profile fits a step.`
|
|
29
|
+
: `No explicit profiles available. Use "auto" for suggestedProfile.`;
|
|
30
|
+
|
|
31
|
+
return `You are an AI task definition assistant. Analyze the given task and return ONLY a JSON object (no markdown, no code fences) with:
|
|
27
32
|
- "improvedDescription": A clearer version of the task for an AI agent to execute
|
|
28
|
-
- "breakdown": Array of
|
|
29
|
-
- "
|
|
33
|
+
- "breakdown": Array of step objects if complex (empty array if simple). Each step: {title, description, suggestedProfile?, requiresApproval?, dependsOn?}
|
|
34
|
+
- "suggestedProfile": one of the available profile IDs or "auto"
|
|
35
|
+
- "requiresApproval": true if the step involves irreversible actions needing human review
|
|
36
|
+
- "dependsOn": array of step indices (0-based) this step depends on (for parallel/swarm patterns)
|
|
37
|
+
- "recommendedPattern": one of "single", "sequence", "planner-executor", "checkpoint", "parallel", "loop", "swarm"
|
|
38
|
+
- "sequence": steps run one after another in order
|
|
39
|
+
- "planner-executor": first step plans, remaining steps execute the plan
|
|
40
|
+
- "checkpoint": like sequence but certain steps pause for human approval
|
|
41
|
+
- "parallel": independent steps run concurrently, a final synthesis step merges results (use dependsOn to mark the synthesis step)
|
|
42
|
+
- "loop": a single step repeats iteratively until a goal is met (include suggestedLoopConfig)
|
|
43
|
+
- "swarm": first step is the mayor (coordinator), middle steps are workers (run in parallel), last step is the refinery (merges results)
|
|
30
44
|
- "complexity": "simple", "moderate", or "complex"
|
|
31
45
|
- "needsCheckpoint": true if irreversible actions or needs human review
|
|
32
|
-
- "reasoning": Brief explanation
|
|
46
|
+
- "reasoning": Brief explanation of why you chose this pattern
|
|
47
|
+
- "suggestedLoopConfig": {maxIterations, timeBudgetMs?} — only for loop pattern
|
|
48
|
+
- "suggestedSwarmConfig": {workerConcurrencyLimit?} — only for swarm pattern
|
|
49
|
+
|
|
50
|
+
${profileList}
|
|
51
|
+
|
|
52
|
+
Pattern selection guide:
|
|
53
|
+
- Use "single" for simple, atomic tasks
|
|
54
|
+
- Use "sequence" for ordered multi-step work where each step builds on the previous
|
|
55
|
+
- Use "planner-executor" when the task needs analysis before action
|
|
56
|
+
- Use "checkpoint" when steps involve deployments, deletions, or other irreversible actions
|
|
57
|
+
- Use "parallel" when sub-tasks are independent and can run concurrently (research, analysis)
|
|
58
|
+
- Use "loop" for iterative refinement (code review cycles, optimization passes)
|
|
59
|
+
- Use "swarm" for complex tasks needing multiple specialized agents coordinated by a lead`;
|
|
60
|
+
}
|
|
33
61
|
|
|
34
62
|
async function collectResultText(
|
|
35
63
|
response: AsyncIterable<Record<string, unknown>>
|
|
@@ -235,7 +263,9 @@ async function runClaudeTaskAssist(
|
|
|
235
263
|
.join("\n");
|
|
236
264
|
|
|
237
265
|
const authEnv = await getAuthEnv();
|
|
238
|
-
const
|
|
266
|
+
const profileIds = listProfiles().map((p) => p.id);
|
|
267
|
+
const systemPrompt = buildTaskAssistSystemPrompt(profileIds);
|
|
268
|
+
const prompt = `${systemPrompt}\n\n${userMessage}`;
|
|
239
269
|
const startedAt = new Date();
|
|
240
270
|
let usage: UsageSnapshot = {};
|
|
241
271
|
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
export interface TaskAssistBreakdownStep {
|
|
2
|
+
title: string;
|
|
3
|
+
description: string;
|
|
4
|
+
suggestedProfile?: string;
|
|
5
|
+
requiresApproval?: boolean;
|
|
6
|
+
dependsOn?: number[];
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
export interface TaskAssistResponse {
|
|
2
10
|
improvedDescription: string;
|
|
3
|
-
breakdown:
|
|
4
|
-
recommendedPattern: "single" | "sequence" | "planner-executor" | "checkpoint";
|
|
11
|
+
breakdown: TaskAssistBreakdownStep[];
|
|
12
|
+
recommendedPattern: "single" | "sequence" | "planner-executor" | "checkpoint" | "parallel" | "loop" | "swarm";
|
|
5
13
|
complexity: "simple" | "moderate" | "complex";
|
|
6
14
|
needsCheckpoint: boolean;
|
|
7
15
|
reasoning: string;
|
|
16
|
+
suggestedLoopConfig?: { maxIterations: number; timeBudgetMs?: number };
|
|
17
|
+
suggestedSwarmConfig?: { workerConcurrencyLimit?: number };
|
|
8
18
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import * as schema from "@/lib/db/schema";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Safety-net test: every table exported from schema.ts must appear in clear.ts
|
|
8
|
+
* (except `settings`, which is intentionally preserved across clears).
|
|
9
|
+
*
|
|
10
|
+
* When you add a new table to schema.ts, this test will fail until you add a
|
|
11
|
+
* corresponding db.delete() call to clear.ts in the correct FK-safe order.
|
|
12
|
+
*/
|
|
13
|
+
describe("clearAllData coverage", () => {
|
|
14
|
+
const INTENTIONALLY_PRESERVED = ["settings"];
|
|
15
|
+
|
|
16
|
+
it("deletes every schema table (except settings)", () => {
|
|
17
|
+
const clearSource = readFileSync(
|
|
18
|
+
join(__dirname, "..", "clear.ts"),
|
|
19
|
+
"utf-8"
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Collect all sqliteTable exports from schema
|
|
23
|
+
const tableExports = Object.entries(schema)
|
|
24
|
+
.filter(
|
|
25
|
+
([, value]) =>
|
|
26
|
+
value != null &&
|
|
27
|
+
typeof value === "object" &&
|
|
28
|
+
"getSQL" in (value as Record<string, unknown>)
|
|
29
|
+
)
|
|
30
|
+
.map(([name]) => name);
|
|
31
|
+
|
|
32
|
+
expect(tableExports.length).toBeGreaterThan(0);
|
|
33
|
+
|
|
34
|
+
const missing = tableExports.filter(
|
|
35
|
+
(name) =>
|
|
36
|
+
!INTENTIONALLY_PRESERVED.includes(name) &&
|
|
37
|
+
!clearSource.includes(`db.delete(${name})`)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(missing, `Tables missing from clear.ts: ${missing.join(", ")}`).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|