@saadnvd1/agent-os 0.3.654 → 0.3.657

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 (47) hide show
  1. package/app/api/health/env-failures/route.test.ts +60 -0
  2. package/app/api/health/env-failures/route.ts +51 -0
  3. package/app/api/shares/pdf/route.test.ts +122 -0
  4. package/app/api/shares/pdf/route.ts +31 -0
  5. package/app/api/shares/preview/route.ts +5 -0
  6. package/components/Health/EnvFailures/ClassRow.tsx +104 -0
  7. package/components/Health/EnvFailures/SessionList.tsx +49 -0
  8. package/components/Health/EnvFailures/index.tsx +46 -0
  9. package/components/Health/HealthPanel.tsx +11 -1
  10. package/components/Share/DownloadMenu.tsx +112 -0
  11. package/components/Share/ShareDialog.tsx +31 -47
  12. package/data/health/index.ts +31 -0
  13. package/data/shares/queries.ts +11 -0
  14. package/lib/agents/brief.ts +1 -1
  15. package/lib/agents/self-env.ts +1 -0
  16. package/lib/chat/runner.ts +3 -0
  17. package/lib/db/migrations/20261016T1800_env_failures.ts +33 -0
  18. package/lib/dev-instance/index.ts +6 -0
  19. package/lib/dev-instance/liveServer.ts +46 -0
  20. package/lib/dev-instance/pickPort.ts +66 -0
  21. package/lib/dev-instance/processInfo.ts +68 -0
  22. package/lib/dev-instance/spawnServer.ts +36 -0
  23. package/lib/dev-instance/startDevServer.ts +80 -0
  24. package/lib/dev-instance/stopDevServer.ts +98 -0
  25. package/lib/dev-instance/types.ts +13 -0
  26. package/lib/health/config.ts +8 -0
  27. package/lib/health/env/classes.ts +121 -0
  28. package/lib/health/env/classify.ts +67 -0
  29. package/lib/health/env/envTick.ts +46 -0
  30. package/lib/health/env/fixText.ts +24 -0
  31. package/lib/health/env/helpers.test-util.ts +21 -0
  32. package/lib/health/env/index.ts +6 -0
  33. package/lib/health/env/raiseAlerts.ts +84 -0
  34. package/lib/health/env/recordHit.ts +62 -0
  35. package/lib/health/env/sendFixNotes.ts +80 -0
  36. package/lib/health/env/summarize.ts +94 -0
  37. package/lib/health/env/types.ts +73 -0
  38. package/lib/health/tick.ts +6 -0
  39. package/lib/security/demo.ts +1 -0
  40. package/lib/share/pdf.ts +116 -0
  41. package/lib/share/view.ts +9 -1
  42. package/package.json +5 -3
  43. package/scripts/dev-stop.ts +7 -0
  44. package/scripts/dev-task.ts +16 -0
  45. package/scripts/screenshots/demo.ts +4 -8
  46. package/scripts/screenshots/run.ts +5 -8
  47. package/server.ts +25 -0
