@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.
Files changed (86) hide show
  1. package/README.md +74 -4
  2. package/app/api/features/pieces/[id]/release/route.ts +16 -0
  3. package/app/api/features/route.ts +8 -0
  4. package/app/api/recipes/[id]/carry/clear/route.ts +20 -0
  5. package/app/api/recipes/[id]/preview/route.ts +19 -0
  6. package/app/api/recipes/[id]/route.ts +51 -0
  7. package/app/api/recipes/[id]/run/route.ts +35 -0
  8. package/app/api/recipes/[id]/runs/route.ts +23 -0
  9. package/app/api/recipes/draft/route.ts +14 -0
  10. package/app/api/recipes/route.ts +35 -0
  11. package/app/api/tasks/[id]/plan-edits/route.ts +34 -0
  12. package/components/CommandPalette/useAppCommands.ts +20 -0
  13. package/components/Features/FeaturesSection.tsx +100 -0
  14. package/components/Features/index.ts +1 -0
  15. package/components/Recipes/RecipeForm.tsx +251 -0
  16. package/components/Recipes/RecipePage.tsx +127 -0
  17. package/components/Recipes/RecipesPanel.tsx +225 -0
  18. package/components/Recipes/RunChip.tsx +16 -0
  19. package/components/Recipes/RunHistory.tsx +88 -0
  20. package/components/Recipes/RunSheet.tsx +84 -0
  21. package/components/Recipes/TimerFields.tsx +193 -0
  22. package/components/Recipes/format.ts +51 -0
  23. package/components/Recipes/index.ts +1 -0
  24. package/components/Schedules/ScheduleForm.tsx +20 -2
  25. package/components/Schedules/SchedulesPanel.tsx +1 -0
  26. package/components/SessionList/RowSubtitle.tsx +16 -0
  27. package/components/SessionList/SessionRowMenu.tsx +29 -0
  28. package/components/SessionList/SidebarHeader.tsx +5 -0
  29. package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
  30. package/components/Settings/SettingsDialog.tsx +9 -0
  31. package/components/Tasks/PlanReview.tsx +253 -0
  32. package/components/Tasks/TaskRow.tsx +19 -44
  33. package/components/Tasks/TasksDialog.tsx +2 -0
  34. package/data/features/index.ts +40 -0
  35. package/data/push/topics.ts +8 -0
  36. package/data/recipes/index.ts +129 -0
  37. package/data/tasks/index.ts +14 -0
  38. package/lib/away/run.ts +4 -1
  39. package/lib/db/changes.ts +8 -0
  40. package/lib/db/migrations.ts +143 -0
  41. package/lib/db/queries.ts +4 -1
  42. package/lib/db/schedules-move.ts +220 -0
  43. package/lib/db/types.ts +6 -0
  44. package/lib/models/plan-step.ts +67 -6
  45. package/lib/notify/copy.ts +28 -10
  46. package/lib/notify/push/events.ts +36 -2
  47. package/lib/notify/push/policy.ts +2 -0
  48. package/lib/notify/push-types.ts +7 -3
  49. package/lib/orchestrator/brief.ts +13 -0
  50. package/lib/orchestrator/conditions.ts +3 -1
  51. package/lib/orchestrator/deliver.ts +2 -0
  52. package/lib/orchestrator/digest.ts +27 -1
  53. package/lib/orchestrator/plan-pieces.ts +41 -0
  54. package/lib/orchestrator/recipe-tools.ts +159 -0
  55. package/lib/orchestrator/review.ts +2 -1
  56. package/lib/orchestrator/serve.ts +25 -0
  57. package/lib/orchestrator/tool-names.ts +5 -0
  58. package/lib/orchestrator/tool-schemas.ts +54 -0
  59. package/lib/orchestrator/tools.ts +10 -0
  60. package/lib/plans/features.ts +200 -0
  61. package/lib/plans/pieces.ts +366 -0
  62. package/lib/plans/release.ts +323 -0
  63. package/lib/plans/view.ts +85 -0
  64. package/lib/recipes/api.ts +19 -0
  65. package/lib/recipes/chip.ts +40 -0
  66. package/lib/recipes/compose.ts +78 -0
  67. package/lib/recipes/draft.ts +93 -0
  68. package/lib/recipes/index.ts +54 -0
  69. package/lib/recipes/result.ts +59 -0
  70. package/lib/recipes/run.ts +202 -0
  71. package/lib/recipes/settle.ts +465 -0
  72. package/lib/recipes/store.ts +378 -0
  73. package/lib/recipes/test-fixtures.ts +49 -0
  74. package/lib/recipes/timer.ts +107 -0
  75. package/lib/recipes/types.ts +116 -0
  76. package/lib/schedules/index.ts +4 -1
  77. package/lib/schedules/start.ts +33 -9
  78. package/lib/schedules/store.ts +6 -2
  79. package/lib/security/demo.ts +1 -0
  80. package/lib/settings-url.ts +1 -0
  81. package/lib/tasks/loop-types.ts +23 -0
  82. package/lib/tasks/plan-view.ts +38 -1
  83. package/package.json +1 -1
  84. package/scripts/revert-schedules-move.ts +24 -0
  85. package/server.ts +2 -0
  86. package/stores/recipesUi.ts +53 -0
