@saadnvd1/agent-os 0.3.534 → 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 CHANGED
@@ -969,8 +969,12 @@ name, a time (presets for hourly, daily, weekdays and weekly, or any
969
969
  five-field cron expression, in America/Chicago unless you pick another zone),
970
970
  a project and a prompt, and starts one of:
971
971
 
972
- - **Task**: a background task that ends in a pull request, as above.
973
- - **Session**: a chat session in the project that runs the prompt and stays.
972
+ - **Task** and **Session**: now recipes (Settings > Recipes) with a timer.
973
+ Migration 81 turned each existing recurring task or session schedule into a
974
+ recipe, with its run history; the Schedules form no longer offers them. A
975
+ one-off that still has one of these kinds is left as it was. To undo the
976
+ move, stop AgentOS and run
977
+ `npx tsx scripts/revert-schedules-move.ts --db <path to agent-os.db>`.
974
978
  - **Orchestrator**: the prompt, posted to the workspace's orchestrator as a
975
979
  message from "Schedule <name>", marked as a scheduled prompt so it never
976
980
  counts as an approval.
@@ -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 ?? "task"
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 && (
@@ -8,6 +8,7 @@ import {
8
8
  TABLES_WATCHED_80,
9
9
  } from "./changes";
10
10
  import type Database from "better-sqlite3";
11
+ import { moveSchedulesToRecipes } from "./schedules-move";
11
12
 
12
13
  interface Migration {
13
14
  id: number;
@@ -1957,6 +1958,21 @@ const migrations: Migration[] = [
1957
1958
  installChangeTriggers(db, TABLES_WATCHED_80);
1958
1959
  },
1959
1960
  },
1961
+ {
1962
+ id: 81,
1963
+ name: "schedules_into_recipes",
1964
+ up: (db) => {
1965
+ // Live task and session schedules become recipes with a timer, and
1966
+ // their runs become recipe runs (lib/db/schedules-move). Reversible:
1967
+ // scripts/revert-schedules-move.ts.
1968
+ const cols = (
1969
+ db.prepare(`PRAGMA table_info(schedules)`).all() as { name: string }[]
1970
+ ).map((c) => c.name);
1971
+ if (!cols.includes("recipe_id") || !cols.includes("migrated_from_kind"))
1972
+ throw new Error("migration 80 hasn't run");
1973
+ moveSchedulesToRecipes(db);
1974
+ },
1975
+ },
1960
1976
  ];
1961
1977
 
1962
1978
  // `upTo`: stop after this id (tests that start from an older database).