@@ -15,6 +15,11 @@ export const HEALTH_DEFAULTS = {
15
15
  armedStuckMinutes: 120,
16
16
  serverErrorsMin: 20,
17
17
  serverErrorsFactor: 5,
18
+ // An environment failure reaches the orchestrator when this many sessions
19
+ // hit one class inside the window, or when it returns after the quiet days.
20
+ envBurstSessions: 3,
21
+ envBurstHours: 24,
22
+ envQuietDays: 7,
18
23
  // The watchers stop waking past this share of the 5-hour usage window.
19
24
  wakeUsageCeilingPct: 90,
20
25
  // 0 turns the watchers off: anomalies still open and reach the chat.
@@ -35,6 +40,9 @@ export const HEALTH_LABELS: Record<HealthKey, string> = {
35
40
  armedStuckMinutes: "PR armed without merging for (minutes)",
36
41
  serverErrorsMin: "Server errors per 10 minutes, at least",
37
42
  serverErrorsFactor: "Server errors, times the usual rate",
43
+ envBurstSessions: "Environment failure: sessions in the window to alert",
44
+ envBurstHours: "Environment failure window (hours)",
45
+ envQuietDays: "Environment failure back after quiet for (days)",
38
46
  wakeUsageCeilingPct: "Stop waking watchers above usage (%)",
39
47
  watchersOn: "Watchers on (1) or off (0)",
40
48
  };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The known environment failures: a pattern, a class and the fix to send.
3
+ * Adding a class is one entry here, with a positive and a negative case in
4
+ * classify.test.ts. The first class that matches wins, so a narrower one
5
+ * sits above the broader one it would otherwise be mistaken for.
6
+ */
7
+
8
+ import type { EnvClass } from "./types";
9
+
10
+ const OLD_PYTHON =
11
+ /python3?\.[0-9]\b|Python3?\.framework\/Versions\/3\.[0-9]\b|Python\/3\.[0-9]\b/i;
12
+ const USE_ENV =
13
+ "run it through the repo's own environment (`uv run …` when it has a pyproject.toml or uv.lock, else its .venv/bin/python), not a bare `python3`";
14
+
15
+ export const ENV_CLASSES: readonly EnvClass[] = [
16
+ {
17
+ id: "python-version",
18
+ label: "Python too old for the code",
19
+ scope: "shell",
20
+ match: [
21
+ /No module named '?"?tomllib/,
22
+ /^SyntaxError:/,
23
+ /unsupported operand type\(s\) for \|/,
24
+ /requires a different Python/,
25
+ /Python 3\.\d+ or (?:later|newer|above) is required/i,
26
+ ],
27
+ requires:
28
+ /tomllib|requires a different Python|Python 3\.\d+ or |unsupported operand|python3?\.[0-9]\b|Versions\/3\.[0-9]\b|Python\/3\.[0-9]\b/i,
29
+ fix: `This interpreter is too old for the code: ${USE_ENV}.`,
30
+ },
31
+ {
32
+ id: "python-externally-managed",
33
+ label: "pip into a managed Python",
34
+ scope: "shell",
35
+ match: [/externally-managed-environment/],
36
+ fix: "Don't `pip install` into a system or Homebrew Python: use the repo's environment (`uv run`, `uv pip`, or its .venv).",
37
+ },
38
+ {
39
+ id: "python-missing-module",
40
+ label: "Python module not found",
41
+ scope: "shell",
42
+ match: [/^(?:ModuleNotFoundError|ImportError): No module named/],
43
+ fix: `Each Python on this machine has different packages: ${USE_ENV}.`,
44
+ },
45
+ {
46
+ id: "python-not-found",
47
+ label: "python not found",
48
+ scope: "shell",
49
+ match: [
50
+ /command not found: python/,
51
+ /^(?:\S+: )?(?:line \d+: )?python[0-9.]*: (?:command )?not found/,
52
+ ],
53
+ fix: "There is no bare `python` here: call `python3`, or better `uv run` in the repo.",
54
+ },
55
+ {
56
+ id: "macos-automation",
57
+ label: "macOS Automation denied",
58
+ scope: "shell",
59
+ match: [
60
+ /\(-1743\)/,
61
+ /Not authorized to send Apple events/,
62
+ /errAEEventNotPermitted/,
63
+ ],
64
+ fix: "macOS Automation (Apple events) permission was refused for this program. It's a machine setting, not something to retry: tell the orchestrator.",
65
+ },
66
+ {
67
+ id: "local-permission",
68
+ label: "Local permission denied",
69
+ scope: "shell",
70
+ match: [/Permission denied/, /\bEACCES\b/, /Operation not permitted/],
71
+ except: {
72
+ command: /\b(?:ssh|scp|sftp|rsync|kubectl|docker\s+exec)\b/,
73
+ output: /\(publickey/,
74
+ },
75
+ fix: null,
76
+ },
77
+ {
78
+ id: "guard-git-write",
79
+ label: "Guard: git write refused",
80
+ scope: "guard",
81
+ match: [
82
+ /^Refused: git (?:pull|stash|checkout|merge|rebase|reset)\b/,
83
+ /is inside .*, a project repository/,
84
+ ],
85
+ fix: "Orchestrators don't pull or switch a project checkout: `git fetch`, then read the latest with `git show origin/main:<path>`.",
86
+ },
87
+ {
88
+ id: "guard-write",
89
+ label: "Guard: write refused",
90
+ scope: "guard",
91
+ match: [
92
+ /orchestrators never write/,
93
+ /you may write only in your folder/,
94
+ /Change code there through a task/,
95
+ ],
96
+ fix: "Write only in your own folder; change repository code through a task (`start_task`) or a quick change.",
97
+ },
98
+ {
99
+ id: "guard-shell",
100
+ label: "Guard: command refused",
101
+ scope: "guard",
102
+ match: [/^Refused: /],
103
+ fix: null,
104
+ },
105
+ ];
106
+
107
+ export const classById = (id: string): EnvClass | undefined =>
108
+ ENV_CLASSES.find((c) => c.id === id);
109
+
110
+ export const SHELL_TOOLS = new Set([
111
+ "bash",
112
+ "shell",
113
+ "exec_command",
114
+ "run_shell_command",
115
+ "local_shell",
116
+ ]);
117
+
118
+ // Commands that show text somebody else wrote: an error in their output is
119
+ // in the data, not something this run hit.
120
+ export const READERS =
121
+ /^(?:grep|egrep|rg|ag|cat|head|tail|less|more|sed\s+-n|awk|jq|sqlite3|journalctl|echo|printf|aos\s+(?:history|inbox)|sm\s+logs|gh\s+run\s+view|git\s+(?:log|show|diff|grep))\b/;
@@ -0,0 +1,67 @@
1
+ import {
2
+ normalizeCommand,
3
+ scrub,
4
+ genericPath,
5
+ } from "@/lib/session-audit/normalize";
6
+ import { ENV_CLASSES, READERS, SHELL_TOOLS } from "./classes";
7
+ import type { EnvHit } from "./types";
8
+
9
+ export interface ToolResult {
10
+ tool: string;
11
+ input: unknown;
12
+ output: string;
13
+ status: string;
14
+ }
15
+
16
+ // How much of a long output is read: the error is at the end of a traceback.
17
+ export const TAIL = 32_000;
18
+
19
+ function commandOf(tool: string, input: unknown): string {
20
+ const i = (input && typeof input === "object" ? input : {}) as Record<
21
+ string,
22
+ unknown
23
+ >;
24
+ const c = i.command ?? i.cmd;
25
+ if (typeof c === "string") return c;
26
+ if (Array.isArray(c)) return c.join(" ");
27
+ const file = i.file_path ?? i.path;
28
+ return typeof file === "string" ? `${tool} ${genericPath(file)}` : tool;
29
+ }
30
+
31
+ // True when every part of the command only reads (after `cd`).
32
+ function onlyReads(command: string): boolean {
33
+ const parts = command
34
+ .split(/\n|&&|\|\||;|\|/)
35
+ .map((p) => p.trim())
36
+ .filter((p) => p && !/^cd\s/.test(p));
37
+ return parts.length > 0 && parts.every((p) => READERS.test(p));
38
+ }
39
+
40
+ export function classifyToolResult(r: ToolResult): EnvHit | null {
41
+ if (!r.output) return null;
42
+ const shell = SHELL_TOOLS.has(r.tool.toLowerCase());
43
+ const command = commandOf(r.tool, r.input);
44
+ const text = r.output.length > TAIL ? r.output.slice(-TAIL) : r.output;
45
+ const lines = text.split("\n");
46
+ const reads = shell && onlyReads(command);
47
+ for (const c of ENV_CLASSES) {
48
+ if (c.scope === "shell" ? !shell || reads : r.status !== "error") continue;
49
+ const line = lines.find((l) => c.match.some((m) => m.test(l.trim())));
50
+ if (line === undefined) continue;
51
+ if (c.requires && !c.requires.test(text)) continue;
52
+ if (c.except?.command?.test(command) || c.except?.output?.test(text))
53
+ continue;
54
+ // The command names the very text it found: a search, not a failure.
55
+ if (shell && command.toLowerCase().includes(line.trim().toLowerCase()))
56
+ continue;
57
+ const hit = c.match.map((m) => m.exec(line.trim())?.[0]).find(Boolean);
58
+ if (shell && hit && command.toLowerCase().includes(hit.toLowerCase()))
59
+ continue;
60
+ return {
61
+ class: c.id,
62
+ line: scrub(line.trim()).slice(0, 200),
63
+ command: normalizeCommand(command),
64
+ };
65
+ }
66
+ return null;
67
+ }
@@ -0,0 +1,46 @@
1
+ import { sendMessage } from "@/lib/bus";
2
+ import { registry } from "@/lib/chat/registry";
3
+ import { chatNeed } from "@/lib/needs-you";
4
+ import { queueEventOnce } from "@/lib/orchestrator/events";
5
+ import { listOrchestrators } from "@/lib/orchestrator/home";
6
+ import type { HealthConfig } from "../config";
7
+ import { raiseAlerts, type AlertDeps } from "./raiseAlerts";
8
+ import { sendFixNotes, type NoteDeps } from "./sendFixNotes";
9
+
10
+ export interface EnvDeps {
11
+ notes: NoteDeps;
12
+ alerts: AlertDeps;
13
+ }
14
+
15
+ export const realEnv: EnvDeps = {
16
+ notes: {
17
+ send: async (sessionId, body) => {
18
+ await sendMessage({
19
+ fromId: null,
20
+ to: sessionId,
21
+ body,
22
+ fromLabel: "AgentOS health",
23
+ origin: { kind: "system", label: "Environment", body },
24
+ });
25
+ },
26
+ // Open asks don't count: an orchestrator with some still hears it.
27
+ blocked: (id, role) =>
28
+ chatNeed({ id, role }, registry.live.get(id)?.state ?? null, 0) !== null,
29
+ },
30
+ alerts: {
31
+ queue: queueEventOnce,
32
+ workspaces: () =>
33
+ listOrchestrators()
34
+ .filter((o) => !o.archived_at && o.workspace_id)
35
+ .map((o) => o.workspace_id as string),
36
+ },
37
+ };
38
+
39
+ export async function envTick(
40
+ now: number,
41
+ c: HealthConfig,
42
+ deps: EnvDeps = realEnv
43
+ ): Promise<void> {
44
+ raiseAlerts(now, c, deps.alerts);
45
+ await sendFixNotes(now, deps.notes);
46
+ }
@@ -0,0 +1,24 @@
1
+ import { getSetting, putSetting } from "@/lib/settings-store";
2
+ import { classById } from "./classes";
3
+
4
+ export const MAX_FIX = 500;
5
+ const key = (id: string) => `health.envFix.${id}`;
6
+
7
+ // The fix a session is sent: this machine's own text if one was set (it
8
+ // stays in the local database, never in the repository), else the default.
9
+ export function fixFor(id: string): string | null {
10
+ const own = getSetting(key(id));
11
+ if (own !== null && own.trim() !== "") return own;
12
+ return classById(id)?.fix ?? null;
13
+ }
14
+
15
+ export const hasOwnFix = (id: string): boolean =>
16
+ (getSetting(key(id)) ?? "").trim() !== "";
17
+
18
+ // Null puts the default back. False for a class that doesn't exist.
19
+ export function setFix(id: string, text: string | null): boolean {
20
+ if (!classById(id)) return false;
21
+ const t = text?.replace(/\s+/g, " ").trim().slice(0, MAX_FIX) ?? "";
22
+ putSetting(key(id), t === "" ? null : t);
23
+ return true;
24
+ }
@@ -0,0 +1,21 @@
1
+ import { db } from "@/lib/db";
2
+ import { updateSessionRow } from "@/lib/db/session-parts/rows";
3
+
4
+ export function seedSession(
5
+ id: string,
6
+ patch: Record<string, string | number | null> = {}
7
+ ): void {
8
+ db.prepare(
9
+ `INSERT OR REPLACE INTO sessions (id, name, tmux_name, working_directory)
10
+ VALUES (?, ?, ?, '/repo')`
11
+ ).run(id, `session ${id}`, `t-${id}`);
12
+ updateSessionRow(db, id, { view: "chat", ...patch });
13
+ }
14
+
15
+ export function resetEnv(): void {
16
+ db.exec(`DELETE FROM env_failures`);
17
+ for (const r of db
18
+ .prepare(`SELECT id FROM sessions WHERE id LIKE 'env-%'`)
19
+ .all() as { id: string }[])
20
+ db.prepare(`DELETE FROM sessions WHERE id = ?`).run(r.id);
21
+ }
@@ -0,0 +1,6 @@
1
+ export { ENV_CLASSES, classById } from "./classes";
2
+ export { classifyToolResult } from "./classify";
3
+ export { noteToolResult, recordHit } from "./recordHit";
4
+ export { envTick, realEnv, type EnvDeps } from "./envTick";
5
+ export { envSummary, envSessions } from "./summarize";
6
+ export { fixFor, setFix, MAX_FIX } from "./fixText";
@@ -0,0 +1,84 @@
1
+ import { db } from "@/lib/db";
2
+ import type { queueEventOnce } from "@/lib/orchestrator/events";
3
+ import { untrusted } from "@/lib/orchestrator/untrusted";
4
+ import type { HealthConfig } from "../config";
5
+ import { MIN } from "../types";
6
+ import { classById } from "./classes";
7
+ import { fixFor } from "./fixText";
8
+ import type { EnvFailureRow } from "./types";
9
+
10
+ const DAY = 24 * 60 * MIN;
11
+
12
+ export interface AlertDeps {
13
+ queue: typeof queueEventOnce;
14
+ workspaces: () => string[];
15
+ }
16
+
17
+ const one = (sql: string, ...args: unknown[]) =>
18
+ (db.prepare(sql).get(...args) as { n: number }).n;
19
+
20
+ // The orchestrator hears of a class when it comes back after a quiet spell
21
+ // (or is new), or when enough sessions hit it inside a window. One event
22
+ // per row, and a burst at most once per window: the root cause is the thing
23
+ // to fix, so a hot class is one message, not one per session.
24
+ export function raiseAlerts(
25
+ now: number,
26
+ c: HealthConfig,
27
+ deps: AlertDeps
28
+ ): number {
29
+ const window = c.envBurstHours * 60 * MIN;
30
+ const quiet = c.envQuietDays * DAY;
31
+ const fresh = db
32
+ .prepare(
33
+ `SELECT * FROM env_failures WHERE alerted IS NULL AND first_at >= ?
34
+ ORDER BY first_at, id`
35
+ )
36
+ .all(now - window) as EnvFailureRow[];
37
+ let raised = 0;
38
+ for (const r of fresh) {
39
+ const recent = one(
40
+ `SELECT COUNT(*) AS n FROM env_failures WHERE class = ? AND first_at >= ?`,
41
+ r.class,
42
+ now - window
43
+ );
44
+ const bursting =
45
+ recent >= c.envBurstSessions &&
46
+ one(
47
+ `SELECT COUNT(*) AS n FROM env_failures WHERE class = ?
48
+ AND alerted = 'burst' AND alerted_at >= ?`,
49
+ r.class,
50
+ now - window
51
+ ) === 0;
52
+ const before = one(
53
+ `SELECT COUNT(*) AS n FROM env_failures WHERE class = ? AND id != ?
54
+ AND first_at < ? AND first_at >= ?`,
55
+ r.class,
56
+ r.id,
57
+ r.first_at,
58
+ r.first_at - quiet
59
+ );
60
+ const kind = bursting ? "burst" : before === 0 ? "recur" : null;
61
+ if (!kind) continue;
62
+ db.prepare(
63
+ `UPDATE env_failures SET alerted = ?, alerted_at = ? WHERE id = ?`
64
+ ).run(kind, now, r.id);
65
+ const label = classById(r.class)?.label ?? r.class;
66
+ const what =
67
+ kind === "burst"
68
+ ? `${recent} sessions in ${c.envBurstHours}h`
69
+ : "back after a quiet spell";
70
+ const line = `Environment failure (${label}): ${what}. Latest: ${untrusted("session command", r.command ?? r.class)}. A fix note ${fixFor(r.class) ? "goes to each session" : "is not defined"}; fix the root cause rather than keep nudging.`;
71
+ const targets = r.workspace_id ? [r.workspace_id] : deps.workspaces();
72
+ for (const ws of targets)
73
+ deps.queue(
74
+ ws,
75
+ `env:${r.class}:${kind}:${r.id}`,
76
+ `env:${r.class}`,
77
+ line,
78
+ now,
79
+ false
80
+ );
81
+ raised++;
82
+ }
83
+ return raised;
84
+ }
@@ -0,0 +1,62 @@
1
+ import { db } from "@/lib/db";
2
+ import type { ChatItem } from "@/lib/chat/events";
3
+ import { classifyToolResult } from "./classify";
4
+ import type { EnvHit } from "./types";
5
+
6
+ type ToolItem = Extract<ChatItem, { kind: "tool" }>;
7
+
8
+ // One row per session and class; the same item seen again doesn't count.
9
+ export function recordHit(
10
+ sessionId: string,
11
+ itemId: string,
12
+ hit: EnvHit,
13
+ now = Date.now()
14
+ ): void {
15
+ const s = db
16
+ .prepare(
17
+ `SELECT r.project_id, COALESCE(r.workspace_id, p.workspace_id) AS ws
18
+ FROM session_rows r LEFT JOIN projects p ON p.id = r.project_id
19
+ WHERE r.id = ?`
20
+ )
21
+ .get(sessionId) as
22
+ | { project_id: string | null; ws: string | null }
23
+ | undefined;
24
+ db.prepare(
25
+ `INSERT INTO env_failures
26
+ (session_id, class, project_id, workspace_id, command, line,
27
+ last_item_id, first_at, last_at)
28
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
29
+ ON CONFLICT(session_id, class) DO UPDATE SET
30
+ hits = hits + (excluded.last_item_id IS NOT last_item_id),
31
+ last_at = excluded.last_at, last_item_id = excluded.last_item_id,
32
+ command = excluded.command, line = excluded.line`
33
+ ).run(
34
+ sessionId,
35
+ hit.class,
36
+ s?.project_id ?? null,
37
+ s?.ws ?? null,
38
+ hit.command,
39
+ hit.line,
40
+ itemId,
41
+ now,
42
+ now
43
+ );
44
+ }
45
+
46
+ // The runner's entry point for every tool item. It must never throw into
47
+ // the chat, and a call still running has no result to read yet.
48
+ export function noteToolResult(sessionId: string, item: ToolItem): void {
49
+ if (item.status !== "done" && item.status !== "error") return;
50
+ if (!item.output) return;
51
+ try {
52
+ const hit = classifyToolResult({
53
+ tool: item.name,
54
+ input: item.input,
55
+ output: item.output,
56
+ status: item.status,
57
+ });
58
+ if (hit) recordHit(sessionId, item.id, hit);
59
+ } catch (error) {
60
+ console.error(`[env-failures] ${sessionId}:`, error);
61
+ }
62
+ }
@@ -0,0 +1,80 @@
1
+ import { db } from "@/lib/db";
2
+ import { MIN } from "../types";
3
+ import { fixFor } from "./fixText";
4
+ import { classById } from "./classes";
5
+ import type { EnvFailureRow } from "./types";
6
+
7
+ // A note this late is about something the session has moved on from.
8
+ export const STALE_MS = 120 * MIN;
9
+
10
+ export interface NoteDeps {
11
+ send: (sessionId: string, body: string) => Promise<void>;
12
+ // Waiting on a person: an approval or a question.
13
+ blocked: (sessionId: string, role: "orchestrator" | null) => boolean;
14
+ }
15
+
16
+ interface Target {
17
+ role: "orchestrator" | null;
18
+ archived_at: string | null;
19
+ task_status: string | null;
20
+ pause_state: string | null;
21
+ }
22
+
23
+ const settle = (id: number, why: string) =>
24
+ db
25
+ .prepare(`UPDATE env_failures SET nudge_skipped = ? WHERE id = ?`)
26
+ .run(why, id);
27
+
28
+ export const noteBody = (r: EnvFailureRow, fix: string): string => {
29
+ const label = classById(r.class)?.label ?? r.class;
30
+ const cmd = r.command ? ` (\`${r.command.slice(0, 100)}\`)` : "";
31
+ return `Environment: ${label}${cmd}. ${fix}`;
32
+ };
33
+
34
+ // Each session hears each class's fix once: the row is claimed before the
35
+ // send, so a failed send is lost rather than repeated. A paused session or
36
+ // one waiting on a person is looked at again next tick.
37
+ export async function sendFixNotes(
38
+ now: number,
39
+ deps: NoteDeps
40
+ ): Promise<number> {
41
+ const rows = db
42
+ .prepare(
43
+ `SELECT * FROM env_failures WHERE nudged_at IS NULL AND nudge_skipped IS NULL
44
+ ORDER BY id`
45
+ )
46
+ .all() as EnvFailureRow[];
47
+ let sent = 0;
48
+ for (const r of rows) {
49
+ const fix = fixFor(r.class);
50
+ if (!fix) {
51
+ settle(r.id, "no fix");
52
+ continue;
53
+ }
54
+ const s = db
55
+ .prepare(
56
+ `SELECT role, archived_at, task_status, pause_state FROM session_rows WHERE id = ?`
57
+ )
58
+ .get(r.session_id) as Target | undefined;
59
+ if (!s || s.archived_at || (s.task_status && s.task_status !== "running")) {
60
+ settle(r.id, "gone");
61
+ continue;
62
+ }
63
+ if (now - r.first_at > STALE_MS) {
64
+ settle(r.id, "stale");
65
+ continue;
66
+ }
67
+ if (s.pause_state || deps.blocked(r.session_id, s.role)) continue;
68
+ db.prepare(`UPDATE env_failures SET nudged_at = ? WHERE id = ?`).run(
69
+ now,
70
+ r.id
71
+ );
72
+ try {
73
+ await deps.send(r.session_id, noteBody(r, fix));
74
+ sent++;
75
+ } catch (error) {
76
+ console.error(`[env-failures] note to ${r.session_id} failed:`, error);
77
+ }
78
+ }
79
+ return sent;
80
+ }
@@ -0,0 +1,94 @@
1
+ import { db } from "@/lib/db";
2
+ import { MIN } from "../types";
3
+ import { ENV_CLASSES } from "./classes";
4
+ import { fixFor, hasOwnFix } from "./fixText";
5
+ import type { EnvSessionView, EnvSummary } from "./types";
6
+
7
+ const DAY = 24 * 60 * MIN;
8
+
9
+ interface Group {
10
+ class: string;
11
+ project_id: string | null;
12
+ name: string | null;
13
+ s7: number;
14
+ s30: number;
15
+ }
16
+
17
+ // Sessions that hit each class in the last 7 and 30 days, by project.
18
+ export function envSummary(now = Date.now()): EnvSummary {
19
+ const groups = db
20
+ .prepare(
21
+ `SELECT e.class, e.project_id, p.name,
22
+ SUM(e.last_at >= ?) AS s7, COUNT(*) AS s30
23
+ FROM env_failures e LEFT JOIN projects p ON p.id = e.project_id
24
+ WHERE e.last_at >= ? GROUP BY e.class, e.project_id`
25
+ )
26
+ .all(now - 7 * DAY, now - 30 * DAY) as Group[];
27
+ return {
28
+ now,
29
+ classes: ENV_CLASSES.map((c) => {
30
+ const mine = groups.filter((g) => g.class === c.id);
31
+ return {
32
+ id: c.id,
33
+ label: c.label,
34
+ fix: fixFor(c.id),
35
+ fixOverridden: hasOwnFix(c.id),
36
+ sessions7: mine.reduce((n, g) => n + g.s7, 0),
37
+ sessions30: mine.reduce((n, g) => n + g.s30, 0),
38
+ projects: mine
39
+ .map((g) => ({
40
+ id: g.project_id,
41
+ name: g.name ?? "No project",
42
+ sessions7: g.s7,
43
+ sessions30: g.s30,
44
+ }))
45
+ .sort((a, b) => b.sessions30 - a.sessions30),
46
+ };
47
+ }).sort((a, b) => b.sessions30 - a.sessions30),
48
+ };
49
+ }
50
+
51
+ interface Row {
52
+ session_id: string;
53
+ name: string | null;
54
+ project: string | null;
55
+ command: string | null;
56
+ line: string | null;
57
+ hits: number;
58
+ first_at: number;
59
+ last_at: number;
60
+ nudged_at: number | null;
61
+ nudge_skipped: string | null;
62
+ }
63
+
64
+ export function envSessions(
65
+ cls: string,
66
+ days: number,
67
+ now = Date.now()
68
+ ): EnvSessionView[] {
69
+ const rows = db
70
+ .prepare(
71
+ `SELECT e.session_id, s.name, p.name AS project, e.command, e.line,
72
+ e.hits, e.first_at, e.last_at, e.nudged_at, e.nudge_skipped
73
+ FROM env_failures e
74
+ LEFT JOIN session_rows s ON s.id = e.session_id
75
+ LEFT JOIN projects p ON p.id = e.project_id
76
+ WHERE e.class = ? AND e.last_at >= ? ORDER BY e.last_at DESC LIMIT 100`
77
+ )
78
+ .all(cls, now - days * DAY) as Row[];
79
+ return rows.map((r) => ({
80
+ sessionId: r.session_id,
81
+ name: r.name ?? r.session_id.slice(0, 8),
82
+ project: r.project,
83
+ command: r.command,
84
+ line: r.line,
85
+ hits: r.hits,
86
+ firstAt: r.first_at,
87
+ lastAt: r.last_at,
88
+ fix: r.nudged_at
89
+ ? "sent"
90
+ : r.nudge_skipped
91
+ ? `not sent (${r.nudge_skipped})`
92
+ : "waiting to send",
93
+ }));
94
+ }