@saadnvd1/agent-os 0.3.533 → 0.3.535
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 +74 -4
- 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/ScheduleForm.tsx +20 -2
- 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 +143 -0
- package/lib/db/queries.ts +4 -1
- package/lib/db/schedules-move.ts +220 -0
- 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/scripts/revert-schedules-move.ts +24 -0
- 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";
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
wall,
|
|
35
35
|
type PresetKind,
|
|
36
36
|
} from "@/lib/schedules/cron";
|
|
37
|
+
import { recipesUiActions } from "@/stores/recipesUi";
|
|
37
38
|
import { parseWhen, relativeTime } from "@/lib/schedules/when";
|
|
38
39
|
|
|
39
40
|
const KINDS: { value: ScheduleKind; label: string; hint: string }[] = [
|
|
@@ -59,6 +60,10 @@ const KINDS: { value: ScheduleKind; label: string; hint: string }[] = [
|
|
|
59
60
|
},
|
|
60
61
|
];
|
|
61
62
|
|
|
63
|
+
// New jobs of these shapes are recipes; the kinds stay only to edit a
|
|
64
|
+
// schedule that still has one (a one-off, say).
|
|
65
|
+
const LEGACY_KINDS: ScheduleKind[] = ["task", "session"];
|
|
66
|
+
|
|
62
67
|
type Preset = PresetKind | "custom" | "once";
|
|
63
68
|
|
|
64
69
|
const PRESETS: { value: Preset; label: string }[] = [
|
|
@@ -171,7 +176,7 @@ export function ScheduleForm({
|
|
|
171
176
|
);
|
|
172
177
|
const [name, setName] = useState(existing?.name ?? draft?.name ?? "");
|
|
173
178
|
const [kind, setKind] = useState<ScheduleKind>(
|
|
174
|
-
existing?.kind ?? draft?.kind ?? "
|
|
179
|
+
existing?.kind ?? draft?.kind ?? "orchestrator"
|
|
175
180
|
);
|
|
176
181
|
const [targetId, setTargetId] = useState(
|
|
177
182
|
existing?.target_session_id ?? draft?.targetSessionId ?? ""
|
|
@@ -329,7 +334,9 @@ export function ScheduleForm({
|
|
|
329
334
|
<Segmented
|
|
330
335
|
label="What it starts"
|
|
331
336
|
value={kind}
|
|
332
|
-
options={KINDS
|
|
337
|
+
options={KINDS.filter(
|
|
338
|
+
(k) => !LEGACY_KINDS.includes(k.value) || existing?.kind === k.value
|
|
339
|
+
)}
|
|
333
340
|
onChange={(k) => {
|
|
334
341
|
setKind(k);
|
|
335
342
|
if (preset === "every" && !frequent(k)) setPreset("daily");
|
|
@@ -338,6 +345,17 @@ export function ScheduleForm({
|
|
|
338
345
|
<p className="text-muted-foreground text-xs">
|
|
339
346
|
{KINDS.find((k) => k.value === kind)?.hint}
|
|
340
347
|
</p>
|
|
348
|
+
<p className="text-muted-foreground text-xs">
|
|
349
|
+
To start a task or session on a timer, use a{" "}
|
|
350
|
+
<button
|
|
351
|
+
type="button"
|
|
352
|
+
className="text-foreground underline underline-offset-2"
|
|
353
|
+
onClick={() => recipesUiActions.open(workspaceId ?? null)}
|
|
354
|
+
>
|
|
355
|
+
recipe
|
|
356
|
+
</button>
|
|
357
|
+
.
|
|
358
|
+
</p>
|
|
341
359
|
</Field>
|
|
342
360
|
|
|
343
361
|
{!workspaceId && (
|
|
@@ -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":
|