@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.
- package/README.md +53 -1
- package/app/api/away/route.ts +56 -0
- package/app/api/away/seen/route.ts +12 -0
- package/app/api/health/route.ts +9 -0
- package/app/api/workspaces/[id]/route.ts +8 -1
- package/app/page.tsx +2 -0
- package/components/Away/AwayReport.tsx +16 -0
- package/components/Away/AwayReportDialog.tsx +193 -0
- package/components/Away/AwayToggle.tsx +91 -0
- package/components/Away/time.test.ts +20 -0
- package/components/Away/time.ts +32 -0
- package/components/Away/useAwayCommands.ts +43 -0
- package/components/Chat/Messages.tsx +1 -0
- package/components/CommandPalette/useAppCommands.ts +2 -0
- package/components/Pane/MobileTabBar.tsx +14 -1
- package/components/SessionList/SidebarHeader.tsx +2 -0
- package/components/Workspaces/WorkspaceSettings.tsx +9 -1
- package/data/away/index.ts +73 -0
- package/data/push/topics.ts +4 -0
- package/data/workspaces/index.ts +6 -1
- package/lib/away/brief.ts +23 -0
- package/lib/away/deploy.ts +173 -0
- package/lib/away/report.ts +398 -0
- package/lib/away/run.ts +445 -0
- package/lib/away/state.ts +201 -0
- package/lib/away/sweep.ts +216 -0
- package/lib/away/view.ts +51 -0
- package/lib/chat/events.ts +1 -1
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +101 -0
- package/lib/db/types.ts +9 -0
- package/lib/notify/index.ts +7 -0
- package/lib/orchestrator/brief.ts +2 -0
- package/lib/orchestrator/conditions.ts +10 -3
- package/lib/orchestrator/digest.ts +11 -8
- package/lib/orchestrator/notes.ts +8 -1
- package/lib/orchestrator/pr-wake.ts +385 -0
- package/lib/orchestrator/watcher.ts +12 -2
- package/lib/project-config/agentos.schema.json +24 -0
- package/lib/project-config/schema.ts +17 -0
- package/lib/tasks/gh.ts +2 -0
- package/lib/tasks/pr-activity.ts +295 -0
- package/lib/tasks/state.ts +9 -1
- package/lib/workspaces.ts +8 -2
- 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
|
+
}
|