@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,60 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
import { recordHit } from "@/lib/health/env/recordHit";
|
|
4
|
+
import { resetEnv, seedSession } from "@/lib/health/env/helpers.test-util";
|
|
5
|
+
import { GET, PATCH } from "./route";
|
|
6
|
+
|
|
7
|
+
const get = (qs = "") =>
|
|
8
|
+
GET(new NextRequest(`http://localhost/api/health/env-failures${qs}`));
|
|
9
|
+
const patch = (body: unknown) =>
|
|
10
|
+
PATCH(
|
|
11
|
+
new NextRequest("http://localhost/api/health/env-failures", {
|
|
12
|
+
method: "PATCH",
|
|
13
|
+
body: JSON.stringify(body),
|
|
14
|
+
})
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
beforeEach(resetEnv);
|
|
18
|
+
|
|
19
|
+
describe("/api/health/env-failures", () => {
|
|
20
|
+
it("counts sessions per class and lists a class's sessions", async () => {
|
|
21
|
+
seedSession("env-a");
|
|
22
|
+
recordHit(
|
|
23
|
+
"env-a",
|
|
24
|
+
"i",
|
|
25
|
+
{ class: "python-not-found", line: "l", command: "python x" },
|
|
26
|
+
Date.now()
|
|
27
|
+
);
|
|
28
|
+
const summary = await (await get()).json();
|
|
29
|
+
const c = summary.classes.find(
|
|
30
|
+
(k: { id: string }) => k.id === "python-not-found"
|
|
31
|
+
);
|
|
32
|
+
expect([c.sessions7, c.sessions30]).toEqual([1, 1]);
|
|
33
|
+
const list = await (await get("?class=python-not-found")).json();
|
|
34
|
+
expect(
|
|
35
|
+
list.sessions.map((s: { sessionId: string }) => s.sessionId)
|
|
36
|
+
).toEqual(["env-a"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("refuses an unknown class or days", async () => {
|
|
40
|
+
expect((await get("?class=nope")).status).toBe(400);
|
|
41
|
+
expect((await get("?days=0")).status).toBe(400);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("sets and clears this machine's fix text", async () => {
|
|
45
|
+
expect(
|
|
46
|
+
(await patch({ class: "python-not-found", fix: "Use uv." })).status
|
|
47
|
+
).toBe(200);
|
|
48
|
+
let s = await (await get()).json();
|
|
49
|
+
let c = s.classes.find((k: { id: string }) => k.id === "python-not-found");
|
|
50
|
+
expect([c.fix, c.fixOverridden]).toEqual(["Use uv.", true]);
|
|
51
|
+
await patch({ class: "python-not-found", fix: null });
|
|
52
|
+
s = await (await get()).json();
|
|
53
|
+
c = s.classes.find((k: { id: string }) => k.id === "python-not-found");
|
|
54
|
+
expect(c.fixOverridden).toBe(false);
|
|
55
|
+
expect((await patch({ class: "nope", fix: "x" })).status).toBe(404);
|
|
56
|
+
expect(
|
|
57
|
+
(await patch({ class: "python-not-found", fix: "x".repeat(501) })).status
|
|
58
|
+
).toBe(400);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ENV_CLASSES } from "@/lib/health/env/classes";
|
|
4
|
+
import { setFix, MAX_FIX } from "@/lib/health/env/fixText";
|
|
5
|
+
import { envSessions, envSummary } from "@/lib/health/env/summarize";
|
|
6
|
+
|
|
7
|
+
const query = z.object({
|
|
8
|
+
class: z
|
|
9
|
+
.string()
|
|
10
|
+
.refine((c) => ENV_CLASSES.some((k) => k.id === c))
|
|
11
|
+
.optional(),
|
|
12
|
+
days: z.coerce.number().int().min(1).max(90).default(30),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// GET /api/health/env-failures - sessions per class and project over the
|
|
16
|
+
// last 7 and 30 days. ?class=<id>[&days=30] lists that class's sessions.
|
|
17
|
+
export function GET(request: NextRequest) {
|
|
18
|
+
const parsed = query.safeParse(
|
|
19
|
+
Object.fromEntries(request.nextUrl.searchParams)
|
|
20
|
+
);
|
|
21
|
+
if (!parsed.success)
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{ error: "Unknown class or days" },
|
|
24
|
+
{ status: 400 }
|
|
25
|
+
);
|
|
26
|
+
const { class: cls, days } = parsed.data;
|
|
27
|
+
return NextResponse.json(
|
|
28
|
+
cls ? { sessions: envSessions(cls, days) } : envSummary()
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const patch = z.object({
|
|
33
|
+
class: z.string(),
|
|
34
|
+
fix: z.string().max(MAX_FIX).nullable(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// PATCH /api/health/env-failures { class, fix } - this machine's own fix text
|
|
38
|
+
// for a class; null puts the default back.
|
|
39
|
+
export async function PATCH(request: NextRequest) {
|
|
40
|
+
const body = patch.safeParse(await request.json().catch(() => null));
|
|
41
|
+
if (!body.success)
|
|
42
|
+
return NextResponse.json(
|
|
43
|
+
{
|
|
44
|
+
error: `Send { class, fix: text up to ${MAX_FIX} characters, or null }`,
|
|
45
|
+
},
|
|
46
|
+
{ status: 400 }
|
|
47
|
+
);
|
|
48
|
+
if (!setFix(body.data.class, body.data.fix))
|
|
49
|
+
return NextResponse.json({ error: "No such class" }, { status: 404 });
|
|
50
|
+
return NextResponse.json({ ok: true });
|
|
51
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
let available = true;
|
|
5
|
+
const printed: string[] = [];
|
|
6
|
+
vi.mock("@/lib/share/pdf", () => ({
|
|
7
|
+
pdfAvailable: async () => available,
|
|
8
|
+
renderPdf: async (html: string) => {
|
|
9
|
+
printed.push(html);
|
|
10
|
+
return Buffer.from("%PDF-1.4 fake");
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const pdf = await import("./route");
|
|
15
|
+
const preview = await import("../preview/route");
|
|
16
|
+
const { saveItem } = await import("@/lib/chat/store");
|
|
17
|
+
const { seedSession } = await import("@/lib/orchestrator/testing");
|
|
18
|
+
const { createProject } = await import("@/lib/projects");
|
|
19
|
+
const { MAX_SHARE_BYTES } = await import("@/lib/share/render");
|
|
20
|
+
|
|
21
|
+
const secret = ["gh", "p_", "b".repeat(36)].join("");
|
|
22
|
+
|
|
23
|
+
const post = (url: string, body: unknown) =>
|
|
24
|
+
new Request(`http://localhost${url}`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "content-type": "application/json" },
|
|
27
|
+
body: JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function chat(extra = "") {
|
|
31
|
+
const project = createProject({
|
|
32
|
+
name: `p-${randomUUID().slice(0, 6)}`,
|
|
33
|
+
workingDirectory: "/tmp/p",
|
|
34
|
+
});
|
|
35
|
+
const id = seedSession({
|
|
36
|
+
projectId: project.id,
|
|
37
|
+
name: "Share me",
|
|
38
|
+
view: "chat",
|
|
39
|
+
});
|
|
40
|
+
saveItem(id, {
|
|
41
|
+
id: "u1",
|
|
42
|
+
kind: "user",
|
|
43
|
+
text: `key ${secret} keepme`,
|
|
44
|
+
createdAt: 1,
|
|
45
|
+
} as never);
|
|
46
|
+
saveItem(id, {
|
|
47
|
+
id: "a1",
|
|
48
|
+
kind: "assistant",
|
|
49
|
+
text: "```js\nconsole.log(1)\n```" + extra,
|
|
50
|
+
createdAt: 2,
|
|
51
|
+
} as never);
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
available = true;
|
|
57
|
+
printed.length = 0;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("POST /api/shares/pdf", () => {
|
|
61
|
+
it("returns a named PDF of the redacted page", async () => {
|
|
62
|
+
const id = chat();
|
|
63
|
+
const res = await pdf.POST(
|
|
64
|
+
post("/api/shares/pdf", { kind: "chat", sessionId: id })
|
|
65
|
+
);
|
|
66
|
+
expect(res.status).toBe(200);
|
|
67
|
+
expect(res.headers.get("content-type")).toBe("application/pdf");
|
|
68
|
+
expect(res.headers.get("content-disposition")).toBe(
|
|
69
|
+
'attachment; filename="share-me.pdf"'
|
|
70
|
+
);
|
|
71
|
+
expect(Buffer.from(await res.arrayBuffer()).toString()).toMatch(/^%PDF/);
|
|
72
|
+
expect(printed[0]).toContain("[secret]");
|
|
73
|
+
expect(printed[0]).not.toContain(secret);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("prints the same page the preview shows, honouring leave-outs", async () => {
|
|
77
|
+
const id = chat();
|
|
78
|
+
const body = { kind: "chat", sessionId: id, dropIds: ["u1"] };
|
|
79
|
+
const p = await (
|
|
80
|
+
await preview.POST(post("/api/shares/preview", body))
|
|
81
|
+
).json();
|
|
82
|
+
await pdf.POST(post("/api/shares/pdf", body));
|
|
83
|
+
expect(printed[0]).toBe(p.html);
|
|
84
|
+
expect(printed[0]).not.toContain("keepme");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("refuses too-big input, bad bodies and an empty selection", async () => {
|
|
88
|
+
const big = await pdf.POST(
|
|
89
|
+
post("/api/shares/pdf", {
|
|
90
|
+
kind: "chat",
|
|
91
|
+
sessionId: chat("word ".repeat(MAX_SHARE_BYTES / 4)),
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
expect(big.status).toBe(413);
|
|
95
|
+
expect((await pdf.POST(post("/api/shares/pdf", {}))).status).toBe(400);
|
|
96
|
+
const id = chat();
|
|
97
|
+
const none = await pdf.POST(
|
|
98
|
+
post("/api/shares/pdf", {
|
|
99
|
+
kind: "chat",
|
|
100
|
+
sessionId: id,
|
|
101
|
+
dropIds: ["u1", "a1"],
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
expect(none.status).toBe(400);
|
|
105
|
+
expect(printed).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("says 501 with no browser, and preview then omits pdf", async () => {
|
|
109
|
+
const id = chat();
|
|
110
|
+
const body = { kind: "chat", sessionId: id };
|
|
111
|
+
const withBrowser = await (
|
|
112
|
+
await preview.POST(post("/api/shares/preview", body))
|
|
113
|
+
).json();
|
|
114
|
+
expect(withBrowser.formats).toEqual(["pdf", "html", "md"]);
|
|
115
|
+
available = false;
|
|
116
|
+
expect((await pdf.POST(post("/api/shares/pdf", body))).status).toBe(501);
|
|
117
|
+
const without = await (
|
|
118
|
+
await preview.POST(post("/api/shares/preview", body))
|
|
119
|
+
).json();
|
|
120
|
+
expect(without.formats).toEqual(["html", "md"]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { renderPdf, pdfAvailable } from "@/lib/share/pdf";
|
|
3
|
+
import { readJson, refuseDemo, fail, toResponse } from "@/lib/share/route";
|
|
4
|
+
import { TOO_BIG, buildPage, parseRequest } from "@/lib/share/service";
|
|
5
|
+
import { fileName } from "@/lib/share/view";
|
|
6
|
+
|
|
7
|
+
// POST /api/shares/pdf - the preview's page as a PDF. Same body as preview.
|
|
8
|
+
// Local work only; the response is application/pdf, named after the title.
|
|
9
|
+
export async function POST(request: Request) {
|
|
10
|
+
const refused = refuseDemo();
|
|
11
|
+
if (refused) return refused;
|
|
12
|
+
const read = await readJson(request);
|
|
13
|
+
if ("refused" in read) return read.refused;
|
|
14
|
+
try {
|
|
15
|
+
const req = parseRequest(read.body);
|
|
16
|
+
if (!(await pdfAvailable()))
|
|
17
|
+
return fail("PDF isn't available on this machine.", 501);
|
|
18
|
+
const page = await buildPage(req);
|
|
19
|
+
if (page.tooBig) return fail(TOO_BIG, 413);
|
|
20
|
+
const pdf = await renderPdf(page.html);
|
|
21
|
+
return new NextResponse(new Uint8Array(pdf), {
|
|
22
|
+
headers: {
|
|
23
|
+
"Content-Type": "application/pdf",
|
|
24
|
+
"Content-Disposition": `attachment; filename="${fileName(page.title, "pdf")}"`,
|
|
25
|
+
"Cache-Control": "no-store",
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return toResponse(err);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import { pdfAvailable } from "@/lib/share/pdf";
|
|
2
3
|
import { linked } from "@/lib/share/connect";
|
|
3
4
|
import { readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
5
|
+
import type { ShareFormat } from "@/lib/share/view";
|
|
4
6
|
import { buildPage, parseRequest } from "@/lib/share/service";
|
|
5
7
|
|
|
6
8
|
// POST /api/shares/preview - exactly what a share would upload, redacted.
|
|
@@ -13,6 +15,8 @@ export async function POST(request: Request) {
|
|
|
13
15
|
try {
|
|
14
16
|
const req = parseRequest(read.body);
|
|
15
17
|
const page = await buildPage(req);
|
|
18
|
+
const formats: ShareFormat[] = ["html", "md"];
|
|
19
|
+
if (await pdfAvailable()) formats.unshift("pdf");
|
|
16
20
|
return NextResponse.json({
|
|
17
21
|
html: page.html,
|
|
18
22
|
markdown: page.markdown,
|
|
@@ -24,6 +28,7 @@ export async function POST(request: Request) {
|
|
|
24
28
|
hits: page.hits,
|
|
25
29
|
tooBig: page.tooBig,
|
|
26
30
|
connectLinked: linked(),
|
|
31
|
+
formats,
|
|
27
32
|
});
|
|
28
33
|
} catch (err) {
|
|
29
34
|
return toResponse(err);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
7
|
+
import { useEnvSessions, useSetEnvFix } from "@/data/health";
|
|
8
|
+
import type { EnvClassView } from "@/lib/health/env/types";
|
|
9
|
+
import { SessionList } from "./SessionList";
|
|
10
|
+
|
|
11
|
+
function Fix({ view }: { view: EnvClassView }) {
|
|
12
|
+
const save = useSetEnvFix();
|
|
13
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
14
|
+
if (draft === null)
|
|
15
|
+
return (
|
|
16
|
+
<div className="space-y-1">
|
|
17
|
+
<p className="text-sm font-medium">
|
|
18
|
+
Fix it sends {view.fixOverridden && "(this machine's text)"}
|
|
19
|
+
</p>
|
|
20
|
+
<p className="text-muted-foreground text-sm">
|
|
21
|
+
{view.fix ?? "None: recorded and escalated only."}
|
|
22
|
+
</p>
|
|
23
|
+
<Button
|
|
24
|
+
variant="outline"
|
|
25
|
+
className="min-h-11 md:min-h-8"
|
|
26
|
+
onClick={() => setDraft(view.fix ?? "")}
|
|
27
|
+
>
|
|
28
|
+
Edit
|
|
29
|
+
</Button>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
const done = () => setDraft(null);
|
|
33
|
+
return (
|
|
34
|
+
<div className="space-y-2">
|
|
35
|
+
<Textarea
|
|
36
|
+
aria-label={`Fix text for ${view.label}`}
|
|
37
|
+
value={draft}
|
|
38
|
+
maxLength={500}
|
|
39
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
40
|
+
/>
|
|
41
|
+
<div className="flex gap-2">
|
|
42
|
+
<Button
|
|
43
|
+
className="min-h-11 md:min-h-8"
|
|
44
|
+
disabled={save.isPending}
|
|
45
|
+
onClick={() =>
|
|
46
|
+
save.mutate({ class: view.id, fix: draft }, { onSuccess: done })
|
|
47
|
+
}
|
|
48
|
+
>
|
|
49
|
+
Save
|
|
50
|
+
</Button>
|
|
51
|
+
{view.fixOverridden && (
|
|
52
|
+
<Button
|
|
53
|
+
variant="outline"
|
|
54
|
+
className="min-h-11 md:min-h-8"
|
|
55
|
+
disabled={save.isPending}
|
|
56
|
+
onClick={() =>
|
|
57
|
+
save.mutate({ class: view.id, fix: null }, { onSuccess: done })
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
Use default
|
|
61
|
+
</Button>
|
|
62
|
+
)}
|
|
63
|
+
<Button variant="ghost" className="min-h-11 md:min-h-8" onClick={done}>
|
|
64
|
+
Cancel
|
|
65
|
+
</Button>
|
|
66
|
+
</div>
|
|
67
|
+
{save.error && (
|
|
68
|
+
<p role="alert" className="text-destructive text-sm">
|
|
69
|
+
{save.error.message}
|
|
70
|
+
</p>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function ClassRow({ view }: { view: EnvClassView }) {
|
|
77
|
+
const [open, setOpen] = useState(false);
|
|
78
|
+
const sessions = useEnvSessions(view.id, open);
|
|
79
|
+
return (
|
|
80
|
+
<li className="border-border/60 border-b px-3 py-1 last:border-b-0">
|
|
81
|
+
<details onToggle={(e) => setOpen(e.currentTarget.open)}>
|
|
82
|
+
<summary className="flex min-h-11 cursor-pointer flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
83
|
+
<span className="font-medium">{view.label}</span>
|
|
84
|
+
<Badge variant="warning">{view.sessions7} in 7d</Badge>
|
|
85
|
+
<Badge variant="secondary">{view.sessions30} in 30d</Badge>
|
|
86
|
+
<span className="text-muted-foreground min-w-0 basis-full text-xs">
|
|
87
|
+
{view.projects
|
|
88
|
+
.slice(0, 4)
|
|
89
|
+
.map((p) => `${p.name} ${p.sessions7}/${p.sessions30}`)
|
|
90
|
+
.join(" · ")}
|
|
91
|
+
</span>
|
|
92
|
+
</summary>
|
|
93
|
+
<div className="space-y-3 pb-3">
|
|
94
|
+
<Fix view={view} />
|
|
95
|
+
<SessionList
|
|
96
|
+
pending={sessions.isPending}
|
|
97
|
+
error={sessions.error?.message}
|
|
98
|
+
sessions={sessions.data?.sessions ?? []}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
</details>
|
|
102
|
+
</li>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { openSessionLink } from "@/components/Chat/session-link";
|
|
4
|
+
import { compactTimeAgo } from "@/lib/session-meta";
|
|
5
|
+
import { hrefWithSession } from "@/lib/session-url";
|
|
6
|
+
import type { EnvSessionView } from "@/lib/health/env/types";
|
|
7
|
+
|
|
8
|
+
export function SessionList({
|
|
9
|
+
sessions,
|
|
10
|
+
pending,
|
|
11
|
+
error,
|
|
12
|
+
}: {
|
|
13
|
+
sessions: EnvSessionView[];
|
|
14
|
+
pending: boolean;
|
|
15
|
+
error?: string;
|
|
16
|
+
}) {
|
|
17
|
+
if (pending)
|
|
18
|
+
return <div className="bg-muted/40 h-16 animate-pulse rounded-lg" />;
|
|
19
|
+
if (error)
|
|
20
|
+
return (
|
|
21
|
+
<p role="alert" className="text-destructive text-sm">
|
|
22
|
+
{error}
|
|
23
|
+
</p>
|
|
24
|
+
);
|
|
25
|
+
return (
|
|
26
|
+
<ul className="space-y-1">
|
|
27
|
+
{sessions.map((s) => (
|
|
28
|
+
<li key={s.sessionId} className="min-h-11 text-sm">
|
|
29
|
+
<a
|
|
30
|
+
href={hrefWithSession("/", s.sessionId)}
|
|
31
|
+
onClick={(e) => openSessionLink(e, s.sessionId)}
|
|
32
|
+
className="font-medium underline-offset-2 hover:underline"
|
|
33
|
+
>
|
|
34
|
+
{s.name}
|
|
35
|
+
</a>
|
|
36
|
+
<span className="text-muted-foreground">
|
|
37
|
+
{s.project ? ` · ${s.project}` : ""} ·{" "}
|
|
38
|
+
{compactTimeAgo(new Date(s.lastAt))} · fix {s.fix}
|
|
39
|
+
</span>
|
|
40
|
+
{s.command && (
|
|
41
|
+
<code className="text-muted-foreground block truncate text-xs">
|
|
42
|
+
{s.command}
|
|
43
|
+
</code>
|
|
44
|
+
)}
|
|
45
|
+
</li>
|
|
46
|
+
))}
|
|
47
|
+
</ul>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEnvFailures } from "@/data/health";
|
|
4
|
+
import { ClassRow } from "./ClassRow";
|
|
5
|
+
|
|
6
|
+
// Environment failures sessions hit (wrong Python, refused writes, lost
|
|
7
|
+
// permissions): how many sessions per class and project, and who.
|
|
8
|
+
export function EnvFailures() {
|
|
9
|
+
const { data, isPending, error } = useEnvFailures();
|
|
10
|
+
const hit = data?.classes.filter((c) => c.sessions30 > 0) ?? [];
|
|
11
|
+
const quiet = data?.classes.filter((c) => c.sessions30 === 0) ?? [];
|
|
12
|
+
return (
|
|
13
|
+
<div className="space-y-3">
|
|
14
|
+
<p className="text-muted-foreground text-sm">
|
|
15
|
+
Sessions that hit a known environment failure, once per session and
|
|
16
|
+
class. Each gets the fix once; the orchestrator hears when a class
|
|
17
|
+
spikes or comes back.
|
|
18
|
+
</p>
|
|
19
|
+
{error && (
|
|
20
|
+
<p role="alert" className="text-destructive text-sm">
|
|
21
|
+
{error.message}
|
|
22
|
+
</p>
|
|
23
|
+
)}
|
|
24
|
+
{isPending && (
|
|
25
|
+
<div className="bg-muted/40 h-24 animate-pulse rounded-xl" />
|
|
26
|
+
)}
|
|
27
|
+
{data && hit.length === 0 && (
|
|
28
|
+
<p className="text-muted-foreground text-sm">
|
|
29
|
+
No environment failures in the last 30 days.
|
|
30
|
+
</p>
|
|
31
|
+
)}
|
|
32
|
+
{hit.length > 0 && (
|
|
33
|
+
<ul className="border-border/60 overflow-hidden rounded-xl border">
|
|
34
|
+
{hit.map((c) => (
|
|
35
|
+
<ClassRow key={c.id} view={c} />
|
|
36
|
+
))}
|
|
37
|
+
</ul>
|
|
38
|
+
)}
|
|
39
|
+
{quiet.length > 0 && (
|
|
40
|
+
<p className="text-muted-foreground text-xs">
|
|
41
|
+
Watching, no hits: {quiet.map((c) => c.label).join(", ")}.
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -19,13 +19,22 @@ import {
|
|
|
19
19
|
type CheckSummary,
|
|
20
20
|
} from "@/lib/health/view";
|
|
21
21
|
import { cn } from "@/lib/utils";
|
|
22
|
+
import { EnvFailures } from "./EnvFailures";
|
|
22
23
|
import { SweepRuns } from "./SweepRuns";
|
|
23
24
|
|
|
24
|
-
const TABS = [
|
|
25
|
+
const TABS = [
|
|
26
|
+
"open",
|
|
27
|
+
"history",
|
|
28
|
+
"env",
|
|
29
|
+
"notes",
|
|
30
|
+
"thresholds",
|
|
31
|
+
"cleanup",
|
|
32
|
+
] as const;
|
|
25
33
|
type Tab = (typeof TABS)[number];
|
|
26
34
|
const TAB_LABEL: Record<Tab, string> = {
|
|
27
35
|
open: "Open",
|
|
28
36
|
history: "History",
|
|
37
|
+
env: "Environment",
|
|
29
38
|
notes: "Notes",
|
|
30
39
|
thresholds: "Thresholds",
|
|
31
40
|
cleanup: "Clean-up",
|
|
@@ -407,6 +416,7 @@ export function HealthPanel() {
|
|
|
407
416
|
empty="Nothing has closed in the last 7 days."
|
|
408
417
|
/>
|
|
409
418
|
)}
|
|
419
|
+
{tab === "env" && <EnvFailures />}
|
|
410
420
|
{tab === "notes" && <Notes />}
|
|
411
421
|
{tab === "thresholds" && <Thresholds />}
|
|
412
422
|
{tab === "cleanup" && <SweepRuns />}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import {
|
|
6
|
+
ChevronDown,
|
|
7
|
+
Download,
|
|
8
|
+
FileCode,
|
|
9
|
+
FileText,
|
|
10
|
+
Loader2,
|
|
11
|
+
type LucideIcon,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import { Button } from "@/components/ui/button";
|
|
14
|
+
import {
|
|
15
|
+
DropdownMenu,
|
|
16
|
+
DropdownMenuContent,
|
|
17
|
+
DropdownMenuItem,
|
|
18
|
+
DropdownMenuTrigger,
|
|
19
|
+
} from "@/components/ui/dropdown-menu";
|
|
20
|
+
import {
|
|
21
|
+
useSharePdf,
|
|
22
|
+
type PreviewRequest,
|
|
23
|
+
type SharePreview,
|
|
24
|
+
} from "@/data/shares";
|
|
25
|
+
import { fileName, type ShareFormat } from "@/lib/share/view";
|
|
26
|
+
|
|
27
|
+
const FORMAT: Record<ShareFormat, { label: string; Icon: LucideIcon }> = {
|
|
28
|
+
pdf: { label: "PDF", Icon: FileText },
|
|
29
|
+
html: { label: "HTML page", Icon: FileCode },
|
|
30
|
+
md: { label: "Markdown", Icon: FileText },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function save(blob: Blob, name: string) {
|
|
34
|
+
const url = URL.createObjectURL(blob);
|
|
35
|
+
const a = document.createElement("a");
|
|
36
|
+
a.href = url;
|
|
37
|
+
a.download = name;
|
|
38
|
+
a.click();
|
|
39
|
+
URL.revokeObjectURL(url);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One Download button for every format the server says it can make. PDF is
|
|
43
|
+
// printed server-side, so it shows a spinner; HTML and Markdown are in hand.
|
|
44
|
+
export function DownloadMenu({
|
|
45
|
+
preview,
|
|
46
|
+
request,
|
|
47
|
+
title,
|
|
48
|
+
disabled,
|
|
49
|
+
}: {
|
|
50
|
+
preview: SharePreview | null;
|
|
51
|
+
request: PreviewRequest;
|
|
52
|
+
title: string;
|
|
53
|
+
disabled: boolean;
|
|
54
|
+
}) {
|
|
55
|
+
const pdf = useSharePdf();
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
const formats = preview?.formats ?? [];
|
|
58
|
+
|
|
59
|
+
const choose = async (format: ShareFormat) => {
|
|
60
|
+
if (!preview) return;
|
|
61
|
+
if (format === "pdf") {
|
|
62
|
+
try {
|
|
63
|
+
save(await pdf.mutateAsync(request), fileName(title, "pdf"));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
toast.error((err as Error).message || "Couldn't make the PDF");
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const md = format === "md";
|
|
70
|
+
save(
|
|
71
|
+
new Blob([md ? preview.markdown : preview.html], {
|
|
72
|
+
type: md ? "text/markdown" : "text/html",
|
|
73
|
+
}),
|
|
74
|
+
fileName(title, format)
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
80
|
+
<DropdownMenuTrigger asChild>
|
|
81
|
+
<Button
|
|
82
|
+
variant="outline"
|
|
83
|
+
className="min-h-11 flex-1 sm:flex-none"
|
|
84
|
+
disabled={disabled || !formats.length || pdf.isPending}
|
|
85
|
+
>
|
|
86
|
+
{pdf.isPending ? (
|
|
87
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
88
|
+
) : (
|
|
89
|
+
<Download className="h-4 w-4" />
|
|
90
|
+
)}
|
|
91
|
+
{pdf.isPending ? "Making PDF…" : "Download"}
|
|
92
|
+
<ChevronDown className="h-4 w-4 opacity-60" />
|
|
93
|
+
</Button>
|
|
94
|
+
</DropdownMenuTrigger>
|
|
95
|
+
<DropdownMenuContent align="end" className="min-w-44">
|
|
96
|
+
{formats.map((f) => {
|
|
97
|
+
const { label, Icon } = FORMAT[f];
|
|
98
|
+
return (
|
|
99
|
+
<DropdownMenuItem
|
|
100
|
+
key={f}
|
|
101
|
+
className="min-h-11 gap-2"
|
|
102
|
+
onSelect={() => void choose(f)}
|
|
103
|
+
>
|
|
104
|
+
<Icon className="h-4 w-4" />
|
|
105
|
+
{label}
|
|
106
|
+
</DropdownMenuItem>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</DropdownMenuContent>
|
|
110
|
+
</DropdownMenu>
|
|
111
|
+
);
|
|
112
|
+
}
|