@@ -0,0 +1,220 @@
1
+ // Migration 81: live task and session schedules become recipes with a timer
2
+ // (lib/recipes), and their runs become recipe runs. `migrated_from_kind` on
3
+ // the schedule row is what makes it reversible: revertSchedulesMove.
4
+ //
5
+ // A one-off (run_at) and an archived schedule are left as they are: a timer
6
+ // repeats, and history of archived ones stays where it was.
7
+
8
+ import { randomUUID } from "crypto";
9
+ import type Database from "better-sqlite3";
10
+
11
+ interface ScheduleRow {
12
+ id: string;
13
+ workspace_id: string;
14
+ project_id: string | null;
15
+ name: string;
16
+ prompt: string;
17
+ kind: "task" | "session";
18
+ created_by_session_id: string | null;
19
+ created_at: string;
20
+ }
21
+
22
+ interface RunRow {
23
+ id: number;
24
+ trigger: string;
25
+ session_id: string;
26
+ created_at: string;
27
+ task_status: string | null;
28
+ pr_number: number | null;
29
+ pr_url: string | null;
30
+ session_exists: number;
31
+ }
32
+
33
+ const TRIGGER: Record<string, string> = {
34
+ schedule: "timer",
35
+ "catch-up": "catch-up",
36
+ manual: "manual",
37
+ };
38
+
39
+ const FINAL: Record<string, string> = {
40
+ merged: "succeeded",
41
+ done: "succeeded",
42
+ dropped: "failed",
43
+ };
44
+
45
+ // The name is unique among a workspace's live recipes, ignoring case.
46
+ function freeName(
47
+ db: Database.Database,
48
+ workspaceId: string,
49
+ name: string
50
+ ): string {
51
+ const taken = db.prepare(
52
+ `SELECT 1 FROM recipes WHERE workspace_id = ? AND archived_at IS NULL AND name = ? COLLATE NOCASE`
53
+ );
54
+ if (!taken.get(workspaceId, name)) return name;
55
+ for (let n = 1; ; n++) {
56
+ const candidate = `${name} (schedule${n > 1 ? ` ${n}` : ""})`;
57
+ if (!taken.get(workspaceId, candidate)) return candidate;
58
+ }
59
+ }
60
+
61
+ export function moveSchedulesToRecipes(db: Database.Database): {
62
+ schedules: number;
63
+ runs: number;
64
+ } {
65
+ const sessionCols = (
66
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
67
+ ).map((c) => c.name);
68
+ const pr = sessionCols.includes("pr_number")
69
+ ? "s.pr_number, s.pr_url"
70
+ : "NULL AS pr_number, NULL AS pr_url";
71
+ const hasTaskStatus = sessionCols.includes("task_status");
72
+
73
+ const schedules = db
74
+ .prepare(
75
+ `SELECT id, workspace_id, project_id, name, prompt, kind, created_by_session_id, created_at
76
+ FROM schedules
77
+ WHERE archived_at IS NULL AND kind IN ('task','session')
78
+ AND run_at IS NULL AND project_id IS NOT NULL
79
+ ORDER BY created_at, id`
80
+ )
81
+ .all() as ScheduleRow[];
82
+
83
+ const insertRecipe = db.prepare(
84
+ `INSERT INTO recipes (id, workspace_id, project_id, name, kind, brief, model, view, created_by_session_id, updated_by_session_id, created_at)
85
+ VALUES (?, ?, ?, ?, ?, ?, NULL, 'chat', ?, ?, ?)`
86
+ );
87
+ const insertRevision = db.prepare(
88
+ `INSERT INTO recipe_revisions (recipe_id, brief, settings, edited_by_session_id) VALUES (?, ?, ?, ?)`
89
+ );
90
+ const switchSchedule = db.prepare(
91
+ `UPDATE schedules SET kind = 'recipe', recipe_id = ?, migrated_from_kind = ?, updated_at = datetime('now') WHERE id = ?`
92
+ );
93
+ const runsOf = db.prepare(
94
+ `SELECT r.id, r.trigger, r.session_id, r.created_at,
95
+ ${hasTaskStatus ? "s.task_status" : "NULL"} AS task_status, ${pr},
96
+ s.id IS NOT NULL AS session_exists
97
+ FROM schedule_runs r LEFT JOIN sessions s ON s.id = r.session_id
98
+ WHERE r.schedule_id = ? AND r.session_id IS NOT NULL
99
+ ORDER BY r.id`
100
+ );
101
+ const insertRun = db.prepare(
102
+ `INSERT INTO recipe_runs (recipe_id, seq, trigger, schedule_run_id, prompt, status, session_id, pr_number, pr_url, summary, started_at, created_at)
103
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
104
+ );
105
+
106
+ let movedRuns = 0;
107
+ const move = db.transaction(() => {
108
+ for (const s of schedules) {
109
+ const recipeId = randomUUID();
110
+ insertRecipe.run(
111
+ recipeId,
112
+ s.workspace_id,
113
+ s.project_id,
114
+ freeName(db, s.workspace_id, s.name),
115
+ s.kind,
116
+ s.prompt,
117
+ s.created_by_session_id,
118
+ s.created_by_session_id,
119
+ s.created_at
120
+ );
121
+ insertRevision.run(
122
+ recipeId,
123
+ s.prompt,
124
+ JSON.stringify({
125
+ kind: s.kind,
126
+ model: null,
127
+ view: "chat",
128
+ auto_done: true,
129
+ max_minutes: null,
130
+ }),
131
+ s.created_by_session_id
132
+ );
133
+ switchSchedule.run(recipeId, s.kind, s.id);
134
+
135
+ const runs = runsOf.all(s.id) as RunRow[];
136
+ runs.forEach((r, i) => {
137
+ // One live run per recipe: only the latest may still be running
138
+ // (settle judges it); an earlier one with no recorded end is
139
+ // closed as cancelled rather than guessed a result.
140
+ const final = r.task_status ? FINAL[r.task_status] : undefined;
141
+ const last = i === runs.length - 1;
142
+ const status = final ?? (last ? "running" : "cancelled");
143
+ insertRun.run(
144
+ recipeId,
145
+ i + 1,
146
+ TRIGGER[r.trigger] ?? "timer",
147
+ r.id,
148
+ s.prompt,
149
+ status,
150
+ r.session_id,
151
+ r.pr_number,
152
+ r.pr_url,
153
+ status === "cancelled"
154
+ ? "Ran before recipes; its outcome wasn't recorded"
155
+ : null,
156
+ r.created_at,
157
+ r.created_at
158
+ );
159
+ movedRuns++;
160
+ });
161
+ }
162
+ });
163
+ move();
164
+ return { schedules: schedules.length, runs: movedRuns };
165
+ }
166
+
167
+ // Undo the move: each moved schedule gets its old kind (and the brief as it
168
+ // stands now) back, and the recipes the move made go, with their runs and
169
+ // revisions. The runs stay in schedule_runs, which the move never touched.
170
+ // Migration 81 stays recorded as applied, so a restart doesn't redo it.
171
+ export function revertSchedulesMove(db: Database.Database): {
172
+ schedules: number;
173
+ } {
174
+ const moved = db
175
+ .prepare(
176
+ `SELECT id, recipe_id, migrated_from_kind FROM schedules WHERE migrated_from_kind IS NOT NULL`
177
+ )
178
+ .all() as {
179
+ id: string;
180
+ recipe_id: string | null;
181
+ migrated_from_kind: string;
182
+ }[];
183
+ const recipe = db.prepare(
184
+ `SELECT brief, project_id FROM recipes WHERE id = ?`
185
+ );
186
+ const restore = db.prepare(
187
+ `UPDATE schedules SET kind = ?, recipe_id = NULL, migrated_from_kind = NULL,
188
+ prompt = COALESCE(?, prompt), project_id = COALESCE(?, project_id), updated_at = datetime('now')
189
+ WHERE id = ?`
190
+ );
191
+ const drop = (table: string) =>
192
+ db.prepare(`DELETE FROM ${table} WHERE recipe_id = ?`);
193
+ const [dropRuns, dropRevisions] = [
194
+ drop("recipe_runs"),
195
+ drop("recipe_revisions"),
196
+ ];
197
+ const dropRecipe = db.prepare(`DELETE FROM recipes WHERE id = ?`);
198
+
199
+ db.transaction(() => {
200
+ for (const s of moved) {
201
+ const r = s.recipe_id
202
+ ? (recipe.get(s.recipe_id) as
203
+ | { brief: string; project_id: string }
204
+ | undefined)
205
+ : undefined;
206
+ restore.run(
207
+ s.migrated_from_kind,
208
+ r?.brief ?? null,
209
+ r?.project_id ?? null,
210
+ s.id
211
+ );
212
+ if (s.recipe_id) {
213
+ dropRuns.run(s.recipe_id);
214
+ dropRevisions.run(s.recipe_id);
215
+ dropRecipe.run(s.recipe_id);
216
+ }
217
+ }
218
+ })();
219
+ return { schedules: moved.length };
220
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.534",
3
+ "version": "0.3.535",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Undoes migration 81: schedules that became recipes get their old kind
3
+ * (task or session) and brief back, and the recipes the move made are
4
+ * removed with their runs. Stop AgentOS first; it reads the database too.
5
+ *
6
+ * npx tsx scripts/revert-schedules-move.ts --db <path to agent-os.db>
7
+ *
8
+ * Migration 81 stays recorded as applied, so a restart won't redo the move.
9
+ */
10
+
11
+ import Database from "better-sqlite3";
12
+ import { revertSchedulesMove } from "../lib/db/schedules-move";
13
+
14
+ const args = process.argv.slice(2);
15
+ const dbPath = args[args.indexOf("--db") + 1];
16
+ if (!args.includes("--db") || !dbPath || dbPath.startsWith("--")) {
17
+ console.error("usage: revert-schedules-move.ts --db <path>");
18
+ process.exit(2);
19
+ }
20
+
21
+ const db = new Database(dbPath, { fileMustExist: true, timeout: 10000 });
22
+ const { schedules } = revertSchedulesMove(db);
23
+ console.log(`Restored ${schedules} schedule(s) to their old kind.`);
24
+ db.close();