feat(swads): add daily image report workflow
This commit is contained in:
+14
-3
@@ -13,6 +13,9 @@ import {
|
||||
InMemorySessionRegistry,
|
||||
PiAgentSessionFactory,
|
||||
SkillCatalog,
|
||||
SwadsGateway,
|
||||
ReportStore,
|
||||
REPORT_CSP,
|
||||
safeErrorMessage,
|
||||
type AgentSessionFactory,
|
||||
} from "@agent-studio/harness";
|
||||
@@ -25,7 +28,7 @@ const eventQuery = z.object({ afterSequence: z.coerce.number().int().nonnegative
|
||||
|
||||
function parse<T>(schema: z.ZodType<T>, value: unknown): T { return schema.parse(value); }
|
||||
|
||||
export interface AppServices { sessions: InMemorySessionRegistry; events: InMemoryEventStore; attachments: FileAttachmentStore; models: InMemoryModelConnectionStore; skills: SkillCatalog }
|
||||
export interface AppServices { sessions: InMemorySessionRegistry; events: InMemoryEventStore; attachments: FileAttachmentStore; models: InMemoryModelConnectionStore; skills: SkillCatalog; reports: ReportStore }
|
||||
export interface AppOptions { projectRoot?: string; runTimeoutMs?: number; approvalTimeoutMs?: number; heartbeatMs?: number; agentFactory?: AgentSessionFactory; onReady?: (services: AppServices) => void }
|
||||
|
||||
export async function buildApp(options: AppOptions = {}): Promise<FastifyInstance> {
|
||||
@@ -35,6 +38,8 @@ export async function buildApp(options: AppOptions = {}): Promise<FastifyInstanc
|
||||
await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
|
||||
await mkdir(agentDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const gateway = new SwadsGateway();
|
||||
const reports = new ReportStore(path.join(projectRoot, "workspace/reports"), path.join(projectRoot, ".agents/skills/swads-daily-report/assets/report.css"));
|
||||
const events = new InMemoryEventStore(1_000);
|
||||
const credentialVault = process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY
|
||||
? new EncryptedFileCredentialVault(path.join(projectRoot, "workspace", "model-credentials.enc.json"), process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY)
|
||||
@@ -46,8 +51,8 @@ export async function buildApp(options: AppOptions = {}): Promise<FastifyInstanc
|
||||
const skills = new SkillCatalog(projectRoot, agentDir);
|
||||
await skills.reload();
|
||||
const sessions = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, skills, undefined, options.approvalTimeoutMs ?? Number(process.env.APPROVAL_TIMEOUT_MS ?? 60_000), options.runTimeoutMs ?? Number(process.env.SESSION_RUN_TIMEOUT_MS ?? 120_000));
|
||||
sessions.setFactory(options.agentFactory ?? new PiAgentSessionFactory(path.join(projectRoot, "fixtures", "ads-account.json"), skills, models, sessions.approvals));
|
||||
options.onReady?.({ sessions, events, attachments, models, skills });
|
||||
sessions.setFactory(options.agentFactory ?? new PiAgentSessionFactory(path.join(projectRoot, "fixtures", "ads-account.json"), skills, models, sessions.approvals, gateway, reports));
|
||||
options.onReady?.({ sessions, events, attachments, models, skills, reports });
|
||||
|
||||
const app = Fastify({
|
||||
genReqId: () => `req_${randomUUID()}`,
|
||||
@@ -65,6 +70,12 @@ export async function buildApp(options: AppOptions = {}): Promise<FastifyInstanc
|
||||
return sendError(reply, request, 500, "INTERNAL_ERROR", "Internal server error");
|
||||
});
|
||||
|
||||
app.get("/api/reports/:accountKey/:reportDate/:reportId/:format", async (request, reply) => {
|
||||
const params = request.params as Record<string, string>;
|
||||
const result = await reports.read({ accountKey: params.accountKey, reportDate: params.reportDate, reportId: params.reportId }, params.format);
|
||||
const mime = { png: "image/png", html: "text/html; charset=utf-8", json: "application/json; charset=utf-8" };
|
||||
return reply.header("content-type", mime[result.format]).header("content-disposition", `${result.format === "png" ? "inline" : "attachment"}; filename="swads-daily-report.${result.format}"`).header("x-content-type-options", "nosniff").header("content-security-policy", REPORT_CSP).header("cache-control", "private, no-store").send(result.bytes);
|
||||
});
|
||||
app.get("/health", async (request) => ({ ok: true, requestId: request.id }));
|
||||
app.get("/api/skills", async (request) => ({ skills: skills.list(), requestId: request.id }));
|
||||
app.post("/api/sessions", async (request, reply) => { const session = await sessions.create(); return reply.code(201).send({ sessionId: session.sessionId, requestId: request.id }); });
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mkdtemp, mkdir } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, copyFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildApp, type AppServices } from "../src/app.js";
|
||||
import type { AgentSessionFactory, AgentSessionPort } from "@agent-studio/harness";
|
||||
import { sampleReport } from "../../../packages/harness/tests/report-fixture.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
class ServerFakePort implements AgentSessionPort {
|
||||
@@ -20,7 +21,7 @@ afterEach(async () => { while (apps.length) await apps.pop()?.close(); });
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "studio-server-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); await mkdir(path.join(root, "fixtures"));
|
||||
const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, services: services! };
|
||||
const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, root, services: services! };
|
||||
}
|
||||
async function createSession(app: FastifyInstance) { const response = await app.inject({ method: "POST", url: "/api/sessions" }); return (response.json() as { sessionId: string }).sessionId; }
|
||||
async function configure(app: FastifyInstance, sessionId: string, supportsImages = true) { return app.inject({ method: "PUT", url: `/api/sessions/${sessionId}/model-config`, payload: { providerId: "fake", api: "openai-responses", modelId: "fake-model", apiKey: "sk-test-never-return", supportsImages } }); }
|
||||
@@ -36,6 +37,14 @@ describe("Fastify API", () => {
|
||||
it("supports approve and deny endpoints with conflict detection", async () => { const { app, services } = await fixture(); const sessionId = await createSession(app); const controller = new AbortController(); const pending = services.sessions.approvals.request({ sessionId, runId: "run_test", toolCallId: "tool_test", toolName: "save_report_draft", arguments: {}, riskLevel: "medium" }, controller.signal); const event = services.events.listAfter(sessionId).events.find((item) => item.type === "approval.required"); if (!event || event.type !== "approval.required") throw new Error("missing approval"); const approved = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/approve` }); expect(approved.statusCode).toBe(200); expect((await pending).status).toBe("approved"); const conflict = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/deny` }); expect(conflict.statusCode).toBe(409); });
|
||||
it("accepts a valid multipart PNG and rejects SVG", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); const accepted = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "image.png", "image/png", png) }); expect(accepted.statusCode).toBe(201); expect(accepted.json()).toMatchObject({ mediaType: "image/png", width: 1, status: "ready" }); const rejected = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "x.svg", "image/svg+xml", Buffer.from("<svg/>")) }); expect(rejected.statusCode).toBe(415); });
|
||||
it("serves SSE events and heartbeat comments", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const address = await app.listen({ host: "127.0.0.1", port: 0 }); const controller = new AbortController(); const response = await fetch(`${address}/api/sessions/${sessionId}/events`, { signal: controller.signal }); expect(response.headers.get("content-type")).toContain("text/event-stream"); const reader = response.body!.getReader(); let text = ""; for (let count = 0; count < 4 && !text.includes(": heartbeat"); count++) { const chunk = await reader.read(); text += new TextDecoder().decode(chunk.value); } controller.abort(); expect(text).toContain("event: session.started"); expect(text).toContain(": heartbeat"); });
|
||||
it("keeps reports downloadable after session deletion and guards artifact routes", async () => {
|
||||
const { app, root, services } = await fixture(); const assets = path.join(root, ".agents/skills/swads-daily-report/assets"); await mkdir(assets, { recursive: true }); await copyFile(path.resolve(import.meta.dirname, "../../../.agents/skills/swads-daily-report/assets/report.css"), path.join(assets, "report.css"));
|
||||
const artifact = await services.reports.create(sampleReport(), new AbortController().signal); const session = await createSession(app); await app.inject({ method: "DELETE", url: `/api/sessions/${session}` });
|
||||
for (const [format, url] of Object.entries(artifact.downloads)) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(200); expect(response.headers["content-disposition"]).toContain(format === "png" ? "inline" : "attachment"); expect(response.headers["x-content-type-options"]).toBe("nosniff"); expect(response.headers["content-security-policy"]).toContain("default-src 'none'"); expect(response.body).not.toContain(root); expect(response.headers["content-type"]).toContain(format === "png" ? "image/png" : format === "json" ? "application/json" : "text/html"); }
|
||||
const base = artifact.downloads.json.slice(0, -4);
|
||||
for (const url of [base + "secret", base.replace(artifact.reportId, "00000000-0000-4000-8000-000000000000") + "json", "/api/reports/%2e%2e/x/y/json", "/api/reports/", base + "%2e%2e%2fsecret"]) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(404); expect(response.body).not.toContain(root); }
|
||||
}, 60_000);
|
||||
|
||||
});
|
||||
|
||||
function multipart(boundary: string, filename: string, mime: string, data: Buffer): Buffer { return Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]); }
|
||||
|
||||
Reference in New Issue
Block a user