@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,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowUpRight } from "lucide-react";
|
|
4
|
+
import type { RecipeRun, RecipeTotals } from "@/lib/recipes/types";
|
|
5
|
+
import { formatRunTime } from "@/lib/schedules/cron";
|
|
6
|
+
import { settingsUiActions } from "@/stores/settingsUi";
|
|
7
|
+
import { sessionOpenActions } from "@/stores/sessionOpen";
|
|
8
|
+
import { RunChip } from "./RunChip";
|
|
9
|
+
import { duration, money, sqlMs, triggerLabel } from "./format";
|
|
10
|
+
|
|
11
|
+
export function Totals({ totals }: { totals: RecipeTotals }) {
|
|
12
|
+
if (!totals.runs) return null;
|
|
13
|
+
const bits = [
|
|
14
|
+
`${totals.runs} ${totals.runs === 1 ? "run" : "runs"}`,
|
|
15
|
+
totals.failed ? `${totals.failed} failed` : null,
|
|
16
|
+
totals.costUsd ? money(totals.costUsd) : null,
|
|
17
|
+
totals.avgMs ? `avg ${duration(totals.avgMs)}` : null,
|
|
18
|
+
].filter(Boolean);
|
|
19
|
+
return <p className="text-muted-foreground text-xs">{bits.join(" · ")}</p>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Facts({ run }: { run: RecipeRun }) {
|
|
23
|
+
const outputs = run.outputs
|
|
24
|
+
? Object.entries(run.outputs)
|
|
25
|
+
.map(([k, v]) => `${k} ${v}`)
|
|
26
|
+
.join(" · ")
|
|
27
|
+
: "";
|
|
28
|
+
const text = [outputs, run.summary ?? run.error].filter(Boolean).join(". ");
|
|
29
|
+
return text ? (
|
|
30
|
+
<p className="text-muted-foreground line-clamp-2 text-xs">{text}</p>
|
|
31
|
+
) : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function RunHistory({ runs }: { runs: RecipeRun[] }) {
|
|
35
|
+
return (
|
|
36
|
+
<div className="space-y-2">
|
|
37
|
+
<p className="label-mono text-muted-foreground">History</p>
|
|
38
|
+
{runs.length === 0 && (
|
|
39
|
+
<p className="text-muted-foreground text-sm">No runs yet.</p>
|
|
40
|
+
)}
|
|
41
|
+
<ul className="divide-border/60 divide-y">
|
|
42
|
+
{runs.map((r) => {
|
|
43
|
+
const stats = [duration(r.duration_ms), money(r.cost_usd)]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join(" · ");
|
|
46
|
+
return (
|
|
47
|
+
<li key={r.id} className="flex items-start gap-3 py-2.5">
|
|
48
|
+
<RunChip status={r.status} />
|
|
49
|
+
<div className="min-w-0 flex-1 space-y-0.5">
|
|
50
|
+
<p className="text-sm tabular-nums">
|
|
51
|
+
#{r.seq} · {formatRunTime(sqlMs(r.created_at))}
|
|
52
|
+
</p>
|
|
53
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
54
|
+
{[
|
|
55
|
+
triggerLabel(r),
|
|
56
|
+
r.pr_number ? `PR #${r.pr_number}` : null,
|
|
57
|
+
stats,
|
|
58
|
+
]
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join(" · ")}
|
|
61
|
+
</p>
|
|
62
|
+
<Facts run={r} />
|
|
63
|
+
{r.note && (
|
|
64
|
+
<p className="text-muted-foreground truncate text-xs italic">
|
|
65
|
+
Note: {r.note}
|
|
66
|
+
</p>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
{r.session_id && (
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => {
|
|
73
|
+
sessionOpenActions.request(r.session_id!);
|
|
74
|
+
settingsUiActions.close();
|
|
75
|
+
}}
|
|
76
|
+
className="text-primary -mr-2 flex min-h-11 shrink-0 items-center gap-1 px-2 text-xs font-medium"
|
|
77
|
+
>
|
|
78
|
+
Open
|
|
79
|
+
<ArrowUpRight className="h-3.5 w-3.5" />
|
|
80
|
+
</button>
|
|
81
|
+
)}
|
|
82
|
+
</li>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</ul>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import { Play } from "lucide-react";
|
|
5
|
+
import { uuid } from "@/lib/uuid";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
8
|
+
import { useRunRecipe, usePreviewQuery } from "@/data/recipes";
|
|
9
|
+
import { MAX_NOTE, type RecipeView } from "@/lib/recipes/types";
|
|
10
|
+
|
|
11
|
+
// Run now: an optional one-off note, and what will be sent. The request id
|
|
12
|
+
// is made once per sheet, so a double tap starts one run.
|
|
13
|
+
export function RunSheet({
|
|
14
|
+
recipe,
|
|
15
|
+
onClose,
|
|
16
|
+
}: {
|
|
17
|
+
recipe: RecipeView;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
}) {
|
|
20
|
+
const [note, setNote] = useState("");
|
|
21
|
+
const [showPrompt, setShowPrompt] = useState(false);
|
|
22
|
+
const requestId = useRef(uuid());
|
|
23
|
+
const run = useRunRecipe();
|
|
24
|
+
const preview = usePreviewQuery(recipe.id, note.trim(), showPrompt);
|
|
25
|
+
const go = () =>
|
|
26
|
+
run.mutate(
|
|
27
|
+
{
|
|
28
|
+
id: recipe.id,
|
|
29
|
+
note: note.trim() || undefined,
|
|
30
|
+
requestId: requestId.current,
|
|
31
|
+
},
|
|
32
|
+
{ onSuccess: onClose }
|
|
33
|
+
);
|
|
34
|
+
return (
|
|
35
|
+
<div className="border-border/60 bg-foreground/[0.03] space-y-3 rounded-xl border p-3">
|
|
36
|
+
<label className="text-sm font-medium" htmlFor="run-note">
|
|
37
|
+
Note for this run (optional)
|
|
38
|
+
</label>
|
|
39
|
+
<Textarea
|
|
40
|
+
id="run-note"
|
|
41
|
+
value={note}
|
|
42
|
+
maxLength={MAX_NOTE}
|
|
43
|
+
rows={2}
|
|
44
|
+
placeholder="Includes #87"
|
|
45
|
+
onChange={(e) => setNote(e.target.value)}
|
|
46
|
+
/>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
aria-expanded={showPrompt}
|
|
50
|
+
className="text-primary min-h-11 text-xs font-medium md:min-h-8"
|
|
51
|
+
onClick={() => setShowPrompt((v) => !v)}
|
|
52
|
+
>
|
|
53
|
+
{showPrompt ? "Hide what it sends" : "Show what it sends"}
|
|
54
|
+
</button>
|
|
55
|
+
{showPrompt && (
|
|
56
|
+
<pre className="bg-background max-h-64 overflow-auto rounded-lg p-3 text-xs whitespace-pre-wrap">
|
|
57
|
+
{preview.data?.prompt ?? (preview.error?.message || "Loading...")}
|
|
58
|
+
</pre>
|
|
59
|
+
)}
|
|
60
|
+
{run.error && (
|
|
61
|
+
<p className="text-destructive text-sm">{run.error.message}</p>
|
|
62
|
+
)}
|
|
63
|
+
<div className="flex gap-2">
|
|
64
|
+
<Button
|
|
65
|
+
size="sm"
|
|
66
|
+
className="h-11 md:h-8"
|
|
67
|
+
disabled={run.isPending || !!recipe.liveRun}
|
|
68
|
+
onClick={go}
|
|
69
|
+
>
|
|
70
|
+
<Play className="h-3.5 w-3.5" />
|
|
71
|
+
{run.isPending ? "Starting..." : "Run"}
|
|
72
|
+
</Button>
|
|
73
|
+
<Button
|
|
74
|
+
size="sm"
|
|
75
|
+
variant="ghost"
|
|
76
|
+
className="h-11 md:h-8"
|
|
77
|
+
onClick={onClose}
|
|
78
|
+
>
|
|
79
|
+
Cancel
|
|
80
|
+
</Button>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import {
|
|
7
|
+
clock12,
|
|
8
|
+
cronError,
|
|
9
|
+
cronPreset,
|
|
10
|
+
DEFAULT_TIMEZONE,
|
|
11
|
+
describeCron,
|
|
12
|
+
formatRunTime,
|
|
13
|
+
isTimezone,
|
|
14
|
+
MIN_GAP_MINUTES,
|
|
15
|
+
minGapMinutes,
|
|
16
|
+
nextRuns,
|
|
17
|
+
presetCron,
|
|
18
|
+
type PresetKind,
|
|
19
|
+
} from "@/lib/schedules/cron";
|
|
20
|
+
import type { RecipeTimer } from "@/lib/recipes/types";
|
|
21
|
+
|
|
22
|
+
type Mode = "off" | Exclude<PresetKind, "every"> | "custom";
|
|
23
|
+
|
|
24
|
+
const MODES: { value: Mode; label: string }[] = [
|
|
25
|
+
{ value: "off", label: "No timer" },
|
|
26
|
+
{ value: "hourly", label: "Hourly" },
|
|
27
|
+
{ value: "daily", label: "Daily" },
|
|
28
|
+
{ value: "weekdays", label: "Weekdays" },
|
|
29
|
+
{ value: "weekly", label: "Weekly" },
|
|
30
|
+
{ value: "custom", label: "Custom" },
|
|
31
|
+
];
|
|
32
|
+
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
33
|
+
const select =
|
|
34
|
+
"border-input bg-background min-h-11 rounded-md border px-2 text-sm md:min-h-9";
|
|
35
|
+
|
|
36
|
+
export interface TimerValue {
|
|
37
|
+
mode: Mode;
|
|
38
|
+
hour: number;
|
|
39
|
+
minute: number;
|
|
40
|
+
weekday: number;
|
|
41
|
+
custom: string;
|
|
42
|
+
timezone: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function timerValue(timer: RecipeTimer | null): TimerValue {
|
|
46
|
+
const preset = timer ? cronPreset(timer.cron) : null;
|
|
47
|
+
const mode: Mode = !timer
|
|
48
|
+
? "off"
|
|
49
|
+
: preset && preset.kind !== "every"
|
|
50
|
+
? preset.kind
|
|
51
|
+
: "custom";
|
|
52
|
+
return {
|
|
53
|
+
mode,
|
|
54
|
+
hour: preset?.hour ?? 9,
|
|
55
|
+
minute: preset?.minute ?? 0,
|
|
56
|
+
weekday: preset?.weekday ?? 1,
|
|
57
|
+
custom: timer?.cron ?? "0 9 * * 1-5",
|
|
58
|
+
timezone: timer?.timezone ?? DEFAULT_TIMEZONE,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function timerCron(v: TimerValue): string | null {
|
|
63
|
+
if (v.mode === "off") return null;
|
|
64
|
+
if (v.mode === "custom") return v.custom.trim();
|
|
65
|
+
return presetCron({
|
|
66
|
+
kind: v.mode,
|
|
67
|
+
hour: v.hour,
|
|
68
|
+
minute: v.minute,
|
|
69
|
+
weekday: v.weekday,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Why the timer can't be saved, or null.
|
|
74
|
+
export function timerProblem(v: TimerValue): string | null {
|
|
75
|
+
const cron = timerCron(v);
|
|
76
|
+
if (!cron) return null;
|
|
77
|
+
if (!isTimezone(v.timezone)) return "Unknown time zone";
|
|
78
|
+
const bad = cronError(cron);
|
|
79
|
+
if (bad) return bad;
|
|
80
|
+
return minGapMinutes(cron, v.timezone) < MIN_GAP_MINUTES
|
|
81
|
+
? "A recipe runs at most once an hour"
|
|
82
|
+
: null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function TimerFields({
|
|
86
|
+
value,
|
|
87
|
+
onChange,
|
|
88
|
+
}: {
|
|
89
|
+
value: TimerValue;
|
|
90
|
+
onChange: (v: TimerValue) => void;
|
|
91
|
+
}) {
|
|
92
|
+
const set = (patch: Partial<TimerValue>) => onChange({ ...value, ...patch });
|
|
93
|
+
const cron = timerCron(value);
|
|
94
|
+
const problem = timerProblem(value);
|
|
95
|
+
const [openedAt] = useState(() => Date.now());
|
|
96
|
+
const upcoming = useMemo(
|
|
97
|
+
() => (cron && !problem ? nextRuns(cron, openedAt, 3, value.timezone) : []),
|
|
98
|
+
[cron, problem, value.timezone, openedAt]
|
|
99
|
+
);
|
|
100
|
+
return (
|
|
101
|
+
<div className="space-y-3">
|
|
102
|
+
<div
|
|
103
|
+
role="radiogroup"
|
|
104
|
+
aria-label="Timer"
|
|
105
|
+
className="bg-foreground/[0.04] grid grid-cols-3 gap-1 rounded-lg p-1 md:flex"
|
|
106
|
+
>
|
|
107
|
+
{MODES.map((m) => (
|
|
108
|
+
<button
|
|
109
|
+
key={m.value}
|
|
110
|
+
type="button"
|
|
111
|
+
role="radio"
|
|
112
|
+
aria-checked={value.mode === m.value}
|
|
113
|
+
onClick={() => set({ mode: m.value })}
|
|
114
|
+
className={cn(
|
|
115
|
+
"min-h-11 min-w-0 flex-1 truncate rounded-md px-1 text-xs font-medium md:min-h-8 md:px-3 md:text-sm",
|
|
116
|
+
value.mode === m.value
|
|
117
|
+
? "bg-background text-foreground shadow-sm"
|
|
118
|
+
: "text-muted-foreground hover:text-foreground"
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
{m.label}
|
|
122
|
+
</button>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
{value.mode === "weekly" && (
|
|
126
|
+
<select
|
|
127
|
+
aria-label="Day"
|
|
128
|
+
className={select}
|
|
129
|
+
value={value.weekday}
|
|
130
|
+
onChange={(e) => set({ weekday: Number(e.target.value) })}
|
|
131
|
+
>
|
|
132
|
+
{DAYS.map((d, i) => (
|
|
133
|
+
<option key={d} value={i}>
|
|
134
|
+
{d}
|
|
135
|
+
</option>
|
|
136
|
+
))}
|
|
137
|
+
</select>
|
|
138
|
+
)}
|
|
139
|
+
{(value.mode === "daily" ||
|
|
140
|
+
value.mode === "weekdays" ||
|
|
141
|
+
value.mode === "weekly") && (
|
|
142
|
+
<span className="ml-0 flex items-center gap-2">
|
|
143
|
+
<select
|
|
144
|
+
aria-label="Hour"
|
|
145
|
+
className={select}
|
|
146
|
+
value={value.hour}
|
|
147
|
+
onChange={(e) => set({ hour: Number(e.target.value) })}
|
|
148
|
+
>
|
|
149
|
+
{Array.from({ length: 24 }, (_, h) => (
|
|
150
|
+
<option key={h} value={h}>
|
|
151
|
+
{clock12(h, 0).replace(":00", "")}
|
|
152
|
+
</option>
|
|
153
|
+
))}
|
|
154
|
+
</select>
|
|
155
|
+
:
|
|
156
|
+
<select
|
|
157
|
+
aria-label="Minute"
|
|
158
|
+
className={select}
|
|
159
|
+
value={value.minute}
|
|
160
|
+
onChange={(e) => set({ minute: Number(e.target.value) })}
|
|
161
|
+
>
|
|
162
|
+
{Array.from({ length: 12 }, (_, i) => i * 5).map((m) => (
|
|
163
|
+
<option key={m} value={m}>
|
|
164
|
+
{String(m).padStart(2, "0")}
|
|
165
|
+
</option>
|
|
166
|
+
))}
|
|
167
|
+
</select>
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
170
|
+
{value.mode === "custom" && (
|
|
171
|
+
<Input
|
|
172
|
+
aria-label="Cron expression"
|
|
173
|
+
className="font-mono"
|
|
174
|
+
value={value.custom}
|
|
175
|
+
onChange={(e) => set({ custom: e.target.value })}
|
|
176
|
+
/>
|
|
177
|
+
)}
|
|
178
|
+
{cron && (
|
|
179
|
+
<p
|
|
180
|
+
className={cn(
|
|
181
|
+
"text-xs",
|
|
182
|
+
problem ? "text-destructive" : "text-muted-foreground"
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
185
|
+
{problem ??
|
|
186
|
+
`${describeCron(cron)} · next ${upcoming
|
|
187
|
+
.map((t) => formatRunTime(t, value.timezone))
|
|
188
|
+
.join(", ")}`}
|
|
189
|
+
</p>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RecipeRun, RunStatus } from "@/lib/recipes/types";
|
|
2
|
+
|
|
3
|
+
export const STATUS_LABEL: Record<RunStatus, string> = {
|
|
4
|
+
queued: "Queued",
|
|
5
|
+
starting: "Starting",
|
|
6
|
+
running: "Running",
|
|
7
|
+
succeeded: "Succeeded",
|
|
8
|
+
failed: "Failed",
|
|
9
|
+
blocked: "Needs you",
|
|
10
|
+
timed_out: "Timed out",
|
|
11
|
+
cancelled: "Cancelled",
|
|
12
|
+
skipped: "Skipped",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const STATUS_TONE: Record<RunStatus, string> = {
|
|
16
|
+
queued: "bg-foreground/10 text-muted-foreground",
|
|
17
|
+
starting: "bg-primary/15 text-primary",
|
|
18
|
+
running: "bg-primary/15 text-primary",
|
|
19
|
+
succeeded: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
|
|
20
|
+
failed: "bg-destructive/15 text-destructive",
|
|
21
|
+
blocked: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
|
22
|
+
timed_out: "bg-destructive/15 text-destructive",
|
|
23
|
+
cancelled: "bg-foreground/10 text-muted-foreground",
|
|
24
|
+
skipped: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// SQLite's datetime('now') is UTC without a zone marker.
|
|
28
|
+
export const sqlMs = (t: string | null) =>
|
|
29
|
+
t ? Date.parse(`${t.replace(" ", "T")}Z`) : 0;
|
|
30
|
+
|
|
31
|
+
export function duration(ms: number | null): string {
|
|
32
|
+
if (ms == null) return "";
|
|
33
|
+
const s = Math.round(ms / 1000);
|
|
34
|
+
if (s < 60) return `${s}s`;
|
|
35
|
+
const m = Math.round(s / 60);
|
|
36
|
+
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const money = (n: number | null) =>
|
|
40
|
+
n == null ? "" : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`;
|
|
41
|
+
|
|
42
|
+
const TRIGGER_LABEL = {
|
|
43
|
+
manual: "by hand",
|
|
44
|
+
tool: "by the orchestrator",
|
|
45
|
+
cli: "by an agent",
|
|
46
|
+
timer: "on its timer",
|
|
47
|
+
"catch-up": "on its timer, late",
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
export const triggerLabel = (r: Pick<RecipeRun, "trigger">) =>
|
|
51
|
+
TRIGGER_LABEL[r.trigger];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { RecipesPanel } from "./RecipesPanel";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Repeat, Layers } from "lucide-react";
|
|
3
4
|
import { HostBadge } from "@/components/Hosts";
|
|
5
|
+
import { rowGroup } from "@/lib/recipes/chip";
|
|
4
6
|
import type { Session } from "@/lib/db";
|
|
5
7
|
import { LiveLine, type LiveInput } from "./LiveLine";
|
|
6
8
|
import { headerParts } from "@/lib/orchestrator/header-line";
|
|
@@ -53,9 +55,23 @@ export function RowSubtitle({
|
|
|
53
55
|
);
|
|
54
56
|
}
|
|
55
57
|
const project = ctx.projectNames.get(session.project_id ?? "");
|
|
58
|
+
const group = rowGroup(session);
|
|
56
59
|
return (
|
|
57
60
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
58
61
|
<LiveLine {...live} />
|
|
62
|
+
{group && (
|
|
63
|
+
<span
|
|
64
|
+
className="text-muted-foreground/70 flex min-w-0 shrink-0 items-center gap-1 truncate text-xs"
|
|
65
|
+
title={group.kind === "recipe" ? "A recipe run" : "A piece of a plan"}
|
|
66
|
+
>
|
|
67
|
+
{group.kind === "recipe" ? (
|
|
68
|
+
<Repeat aria-hidden className="h-3 w-3 shrink-0" />
|
|
69
|
+
) : (
|
|
70
|
+
<Layers aria-hidden className="h-3 w-3 shrink-0" />
|
|
71
|
+
)}
|
|
72
|
+
<span className="truncate">{group.text}</span>
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
59
75
|
{(project || detail || usage) && (
|
|
60
76
|
<span className="text-muted-foreground/70 truncate text-xs">
|
|
61
77
|
{[usage && usageLabel(usage), detail?.slice(0, 200), project]
|
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
Loader2,
|
|
15
15
|
Pencil,
|
|
16
16
|
Pin,
|
|
17
|
+
Play,
|
|
18
|
+
Repeat,
|
|
19
|
+
ChefHat,
|
|
17
20
|
PinOff,
|
|
18
21
|
RotateCcw,
|
|
19
22
|
Sparkles,
|
|
@@ -24,6 +27,8 @@ import * as CM from "@/components/ui/context-menu";
|
|
|
24
27
|
import type { Session } from "@/lib/db";
|
|
25
28
|
import { selectionActions } from "@/stores/sessionSelection";
|
|
26
29
|
import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
30
|
+
import { recipesUiActions } from "@/stores/recipesUi";
|
|
31
|
+
import { rowGroup } from "@/lib/recipes/chip";
|
|
27
32
|
import { useDoneAction } from "./useDoneAction";
|
|
28
33
|
import { useRowContext } from "./RowContext";
|
|
29
34
|
import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
|
|
@@ -95,6 +100,8 @@ export function SessionRowMenu({
|
|
|
95
100
|
const others = ctx.projects.filter(
|
|
96
101
|
(p) => p.id !== session.project_id && !p.is_uncategorized
|
|
97
102
|
);
|
|
103
|
+
const group = rowGroup(session);
|
|
104
|
+
const recipeRun = group?.kind === "recipe" ? { id: group.id } : null;
|
|
98
105
|
const workspaceId =
|
|
99
106
|
session.workspace_id ??
|
|
100
107
|
ctx.projects.find((p) => p.id === session.project_id)?.workspace_id ??
|
|
@@ -251,6 +258,28 @@ export function SessionRowMenu({
|
|
|
251
258
|
Schedule check-ins
|
|
252
259
|
</Item>
|
|
253
260
|
)}
|
|
261
|
+
{recipeRun && (
|
|
262
|
+
<>
|
|
263
|
+
<Item onClick={() => recipesUiActions.openRecipe(recipeRun.id)}>
|
|
264
|
+
<Repeat className={icon} />
|
|
265
|
+
Open recipe
|
|
266
|
+
</Item>
|
|
267
|
+
<Item onClick={() => recipesUiActions.openRecipe(recipeRun.id, true)}>
|
|
268
|
+
<Play className={icon} />
|
|
269
|
+
Run again
|
|
270
|
+
</Item>
|
|
271
|
+
</>
|
|
272
|
+
)}
|
|
273
|
+
{!recipeRun && !session.role && !session.quick_of && (
|
|
274
|
+
<Item
|
|
275
|
+
onClick={() =>
|
|
276
|
+
recipesUiActions.saveSessionAs(session.id, workspaceId)
|
|
277
|
+
}
|
|
278
|
+
>
|
|
279
|
+
<ChefHat className={icon} />
|
|
280
|
+
Save as recipe
|
|
281
|
+
</Item>
|
|
282
|
+
)}
|
|
254
283
|
<Item
|
|
255
284
|
onClick={() =>
|
|
256
285
|
selectionActions.toggle(session.id, false, ctx.orderedIds())
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
Archive,
|
|
5
5
|
BookOpen,
|
|
6
|
+
ChefHat,
|
|
6
7
|
Clock,
|
|
7
8
|
FolderGit2,
|
|
8
9
|
FolderPlus,
|
|
@@ -31,6 +32,7 @@ import { archivedUiActions } from "@/stores/archivedUi";
|
|
|
31
32
|
import { tasksUiActions } from "@/stores/tasksUi";
|
|
32
33
|
import { newDraft } from "@/stores/drafts";
|
|
33
34
|
import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
35
|
+
import { recipesUiActions } from "@/stores/recipesUi";
|
|
34
36
|
import { busUiActions } from "@/stores/busUi";
|
|
35
37
|
import { settingsUiActions } from "@/stores/settingsUi";
|
|
36
38
|
import { usageUiActions } from "@/stores/usageUi";
|
|
@@ -106,6 +108,9 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
106
108
|
</span>
|
|
107
109
|
),
|
|
108
110
|
}),
|
|
111
|
+
menuItem("Recipes", () => recipesUiActions.open(workspaceId), {
|
|
112
|
+
icon: ChefHat,
|
|
113
|
+
}),
|
|
109
114
|
menuItem("Messages", busUiActions.open, { icon: MessagesSquare }),
|
|
110
115
|
...(props.onOpenDocs
|
|
111
116
|
? [menuItem("Docs", props.onOpenDocs, { icon: BookOpen })]
|
|
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|
|
4
4
|
import {
|
|
5
5
|
Archive,
|
|
6
6
|
Check,
|
|
7
|
+
ChefHat,
|
|
7
8
|
ChevronDown,
|
|
8
9
|
CircleCheck,
|
|
9
10
|
Clock,
|
|
@@ -30,6 +31,7 @@ import { archivedUiActions, cleanupUiActions } from "@/stores/archivedUi";
|
|
|
30
31
|
import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
|
|
31
32
|
import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
32
33
|
import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
34
|
+
import { recipesUiActions } from "@/stores/recipesUi";
|
|
33
35
|
import { useAddExistingProject } from "@/components/Projects/AddProject/useAddExistingProject";
|
|
34
36
|
|
|
35
37
|
const icon = "mr-2 h-3.5 w-3.5";
|
|
@@ -89,10 +91,18 @@ export function WorkspaceSwitcher({
|
|
|
89
91
|
New workspace
|
|
90
92
|
</DM.DropdownMenuItem>
|
|
91
93
|
{!current && (
|
|
92
|
-
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
<>
|
|
95
|
+
<DM.DropdownMenuItem onClick={() => recipesUiActions.open(null)}>
|
|
96
|
+
<ChefHat className={icon} />
|
|
97
|
+
Recipes
|
|
98
|
+
</DM.DropdownMenuItem>
|
|
99
|
+
<DM.DropdownMenuItem
|
|
100
|
+
onClick={() => schedulesUiActions.open(null)}
|
|
101
|
+
>
|
|
102
|
+
<Clock className={icon} />
|
|
103
|
+
Schedules
|
|
104
|
+
</DM.DropdownMenuItem>
|
|
105
|
+
</>
|
|
96
106
|
)}
|
|
97
107
|
{current && (
|
|
98
108
|
<>
|
|
@@ -110,6 +120,12 @@ export function WorkspaceSwitcher({
|
|
|
110
120
|
<Workflow className={icon} />
|
|
111
121
|
Orchestrator
|
|
112
122
|
</DM.DropdownMenuItem>
|
|
123
|
+
<DM.DropdownMenuItem
|
|
124
|
+
onClick={() => recipesUiActions.open(current.id)}
|
|
125
|
+
>
|
|
126
|
+
<ChefHat className={icon} />
|
|
127
|
+
Recipes
|
|
128
|
+
</DM.DropdownMenuItem>
|
|
113
129
|
<DM.DropdownMenuItem
|
|
114
130
|
onClick={() => schedulesUiActions.open(current.id)}
|
|
115
131
|
>
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
ArrowLeft,
|
|
7
7
|
Bell,
|
|
8
8
|
ChevronRight,
|
|
9
|
+
ChefHat,
|
|
9
10
|
ClipboardList,
|
|
10
11
|
Clock,
|
|
11
12
|
Cpu,
|
|
@@ -34,6 +35,7 @@ import { useSettingsUrl } from "@/hooks/useSettingsUrl";
|
|
|
34
35
|
import { SETTINGS_SECTIONS, type SettingsSection } from "@/lib/settings-url";
|
|
35
36
|
import { cn } from "@/lib/utils";
|
|
36
37
|
import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
38
|
+
import { RecipesPanel } from "@/components/Recipes";
|
|
37
39
|
import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
|
|
38
40
|
import { SetupSection } from "./SetupSection";
|
|
39
41
|
import {
|
|
@@ -70,6 +72,11 @@ export const SECTION_INFO: Record<
|
|
|
70
72
|
hint: "Sound, browser alerts, your phone",
|
|
71
73
|
icon: Bell,
|
|
72
74
|
},
|
|
75
|
+
recipes: {
|
|
76
|
+
label: "Recipes",
|
|
77
|
+
hint: "Saved jobs you run again, by hand or on a timer",
|
|
78
|
+
icon: ChefHat,
|
|
79
|
+
},
|
|
73
80
|
schedules: {
|
|
74
81
|
label: "Schedules",
|
|
75
82
|
hint: "Work that starts at set times",
|
|
@@ -105,6 +112,8 @@ function SectionBody({
|
|
|
105
112
|
return <DevicesPanel />;
|
|
106
113
|
case "notifications":
|
|
107
114
|
return <NotificationsSection browser={browser} />;
|
|
115
|
+
case "recipes":
|
|
116
|
+
return <RecipesPanel />;
|
|
108
117
|
case "schedules":
|
|
109
118
|
return <SchedulesPanel />;
|
|
110
119
|
case "audits":
|