@saadnvd1/agent-os 0.3.654 → 0.3.658
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/app/api/health/env-failures/route.test.ts +60 -0
- package/app/api/health/env-failures/route.ts +51 -0
- package/app/api/shares/pdf/route.test.ts +122 -0
- package/app/api/shares/pdf/route.ts +31 -0
- package/app/api/shares/preview/route.ts +5 -0
- package/components/Health/EnvFailures/ClassRow.tsx +104 -0
- package/components/Health/EnvFailures/SessionList.tsx +49 -0
- package/components/Health/EnvFailures/index.tsx +46 -0
- package/components/Health/HealthPanel.tsx +11 -1
- package/components/Share/DownloadMenu.tsx +112 -0
- package/components/Share/ShareDialog.tsx +31 -47
- package/data/health/index.ts +31 -0
- package/data/shares/queries.ts +11 -0
- package/lib/agents/brief.ts +1 -1
- package/lib/agents/self-env.ts +1 -0
- package/lib/chat/runner.ts +3 -0
- package/lib/db/migrations/20261016T1800_env_failures.ts +33 -0
- package/lib/dev-instance/index.ts +6 -0
- package/lib/dev-instance/liveServer.ts +57 -0
- package/lib/dev-instance/pickPort.ts +66 -0
- package/lib/dev-instance/processInfo.ts +68 -0
- package/lib/dev-instance/spawnServer.ts +36 -0
- package/lib/dev-instance/startDevServer.ts +80 -0
- package/lib/dev-instance/stopDevServer.ts +98 -0
- package/lib/dev-instance/types.ts +13 -0
- package/lib/health/config.ts +8 -0
- package/lib/health/env/classes.ts +121 -0
- package/lib/health/env/classify.ts +67 -0
- package/lib/health/env/envTick.ts +46 -0
- package/lib/health/env/fixText.ts +24 -0
- package/lib/health/env/helpers.test-util.ts +21 -0
- package/lib/health/env/index.ts +6 -0
- package/lib/health/env/raiseAlerts.ts +84 -0
- package/lib/health/env/recordHit.ts +62 -0
- package/lib/health/env/sendFixNotes.ts +80 -0
- package/lib/health/env/summarize.ts +94 -0
- package/lib/health/env/types.ts +73 -0
- package/lib/health/tick.ts +6 -0
- package/lib/orchestrator/brief.ts +1 -1
- package/lib/orchestrator/shell-guard.ts +43 -10
- package/lib/orchestrator/write-guard.ts +52 -0
- package/lib/security/demo.ts +1 -0
- package/lib/share/pdf.ts +116 -0
- package/lib/share/view.ts +9 -1
- package/package.json +5 -3
- package/scripts/dev-stop.ts +7 -0
- package/scripts/dev-task.ts +16 -0
- package/scripts/screenshots/demo.ts +4 -8
- package/scripts/screenshots/run.ts +5 -8
- package/server.ts +25 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface InstanceRecord {
|
|
2
|
+
pid: number;
|
|
3
|
+
port: number;
|
|
4
|
+
cwd: string;
|
|
5
|
+
startedAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DevRecord extends InstanceRecord {
|
|
9
|
+
log: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The port the server falls back to with no PORT or -p.
|
|
13
|
+
export const DEFAULT_PORT = 3011;
|
package/lib/health/config.ts
CHANGED
|
@@ -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
|
+
}
|