@saadnvd1/agent-os 0.3.460 → 0.3.462

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 (38) hide show
  1. package/README.md +51 -3
  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/page.tsx +2 -0
  6. package/components/Away/AwayReport.tsx +16 -0
  7. package/components/Away/AwayReportDialog.tsx +193 -0
  8. package/components/Away/AwayToggle.tsx +91 -0
  9. package/components/Away/time.test.ts +20 -0
  10. package/components/Away/time.ts +32 -0
  11. package/components/Away/useAwayCommands.ts +43 -0
  12. package/components/Chat/Messages.tsx +1 -0
  13. package/components/CommandPalette/useAppCommands.ts +2 -0
  14. package/components/Pane/MobileTabBar.tsx +14 -1
  15. package/components/SessionList/SidebarHeader.tsx +2 -0
  16. package/data/away/index.ts +73 -0
  17. package/data/push/topics.ts +4 -0
  18. package/lib/away/brief.ts +23 -0
  19. package/lib/away/deploy.ts +173 -0
  20. package/lib/away/report.ts +398 -0
  21. package/lib/away/run.ts +445 -0
  22. package/lib/away/state.ts +201 -0
  23. package/lib/away/sweep.ts +216 -0
  24. package/lib/away/view.ts +51 -0
  25. package/lib/chat/events.ts +1 -1
  26. package/lib/db/changes.ts +3 -0
  27. package/lib/db/migrations.ts +69 -0
  28. package/lib/db/types.ts +4 -0
  29. package/lib/notify/index.ts +7 -0
  30. package/lib/orchestrator/digest.ts +11 -8
  31. package/lib/orchestrator/notes.ts +8 -1
  32. package/lib/orchestrator/watcher.ts +6 -0
  33. package/lib/project-config/agentos.schema.json +24 -0
  34. package/lib/project-config/schema.ts +17 -0
  35. package/lib/worktree-hooks.ts +9 -0
  36. package/package.json +1 -1
  37. package/scripts/hooks/staged-secrets.test.ts +92 -0
  38. package/scripts/hooks/staged-secrets.ts +64 -0
@@ -0,0 +1,73 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { AwayView } from "@/lib/away/view";
3
+ import { post } from "../orchestrators";
4
+ import { usePollWhenOffline } from "../push/connection";
5
+
6
+ export const awayKeys = {
7
+ all: ["away"] as const,
8
+ };
9
+
10
+ export function useAwayQuery() {
11
+ return useQuery({
12
+ queryKey: awayKeys.all,
13
+ queryFn: async () => {
14
+ const res = await fetch("/api/away");
15
+ if (!res.ok) throw new Error(`Request failed (${res.status})`);
16
+ return (await res.json()) as AwayView;
17
+ },
18
+ staleTime: 10_000,
19
+ // Pushed when it changes (away_periods); a minute catches it running
20
+ // out while the stream is down.
21
+ refetchInterval: usePollWhenOffline(60_000),
22
+ });
23
+ }
24
+
25
+ // expiresAt null: until he's back. `false` turns it off.
26
+ export function useSetAway() {
27
+ const queryClient = useQueryClient();
28
+ return useMutation({
29
+ mutationFn: (on: false | { expiresAt: Date | null }) =>
30
+ post(
31
+ "/api/away",
32
+ on
33
+ ? { on: true, expiresAt: on.expiresAt?.toISOString() ?? null }
34
+ : { on: false }
35
+ ) as Promise<AwayView>,
36
+ onSuccess: (view) => queryClient.setQueryData(awayKeys.all, view),
37
+ onSettled: () => queryClient.invalidateQueries({ queryKey: awayKeys.all }),
38
+ });
39
+ }
40
+
41
+ export function useDismissAwayReport() {
42
+ const queryClient = useQueryClient();
43
+ return useMutation({
44
+ mutationFn: (id: number) => post("/api/away/seen", { id }),
45
+ onMutate: () =>
46
+ queryClient.setQueryData<AwayView>(awayKeys.all, (prev) =>
47
+ prev ? { ...prev, report: null } : prev
48
+ ),
49
+ onSettled: () => queryClient.invalidateQueries({ queryKey: awayKeys.all }),
50
+ });
51
+ }
52
+
53
+ // The choices the toggle offers, as when each ends.
54
+ export const AWAY_DURATIONS = [
55
+ { id: "1h", label: "For 1 hour" },
56
+ { id: "4h", label: "For 4 hours" },
57
+ { id: "morning", label: "Until morning" },
58
+ { id: "back", label: "Until I'm back" },
59
+ ] as const;
60
+
61
+ export type AwayDuration = (typeof AWAY_DURATIONS)[number]["id"];
62
+
63
+ // "Until morning" is 8 AM, local: today's if it's still before 5 AM,
64
+ // else tomorrow's.
65
+ export function awayEnds(d: AwayDuration, now = new Date()): Date | null {
66
+ if (d === "1h") return new Date(now.getTime() + 60 * 60 * 1000);
67
+ if (d === "4h") return new Date(now.getTime() + 4 * 60 * 60 * 1000);
68
+ if (d === "back") return null;
69
+ const morning = new Date(now);
70
+ morning.setHours(8, 0, 0, 0);
71
+ if (now.getHours() >= 5) morning.setDate(morning.getDate() + 1);
72
+ return morning;
73
+ }
@@ -12,6 +12,7 @@ import { ruleKeys } from "../orchestrators/rules";
12
12
  import { workspaceKeys } from "../workspaces";
