@m8i-51/shoal 0.1.0
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 +121 -0
- package/bin/shoal.js +56 -0
- package/framework/__tests__/coverage.test.ts +232 -0
- package/framework/__tests__/report.test.ts +154 -0
- package/framework/account-manager.ts +414 -0
- package/framework/agent-loop.ts +103 -0
- package/framework/agent-store.ts +47 -0
- package/framework/cost.ts +91 -0
- package/framework/coverage.ts +157 -0
- package/framework/findings.ts +53 -0
- package/framework/github.ts +64 -0
- package/framework/llm-client.ts +507 -0
- package/framework/observation.ts +182 -0
- package/framework/org-designer.ts +85 -0
- package/framework/product-discovery.ts +327 -0
- package/framework/report.ts +276 -0
- package/framework/scenario-designer.ts +141 -0
- package/framework/triage.ts +208 -0
- package/framework/types.ts +80 -0
- package/package.json +55 -0
- package/run.ts +1213 -0
- package/server/index.ts +227 -0
- package/server/runner.ts +125 -0
- package/server/runs.ts +103 -0
- package/targets/example.ts +55 -0
- package/targets/index.ts +17 -0
- package/targets/noop.ts +6 -0
- package/targets/types.ts +19 -0
- package/triage-only.ts +57 -0
- package/web/dist/assets/index-CD6EJ_1O.js +68 -0
- package/web/dist/assets/index-DPLuVm2n.css +1 -0
- package/web/dist/index.html +13 -0
package/server/index.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
6
|
+
import { listRuns, getReportPath } from "./runs.js";
|
|
7
|
+
import { activeSessions, spawnRun, cancelSession } from "./runner.js";
|
|
8
|
+
|
|
9
|
+
function specFilePath(baseUrl: string): string {
|
|
10
|
+
try {
|
|
11
|
+
const host = new URL(baseUrl).host.replace(/[^a-zA-Z0-9]/g, "-");
|
|
12
|
+
return join(process.cwd(), "product-specs", `${host}.json`);
|
|
13
|
+
} catch {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const app = express();
|
|
20
|
+
const PORT = parseInt(process.env.PORT ?? "4000", 10);
|
|
21
|
+
|
|
22
|
+
app.use(express.json());
|
|
23
|
+
|
|
24
|
+
// ----------------------------------------------------------------
|
|
25
|
+
// API: product spec (goals)
|
|
26
|
+
// ----------------------------------------------------------------
|
|
27
|
+
app.get("/api/spec", (_req, res) => {
|
|
28
|
+
const baseUrl = process.env.BASE_URL ?? "http://localhost:3000";
|
|
29
|
+
const filePath = specFilePath(baseUrl);
|
|
30
|
+
if (!filePath || !existsSync(filePath)) {
|
|
31
|
+
res.status(404).json({ error: "spec not found" });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
res.json(JSON.parse(readFileSync(filePath, "utf-8")));
|
|
36
|
+
} catch {
|
|
37
|
+
res.status(500).json({ error: "failed to read spec" });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
app.patch("/api/spec/goals", (req, res) => {
|
|
42
|
+
const baseUrl = process.env.BASE_URL ?? "http://localhost:3000";
|
|
43
|
+
const filePath = specFilePath(baseUrl);
|
|
44
|
+
if (!filePath || !existsSync(filePath)) {
|
|
45
|
+
res.status(404).json({ error: "spec not found" });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const { goals } = req.body as { goals?: unknown };
|
|
49
|
+
if (!Array.isArray(goals) || !goals.every((g) => typeof g === "string")) {
|
|
50
|
+
res.status(400).json({ error: "goals must be an array of strings" });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const spec = JSON.parse(readFileSync(filePath, "utf-8")) as Record<string, unknown>;
|
|
55
|
+
spec.appGoals = goals;
|
|
56
|
+
writeFileSync(filePath, JSON.stringify(spec, null, 2), "utf-8");
|
|
57
|
+
res.json({ ok: true });
|
|
58
|
+
} catch {
|
|
59
|
+
res.status(500).json({ error: "failed to update spec" });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ----------------------------------------------------------------
|
|
64
|
+
// API: list runs(アクティブなセッション情報で補完)
|
|
65
|
+
// ----------------------------------------------------------------
|
|
66
|
+
app.get("/api/runs", (_req, res) => {
|
|
67
|
+
const runs = listRuns();
|
|
68
|
+
|
|
69
|
+
// activeSessions が持つ isLive フラグで補完(インメモリ情報が優先)
|
|
70
|
+
const enriched = runs.map((r) => {
|
|
71
|
+
const session = activeSessions.get(r.runId);
|
|
72
|
+
if (session) return { ...r, isLive: !session.done };
|
|
73
|
+
return r;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
res.json(enriched);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ----------------------------------------------------------------
|
|
80
|
+
// API: serve HTML report for a run
|
|
81
|
+
// ----------------------------------------------------------------
|
|
82
|
+
app.get("/api/runs/:runId/report", (req, res) => {
|
|
83
|
+
const reportPath = getReportPath(req.params.runId);
|
|
84
|
+
if (!reportPath) {
|
|
85
|
+
res.status(404).json({ error: "report not found" });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
res.sendFile(reportPath);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ----------------------------------------------------------------
|
|
92
|
+
// API: start a run
|
|
93
|
+
// ----------------------------------------------------------------
|
|
94
|
+
app.post("/api/runs/start", (req, res) => {
|
|
95
|
+
const { baseUrl, maxBrowsers, maxExplorers, llmBaseUrl, llmApiKey, llmModel } = req.body as {
|
|
96
|
+
baseUrl?: string;
|
|
97
|
+
maxBrowsers?: number;
|
|
98
|
+
maxExplorers?: number;
|
|
99
|
+
llmBaseUrl?: string;
|
|
100
|
+
llmApiKey?: string;
|
|
101
|
+
llmModel?: string;
|
|
102
|
+
};
|
|
103
|
+
const sessionId = spawnRun({ baseUrl, maxBrowsers, maxExplorers, llmBaseUrl, llmApiKey, llmModel });
|
|
104
|
+
res.json({ sessionId });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ----------------------------------------------------------------
|
|
108
|
+
// API: cancel a running run
|
|
109
|
+
// ----------------------------------------------------------------
|
|
110
|
+
app.post("/api/runs/:runId/cancel", (req, res) => {
|
|
111
|
+
const ok = cancelSession(req.params.runId);
|
|
112
|
+
res.json({ ok });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ----------------------------------------------------------------
|
|
116
|
+
// SSE ヘルパー
|
|
117
|
+
// ----------------------------------------------------------------
|
|
118
|
+
function sseStream(req: express.Request, res: express.Response, sessionId: string) {
|
|
119
|
+
const session = activeSessions.get(sessionId);
|
|
120
|
+
if (!session) {
|
|
121
|
+
res.status(404).json({ error: "session not found" });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
126
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
127
|
+
res.setHeader("Connection", "keep-alive");
|
|
128
|
+
res.flushHeaders();
|
|
129
|
+
|
|
130
|
+
const send = (line: string) => {
|
|
131
|
+
if (res.destroyed) return;
|
|
132
|
+
try { res.write(`data: ${JSON.stringify(line)}\n\n`); } catch { /* ignore */ }
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const sendDone = () => {
|
|
136
|
+
if (res.destroyed) return;
|
|
137
|
+
try {
|
|
138
|
+
res.write(`event: done\ndata: ${JSON.stringify({ exitCode: session.exitCode })}\n\n`);
|
|
139
|
+
res.end();
|
|
140
|
+
} catch { /* ignore */ }
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
for (const line of session.lines) send(line);
|
|
144
|
+
|
|
145
|
+
if (session.done) { sendDone(); return; }
|
|
146
|
+
|
|
147
|
+
session.listeners.push(send);
|
|
148
|
+
session.doneListeners.push(sendDone);
|
|
149
|
+
|
|
150
|
+
req.on("close", () => {
|
|
151
|
+
session.listeners = session.listeners.filter((l) => l !== send);
|
|
152
|
+
session.doneListeners = session.doneListeners.filter((l) => l !== sendDone);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ----------------------------------------------------------------
|
|
157
|
+
// API: SSE — /api/sessions/:sessionId/events(後方互換)
|
|
158
|
+
// ----------------------------------------------------------------
|
|
159
|
+
app.get("/api/sessions/:sessionId/events", (req, res) => {
|
|
160
|
+
sseStream(req, res, req.params.sessionId);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ----------------------------------------------------------------
|
|
164
|
+
// API: SSE — /api/runs/:runId/events(詳細ページ用)
|
|
165
|
+
// ----------------------------------------------------------------
|
|
166
|
+
app.get("/api/runs/:runId/events", (req, res) => {
|
|
167
|
+
sseStream(req, res, req.params.runId);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ----------------------------------------------------------------
|
|
171
|
+
// API: ログ行をまとめて返す(完了後・再起動後もファイルから参照可能)
|
|
172
|
+
// ----------------------------------------------------------------
|
|
173
|
+
app.get("/api/runs/:runId/log", (req, res) => {
|
|
174
|
+
const { runId } = req.params;
|
|
175
|
+
|
|
176
|
+
// 1. アクティブセッション(インメモリ)を優先
|
|
177
|
+
const session = activeSessions.get(runId);
|
|
178
|
+
if (session) {
|
|
179
|
+
res.json({ lines: session.lines, done: session.done, exitCode: session.exitCode });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 2. 保存済みログファイルにフォールバック
|
|
184
|
+
const logFilePath = join(process.cwd(), "logs", `log_${runId}.txt`);
|
|
185
|
+
if (existsSync(logFilePath)) {
|
|
186
|
+
const lines = readFileSync(logFilePath, "utf-8").split("\n").filter((l) => l !== "");
|
|
187
|
+
res.json({ lines, done: true, exitCode: null });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
res.status(404).json({ error: "no log found" });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ----------------------------------------------------------------
|
|
195
|
+
// Static: serve built React app
|
|
196
|
+
// ----------------------------------------------------------------
|
|
197
|
+
const distPath = join(__dirname, "..", "web", "dist");
|
|
198
|
+
if (existsSync(distPath)) {
|
|
199
|
+
app.use(express.static(distPath));
|
|
200
|
+
app.get("/{*splat}", (_req, res) => {
|
|
201
|
+
res.sendFile(join(distPath, "index.html"));
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
app.get("/{*splat}", (_req, res) => {
|
|
205
|
+
res.status(503).send("Frontend not built. Run: npm run build:web");
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Express エラーハンドラ(クラッシュ防止)
|
|
210
|
+
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
211
|
+
console.error("[server] unhandled error:", err.message);
|
|
212
|
+
if (!res.headersSent) {
|
|
213
|
+
res.status(500).json({ error: "internal server error" });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Node.js uncaught exception / rejection をログだけしてサーバーを落とさない
|
|
218
|
+
process.on("uncaughtException", (err) => {
|
|
219
|
+
console.error("[server] uncaughtException:", err.message);
|
|
220
|
+
});
|
|
221
|
+
process.on("unhandledRejection", (reason) => {
|
|
222
|
+
console.error("[server] unhandledRejection:", reason);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
app.listen(PORT, () => {
|
|
226
|
+
console.log(`\nshoal dashboard → http://localhost:${PORT}\n`);
|
|
227
|
+
});
|
package/server/runner.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "child_process";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync, appendFileSync, unlinkSync } from "fs";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const packageRoot = join(__dirname, "..");
|
|
8
|
+
|
|
9
|
+
export interface Session {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
startedAt: string;
|
|
12
|
+
completedAt: string | null;
|
|
13
|
+
done: boolean;
|
|
14
|
+
exitCode: number | null;
|
|
15
|
+
lines: string[];
|
|
16
|
+
listeners: ((line: string) => void)[];
|
|
17
|
+
doneListeners: (() => void)[];
|
|
18
|
+
child: ChildProcess | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const activeSessions = new Map<string, Session>();
|
|
22
|
+
|
|
23
|
+
export function spawnRun(opts: {
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
maxBrowsers?: number;
|
|
26
|
+
maxExplorers?: number;
|
|
27
|
+
llmBaseUrl?: string;
|
|
28
|
+
llmApiKey?: string;
|
|
29
|
+
llmModel?: string;
|
|
30
|
+
}): string {
|
|
31
|
+
const sessionId = `run_${Date.now()}`;
|
|
32
|
+
|
|
33
|
+
const session: Session = {
|
|
34
|
+
sessionId,
|
|
35
|
+
startedAt: new Date().toISOString(),
|
|
36
|
+
completedAt: null,
|
|
37
|
+
done: false,
|
|
38
|
+
exitCode: null,
|
|
39
|
+
lines: [],
|
|
40
|
+
listeners: [],
|
|
41
|
+
doneListeners: [],
|
|
42
|
+
child: null,
|
|
43
|
+
};
|
|
44
|
+
activeSessions.set(sessionId, session);
|
|
45
|
+
|
|
46
|
+
const logsDir = join(process.cwd(), "logs");
|
|
47
|
+
mkdirSync(logsDir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
// ログをリアルタイムでファイルに書き出す(サーバー再起動後もポーリングで読める)
|
|
50
|
+
const logFilePath = join(logsDir, `log_${sessionId}.txt`);
|
|
51
|
+
console.log(`[runner] spawning ${sessionId}, log → ${logFilePath}`);
|
|
52
|
+
|
|
53
|
+
// running_*.json で実行中フラグをディスクに残す
|
|
54
|
+
const pendingPath = join(logsDir, `running_${sessionId}.json`);
|
|
55
|
+
writeFileSync(pendingPath, JSON.stringify({ runId: sessionId, startedAt: session.startedAt }));
|
|
56
|
+
|
|
57
|
+
const tsxBin = join(packageRoot, "node_modules", ".bin", "tsx");
|
|
58
|
+
const bin = existsSync(tsxBin) ? tsxBin : "tsx";
|
|
59
|
+
const script = join(packageRoot, "run.ts");
|
|
60
|
+
|
|
61
|
+
const env: NodeJS.ProcessEnv = {
|
|
62
|
+
...process.env,
|
|
63
|
+
SHOAL_RUN_ID: sessionId,
|
|
64
|
+
...(opts.baseUrl ? { BASE_URL: opts.baseUrl } : {}),
|
|
65
|
+
...(opts.maxBrowsers != null ? { MAX_BROWSERS: String(opts.maxBrowsers) } : {}),
|
|
66
|
+
...(opts.maxExplorers != null ? { MAX_EXPLORERS: String(opts.maxExplorers) } : {}),
|
|
67
|
+
...(opts.llmBaseUrl ? { LLM_BASE_URL: opts.llmBaseUrl } : {}),
|
|
68
|
+
...(opts.llmApiKey ? { LLM_API_KEY: opts.llmApiKey } : {}),
|
|
69
|
+
...(opts.llmModel ? { LLM_MODEL: opts.llmModel } : {}),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const child = spawn(bin, [script], {
|
|
73
|
+
env,
|
|
74
|
+
cwd: process.cwd(),
|
|
75
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
76
|
+
});
|
|
77
|
+
session.child = child;
|
|
78
|
+
|
|
79
|
+
const emit = (line: string) => {
|
|
80
|
+
session.lines.push(line);
|
|
81
|
+
try { appendFileSync(logFilePath, line + "\n"); } catch { /* ignore */ }
|
|
82
|
+
for (const listener of session.listeners) {
|
|
83
|
+
listener(line);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
88
|
+
let buf = "";
|
|
89
|
+
stream.on("data", (chunk: Buffer) => {
|
|
90
|
+
buf += chunk.toString();
|
|
91
|
+
const parts = buf.split("\n");
|
|
92
|
+
buf = parts.pop() ?? "";
|
|
93
|
+
for (const line of parts) emit(line);
|
|
94
|
+
});
|
|
95
|
+
stream.on("end", () => {
|
|
96
|
+
if (buf) emit(buf);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
child.on("exit", (code) => {
|
|
101
|
+
session.done = true;
|
|
102
|
+
session.completedAt = new Date().toISOString();
|
|
103
|
+
session.exitCode = code ?? 0;
|
|
104
|
+
try { unlinkSync(pendingPath); } catch { /* ignore */ }
|
|
105
|
+
for (const listener of session.doneListeners) listener();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return sessionId;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function cancelSession(sessionId: string): boolean {
|
|
112
|
+
const session = activeSessions.get(sessionId);
|
|
113
|
+
if (!session || session.done || !session.child) return false;
|
|
114
|
+
try {
|
|
115
|
+
session.child.kill("SIGTERM");
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
if (!session.done) {
|
|
118
|
+
try { session.child?.kill("SIGKILL"); } catch { /* ignore */ }
|
|
119
|
+
}
|
|
120
|
+
}, 4000);
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/server/runs.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { RunLog, Finding } from "../framework/types";
|
|
4
|
+
|
|
5
|
+
export interface RunSummary {
|
|
6
|
+
runId: string;
|
|
7
|
+
startedAt: string;
|
|
8
|
+
completedAt: string | null;
|
|
9
|
+
status: "completed" | "running";
|
|
10
|
+
agentCount: number;
|
|
11
|
+
completedAgents: number;
|
|
12
|
+
errorAgents: number;
|
|
13
|
+
findingCount: number;
|
|
14
|
+
findingsByCategory: Record<string, number>;
|
|
15
|
+
hasReport: boolean;
|
|
16
|
+
isLive?: boolean;
|
|
17
|
+
estimatedCostUSD: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function countFindings(runId: string): { total: number; byCategory: Record<string, number> } {
|
|
21
|
+
const dir = path.join(process.cwd(), "findings", runId);
|
|
22
|
+
if (!fs.existsSync(dir)) return { total: 0, byCategory: {} };
|
|
23
|
+
const byCategory: Record<string, number> = {};
|
|
24
|
+
let total = 0;
|
|
25
|
+
for (const file of fs.readdirSync(dir)) {
|
|
26
|
+
if (!file.endsWith(".json")) continue;
|
|
27
|
+
try {
|
|
28
|
+
const f: Finding = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
29
|
+
byCategory[f.category] = (byCategory[f.category] ?? 0) + 1;
|
|
30
|
+
total++;
|
|
31
|
+
} catch {
|
|
32
|
+
// skip malformed
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { total, byCategory };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function listRuns(): RunSummary[] {
|
|
39
|
+
const logsDir = path.join(process.cwd(), "logs");
|
|
40
|
+
if (!fs.existsSync(logsDir)) return [];
|
|
41
|
+
|
|
42
|
+
const summaries: RunSummary[] = [];
|
|
43
|
+
const seenRunIds = new Set<string>();
|
|
44
|
+
|
|
45
|
+
for (const file of fs.readdirSync(logsDir)) {
|
|
46
|
+
// 実行中の pending ファイル(running_*.json)
|
|
47
|
+
if (file.startsWith("running_") && file.endsWith(".json")) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = fs.readFileSync(path.join(logsDir, file), "utf-8");
|
|
50
|
+
const { runId, startedAt } = JSON.parse(raw);
|
|
51
|
+
if (!seenRunIds.has(runId)) {
|
|
52
|
+
seenRunIds.add(runId);
|
|
53
|
+
summaries.push({
|
|
54
|
+
runId,
|
|
55
|
+
startedAt,
|
|
56
|
+
completedAt: null,
|
|
57
|
+
status: "running",
|
|
58
|
+
agentCount: 0,
|
|
59
|
+
completedAgents: 0,
|
|
60
|
+
errorAgents: 0,
|
|
61
|
+
findingCount: 0,
|
|
62
|
+
findingsByCategory: {},
|
|
63
|
+
hasReport: false,
|
|
64
|
+
isLive: true,
|
|
65
|
+
estimatedCostUSD: null,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
} catch { /* skip */ }
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 通常のランログ(YYYY-MM-DDTHH-MM-SS_run_*.json)
|
|
73
|
+
if (!file.endsWith(".json") || file.startsWith("report_")) continue;
|
|
74
|
+
try {
|
|
75
|
+
const raw = fs.readFileSync(path.join(logsDir, file), "utf-8");
|
|
76
|
+
const log: RunLog = JSON.parse(raw);
|
|
77
|
+
if (seenRunIds.has(log.runId)) continue;
|
|
78
|
+
seenRunIds.add(log.runId);
|
|
79
|
+
const { total, byCategory } = countFindings(log.runId);
|
|
80
|
+
const reportPath = path.join(logsDir, `report_${log.runId}.html`);
|
|
81
|
+
summaries.push({
|
|
82
|
+
runId: log.runId,
|
|
83
|
+
startedAt: log.startedAt,
|
|
84
|
+
completedAt: log.completedAt,
|
|
85
|
+
status: log.completedAt ? "completed" : "running",
|
|
86
|
+
agentCount: log.agents.length,
|
|
87
|
+
completedAgents: log.agents.filter((a) => a.status === "completed").length,
|
|
88
|
+
errorAgents: log.agents.filter((a) => a.status === "error").length,
|
|
89
|
+
findingCount: total,
|
|
90
|
+
findingsByCategory: byCategory,
|
|
91
|
+
hasReport: fs.existsSync(reportPath),
|
|
92
|
+
estimatedCostUSD: log.summary?.cost?.estimatedUSD ?? null,
|
|
93
|
+
});
|
|
94
|
+
} catch { /* skip */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return summaries.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getReportPath(runId: string): string | null {
|
|
101
|
+
const p = path.join(process.cwd(), "logs", `report_${runId}.html`);
|
|
102
|
+
return fs.existsSync(p) ? p : null;
|
|
103
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example target — copy this file and adapt it to your app.
|
|
3
|
+
*
|
|
4
|
+
* A target connects shoal to your app's API.
|
|
5
|
+
* Define the tools agents can call, then implement the execute() handler.
|
|
6
|
+
*/
|
|
7
|
+
import type { TargetConfig } from "./types";
|
|
8
|
+
|
|
9
|
+
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
|
|
10
|
+
|
|
11
|
+
async function api(endpoint: string, method = "GET", body?: unknown): Promise<unknown> {
|
|
12
|
+
const res = await fetch(`${BASE_URL}${endpoint}`, {
|
|
13
|
+
method,
|
|
14
|
+
headers: { "Content-Type": "application/json" },
|
|
15
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
16
|
+
});
|
|
17
|
+
const text = await res.text();
|
|
18
|
+
return text ? JSON.parse(text) : { ok: res.ok };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const exampleConfig: TargetConfig = {
|
|
22
|
+
appTools: [
|
|
23
|
+
// Define the API tools agents can call.
|
|
24
|
+
// Each tool is described in plain English so agents know when and how to use it.
|
|
25
|
+
{
|
|
26
|
+
name: "get_items",
|
|
27
|
+
description: "Get a list of items from the app.",
|
|
28
|
+
input_schema: { type: "object", properties: {}, required: [] },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "create_item",
|
|
32
|
+
description: "Create a new item.",
|
|
33
|
+
input_schema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
title: { type: "string", description: "Item title" },
|
|
37
|
+
description: { type: "string", description: "Item description (optional)" },
|
|
38
|
+
},
|
|
39
|
+
required: ["title"],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
// Add more tools as needed...
|
|
43
|
+
],
|
|
44
|
+
|
|
45
|
+
async execute(toolName, input, agentId) {
|
|
46
|
+
switch (toolName) {
|
|
47
|
+
case "get_items":
|
|
48
|
+
return api("/api/items");
|
|
49
|
+
case "create_item":
|
|
50
|
+
return api("/api/items", "POST", { ...input, createdBy: agentId });
|
|
51
|
+
default:
|
|
52
|
+
return { error: "unknown tool" };
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
package/targets/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { TargetConfig } from "./types";
|
|
2
|
+
import { exampleConfig } from "./example";
|
|
3
|
+
import { noopTarget } from "./noop";
|
|
4
|
+
|
|
5
|
+
const TARGETS: Record<string, TargetConfig> = {
|
|
6
|
+
"example": exampleConfig,
|
|
7
|
+
"none": noopTarget,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function loadTarget(name: string): TargetConfig {
|
|
11
|
+
const target = TARGETS[name];
|
|
12
|
+
if (!target) {
|
|
13
|
+
console.warn(`[target] "${name}" not found, falling back to noop`);
|
|
14
|
+
return noopTarget;
|
|
15
|
+
}
|
|
16
|
+
return target;
|
|
17
|
+
}
|
package/targets/noop.ts
ADDED
package/targets/types.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Tool } from "../framework/llm-client";
|
|
2
|
+
|
|
3
|
+
export interface Credentials {
|
|
4
|
+
email: string;
|
|
5
|
+
password: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TargetConfig {
|
|
9
|
+
appTools: Tool[];
|
|
10
|
+
execute(toolName: string, input: Record<string, unknown>, agentId: string): Promise<unknown>;
|
|
11
|
+
/** Optional: absolute path to the project repository on the local filesystem.
|
|
12
|
+
* If set, product-discovery will scan for README/docs/openapi files here.
|
|
13
|
+
* If not set but GITHUB_REPO is set, it will fetch the README from GitHub instead. */
|
|
14
|
+
projectPath?: string;
|
|
15
|
+
/** Optional: seed credentials for the Account Manager agent.
|
|
16
|
+
* If set, shoal will log in, discover roles, create test accounts per role,
|
|
17
|
+
* and run all browser agents in authenticated sessions. */
|
|
18
|
+
credentials?: Credentials;
|
|
19
|
+
}
|
package/triage-only.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* triage-only.ts
|
|
3
|
+
* 指定runIdのfindingsを読み込んでトリアージエージェントだけを実行する
|
|
4
|
+
*
|
|
5
|
+
* 使い方:
|
|
6
|
+
* RUN_ID=run_xxx npx tsx scripts/triage-only.ts
|
|
7
|
+
* (RUN_ID省略時は最新のrunを使用)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import "dotenv/config";
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import { createLLMClient } from "./framework/llm-client";
|
|
14
|
+
import { runTriageAgent } from "./framework/triage";
|
|
15
|
+
import type { Finding } from "./framework/types";
|
|
16
|
+
|
|
17
|
+
const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? "";
|
|
18
|
+
const GITHUB_REPO = process.env.GITHUB_REPO ?? "";
|
|
19
|
+
|
|
20
|
+
function loadFindings(runId: string): Finding[] {
|
|
21
|
+
const dir = path.join(process.cwd(), "findings", runId);
|
|
22
|
+
if (!fs.existsSync(dir)) {
|
|
23
|
+
console.error(`findings/${runId} が見つかりません`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json") && f !== "triage_result.json");
|
|
27
|
+
return files.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")) as Finding);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getLatestRunId(): string {
|
|
31
|
+
const findingsDir = path.join(process.cwd(), "findings");
|
|
32
|
+
const runs = fs.readdirSync(findingsDir).filter((d) => d.startsWith("run_")).sort();
|
|
33
|
+
if (runs.length === 0) {
|
|
34
|
+
console.error("findingsディレクトリにrunが見つかりません");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
return runs[runs.length - 1];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
const runId = process.env.RUN_ID ?? getLatestRunId();
|
|
42
|
+
console.log(`[トリアージ単体実行] runId: ${runId}`);
|
|
43
|
+
|
|
44
|
+
const findings = loadFindings(runId);
|
|
45
|
+
console.log(`[トリアージ単体実行] findings読み込み: ${findings.length}件`);
|
|
46
|
+
findings.forEach((f) => console.log(` - ${f.agentName}: ${f.title.slice(0, 50)}`));
|
|
47
|
+
|
|
48
|
+
const { client, defaultModel } = createLLMClient();
|
|
49
|
+
const result = await runTriageAgent(findings, client, defaultModel, { token: GITHUB_TOKEN, repo: GITHUB_REPO });
|
|
50
|
+
|
|
51
|
+
console.log("\n=== トリアージ結果 ===");
|
|
52
|
+
console.log(` Issue作成: ${result.issuesCreated}件`);
|
|
53
|
+
console.log(` スキップ: ${result.skipped.length}件`);
|
|
54
|
+
console.log(` 未処理: ${result.unprocessed.length}件`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
main().catch(console.error);
|