@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
package/lib/db/migrations.ts
CHANGED
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
TABLES_WATCHED_56,
|
|
6
6
|
TABLES_WATCHED_58,
|
|
7
7
|
TABLES_WATCHED_78,
|
|
8
|
+
TABLES_WATCHED_80,
|
|
8
9
|
} from "./changes";
|
|
9
10
|
import type Database from "better-sqlite3";
|
|
11
|
+
import { moveSchedulesToRecipes } from "./schedules-move";
|
|
10
12
|
|
|
11
13
|
interface Migration {
|
|
12
14
|
id: number;
|
|
@@ -1830,6 +1832,147 @@ const migrations: Migration[] = [
|
|
|
1830
1832
|
if (!has) db.exec(`ALTER TABLE task_queue ADD COLUMN agent_type TEXT`);
|
|
1831
1833
|
},
|
|
1832
1834
|
},
|
|
1835
|
+
{
|
|
1836
|
+
id: 80,
|
|
1837
|
+
name: "recipes",
|
|
1838
|
+
up: (db) => {
|
|
1839
|
+
// Recipes: a saved brief that starts a fresh task or session each run
|
|
1840
|
+
// (lib/recipes), and the features a plan splits into pieces
|
|
1841
|
+
// (lib/plans). All additive. A run or piece finds its session by
|
|
1842
|
+
// session_id, so sessions gains no column.
|
|
1843
|
+
db.exec(`
|
|
1844
|
+
CREATE TABLE IF NOT EXISTS recipes (
|
|
1845
|
+
id TEXT PRIMARY KEY,
|
|
1846
|
+
workspace_id TEXT NOT NULL,
|
|
1847
|
+
project_id TEXT NOT NULL,
|
|
1848
|
+
name TEXT NOT NULL,
|
|
1849
|
+
kind TEXT NOT NULL,
|
|
1850
|
+
brief TEXT NOT NULL,
|
|
1851
|
+
model TEXT,
|
|
1852
|
+
view TEXT NOT NULL DEFAULT 'chat',
|
|
1853
|
+
auto_done INTEGER NOT NULL DEFAULT 1,
|
|
1854
|
+
max_minutes INTEGER,
|
|
1855
|
+
carry_cleared_at INTEGER,
|
|
1856
|
+
source_session_id TEXT,
|
|
1857
|
+
created_by_session_id TEXT,
|
|
1858
|
+
updated_by_session_id TEXT,
|
|
1859
|
+
archived_at TEXT,
|
|
1860
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1861
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1862
|
+
);
|
|
1863
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_name
|
|
1864
|
+
ON recipes(workspace_id, name COLLATE NOCASE) WHERE archived_at IS NULL;
|
|
1865
|
+
|
|
1866
|
+
CREATE TABLE IF NOT EXISTS recipe_runs (
|
|
1867
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1868
|
+
recipe_id TEXT NOT NULL,
|
|
1869
|
+
seq INTEGER NOT NULL,
|
|
1870
|
+
trigger TEXT NOT NULL,
|
|
1871
|
+
triggered_by_session_id TEXT,
|
|
1872
|
+
schedule_run_id INTEGER,
|
|
1873
|
+
request_id TEXT,
|
|
1874
|
+
note TEXT,
|
|
1875
|
+
prompt TEXT NOT NULL,
|
|
1876
|
+
revision_id INTEGER,
|
|
1877
|
+
status TEXT NOT NULL,
|
|
1878
|
+
session_id TEXT,
|
|
1879
|
+
pr_number INTEGER,
|
|
1880
|
+
pr_url TEXT,
|
|
1881
|
+
summary TEXT,
|
|
1882
|
+
outputs TEXT,
|
|
1883
|
+
summary_source TEXT,
|
|
1884
|
+
error TEXT,
|
|
1885
|
+
cost_usd REAL,
|
|
1886
|
+
duration_ms INTEGER,
|
|
1887
|
+
started_at TEXT,
|
|
1888
|
+
ended_at TEXT,
|
|
1889
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1890
|
+
UNIQUE (recipe_id, seq)
|
|
1891
|
+
);
|
|
1892
|
+
CREATE INDEX IF NOT EXISTS idx_recipe_runs_recipe
|
|
1893
|
+
ON recipe_runs(recipe_id, id DESC);
|
|
1894
|
+
CREATE INDEX IF NOT EXISTS idx_recipe_runs_session
|
|
1895
|
+
ON recipe_runs(session_id);
|
|
1896
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_recipe_runs_one_live
|
|
1897
|
+
ON recipe_runs(recipe_id)
|
|
1898
|
+
WHERE status IN ('queued','starting','running');
|
|
1899
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_recipe_runs_request
|
|
1900
|
+
ON recipe_runs(request_id) WHERE request_id IS NOT NULL;
|
|
1901
|
+
|
|
1902
|
+
CREATE TABLE IF NOT EXISTS recipe_revisions (
|
|
1903
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1904
|
+
recipe_id TEXT NOT NULL,
|
|
1905
|
+
brief TEXT NOT NULL,
|
|
1906
|
+
settings TEXT NOT NULL,
|
|
1907
|
+
edited_by_session_id TEXT,
|
|
1908
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1909
|
+
);
|
|
1910
|
+
|
|
1911
|
+
CREATE TABLE IF NOT EXISTS features (
|
|
1912
|
+
id TEXT PRIMARY KEY,
|
|
1913
|
+
workspace_id TEXT NOT NULL,
|
|
1914
|
+
name TEXT NOT NULL,
|
|
1915
|
+
plan_session_id TEXT NOT NULL,
|
|
1916
|
+
plan_text TEXT NOT NULL,
|
|
1917
|
+
status TEXT NOT NULL,
|
|
1918
|
+
done_at TEXT,
|
|
1919
|
+
cost_usd REAL,
|
|
1920
|
+
duration_ms INTEGER,
|
|
1921
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1922
|
+
);
|
|
1923
|
+
CREATE INDEX IF NOT EXISTS idx_features_plan
|
|
1924
|
+
ON features(plan_session_id);
|
|
1925
|
+
|
|
1926
|
+
CREATE TABLE IF NOT EXISTS feature_pieces (
|
|
1927
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1928
|
+
feature_id TEXT NOT NULL,
|
|
1929
|
+
position INTEGER NOT NULL,
|
|
1930
|
+
name TEXT NOT NULL,
|
|
1931
|
+
slice TEXT NOT NULL,
|
|
1932
|
+
project_id TEXT NOT NULL,
|
|
1933
|
+
after_position INTEGER,
|
|
1934
|
+
after_kind TEXT,
|
|
1935
|
+
reasons TEXT,
|
|
1936
|
+
owns TEXT,
|
|
1937
|
+
status TEXT NOT NULL,
|
|
1938
|
+
session_id TEXT,
|
|
1939
|
+
note TEXT,
|
|
1940
|
+
UNIQUE (feature_id, position)
|
|
1941
|
+
);
|
|
1942
|
+
CREATE INDEX IF NOT EXISTS idx_feature_pieces_session
|
|
1943
|
+
ON feature_pieces(session_id);
|
|
1944
|
+
`);
|
|
1945
|
+
const cols = (
|
|
1946
|
+
db.prepare(`PRAGMA table_info(schedules)`).all() as { name: string }[]
|
|
1947
|
+
).map((c) => c.name);
|
|
1948
|
+
if (!cols.includes("recipe_id"))
|
|
1949
|
+
db.exec(`ALTER TABLE schedules ADD COLUMN recipe_id TEXT`);
|
|
1950
|
+
if (!cols.includes("migrated_from_kind"))
|
|
1951
|
+
db.exec(`ALTER TABLE schedules ADD COLUMN migrated_from_kind TEXT`);
|
|
1952
|
+
const plan = (
|
|
1953
|
+
db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
|
|
1954
|
+
).map((c) => c.name);
|
|
1955
|
+
// Edits Saad made to a plan's pieces before approving it (merge, drop).
|
|
1956
|
+
if (!plan.includes("plan_edits"))
|
|
1957
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN plan_edits TEXT`);
|
|
1958
|
+
installChangeTriggers(db, TABLES_WATCHED_80);
|
|
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
|
+
},
|
|
1833
1976
|
];
|
|
1834
1977
|
|
|
1835
1978
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/queries.ts
CHANGED
|
@@ -32,7 +32,10 @@ export function sessionListColumns(db: Database.Database, alias = ""): string {
|
|
|
32
32
|
.split(",")
|
|
33
33
|
.map((c) => `${prefix}${c}`)
|
|
34
34
|
.join(", ");
|
|
35
|
-
|
|
35
|
+
// The recipe run or feature piece a session is, as JSON, for its row's chip.
|
|
36
|
+
const recipe = `(SELECT json_object('id', r.id, 'name', r.name, 'seq', rr.seq) FROM recipe_runs rr JOIN recipes r ON r.id = rr.recipe_id WHERE rr.session_id = ${alias || "sessions"}.id)`;
|
|
37
|
+
const piece = `(SELECT json_object('feature', f.id, 'name', f.name, 'position', fp.position, 'total', (SELECT COUNT(*) FROM feature_pieces WHERE feature_id = f.id AND status != 'dropped')) FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id WHERE fp.session_id = ${alias || "sessions"}.id)`;
|
|
38
|
+
return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief, ${recipe} AS recipe_run, ${piece} AS feature_piece`;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
export const queries = {
|
|
@@ -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/lib/db/types.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface Session {
|
|
|
26
26
|
started_by?: string | null;
|
|
27
27
|
// 1 planned, 0 no plan, null not stated; why, in the starter's words.
|
|
28
28
|
plan_decided?: number | null;
|
|
29
|
+
// JSON: the pieces Saad merged or dropped before approving the plan.
|
|
30
|
+
plan_edits?: string | null;
|
|
29
31
|
plan_reason?: string | null;
|
|
30
32
|
system_prompt: string | null;
|
|
31
33
|
group_path: string; // Deprecated - use project_id
|
|
@@ -55,6 +57,10 @@ export interface Session {
|
|
|
55
57
|
task_brief?: string | null;
|
|
56
58
|
// Lists carry this instead of the brief itself (0/1).
|
|
57
59
|
has_brief?: number;
|
|
60
|
+
// JSON, in lists: the recipe run this session is ({id, name, seq}), and the
|
|
61
|
+
// feature piece it builds ({feature, name, position, total}).
|
|
62
|
+
recipe_run?: string | null;
|
|
63
|
+
feature_piece?: string | null;
|
|
58
64
|
// How the session is shown: a chat conversation or a terminal.
|
|
59
65
|
view: "chat" | "terminal";
|
|
60
66
|
chat_access: ChatAccess;
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { ANTHROPIC } from "./roles";
|
|
|
21
21
|
import { queueEvent } from "../orchestrator/events";
|
|
22
22
|
import { addNote } from "../orchestrator/notes";
|
|
23
23
|
import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
|
|
24
|
+
import { PIECES_PROMPT, pieceBrief } from "../plans/pieces";
|
|
25
|
+
import { createFeature, planFor, planPathOf, splitOf } from "../plans/features";
|
|
24
26
|
|
|
25
27
|
export const PLAN_FILE = ".agentos/plan.md";
|
|
26
28
|
|
|
@@ -31,7 +33,9 @@ export function planPrompt(task: string, only = false): string {
|
|
|
31
33
|
return `${task}
|
|
32
34
|
|
|
33
35
|
---
|
|
34
|
-
Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. Don't end your turn while subagents or background commands you started are still running; wait for their results and write the plan in the same turn. End the turn once the plan is written: ${after}
|
|
36
|
+
Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. Don't end your turn while subagents or background commands you started are still running; wait for their results and write the plan in the same turn. End the turn once the plan is written: ${after}.
|
|
37
|
+
|
|
38
|
+
${PIECES_PROMPT}`;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export const PLAN_RETRY_PROMPT = `Write the plan to \`${PLAN_FILE}\` now, plan only.`;
|
|
@@ -42,10 +46,16 @@ export const planRetryMessageId = (sessionId: string) =>
|
|
|
42
46
|
// treated as having nothing running and re-prompted.
|
|
43
47
|
export const PLAN_BACKGROUND_WAIT_MS = 20 * 60 * 1000;
|
|
44
48
|
|
|
45
|
-
export function implementPrompt(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
export function implementPrompt(
|
|
50
|
+
planned: boolean,
|
|
51
|
+
amendment?: string,
|
|
52
|
+
piece?: string
|
|
53
|
+
): string {
|
|
54
|
+
const base = piece
|
|
55
|
+
? `The plan is in \`${PLAN_FILE}\`. ${piece}\nFinish it as the task's instructions above say. Where the plan turns out wrong, do what's right and say so.`
|
|
56
|
+
: planned
|
|
57
|
+
? `The plan is in \`${PLAN_FILE}\`. Implement it now, finishing the task as its instructions above say. Where the plan turns out wrong, do what's right and say so.`
|
|
58
|
+
: `No plan was written to \`${PLAN_FILE}\`. Plan briefly, then implement the task, finishing it as its instructions above say.`;
|
|
49
59
|
return amendment
|
|
50
60
|
? `${base}\n\nThe plan was approved with this change, which is now part of the task's scope:\n${amendment}`
|
|
51
61
|
: base;
|
|
@@ -87,6 +97,18 @@ export function planSummary(text: string, max = 5): string[] {
|
|
|
87
97
|
return picked.map((l) => trim(l.text));
|
|
88
98
|
}
|
|
89
99
|
|
|
100
|
+
function piecesLine(r: NonNullable<ReturnType<typeof planFor>>): string {
|
|
101
|
+
const parts = r.pieces.map((p) => {
|
|
102
|
+
const after = p.afterPosition
|
|
103
|
+
? ` after ${p.afterPosition} is ${p.afterKind}`
|
|
104
|
+
: "";
|
|
105
|
+
const why = p.reasons.length ? ` (${p.reasons.join("+")})` : "";
|
|
106
|
+
return `${p.position} ${p.name}${after}${why}`;
|
|
107
|
+
});
|
|
108
|
+
const folded = r.folded.length ? `, ${r.folded.length} folded` : "";
|
|
109
|
+
return `${r.pieces.length} pieces${folded}: ${parts.join("; ")}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
90
112
|
// Tells the orchestrator that started a task its plan is written, once.
|
|
91
113
|
function announcePlan(s: Session, ref: string | null, retried = false): void {
|
|
92
114
|
if (!s.started_by && !s.held_by_device) return;
|
|
@@ -105,6 +127,8 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
|
|
|
105
127
|
const summary = planSummary(fs.readFileSync(file, "utf8"));
|
|
106
128
|
head = `Task "${s.name}": plan written to ${file}; ${next}.`;
|
|
107
129
|
if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
|
|
130
|
+
const pieces = planFor(s, fs.readFileSync(file, "utf8"));
|
|
131
|
+
if (pieces && pieces.pieces.length > 1) head += `\n${piecesLine(pieces)}`;
|
|
108
132
|
} catch {
|
|
109
133
|
// No plan file: the line says so.
|
|
110
134
|
}
|
|
@@ -118,6 +142,14 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
|
|
|
118
142
|
queueEvent(workspaceId!, `plan-done:${s.id}`, s.id, head);
|
|
119
143
|
}
|
|
120
144
|
|
|
145
|
+
function readPlan(s: Session): string | null {
|
|
146
|
+
try {
|
|
147
|
+
return fs.readFileSync(path.join(s.worktree_path!, PLAN_FILE), "utf8");
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
121
153
|
export const implementMessageId = (sessionId: string) =>
|
|
122
154
|
`implement-${sessionId}`;
|
|
123
155
|
|
|
@@ -146,6 +178,9 @@ async function startImplementing(
|
|
|
146
178
|
override: taskOverride(s),
|
|
147
179
|
});
|
|
148
180
|
const id = implementMessageId(sessionId);
|
|
181
|
+
const planText = readPlan(s);
|
|
182
|
+
const split = planText ? splitOf(s, planText) : null;
|
|
183
|
+
const workspaceId = sessionWorkspace(s);
|
|
149
184
|
const claimed = db.transaction(() => {
|
|
150
185
|
const changed = db
|
|
151
186
|
.prepare(
|
|
@@ -155,11 +190,26 @@ async function startImplementing(
|
|
|
155
190
|
.run(sessionId, from).changes;
|
|
156
191
|
if (!changed) return false;
|
|
157
192
|
setSessionModel(sessionId, routed);
|
|
193
|
+
let piece: string | undefined;
|
|
194
|
+
let note = amendment;
|
|
195
|
+
if (split?.notBuilding.length)
|
|
196
|
+
note = [amendment, `Not building: ${split.notBuilding.join("; ")}`]
|
|
197
|
+
.filter(Boolean)
|
|
198
|
+
.join("\n");
|
|
199
|
+
if (split && split.pieces.length > 1 && planText && workspaceId) {
|
|
200
|
+
const feature = createFeature(s, planText, split.pieces, workspaceId);
|
|
201
|
+
piece = pieceBrief(
|
|
202
|
+
split.pieces.length,
|
|
203
|
+
split.pieces[0],
|
|
204
|
+
planPathOf(feature.id)
|
|
205
|
+
);
|
|
206
|
+
}
|
|
158
207
|
enqueue(sessionId, {
|
|
159
208
|
id,
|
|
160
209
|
text: implementPrompt(
|
|
161
210
|
fs.existsSync(path.join(s.worktree_path!, PLAN_FILE)),
|
|
162
|
-
|
|
211
|
+
note,
|
|
212
|
+
piece
|
|
163
213
|
),
|
|
164
214
|
origin: { kind: "decision", label: "Plan step" },
|
|
165
215
|
});
|
|
@@ -308,6 +358,17 @@ export async function handoffAfterPlan(
|
|
|
308
358
|
} catch (e) {
|
|
309
359
|
console.error("plan-done event failed:", e);
|
|
310
360
|
}
|
|
361
|
+
try {
|
|
362
|
+
const text = readPlan(s);
|
|
363
|
+
const planned = text ? planFor(s, text) : null;
|
|
364
|
+
(await import("../notify/push/events")).pushPlanAwaiting(
|
|
365
|
+
s.id,
|
|
366
|
+
s.name,
|
|
367
|
+
planned?.pieces.length ?? 1
|
|
368
|
+
);
|
|
369
|
+
} catch (e) {
|
|
370
|
+
console.error("plan push failed:", e);
|
|
371
|
+
}
|
|
311
372
|
// The worker has nothing left to do: it ends at this turn boundary, and
|
|
312
373
|
// the next message (an approval, or a revision) starts one that resumes.
|
|
313
374
|
try {
|
package/lib/notify/copy.ts
CHANGED
|
@@ -13,14 +13,14 @@ import {
|
|
|
13
13
|
type PushEvent,
|
|
14
14
|
type PushLevel,
|
|
15
15
|
} from "./push-types";
|
|
16
|
-
import { safeId, safeKey } from "./redact";
|
|
16
|
+
import { safeId, safeKey, scrubText } from "./redact";
|
|
17
17
|
|
|
18
18
|
export interface CategoryDef {
|
|
19
19
|
level: PushLevel;
|
|
20
20
|
defaultOn: boolean;
|
|
21
21
|
/** Settings label. */
|
|
22
22
|
label: string;
|
|
23
|
-
/** The
|
|
23
|
+
/** The word or phrase shown as the body unless the sender names one. */
|
|
24
24
|
word: string;
|
|
25
25
|
/** App path for an id, or the fallback when the id is missing or unsafe. */
|
|
26
26
|
link: (id: string | null) => string;
|
|
@@ -51,6 +51,13 @@ export const CATEGORIES: Record<PushCategory, CategoryDef> = {
|
|
|
51
51
|
word: "Ready for review",
|
|
52
52
|
link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
|
|
53
53
|
},
|
|
54
|
+
plan: {
|
|
55
|
+
level: "active",
|
|
56
|
+
defaultOn: true,
|
|
57
|
+
label: "Plan ready",
|
|
58
|
+
word: "Plan ready",
|
|
59
|
+
link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
|
|
60
|
+
},
|
|
54
61
|
ci: {
|
|
55
62
|
level: "active",
|
|
56
63
|
defaultOn: true,
|
|
@@ -108,9 +115,18 @@ export function enabledCategories(
|
|
|
108
115
|
: defaultCategories();
|
|
109
116
|
}
|
|
110
117
|
|
|
118
|
+
const NAMED_MAX = 80;
|
|
119
|
+
|
|
120
|
+
/** Text a sender named for the alert: one scrubbed line, or null. */
|
|
121
|
+
function named(text: string | null | undefined): string | null {
|
|
122
|
+
const clean = scrubText(text, NAMED_MAX);
|
|
123
|
+
return clean || null;
|
|
124
|
+
}
|
|
125
|
+
|
|
111
126
|
/**
|
|
112
|
-
* The v1 payload for an event. Built
|
|
113
|
-
* and
|
|
127
|
+
* The v1 payload for an event. Built from the registry, a vetted id and key,
|
|
128
|
+
* and at most a title and body the sender named (a plan's or feature's name),
|
|
129
|
+
* each scrubbed to one short line with no secret-shaped runs.
|
|
114
130
|
*/
|
|
115
131
|
export function buildPushPayload(event: PushEvent): PushEnvelopeV1 {
|
|
116
132
|
const def = CATEGORIES[event.category];
|
|
@@ -118,8 +134,8 @@ export function buildPushPayload(event: PushEvent): PushEnvelopeV1 {
|
|
|
118
134
|
return {
|
|
119
135
|
v: 1,
|
|
120
136
|
c: event.category,
|
|
121
|
-
t: PUSH_TITLE,
|
|
122
|
-
b: def.word,
|
|
137
|
+
t: named(event.title) ?? PUSH_TITLE,
|
|
138
|
+
b: named(event.body) ?? def.word,
|
|
123
139
|
l: def.link(id),
|
|
124
140
|
k: safeKey(event.key) ?? `${event.category}:${id ?? "-"}`,
|
|
125
141
|
p: def.level,
|
|
@@ -169,8 +185,8 @@ export function isAppLink(link: string): boolean {
|
|
|
169
185
|
}
|
|
170
186
|
|
|
171
187
|
/**
|
|
172
|
-
* True only for a payload the registry could have built:
|
|
173
|
-
*
|
|
188
|
+
* True only for a payload the registry could have built: a scrubbed title
|
|
189
|
+
* and body, a known link shape, a plain key. A sender checks this
|
|
174
190
|
* before it sends, so nothing else can leave the machine.
|
|
175
191
|
*/
|
|
176
192
|
export function isGenericPayload(p: unknown): p is PushEnvelopeV1 {
|
|
@@ -180,8 +196,10 @@ export function isGenericPayload(p: unknown): p is PushEnvelopeV1 {
|
|
|
180
196
|
const def = CATEGORIES[o.c];
|
|
181
197
|
return (
|
|
182
198
|
Object.keys(o).sort().join() === "b,c,k,l,p,t,v" &&
|
|
183
|
-
o.t ===
|
|
184
|
-
o.
|
|
199
|
+
typeof o.t === "string" &&
|
|
200
|
+
named(o.t) === o.t &&
|
|
201
|
+
typeof o.b === "string" &&
|
|
202
|
+
named(o.b) === o.b &&
|
|
185
203
|
o.p === def.level &&
|
|
186
204
|
typeof o.l === "string" &&
|
|
187
205
|
isAppLink(o.l) &&
|
|
@@ -52,8 +52,42 @@ export const pushCiFailed = (taskId: string, sha: string) =>
|
|
|
52
52
|
forTask("ci", "ci", taskId, sha);
|
|
53
53
|
export const pushReviewBlocked = (taskId: string, sha: string) =>
|
|
54
54
|
forTask("ci", "review-block", taskId, sha);
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// A piece of a feature is covered by the feature's own push when it closes.
|
|
56
|
+
const isFeaturePiece = (sessionId: string) =>
|
|
57
|
+
!!db
|
|
58
|
+
.prepare(`SELECT 1 FROM feature_pieces WHERE session_id = ?`)
|
|
59
|
+
.get(sessionId);
|
|
60
|
+
|
|
61
|
+
export const pushMerged = (taskId: string) => {
|
|
62
|
+
if (!isFeaturePiece(taskId)) forTask("merged", "merged", taskId);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const pushFeatureMerged = (featureId: string, name: string) =>
|
|
66
|
+
fire({
|
|
67
|
+
category: "merged",
|
|
68
|
+
id: null,
|
|
69
|
+
key: `feature:${featureId}`,
|
|
70
|
+
collapse: `group:feature:${featureId}`,
|
|
71
|
+
title: "Feature merged",
|
|
72
|
+
body: name,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const pushPlanAwaiting = (
|
|
76
|
+
taskId: string,
|
|
77
|
+
name: string,
|
|
78
|
+
pieces: number
|
|
79
|
+
) =>
|
|
80
|
+
fire(
|
|
81
|
+
{
|
|
82
|
+
category: "plan",
|
|
83
|
+
id: taskId,
|
|
84
|
+
key: `plan-ready:${taskId}`,
|
|
85
|
+
collapse: `plan:${taskId}`,
|
|
86
|
+
title: "Plan ready",
|
|
87
|
+
body: `${name}, ${pieces} ${pieces === 1 ? "piece" : "pieces"}`,
|
|
88
|
+
},
|
|
89
|
+
taskId
|
|
90
|
+
);
|
|
57
91
|
|
|
58
92
|
export const pushPlanReady = (sessionId: string, itemId: string) => {
|
|
59
93
|
// Only a task has a screen to open; a plain chat's plan goes to the list.
|