@saadnvd1/agent-os 0.3.533 → 0.3.534
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 +68 -2
- package/app/api/features/pieces/[id]/release/route.ts +16 -0
- package/app/api/features/route.ts +8 -0
- package/app/api/recipes/[id]/carry/clear/route.ts +20 -0
- package/app/api/recipes/[id]/preview/route.ts +19 -0
- package/app/api/recipes/[id]/route.ts +51 -0
- package/app/api/recipes/[id]/run/route.ts +35 -0
- package/app/api/recipes/[id]/runs/route.ts +23 -0
- package/app/api/recipes/draft/route.ts +14 -0
- package/app/api/recipes/route.ts +35 -0
- package/app/api/tasks/[id]/plan-edits/route.ts +34 -0
- package/components/CommandPalette/useAppCommands.ts +20 -0
- package/components/Features/FeaturesSection.tsx +100 -0
- package/components/Features/index.ts +1 -0
- package/components/Recipes/RecipeForm.tsx +251 -0
- package/components/Recipes/RecipePage.tsx +127 -0
- package/components/Recipes/RecipesPanel.tsx +225 -0
- package/components/Recipes/RunChip.tsx +16 -0
- package/components/Recipes/RunHistory.tsx +88 -0
- package/components/Recipes/RunSheet.tsx +84 -0
- package/components/Recipes/TimerFields.tsx +193 -0
- package/components/Recipes/format.ts +51 -0
- package/components/Recipes/index.ts +1 -0
- package/components/Schedules/SchedulesPanel.tsx +1 -0
- package/components/SessionList/RowSubtitle.tsx +16 -0
- package/components/SessionList/SessionRowMenu.tsx +29 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Tasks/PlanReview.tsx +253 -0
- package/components/Tasks/TaskRow.tsx +19 -44
- package/components/Tasks/TasksDialog.tsx +2 -0
- package/data/features/index.ts +40 -0
- package/data/push/topics.ts +8 -0
- package/data/recipes/index.ts +129 -0
- package/data/tasks/index.ts +14 -0
- package/lib/away/run.ts +4 -1
- package/lib/db/changes.ts +8 -0
- package/lib/db/migrations.ts +127 -0
- package/lib/db/queries.ts +4 -1
- package/lib/db/types.ts +6 -0
- package/lib/models/plan-step.ts +67 -6
- package/lib/notify/copy.ts +28 -10
- package/lib/notify/push/events.ts +36 -2
- package/lib/notify/push/policy.ts +2 -0
- package/lib/notify/push-types.ts +7 -3
- package/lib/orchestrator/brief.ts +13 -0
- package/lib/orchestrator/conditions.ts +3 -1
- package/lib/orchestrator/deliver.ts +2 -0
- package/lib/orchestrator/digest.ts +27 -1
- package/lib/orchestrator/plan-pieces.ts +41 -0
- package/lib/orchestrator/recipe-tools.ts +159 -0
- package/lib/orchestrator/review.ts +2 -1
- package/lib/orchestrator/serve.ts +25 -0
- package/lib/orchestrator/tool-names.ts +5 -0
- package/lib/orchestrator/tool-schemas.ts +54 -0
- package/lib/orchestrator/tools.ts +10 -0
- package/lib/plans/features.ts +200 -0
- package/lib/plans/pieces.ts +366 -0
- package/lib/plans/release.ts +323 -0
- package/lib/plans/view.ts +85 -0
- package/lib/recipes/api.ts +19 -0
- package/lib/recipes/chip.ts +40 -0
- package/lib/recipes/compose.ts +78 -0
- package/lib/recipes/draft.ts +93 -0
- package/lib/recipes/index.ts +54 -0
- package/lib/recipes/result.ts +59 -0
- package/lib/recipes/run.ts +202 -0
- package/lib/recipes/settle.ts +465 -0
- package/lib/recipes/store.ts +378 -0
- package/lib/recipes/test-fixtures.ts +49 -0
- package/lib/recipes/timer.ts +107 -0
- package/lib/recipes/types.ts +116 -0
- package/lib/schedules/index.ts +4 -1
- package/lib/schedules/start.ts +33 -9
- package/lib/schedules/store.ts +6 -2
- package/lib/security/demo.ts +1 -0
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/loop-types.ts +23 -0
- package/lib/tasks/plan-view.ts +38 -1
- package/package.json +1 -1
- package/server.ts +2 -0
- package/stores/recipesUi.ts +53 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
6
|
+
import { Markdown } from "@/components/Chat/Markdown";
|
|
7
|
+
import { useApprovePlan, useDropTask, usePlanEdits } from "@/data/tasks";
|
|
8
|
+
import type { PlanPieceView } from "@/lib/tasks/loop-types";
|
|
9
|
+
import type { TaskView } from "@/lib/tasks";
|
|
10
|
+
import { cn } from "@/lib/utils";
|
|
11
|
+
|
|
12
|
+
const CIRCLED = ["①", "②", "③", "④", "⑤", "⑥"];
|
|
13
|
+
const REASON: Record<string, string> = {
|
|
14
|
+
a: "spans repositories",
|
|
15
|
+
b: "must be live before the other can merge",
|
|
16
|
+
c: "needs you mid-way",
|
|
17
|
+
d: "must be revertible on its own",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function Piece({
|
|
21
|
+
p,
|
|
22
|
+
edits,
|
|
23
|
+
busy,
|
|
24
|
+
onEdit,
|
|
25
|
+
}: {
|
|
26
|
+
p: PlanPieceView;
|
|
27
|
+
edits: { merge: number[]; drop: number[] };
|
|
28
|
+
busy: boolean;
|
|
29
|
+
onEdit: (merge: number[], drop: number[]) => void;
|
|
30
|
+
}) {
|
|
31
|
+
const mark = CIRCLED[p.position - 1];
|
|
32
|
+
const off = p.edit !== null;
|
|
33
|
+
const without = (list: number[]) => list.filter((n) => n !== p.position);
|
|
34
|
+
return (
|
|
35
|
+
<li className={cn("space-y-1 px-3 py-2.5", off && "opacity-60")}>
|
|
36
|
+
<div className="flex items-baseline gap-2">
|
|
37
|
+
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
|
38
|
+
{mark} {p.name}
|
|
39
|
+
</span>
|
|
40
|
+
<span className="text-muted-foreground shrink-0 text-xs">
|
|
41
|
+
{p.project}
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
<p className="text-muted-foreground text-xs">{p.slice.split("\n")[0]}</p>
|
|
45
|
+
{p.afterPosition && (
|
|
46
|
+
<p className="text-xs">
|
|
47
|
+
after {CIRCLED[p.afterPosition - 1]} is {p.afterKind}
|
|
48
|
+
</p>
|
|
49
|
+
)}
|
|
50
|
+
{p.reasons.length > 0 && (
|
|
51
|
+
<p className="text-xs">
|
|
52
|
+
{p.reasons.map((r) => `(${r}) ${REASON[r]}`).join("; ")}
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
{p.owns.length > 0 && (
|
|
56
|
+
<p className="text-muted-foreground text-xs">
|
|
57
|
+
owns: {p.owns.join(", ")}
|
|
58
|
+
</p>
|
|
59
|
+
)}
|
|
60
|
+
{p.position > 1 && (
|
|
61
|
+
<div className="flex flex-wrap items-center gap-2 pt-1">
|
|
62
|
+
{off ? (
|
|
63
|
+
<>
|
|
64
|
+
<span className="bg-foreground/10 rounded-full px-2 py-0.5 text-[11px]">
|
|
65
|
+
{p.edit === "merge"
|
|
66
|
+
? `Merged into ${CIRCLED[p.position - 2]}`
|
|
67
|
+
: "Dropped"}
|
|
68
|
+
</span>
|
|
69
|
+
<Button
|
|
70
|
+
size="sm"
|
|
71
|
+
variant="ghost"
|
|
72
|
+
className="h-11 md:h-8"
|
|
73
|
+
disabled={busy}
|
|
74
|
+
onClick={() =>
|
|
75
|
+
onEdit(without(edits.merge), without(edits.drop))
|
|
76
|
+
}
|
|
77
|
+
>
|
|
78
|
+
Undo
|
|
79
|
+
</Button>
|
|
80
|
+
</>
|
|
81
|
+
) : (
|
|
82
|
+
<>
|
|
83
|
+
<Button
|
|
84
|
+
size="sm"
|
|
85
|
+
variant="outline"
|
|
86
|
+
className="h-11 md:h-8"
|
|
87
|
+
disabled={busy || p.forced}
|
|
88
|
+
title={
|
|
89
|
+
p.forced ? "Split because it spans repositories" : undefined
|
|
90
|
+
}
|
|
91
|
+
onClick={() => onEdit([...edits.merge, p.position], edits.drop)}
|
|
92
|
+
>
|
|
93
|
+
Merge into {CIRCLED[p.position - 2]}
|
|
94
|
+
</Button>
|
|
95
|
+
<Button
|
|
96
|
+
size="sm"
|
|
97
|
+
variant="ghost"
|
|
98
|
+
className="h-11 md:h-8"
|
|
99
|
+
disabled={busy}
|
|
100
|
+
onClick={() => onEdit(edits.merge, [...edits.drop, p.position])}
|
|
101
|
+
>
|
|
102
|
+
Drop
|
|
103
|
+
</Button>
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</li>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A plan waiting for a decision: how it splits, the plan itself, and
|
|
113
|
+
// Reject, Approve with a note, Approve.
|
|
114
|
+
export function PlanReview({ task }: { task: TaskView }) {
|
|
115
|
+
const approve = useApprovePlan();
|
|
116
|
+
const drop = useDropTask();
|
|
117
|
+
const planEdits = usePlanEdits();
|
|
118
|
+
const [showPlan, setShowPlan] = useState(false);
|
|
119
|
+
const [noting, setNoting] = useState(false);
|
|
120
|
+
const [note, setNote] = useState("");
|
|
121
|
+
const [rejecting, setRejecting] = useState(false);
|
|
122
|
+
const pieces = task.plan?.pieces;
|
|
123
|
+
const edits = {
|
|
124
|
+
merge:
|
|
125
|
+
pieces?.pieces.filter((p) => p.edit === "merge").map((p) => p.position) ??
|
|
126
|
+
[],
|
|
127
|
+
drop:
|
|
128
|
+
pieces?.pieces.filter((p) => p.edit === "drop").map((p) => p.position) ??
|
|
129
|
+
[],
|
|
130
|
+
};
|
|
131
|
+
const error =
|
|
132
|
+
approve.error?.message || drop.error?.message || planEdits.error?.message;
|
|
133
|
+
return (
|
|
134
|
+
<div className="space-y-3">
|
|
135
|
+
{pieces && pieces.pieces.length > 1 ? (
|
|
136
|
+
<div className="space-y-1">
|
|
137
|
+
<p className="label-mono text-muted-foreground">
|
|
138
|
+
Pieces ({pieces.pieces.length})
|
|
139
|
+
</p>
|
|
140
|
+
<ul className="divide-border/60 border-border/60 divide-y rounded-lg border">
|
|
141
|
+
{pieces.pieces.map((p) => (
|
|
142
|
+
<Piece
|
|
143
|
+
key={p.position}
|
|
144
|
+
p={p}
|
|
145
|
+
edits={edits}
|
|
146
|
+
busy={planEdits.isPending}
|
|
147
|
+
onEdit={(merge, dropped) =>
|
|
148
|
+
planEdits.mutate({ id: task.id, merge, drop: dropped })
|
|
149
|
+
}
|
|
150
|
+
/>
|
|
151
|
+
))}
|
|
152
|
+
</ul>
|
|
153
|
+
</div>
|
|
154
|
+
) : (
|
|
155
|
+
<p className="text-muted-foreground text-xs">1 piece</p>
|
|
156
|
+
)}
|
|
157
|
+
{pieces?.folded.map((f) => (
|
|
158
|
+
<p key={f.name} className="text-xs text-amber-700 dark:text-amber-300">
|
|
159
|
+
"{f.name}" folded into {CIRCLED[f.into - 1]}: {f.why}
|
|
160
|
+
</p>
|
|
161
|
+
))}
|
|
162
|
+
{pieces?.warnings.map((w) => (
|
|
163
|
+
<p key={w} className="text-xs text-amber-700 dark:text-amber-300">
|
|
164
|
+
{w}
|
|
165
|
+
</p>
|
|
166
|
+
))}
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
aria-expanded={showPlan}
|
|
170
|
+
className="text-primary min-h-11 text-xs font-medium md:min-h-8"
|
|
171
|
+
onClick={() => setShowPlan((v) => !v)}
|
|
172
|
+
>
|
|
173
|
+
{showPlan ? "Hide the plan" : "Show the plan"}
|
|
174
|
+
</button>
|
|
175
|
+
{showPlan && (
|
|
176
|
+
<div className="bg-foreground/[0.04] max-h-80 overflow-y-auto rounded-lg p-3 text-sm">
|
|
177
|
+
{task.plan?.text ? (
|
|
178
|
+
<Markdown text={task.plan.text} />
|
|
179
|
+
) : (
|
|
180
|
+
<p className="text-muted-foreground text-xs">
|
|
181
|
+
No plan file was written.
|
|
182
|
+
</p>
|
|
183
|
+
)}
|
|
184
|
+
{task.plan?.truncated && (
|
|
185
|
+
<p className="text-muted-foreground mt-2 font-mono text-[11px]">
|
|
186
|
+
Shortened; the whole plan is {task.plan.path} in the worktree.
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
{noting && (
|
|
192
|
+
<div className="space-y-2">
|
|
193
|
+
<Textarea
|
|
194
|
+
rows={3}
|
|
195
|
+
value={note}
|
|
196
|
+
placeholder="A change to the plan (recorded as a scope change)"
|
|
197
|
+
onChange={(e) => setNote(e.target.value)}
|
|
198
|
+
/>
|
|
199
|
+
<Button
|
|
200
|
+
size="sm"
|
|
201
|
+
className="h-11 md:h-8"
|
|
202
|
+
disabled={approve.isPending || !note.trim()}
|
|
203
|
+
onClick={() =>
|
|
204
|
+
approve.mutate({ id: task.id, message: note.trim() })
|
|
205
|
+
}
|
|
206
|
+
>
|
|
207
|
+
Approve with this note
|
|
208
|
+
</Button>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
<div className="flex flex-wrap gap-2">
|
|
212
|
+
{rejecting ? (
|
|
213
|
+
<Button
|
|
214
|
+
size="sm"
|
|
215
|
+
variant="destructive"
|
|
216
|
+
className="h-11 md:h-8"
|
|
217
|
+
disabled={drop.isPending}
|
|
218
|
+
onClick={() => drop.mutate(task.id)}
|
|
219
|
+
>
|
|
220
|
+
Drop this plan
|
|
221
|
+
</Button>
|
|
222
|
+
) : (
|
|
223
|
+
<Button
|
|
224
|
+
size="sm"
|
|
225
|
+
variant="ghost"
|
|
226
|
+
className="h-11 md:h-8"
|
|
227
|
+
onClick={() => setRejecting(true)}
|
|
228
|
+
>
|
|
229
|
+
Reject
|
|
230
|
+
</Button>
|
|
231
|
+
)}
|
|
232
|
+
<Button
|
|
233
|
+
size="sm"
|
|
234
|
+
variant="outline"
|
|
235
|
+
className="h-11 md:h-8"
|
|
236
|
+
aria-expanded={noting}
|
|
237
|
+
onClick={() => setNoting((v) => !v)}
|
|
238
|
+
>
|
|
239
|
+
Approve with note…
|
|
240
|
+
</Button>
|
|
241
|
+
<Button
|
|
242
|
+
size="sm"
|
|
243
|
+
className="h-11 md:h-8"
|
|
244
|
+
disabled={approve.isPending}
|
|
245
|
+
onClick={() => approve.mutate({ id: task.id })}
|
|
246
|
+
>
|
|
247
|
+
{approve.isPending ? "Approving..." : "Approve"}
|
|
248
|
+
</Button>
|
|
249
|
+
</div>
|
|
250
|
+
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import {
|
|
5
|
+
ChefHat,
|
|
5
6
|
ExternalLink,
|
|
6
7
|
GitMerge,
|
|
7
8
|
KanbanSquare,
|
|
@@ -10,17 +11,13 @@ import {
|
|
|
10
11
|
import { Button } from "@/components/ui/button";
|
|
11
12
|
import type { TaskView } from "@/lib/tasks";
|
|
12
13
|
import { isFinished } from "@/lib/tasks/state";
|
|
13
|
-
import {
|
|
14
|
-
useApprovePlan,
|
|
15
|
-
useArmTask,
|
|
16
|
-
useDropTask,
|
|
17
|
-
useSignOffTask,
|
|
18
|
-
} from "@/data/tasks";
|
|
19
|
-
import { Markdown } from "@/components/Chat/Markdown";
|
|
14
|
+
import { useArmTask, useDropTask, useSignOffTask } from "@/data/tasks";
|
|
20
15
|
import { tmuxAttachActions } from "@/stores/tmuxAttach";
|
|
21
16
|
import { tasksUiActions } from "@/stores/tasksUi";
|
|
17
|
+
import { recipesUiActions } from "@/stores/recipesUi";
|
|
22
18
|
import { cn } from "@/lib/utils";
|
|
23
19
|
import { SimulatorIndicator } from "@/components/Simulator";
|
|
20
|
+
import { PlanReview } from "./PlanReview";
|
|
24
21
|
import { MoveTaskButton } from "./MoveTaskButton";
|
|
25
22
|
import { ResumeOnModel } from "./ResumeOnModel";
|
|
26
23
|
import { TASK_STATE as STATE } from "./task-state-labels";
|
|
@@ -30,8 +27,6 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
30
27
|
const signOff = useSignOffTask();
|
|
31
28
|
const drop = useDropTask();
|
|
32
29
|
const arm = useArmTask();
|
|
33
|
-
const approve = useApprovePlan();
|
|
34
|
-
const [showPlan, setShowPlan] = useState(false);
|
|
35
30
|
const [confirmDrop, setConfirmDrop] = useState(false);
|
|
36
31
|
const [moveError, setMoveError] = useState<string | null>(null);
|
|
37
32
|
const live = !isFinished(task.state);
|
|
@@ -39,7 +34,6 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
39
34
|
signOff.error?.message ||
|
|
40
35
|
drop.error?.message ||
|
|
41
36
|
arm.error?.message ||
|
|
42
|
-
approve.error?.message ||
|
|
43
37
|
moveError ||
|
|
44
38
|
task.hostError;
|
|
45
39
|
const settingUp = live && task.setup?.status === "running";
|
|
@@ -159,26 +153,6 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
159
153
|
{task.armed ? "Armed" : "Arm merge"}
|
|
160
154
|
</Button>
|
|
161
155
|
)}
|
|
162
|
-
{task.state === "plan-ready" && (
|
|
163
|
-
<>
|
|
164
|
-
<Button
|
|
165
|
-
size="sm"
|
|
166
|
-
variant="outline"
|
|
167
|
-
aria-expanded={showPlan}
|
|
168
|
-
onClick={() => setShowPlan((v) => !v)}
|
|
169
|
-
>
|
|
170
|
-
{showPlan ? "Hide plan" : "View plan"}
|
|
171
|
-
</Button>
|
|
172
|
-
<Button
|
|
173
|
-
size="sm"
|
|
174
|
-
className="h-11 md:h-8"
|
|
175
|
-
disabled={approve.isPending}
|
|
176
|
-
onClick={() => approve.mutate({ id: task.id })}
|
|
177
|
-
>
|
|
178
|
-
{approve.isPending ? "Approving..." : "Approve"}
|
|
179
|
-
</Button>
|
|
180
|
-
</>
|
|
181
|
-
)}
|
|
182
156
|
{task.state === "review" && (
|
|
183
157
|
<Button
|
|
184
158
|
size="sm"
|
|
@@ -190,20 +164,21 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
190
164
|
)}
|
|
191
165
|
</div>
|
|
192
166
|
)}
|
|
193
|
-
{live && task.state === "plan-ready" &&
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
167
|
+
{live && task.state === "plan-ready" && <PlanReview task={task} />}
|
|
168
|
+
{!live && !task.hostId && (
|
|
169
|
+
<div>
|
|
170
|
+
<Button
|
|
171
|
+
size="sm"
|
|
172
|
+
variant="ghost"
|
|
173
|
+
className="text-muted-foreground h-11 md:h-8"
|
|
174
|
+
onClick={() => {
|
|
175
|
+
recipesUiActions.saveSessionAs(task.id);
|
|
176
|
+
tasksUiActions.setPanelOpen(false);
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<ChefHat className="h-3.5 w-3.5" />
|
|
180
|
+
Save as recipe
|
|
181
|
+
</Button>
|
|
207
182
|
</div>
|
|
208
183
|
)}
|
|
209
184
|
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
@@ -16,6 +16,7 @@ import { newDraft } from "@/stores/drafts";
|
|
|
16
16
|
import { TaskRow } from "./TaskRow";
|
|
17
17
|
import { BoardTodoSection } from "@/components/LumifyHub";
|
|
18
18
|
import { StacksSection } from "@/components/Stacks";
|
|
19
|
+
import { FeaturesSection } from "@/components/Features";
|
|
19
20
|
|
|
20
21
|
export function TasksDialog() {
|
|
21
22
|
const { panelOpen } = useSnapshot(tasksUi);
|
|
@@ -57,6 +58,7 @@ export function TasksDialog() {
|
|
|
57
58
|
<TaskRow key={t.id} task={t} />
|
|
58
59
|
))}
|
|
59
60
|
</div>
|
|
61
|
+
<FeaturesSection />
|
|
60
62
|
<StacksSection />
|
|
61
63
|
<BoardTodoSection />
|
|
62
64
|
{done.length > 0 && (
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { FeatureView } from "@/lib/plans/view";
|
|
3
|
+
import { usePollWhenOffline } from "../push/connection";
|
|
4
|
+
|
|
5
|
+
export const featureKeys = {
|
|
6
|
+
all: ["features"] as const,
|
|
7
|
+
list: (workspaceId: string | null) =>
|
|
8
|
+
[...featureKeys.all, "list", workspaceId ?? "all"] as const,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function useFeaturesQuery(workspaceId: string | null = null) {
|
|
12
|
+
return useQuery({
|
|
13
|
+
queryKey: featureKeys.list(workspaceId),
|
|
14
|
+
queryFn: async () => {
|
|
15
|
+
const res = await fetch(
|
|
16
|
+
`/api/features${workspaceId ? `?workspace=${workspaceId}` : ""}`
|
|
17
|
+
);
|
|
18
|
+
const data = await res.json();
|
|
19
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
20
|
+
return data.features as FeatureView[];
|
|
21
|
+
},
|
|
22
|
+
// Pushed when features or their pieces change.
|
|
23
|
+
refetchInterval: usePollWhenOffline(20000),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useReleasePiece() {
|
|
28
|
+
const queryClient = useQueryClient();
|
|
29
|
+
return useMutation({
|
|
30
|
+
mutationFn: async (id: number) => {
|
|
31
|
+
const res = await fetch(`/api/features/pieces/${id}/release`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
});
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
36
|
+
},
|
|
37
|
+
onSettled: () =>
|
|
38
|
+
queryClient.invalidateQueries({ queryKey: featureKeys.all }),
|
|
39
|
+
});
|
|
40
|
+
}
|
package/data/push/topics.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { orchestratorKeys } from "../orchestrators";
|
|
|
12
12
|
import { ruleKeys } from "../orchestrators/rules";
|
|
13
13
|
import { workspaceKeys } from "../workspaces";
|
|
14
14
|
import { scheduleKeys } from "../schedules";
|
|
15
|
+
import { recipeKeys } from "../recipes";
|
|
16
|
+
import { featureKeys } from "../features";
|
|
15
17
|
import { archivedKeys } from "../done";
|
|
16
18
|
import { awayKeys } from "../away";
|
|
17
19
|
|
|
@@ -54,6 +56,12 @@ function keysFor(topic: string): QueryKey[] {
|
|
|
54
56
|
case "schedules":
|
|
55
57
|
case "schedule_runs":
|
|
56
58
|
return [scheduleKeys.all];
|
|
59
|
+
case "recipes":
|
|
60
|
+
case "recipe_runs":
|
|
61
|
+
return [recipeKeys.all];
|
|
62
|
+
case "features":
|
|
63
|
+
case "feature_pieces":
|
|
64
|
+
return [featureKeys.all, taskKeys.all, sessionKeys.all];
|
|
57
65
|
case "orchestrator_asks":
|
|
58
66
|
case "orchestrator_questions":
|
|
59
67
|
case "needs_you_dismissed":
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { RecipeInput, RecipeRun, RecipeView } from "@/lib/recipes/types";
|
|
3
|
+
import type { RecipeDraft } from "@/lib/recipes/draft";
|
|
4
|
+
import type { TimerInput } from "@/lib/recipes/timer";
|
|
5
|
+
import { sessionKeys } from "../sessions/keys";
|
|
6
|
+
import { taskKeys } from "../tasks";
|
|
7
|
+
import { usePollWhenOffline } from "../push/connection";
|
|
8
|
+
|
|
9
|
+
export const recipeKeys = {
|
|
10
|
+
all: ["recipes"] as const,
|
|
11
|
+
list: (workspaceId: string | null) =>
|
|
12
|
+
[...recipeKeys.all, "list", workspaceId ?? "all"] as const,
|
|
13
|
+
detail: (id: string) => [...recipeKeys.all, "detail", id] as const,
|
|
14
|
+
preview: (id: string, note: string) =>
|
|
15
|
+
[...recipeKeys.all, "preview", id, note] as const,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
async function call<T>(
|
|
19
|
+
url: string,
|
|
20
|
+
method = "GET",
|
|
21
|
+
body?: unknown
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
const res = await fetch(url, {
|
|
24
|
+
method,
|
|
25
|
+
headers:
|
|
26
|
+
body === undefined ? undefined : { "Content-Type": "application/json" },
|
|
27
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
const data = await res.json();
|
|
30
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
31
|
+
return data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useRecipesQuery(workspaceId: string | null, enabled = true) {
|
|
35
|
+
return useQuery({
|
|
36
|
+
queryKey: recipeKeys.list(workspaceId),
|
|
37
|
+
queryFn: async () =>
|
|
38
|
+
(
|
|
39
|
+
await call<{ recipes: RecipeView[] }>(
|
|
40
|
+
`/api/recipes${workspaceId ? `?workspace=${workspaceId}` : ""}`
|
|
41
|
+
)
|
|
42
|
+
).recipes,
|
|
43
|
+
enabled,
|
|
44
|
+
// Pushed when recipes or their runs change.
|
|
45
|
+
refetchInterval: usePollWhenOffline(15000),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useRecipeQuery(id: string | null) {
|
|
50
|
+
return useQuery({
|
|
51
|
+
queryKey: recipeKeys.detail(id ?? ""),
|
|
52
|
+
queryFn: () =>
|
|
53
|
+
call<{ recipe: RecipeView; runs: RecipeRun[] }>(`/api/recipes/${id}`),
|
|
54
|
+
enabled: !!id,
|
|
55
|
+
refetchInterval: usePollWhenOffline(10000),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// What a run started now would send, for the Run sheet.
|
|
60
|
+
export function usePreviewQuery(id: string, note: string, enabled: boolean) {
|
|
61
|
+
return useQuery({
|
|
62
|
+
queryKey: recipeKeys.preview(id, note),
|
|
63
|
+
queryFn: () =>
|
|
64
|
+
call<{ prompt: string }>(
|
|
65
|
+
`/api/recipes/${id}/preview${note ? `?note=${encodeURIComponent(note)}` : ""}`
|
|
66
|
+
),
|
|
67
|
+
enabled,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function useRecipeMutation<V, R>(fn: (v: V) => Promise<R>) {
|
|
72
|
+
const queryClient = useQueryClient();
|
|
73
|
+
return useMutation({
|
|
74
|
+
mutationFn: fn,
|
|
75
|
+
onSettled: () => {
|
|
76
|
+
queryClient.invalidateQueries({ queryKey: recipeKeys.all });
|
|
77
|
+
queryClient.invalidateQueries({ queryKey: taskKeys.all });
|
|
78
|
+
queryClient.invalidateQueries({ queryKey: sessionKeys.all });
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type RecipeFormInput = RecipeInput & { timer?: TimerInput | null };
|
|
84
|
+
|
|
85
|
+
export function useCreateRecipe() {
|
|
86
|
+
return useRecipeMutation((input: RecipeFormInput) =>
|
|
87
|
+
call<{ recipe: RecipeView }>("/api/recipes", "POST", input)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function useUpdateRecipe() {
|
|
92
|
+
return useRecipeMutation(
|
|
93
|
+
({
|
|
94
|
+
id,
|
|
95
|
+
...patch
|
|
96
|
+
}: Partial<Omit<RecipeFormInput, "workspaceId">> & { id: string }) =>
|
|
97
|
+
call<{ recipe: RecipeView }>(`/api/recipes/${id}`, "PATCH", patch)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function useArchiveRecipe() {
|
|
102
|
+
return useRecipeMutation((id: string) =>
|
|
103
|
+
call(`/api/recipes/${id}`, "DELETE")
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function useRunRecipe() {
|
|
108
|
+
return useRecipeMutation(
|
|
109
|
+
(input: { id: string; note?: string; requestId: string }) =>
|
|
110
|
+
call<{ run: RecipeRun; queued: boolean }>(
|
|
111
|
+
`/api/recipes/${input.id}/run`,
|
|
112
|
+
"POST",
|
|
113
|
+
{ note: input.note, requestId: input.requestId }
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function useClearCarry() {
|
|
119
|
+
return useRecipeMutation((id: string) =>
|
|
120
|
+
call(`/api/recipes/${id}/carry/clear`, "POST", {})
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function useRecipeDraft() {
|
|
125
|
+
return useMutation({
|
|
126
|
+
mutationFn: (sessionId: string) =>
|
|
127
|
+
call<RecipeDraft>("/api/recipes/draft", "POST", { sessionId }),
|
|
128
|
+
});
|
|
129
|
+
}
|
package/data/tasks/index.ts
CHANGED
|
@@ -97,6 +97,20 @@ export function useApprovePlan() {
|
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Which waiting pieces of a plan to fold into the one before, or leave out.
|
|
101
|
+
export function usePlanEdits() {
|
|
102
|
+
return useTaskMutation(
|
|
103
|
+
async (input: { id: string; merge: number[]; drop: number[] }) =>
|
|
104
|
+
json(
|
|
105
|
+
await fetch(`/api/tasks/${input.id}/plan-edits`, {
|
|
106
|
+
method: "PUT",
|
|
107
|
+
headers: { "Content-Type": "application/json" },
|
|
108
|
+
body: JSON.stringify({ merge: input.merge, drop: input.drop }),
|
|
109
|
+
})
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
100
114
|
export function useResumeTask() {
|
|
101
115
|
return useTaskMutation(async (input: { id: string; force?: boolean }) =>
|
|
102
116
|
json(
|
package/lib/away/run.ts
CHANGED
|
@@ -286,7 +286,10 @@ async function rerunReview(
|
|
|
286
286
|
|
|
287
287
|
const SHA = /^[0-9a-f]{40}$/;
|
|
288
288
|
|
|
289
|
-
async function mergeCommit(
|
|
289
|
+
export async function mergeCommit(
|
|
290
|
+
repo: string,
|
|
291
|
+
pr: number
|
|
292
|
+
): Promise<string | null> {
|
|
290
293
|
const out = await run(
|
|
291
294
|
"gh",
|
|
292
295
|
[
|
package/lib/db/changes.ts
CHANGED
|
@@ -34,6 +34,13 @@ export const TABLES_WATCHED_78 = [
|
|
|
34
34
|
"needs_you_dismissed",
|
|
35
35
|
] as const;
|
|
36
36
|
|
|
37
|
+
export const TABLES_WATCHED_80 = [
|
|
38
|
+
"recipes",
|
|
39
|
+
"recipe_runs",
|
|
40
|
+
"features",
|
|
41
|
+
"feature_pieces",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
37
44
|
// Every table any migration watches, for the browser's topic map to cover.
|
|
38
45
|
export const WATCHED_TABLES: readonly string[] = [
|
|
39
46
|
...TABLES_WATCHED_45,
|
|
@@ -41,6 +48,7 @@ export const WATCHED_TABLES: readonly string[] = [
|
|
|
41
48
|
...TABLES_WATCHED_56,
|
|
42
49
|
...TABLES_WATCHED_58,
|
|
43
50
|
...TABLES_WATCHED_78,
|
|
51
|
+
...TABLES_WATCHED_80,
|
|
44
52
|
];
|
|
45
53
|
|
|
46
54
|
export function installChangeTriggers(
|