@saadnvd1/agent-os 0.3.459 → 0.3.461

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 (45) hide show
  1. package/README.md +53 -1
  2. package/app/api/away/route.ts +56 -0
  3. package/app/api/away/seen/route.ts +12 -0
  4. package/app/api/health/route.ts +9 -0
  5. package/app/api/workspaces/[id]/route.ts +8 -1
  6. package/app/page.tsx +2 -0
  7. package/components/Away/AwayReport.tsx +16 -0
  8. package/components/Away/AwayReportDialog.tsx +193 -0
  9. package/components/Away/AwayToggle.tsx +91 -0
  10. package/components/Away/time.test.ts +20 -0
  11. package/components/Away/time.ts +32 -0
  12. package/components/Away/useAwayCommands.ts +43 -0
  13. package/components/Chat/Messages.tsx +1 -0
  14. package/components/CommandPalette/useAppCommands.ts +2 -0
  15. package/components/Pane/MobileTabBar.tsx +14 -1
  16. package/components/SessionList/SidebarHeader.tsx +2 -0
  17. package/components/Workspaces/WorkspaceSettings.tsx +9 -1
  18. package/data/away/index.ts +73 -0
  19. package/data/push/topics.ts +4 -0
  20. package/data/workspaces/index.ts +6 -1
  21. package/lib/away/brief.ts +23 -0
  22. package/lib/away/deploy.ts +173 -0
  23. package/lib/away/report.ts +398 -0
  24. package/lib/away/run.ts +445 -0
  25. package/lib/away/state.ts +201 -0
  26. package/lib/away/sweep.ts +216 -0
  27. package/lib/away/view.ts +51 -0
  28. package/lib/chat/events.ts +1 -1
  29. package/lib/db/changes.ts +3 -0
  30. package/lib/db/migrations.ts +101 -0
  31. package/lib/db/types.ts +9 -0
  32. package/lib/notify/index.ts +7 -0
  33. package/lib/orchestrator/brief.ts +2 -0
  34. package/lib/orchestrator/conditions.ts +10 -3
  35. package/lib/orchestrator/digest.ts +11 -8
  36. package/lib/orchestrator/notes.ts +8 -1
  37. package/lib/orchestrator/pr-wake.ts +385 -0
  38. package/lib/orchestrator/watcher.ts +12 -2
  39. package/lib/project-config/agentos.schema.json +24 -0
  40. package/lib/project-config/schema.ts +17 -0
  41. package/lib/tasks/gh.ts +2 -0
  42. package/lib/tasks/pr-activity.ts +295 -0
  43. package/lib/tasks/state.ts +9 -1
  44. package/lib/workspaces.ts +8 -2
  45. package/package.json +1 -1
