@allenpan2026/harshjudge 0.4.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/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +11 -0
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/dist/cli.js +1869 -0
- package/dist/cli.js.map +1 -0
- package/dist/dashboard-worker.js +896 -0
- package/dist/dashboard-worker.js.map +1 -0
- package/package.json +64 -0
- package/skills/harshjudge/SKILL.md +152 -0
- package/skills/harshjudge/assets/prd.md +36 -0
- package/skills/harshjudge/references/create.md +258 -0
- package/skills/harshjudge/references/iterate.md +152 -0
- package/skills/harshjudge/references/run-playwright.md +41 -0
- package/skills/harshjudge/references/run-step-agent.md +65 -0
- package/skills/harshjudge/references/run.md +129 -0
- package/skills/harshjudge/references/setup.md +129 -0
- package/skills/harshjudge/references/status.md +134 -0
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
// src/ux/server/DashboardServer.ts
|
|
2
|
+
import { createServer } from "http";
|
|
3
|
+
import { readFile, stat, readdir } from "fs/promises";
|
|
4
|
+
import { join as join2, extname, dirname } from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import yaml from "js-yaml";
|
|
7
|
+
|
|
8
|
+
// src/types/scenario.ts
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
var StepReferenceSchema = z.object({
|
|
11
|
+
id: z.string().regex(/^\d{2}$/, "Step ID must be zero-padded (01, 02, etc.)"),
|
|
12
|
+
title: z.string().min(1),
|
|
13
|
+
file: z.string().regex(/^\d{2}-[\w-]+\.md$/, "Step file must match pattern: {id}-{slug}.md")
|
|
14
|
+
});
|
|
15
|
+
var ScenarioMetaSchema = z.object({
|
|
16
|
+
// Scenario definition
|
|
17
|
+
slug: z.string().regex(/^[a-z0-9-]+$/),
|
|
18
|
+
title: z.string().min(1),
|
|
19
|
+
starred: z.boolean().default(false),
|
|
20
|
+
tags: z.array(z.string()).default([]),
|
|
21
|
+
estimatedDuration: z.number().positive().default(60),
|
|
22
|
+
steps: z.array(StepReferenceSchema).default([]),
|
|
23
|
+
// Statistics (machine-updated)
|
|
24
|
+
totalRuns: z.number().nonnegative().default(0),
|
|
25
|
+
passCount: z.number().nonnegative().default(0),
|
|
26
|
+
failCount: z.number().nonnegative().default(0),
|
|
27
|
+
lastRun: z.string().nullable().default(null),
|
|
28
|
+
lastResult: z.enum(["pass", "fail"]).nullable().default(null),
|
|
29
|
+
avgDuration: z.number().nonnegative().default(0)
|
|
30
|
+
});
|
|
31
|
+
var DEFAULT_SCENARIO_STATS = {
|
|
32
|
+
totalRuns: 0,
|
|
33
|
+
passCount: 0,
|
|
34
|
+
failCount: 0,
|
|
35
|
+
lastRun: null,
|
|
36
|
+
lastResult: null,
|
|
37
|
+
avgDuration: 0
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// src/types/run.ts
|
|
41
|
+
import { z as z2 } from "zod";
|
|
42
|
+
var StepResultSchema = z2.object({
|
|
43
|
+
id: z2.string().regex(/^\d{2}$/, "Step ID must be zero-padded"),
|
|
44
|
+
status: z2.enum(["pass", "fail", "skipped"]),
|
|
45
|
+
duration: z2.number().nonnegative().optional().default(0),
|
|
46
|
+
error: z2.string().nullable().default(null),
|
|
47
|
+
evidenceFiles: z2.array(z2.string()).default([]),
|
|
48
|
+
/** AI-generated summary describing what happened in this step */
|
|
49
|
+
summary: z2.string().nullable().optional().default(null)
|
|
50
|
+
});
|
|
51
|
+
var RunResultSchema = z2.object({
|
|
52
|
+
runId: z2.string(),
|
|
53
|
+
scenarioSlug: z2.string().optional(),
|
|
54
|
+
// Optional for backward compat
|
|
55
|
+
status: z2.enum(["pass", "fail", "running"]),
|
|
56
|
+
startedAt: z2.string(),
|
|
57
|
+
completedAt: z2.string().optional(),
|
|
58
|
+
duration: z2.number().nonnegative().optional().default(0),
|
|
59
|
+
steps: z2.array(StepResultSchema).default([]),
|
|
60
|
+
failedStep: z2.string().nullable().default(null),
|
|
61
|
+
// Changed from number to string (step ID)
|
|
62
|
+
errorMessage: z2.string().nullable().default(null)
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// src/schemas/index.ts
|
|
66
|
+
import { z as z3 } from "zod";
|
|
67
|
+
var InitProjectParamsSchema = z3.object({
|
|
68
|
+
projectName: z3.string().min(1).max(100),
|
|
69
|
+
baseUrl: z3.string().url().optional()
|
|
70
|
+
});
|
|
71
|
+
var SaveScenarioParamsSchema = z3.object({
|
|
72
|
+
slug: z3.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
|
|
73
|
+
title: z3.string().min(1).max(200),
|
|
74
|
+
content: z3.string().min(1),
|
|
75
|
+
tags: z3.array(z3.string()).optional().default([]),
|
|
76
|
+
estimatedDuration: z3.number().positive().optional().default(60)
|
|
77
|
+
});
|
|
78
|
+
var StepInputSchema = z3.object({
|
|
79
|
+
title: z3.string().min(1),
|
|
80
|
+
description: z3.string().optional().default(""),
|
|
81
|
+
preconditions: z3.string().optional().default(""),
|
|
82
|
+
actions: z3.string().min(1),
|
|
83
|
+
expectedOutcome: z3.string().min(1)
|
|
84
|
+
});
|
|
85
|
+
var CreateScenarioParamsSchema = z3.object({
|
|
86
|
+
slug: z3.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
|
|
87
|
+
title: z3.string().min(1).max(200),
|
|
88
|
+
steps: z3.array(StepInputSchema).min(1),
|
|
89
|
+
tags: z3.array(z3.string()).optional().default([]),
|
|
90
|
+
estimatedDuration: z3.number().positive().optional().default(60),
|
|
91
|
+
starred: z3.boolean().optional().default(false)
|
|
92
|
+
});
|
|
93
|
+
var ToggleStarParamsSchema = z3.object({
|
|
94
|
+
scenarioSlug: z3.string().regex(/^[a-z0-9-]+$/),
|
|
95
|
+
starred: z3.boolean().optional()
|
|
96
|
+
// If omitted, toggles current state
|
|
97
|
+
});
|
|
98
|
+
var StartRunParamsSchema = z3.object({
|
|
99
|
+
scenarioSlug: z3.string().regex(/^[a-z0-9-]+$/)
|
|
100
|
+
});
|
|
101
|
+
var RecordEvidenceParamsSchema = z3.object({
|
|
102
|
+
runId: z3.string().min(1),
|
|
103
|
+
step: z3.number().int().positive(),
|
|
104
|
+
// v2: accepts number, will be converted to zero-padded string
|
|
105
|
+
type: z3.enum([
|
|
106
|
+
"screenshot",
|
|
107
|
+
"db_snapshot",
|
|
108
|
+
"console_log",
|
|
109
|
+
"network_log",
|
|
110
|
+
"html_snapshot",
|
|
111
|
+
"custom"
|
|
112
|
+
]),
|
|
113
|
+
name: z3.string().min(1).max(100),
|
|
114
|
+
data: z3.string(),
|
|
115
|
+
// For screenshot: absolute file path; for others: content
|
|
116
|
+
metadata: z3.record(z3.unknown()).optional()
|
|
117
|
+
});
|
|
118
|
+
var CompleteRunParamsSchema = z3.object({
|
|
119
|
+
runId: z3.string().min(1),
|
|
120
|
+
status: z3.enum(["pass", "fail"]),
|
|
121
|
+
duration: z3.number().nonnegative(),
|
|
122
|
+
failedStep: z3.string().regex(/^\d{2}$/, "Step ID must be zero-padded").optional(),
|
|
123
|
+
// Changed from number to string
|
|
124
|
+
errorMessage: z3.string().optional(),
|
|
125
|
+
steps: z3.array(StepResultSchema).optional()
|
|
126
|
+
// NEW: per-step results
|
|
127
|
+
});
|
|
128
|
+
var CompleteStepParamsSchema = z3.object({
|
|
129
|
+
runId: z3.string().min(1),
|
|
130
|
+
stepId: z3.string().regex(/^\d{2}$/, 'Step ID must be zero-padded (e.g., "01", "02")'),
|
|
131
|
+
status: z3.enum(["pass", "fail", "skipped"]),
|
|
132
|
+
duration: z3.number().nonnegative(),
|
|
133
|
+
error: z3.string().optional(),
|
|
134
|
+
/** AI-generated summary describing what happened in this step and match result */
|
|
135
|
+
summary: z3.string().optional()
|
|
136
|
+
});
|
|
137
|
+
var GetStatusParamsSchema = z3.object({
|
|
138
|
+
scenarioSlug: z3.string().regex(/^[a-z0-9-]+$/).optional(),
|
|
139
|
+
starredOnly: z3.boolean().optional().default(false)
|
|
140
|
+
});
|
|
141
|
+
var OpenDashboardParamsSchema = z3.object({
|
|
142
|
+
port: z3.number().int().min(1024).max(65535).optional(),
|
|
143
|
+
openBrowser: z3.boolean().optional().default(true),
|
|
144
|
+
projectPath: z3.string().optional().describe(
|
|
145
|
+
"Path to the project directory containing .harshJudge folder. Defaults to current working directory."
|
|
146
|
+
)
|
|
147
|
+
});
|
|
148
|
+
var CloseDashboardParamsSchema = z3.object({
|
|
149
|
+
projectPath: z3.string().optional().describe(
|
|
150
|
+
"Path to the project directory containing .harshJudge folder. Defaults to current working directory."
|
|
151
|
+
)
|
|
152
|
+
});
|
|
153
|
+
var GetDashboardStatusParamsSchema = z3.object({
|
|
154
|
+
projectPath: z3.string().optional().describe(
|
|
155
|
+
"Path to the project directory containing .harshJudge folder. Defaults to current working directory."
|
|
156
|
+
)
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// src/ux/server/PathResolver.ts
|
|
160
|
+
import { join } from "path";
|
|
161
|
+
var HARSHJUDGE_DIR = ".harshJudge";
|
|
162
|
+
var PathResolver = class {
|
|
163
|
+
projectRoot;
|
|
164
|
+
harshJudgePath;
|
|
165
|
+
/**
|
|
166
|
+
* Create a PathResolver from a project root path.
|
|
167
|
+
* @param projectRoot - The project root directory (must NOT contain .harshJudge)
|
|
168
|
+
* @throws Error if projectRoot contains .harshJudge
|
|
169
|
+
*/
|
|
170
|
+
constructor(projectRoot) {
|
|
171
|
+
if (projectRoot.includes(HARSHJUDGE_DIR)) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Invalid projectRoot: "${projectRoot}" contains "${HARSHJUDGE_DIR}". Please provide the project working directory, not the .harshJudge path.`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
this.projectRoot = projectRoot;
|
|
177
|
+
this.harshJudgePath = join(projectRoot, HARSHJUDGE_DIR);
|
|
178
|
+
}
|
|
179
|
+
// --- Root Paths ---
|
|
180
|
+
/** Get the project root directory */
|
|
181
|
+
getProjectRoot() {
|
|
182
|
+
return this.projectRoot;
|
|
183
|
+
}
|
|
184
|
+
/** Get the .harshJudge directory path */
|
|
185
|
+
getHarshJudgePath() {
|
|
186
|
+
return this.harshJudgePath;
|
|
187
|
+
}
|
|
188
|
+
// --- Config Paths ---
|
|
189
|
+
/** Get config.yaml path */
|
|
190
|
+
getConfigPath() {
|
|
191
|
+
return join(this.harshJudgePath, "config.yaml");
|
|
192
|
+
}
|
|
193
|
+
/** Get prd.md path */
|
|
194
|
+
getPrdPath() {
|
|
195
|
+
return join(this.harshJudgePath, "prd.md");
|
|
196
|
+
}
|
|
197
|
+
// --- Scenarios Paths ---
|
|
198
|
+
/** Get the scenarios directory path */
|
|
199
|
+
getScenariosDir() {
|
|
200
|
+
return join(this.harshJudgePath, "scenarios");
|
|
201
|
+
}
|
|
202
|
+
/** Get a specific scenario directory path */
|
|
203
|
+
getScenarioDir(scenarioSlug) {
|
|
204
|
+
return join(this.getScenariosDir(), scenarioSlug);
|
|
205
|
+
}
|
|
206
|
+
/** Get scenario meta.yaml path */
|
|
207
|
+
getScenarioMetaPath(scenarioSlug) {
|
|
208
|
+
return join(this.getScenarioDir(scenarioSlug), "meta.yaml");
|
|
209
|
+
}
|
|
210
|
+
/** Get scenario.md path (v1 structure, optional) */
|
|
211
|
+
getScenarioContentPath(scenarioSlug) {
|
|
212
|
+
return join(this.getScenarioDir(scenarioSlug), "scenario.md");
|
|
213
|
+
}
|
|
214
|
+
// --- Steps Paths ---
|
|
215
|
+
/** Get the steps directory for a scenario */
|
|
216
|
+
getStepsDir(scenarioSlug) {
|
|
217
|
+
return join(this.getScenarioDir(scenarioSlug), "steps");
|
|
218
|
+
}
|
|
219
|
+
/** Get a specific step file path */
|
|
220
|
+
getStepPath(scenarioSlug, stepFileName) {
|
|
221
|
+
return join(this.getStepsDir(scenarioSlug), stepFileName);
|
|
222
|
+
}
|
|
223
|
+
// --- Runs Paths ---
|
|
224
|
+
/** Get the runs directory for a scenario */
|
|
225
|
+
getRunsDir(scenarioSlug) {
|
|
226
|
+
return join(this.getScenarioDir(scenarioSlug), "runs");
|
|
227
|
+
}
|
|
228
|
+
/** Get a specific run directory path */
|
|
229
|
+
getRunDir(scenarioSlug, runId) {
|
|
230
|
+
return join(this.getRunsDir(scenarioSlug), runId);
|
|
231
|
+
}
|
|
232
|
+
/** Get run result.json path */
|
|
233
|
+
getRunResultPath(scenarioSlug, runId) {
|
|
234
|
+
return join(this.getRunDir(scenarioSlug, runId), "result.json");
|
|
235
|
+
}
|
|
236
|
+
// --- Run Step Evidence Paths ---
|
|
237
|
+
/** Get the step directory within a run */
|
|
238
|
+
getRunStepDir(scenarioSlug, runId, stepId) {
|
|
239
|
+
return join(this.getRunDir(scenarioSlug, runId), `step-${stepId}`);
|
|
240
|
+
}
|
|
241
|
+
/** Get the evidence directory for a step within a run */
|
|
242
|
+
getRunStepEvidenceDir(scenarioSlug, runId, stepId) {
|
|
243
|
+
return join(this.getRunStepDir(scenarioSlug, runId, stepId), "evidence");
|
|
244
|
+
}
|
|
245
|
+
/** Get a specific evidence file path */
|
|
246
|
+
getEvidencePath(scenarioSlug, runId, stepId, fileName) {
|
|
247
|
+
return join(this.getRunStepEvidenceDir(scenarioSlug, runId, stepId), fileName);
|
|
248
|
+
}
|
|
249
|
+
/** Get evidence metadata file path */
|
|
250
|
+
getEvidenceMetaPath(scenarioSlug, runId, stepId, fileName) {
|
|
251
|
+
return join(this.getRunStepEvidenceDir(scenarioSlug, runId, stepId), `${fileName}.meta.json`);
|
|
252
|
+
}
|
|
253
|
+
// --- Legacy v1 Evidence Paths (flat structure at run root) ---
|
|
254
|
+
/** Get legacy flat evidence directory at run root */
|
|
255
|
+
getLegacyEvidenceDir(scenarioSlug, runId) {
|
|
256
|
+
return join(this.getRunDir(scenarioSlug, runId), "evidence");
|
|
257
|
+
}
|
|
258
|
+
// --- Utility Methods ---
|
|
259
|
+
/**
|
|
260
|
+
* Check if a path is within the .harshJudge directory (for security)
|
|
261
|
+
*/
|
|
262
|
+
isWithinHarshJudge(path) {
|
|
263
|
+
return path.startsWith(this.harshJudgePath);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Validate that a path is safe (within .harshJudge and no traversal)
|
|
267
|
+
*/
|
|
268
|
+
isPathSafe(path) {
|
|
269
|
+
if (path.includes("..")) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
return this.isWithinHarshJudge(path);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
function createPathResolver(path) {
|
|
276
|
+
const harshJudgeIndex = path.indexOf(HARSHJUDGE_DIR);
|
|
277
|
+
if (harshJudgeIndex !== -1) {
|
|
278
|
+
const projectRoot = path.substring(0, harshJudgeIndex).replace(/\/$/, "");
|
|
279
|
+
if (!projectRoot) {
|
|
280
|
+
throw new Error(`Invalid path: cannot determine project root from "${path}"`);
|
|
281
|
+
}
|
|
282
|
+
return new PathResolver(projectRoot);
|
|
283
|
+
}
|
|
284
|
+
return new PathResolver(path);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/ux/server/DashboardServer.ts
|
|
288
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
289
|
+
var __dirname = dirname(__filename);
|
|
290
|
+
var DashboardServer = class {
|
|
291
|
+
server = null;
|
|
292
|
+
port;
|
|
293
|
+
pathResolver;
|
|
294
|
+
distPath;
|
|
295
|
+
constructor(options = {}) {
|
|
296
|
+
this.port = options.port ?? 3e3;
|
|
297
|
+
const projectPath = options.projectPath ?? process.cwd();
|
|
298
|
+
this.pathResolver = createPathResolver(projectPath);
|
|
299
|
+
this.distPath = options.distPath ?? join2(__dirname, "../../dist");
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Start the dashboard server
|
|
303
|
+
* @returns The actual port the server is listening on
|
|
304
|
+
*/
|
|
305
|
+
async start() {
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
this.server = createServer(this.handleRequest.bind(this));
|
|
308
|
+
this.server.on("error", (err) => {
|
|
309
|
+
if (err.code === "EADDRINUSE") {
|
|
310
|
+
reject(new Error(`Port ${this.port} is already in use`));
|
|
311
|
+
} else {
|
|
312
|
+
reject(err);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
this.server.listen(this.port, () => {
|
|
316
|
+
const address = this.server?.address();
|
|
317
|
+
resolve(address.port);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Stop the dashboard server
|
|
323
|
+
*/
|
|
324
|
+
async stop() {
|
|
325
|
+
return new Promise((resolve) => {
|
|
326
|
+
if (this.server) {
|
|
327
|
+
this.server.close(() => {
|
|
328
|
+
this.server = null;
|
|
329
|
+
resolve();
|
|
330
|
+
});
|
|
331
|
+
} else {
|
|
332
|
+
resolve();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Check if the server is running
|
|
338
|
+
*/
|
|
339
|
+
isRunning() {
|
|
340
|
+
return this.server !== null && this.server.listening;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Get the server URL
|
|
344
|
+
*/
|
|
345
|
+
getUrl() {
|
|
346
|
+
return `http://localhost:${this.port}`;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Handle incoming HTTP requests
|
|
350
|
+
*/
|
|
351
|
+
async handleRequest(req, res) {
|
|
352
|
+
const url = req.url || "/";
|
|
353
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
354
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
355
|
+
if (req.method === "OPTIONS") {
|
|
356
|
+
res.writeHead(204);
|
|
357
|
+
res.end();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (req.method !== "GET") {
|
|
361
|
+
res.writeHead(405);
|
|
362
|
+
res.end("Method Not Allowed");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (url.startsWith("/api/")) {
|
|
366
|
+
await this.handleApiRequest(url, res);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
await this.serveStaticFile(url, res);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Handle API requests
|
|
373
|
+
*/
|
|
374
|
+
async handleApiRequest(url, res) {
|
|
375
|
+
const urlParts = url.split("?");
|
|
376
|
+
const cleanUrl = urlParts[0] ?? "";
|
|
377
|
+
const queryString = urlParts[1] ?? "";
|
|
378
|
+
const parts = cleanUrl.replace("/api/", "").split("/").map(decodeURIComponent);
|
|
379
|
+
try {
|
|
380
|
+
if (parts[0] === "file") {
|
|
381
|
+
const params = new URLSearchParams(queryString);
|
|
382
|
+
const filePath = params.get("path");
|
|
383
|
+
if (!filePath) {
|
|
384
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
385
|
+
res.end(JSON.stringify({ error: "Missing path parameter" }));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (!filePath.includes(".harshJudge")) {
|
|
389
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
390
|
+
res.end(
|
|
391
|
+
JSON.stringify({
|
|
392
|
+
error: "Access denied: can only serve HarshJudge evidence files"
|
|
393
|
+
})
|
|
394
|
+
);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (filePath.includes("..")) {
|
|
398
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
399
|
+
res.end(
|
|
400
|
+
JSON.stringify({
|
|
401
|
+
error: "Access denied: path traversal not allowed"
|
|
402
|
+
})
|
|
403
|
+
);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const content = await readFile(filePath);
|
|
408
|
+
const contentType = this.getContentType(filePath);
|
|
409
|
+
res.writeHead(200, {
|
|
410
|
+
"Content-Type": contentType,
|
|
411
|
+
"Cache-Control": "public, max-age=3600"
|
|
412
|
+
});
|
|
413
|
+
res.end(content);
|
|
414
|
+
return;
|
|
415
|
+
} catch {
|
|
416
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
417
|
+
res.end(JSON.stringify({ error: "File not found" }));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (parts[0] === "projects" && parts.length === 1) {
|
|
422
|
+
const projects = await this.getProjects();
|
|
423
|
+
this.sendJson(res, projects);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (parts[0] === "projects" && parts[2] === "scenarios" && parts.length === 3) {
|
|
427
|
+
const projectPath = parts[1];
|
|
428
|
+
const scenarios = await this.getScenarios(projectPath ?? "");
|
|
429
|
+
this.sendJson(res, scenarios);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (parts[0] === "projects" && parts[2] === "scenarios" && parts.length === 4) {
|
|
433
|
+
const projectPath = parts[1];
|
|
434
|
+
const scenarioSlug = parts[3];
|
|
435
|
+
const detail = await this.getScenarioDetail(
|
|
436
|
+
projectPath ?? "",
|
|
437
|
+
scenarioSlug ?? ""
|
|
438
|
+
);
|
|
439
|
+
this.sendJson(res, detail);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (parts[0] === "projects" && parts[2] === "scenarios" && parts[4] === "runs" && parts.length === 5) {
|
|
443
|
+
const projectPath = parts[1];
|
|
444
|
+
const scenarioSlug = parts[3];
|
|
445
|
+
const runs = await this.getRunHistory(
|
|
446
|
+
projectPath ?? "",
|
|
447
|
+
scenarioSlug ?? ""
|
|
448
|
+
);
|
|
449
|
+
this.sendJson(res, runs);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (parts[0] === "projects" && parts[2] === "scenarios" && parts[4] === "runs" && parts.length === 6) {
|
|
453
|
+
const projectPath = parts[1];
|
|
454
|
+
const scenarioSlug = parts[3];
|
|
455
|
+
const runId = parts[5];
|
|
456
|
+
const detail = await this.getRunDetail(
|
|
457
|
+
projectPath ?? "",
|
|
458
|
+
scenarioSlug ?? "",
|
|
459
|
+
runId ?? ""
|
|
460
|
+
);
|
|
461
|
+
this.sendJson(res, detail);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
465
|
+
res.end(JSON.stringify({ error: "API endpoint not found" }));
|
|
466
|
+
} catch (err) {
|
|
467
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
468
|
+
res.end(
|
|
469
|
+
JSON.stringify({
|
|
470
|
+
error: err instanceof Error ? err.message : "Internal server error"
|
|
471
|
+
})
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Send JSON response
|
|
477
|
+
*/
|
|
478
|
+
sendJson(res, data) {
|
|
479
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
480
|
+
res.end(JSON.stringify(data));
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Serve a static file or fallback to index.html for SPA routing
|
|
484
|
+
*/
|
|
485
|
+
async serveStaticFile(url, res) {
|
|
486
|
+
let cleanUrl = url.split("?")[0];
|
|
487
|
+
if (cleanUrl === "/") {
|
|
488
|
+
cleanUrl = "/index.html";
|
|
489
|
+
}
|
|
490
|
+
const decodedUrl = decodeURIComponent(cleanUrl ?? "");
|
|
491
|
+
if ((cleanUrl ?? "").includes("..") || decodedUrl.includes("..")) {
|
|
492
|
+
res.writeHead(403);
|
|
493
|
+
res.end("Forbidden");
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
let filePath = join2(this.distPath, decodedUrl);
|
|
497
|
+
try {
|
|
498
|
+
const stats = await stat(filePath);
|
|
499
|
+
if (stats.isDirectory()) {
|
|
500
|
+
filePath = join2(filePath, "index.html");
|
|
501
|
+
}
|
|
502
|
+
const content = await readFile(filePath);
|
|
503
|
+
const contentType = this.getContentType(filePath);
|
|
504
|
+
res.writeHead(200, {
|
|
505
|
+
"Content-Type": contentType,
|
|
506
|
+
"Cache-Control": "no-cache"
|
|
507
|
+
});
|
|
508
|
+
res.end(content);
|
|
509
|
+
} catch {
|
|
510
|
+
try {
|
|
511
|
+
const indexPath = join2(this.distPath, "index.html");
|
|
512
|
+
const indexContent = await readFile(indexPath);
|
|
513
|
+
res.writeHead(200, {
|
|
514
|
+
"Content-Type": "text/html",
|
|
515
|
+
"Cache-Control": "no-cache"
|
|
516
|
+
});
|
|
517
|
+
res.end(indexContent);
|
|
518
|
+
} catch {
|
|
519
|
+
res.writeHead(404);
|
|
520
|
+
res.end("Not Found - Dashboard not built. Run `npm run build` first.");
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Get MIME type for a file based on its extension
|
|
526
|
+
*/
|
|
527
|
+
getContentType(filePath) {
|
|
528
|
+
const ext = extname(filePath).toLowerCase();
|
|
529
|
+
const mimeTypes = {
|
|
530
|
+
".html": "text/html; charset=utf-8",
|
|
531
|
+
".js": "application/javascript; charset=utf-8",
|
|
532
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
533
|
+
".css": "text/css; charset=utf-8",
|
|
534
|
+
".json": "application/json; charset=utf-8",
|
|
535
|
+
".png": "image/png",
|
|
536
|
+
".jpg": "image/jpeg",
|
|
537
|
+
".jpeg": "image/jpeg",
|
|
538
|
+
".gif": "image/gif",
|
|
539
|
+
".svg": "image/svg+xml",
|
|
540
|
+
".ico": "image/x-icon",
|
|
541
|
+
".woff": "font/woff",
|
|
542
|
+
".woff2": "font/woff2",
|
|
543
|
+
".ttf": "font/ttf",
|
|
544
|
+
".eot": "application/vnd.ms-fontobject"
|
|
545
|
+
};
|
|
546
|
+
return mimeTypes[ext] || "application/octet-stream";
|
|
547
|
+
}
|
|
548
|
+
// --- Data Service Methods ---
|
|
549
|
+
/**
|
|
550
|
+
* Discover all HarshJudge projects in the project path
|
|
551
|
+
*/
|
|
552
|
+
async getProjects() {
|
|
553
|
+
try {
|
|
554
|
+
const harshJudgePath = this.pathResolver.getHarshJudgePath();
|
|
555
|
+
const exists = await this.pathExists(harshJudgePath);
|
|
556
|
+
if (!exists) {
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
const config = await this.readConfig();
|
|
560
|
+
if (!config) {
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
const scenarios = await this.getScenarios(
|
|
564
|
+
this.pathResolver.getProjectRoot()
|
|
565
|
+
);
|
|
566
|
+
const overallStatus = this.calculateOverallStatus(scenarios);
|
|
567
|
+
return [
|
|
568
|
+
{
|
|
569
|
+
path: this.pathResolver.getProjectRoot(),
|
|
570
|
+
name: config.projectName,
|
|
571
|
+
scenarioCount: scenarios.length,
|
|
572
|
+
overallStatus
|
|
573
|
+
}
|
|
574
|
+
];
|
|
575
|
+
} catch {
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Get all scenarios for a project
|
|
581
|
+
* @param projectPath - Project root path (NOT the .harshJudge path)
|
|
582
|
+
*/
|
|
583
|
+
async getScenarios(projectPath) {
|
|
584
|
+
try {
|
|
585
|
+
const resolver = createPathResolver(projectPath);
|
|
586
|
+
const scenariosPath = resolver.getScenariosDir();
|
|
587
|
+
const exists = await this.pathExists(scenariosPath);
|
|
588
|
+
if (!exists) {
|
|
589
|
+
return [];
|
|
590
|
+
}
|
|
591
|
+
const entries = await readdir(scenariosPath, { withFileTypes: true });
|
|
592
|
+
const scenarioDirs = entries.filter((e) => e.isDirectory());
|
|
593
|
+
const scenarios = [];
|
|
594
|
+
for (const dir of scenarioDirs) {
|
|
595
|
+
const scenario = await this.readScenarioSummary(
|
|
596
|
+
resolver.getScenarioDir(dir.name),
|
|
597
|
+
dir.name
|
|
598
|
+
);
|
|
599
|
+
if (scenario) {
|
|
600
|
+
scenarios.push(scenario);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return scenarios.sort((a, b) => {
|
|
604
|
+
if (!a.lastRun && !b.lastRun) return 0;
|
|
605
|
+
if (!a.lastRun) return 1;
|
|
606
|
+
if (!b.lastRun) return -1;
|
|
607
|
+
return new Date(b.lastRun).getTime() - new Date(a.lastRun).getTime();
|
|
608
|
+
});
|
|
609
|
+
} catch {
|
|
610
|
+
return [];
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Get detailed scenario information including runs
|
|
615
|
+
* @param projectPath - Project root path (NOT the .harshJudge path)
|
|
616
|
+
*/
|
|
617
|
+
async getScenarioDetail(projectPath, slug) {
|
|
618
|
+
try {
|
|
619
|
+
const resolver = createPathResolver(projectPath);
|
|
620
|
+
const scenarioPath = resolver.getScenarioDir(slug);
|
|
621
|
+
const exists = await this.pathExists(scenarioPath);
|
|
622
|
+
if (!exists) {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
const [scenarioContent, meta] = await Promise.all([
|
|
626
|
+
this.readScenarioContent(scenarioPath),
|
|
627
|
+
this.readScenarioMeta(scenarioPath)
|
|
628
|
+
]);
|
|
629
|
+
if (!meta && !scenarioContent) {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
const recentRuns = await this.getRecentRuns(scenarioPath, 10);
|
|
633
|
+
const metaData = meta || this.defaultMeta();
|
|
634
|
+
const steps = (meta?.steps || []).map((step) => ({
|
|
635
|
+
id: step.id,
|
|
636
|
+
title: step.title
|
|
637
|
+
}));
|
|
638
|
+
return {
|
|
639
|
+
slug,
|
|
640
|
+
title: meta?.title || scenarioContent?.title || slug,
|
|
641
|
+
starred: meta?.starred ?? false,
|
|
642
|
+
tags: meta?.tags || scenarioContent?.tags || [],
|
|
643
|
+
stepCount: steps.length,
|
|
644
|
+
steps,
|
|
645
|
+
content: scenarioContent?.content || "",
|
|
646
|
+
meta: metaData,
|
|
647
|
+
recentRuns
|
|
648
|
+
};
|
|
649
|
+
} catch {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Get run history for a scenario
|
|
655
|
+
* @param projectPath - Project root path (NOT the .harshJudge path)
|
|
656
|
+
*/
|
|
657
|
+
async getRunHistory(projectPath, scenarioSlug) {
|
|
658
|
+
try {
|
|
659
|
+
const resolver = createPathResolver(projectPath);
|
|
660
|
+
const scenarioPath = resolver.getScenarioDir(scenarioSlug);
|
|
661
|
+
return await this.getRecentRuns(scenarioPath, 100);
|
|
662
|
+
} catch {
|
|
663
|
+
return [];
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Get run detail including evidence paths
|
|
668
|
+
* @param projectPath - Project root path (NOT the .harshJudge path)
|
|
669
|
+
*/
|
|
670
|
+
async getRunDetail(projectPath, scenarioSlug, runId) {
|
|
671
|
+
try {
|
|
672
|
+
const resolver = createPathResolver(projectPath);
|
|
673
|
+
const runPath = resolver.getRunDir(scenarioSlug, runId);
|
|
674
|
+
const exists = await this.pathExists(runPath);
|
|
675
|
+
if (!exists) {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
const result = await this.readRunResult(runPath);
|
|
679
|
+
const evidencePaths = await this.getEvidencePaths(runPath);
|
|
680
|
+
return {
|
|
681
|
+
runId,
|
|
682
|
+
scenarioSlug,
|
|
683
|
+
result,
|
|
684
|
+
evidencePaths
|
|
685
|
+
};
|
|
686
|
+
} catch {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// --- Private helper methods ---
|
|
691
|
+
async pathExists(path) {
|
|
692
|
+
try {
|
|
693
|
+
await stat(path);
|
|
694
|
+
return true;
|
|
695
|
+
} catch {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
async readConfig() {
|
|
700
|
+
try {
|
|
701
|
+
const configPath = this.pathResolver.getConfigPath();
|
|
702
|
+
const content = await readFile(configPath, "utf-8");
|
|
703
|
+
return yaml.load(content);
|
|
704
|
+
} catch {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
async readScenarioMeta(scenarioPath) {
|
|
709
|
+
try {
|
|
710
|
+
const metaPath = join2(scenarioPath, "meta.yaml");
|
|
711
|
+
const content = await readFile(metaPath, "utf-8");
|
|
712
|
+
return yaml.load(content);
|
|
713
|
+
} catch {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
async readScenarioContent(scenarioPath) {
|
|
718
|
+
try {
|
|
719
|
+
const scenarioFile = join2(scenarioPath, "scenario.md");
|
|
720
|
+
const content = await readFile(scenarioFile, "utf-8");
|
|
721
|
+
const frontmatterMatch = content.match(
|
|
722
|
+
/^---\n([\s\S]*?)\n---\n([\s\S]*)$/
|
|
723
|
+
);
|
|
724
|
+
if (!frontmatterMatch || !frontmatterMatch[1] || frontmatterMatch[2] === void 0) {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
const frontmatter = yaml.load(frontmatterMatch[1]);
|
|
728
|
+
return {
|
|
729
|
+
title: frontmatter.title || "Untitled",
|
|
730
|
+
tags: frontmatter.tags || [],
|
|
731
|
+
content: frontmatterMatch[2]
|
|
732
|
+
};
|
|
733
|
+
} catch {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
async readScenarioSummary(scenarioPath, slug) {
|
|
738
|
+
const [meta, scenario] = await Promise.all([
|
|
739
|
+
this.readScenarioMeta(scenarioPath),
|
|
740
|
+
this.readScenarioContent(scenarioPath)
|
|
741
|
+
]);
|
|
742
|
+
if (!meta && !scenario) {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
const metaData = meta || this.defaultMeta();
|
|
746
|
+
const passRate = metaData.totalRuns > 0 ? metaData.passCount / metaData.totalRuns * 100 : 0;
|
|
747
|
+
return {
|
|
748
|
+
slug,
|
|
749
|
+
title: meta?.title || scenario?.title || slug,
|
|
750
|
+
starred: meta?.starred ?? false,
|
|
751
|
+
tags: meta?.tags || scenario?.tags || [],
|
|
752
|
+
stepCount: meta?.steps?.length ?? 0,
|
|
753
|
+
lastResult: metaData.lastResult,
|
|
754
|
+
lastRun: metaData.lastRun,
|
|
755
|
+
totalRuns: metaData.totalRuns,
|
|
756
|
+
passRate
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
async readRunResult(runPath) {
|
|
760
|
+
try {
|
|
761
|
+
const resultPath = join2(runPath, "result.json");
|
|
762
|
+
const content = await readFile(resultPath, "utf-8");
|
|
763
|
+
return JSON.parse(content);
|
|
764
|
+
} catch {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
async getRecentRuns(scenarioPath, limit) {
|
|
769
|
+
try {
|
|
770
|
+
const runsPath = join2(scenarioPath, "runs");
|
|
771
|
+
const exists = await this.pathExists(runsPath);
|
|
772
|
+
if (!exists) {
|
|
773
|
+
return [];
|
|
774
|
+
}
|
|
775
|
+
const entries = await readdir(runsPath, { withFileTypes: true });
|
|
776
|
+
const runDirs = entries.filter((e) => e.isDirectory());
|
|
777
|
+
const runs = [];
|
|
778
|
+
for (const dir of runDirs) {
|
|
779
|
+
const runPath = join2(runsPath, dir.name);
|
|
780
|
+
const result = await this.readRunResult(runPath);
|
|
781
|
+
if (result) {
|
|
782
|
+
const status = result.status;
|
|
783
|
+
runs.push({
|
|
784
|
+
id: result.runId,
|
|
785
|
+
runNumber: runs.length + 1,
|
|
786
|
+
status,
|
|
787
|
+
duration: result.duration ?? 0,
|
|
788
|
+
startedAt: result.startedAt,
|
|
789
|
+
completedAt: result.completedAt,
|
|
790
|
+
errorMessage: result.errorMessage ?? null
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return runs.sort((a, b) => {
|
|
795
|
+
const aTime = new Date(a.startedAt || a.completedAt || 0).getTime();
|
|
796
|
+
const bTime = new Date(b.startedAt || b.completedAt || 0).getTime();
|
|
797
|
+
return bTime - aTime;
|
|
798
|
+
}).slice(0, limit);
|
|
799
|
+
} catch {
|
|
800
|
+
return [];
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
async getEvidencePaths(runPath) {
|
|
804
|
+
try {
|
|
805
|
+
const paths = [];
|
|
806
|
+
const runEntries = await readdir(runPath, { withFileTypes: true });
|
|
807
|
+
const stepDirs = runEntries.filter(
|
|
808
|
+
(e) => e.isDirectory() && e.name.startsWith("step-")
|
|
809
|
+
);
|
|
810
|
+
if (stepDirs.length > 0) {
|
|
811
|
+
for (const stepDir of stepDirs) {
|
|
812
|
+
const stepEvidencePath = join2(runPath, stepDir.name, "evidence");
|
|
813
|
+
if (await this.pathExists(stepEvidencePath)) {
|
|
814
|
+
const entries2 = await readdir(stepEvidencePath);
|
|
815
|
+
for (const entry of entries2) {
|
|
816
|
+
if (!entry.endsWith(".meta.json")) {
|
|
817
|
+
paths.push(join2(stepEvidencePath, entry));
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return paths;
|
|
823
|
+
}
|
|
824
|
+
const evidencePath = join2(runPath, "evidence");
|
|
825
|
+
const exists = await this.pathExists(evidencePath);
|
|
826
|
+
if (!exists) {
|
|
827
|
+
return [];
|
|
828
|
+
}
|
|
829
|
+
const entries = await readdir(evidencePath);
|
|
830
|
+
return entries.filter((e) => !e.endsWith(".meta.json")).map((e) => join2(evidencePath, e));
|
|
831
|
+
} catch {
|
|
832
|
+
return [];
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
calculateOverallStatus(scenarios) {
|
|
836
|
+
if (scenarios.length === 0) {
|
|
837
|
+
return "never_run";
|
|
838
|
+
}
|
|
839
|
+
const hasFailure = scenarios.some((s) => s.lastResult === "fail");
|
|
840
|
+
if (hasFailure) {
|
|
841
|
+
return "fail";
|
|
842
|
+
}
|
|
843
|
+
const hasPass = scenarios.some((s) => s.lastResult === "pass");
|
|
844
|
+
if (hasPass) {
|
|
845
|
+
return "pass";
|
|
846
|
+
}
|
|
847
|
+
return "never_run";
|
|
848
|
+
}
|
|
849
|
+
defaultMeta() {
|
|
850
|
+
return { ...DEFAULT_SCENARIO_STATS };
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
// src/services/dashboard-worker.ts
|
|
855
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
856
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
857
|
+
var __filename2 = fileURLToPath2(import.meta.url);
|
|
858
|
+
var __dirname2 = dirname2(__filename2);
|
|
859
|
+
async function main() {
|
|
860
|
+
const port = parseInt(process.env["HARSHJUDGE_PORT"] || "7002", 10);
|
|
861
|
+
const projectPath = process.env["HARSHJUDGE_PROJECT_PATH"] || process.cwd();
|
|
862
|
+
const distPath = join3(__dirname2, "ux-dist");
|
|
863
|
+
console.log(`[HarshJudge Worker] Starting dashboard server...`);
|
|
864
|
+
console.log(`[HarshJudge Worker] Port: ${port}`);
|
|
865
|
+
console.log(`[HarshJudge Worker] Project path: ${projectPath}`);
|
|
866
|
+
console.log(`[HarshJudge Worker] Dist path: ${distPath}`);
|
|
867
|
+
const server = new DashboardServer({
|
|
868
|
+
port,
|
|
869
|
+
projectPath,
|
|
870
|
+
distPath
|
|
871
|
+
});
|
|
872
|
+
try {
|
|
873
|
+
const actualPort = await server.start();
|
|
874
|
+
console.log(
|
|
875
|
+
`[HarshJudge Worker] Dashboard running at http://localhost:${actualPort}`
|
|
876
|
+
);
|
|
877
|
+
process.on("SIGTERM", async () => {
|
|
878
|
+
console.log("[HarshJudge Worker] Received SIGTERM, shutting down...");
|
|
879
|
+
await server.stop();
|
|
880
|
+
process.exit(0);
|
|
881
|
+
});
|
|
882
|
+
process.on("SIGINT", async () => {
|
|
883
|
+
console.log("[HarshJudge Worker] Received SIGINT, shutting down...");
|
|
884
|
+
await server.stop();
|
|
885
|
+
process.exit(0);
|
|
886
|
+
});
|
|
887
|
+
} catch (error) {
|
|
888
|
+
console.error("[HarshJudge Worker] Failed to start:", error);
|
|
889
|
+
process.exit(1);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
main().catch((err) => {
|
|
893
|
+
console.error("[HarshJudge Worker] Fatal error:", err);
|
|
894
|
+
process.exit(1);
|
|
895
|
+
});
|
|
896
|
+
//# sourceMappingURL=dashboard-worker.js.map
|