@@ -0,0 +1,378 @@
1
+ import { randomUUID } from "crypto";
2
+ import { db } from "../db";
3
+ import { getProject } from "../projects";
4
+ import { getWorkspace } from "../workspaces";
5
+ import { parseOverride, type ModelOverride } from "../models/resolve";
6
+ import {
7
+ MAX_BRIEF,
8
+ MAX_NAME,
9
+ RECIPE_KINDS,
10
+ isLive,
11
+ type Recipe,
12
+ type RecipeInput,
13
+ type RecipeRun,
14
+ type RecipeTotals,
15
+ type RunStatus,
16
+ } from "./types";
17
+
18
+ type RecipeRow = Omit<Recipe, "auto_done"> & { auto_done: number };
19
+ const toRecipe = (r: RecipeRow): Recipe => ({ ...r, auto_done: !!r.auto_done });
20
+
21
+ type RunRow = Omit<RecipeRun, "outputs"> & { outputs: string | null };
22
+ export const toRun = (r: RunRow): RecipeRun => ({
23
+ ...r,
24
+ outputs: r.outputs ? (JSON.parse(r.outputs) as Record<string, string>) : null,
25
+ });
26
+
27
+ export function getRecipe(id: string): Recipe | null {
28
+ const row = db.prepare(`SELECT * FROM recipes WHERE id = ?`).get(id) as
29
+ | RecipeRow
30
+ | undefined;
31
+ return row ? toRecipe(row) : null;
32
+ }
33
+
34
+ export function listRecipes(workspaceId?: string | null): Recipe[] {
35
+ const rows = (
36
+ workspaceId
37
+ ? db
38
+ .prepare(
39
+ `SELECT * FROM recipes WHERE archived_at IS NULL AND workspace_id = ? ORDER BY name COLLATE NOCASE`
40
+ )
41
+ .all(workspaceId)
42
+ : db
43
+ .prepare(
44
+ `SELECT * FROM recipes WHERE archived_at IS NULL ORDER BY name COLLATE NOCASE`
45
+ )
46
+ .all()
47
+ ) as RecipeRow[];
48
+ return rows.map(toRecipe);
49
+ }
50
+
51
+ // By id, id prefix or name (case-insensitive) among a workspace's live
52
+ // recipes. Never reaches another workspace's.
53
+ export function findRecipe(workspaceId: string, ref: string): Recipe {
54
+ const r = ref.trim();
55
+ const live = listRecipes(workspaceId);
56
+ const byId = live.filter(
57
+ (x) => x.id === r || (r.length >= 6 && x.id.startsWith(r))
58
+ );
59
+ const matches = byId.length
60
+ ? byId
61
+ : live.filter((x) => x.name.toLowerCase() === r.toLowerCase());
62
+ if (matches.length === 1) return matches[0];
63
+ if (matches.length)
64
+ throw new Error(`"${ref}" matches several recipes; use its id`);
65
+ const names = live.map((x) => x.name).join(", ") || "none";
66
+ throw new Error(`No recipe "${ref}" in this workspace (recipes: ${names})`);
67
+ }
68
+
69
+ export const modelOf = (r: Pick<Recipe, "model">): ModelOverride | null =>
70
+ !r.model
71
+ ? null
72
+ : r.model.startsWith("{")
73
+ ? (JSON.parse(r.model) as ModelOverride)
74
+ : r.model;
75
+
76
+ function validate(
77
+ input: RecipeInput,
78
+ selfId?: string
79
+ ): Required<Omit<RecipeInput, "sourceSessionId">> {
80
+ const name = input.name?.trim();
81
+ if (!name) throw new Error("Give the recipe a name");
82
+ if (name.length > MAX_NAME)
83
+ throw new Error(`Keep the name under ${MAX_NAME} characters`);
84
+ // It's quoted inside the run's first message.
85
+ if (/["[\]\n]/.test(name))
86
+ throw new Error("A recipe name can't use quotes or [ ]");
87
+ const brief = input.brief?.trim();
88
+ if (!brief) throw new Error("Write the brief it runs");
89
+ if (brief.length > MAX_BRIEF)
90
+ throw new Error(
91
+ `Keep the brief under ${MAX_BRIEF} characters: put the steps in a script in the repo and have the brief run it`
92
+ );
93
+ if (!RECIPE_KINDS.includes(input.kind))
94
+ throw new Error("Pick task or session");
95
+ if (!getWorkspace(input.workspaceId)) throw new Error("Pick a workspace");
96
+ const project = input.projectId ? getProject(input.projectId) : null;
97
+ if (!project || project.is_uncategorized) throw new Error("Pick a project");
98
+ if (project.workspace_id !== input.workspaceId)
99
+ throw new Error(`${project.name} isn't in this workspace`);
100
+ if (project.host_id && project.host_id !== "local")
101
+ throw new Error("Recipes run on this machine's projects only");
102
+ const view = input.view ?? "chat";
103
+ if (view !== "chat" && view !== "terminal")
104
+ throw new Error("view: chat or terminal");
105
+ let model: string | null = null;
106
+ const parsed = parseOverride(input.model);
107
+ if (parsed) {
108
+ if (input.kind === "session" && typeof parsed !== "string")
109
+ throw new Error(`model: a session takes one "provider/model"`);
110
+ model = typeof parsed === "string" ? parsed : JSON.stringify(parsed);
111
+ }
112
+ const maxMinutes = input.maxMinutes ?? null;
113
+ if (
114
+ maxMinutes !== null &&
115
+ (!Number.isInteger(maxMinutes) || maxMinutes < 1 || maxMinutes > 1440)
116
+ )
117
+ throw new Error("Time limit: 1 to 1440 minutes");
118
+ const clash = db
119
+ .prepare(
120
+ `SELECT name FROM recipes WHERE workspace_id = ? AND archived_at IS NULL
121
+ AND name = ? COLLATE NOCASE AND id != ?`
122
+ )
123
+ .get(input.workspaceId, name, selfId ?? "") as { name: string } | undefined;
124
+ if (clash) throw new Error(`There's already a recipe called "${clash.name}"`);
125
+ return {
126
+ workspaceId: input.workspaceId,
127
+ projectId: project.id,
128
+ name,
129
+ kind: input.kind,
130
+ brief,
131
+ model,
132
+ view,
133
+ autoDone: input.autoDone ?? true,
134
+ maxMinutes,
135
+ };
136
+ }
137
+
138
+ function settingsOf(v: {
139
+ kind: string;
140
+ model: string | null;
141
+ view: string;
142
+ autoDone: boolean;
143
+ maxMinutes: number | null;
144
+ }) {
145
+ return JSON.stringify({
146
+ kind: v.kind,
147
+ model: v.model,
148
+ view: v.view,
149
+ auto_done: v.autoDone,
150
+ max_minutes: v.maxMinutes,
151
+ });
152
+ }
153
+
154
+ function addRevision(
155
+ recipeId: string,
156
+ brief: string,
157
+ settings: string,
158
+ by: string | null
159
+ ): number {
160
+ return Number(
161
+ db
162
+ .prepare(
163
+ `INSERT INTO recipe_revisions (recipe_id, brief, settings, edited_by_session_id) VALUES (?, ?, ?, ?)`
164
+ )
165
+ .run(recipeId, brief, settings, by).lastInsertRowid
166
+ );
167
+ }
168
+
169
+ // `by`: the agent session that wrote it, or null for Saad.
170
+ export function createRecipe(
171
+ input: RecipeInput,
172
+ by: string | null = null
173
+ ): Recipe {
174
+ const v = validate(input);
175
+ const id = randomUUID();
176
+ const make = db.transaction(() => {
177
+ db.prepare(
178
+ `INSERT INTO recipes (id, workspace_id, project_id, name, kind, brief, model, view, auto_done, max_minutes,
179
+ source_session_id, created_by_session_id, updated_by_session_id)
180
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
181
+ ).run(
182
+ id,
183
+ v.workspaceId,
184
+ v.projectId,
185
+ v.name,
186
+ v.kind,
187
+ v.brief,
188
+ v.model,
189
+ v.view,
190
+ v.autoDone ? 1 : 0,
191
+ v.maxMinutes,
192
+ input.sourceSessionId ?? null,
193
+ by,
194
+ by
195
+ );
196
+ addRevision(id, v.brief, settingsOf(v), by);
197
+ });
198
+ make();
199
+ return getRecipe(id)!;
200
+ }
201
+
202
+ export function updateRecipe(
203
+ id: string,
204
+ patch: Partial<Omit<RecipeInput, "workspaceId" | "sourceSessionId">>,
205
+ by: string | null = null
206
+ ): Recipe {
207
+ const current = getRecipe(id);
208
+ if (!current || current.archived_at) throw new Error("Recipe not found");
209
+ const v = validate(
210
+ {
211
+ workspaceId: current.workspace_id,
212
+ projectId: patch.projectId ?? current.project_id,
213
+ name: patch.name ?? current.name,
214
+ kind: patch.kind ?? current.kind,
215
+ brief: patch.brief ?? current.brief,
216
+ model: patch.model === undefined ? current.model : patch.model,
217
+ view: patch.view ?? current.view,
218
+ autoDone: patch.autoDone ?? current.auto_done,
219
+ maxMinutes:
220
+ patch.maxMinutes === undefined ? current.max_minutes : patch.maxMinutes,
221
+ },
222
+ id
223
+ );
224
+ const settings = settingsOf(v);
225
+ const before = settingsOf({
226
+ kind: current.kind,
227
+ model: current.model,
228
+ view: current.view,
229
+ autoDone: current.auto_done,
230
+ maxMinutes: current.max_minutes,
231
+ });
232
+ db.transaction(() => {
233
+ db.prepare(
234
+ `UPDATE recipes SET project_id = ?, name = ?, kind = ?, brief = ?, model = ?, view = ?, auto_done = ?,
235
+ max_minutes = ?, updated_by_session_id = ?, updated_at = datetime('now') WHERE id = ?`
236
+ ).run(
237
+ v.projectId,
238
+ v.name,
239
+ v.kind,
240
+ v.brief,
241
+ v.model,
242
+ v.view,
243
+ v.autoDone ? 1 : 0,
244
+ v.maxMinutes,
245
+ by,
246
+ id
247
+ );
248
+ if (v.brief !== current.brief || settings !== before)
249
+ addRevision(id, v.brief, settings, by);
250
+ db.prepare(
251
+ `UPDATE schedules SET name = ?, project_id = ?, updated_at = datetime('now') WHERE recipe_id = ?`
252
+ ).run(v.name, v.projectId, id);
253
+ })();
254
+ return getRecipe(id)!;
255
+ }
256
+
257
+ // History stays: the recipe leaves the list and its timer turns off.
258
+ export function archiveRecipe(id: string): void {
259
+ db.transaction(() => {
260
+ db.prepare(
261
+ `UPDATE recipes SET archived_at = datetime('now') WHERE id = ? AND archived_at IS NULL`
262
+ ).run(id);
263
+ db.prepare(
264
+ `UPDATE schedules SET enabled = 0, archived_at = datetime('now') WHERE recipe_id = ? AND archived_at IS NULL`
265
+ ).run(id);
266
+ })();
267
+ }
268
+
269
+ // "Clear" on the last-run card: the next run starts without a carry block.
270
+ export function clearCarry(id: string, now = Date.now()): void {
271
+ db.prepare(`UPDATE recipes SET carry_cleared_at = ? WHERE id = ?`).run(
272
+ now,
273
+ id
274
+ );
275
+ }
276
+
277
+ export function lastRevisionId(recipeId: string): number | null {
278
+ const row = db
279
+ .prepare(
280
+ `SELECT id FROM recipe_revisions WHERE recipe_id = ? ORDER BY id DESC LIMIT 1`
281
+ )
282
+ .get(recipeId) as { id: number } | undefined;
283
+ return row?.id ?? null;
284
+ }
285
+
286
+ export function getRun(id: number): RecipeRun | null {
287
+ const row = db.prepare(`SELECT * FROM recipe_runs WHERE id = ?`).get(id) as
288
+ | RunRow
289
+ | undefined;
290
+ return row ? toRun(row) : null;
291
+ }
292
+
293
+ export function runForSession(sessionId: string): RecipeRun | null {
294
+ const row = db
295
+ .prepare(`SELECT * FROM recipe_runs WHERE session_id = ?`)
296
+ .get(sessionId) as RunRow | undefined;
297
+ return row ? toRun(row) : null;
298
+ }
299
+
300
+ export function listRuns(
301
+ recipeId: string,
302
+ opts: { before?: number; limit?: number } = {}
303
+ ): RecipeRun[] {
304
+ const limit = Math.min(Math.max(opts.limit ?? 20, 1), 100);
305
+ const rows = db
306
+ .prepare(
307
+ `SELECT * FROM recipe_runs WHERE recipe_id = ? AND id < ? ORDER BY id DESC LIMIT ?`
308
+ )
309
+ .all(recipeId, opts.before ?? Number.MAX_SAFE_INTEGER, limit) as RunRow[];
310
+ return rows.map(toRun);
311
+ }
312
+
313
+ export function liveRun(recipeId: string): RecipeRun | null {
314
+ const row = db
315
+ .prepare(
316
+ `SELECT * FROM recipe_runs WHERE recipe_id = ? AND status IN ('queued','starting','running')`
317
+ )
318
+ .get(recipeId) as RunRow | undefined;
319
+ return row ? toRun(row) : null;
320
+ }
321
+
322
+ export function lastRun(recipeId: string): RecipeRun | null {
323
+ return listRuns(recipeId, { limit: 1 })[0] ?? null;
324
+ }
325
+
326
+ // The newest settled run with this status, after the carry was last cleared.
327
+ export function lastSettled(
328
+ recipeId: string,
329
+ clearedAt: number | null,
330
+ status?: RunStatus
331
+ ): RecipeRun | null {
332
+ const row = db
333
+ .prepare(
334
+ `SELECT * FROM recipe_runs WHERE recipe_id = ?
335
+ AND status NOT IN ('queued','starting','running','skipped','cancelled')
336
+ ${status ? "AND status = ?" : ""}
337
+ ${clearedAt ? "AND strftime('%s', COALESCE(ended_at, created_at)) * 1000 > ?" : ""}
338
+ ORDER BY id DESC LIMIT 1`
339
+ )
340
+ .get(
341
+ ...[
342
+ recipeId,
343
+ ...(status ? [status] : []),
344
+ ...(clearedAt ? [clearedAt] : []),
345
+ ]
346
+ ) as RunRow | undefined;
347
+ return row ? toRun(row) : null;
348
+ }
349
+
350
+ export function totals(recipeId: string): RecipeTotals {
351
+ const row = db
352
+ .prepare(
353
+ `SELECT COUNT(*) AS runs,
354
+ SUM(status = 'succeeded') AS succeeded,
355
+ SUM(status IN ('failed','blocked','timed_out')) AS failed,
356
+ COALESCE(SUM(cost_usd), 0) AS cost,
357
+ AVG(duration_ms) AS avg_ms
358
+ FROM recipe_runs WHERE recipe_id = ? AND status NOT IN ('skipped','cancelled')`
359
+ )
360
+ .get(recipeId) as {
361
+ runs: number;
362
+ succeeded: number | null;
363
+ failed: number | null;
364
+ cost: number;
365
+ avg_ms: number | null;
366
+ };
367
+ return {
368
+ runs: row.runs,
369
+ succeeded: row.succeeded ?? 0,
370
+ failed: row.failed ?? 0,
371
+ costUsd: row.cost,
372
+ avgMs: row.avg_ms === null ? null : Math.round(row.avg_ms),
373
+ };
374
+ }
375
+
376
+ export { isLive };
377
+
378
+ export const hasLiveRun = (recipeId: string): boolean => !!liveRun(recipeId);
@@ -0,0 +1,49 @@
1
+ // Shared setup for the recipes tests: a workspace and project, a recipe in
2
+ // them, and a session standing in for a run's.
3
+
4
+ import { randomUUID } from "crypto";
5
+ import { db } from "../db";
6
+ import { createProject } from "../projects";
7
+ import { createWorkspace, setProjectWorkspace } from "../workspaces";
8
+ import { createRecipe } from "./store";
9
+ import type { Recipe, RecipeInput } from "./types";
10
+
11
+ export function workspaceWithProject() {
12
+ const ws = createWorkspace(`ws-${randomUUID().slice(0, 6)}`);
13
+ const project = createProject({
14
+ name: `p-${randomUUID().slice(0, 6)}`,
15
+ workingDirectory: "/tmp",
16
+ });
17
+ setProjectWorkspace(project.id, ws.id);
18
+ return { ws, project };
19
+ }
20
+
21
+ export function makeRecipe(
22
+ fields: Partial<RecipeInput> = {},
23
+ by: string | null = null
24
+ ): Recipe {
25
+ const { ws, project } = workspaceWithProject();
26
+ return createRecipe(
27
+ {
28
+ workspaceId: ws.id,
29
+ projectId: project.id,
30
+ name: "TestFlight build",
31
+ kind: "session",
32
+ brief: "Run scripts/testflight and judge the result.",
33
+ ...fields,
34
+ },
35
+ by
36
+ );
37
+ }
38
+
39
+ export function makeSession(
40
+ fields: Record<string, string | number | null> = {}
41
+ ) {
42
+ const id = randomUUID();
43
+ db.prepare(
44
+ `INSERT INTO sessions (id, name, tmux_name, working_directory, view) VALUES (?, 's', ?, '/tmp', 'chat')`
45
+ ).run(id, `claude-${id}`);
46
+ for (const [k, v] of Object.entries(fields))
47
+ db.prepare(`UPDATE sessions SET ${k} = ? WHERE id = ?`).run(v, id);
48
+ return id;
49
+ }
@@ -0,0 +1,107 @@
1
+ // A recipe's timer is a `schedules` row of kind "recipe": the Schedules
2
+ // engine (ticker, lease, slot claiming, catch-up, brake retry) runs it.
3
+
4
+ import { randomUUID } from "crypto";
5
+ import { db } from "../db";
6
+ import {
7
+ cronError,
8
+ DEFAULT_TIMEZONE,
9
+ describeCron,
10
+ isTimezone,
11
+ MIN_GAP_MINUTES,
12
+ minGapMinutes,
13
+ } from "../schedules/cron";
14
+ import { nextRunAt } from "../schedules/scheduler";
15
+ import { getSchedule } from "../schedules/store";
16
+ import type { Recipe, RecipeTimer } from "./types";
17
+
18
+ export interface TimerInput {
19
+ cron: string;
20
+ timezone?: string;
21
+ enabled?: boolean;
22
+ }
23
+
24
+ function timerRowId(recipeId: string): string | null {
25
+ const row = db
26
+ .prepare(
27
+ `SELECT id FROM schedules WHERE recipe_id = ? AND archived_at IS NULL`
28
+ )
29
+ .get(recipeId) as { id: string } | undefined;
30
+ return row?.id ?? null;
31
+ }
32
+
33
+ export function getTimer(
34
+ recipeId: string,
35
+ now = Date.now()
36
+ ): RecipeTimer | null {
37
+ const id = timerRowId(recipeId);
38
+ const s = id ? getSchedule(id) : null;
39
+ if (!s) return null;
40
+ return {
41
+ scheduleId: s.id,
42
+ cron: s.cron,
43
+ timezone: s.timezone,
44
+ enabled: s.enabled,
45
+ description: describeCron(s.cron),
46
+ nextRunAt: nextRunAt(s, now),
47
+ };
48
+ }
49
+
50
+ // Only Saad sets a recurring trigger: the orchestrator's tools have no way
51
+ // in here.
52
+ export function setTimer(
53
+ recipe: Recipe,
54
+ input: TimerInput | null,
55
+ now = Date.now()
56
+ ): RecipeTimer | null {
57
+ const existing = timerRowId(recipe.id);
58
+ if (input === null) {
59
+ if (existing)
60
+ db.prepare(
61
+ `UPDATE schedules SET enabled = 0, archived_at = datetime('now') WHERE id = ?`
62
+ ).run(existing);
63
+ return null;
64
+ }
65
+ const cron = input.cron.trim().replace(/\s+/g, " ");
66
+ const timezone = input.timezone?.trim() || DEFAULT_TIMEZONE;
67
+ if (!isTimezone(timezone)) throw new Error(`Unknown time zone "${timezone}"`);
68
+ const bad = cronError(cron);
69
+ if (bad) throw new Error(bad);
70
+ if (minGapMinutes(cron, timezone) < MIN_GAP_MINUTES)
71
+ throw new Error("A recipe runs at most once an hour");
72
+ const enabled = input.enabled ?? true;
73
+ if (!existing) {
74
+ db.prepare(
75
+ `INSERT INTO schedules (id, workspace_id, project_id, name, cron, timezone, prompt, kind, recipe_id, enabled, armed_at)
76
+ VALUES (?, ?, ?, ?, ?, ?, '', 'recipe', ?, ?, ?)`
77
+ ).run(
78
+ randomUUID(),
79
+ recipe.workspace_id,
80
+ recipe.project_id,
81
+ recipe.name,
82
+ cron,
83
+ timezone,
84
+ recipe.id,
85
+ enabled ? 1 : 0,
86
+ now
87
+ );
88
+ } else {
89
+ const current = getSchedule(existing)!;
90
+ // A new time or a fresh enable counts from now: no earlier slot is
91
+ // "missed".
92
+ const rearm =
93
+ cron !== current.cron ||
94
+ timezone !== current.timezone ||
95
+ (enabled && !current.enabled);
96
+ db.prepare(
97
+ `UPDATE schedules SET cron = ?, timezone = ?, enabled = ?, armed_at = ?, updated_at = datetime('now') WHERE id = ?`
98
+ ).run(
99
+ cron,
100
+ timezone,
101
+ enabled ? 1 : 0,
102
+ rearm ? now : current.armed_at,
103
+ existing
104
+ );
105
+ }
106
+ return getTimer(recipe.id, now);
107
+ }
@@ -0,0 +1,116 @@
1
+ // Recipes: a saved brief that starts a fresh task or session each run.
2
+
3
+ export const RECIPE_KINDS = ["task", "session"] as const;
4
+ export type RecipeKind = (typeof RECIPE_KINDS)[number];
5
+
6
+ export const MAX_BRIEF = 4000;
7
+ export const MAX_NOTE = 1000;
8
+ export const MAX_NAME = 80;
9
+
10
+ export interface Recipe {
11
+ id: string;
12
+ workspace_id: string;
13
+ project_id: string;
14
+ name: string;
15
+ kind: RecipeKind;
16
+ brief: string;
17
+ // "provider/model", or a JSON role map (see lib/models); null: the defaults.
18
+ model: string | null;
19
+ view: "chat" | "terminal";
20
+ auto_done: boolean;
21
+ max_minutes: number | null;
22
+ carry_cleared_at: number | null;
23
+ source_session_id: string | null;
24
+ created_by_session_id: string | null;
25
+ updated_by_session_id: string | null;
26
+ archived_at: string | null;
27
+ created_at: string;
28
+ updated_at: string;
29
+ }
30
+
31
+ export const RUN_TRIGGERS = [
32
+ "manual",
33
+ "tool",
34
+ "cli",
35
+ "timer",
36
+ "catch-up",
37
+ ] as const;
38
+ export type RunTriggerKind = (typeof RUN_TRIGGERS)[number];
39
+
40
+ export type RunStatus =
41
+ | "queued"
42
+ | "starting"
43
+ | "running"
44
+ | "succeeded"
45
+ | "failed"
46
+ | "blocked"
47
+ | "timed_out"
48
+ | "cancelled"
49
+ | "skipped";
50
+
51
+ export const LIVE_STATUSES: RunStatus[] = ["queued", "starting", "running"];
52
+ export const isLive = (s: RunStatus) => LIVE_STATUSES.includes(s);
53
+
54
+ export interface RecipeRun {
55
+ id: number;
56
+ recipe_id: string;
57
+ seq: number;
58
+ trigger: RunTriggerKind;
59
+ triggered_by_session_id: string | null;
60
+ schedule_run_id: number | null;
61
+ request_id: string | null;
62
+ note: string | null;
63
+ prompt: string;
64
+ revision_id: number | null;
65
+ status: RunStatus;
66
+ session_id: string | null;
67
+ pr_number: number | null;
68
+ pr_url: string | null;
69
+ summary: string | null;
70
+ outputs: Record<string, string> | null;
71
+ summary_source: "line" | "model" | "state" | null;
72
+ error: string | null;
73
+ cost_usd: number | null;
74
+ duration_ms: number | null;
75
+ started_at: string | null;
76
+ ended_at: string | null;
77
+ created_at: string;
78
+ }
79
+
80
+ export interface RecipeInput {
81
+ workspaceId: string;
82
+ projectId: string;
83
+ name: string;
84
+ kind: RecipeKind;
85
+ brief: string;
86
+ model?: string | null;
87
+ view?: "chat" | "terminal";
88
+ autoDone?: boolean;
89
+ maxMinutes?: number | null;
90
+ sourceSessionId?: string | null;
91
+ }
92
+
93
+ export interface RecipeTimer {
94
+ scheduleId: string;
95
+ cron: string;
96
+ timezone: string;
97
+ enabled: boolean;
98
+ description: string;
99
+ nextRunAt: number | null;
100
+ }
101
+
102
+ export interface RecipeTotals {
103
+ runs: number;
104
+ succeeded: number;
105
+ failed: number;
106
+ costUsd: number;
107
+ avgMs: number | null;
108
+ }
109
+
110
+ export interface RecipeView extends Recipe {
111
+ projectName: string | null;
112
+ lastRun: RecipeRun | null;
113
+ liveRun: RecipeRun | null;
114
+ timer: RecipeTimer | null;
115
+ totals: RecipeTotals;
116
+ }
@@ -78,7 +78,10 @@ export function scheduleView(s: Schedule, now = Date.now()): ScheduleView {
78
78
  }
79
79
 
80
80
  export function listScheduleViews(workspaceId?: string | null): ScheduleView[] {
81
- return listSchedules(workspaceId).map((s) => scheduleView(s));
81
+ // A recipe's timer shows on the recipe, not in this list.
82
+ return listSchedules(workspaceId)
83
+ .filter((s) => s.kind !== "recipe")
84
+ .map((s) => scheduleView(s));
82
85
  }
83
86
 
84
87
  // `aos schedule add --session <ref> --every 30m "<prompt>"`: the session by