13
13
  import { scheduleKeys } from "../schedules";
14
14
  import { archivedKeys } from "../done";
15
+ import { awayKeys } from "../away";
15
16
 
16
17
  // What each pushed topic (a table that changed, lib/db/changes.ts, or a
17
18
  // pushed view) means the browser should refetch. Only queries on screen
@@ -56,6 +57,8 @@ function keysFor(topic: string): QueryKey[] {
56
57
  return [orchestratorKeys.all];
57
58
  case "orchestrator_rules":
58
59
  return [ruleKeys.all];
60
+ case "away_periods":
61
+ return [awayKeys.all];
59
62
  case "dev_servers":
60
63
  return [devServerKeys.all];
61
64
  default:
@@ -96,6 +99,7 @@ export const ALL_TOPICS = [
96
99
  "schedules",
97
100
  "orchestrator_asks",
98
101
  "orchestrator_rules",
102
+ "away_periods",
99
103
  "dev_servers",
100
104
  ];
101
105
 
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What an orchestrator is told while Saad is away: the event that turns
3
+ * Away on, and the line leading its brief's digest on a fresh start.
4
+ */
5
+
6
+ import {
7
+ activeAway,
8
+ clockTime,
9
+ fromSql,
10
+ untilText,
11
+ type AwayRow,
12
+ } from "./state";
13
+ import { SWEEP_EVERY_MS } from "./sweep";
14
+
15
+ export function awayOnLine(row: AwayRow, now = Date.now()): string {
16
+ return `Saad is away (Away mode, since ${clockTime(fromSql(row.started_at), now)}, ${untilText(row, now)}). Follow work through rather than only reacting to events: keep each task moving to merged and deployed, sign off what passes its gates, and pick up what stalled. Every ${SWEEP_EVERY_MS / 60000} minutes AgentOS sweeps: it nudges stalled tasks, asks idle ones with uncommitted work to commit and open a PR, runs lost reviews again and checks the deploy after each merge; what needs you comes as events, and each step is in your log. Interactive sessions (not tasks) are Saad's: in Away mode only ask them to wrap up, never drive them further. Hard lines still go to ask_saad; while he's away his asks reach his phone in one batch per sweep, so raise them as usual. \`aos notify\` is held for that batch too, so don't send one per item.`;
17
+ }
18
+
19
+ // For the digest: the same line while Away is on, else nothing.
20
+ export function awayDigestLine(now = Date.now()): string | null {
21
+ const row = activeAway();
22
+ return row ? awayOnLine(row, now) : null;
23
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * After a merge, did the deploy land and the server come back healthy? Read
3
+ * from the project's `deploy` config (agentos.json): the file its deploy
4
+ * writes the deployed commit to (scripts/autodeploy's `deployed`), the file
5
+ * it writes a commit whose deploy failed to (`alerted`), and a health URL.
6
+ * A project without one isn't checked; a missing `deployed` file means no
7
+ * deploy runs on this machine, which isn't a failure either.
8
+ */
9
+
10
+ import fs from "fs";
11
+ import type { ProjectConfig } from "../project-config";
12
+ import { run } from "../tasks/gh";
13
+ import { expandHome } from "../tasks/session";
14
+ import { selfUrl } from "../agents/self-env";
15
+ import { commitTime } from "../orchestrator/repo";
16
+
17
+ export type DeployConfig = NonNullable<ProjectConfig["deploy"]>;
18
+
19
+ export type DeployVerdict =
20
+ | { state: "ok"; detail: string }
21
+ | { state: "failed"; detail: string }
22
+ | { state: "pending" }
23
+ | { state: "unchecked"; detail: string };
24
+
25
+ export const DEPLOY_WITHIN_MIN = 30;
26
+ // After the deploy lands, the server gets this long to answer healthy.
27
+ export const HEALTH_GRACE_MS = 3 * 60 * 1000;
28
+
29
+ export interface DeployDeps {
30
+ // The commit a state file names, or null when there's no such file.
31
+ readSha: (file: string) => { sha: string; at: number } | null;
32
+ // Whether `sha` is in the history of `tip` (fetching it if need be);
33
+ // throws when that can't be told.
34
+ contains: (repo: string, tip: string, sha: string) => Promise<boolean>;
35
+ // startedAt, when the server says when it started (AgentOS's
36
+ // /api/health does), so a deploy that never restarted it is caught.
37
+ health: (
38
+ url: string
39
+ ) => Promise<{ ok: boolean; why?: string; startedAt?: number }>;
40
+ // When GitHub made the merge commit, in ms (0 when unknown): the time a
41
+ // restart has to come after. Not when AgentOS noticed the merge, which
42
+ // can be after a restart that already took it.
43
+ committedAt: (repo: string, sha: string) => Promise<number>;
44
+ }
45
+
46
+ const SHA = /^[0-9a-f]{40}$/;
47
+
48
+ function readSha(file: string): { sha: string; at: number } | null {
49
+ try {
50
+ const full = expandHome(file);
51
+ const sha = fs.readFileSync(full, "utf-8").slice(0, 80).trim();
52
+ return SHA.test(sha) ? { sha, at: fs.statSync(full).mtimeMs } : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ async function contains(
59
+ repo: string,
60
+ tip: string,
61
+ sha: string
62
+ ): Promise<boolean> {
63
+ if (tip === sha) return true;
64
+ const ancestor = () =>
65
+ run("git", ["merge-base", "--is-ancestor", sha, tip], repo).then(
66
+ () => true,
67
+ () => false
68
+ );
69
+ if (await ancestor()) return true;
70
+ // The deployed commit may be newer than this checkout knows. A fetch that
71
+ // fails throws, so stale refs never read as "not deployed".
72
+ await run("git", ["fetch", "--quiet", "origin"], repo);
73
+ return ancestor();
74
+ }
75
+
76
+ async function health(
77
+ url: string
78
+ ): Promise<{ ok: boolean; why?: string; startedAt?: number }> {
79
+ try {
80
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
81
+ if (!res.ok) return { ok: false, why: `HTTP ${res.status}` };
82
+ const body = (await res.json().catch(() => null)) as {
83
+ startedAt?: unknown;
84
+ } | null;
85
+ const startedAt =
86
+ typeof body?.startedAt === "string" ? Date.parse(body.startedAt) : NaN;
87
+ return Number.isFinite(startedAt) ? { ok: true, startedAt } : { ok: true };
88
+ } catch (error) {
89
+ return {
90
+ ok: false,
91
+ why: error instanceof Error ? error.message : String(error),
92
+ };
93
+ }
94
+ }
95
+
96
+ export const DEFAULT_DEPS: DeployDeps = {
97
+ readSha,
98
+ contains,
99
+ health,
100
+ committedAt: async (repo, sha) =>
101
+ (await commitTime(repo, sha).catch(() => 0)) * 1000,
102
+ };
103
+
104
+ // A path alone is this AgentOS server's own.
105
+ export const healthUrl = (health: string) =>
106
+ health.startsWith("/") ? `${selfUrl()}${health}` : health;
107
+
108
+ // Pending when it can't be told yet (git couldn't fetch): the next sweep
109
+ // asks again, rather than a blip reading as a failed deploy.
110
+ export async function checkDeploy(
111
+ input: Parameters<typeof judge>[0],
112
+ deps: DeployDeps = DEFAULT_DEPS
113
+ ): Promise<DeployVerdict> {
114
+ try {
115
+ return await judge(input, deps);
116
+ } catch {
117
+ return { state: "pending" };
118
+ }
119
+ }
120
+
121
+ async function judge(
122
+ input: {
123
+ repo: string;
124
+ config: DeployConfig | undefined;
125
+ mergeSha: string | null;
126
+ mergedAt: number;
127
+ now: number;
128
+ },
129
+ deps: DeployDeps = DEFAULT_DEPS
130
+ ): Promise<DeployVerdict> {
131
+ const { config, mergeSha } = input;
132
+ if (!config)
133
+ return { state: "unchecked", detail: "no deploy check configured" };
134
+ if (!mergeSha) return { state: "pending" };
135
+ const short = mergeSha.slice(0, 7);
136
+ const deployed = deps.readSha(config.deployed);
137
+ if (!deployed)
138
+ return { state: "unchecked", detail: "no deploy runs on this machine" };
139
+
140
+ if (await deps.contains(input.repo, deployed.sha, mergeSha)) {
141
+ if (!config.health) return { state: "ok", detail: `${short} deployed` };
142
+ const url = healthUrl(config.health);
143
+ const answer = await deps.health(url);
144
+ const merged =
145
+ answer.ok && answer.startedAt !== undefined
146
+ ? await deps.committedAt(input.repo, mergeSha)
147
+ : 0;
148
+ const stale = !!merged && answer.startedAt! < merged;
149
+ if (answer.ok && !stale)
150
+ return { state: "ok", detail: `${short} deployed, server healthy` };
151
+ if (input.now - deployed.at < HEALTH_GRACE_MS) return { state: "pending" };
152
+ return {
153
+ state: "failed",
154
+ detail: stale
155
+ ? `${short} deployed, but the server answering ${config.health} hasn't restarted since the merge`
156
+ : `${short} deployed, but ${config.health} didn't answer healthy: ${answer.why ?? "no answer"}`,
157
+ };
158
+ }
159
+
160
+ const failed = config.failed ? deps.readSha(config.failed) : null;
161
+ if (failed && (await deps.contains(input.repo, failed.sha, mergeSha)))
162
+ return {
163
+ state: "failed",
164
+ detail: `the deploy of ${failed.sha.slice(0, 7)} (which has ${short}) failed; see the deploy's log`,
165
+ };
166
+ const within = (config.within_minutes ?? DEPLOY_WITHIN_MIN) * 60 * 1000;
167
+ if (input.now - input.mergedAt >= within)
168
+ return {
169
+ state: "failed",
170
+ detail: `${short} not deployed ${Math.round(within / 60000)} minutes after the merge`,
171
+ };
172
+ return { state: "pending" };
173
+ }
@@ -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
+ }