@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.
- package/README.md +51 -3
- 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/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/data/away/index.ts +73 -0
- package/data/push/topics.ts +4 -0
- 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 +69 -0
- package/lib/db/types.ts +4 -0
- package/lib/notify/index.ts +7 -0
- package/lib/orchestrator/digest.ts +11 -8
- package/lib/orchestrator/notes.ts +8 -1
- package/lib/orchestrator/watcher.ts +6 -0
- package/lib/project-config/agentos.schema.json +24 -0
- package/lib/project-config/schema.ts +17 -0
- package/lib/worktree-hooks.ts +9 -0
- package/package.json +1 -1
- package/scripts/hooks/staged-secrets.test.ts +92 -0
- 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
|
+
}
|
package/data/push/topics.ts
CHANGED
|
@@ -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
|
+
}
|