@@ -0,0 +1,398 @@
1
+ /**
2
+ * "While you were away": what happened in every workspace between Away on
3
+ * and off, read straight from the database, no model involved. Built once
4
+ * as Away ends and kept with the period, so it reads the same later.
5
+ */
6
+
7
+ import { db } from "../db";
8
+ import { listWorkspaces } from "../workspaces";
9
+ import { HARD_LINES, openAsks } from "../orchestrator/asks";
10
+ import {
11
+ checksBySession,
12
+ clip,
13
+ failuresBySession,
14
+ type CheckRow,
15
+ type FailureRow,
16
+ } from "../orchestrator/digest";
17
+ import type { AskKind } from "../orchestrator/ask-view";
18
+ import { clockTime, fromSql, type AwayRow } from "./state";
19
+
20
+ export type DeployState = "deployed" | "failed" | "pending" | "unchecked";
21
+
22
+ export interface MergedItem {
23
+ task: string;
24
+ project: string;
25
+ pr: number | null;
26
+ url: string | null;
27
+ deploy: DeployState;
28
+ deployDetail: string | null;
29
+ }
30
+
31
+ export interface InFlightItem {
32
+ task: string;
33
+ project: string;
34
+ pr: number | null;
35
+ url: string | null;
36
+ why: string;
37
+ }
38
+
39
+ export interface FailedItem {
40
+ what: string;
41
+ url: string | null;
42
+ }
43
+
44
+ export interface WaitingItem {
45
+ askId: number;
46
+ kind: AskKind;
47
+ title: string;
48
+ hardLine: boolean;
49
+ url: string | null;
50
+ }
51
+
52
+ export interface WorkspaceReport {
53
+ id: string;
54
+ name: string;
55
+ merged: MergedItem[];
56
+ inFlight: InFlightItem[];
57
+ failed: FailedItem[];
58
+ waiting: WaitingItem[];
59
+ }
60
+
61
+ export interface AwayReport {
62
+ awayId: number;
63
+ startedAt: string;
64
+ endedAt: string;
65
+ endedBy: string | null;
66
+ setBy: string;
67
+ // Workspaces with anything to say, in sidebar order.
68
+ workspaces: WorkspaceReport[];
69
+ totals: {
70
+ merged: number;
71
+ deployed: number;
72
+ deployFailed: number;
73
+ inFlight: number;
74
+ failed: number;
75
+ waiting: number;
76
+ hardLines: number;
77
+ };
78
+ }
79
+
80
+ const sha7 = (sha: string) => sha.slice(0, 7);
81
+
82
+ // Why a running task hasn't merged yet, in the order that matters to Saad:
83
+ // his, then broken, then waiting on the pipeline.
84
+ export function inFlightWhy(t: {
85
+ prNumber: number | null;
86
+ armed: boolean;
87
+ checks: CheckRow[];
88
+ failures: FailureRow[];
89
+ askKinds: string[];
90
+ nudges: number;
91
+ }): string {
92
+ const escalated = t.failures.filter((f) => f.escalated_at);
93
+ if (escalated.length)
94
+ return `with you: the ${escalated.map((f) => f.gate).join(" and ")} gate failed twice`;
95
+ if (t.askKinds.length) return `waiting on your answer (${t.askKinds[0]})`;
96
+ const nudged = t.nudges ? `; nudged ${t.nudges}x while you were away` : "";
97
+ if (!t.prNumber) return `no PR yet${nudged}`;
98
+ const of = (kind: string) => t.checks.find((c) => c.kind === kind);
99
+ const review = of("review");
100
+ const ci = of("ci");
101
+ if (ci && ci.status !== "pass" && ci.status !== "running")
102
+ return `CI failing at ${sha7(ci.sha)}${nudged}`;
103
+ if (review?.status === "block")
104
+ return `review found blocking issues at ${sha7(review.sha)}`;
105
+ const failed = t.failures.filter((f) => f.count > 0);
106
+ if (failed.length)
107
+ return `the ${failed.map((f) => f.gate).join(" and ")} gate failed once; retrying`;
108
+ if (review?.status === "running")
109
+ return `review of ${sha7(review.sha)} running`;
110
+ if (!review || review.status === "error")
111
+ return "PR open, waiting on its review";
112
+ if (t.armed) return "armed to merge, waiting on its gates";
113
+ return "PR open and reviewed, waiting on sign-off";
114
+ }
115
+
116
+ interface TaskRow {
117
+ id: string;
118
+ name: string;
119
+ project: string;
120
+ pr_number: number | null;
121
+ pr_url: string | null;
122
+ armed_at: string | null;
123
+ }
124
+
125
+ function merged(
126
+ workspaceId: string,
127
+ away: AwayRow,
128
+ end: string,
129
+ deployConfigured: (projectId: string) => boolean
130
+ ): MergedItem[] {
131
+ const rows = db
132
+ .prepare(
133
+ `SELECT s.id, s.name, p.name AS project, p.id AS project_id, s.pr_number, s.pr_url
134
+ FROM sessions s JOIN projects p ON p.id = s.project_id
135
+ WHERE p.workspace_id = ? AND s.task_status = 'merged'
136
+ AND s.merged_at >= ? AND s.merged_at <= ?
137
+ ORDER BY s.merged_at, s.id`
138
+ )
139
+ .all(workspaceId, away.started_at, end) as (TaskRow & {
140
+ project_id: string;
141
+ })[];
142
+ const action = db.prepare(
143
+ `SELECT kind, detail FROM away_actions WHERE away_id = ? AND key = ?`
144
+ );
145
+ return rows.map((r) => {
146
+ const done = action.get(away.id, `deploy:${r.id}`) as
147
+ | { kind: string; detail: string | null }
148
+ | undefined;
149
+ const deploy: DeployState =
150
+ done?.kind === "deploy_ok"
151
+ ? "deployed"
152
+ : done?.kind === "deploy_failed"
153
+ ? "failed"
154
+ : done?.kind === "deploy_unchecked" || !deployConfigured(r.project_id)
155
+ ? "unchecked"
156
+ : "pending";
157
+ return {
158
+ task: r.name,
159
+ project: r.project,
160
+ pr: r.pr_number,
161
+ url: r.pr_url,
162
+ deploy,
163
+ deployDetail: done?.detail ?? null,
164
+ };
165
+ });
166
+ }
167
+
168
+ function inFlight(workspaceId: string, awayId: number): InFlightItem[] {
169
+ const tasks = db
170
+ .prepare(
171
+ `SELECT s.id, s.name, p.name AS project, s.pr_number, s.pr_url,
172
+ s.merge_armed_at AS armed_at
173
+ FROM sessions s JOIN projects p ON p.id = s.project_id
174
+ WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
175
+ AND s.task_status = 'running'
176
+ ORDER BY s.created_at, s.id`
177
+ )
178
+ .all(workspaceId) as TaskRow[];
179
+ if (!tasks.length) return [];
180
+ const checks = checksBySession(workspaceId);
181
+ const failures = failuresBySession(workspaceId);
182
+ const asks = openAsks(workspaceId);
183
+ const nudges = db.prepare(
184
+ `SELECT COUNT(*) AS n FROM away_actions
185
+ WHERE away_id = ? AND subject = ? AND kind = 'nudge'`
186
+ );
187
+ return tasks.map((t) => ({
188
+ task: t.name,
189
+ project: t.project,
190
+ pr: t.pr_number,
191
+ url: t.pr_url,
192
+ why: inFlightWhy({
193
+ prNumber: t.pr_number,
194
+ armed: !!t.armed_at,
195
+ checks: checks.get(t.id) ?? [],
196
+ failures: failures.get(t.id) ?? [],
197
+ askKinds: asks
198
+ .filter((a) => a.subject === `task:${t.id}`)
199
+ .map((a) => a.kind),
200
+ nudges: (nudges.get(awayId, t.id) as { n: number }).n,
201
+ }),
202
+ }));
203
+ }
204
+
205
+ function failed(workspaceId: string, away: AwayRow, end: string) {
206
+ const escalations = db
207
+ .prepare(
208
+ `SELECT text FROM orchestrator_notes
209
+ WHERE workspace_id = ? AND kind = 'escalation'
210
+ AND created_at >= ? AND created_at <= ?
211
+ ORDER BY id`
212
+ )
213
+ .all(workspaceId, away.started_at, end) as { text: string }[];
214
+ const deploys = db
215
+ .prepare(
216
+ `SELECT a.detail, s.name, s.pr_number, s.pr_url FROM away_actions a
217
+ LEFT JOIN sessions s ON s.id = a.subject
218
+ WHERE a.away_id = ? AND a.workspace_id = ? AND a.kind = 'deploy_failed'
219
+ ORDER BY a.id`
220
+ )
221
+ .all(away.id, workspaceId) as {
222
+ detail: string | null;
223
+ name: string | null;
224
+ pr_number: number | null;
225
+ pr_url: string | null;
226
+ }[];
227
+ const ended = db
228
+ .prepare(
229
+ `SELECT s.name, s.task_status, s.pr_number, s.pr_url FROM sessions s
230
+ JOIN projects p ON p.id = s.project_id
231
+ WHERE p.workspace_id = ? AND s.task_status IN ('dropped', 'done')
232
+ AND s.task_ended_at >= ? AND s.task_ended_at <= ?
233
+ ORDER BY s.task_ended_at, s.id`
234
+ )
235
+ .all(workspaceId, away.started_at, end) as {
236
+ name: string;
237
+ task_status: string;
238
+ pr_number: number | null;
239
+ pr_url: string | null;
240
+ }[];
241
+ return [
242
+ ...ended.map((t) => ({
243
+ what: `${t.name} ${t.task_status === "dropped" ? "was dropped" : "ended without a merge"}${t.pr_number ? ` (PR #${t.pr_number})` : ""}`,
244
+ url: t.pr_url,
245
+ })),
246
+ ...deploys.map((d) => ({
247
+ what: `Deploy failed after ${d.name ?? "a task"}${d.pr_number ? ` (PR #${d.pr_number})` : ""} merged: ${d.detail ?? "no detail"}`,
248
+ url: d.pr_url,
249
+ })),
250
+ ...escalations.map((e) => ({ what: clip(e.text), url: null })),
251
+ ];
252
+ }
253
+
254
+ function waiting(workspaceId: string): WaitingItem[] {
255
+ return openAsks(workspaceId).map((a) => ({
256
+ askId: a.id,
257
+ kind: a.kind,
258
+ title: clip(a.title, 160),
259
+ hardLine: HARD_LINES.includes(a.kind),
260
+ url: a.link,
261
+ }));
262
+ }
263
+
264
+ export function buildAwayReport(
265
+ away: AwayRow,
266
+ deployConfigured: (projectId: string) => boolean
267
+ ): AwayReport {
268
+ const end = away.ended_at ?? away.started_at;
269
+ const workspaces = listWorkspaces()
270
+ .map((w) => ({
271
+ id: w.id,
272
+ name: w.name,
273
+ merged: merged(w.id, away, end, deployConfigured),
274
+ inFlight: inFlight(w.id, away.id),
275
+ failed: failed(w.id, away, end),
276
+ waiting: waiting(w.id),
277
+ }))
278
+ .filter(
279
+ (w) =>
280
+ w.merged.length ||
281
+ w.inFlight.length ||
282
+ w.failed.length ||
283
+ w.waiting.length
284
+ );
285
+ const all = <T>(pick: (w: WorkspaceReport) => T[]) =>
286
+ workspaces.flatMap(pick);
287
+ const mergedAll = all((w) => w.merged);
288
+ const waitingAll = all((w) => w.waiting);
289
+ return {
290
+ awayId: away.id,
291
+ startedAt: away.started_at,
292
+ endedAt: end,
293
+ endedBy: away.ended_by,
294
+ setBy: away.set_by,
295
+ workspaces,
296
+ totals: {
297
+ merged: mergedAll.length,
298
+ deployed: mergedAll.filter((m) => m.deploy === "deployed").length,
299
+ deployFailed: mergedAll.filter((m) => m.deploy === "failed").length,
300
+ inFlight: all((w) => w.inFlight).length,
301
+ failed: all((w) => w.failed).length,
302
+ waiting: waitingAll.length,
303
+ hardLines: waitingAll.filter((a) => a.hardLine).length,
304
+ },
305
+ };
306
+ }
307
+
308
+ const plural = (n: number, one: string, many = `${one}s`) =>
309
+ `${n} ${n === 1 ? one : many}`;
310
+
311
+ // The phone's version: counts, and whether anything waits on him.
312
+ export function phoneSummary(r: AwayReport): string {
313
+ const t = r.totals;
314
+ const deploy = t.merged
315
+ ? ` (${t.deployed} deployed${t.deployFailed ? `, ${t.deployFailed} deploy failed` : ""})`
316
+ : "";
317
+ const waiting = t.waiting
318
+ ? `${plural(t.waiting, "ask")} waiting on you${t.hardLines ? `, ${t.hardLines} a hard line` : ""}.`
319
+ : "Nothing waiting on you.";
320
+ return [
321
+ `While you were away: ${t.merged} merged${deploy}, ${t.inFlight} in flight, ${t.failed} failed or escalated.`,
322
+ waiting,
323
+ "The full report is in AgentOS.",
324
+ ].join(" ");
325
+ }
326
+
327
+ const DEPLOY_WORD: Record<DeployState, string> = {
328
+ deployed: "deployed",
329
+ failed: "deploy failed",
330
+ pending: "deploy not confirmed yet",
331
+ unchecked: "no deploy check",
332
+ };
333
+
334
+ // Task names, ask titles and log lines are text agents wrote: no markup.
335
+ const esc = (text: string) => text.replace(/[[\]<>`*_]/g, "\\$&");
336
+ const safeUrl = (url: string | null) =>
337
+ url && /^https:\/\/[^\s()<>]+$/.test(url) ? url : null;
338
+ const link = (label: string, url: string | null) =>
339
+ safeUrl(url) ? `[${label}](${url})` : label;
340
+
341
+ // For Settings > Audits: a markdown file with the same content.
342
+ export function reportMarkdown(r: AwayReport): string {
343
+ const from = clockTime(fromSql(r.startedAt), fromSql(r.endedAt));
344
+ const to = clockTime(fromSql(r.endedAt), fromSql(r.endedAt));
345
+ const out = [
346
+ `---\naudited: ${new Date(fromSql(r.endedAt)).toISOString()}\n---`,
347
+ `# While you were away (${from} to ${to})`,
348
+ "",
349
+ phoneSummary(r).replace(" The full report is in AgentOS.", ""),
350
+ ];
351
+ const section = (title: string, lines: string[], empty: string) => {
352
+ out.push("", `## ${title}`, "");
353
+ out.push(...(lines.length ? lines : [empty]));
354
+ };
355
+ const each = <T>(
356
+ pick: (w: WorkspaceReport) => T[],
357
+ line: (item: T) => string
358
+ ) =>
359
+ r.workspaces.flatMap((w) =>
360
+ pick(w).map((item) => `- **${esc(w.name)}**: ${line(item)}`)
361
+ );
362
+ section(
363
+ "Merged",
364
+ each(
365
+ (w) => w.merged,
366
+ (m) =>
367
+ `${m.pr ? `${link(`PR #${m.pr}`, m.url)} ` : ""}${esc(m.task)} (${esc(m.project)}): ${DEPLOY_WORD[m.deploy]}${m.deployDetail ? `, ${esc(m.deployDetail)}` : ""}`
368
+ ),
369
+ "- None."
370
+ );
371
+ section(
372
+ "Still in flight",
373
+ each(
374
+ (w) => w.inFlight,
375
+ (t) =>
376
+ `${t.pr ? `${link(`PR #${t.pr}`, t.url)} ` : ""}${esc(t.task)} (${esc(t.project)}): ${t.why}`
377
+ ),
378
+ "- None."
379
+ );
380
+ section(
381
+ "Failed or escalated",
382
+ each(
383
+ (w) => w.failed,
384
+ (f) => `${esc(f.what)}${safeUrl(f.url) ? ` (${f.url})` : ""}`
385
+ ),
386
+ "- None."
387
+ );
388
+ section(
389
+ "Waiting on you",
390
+ each(
391
+ (w) => w.waiting,
392
+ (a) =>
393
+ `${a.hardLine ? "**hard line** " : ""}${a.kind}: ${esc(a.title)}${safeUrl(a.url) ? ` (${a.url})` : ""}`
394
+ ),
395
+ "- None."
396
+ );
397
+ return out.join("\n") + "\n";
398
+ }