@prismshadow/penguin-server 0.1.2 → 0.1.5
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/dist/api/types.d.ts +240 -21
- package/dist/index.js +1574 -249
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/web-dist/assets/index-Da8gVBDc.css +1 -0
- package/web-dist/assets/{index-zCEa-kcQ.js → index-DibOt1-H.js} +1 -1
- package/web-dist/assets/index-nay7sGjx.js +171 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-BwOLjdOw.css +0 -1
- package/web-dist/assets/index-Ddrpqgqu.js +0 -104
package/dist/index.js
CHANGED
|
@@ -3,10 +3,11 @@ import { config as loadDotenv } from "dotenv";
|
|
|
3
3
|
import { serve } from "@hono/node-server";
|
|
4
4
|
|
|
5
5
|
// src/app.ts
|
|
6
|
-
import
|
|
6
|
+
import fs17 from "fs";
|
|
7
7
|
import fsp from "fs/promises";
|
|
8
|
-
import
|
|
9
|
-
import { Hono as
|
|
8
|
+
import path16 from "path";
|
|
9
|
+
import { Hono as Hono21 } from "hono";
|
|
10
|
+
import { bodyLimit } from "hono/body-limit";
|
|
10
11
|
|
|
11
12
|
// src/db/database.ts
|
|
12
13
|
import { mkdirSync } from "fs";
|
|
@@ -102,6 +103,20 @@ CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files
|
|
|
102
103
|
invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
|
|
103
104
|
PRIMARY KEY (project_id, agent_id, name)
|
|
104
105
|
);
|
|
106
|
+
CREATE TABLE IF NOT EXISTS goal_state ( -- goal-mode runtime state (GOAL.yaml on disk is the model-facing protocol; this row is what the UI reads)
|
|
107
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display
|
|
108
|
+
session_id TEXT NOT NULL,
|
|
109
|
+
project_id TEXT NOT NULL,
|
|
110
|
+
agent_id TEXT NOT NULL,
|
|
111
|
+
objective TEXT NOT NULL,
|
|
112
|
+
status TEXT NOT NULL, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active)
|
|
113
|
+
budget INTEGER NOT NULL, -- token budget; -1 = unlimited
|
|
114
|
+
used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting)
|
|
115
|
+
rounds INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
created_at TEXT NOT NULL,
|
|
117
|
+
updated_at TEXT NOT NULL
|
|
118
|
+
);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);
|
|
105
120
|
CREATE TABLE IF NOT EXISTS ui_prefs (
|
|
106
121
|
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
|
107
122
|
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
|
|
@@ -285,13 +300,13 @@ var ErrorsRepo = class {
|
|
|
285
300
|
};
|
|
286
301
|
}
|
|
287
302
|
/** The most recent `limit` entries (reverse chronological order). */
|
|
288
|
-
recent(projectId, f = {}, limit = 20) {
|
|
303
|
+
recent(projectId, f = {}, limit = 20, offset = 0) {
|
|
289
304
|
const { where, params } = this.conds(projectId, f);
|
|
290
305
|
const rows = this.db.prepare(
|
|
291
306
|
`SELECT ts, source, code, kind, message
|
|
292
307
|
FROM error_records WHERE ${where}
|
|
293
|
-
ORDER BY id DESC LIMIT :limit`
|
|
294
|
-
).all({ ...params, limit });
|
|
308
|
+
ORDER BY id DESC LIMIT :limit OFFSET :offset`
|
|
309
|
+
).all({ ...params, limit, offset });
|
|
295
310
|
return rows.map((r) => ({
|
|
296
311
|
ts: r.ts,
|
|
297
312
|
source: r.source,
|
|
@@ -300,6 +315,10 @@ var ErrorsRepo = class {
|
|
|
300
315
|
message: r.message
|
|
301
316
|
}));
|
|
302
317
|
}
|
|
318
|
+
/** Cascading cleanup on Agent deletion (unattributed errors carry no agent_id and are unaffected). */
|
|
319
|
+
deleteByAgent(projectId, agentId) {
|
|
320
|
+
this.db.prepare("DELETE FROM error_records WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
|
|
321
|
+
}
|
|
303
322
|
/** Cascading cleanup on Project deletion (unattributed errors belong to no Project and are unaffected). */
|
|
304
323
|
deleteByProject(projectId) {
|
|
305
324
|
this.db.prepare("DELETE FROM error_records WHERE project_id = ?").run(projectId);
|
|
@@ -385,8 +404,77 @@ var ProjectsRepo = class {
|
|
|
385
404
|
}
|
|
386
405
|
};
|
|
387
406
|
|
|
388
|
-
// src/db/repos/
|
|
407
|
+
// src/db/repos/goals.ts
|
|
389
408
|
function mapRow2(r) {
|
|
409
|
+
return {
|
|
410
|
+
id: Number(r.id),
|
|
411
|
+
sessionId: r.session_id,
|
|
412
|
+
projectId: r.project_id,
|
|
413
|
+
agentId: r.agent_id,
|
|
414
|
+
objective: r.objective,
|
|
415
|
+
status: r.status,
|
|
416
|
+
budget: Number(r.budget),
|
|
417
|
+
used: Number(r.used),
|
|
418
|
+
rounds: Number(r.rounds),
|
|
419
|
+
createdAt: r.created_at,
|
|
420
|
+
updatedAt: r.updated_at
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
var GoalsRepo = class {
|
|
424
|
+
constructor(db) {
|
|
425
|
+
this.db = db;
|
|
426
|
+
}
|
|
427
|
+
db;
|
|
428
|
+
/** Register a new goal run (status active, counters at zero); returns the new row id. */
|
|
429
|
+
create(args) {
|
|
430
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
431
|
+
const result = this.db.prepare(
|
|
432
|
+
`INSERT INTO goal_state
|
|
433
|
+
(session_id, project_id, agent_id, objective, status, budget, used, rounds, created_at, updated_at)
|
|
434
|
+
VALUES (?, ?, ?, ?, 'active', ?, 0, 0, ?, ?)`
|
|
435
|
+
).run(args.sessionId, args.projectId, args.agentId, args.objective, args.budget, now, now);
|
|
436
|
+
return Number(result.lastInsertRowid);
|
|
437
|
+
}
|
|
438
|
+
/** Per-round progress refresh (round just started; `used` mirrors the runner's accounting so far). */
|
|
439
|
+
progress(id, rounds, used) {
|
|
440
|
+
this.db.prepare("UPDATE goal_state SET rounds = ?, used = ?, updated_at = ? WHERE id = ?").run(rounds, used, (/* @__PURE__ */ new Date()).toISOString(), id);
|
|
441
|
+
}
|
|
442
|
+
/** Record the terminal outcome. */
|
|
443
|
+
finish(id, status, rounds, used) {
|
|
444
|
+
this.db.prepare(
|
|
445
|
+
"UPDATE goal_state SET status = ?, rounds = ?, used = ?, updated_at = ? WHERE id = ?"
|
|
446
|
+
).run(status, rounds, used, (/* @__PURE__ */ new Date()).toISOString(), id);
|
|
447
|
+
}
|
|
448
|
+
/** The Session's most recent goal run (what the chat page's banner restores from); null if it never ran one. */
|
|
449
|
+
latestForSession(sessionId) {
|
|
450
|
+
const r = this.db.prepare("SELECT * FROM goal_state WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
|
|
451
|
+
return r ? mapRow2(r) : null;
|
|
452
|
+
}
|
|
453
|
+
deleteBySession(sessionId) {
|
|
454
|
+
this.db.prepare("DELETE FROM goal_state WHERE session_id = ?").run(sessionId);
|
|
455
|
+
}
|
|
456
|
+
deleteByAgent(projectId, agentId) {
|
|
457
|
+
this.db.prepare("DELETE FROM goal_state WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
|
|
458
|
+
}
|
|
459
|
+
deleteByProject(projectId) {
|
|
460
|
+
this.db.prepare("DELETE FROM goal_state WHERE project_id = ?").run(projectId);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Startup reconciliation: a goal runs only in SessionManager memory, so a hard crash
|
|
464
|
+
* (SIGKILL, power loss) leaves its row stuck `active` with no runner behind it — the banner
|
|
465
|
+
* would then restore a forever-"running" goal. Called once at boot, before the server accepts
|
|
466
|
+
* connections (nothing can be running yet), so every remaining `active` row is an orphan:
|
|
467
|
+
* mark them `aborted`. The on-disk GOAL.yaml is left `active` as the documented resume point.
|
|
468
|
+
* Returns the number of rows reconciled.
|
|
469
|
+
*/
|
|
470
|
+
abortOrphanedActive() {
|
|
471
|
+
const result = this.db.prepare("UPDATE goal_state SET status = 'aborted', updated_at = ? WHERE status = 'active'").run((/* @__PURE__ */ new Date()).toISOString());
|
|
472
|
+
return Number(result.changes);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// src/db/repos/schedules.ts
|
|
477
|
+
function mapRow3(r) {
|
|
390
478
|
return {
|
|
391
479
|
projectId: r.project_id,
|
|
392
480
|
agentId: r.agent_id,
|
|
@@ -408,11 +496,11 @@ var SchedulesRepo = class {
|
|
|
408
496
|
db;
|
|
409
497
|
find(projectId, agentId, name) {
|
|
410
498
|
const r = this.db.prepare("SELECT * FROM schedule_state WHERE project_id = ? AND agent_id = ? AND name = ?").get(projectId, agentId, name);
|
|
411
|
-
return r ?
|
|
499
|
+
return r ? mapRow3(r) : null;
|
|
412
500
|
}
|
|
413
501
|
listByAgent(projectId, agentId) {
|
|
414
502
|
const rows = this.db.prepare("SELECT * FROM schedule_state WHERE project_id = ? AND agent_id = ? ORDER BY name").all(projectId, agentId);
|
|
415
|
-
return rows.map((r) =>
|
|
503
|
+
return rows.map((r) => mapRow3(r));
|
|
416
504
|
}
|
|
417
505
|
/**
|
|
418
506
|
* Register or sync a task's runtime state, returning the synced row plus a `fresh`
|
|
@@ -507,13 +595,16 @@ var SchedulesRepo = class {
|
|
|
507
595
|
}
|
|
508
596
|
return removed;
|
|
509
597
|
}
|
|
598
|
+
deleteByAgent(projectId, agentId) {
|
|
599
|
+
this.db.prepare("DELETE FROM schedule_state WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
|
|
600
|
+
}
|
|
510
601
|
deleteByProject(projectId) {
|
|
511
602
|
this.db.prepare("DELETE FROM schedule_state WHERE project_id = ?").run(projectId);
|
|
512
603
|
}
|
|
513
604
|
};
|
|
514
605
|
|
|
515
606
|
// src/db/repos/sessions.ts
|
|
516
|
-
function
|
|
607
|
+
function mapRow4(r) {
|
|
517
608
|
return {
|
|
518
609
|
sessionId: r.session_id,
|
|
519
610
|
projectId: r.project_id,
|
|
@@ -567,15 +658,15 @@ var SessionsRepo = class {
|
|
|
567
658
|
}
|
|
568
659
|
findById(sessionId) {
|
|
569
660
|
const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
570
|
-
return r ?
|
|
661
|
+
return r ? mapRow4(r) : null;
|
|
571
662
|
}
|
|
572
663
|
listByAgent(projectId, agentId) {
|
|
573
664
|
const rows = this.db.prepare("SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?").all(projectId, agentId);
|
|
574
|
-
return rows.map(
|
|
665
|
+
return rows.map(mapRow4);
|
|
575
666
|
}
|
|
576
667
|
listByProject(projectId) {
|
|
577
668
|
const rows = this.db.prepare("SELECT * FROM sessions WHERE project_id = ?").all(projectId);
|
|
578
|
-
return rows.map(
|
|
669
|
+
return rows.map(mapRow4);
|
|
579
670
|
}
|
|
580
671
|
updateApprovalMode(sessionId, mode) {
|
|
581
672
|
this.db.prepare("UPDATE sessions SET approval_mode = ? WHERE session_id = ?").run(mode, sessionId);
|
|
@@ -780,7 +871,7 @@ var UsageRepo = class {
|
|
|
780
871
|
};
|
|
781
872
|
|
|
782
873
|
// src/db/repos/users.ts
|
|
783
|
-
function
|
|
874
|
+
function mapRow5(r) {
|
|
784
875
|
return {
|
|
785
876
|
userId: r.user_id,
|
|
786
877
|
passwordHash: r.password_hash,
|
|
@@ -807,12 +898,12 @@ var UsersRepo = class {
|
|
|
807
898
|
}
|
|
808
899
|
findById(userId) {
|
|
809
900
|
const r = this.db.prepare("SELECT * FROM users WHERE user_id = ?").get(userId);
|
|
810
|
-
return r ?
|
|
901
|
+
return r ? mapRow5(r) : null;
|
|
811
902
|
}
|
|
812
903
|
/** All users (for the admin user backend), ordered by creation time ascending. */
|
|
813
904
|
list() {
|
|
814
905
|
const rows = this.db.prepare("SELECT * FROM users ORDER BY created_at ASC, user_id ASC").all();
|
|
815
|
-
return rows.map(
|
|
906
|
+
return rows.map(mapRow5);
|
|
816
907
|
}
|
|
817
908
|
count() {
|
|
818
909
|
const r = this.db.prepare("SELECT COUNT(*) AS n FROM users").get();
|
|
@@ -1473,6 +1564,14 @@ function projectsRoutes(deps2) {
|
|
|
1473
1564
|
const project = await deps2.projectService.createProject(c.var.user, projectId, name);
|
|
1474
1565
|
return c.json({ project }, 201);
|
|
1475
1566
|
});
|
|
1567
|
+
app2.patch("/:projectId", async (c) => {
|
|
1568
|
+
const projectId = requireValidId(c, "projectId");
|
|
1569
|
+
const body = await readJson(c);
|
|
1570
|
+
const name = requireString(body, "name", { maxLen: 100, label: "name" }).trim();
|
|
1571
|
+
if (name === "") throw badRequest("name must be at least 1 characters.");
|
|
1572
|
+
const project = await deps2.projectService.renameProject(c.var.user.userId, projectId, name);
|
|
1573
|
+
return c.json({ project });
|
|
1574
|
+
});
|
|
1476
1575
|
app2.delete("/:projectId", async (c) => {
|
|
1477
1576
|
await deps2.projectService.deleteProject(c.var.user.userId, requireValidId(c, "projectId"));
|
|
1478
1577
|
return c.body(null, 204);
|
|
@@ -1514,6 +1613,12 @@ function membersRoutes(deps2) {
|
|
|
1514
1613
|
|
|
1515
1614
|
// src/http/routes/models.ts
|
|
1516
1615
|
import { Hono as Hono7 } from "hono";
|
|
1616
|
+
function publishCredentialsUpdated(deps2, projectId) {
|
|
1617
|
+
const event = { type: "credentials_updated" };
|
|
1618
|
+
for (const row of deps2.sessionsRepo.listByProject(projectId)) {
|
|
1619
|
+
deps2.channels.peek(row.sessionId)?.publish(event, "server_event");
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1517
1622
|
function parseRef(value, label) {
|
|
1518
1623
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1519
1624
|
throw badRequest(`${label} must be a { provider, modelId } object.`);
|
|
@@ -1633,7 +1738,10 @@ function modelsRoutes(deps2) {
|
|
|
1633
1738
|
const projectId = requireValidId(c, "projectId");
|
|
1634
1739
|
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
1635
1740
|
const req = parseModelsUpdate(await readJson(c));
|
|
1636
|
-
|
|
1741
|
+
const res = await deps2.projectConfigService.updateModels(projectId, req);
|
|
1742
|
+
deps2.manager.invalidateProjectRuntimes(projectId);
|
|
1743
|
+
publishCredentialsUpdated(deps2, projectId);
|
|
1744
|
+
return c.json(res);
|
|
1637
1745
|
});
|
|
1638
1746
|
app2.post("/test", async (c) => {
|
|
1639
1747
|
const projectId = requireValidId(c, "projectId");
|
|
@@ -2091,6 +2199,7 @@ async function readItem(deps2, projectId, agentId, name) {
|
|
|
2091
2199
|
|
|
2092
2200
|
// src/http/routes/benchmarks.ts
|
|
2093
2201
|
import { Hono as Hono10 } from "hono";
|
|
2202
|
+
var TEXT_PREVIEW_BYTES = 256 * 1024;
|
|
2094
2203
|
function benchmarksRoutes(deps2) {
|
|
2095
2204
|
const app2 = new Hono10();
|
|
2096
2205
|
app2.get("/", async (c) => {
|
|
@@ -2100,6 +2209,58 @@ function benchmarksRoutes(deps2) {
|
|
|
2100
2209
|
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2101
2210
|
return c.json(await deps2.benchmarks.list(projectId, agentId));
|
|
2102
2211
|
});
|
|
2212
|
+
app2.get("/:benchmarkId/cases", async (c) => {
|
|
2213
|
+
const projectId = requireValidId(c, "projectId");
|
|
2214
|
+
const agentId = requireValidId(c, "agentId");
|
|
2215
|
+
const benchmarkId = requireValidId(c, "benchmarkId");
|
|
2216
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2217
|
+
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2218
|
+
return c.json(await deps2.benchmarks.listCases(projectId, agentId, benchmarkId));
|
|
2219
|
+
});
|
|
2220
|
+
app2.get("/:benchmarkId/cases/:caseId/files", async (c) => {
|
|
2221
|
+
const projectId = requireValidId(c, "projectId");
|
|
2222
|
+
const agentId = requireValidId(c, "agentId");
|
|
2223
|
+
const benchmarkId = requireValidId(c, "benchmarkId");
|
|
2224
|
+
const caseId = requireValidId(c, "caseId");
|
|
2225
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2226
|
+
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2227
|
+
return c.json(
|
|
2228
|
+
await deps2.benchmarks.listCaseFiles(
|
|
2229
|
+
projectId,
|
|
2230
|
+
agentId,
|
|
2231
|
+
benchmarkId,
|
|
2232
|
+
caseId,
|
|
2233
|
+
c.req.query("path") ?? ""
|
|
2234
|
+
)
|
|
2235
|
+
);
|
|
2236
|
+
});
|
|
2237
|
+
app2.get("/:benchmarkId/cases/:caseId/files/content", async (c) => {
|
|
2238
|
+
const projectId = requireValidId(c, "projectId");
|
|
2239
|
+
const agentId = requireValidId(c, "agentId");
|
|
2240
|
+
const benchmarkId = requireValidId(c, "benchmarkId");
|
|
2241
|
+
const caseId = requireValidId(c, "caseId");
|
|
2242
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2243
|
+
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2244
|
+
const download = c.req.query("download") === "1";
|
|
2245
|
+
const boundedPreview = !download && c.req.query("preview") === "1";
|
|
2246
|
+
const { data, fileName, contentType, scriptable, truncated } = await deps2.benchmarks.readCaseFile(
|
|
2247
|
+
projectId,
|
|
2248
|
+
agentId,
|
|
2249
|
+
benchmarkId,
|
|
2250
|
+
caseId,
|
|
2251
|
+
c.req.query("path") ?? "",
|
|
2252
|
+
boundedPreview ? { maxBytes: TEXT_PREVIEW_BYTES } : void 0
|
|
2253
|
+
);
|
|
2254
|
+
return new Response(new Uint8Array(data), {
|
|
2255
|
+
status: 200,
|
|
2256
|
+
headers: {
|
|
2257
|
+
"Content-Type": !download && scriptable ? "text/plain; charset=utf-8" : contentType,
|
|
2258
|
+
"Content-Disposition": `${download ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
2259
|
+
"X-Content-Type-Options": "nosniff",
|
|
2260
|
+
...truncated ? { "X-Content-Truncated": "1" } : {}
|
|
2261
|
+
}
|
|
2262
|
+
});
|
|
2263
|
+
});
|
|
2103
2264
|
return app2;
|
|
2104
2265
|
}
|
|
2105
2266
|
|
|
@@ -2305,6 +2466,9 @@ function agentsRoutes(deps2) {
|
|
|
2305
2466
|
await settleWithin(runnings, 5e3);
|
|
2306
2467
|
await deps2.agentService.deleteAgent(projectId, agentId);
|
|
2307
2468
|
deps2.sessionsRepo.deleteByAgent(projectId, agentId);
|
|
2469
|
+
deps2.goalsRepo.deleteByAgent(projectId, agentId);
|
|
2470
|
+
deps2.schedulesRepo.deleteByAgent(projectId, agentId);
|
|
2471
|
+
deps2.errorsRepo.deleteByAgent(projectId, agentId);
|
|
2308
2472
|
} finally {
|
|
2309
2473
|
deps2.manager.endAgentDeletion(projectId, agentId);
|
|
2310
2474
|
}
|
|
@@ -2410,6 +2574,7 @@ function agentConfigRoutes(deps2) {
|
|
|
2410
2574
|
|
|
2411
2575
|
// src/http/routes/agent-traces.ts
|
|
2412
2576
|
import { Hono as Hono16 } from "hono";
|
|
2577
|
+
var MAX_TRACE_BYTES = 14 * 1024 * 1024;
|
|
2413
2578
|
function agentTracesRoutes(deps2) {
|
|
2414
2579
|
const app2 = new Hono16();
|
|
2415
2580
|
app2.get("/", async (c) => {
|
|
@@ -2437,6 +2602,41 @@ function agentTracesRoutes(deps2) {
|
|
|
2437
2602
|
const index = positiveIntParam(c, "index");
|
|
2438
2603
|
return c.json(await deps2.traceService.analyze(projectId, agentId, sessionId, index));
|
|
2439
2604
|
});
|
|
2605
|
+
app2.get("/:sessionId/:index/download", async (c) => {
|
|
2606
|
+
const projectId = requireValidId(c, "projectId");
|
|
2607
|
+
const agentId = requireValidId(c, "agentId");
|
|
2608
|
+
const sessionId = requireValidId(c, "sessionId");
|
|
2609
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2610
|
+
const index = positiveIntParam(c, "index");
|
|
2611
|
+
const bytes = await deps2.traceService.readFileRaw(projectId, agentId, sessionId, index);
|
|
2612
|
+
const fileName = `${sessionId}_${String(index).padStart(3, "0")}.jsonl`;
|
|
2613
|
+
return new Response(new Uint8Array(bytes), {
|
|
2614
|
+
headers: {
|
|
2615
|
+
"Content-Type": "application/x-ndjson",
|
|
2616
|
+
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
2617
|
+
"X-Content-Type-Options": "nosniff"
|
|
2618
|
+
}
|
|
2619
|
+
});
|
|
2620
|
+
});
|
|
2621
|
+
app2.post("/import", async (c) => {
|
|
2622
|
+
const projectId = requireValidId(c, "projectId");
|
|
2623
|
+
const agentId = requireValidId(c, "agentId");
|
|
2624
|
+
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
2625
|
+
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2626
|
+
const body = await readJson(c);
|
|
2627
|
+
const dataBase64 = requireString(body, "dataBase64", { minLen: 1, maxLen: 20 * 1024 * 1024 });
|
|
2628
|
+
const bytes = Buffer.from(dataBase64, "base64");
|
|
2629
|
+
if (bytes.byteLength === 0) throw badRequest("Import file is empty.");
|
|
2630
|
+
if (bytes.byteLength > MAX_TRACE_BYTES) {
|
|
2631
|
+
throw badRequest("Import file exceeds the 14MB limit.");
|
|
2632
|
+
}
|
|
2633
|
+
const res = await deps2.traceService.importTraceFile(
|
|
2634
|
+
projectId,
|
|
2635
|
+
agentId,
|
|
2636
|
+
bytes.toString("utf8")
|
|
2637
|
+
);
|
|
2638
|
+
return c.json(res);
|
|
2639
|
+
});
|
|
2440
2640
|
return app2;
|
|
2441
2641
|
}
|
|
2442
2642
|
|
|
@@ -2473,12 +2673,32 @@ function usageRoutes(deps2) {
|
|
|
2473
2673
|
})
|
|
2474
2674
|
);
|
|
2475
2675
|
});
|
|
2676
|
+
app2.get("/errors", (c) => {
|
|
2677
|
+
const projectId = requireValidId(c, "projectId");
|
|
2678
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2679
|
+
const { offset, limit } = paginationQuery(c);
|
|
2680
|
+
const from = optionalDateParam(c.req.query("from"), "from");
|
|
2681
|
+
const to = optionalDateParam(c.req.query("to"), "to");
|
|
2682
|
+
const agentId = c.req.query("agentId");
|
|
2683
|
+
return c.json(
|
|
2684
|
+
deps2.usageService.queryErrors(projectId, {
|
|
2685
|
+
offset,
|
|
2686
|
+
limit,
|
|
2687
|
+
// Same admin-only rule as the dashboard: a regular member seeing another tenant's
|
|
2688
|
+
// unattributed errors would be a cross-tenant leak.
|
|
2689
|
+
includeGlobalErrors: c.var.user.isAdmin,
|
|
2690
|
+
...from !== void 0 ? { from } : {},
|
|
2691
|
+
...to !== void 0 ? { to } : {},
|
|
2692
|
+
...agentId !== void 0 && agentId !== "" ? { agentId } : {}
|
|
2693
|
+
})
|
|
2694
|
+
);
|
|
2695
|
+
});
|
|
2476
2696
|
return app2;
|
|
2477
2697
|
}
|
|
2478
2698
|
|
|
2479
2699
|
// src/http/routes/sessions.ts
|
|
2480
|
-
import
|
|
2481
|
-
import
|
|
2700
|
+
import fs8 from "fs/promises";
|
|
2701
|
+
import path7 from "path";
|
|
2482
2702
|
import { Hono as Hono18 } from "hono";
|
|
2483
2703
|
import { imageUrlMessage, scratchpadDir, userText } from "@prismshadow/penguin-core";
|
|
2484
2704
|
|
|
@@ -2658,9 +2878,14 @@ var WorkspaceFilesService = class {
|
|
|
2658
2878
|
);
|
|
2659
2879
|
return unique.filter((_, i) => exists[i]);
|
|
2660
2880
|
}
|
|
2661
|
-
/**
|
|
2881
|
+
/**
|
|
2882
|
+
* List a directory: dirs come first, each group sorted by name. Entries whose
|
|
2883
|
+
* canonical target leaves the Workspace are omitted, so listing cannot expose
|
|
2884
|
+
* metadata for an out-of-bounds symlink.
|
|
2885
|
+
*/
|
|
2662
2886
|
async list(workspace, rel) {
|
|
2663
2887
|
const dir = await this.resolveRead(workspace, rel);
|
|
2888
|
+
const realBase = await this.realBase(workspace);
|
|
2664
2889
|
let dirents;
|
|
2665
2890
|
try {
|
|
2666
2891
|
dirents = await fs6.readdir(dir, { withFileTypes: true });
|
|
@@ -2673,33 +2898,31 @@ var WorkspaceFilesService = class {
|
|
|
2673
2898
|
}
|
|
2674
2899
|
throw err;
|
|
2675
2900
|
}
|
|
2676
|
-
const
|
|
2901
|
+
const listed = await Promise.all(
|
|
2677
2902
|
dirents.map(async (d) => {
|
|
2678
|
-
let sizeBytes = 0;
|
|
2679
|
-
let mtime = "";
|
|
2680
|
-
let isDir = d.isDirectory();
|
|
2681
2903
|
try {
|
|
2682
|
-
const
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2904
|
+
const canonical = await fs6.realpath(path5.join(dir, d.name));
|
|
2905
|
+
this.assertInside(canonical, realBase);
|
|
2906
|
+
const stat = await fs6.stat(canonical);
|
|
2907
|
+
return {
|
|
2908
|
+
name: d.name,
|
|
2909
|
+
kind: stat.isDirectory() ? "dir" : "file",
|
|
2910
|
+
sizeBytes: stat.size,
|
|
2911
|
+
mtime: stat.mtime.toISOString()
|
|
2912
|
+
};
|
|
2686
2913
|
} catch {
|
|
2914
|
+
return null;
|
|
2687
2915
|
}
|
|
2688
|
-
return {
|
|
2689
|
-
name: d.name,
|
|
2690
|
-
kind: isDir ? "dir" : "file",
|
|
2691
|
-
sizeBytes,
|
|
2692
|
-
mtime
|
|
2693
|
-
};
|
|
2694
2916
|
})
|
|
2695
2917
|
);
|
|
2918
|
+
const entries = listed.filter((entry) => entry !== null);
|
|
2696
2919
|
entries.sort(
|
|
2697
2920
|
(a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1
|
|
2698
2921
|
);
|
|
2699
2922
|
return { path: rel, entries };
|
|
2700
2923
|
}
|
|
2701
2924
|
/** Read a file (preview/download): IO on the canonical path (resolveRead has already eliminated symlink escapes). */
|
|
2702
|
-
async read(workspace, rel) {
|
|
2925
|
+
async read(workspace, rel, options) {
|
|
2703
2926
|
const file = await this.resolveRead(workspace, rel);
|
|
2704
2927
|
let stat;
|
|
2705
2928
|
try {
|
|
@@ -2708,16 +2931,35 @@ var WorkspaceFilesService = class {
|
|
|
2708
2931
|
throw new HttpError(404, "path_not_found", "File does not exist.");
|
|
2709
2932
|
}
|
|
2710
2933
|
if (stat.isDirectory()) throw badRequest("path is a directory.");
|
|
2711
|
-
|
|
2934
|
+
const maxBytes = options?.maxBytes;
|
|
2935
|
+
if (maxBytes !== void 0 && (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_READ_BYTES)) {
|
|
2936
|
+
throw badRequest("maxBytes must be a positive integer within the read limit.");
|
|
2937
|
+
}
|
|
2938
|
+
if (maxBytes === void 0 && stat.size > MAX_READ_BYTES) {
|
|
2712
2939
|
throw new HttpError(413, "file_too_large", "File exceeds the 50MB read limit.");
|
|
2713
2940
|
}
|
|
2714
|
-
|
|
2941
|
+
let data;
|
|
2942
|
+
let truncated = false;
|
|
2943
|
+
if (maxBytes !== void 0 && stat.size > maxBytes) {
|
|
2944
|
+
const handle = await fs6.open(file, "r");
|
|
2945
|
+
try {
|
|
2946
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
2947
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
|
2948
|
+
data = buffer.subarray(0, bytesRead);
|
|
2949
|
+
truncated = true;
|
|
2950
|
+
} finally {
|
|
2951
|
+
await handle.close();
|
|
2952
|
+
}
|
|
2953
|
+
} else {
|
|
2954
|
+
data = await fs6.readFile(file);
|
|
2955
|
+
}
|
|
2715
2956
|
const ext = path5.extname(file).toLowerCase();
|
|
2716
2957
|
return {
|
|
2717
2958
|
data,
|
|
2718
2959
|
fileName: path5.basename(file),
|
|
2719
2960
|
contentType: CONTENT_TYPES[ext] ?? "application/octet-stream",
|
|
2720
|
-
scriptable: ext === ".html" || ext === ".htm" || ext === ".svg"
|
|
2961
|
+
scriptable: ext === ".html" || ext === ".htm" || ext === ".svg",
|
|
2962
|
+
...truncated ? { truncated: true } : {}
|
|
2721
2963
|
};
|
|
2722
2964
|
}
|
|
2723
2965
|
/**
|
|
@@ -2735,6 +2977,10 @@ var WorkspaceFilesService = class {
|
|
|
2735
2977
|
}
|
|
2736
2978
|
const { dir, name } = await this.resolveWriteParent(workspace, rel);
|
|
2737
2979
|
const file = path5.join(dir, name);
|
|
2980
|
+
if (process.platform === "win32") {
|
|
2981
|
+
const st = await fs6.lstat(file).catch(() => null);
|
|
2982
|
+
if (st?.isSymbolicLink()) throw badRequest("path must not be a symlink.");
|
|
2983
|
+
}
|
|
2738
2984
|
const flags = fsc.O_WRONLY | fsc.O_CREAT | fsc.O_TRUNC | (fsc.O_NOFOLLOW ?? 0);
|
|
2739
2985
|
let handle;
|
|
2740
2986
|
try {
|
|
@@ -2755,6 +3001,133 @@ var WorkspaceFilesService = class {
|
|
|
2755
3001
|
}
|
|
2756
3002
|
};
|
|
2757
3003
|
|
|
3004
|
+
// src/services/task-attachments.ts
|
|
3005
|
+
import fs7 from "fs/promises";
|
|
3006
|
+
import path6 from "path";
|
|
3007
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
3008
|
+
import { appendAttachmentLines, attachedFileLine } from "@prismshadow/penguin-core";
|
|
3009
|
+
var MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
3010
|
+
var MAX_ATTACHMENT_COUNT = 20;
|
|
3011
|
+
var MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024;
|
|
3012
|
+
var MAX_STEM_BYTES = 80;
|
|
3013
|
+
var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
3014
|
+
var INVISIBLE_CHAR = new RegExp("\\p{C}", "u");
|
|
3015
|
+
function unsafeNameChar(ch) {
|
|
3016
|
+
if (/[A-Za-z0-9._-]/.test(ch)) return false;
|
|
3017
|
+
return ch.codePointAt(0) < 128 || INVISIBLE_CHAR.test(ch);
|
|
3018
|
+
}
|
|
3019
|
+
function sanitizeSegment(value) {
|
|
3020
|
+
return Array.from(value, (ch) => unsafeNameChar(ch) ? "-" : ch).join("");
|
|
3021
|
+
}
|
|
3022
|
+
function truncateBytes(value, maxBytes) {
|
|
3023
|
+
if (Buffer.byteLength(value) <= maxBytes) return value;
|
|
3024
|
+
let out = "";
|
|
3025
|
+
for (const ch of value) {
|
|
3026
|
+
if (Buffer.byteLength(out) + Buffer.byteLength(ch) > maxBytes) break;
|
|
3027
|
+
out += ch;
|
|
3028
|
+
}
|
|
3029
|
+
return out;
|
|
3030
|
+
}
|
|
3031
|
+
var MAX_NAME_ATTEMPTS = 16;
|
|
3032
|
+
function parseAttachmentPart(part, index) {
|
|
3033
|
+
const fileName = part.fileName;
|
|
3034
|
+
if (typeof fileName !== "string" || fileName.length === 0 || fileName.includes("/") || fileName.includes("\\") || fileName.includes("..") || fileName.includes("\0")) {
|
|
3035
|
+
throw badRequest(
|
|
3036
|
+
`input[${index}].fileName must be a non-empty file name without path separators or "..".`
|
|
3037
|
+
);
|
|
3038
|
+
}
|
|
3039
|
+
const dataUrl = part.dataUrl;
|
|
3040
|
+
const match = typeof dataUrl === "string" ? /^data:[^,]*;base64,([A-Za-z0-9+/=\s]+)$/.exec(dataUrl) : null;
|
|
3041
|
+
if (!match) {
|
|
3042
|
+
throw badRequest(`input[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
|
|
3043
|
+
}
|
|
3044
|
+
const bytes = Buffer.from(match[1], "base64");
|
|
3045
|
+
if (bytes.length === 0) {
|
|
3046
|
+
throw badRequest(`input[${index}].dataUrl decodes to an empty file.`);
|
|
3047
|
+
}
|
|
3048
|
+
if (bytes.length > MAX_ATTACHMENT_BYTES) {
|
|
3049
|
+
throw new HttpError(
|
|
3050
|
+
413,
|
|
3051
|
+
"file_too_large",
|
|
3052
|
+
`Attached file exceeds the ${MAX_ATTACHMENT_BYTES / (1024 * 1024)}MB limit.`
|
|
3053
|
+
);
|
|
3054
|
+
}
|
|
3055
|
+
return { fileName, bytes };
|
|
3056
|
+
}
|
|
3057
|
+
function assertAttachmentBudget(attachments) {
|
|
3058
|
+
if (attachments.length > MAX_ATTACHMENT_COUNT) {
|
|
3059
|
+
throw new HttpError(
|
|
3060
|
+
413,
|
|
3061
|
+
"too_many_files",
|
|
3062
|
+
`A message may carry at most ${MAX_ATTACHMENT_COUNT} attached files.`
|
|
3063
|
+
);
|
|
3064
|
+
}
|
|
3065
|
+
let total = 0;
|
|
3066
|
+
for (const a of attachments) total += a.bytes.length;
|
|
3067
|
+
if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
|
|
3068
|
+
throw new HttpError(
|
|
3069
|
+
413,
|
|
3070
|
+
"payload_too_large",
|
|
3071
|
+
`Attached files exceed the ${MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)}MB total limit for one message.`
|
|
3072
|
+
);
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
function scratchpadName(fileName) {
|
|
3076
|
+
const ext = sanitizeSegment(path6.extname(fileName));
|
|
3077
|
+
const rawStem = fileName.slice(0, fileName.length - path6.extname(fileName).length);
|
|
3078
|
+
const stem = truncateBytes(sanitizeSegment(rawStem), MAX_STEM_BYTES).replace(/[. ]+$/, "");
|
|
3079
|
+
if (!stem || /^-+$/.test(stem)) return `file${ext}`;
|
|
3080
|
+
return WINDOWS_RESERVED.test(stem) ? `_${stem}${ext}` : `${stem}${ext}`;
|
|
3081
|
+
}
|
|
3082
|
+
async function writeAttachment(dir, attachment) {
|
|
3083
|
+
const base = scratchpadName(attachment.fileName);
|
|
3084
|
+
const ext = path6.extname(base);
|
|
3085
|
+
const stem = base.slice(0, base.length - ext.length);
|
|
3086
|
+
for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) {
|
|
3087
|
+
const name = attempt === 0 ? base : `${stem}-${randomBytes4(3).toString("hex")}${ext}`;
|
|
3088
|
+
const file = path6.join(dir, name);
|
|
3089
|
+
try {
|
|
3090
|
+
await fs7.writeFile(file, attachment.bytes, { flag: "wx" });
|
|
3091
|
+
return file;
|
|
3092
|
+
} catch (err) {
|
|
3093
|
+
if (err.code !== "EEXIST") throw err;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
throw new Error(
|
|
3097
|
+
`failed to allocate a unique attachment file name under ${dir} after ${MAX_NAME_ATTEMPTS} attempts`
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
3100
|
+
async function openScratchpadDir(root, sessionId) {
|
|
3101
|
+
const dir = path6.join(root, sessionId);
|
|
3102
|
+
await fs7.mkdir(dir, { recursive: true });
|
|
3103
|
+
const canonicalRoot = await fs7.realpath(root);
|
|
3104
|
+
const rel = path6.relative(canonicalRoot, await fs7.realpath(dir));
|
|
3105
|
+
if (rel !== sessionId) {
|
|
3106
|
+
throw new Error(
|
|
3107
|
+
`session scratchpad ${dir} resolves outside the agent scratchpad root; refusing to write attachments`
|
|
3108
|
+
);
|
|
3109
|
+
}
|
|
3110
|
+
return dir;
|
|
3111
|
+
}
|
|
3112
|
+
async function removeAttachments(files) {
|
|
3113
|
+
await Promise.all(files.map((f) => fs7.rm(f, { force: true }).catch(() => {
|
|
3114
|
+
})));
|
|
3115
|
+
}
|
|
3116
|
+
async function attachFilesToInput(messages, attachments, root, sessionId) {
|
|
3117
|
+
if (attachments.length === 0) return { input: messages, written: [] };
|
|
3118
|
+
const dir = await openScratchpadDir(root, sessionId);
|
|
3119
|
+
const written = [];
|
|
3120
|
+
try {
|
|
3121
|
+
for (const attachment of attachments) {
|
|
3122
|
+
written.push(await writeAttachment(dir, attachment));
|
|
3123
|
+
}
|
|
3124
|
+
} catch (err) {
|
|
3125
|
+
await removeAttachments(written);
|
|
3126
|
+
throw err;
|
|
3127
|
+
}
|
|
3128
|
+
return { input: appendAttachmentLines(messages, written.map(attachedFileLine)), written };
|
|
3129
|
+
}
|
|
3130
|
+
|
|
2758
3131
|
// src/http/routes/sessions.ts
|
|
2759
3132
|
var SESSION_TITLE_MAX = 120;
|
|
2760
3133
|
var STAT_MAX_PATHS = 100;
|
|
@@ -2772,12 +3145,33 @@ var SESSION_CATEGORIES = [
|
|
|
2772
3145
|
"schedule",
|
|
2773
3146
|
"archived"
|
|
2774
3147
|
];
|
|
3148
|
+
var IMAGE_DATA_URL = /^data:[^;,]+;base64,[A-Za-z0-9+/=\s]+$/;
|
|
3149
|
+
function requireImageUrl(url, field) {
|
|
3150
|
+
if (typeof url === "string") {
|
|
3151
|
+
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
|
3152
|
+
if (IMAGE_DATA_URL.test(url)) return url;
|
|
3153
|
+
}
|
|
3154
|
+
throw badRequest(
|
|
3155
|
+
`${field} must be an http(s) URL or a base64 data: URL (data:<mime>;base64,<bytes>).`
|
|
3156
|
+
);
|
|
3157
|
+
}
|
|
3158
|
+
function resolveScratchpadFile(dir, fileName) {
|
|
3159
|
+
if (!fileName || fileName === "." || fileName === "..") return null;
|
|
3160
|
+
for (const ch of fileName) {
|
|
3161
|
+
const code = ch.codePointAt(0);
|
|
3162
|
+
if (code < 32 || code === 127 || ch === "/" || ch === "\\") return null;
|
|
3163
|
+
}
|
|
3164
|
+
const resolved = path7.resolve(dir, fileName);
|
|
3165
|
+
return path7.dirname(resolved) === path7.resolve(dir) ? resolved : null;
|
|
3166
|
+
}
|
|
2775
3167
|
function parseTaskInput(body) {
|
|
2776
3168
|
const input = body.input;
|
|
2777
3169
|
if (!Array.isArray(input) || input.length === 0) {
|
|
2778
3170
|
throw badRequest("input must be an array with at least one item.");
|
|
2779
3171
|
}
|
|
2780
|
-
|
|
3172
|
+
const messages = [];
|
|
3173
|
+
const attachments = [];
|
|
3174
|
+
input.forEach((item, i) => {
|
|
2781
3175
|
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
2782
3176
|
throw badRequest(`input[${i}] must be an object.`);
|
|
2783
3177
|
}
|
|
@@ -2786,17 +3180,39 @@ function parseTaskInput(body) {
|
|
|
2786
3180
|
if (typeof part.text !== "string" || part.text.length === 0) {
|
|
2787
3181
|
throw badRequest(`input[${i}].text must be a non-empty string.`);
|
|
2788
3182
|
}
|
|
2789
|
-
|
|
3183
|
+
messages.push(userText(part.text));
|
|
3184
|
+
return;
|
|
2790
3185
|
}
|
|
2791
3186
|
if (part.type === "image_url") {
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
throw badRequest(`input[${i}].imageUrl only supports data: or http(s) URLs.`);
|
|
2795
|
-
}
|
|
2796
|
-
return imageUrlMessage(url);
|
|
3187
|
+
messages.push(imageUrlMessage(requireImageUrl(part.imageUrl, `input[${i}].imageUrl`)));
|
|
3188
|
+
return;
|
|
2797
3189
|
}
|
|
2798
|
-
|
|
3190
|
+
if (part.type === "file") {
|
|
3191
|
+
attachments.push(parseAttachmentPart(part, i));
|
|
3192
|
+
assertAttachmentBudget(attachments);
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
throw badRequest(`input[${i}].type must be one of text / image_url / file.`);
|
|
2799
3196
|
});
|
|
3197
|
+
return { messages, attachments };
|
|
3198
|
+
}
|
|
3199
|
+
function parseSteerImages(body) {
|
|
3200
|
+
const images = body.images;
|
|
3201
|
+
if (images === void 0) return [];
|
|
3202
|
+
if (!Array.isArray(images)) throw badRequest("images must be an array.");
|
|
3203
|
+
return images.map((url, i) => requireImageUrl(url, `images[${i}]`));
|
|
3204
|
+
}
|
|
3205
|
+
function parseGoalField(body) {
|
|
3206
|
+
const goal = body.goal;
|
|
3207
|
+
if (goal === void 0) return null;
|
|
3208
|
+
if (goal === null || typeof goal !== "object" || Array.isArray(goal)) {
|
|
3209
|
+
throw badRequest("goal must be an object.");
|
|
3210
|
+
}
|
|
3211
|
+
const budget = goal.budget;
|
|
3212
|
+
if (budget !== void 0 && (typeof budget !== "number" || !Number.isInteger(budget) || budget <= 0 && budget !== -1)) {
|
|
3213
|
+
throw badRequest("goal.budget must be a positive integer, or -1 for unlimited.");
|
|
3214
|
+
}
|
|
3215
|
+
return { budget: budget ?? -1 };
|
|
2800
3216
|
}
|
|
2801
3217
|
function agentSessionsRoutes(deps2) {
|
|
2802
3218
|
const app2 = new Hono18();
|
|
@@ -2947,11 +3363,12 @@ function sessionsRoutes(deps2) {
|
|
|
2947
3363
|
]);
|
|
2948
3364
|
}
|
|
2949
3365
|
await deps2.traceService.deleteSessionTraces(row.projectId, row.agentId, row.sessionId);
|
|
2950
|
-
await
|
|
2951
|
-
|
|
3366
|
+
await fs8.rm(
|
|
3367
|
+
path7.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
|
|
2952
3368
|
{ recursive: true, force: true }
|
|
2953
3369
|
);
|
|
2954
3370
|
deps2.sessionsRepo.deleteById(row.sessionId);
|
|
3371
|
+
deps2.goalsRepo.deleteBySession(row.sessionId);
|
|
2955
3372
|
deps2.sessionSources.delete(row.sessionId);
|
|
2956
3373
|
} finally {
|
|
2957
3374
|
deps2.manager.endSessionDeletion(row.sessionId);
|
|
@@ -2961,17 +3378,14 @@ function sessionsRoutes(deps2) {
|
|
|
2961
3378
|
app2.get("/:sessionId/scratchpad/:fileName", async (c) => {
|
|
2962
3379
|
const row = resolveSession(c);
|
|
2963
3380
|
const fileName = c.req.param("fileName") ?? "";
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
}
|
|
2967
|
-
const filePath = path6.join(
|
|
2968
|
-
scratchpadDir(deps2.config.root, row.projectId, row.agentId),
|
|
2969
|
-
row.sessionId,
|
|
3381
|
+
const filePath = resolveScratchpadFile(
|
|
3382
|
+
path7.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
|
|
2970
3383
|
fileName
|
|
2971
3384
|
);
|
|
3385
|
+
if (!filePath) throw new HttpError(404, "file_not_found", "File does not exist.");
|
|
2972
3386
|
let bytes;
|
|
2973
3387
|
try {
|
|
2974
|
-
bytes = await
|
|
3388
|
+
bytes = await fs8.readFile(filePath);
|
|
2975
3389
|
} catch {
|
|
2976
3390
|
throw new HttpError(404, "file_not_found", "File does not exist.");
|
|
2977
3391
|
}
|
|
@@ -2982,20 +3396,39 @@ function sessionsRoutes(deps2) {
|
|
|
2982
3396
|
".gif": "image/gif",
|
|
2983
3397
|
".webp": "image/webp"
|
|
2984
3398
|
};
|
|
2985
|
-
const mime = MIME_BY_EXT[
|
|
3399
|
+
const mime = MIME_BY_EXT[path7.extname(fileName).toLowerCase()];
|
|
2986
3400
|
return c.body(new Uint8Array(bytes), 200, {
|
|
2987
|
-
"content-type": mime,
|
|
3401
|
+
"content-type": mime ?? "application/octet-stream",
|
|
3402
|
+
// nosniff: the composer's file attachments land in this same directory, so the bytes
|
|
3403
|
+
// here are arbitrary user content served from the App's own origin — without it a
|
|
3404
|
+
// browser could sniff an `application/octet-stream` upload back into HTML and run it
|
|
3405
|
+
// same-origin (the same defense workspace file reads apply).
|
|
3406
|
+
"x-content-type-options": "nosniff",
|
|
3407
|
+
// Second, independent layer for everything that fell off the allowlist: the only reason
|
|
3408
|
+
// this endpoint is fetched inline is the conversation's <img> tags, so anything that is
|
|
3409
|
+
// not one of those images is served as a download and never renders as a document —
|
|
3410
|
+
// nosniff alone would be the whole defense otherwise.
|
|
3411
|
+
...mime === void 0 ? {
|
|
3412
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`
|
|
3413
|
+
} : {},
|
|
2988
3414
|
"cache-control": "private, max-age=31536000, immutable"
|
|
2989
3415
|
});
|
|
2990
3416
|
});
|
|
2991
3417
|
app2.get("/:sessionId/messages", async (c) => {
|
|
2992
3418
|
const row = resolveSession(c);
|
|
3419
|
+
let live;
|
|
3420
|
+
if (deps2.manager.statusOf(row.sessionId) !== "idle") {
|
|
3421
|
+
live = {
|
|
3422
|
+
cursor: deps2.channels.get(row.sessionId).lastEventId,
|
|
3423
|
+
fragments: deps2.manager.liveFragments(row.sessionId)
|
|
3424
|
+
};
|
|
3425
|
+
}
|
|
2993
3426
|
const messages = await deps2.traceService.readMessages(
|
|
2994
3427
|
row.projectId,
|
|
2995
3428
|
row.agentId,
|
|
2996
3429
|
row.sessionId
|
|
2997
3430
|
);
|
|
2998
|
-
return c.json({ messages });
|
|
3431
|
+
return c.json({ messages, ...live !== void 0 ? { live } : {} });
|
|
2999
3432
|
});
|
|
3000
3433
|
app2.get("/:sessionId/stream", (c) => {
|
|
3001
3434
|
const row = resolveSession(c);
|
|
@@ -3017,23 +3450,72 @@ function sessionsRoutes(deps2) {
|
|
|
3017
3450
|
app2.post("/:sessionId/tasks", async (c) => {
|
|
3018
3451
|
const row = resolveSession(c);
|
|
3019
3452
|
const body = await readJson(c);
|
|
3020
|
-
const
|
|
3453
|
+
const goal = parseGoalField(body);
|
|
3021
3454
|
const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
|
|
3455
|
+
if (goal) {
|
|
3456
|
+
const { messages, attachments } = parseTaskInput(body);
|
|
3457
|
+
const text = messages.filter((m) => m.payload.type === "text").map((m) => m.payload.text).join("\n").trim();
|
|
3458
|
+
if (!text) {
|
|
3459
|
+
throw badRequest("goal mode requires a non-empty text objective.");
|
|
3460
|
+
}
|
|
3461
|
+
if (attachments.length > 0) {
|
|
3462
|
+
throw badRequest("goal mode accepts text and images only (no file attachments).");
|
|
3463
|
+
}
|
|
3464
|
+
const { sessionId } = await deps2.manager.startGoal(row.sessionId, {
|
|
3465
|
+
input: messages,
|
|
3466
|
+
budget: goal.budget,
|
|
3467
|
+
...thinkingLevel !== void 0 ? { thinkingLevel } : {}
|
|
3468
|
+
});
|
|
3469
|
+
return c.json({ sessionId }, 202);
|
|
3470
|
+
}
|
|
3471
|
+
const parsed = parseTaskInput(body);
|
|
3022
3472
|
const queueIfBusy = body.queueIfBusy === true;
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3473
|
+
deps2.manager.assertCanAcceptTask(row.sessionId, { queueIfBusy });
|
|
3474
|
+
const { input, written } = await attachFilesToInput(
|
|
3475
|
+
parsed.messages,
|
|
3476
|
+
parsed.attachments,
|
|
3477
|
+
scratchpadDir(deps2.config.root, row.projectId, row.agentId),
|
|
3478
|
+
row.sessionId
|
|
3479
|
+
);
|
|
3480
|
+
try {
|
|
3481
|
+
const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
|
|
3482
|
+
...thinkingLevel !== void 0 ? { thinkingLevel } : {},
|
|
3483
|
+
queueIfBusy
|
|
3484
|
+
});
|
|
3485
|
+
return c.json({ sessionId, queued }, 202);
|
|
3486
|
+
} catch (err) {
|
|
3487
|
+
await removeAttachments(written);
|
|
3488
|
+
throw err;
|
|
3489
|
+
}
|
|
3028
3490
|
});
|
|
3029
3491
|
app2.post("/:sessionId/steer", async (c) => {
|
|
3030
3492
|
const row = resolveSession(c);
|
|
3031
3493
|
const body = await readJson(c);
|
|
3032
3494
|
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
3033
|
-
|
|
3034
|
-
|
|
3495
|
+
const images = parseSteerImages(body);
|
|
3496
|
+
if (!text && images.length === 0) {
|
|
3497
|
+
throw badRequest("text or images must carry the steering message.");
|
|
3498
|
+
}
|
|
3499
|
+
deps2.manager.steer(row.sessionId, [
|
|
3500
|
+
...text ? [userText(text)] : [],
|
|
3501
|
+
...images.map((url) => imageUrlMessage(url))
|
|
3502
|
+
]);
|
|
3035
3503
|
return c.body(null, 202);
|
|
3036
3504
|
});
|
|
3505
|
+
app2.get("/:sessionId/goal", (c) => {
|
|
3506
|
+
const row = resolveSession(c);
|
|
3507
|
+
const g = deps2.goalsRepo.latestForSession(row.sessionId);
|
|
3508
|
+
return c.json({
|
|
3509
|
+
goal: g ? {
|
|
3510
|
+
objective: g.objective,
|
|
3511
|
+
status: g.status,
|
|
3512
|
+
budget: g.budget,
|
|
3513
|
+
used: g.used,
|
|
3514
|
+
rounds: g.rounds,
|
|
3515
|
+
updatedAt: g.updatedAt
|
|
3516
|
+
} : null
|
|
3517
|
+
});
|
|
3518
|
+
});
|
|
3037
3519
|
app2.post("/:sessionId/approvals/:toolCallId", async (c) => {
|
|
3038
3520
|
const row = resolveSession(c);
|
|
3039
3521
|
const body = await readJson(c);
|
|
@@ -3053,6 +3535,11 @@ function sessionsRoutes(deps2) {
|
|
|
3053
3535
|
const aborted = deps2.manager.abortTask(row.sessionId);
|
|
3054
3536
|
return c.body(null, aborted ? 202 : 204);
|
|
3055
3537
|
});
|
|
3538
|
+
app2.post("/:sessionId/retry-now", (c) => {
|
|
3539
|
+
const row = resolveSession(c);
|
|
3540
|
+
const skipped = deps2.manager.retryNow(row.sessionId);
|
|
3541
|
+
return c.json({ skipped });
|
|
3542
|
+
});
|
|
3056
3543
|
app2.post("/:sessionId/compact", async (c) => {
|
|
3057
3544
|
const row = resolveSession(c);
|
|
3058
3545
|
const { sessionId } = await deps2.manager.startCompact(row.sessionId);
|
|
@@ -3167,6 +3654,104 @@ function sessionsRoutes(deps2) {
|
|
|
3167
3654
|
return app2;
|
|
3168
3655
|
}
|
|
3169
3656
|
|
|
3657
|
+
// src/http/routes/version.ts
|
|
3658
|
+
import { spawn } from "child_process";
|
|
3659
|
+
import { BUILD_DATE, VERSION } from "@prismshadow/penguin-core";
|
|
3660
|
+
import { Hono as Hono19 } from "hono";
|
|
3661
|
+
var OUTPUT_CAP = 64 * 1024;
|
|
3662
|
+
var UPDATE_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
3663
|
+
var REFUSAL_MARKERS = [
|
|
3664
|
+
"runs from a source checkout",
|
|
3665
|
+
// sourceCheckout
|
|
3666
|
+
"Cannot tell how this penguin was installed",
|
|
3667
|
+
// unknownInstall
|
|
3668
|
+
"could not be identified",
|
|
3669
|
+
// npmUnknownManager
|
|
3670
|
+
"does not run on Windows",
|
|
3671
|
+
// windowsUnsupported
|
|
3672
|
+
"cannot run your package manager for you"
|
|
3673
|
+
// windowsGlobalInstall
|
|
3674
|
+
];
|
|
3675
|
+
function classifyUpdateRun(exitCode, output) {
|
|
3676
|
+
if (exitCode !== 0) return { status: "failed", output, needsRestart: false };
|
|
3677
|
+
if (REFUSAL_MARKERS.some((marker) => output.includes(marker))) {
|
|
3678
|
+
return { status: "unsupported", output, needsRestart: false };
|
|
3679
|
+
}
|
|
3680
|
+
return { status: "updated", output, needsRestart: true };
|
|
3681
|
+
}
|
|
3682
|
+
function runSelfUpdate(cliEntry) {
|
|
3683
|
+
return new Promise((resolve) => {
|
|
3684
|
+
const child = spawn(process.execPath, [cliEntry, "update", "--yes"], {
|
|
3685
|
+
env: { ...process.env, PENGUIN_LANG: "en" },
|
|
3686
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
3687
|
+
});
|
|
3688
|
+
let output = "";
|
|
3689
|
+
const append = (chunk) => {
|
|
3690
|
+
output = (output + chunk.toString("utf8")).slice(-OUTPUT_CAP);
|
|
3691
|
+
};
|
|
3692
|
+
child.stdout.on("data", append);
|
|
3693
|
+
child.stderr.on("data", append);
|
|
3694
|
+
let timedOut = false;
|
|
3695
|
+
const timer = setTimeout(() => {
|
|
3696
|
+
timedOut = true;
|
|
3697
|
+
child.kill("SIGTERM");
|
|
3698
|
+
setTimeout(() => child.kill("SIGKILL"), 1e4).unref();
|
|
3699
|
+
}, UPDATE_TIMEOUT_MS);
|
|
3700
|
+
timer.unref();
|
|
3701
|
+
child.on("error", (err) => {
|
|
3702
|
+
clearTimeout(timer);
|
|
3703
|
+
resolve({
|
|
3704
|
+
status: "failed",
|
|
3705
|
+
output: `${output}
|
|
3706
|
+
[failed to run the update command: ${err.message}]`.trim(),
|
|
3707
|
+
needsRestart: false
|
|
3708
|
+
});
|
|
3709
|
+
});
|
|
3710
|
+
child.on("close", (code) => {
|
|
3711
|
+
clearTimeout(timer);
|
|
3712
|
+
if (timedOut) {
|
|
3713
|
+
resolve({
|
|
3714
|
+
status: "failed",
|
|
3715
|
+
output: `${output}
|
|
3716
|
+
[the update run was killed after ${UPDATE_TIMEOUT_MS / 6e4} minutes]`.trim(),
|
|
3717
|
+
needsRestart: false
|
|
3718
|
+
});
|
|
3719
|
+
return;
|
|
3720
|
+
}
|
|
3721
|
+
resolve(classifyUpdateRun(code ?? -1, output.trim()));
|
|
3722
|
+
});
|
|
3723
|
+
});
|
|
3724
|
+
}
|
|
3725
|
+
function versionRoutes(deps2) {
|
|
3726
|
+
const app2 = new Hono19();
|
|
3727
|
+
let inflightRun = null;
|
|
3728
|
+
app2.get("/", (c) => {
|
|
3729
|
+
return c.json({ version: VERSION, buildDate: BUILD_DATE });
|
|
3730
|
+
});
|
|
3731
|
+
app2.get("/update-check", async (c) => {
|
|
3732
|
+
return c.json(await deps2.updateCheck.check(c.req.query("force") === "1"));
|
|
3733
|
+
});
|
|
3734
|
+
app2.post("/update", async (c) => {
|
|
3735
|
+
if (!c.var.user.isAdmin) {
|
|
3736
|
+
throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
|
|
3737
|
+
}
|
|
3738
|
+
const cliEntry = process.env.PENGUIN_CLI_ENTRY;
|
|
3739
|
+
if (!cliEntry) {
|
|
3740
|
+
return c.json({
|
|
3741
|
+
status: "unsupported",
|
|
3742
|
+
reason: "not_launched_via_cli",
|
|
3743
|
+
output: "",
|
|
3744
|
+
needsRestart: false
|
|
3745
|
+
});
|
|
3746
|
+
}
|
|
3747
|
+
inflightRun ??= runSelfUpdate(cliEntry).finally(() => {
|
|
3748
|
+
inflightRun = null;
|
|
3749
|
+
});
|
|
3750
|
+
return c.json(await inflightRun);
|
|
3751
|
+
});
|
|
3752
|
+
return app2;
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3170
3755
|
// src/runtime/channel.ts
|
|
3171
3756
|
import { randomUUID } from "crypto";
|
|
3172
3757
|
var DEFAULT_MAX_COUNT = 1e4;
|
|
@@ -3191,6 +3776,16 @@ var Channel = class {
|
|
|
3191
3776
|
get subscriberCount() {
|
|
3192
3777
|
return this.listeners.size;
|
|
3193
3778
|
}
|
|
3779
|
+
/**
|
|
3780
|
+
* Id of the most recently assigned event (`<epoch>-<seq>`; seq 0 when none was assigned
|
|
3781
|
+
* yet). Unicast (sendTo) seqs count too — the value is a position marker, not a buffer
|
|
3782
|
+
* lookup key. Used as the live-tail cursor on GET /messages: captured in the same
|
|
3783
|
+
* synchronous tick as the fragment snapshot, it tells the client which buffered events
|
|
3784
|
+
* the snapshot already covers.
|
|
3785
|
+
*/
|
|
3786
|
+
get lastEventId() {
|
|
3787
|
+
return `${this.epoch}-${this.nextSeq - 1}`;
|
|
3788
|
+
}
|
|
3194
3789
|
/** Broadcast an event: number it, buffer it (evicting the oldest), notify all subscribers. */
|
|
3195
3790
|
publish(data, event) {
|
|
3196
3791
|
const entry = this.makeEvent(data, event);
|
|
@@ -3358,12 +3953,16 @@ var ErrorRecorder = class {
|
|
|
3358
3953
|
};
|
|
3359
3954
|
|
|
3360
3955
|
// src/runtime/session-manager.ts
|
|
3361
|
-
import
|
|
3362
|
-
import
|
|
3956
|
+
import fs9 from "fs/promises";
|
|
3957
|
+
import path9 from "path";
|
|
3363
3958
|
import {
|
|
3364
3959
|
createAgent,
|
|
3365
3960
|
findLatestTraceFile,
|
|
3961
|
+
goalFinishedOf,
|
|
3962
|
+
goalTokenDelta,
|
|
3963
|
+
isGoalRoundInput,
|
|
3366
3964
|
isSessionMeta as isSessionMeta2,
|
|
3965
|
+
stripLeadingMarkerBlocks,
|
|
3367
3966
|
tracesDir
|
|
3368
3967
|
} from "@prismshadow/penguin-core";
|
|
3369
3968
|
|
|
@@ -3434,6 +4033,165 @@ function makeApprove(args) {
|
|
|
3434
4033
|
};
|
|
3435
4034
|
}
|
|
3436
4035
|
|
|
4036
|
+
// src/runtime/live-tail.ts
|
|
4037
|
+
import { isPartialPayload } from "@prismshadow/penguin-core";
|
|
4038
|
+
var FRAGMENT_CAP = 512 * 1024;
|
|
4039
|
+
var FRAGMENT_CAP_SLACK = 64 * 1024;
|
|
4040
|
+
function partialKindFor(p) {
|
|
4041
|
+
switch (p.type) {
|
|
4042
|
+
case "text":
|
|
4043
|
+
return p.role === "assistant" ? "partial_text" : null;
|
|
4044
|
+
case "thinking":
|
|
4045
|
+
return "partial_thinking";
|
|
4046
|
+
case "tool_call":
|
|
4047
|
+
return "partial_tool_call";
|
|
4048
|
+
case "tool_call_output":
|
|
4049
|
+
return "partial_tool_call_output";
|
|
4050
|
+
default:
|
|
4051
|
+
return null;
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
function fragmentKey(origin, kind, toolCallId) {
|
|
4055
|
+
return `${(origin ?? []).join("/")}\0${kind}::${toolCallId}`;
|
|
4056
|
+
}
|
|
4057
|
+
function toolCallIdOf(p) {
|
|
4058
|
+
const id = p.tool_call_id;
|
|
4059
|
+
return typeof id === "string" ? id : "";
|
|
4060
|
+
}
|
|
4061
|
+
function appendDelta(frag, p) {
|
|
4062
|
+
switch (p.type) {
|
|
4063
|
+
case "partial_text":
|
|
4064
|
+
frag.buffer += p.text;
|
|
4065
|
+
break;
|
|
4066
|
+
case "partial_thinking":
|
|
4067
|
+
frag.buffer += p.thinking;
|
|
4068
|
+
break;
|
|
4069
|
+
case "partial_tool_call":
|
|
4070
|
+
frag.buffer += p.arguments;
|
|
4071
|
+
if (p.name) frag.name = p.name;
|
|
4072
|
+
frag.toolCallId = p.tool_call_id;
|
|
4073
|
+
break;
|
|
4074
|
+
case "partial_tool_call_output":
|
|
4075
|
+
frag.buffer += p.output;
|
|
4076
|
+
if (p.images && p.images.length > 0) frag.images = p.images;
|
|
4077
|
+
frag.toolCallId = p.tool_call_id;
|
|
4078
|
+
break;
|
|
4079
|
+
}
|
|
4080
|
+
if (frag.buffer.length > FRAGMENT_CAP + FRAGMENT_CAP_SLACK) {
|
|
4081
|
+
frag.buffer = frag.buffer.slice(frag.buffer.length - FRAGMENT_CAP);
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
function startPayload(frag) {
|
|
4085
|
+
switch (frag.kind) {
|
|
4086
|
+
case "partial_text":
|
|
4087
|
+
return { type: "partial_text", role: "assistant", event_type: "start", text: frag.buffer };
|
|
4088
|
+
case "partial_thinking":
|
|
4089
|
+
return {
|
|
4090
|
+
type: "partial_thinking",
|
|
4091
|
+
role: "assistant",
|
|
4092
|
+
event_type: "start",
|
|
4093
|
+
thinking: frag.buffer
|
|
4094
|
+
};
|
|
4095
|
+
case "partial_tool_call":
|
|
4096
|
+
return {
|
|
4097
|
+
type: "partial_tool_call",
|
|
4098
|
+
role: "assistant",
|
|
4099
|
+
event_type: "start",
|
|
4100
|
+
name: frag.name ?? "",
|
|
4101
|
+
arguments: frag.buffer,
|
|
4102
|
+
tool_call_id: frag.toolCallId ?? ""
|
|
4103
|
+
};
|
|
4104
|
+
case "partial_tool_call_output":
|
|
4105
|
+
return {
|
|
4106
|
+
type: "partial_tool_call_output",
|
|
4107
|
+
role: "user",
|
|
4108
|
+
event_type: "start",
|
|
4109
|
+
output: frag.buffer,
|
|
4110
|
+
...frag.images !== void 0 && frag.images.length > 0 ? { images: frag.images } : {},
|
|
4111
|
+
tool_call_id: frag.toolCallId ?? ""
|
|
4112
|
+
};
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
var LiveTailTracker = class {
|
|
4116
|
+
/** sessionId → open fragments keyed by fragmentKey, in the order they were opened. */
|
|
4117
|
+
sessions = /* @__PURE__ */ new Map();
|
|
4118
|
+
/** Feed one published message (call in the same synchronous tick as the channel publish). */
|
|
4119
|
+
observe(sessionId, msg) {
|
|
4120
|
+
if (msg.type !== "model_msg") return;
|
|
4121
|
+
const p = msg.payload;
|
|
4122
|
+
if (!isPartialPayload(p)) {
|
|
4123
|
+
const kind = partialKindFor(p);
|
|
4124
|
+
if (kind === null) return;
|
|
4125
|
+
const open3 = this.sessions.get(sessionId);
|
|
4126
|
+
if (!open3) return;
|
|
4127
|
+
open3.delete(fragmentKey(msg.origin, kind, toolCallIdOf(p)));
|
|
4128
|
+
if (open3.size === 0) this.sessions.delete(sessionId);
|
|
4129
|
+
return;
|
|
4130
|
+
}
|
|
4131
|
+
const key = fragmentKey(msg.origin, p.type, toolCallIdOf(p));
|
|
4132
|
+
let open2 = this.sessions.get(sessionId);
|
|
4133
|
+
if (p.event_type === "start") {
|
|
4134
|
+
if (!open2) {
|
|
4135
|
+
open2 = /* @__PURE__ */ new Map();
|
|
4136
|
+
this.sessions.set(sessionId, open2);
|
|
4137
|
+
}
|
|
4138
|
+
const frag2 = {
|
|
4139
|
+
kind: p.type,
|
|
4140
|
+
timestamp: msg.timestamp,
|
|
4141
|
+
buffer: "",
|
|
4142
|
+
...msg.origin && msg.origin.length > 0 ? { origin: [...msg.origin] } : {}
|
|
4143
|
+
};
|
|
4144
|
+
open2.delete(key);
|
|
4145
|
+
open2.set(key, frag2);
|
|
4146
|
+
appendDelta(frag2, p);
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
let frag = open2?.get(key);
|
|
4150
|
+
if (!frag) {
|
|
4151
|
+
if (p.event_type === "stop") return;
|
|
4152
|
+
if (!open2) {
|
|
4153
|
+
open2 = /* @__PURE__ */ new Map();
|
|
4154
|
+
this.sessions.set(sessionId, open2);
|
|
4155
|
+
}
|
|
4156
|
+
frag = {
|
|
4157
|
+
kind: p.type,
|
|
4158
|
+
timestamp: msg.timestamp,
|
|
4159
|
+
buffer: "",
|
|
4160
|
+
...msg.origin && msg.origin.length > 0 ? { origin: [...msg.origin] } : {}
|
|
4161
|
+
};
|
|
4162
|
+
open2.set(key, frag);
|
|
4163
|
+
}
|
|
4164
|
+
appendDelta(frag, p);
|
|
4165
|
+
if (p.event_type === "stop") {
|
|
4166
|
+
open2.delete(key);
|
|
4167
|
+
if (open2.size === 0) this.sessions.delete(sessionId);
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
/**
|
|
4171
|
+
* Synthetic `partial_* start` messages for every open fragment (in open order), each
|
|
4172
|
+
* carrying the full accumulated content, the original origin chain, and the original
|
|
4173
|
+
* start timestamp. Empty when the session has no open fragments.
|
|
4174
|
+
*/
|
|
4175
|
+
fragments(sessionId) {
|
|
4176
|
+
const open2 = this.sessions.get(sessionId);
|
|
4177
|
+
if (!open2) return [];
|
|
4178
|
+
const out = [];
|
|
4179
|
+
for (const frag of open2.values()) {
|
|
4180
|
+
out.push({
|
|
4181
|
+
timestamp: frag.timestamp,
|
|
4182
|
+
type: "model_msg",
|
|
4183
|
+
payload: startPayload(frag),
|
|
4184
|
+
...frag.origin !== void 0 ? { origin: [...frag.origin] } : {}
|
|
4185
|
+
});
|
|
4186
|
+
}
|
|
4187
|
+
return out;
|
|
4188
|
+
}
|
|
4189
|
+
/** Drop all fragment state for a session (the run ended; nothing will continue these fragments). */
|
|
4190
|
+
clear(sessionId) {
|
|
4191
|
+
this.sessions.delete(sessionId);
|
|
4192
|
+
}
|
|
4193
|
+
};
|
|
4194
|
+
|
|
3437
4195
|
// src/runtime/session-sources.ts
|
|
3438
4196
|
function asSessionSource(v) {
|
|
3439
4197
|
return v === "schedule" || v === "subagent" ? v : void 0;
|
|
@@ -3456,11 +4214,26 @@ var SessionSources = class {
|
|
|
3456
4214
|
|
|
3457
4215
|
// src/runtime/stream-error-watcher.ts
|
|
3458
4216
|
import { isEventMessage, isModelMessage, isSessionMeta } from "@prismshadow/penguin-core";
|
|
3459
|
-
import
|
|
4217
|
+
import path8 from "path";
|
|
3460
4218
|
var TOOL_NAMES_MAX = 1e3;
|
|
3461
4219
|
var ORIGIN_CTX_MAX = 200;
|
|
3462
4220
|
var LLM_FAILURES = {
|
|
3463
|
-
|
|
4221
|
+
// Reached here only when no further attempt followed: the ladder ran out (or the run ended
|
|
4222
|
+
// on it), so the user lost the turn and someone should look. A `failed` that was retried
|
|
4223
|
+
// takes LLM_FAILED_RETRIED instead.
|
|
4224
|
+
failed: {
|
|
4225
|
+
code: "llm_failed",
|
|
4226
|
+
kind: "unexpected",
|
|
4227
|
+
text: "LLM request failed and the retries did not recover it."
|
|
4228
|
+
},
|
|
4229
|
+
// Its own code, not the failed bucket: dedup is `(source, code, Project)` over a short
|
|
4230
|
+
// window, and `llm_failed_retried` can now fire on any recovered blip — sharing a bucket
|
|
4231
|
+
// would let a genuine credential failure be dropped as a duplicate of one.
|
|
4232
|
+
auth: {
|
|
4233
|
+
code: "llm_auth",
|
|
4234
|
+
kind: "unexpected",
|
|
4235
|
+
text: "LLM request rejected: the provider did not accept the credentials."
|
|
4236
|
+
},
|
|
3464
4237
|
timeout: {
|
|
3465
4238
|
code: "llm_timeout",
|
|
3466
4239
|
kind: "expected",
|
|
@@ -3472,12 +4245,23 @@ var LLM_FAILURES = {
|
|
|
3472
4245
|
text: "LLM response could not be parsed (the engine reconnects and retries)."
|
|
3473
4246
|
}
|
|
3474
4247
|
};
|
|
4248
|
+
var LLM_FAILED_RETRIED = {
|
|
4249
|
+
code: "llm_failed_retried",
|
|
4250
|
+
kind: "expected",
|
|
4251
|
+
text: "LLM request failed (the engine reconnects and retries)."
|
|
4252
|
+
};
|
|
3475
4253
|
function isLlmFailure(s) {
|
|
3476
|
-
return s === "failed" || s === "timeout" || s === "malformed";
|
|
4254
|
+
return s === "failed" || s === "timeout" || s === "malformed" || s === "auth";
|
|
3477
4255
|
}
|
|
3478
4256
|
function isToolFailure(s) {
|
|
3479
4257
|
return s === "failed" || s === "timeout";
|
|
3480
4258
|
}
|
|
4259
|
+
var COMMAND_TOOLS = /* @__PURE__ */ new Set(["exec_command", "input_command"]);
|
|
4260
|
+
var ORDINARY_EXIT_NOTE = /\[exit code: [^\]\n]*\]\s*$/;
|
|
4261
|
+
function isOrdinaryCommandExit(name, output) {
|
|
4262
|
+
if (name !== void 0 && !COMMAND_TOOLS.has(name)) return false;
|
|
4263
|
+
return ORDINARY_EXIT_NOTE.test(output);
|
|
4264
|
+
}
|
|
3481
4265
|
function isUserAbortReason(reason) {
|
|
3482
4266
|
return /^aborted\b/i.test(reason);
|
|
3483
4267
|
}
|
|
@@ -3497,7 +4281,7 @@ var StreamErrorWatcher = class {
|
|
|
3497
4281
|
}
|
|
3498
4282
|
errors;
|
|
3499
4283
|
ctx;
|
|
3500
|
-
/** LLM failures awaiting a real reason: origin → failure state (see file header; each session has at most one in-flight Request). */
|
|
4284
|
+
/** LLM failures awaiting a real reason: origin → failure state + the request_end's own detail (see file header; each session has at most one in-flight Request). */
|
|
3501
4285
|
pending = /* @__PURE__ */ new Map();
|
|
3502
4286
|
/** Names of in-flight tool calls: `origin \0 tool_call_id` → tool name (dequeued once output arrives). */
|
|
3503
4287
|
toolNames = /* @__PURE__ */ new Map();
|
|
@@ -3535,7 +4319,7 @@ var StreamErrorWatcher = class {
|
|
|
3535
4319
|
registerOrigin(msg) {
|
|
3536
4320
|
const key = originKey(msg);
|
|
3537
4321
|
if (!key) return;
|
|
3538
|
-
const agentId =
|
|
4322
|
+
const agentId = path8.basename(path8.dirname(msg.payload.agent_state));
|
|
3539
4323
|
if (!agentId || agentId === "." || agentId === "..") return;
|
|
3540
4324
|
this.originCtx.delete(key);
|
|
3541
4325
|
this.originCtx.set(key, { agentId, sessionId: key });
|
|
@@ -3561,11 +4345,18 @@ var StreamErrorWatcher = class {
|
|
|
3561
4345
|
const key = originKey(msg);
|
|
3562
4346
|
if (p.type === "request_end") {
|
|
3563
4347
|
this.flush(key);
|
|
3564
|
-
if (isLlmFailure(p.status))
|
|
4348
|
+
if (isLlmFailure(p.status)) {
|
|
4349
|
+
this.pending.set(key, {
|
|
4350
|
+
status: p.status,
|
|
4351
|
+
// The event's own failure detail (LLMOutcome.message): the message of record when
|
|
4352
|
+
// no abort follows (the retry path).
|
|
4353
|
+
...typeof p.message === "string" && p.message.trim() ? { message: p.message } : {}
|
|
4354
|
+
});
|
|
4355
|
+
}
|
|
3565
4356
|
return;
|
|
3566
4357
|
}
|
|
3567
4358
|
if (p.type === "request_begin") {
|
|
3568
|
-
this.flush(key);
|
|
4359
|
+
this.flush(key, null, true);
|
|
3569
4360
|
return;
|
|
3570
4361
|
}
|
|
3571
4362
|
if (p.type === "abort") {
|
|
@@ -3574,17 +4365,23 @@ var StreamErrorWatcher = class {
|
|
|
3574
4365
|
}
|
|
3575
4366
|
/**
|
|
3576
4367
|
* Persist a pending LLM failure (no-op if none is pending); `reason` is the abort
|
|
3577
|
-
* message that arrived afterward
|
|
4368
|
+
* message that arrived afterward, and `retried` says whether another attempt followed
|
|
4369
|
+
* (only a `request_begin` proves that). Pending state is already bucketed by origin, so
|
|
3578
4370
|
* `key` is exactly "the session that produced this failure" — attribution is looked
|
|
3579
4371
|
* up from it (see file header).
|
|
4372
|
+
*
|
|
4373
|
+
* `retried` defaults to false so every other resolution — an abort, the defensive flush at
|
|
4374
|
+
* the next request_end, or close() at the end of the run — is read as "nothing came after
|
|
4375
|
+
* this failure". That is the conservative direction: at worst a recovered blip is escalated,
|
|
4376
|
+
* never a lost turn silently downgraded.
|
|
3580
4377
|
*/
|
|
3581
|
-
flush(key, reason) {
|
|
3582
|
-
const
|
|
3583
|
-
if (
|
|
4378
|
+
flush(key, reason, retried = false) {
|
|
4379
|
+
const entry = this.pending.get(key);
|
|
4380
|
+
if (entry === void 0) return;
|
|
3584
4381
|
this.pending.delete(key);
|
|
3585
|
-
const spec = LLM_FAILURES[status];
|
|
4382
|
+
const spec = retried && entry.status === "failed" ? LLM_FAILED_RETRIED : LLM_FAILURES[entry.status];
|
|
3586
4383
|
const trimmed = reason?.trim();
|
|
3587
|
-
const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : spec.text;
|
|
4384
|
+
const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : entry.message ?? spec.text;
|
|
3588
4385
|
this.errors.record({
|
|
3589
4386
|
source: "llm",
|
|
3590
4387
|
err: message,
|
|
@@ -3612,9 +4409,11 @@ var StreamErrorWatcher = class {
|
|
|
3612
4409
|
const name = this.toolNames.get(key);
|
|
3613
4410
|
this.toolNames.delete(key);
|
|
3614
4411
|
if (!isToolFailure(p.stop_reason)) return;
|
|
4412
|
+
const output = p.output ?? "";
|
|
4413
|
+
if (isOrdinaryCommandExit(name, output)) return;
|
|
3615
4414
|
this.errors.record({
|
|
3616
4415
|
source: "environment",
|
|
3617
|
-
err: toolFailureText(
|
|
4416
|
+
err: toolFailureText(output),
|
|
3618
4417
|
ctx: this.ctxFor(origin),
|
|
3619
4418
|
// The tool name goes into code: so the stats dashboard's "most common error code" and table can show which tool failed.
|
|
3620
4419
|
code: `tool_${p.stop_reason}:${name ?? "unknown"}`,
|
|
@@ -3653,7 +4452,7 @@ function createCoreSessionLoader(root, sources) {
|
|
|
3653
4452
|
}
|
|
3654
4453
|
}
|
|
3655
4454
|
try {
|
|
3656
|
-
const stat = await
|
|
4455
|
+
const stat = await fs9.stat(row.workspace);
|
|
3657
4456
|
if (!stat.isDirectory()) throw new Error("not a directory");
|
|
3658
4457
|
} catch {
|
|
3659
4458
|
throw new HttpError(
|
|
@@ -3748,6 +4547,8 @@ var SessionManager = class {
|
|
|
3748
4547
|
deletingSessions = /* @__PURE__ */ new Set();
|
|
3749
4548
|
/** Per-Agent config generation (key = agentKey), bumped by invalidateAgentRuntimes on vault updates. */
|
|
3750
4549
|
agentGenerations = /* @__PURE__ */ new Map();
|
|
4550
|
+
/** Open streaming fragments of running sessions (fed by drive, served to GET /messages; see live-tail.ts). */
|
|
4551
|
+
liveTail = new LiveTailTracker();
|
|
3751
4552
|
sweepTimer;
|
|
3752
4553
|
// —— Query surface (used by Session listing / Agent active-count / SSE subscription replay) ——
|
|
3753
4554
|
statusOf(sessionId) {
|
|
@@ -3763,6 +4564,15 @@ var SessionManager = class {
|
|
|
3763
4564
|
pendingFollowUpCount(sessionId) {
|
|
3764
4565
|
return this.entries.get(sessionId)?.followUps.length ?? 0;
|
|
3765
4566
|
}
|
|
4567
|
+
/**
|
|
4568
|
+
* Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
|
|
4569
|
+
* streaming fragment, carrying the full accumulated content so far (see live-tail.ts).
|
|
4570
|
+
* Empty when idle or when nothing is streaming. GET /messages attaches this (with a
|
|
4571
|
+
* channel cursor) so a client joining mid-stream can render the in-progress message.
|
|
4572
|
+
*/
|
|
4573
|
+
liveFragments(sessionId) {
|
|
4574
|
+
return this.liveTail.fragments(sessionId);
|
|
4575
|
+
}
|
|
3766
4576
|
/** Number of Sessions for this Agent that are currently running / compacting. */
|
|
3767
4577
|
activeCountForAgent(projectId, agentId) {
|
|
3768
4578
|
let n = 0;
|
|
@@ -3802,7 +4612,39 @@ var SessionManager = class {
|
|
|
3802
4612
|
const key = agentKey(projectId, agentId);
|
|
3803
4613
|
this.agentGenerations.set(key, this.generationOf(projectId, agentId) + 1);
|
|
3804
4614
|
}
|
|
4615
|
+
/**
|
|
4616
|
+
* After a Project's models/credentials change: invalidate every cached runtime in this
|
|
4617
|
+
* Project, so the next Task re-resumes with the new api_key / base_url. Same
|
|
4618
|
+
* effective-value semantics as invalidateAgentRuntimes — no hot swap into a Task already
|
|
4619
|
+
* in flight. Iterating the active table is complete here, not a shortcut: the generation
|
|
4620
|
+
* map only matters for runtimes that are already cached, and an Agent with no cached
|
|
4621
|
+
* entry builds fresh through the loader anyway.
|
|
4622
|
+
*/
|
|
4623
|
+
invalidateProjectRuntimes(projectId) {
|
|
4624
|
+
const agentIds = /* @__PURE__ */ new Set();
|
|
4625
|
+
for (const e of this.entries.values()) {
|
|
4626
|
+
if (e.projectId === projectId) agentIds.add(e.agentId);
|
|
4627
|
+
}
|
|
4628
|
+
for (const agentId of agentIds) this.invalidateAgentRuntimes(projectId, agentId);
|
|
4629
|
+
}
|
|
3805
4630
|
// —— Task / compaction drive ——
|
|
4631
|
+
/**
|
|
4632
|
+
* Cheap, lock-free rehearsal of the 409/503 conditions startTask checks, throwing exactly the
|
|
4633
|
+
* same HttpErrors. **Advisory only**: it neither takes the Session lock nor loads an entry, so
|
|
4634
|
+
* a session that isn't in the active table reads as acceptable and a status change racing this
|
|
4635
|
+
* call is not caught — the authoritative check is still the one inside startTask.
|
|
4636
|
+
*
|
|
4637
|
+
* It exists so a caller that has irreversible work to do first (POST /tasks writes the
|
|
4638
|
+
* message's file attachments to disk) can find out about the ordinary "a Task is already
|
|
4639
|
+
* running" rejection before doing it, instead of undoing it afterwards.
|
|
4640
|
+
*/
|
|
4641
|
+
assertCanAcceptTask(sessionId, opts) {
|
|
4642
|
+
this.assertOpen();
|
|
4643
|
+
this.assertAgentNotDeleting(sessionId);
|
|
4644
|
+
this.assertSessionNotDeleting(sessionId);
|
|
4645
|
+
const entry = this.entries.get(sessionId);
|
|
4646
|
+
if (entry && !opts?.queueIfBusy) this.assertIdle(entry);
|
|
4647
|
+
}
|
|
3806
4648
|
/**
|
|
3807
4649
|
* Start a Task: get-or-load → 409
|
|
3808
4650
|
* mutual-exclusion check → publish the input messages first → drive run in the
|
|
@@ -3834,6 +4676,135 @@ var SessionManager = class {
|
|
|
3834
4676
|
return { sessionId: entry.sessionId, queued: false };
|
|
3835
4677
|
});
|
|
3836
4678
|
}
|
|
4679
|
+
/**
|
|
4680
|
+
* Start a goal run: like startTask, but the run is core goal mode — one
|
|
4681
|
+
* `session.run(input, { goal })` call loops rounds until a terminal state, and the
|
|
4682
|
+
* Session stays `running` for the whole goal (every round), so the existing abort
|
|
4683
|
+
* endpoint interrupts the entire loop and schedules queue behind it as usual. Round
|
|
4684
|
+
* inputs are yielded by core and published like any streamed message; progress
|
|
4685
|
+
* additionally goes out as goal_* server events and into goal_state (when a repo is
|
|
4686
|
+
* wired).
|
|
4687
|
+
*/
|
|
4688
|
+
async startGoal(sessionId, args) {
|
|
4689
|
+
return this.withLock(sessionId, async () => {
|
|
4690
|
+
this.assertOpen();
|
|
4691
|
+
this.assertAgentNotDeleting(sessionId);
|
|
4692
|
+
this.assertSessionNotDeleting(sessionId);
|
|
4693
|
+
const entry = await this.ensureEntry(sessionId);
|
|
4694
|
+
this.assertIdle(entry);
|
|
4695
|
+
const text = args.input.filter(isPlainText("user")).map((m) => m.payload.text).join("\n");
|
|
4696
|
+
const objective = stripLeadingMarkerBlocks(text).trim() || text.trim();
|
|
4697
|
+
const ac = new AbortController();
|
|
4698
|
+
entry.status = "running";
|
|
4699
|
+
entry.abort = ac;
|
|
4700
|
+
entry.lastActivityMs = Date.now();
|
|
4701
|
+
this.publishState(entry, "running");
|
|
4702
|
+
const approve = makeApprove({
|
|
4703
|
+
getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
|
|
4704
|
+
toolPermission: (name) => entry.session.toolPermission(name),
|
|
4705
|
+
registry: entry.approvals,
|
|
4706
|
+
publishRequest: (pending) => this.publishEvent(entry, {
|
|
4707
|
+
type: "approval_request",
|
|
4708
|
+
toolCall: pending.toolCall,
|
|
4709
|
+
...pending.origin !== void 0 ? { origin: pending.origin } : {}
|
|
4710
|
+
})
|
|
4711
|
+
});
|
|
4712
|
+
const goalId = this.deps.goals?.create({
|
|
4713
|
+
sessionId: entry.sessionId,
|
|
4714
|
+
projectId: entry.projectId,
|
|
4715
|
+
agentId: entry.agentId,
|
|
4716
|
+
objective,
|
|
4717
|
+
budget: args.budget
|
|
4718
|
+
});
|
|
4719
|
+
this.publishEvent(entry, {
|
|
4720
|
+
type: "goal_started",
|
|
4721
|
+
sessionId: entry.sessionId,
|
|
4722
|
+
objective,
|
|
4723
|
+
budget: args.budget
|
|
4724
|
+
});
|
|
4725
|
+
const gen = this.goalStream(entry, {
|
|
4726
|
+
input: args.input,
|
|
4727
|
+
budget: args.budget,
|
|
4728
|
+
...args.thinkingLevel !== void 0 ? { thinkingLevel: args.thinkingLevel } : {},
|
|
4729
|
+
approve,
|
|
4730
|
+
signal: ac.signal,
|
|
4731
|
+
...goalId !== void 0 ? { goalId } : {}
|
|
4732
|
+
});
|
|
4733
|
+
entry.running = this.drive(entry, gen, { userExcerpt: objective });
|
|
4734
|
+
return { sessionId: entry.sessionId };
|
|
4735
|
+
});
|
|
4736
|
+
}
|
|
4737
|
+
/**
|
|
4738
|
+
* Taps core's goal-mode stream for `drive`: round boundaries (the injected `[goal]`
|
|
4739
|
+
* inputs) become goal_round events + goal_state refreshes, and the terminal
|
|
4740
|
+
* `goal_finished` event message becomes the goal_finished server event + the run-state
|
|
4741
|
+
* row's final status. Token numbers mirror core's own accounting (same
|
|
4742
|
+
* `goalTokenDelta`), so the UI shows exactly what the budget check uses.
|
|
4743
|
+
*/
|
|
4744
|
+
async *goalStream(entry, args) {
|
|
4745
|
+
const gen = entry.session.run(args.input, {
|
|
4746
|
+
approve: args.approve,
|
|
4747
|
+
signal: args.signal,
|
|
4748
|
+
...args.thinkingLevel !== void 0 ? { thinkingLevel: args.thinkingLevel } : {},
|
|
4749
|
+
goal: { budget: args.budget }
|
|
4750
|
+
});
|
|
4751
|
+
let round = 0;
|
|
4752
|
+
let used = 0;
|
|
4753
|
+
let finished = false;
|
|
4754
|
+
try {
|
|
4755
|
+
for await (const msg of gen) {
|
|
4756
|
+
used += goalTokenDelta(msg);
|
|
4757
|
+
if (isGoalRoundInput(msg)) {
|
|
4758
|
+
round++;
|
|
4759
|
+
if (args.goalId !== void 0) this.deps.goals?.progress(args.goalId, round, used);
|
|
4760
|
+
this.publishEvent(entry, {
|
|
4761
|
+
type: "goal_round",
|
|
4762
|
+
sessionId: entry.sessionId,
|
|
4763
|
+
round,
|
|
4764
|
+
used,
|
|
4765
|
+
budget: args.budget
|
|
4766
|
+
});
|
|
4767
|
+
}
|
|
4768
|
+
const outcome = goalFinishedOf(msg);
|
|
4769
|
+
if (outcome) {
|
|
4770
|
+
finished = true;
|
|
4771
|
+
if (args.goalId !== void 0) {
|
|
4772
|
+
this.deps.goals?.finish(
|
|
4773
|
+
args.goalId,
|
|
4774
|
+
outcome.outcome,
|
|
4775
|
+
outcome.rounds,
|
|
4776
|
+
outcome.tokensUsed
|
|
4777
|
+
);
|
|
4778
|
+
}
|
|
4779
|
+
this.publishEvent(entry, {
|
|
4780
|
+
type: "goal_finished",
|
|
4781
|
+
sessionId: entry.sessionId,
|
|
4782
|
+
outcome: outcome.outcome,
|
|
4783
|
+
rounds: outcome.rounds,
|
|
4784
|
+
used: outcome.tokensUsed
|
|
4785
|
+
});
|
|
4786
|
+
}
|
|
4787
|
+
yield msg;
|
|
4788
|
+
}
|
|
4789
|
+
if (!finished) {
|
|
4790
|
+
this.finishAborted(entry, args.goalId, round, used);
|
|
4791
|
+
}
|
|
4792
|
+
} catch (err) {
|
|
4793
|
+
if (!finished) this.finishAborted(entry, args.goalId, round, used);
|
|
4794
|
+
throw err;
|
|
4795
|
+
}
|
|
4796
|
+
}
|
|
4797
|
+
/** Closes a goal's run state as aborted (stream cut off / infrastructure failure). */
|
|
4798
|
+
finishAborted(entry, goalId, round, used) {
|
|
4799
|
+
if (goalId !== void 0) this.deps.goals?.finish(goalId, "aborted", round, used);
|
|
4800
|
+
this.publishEvent(entry, {
|
|
4801
|
+
type: "goal_finished",
|
|
4802
|
+
sessionId: entry.sessionId,
|
|
4803
|
+
outcome: "aborted",
|
|
4804
|
+
rounds: round,
|
|
4805
|
+
used
|
|
4806
|
+
});
|
|
4807
|
+
}
|
|
3837
4808
|
/** Shared task launch (fresh tasks and auto-started follow-ups): flips to running, publishes the input, and drives the run with the per-turn thinking level (if any). Caller holds the session lock and has verified idle. */
|
|
3838
4809
|
launchTask(entry, input, thinkingLevel) {
|
|
3839
4810
|
const channel = this.deps.channels.get(entry.sessionId);
|
|
@@ -3912,15 +4883,16 @@ var SessionManager = class {
|
|
|
3912
4883
|
return entry.approvals.decide(toolCallId, decision);
|
|
3913
4884
|
}
|
|
3914
4885
|
/**
|
|
3915
|
-
* Mid-run steering: forward the
|
|
3916
|
-
* turns as a standalone `[user_steering]` user message
|
|
3917
|
-
*
|
|
3918
|
-
* Session isn't running a Task (idle / compacting / not loaded)
|
|
3919
|
-
* race window — the caller falls back to submitting a normal
|
|
4886
|
+
* Mid-run steering: forward the message to the running Session (core delivers it between
|
|
4887
|
+
* turns as a standalone `[user_steering]` user message followed by its images — no SSE
|
|
4888
|
+
* event of its own; the messages arrive through the stream the drive loop already
|
|
4889
|
+
* publishes). 409 when the Session isn't running a Task (idle / compacting / not loaded)
|
|
4890
|
+
* or the run finished in the race window — the caller falls back to submitting a normal
|
|
4891
|
+
* task, which carries the same text and images.
|
|
3920
4892
|
*/
|
|
3921
|
-
steer(sessionId,
|
|
4893
|
+
steer(sessionId, input) {
|
|
3922
4894
|
const entry = this.entries.get(sessionId);
|
|
3923
|
-
if (!entry || entry.status !== "running" || !entry.session.steer(
|
|
4895
|
+
if (!entry || entry.status !== "running" || !entry.session.steer(input)) {
|
|
3924
4896
|
throw new HttpError(
|
|
3925
4897
|
409,
|
|
3926
4898
|
"not_running",
|
|
@@ -3929,6 +4901,21 @@ var SessionManager = class {
|
|
|
3929
4901
|
}
|
|
3930
4902
|
entry.lastActivityMs = Date.now();
|
|
3931
4903
|
}
|
|
4904
|
+
/**
|
|
4905
|
+
* "Retry now" on the reconnect countdown: skip the in-progress backoff wait and fire
|
|
4906
|
+
* the next retry immediately (the attempt counter is unchanged — the skipped wait does
|
|
4907
|
+
* not consume an extra attempt). Returns false as a benign no-op when the session has
|
|
4908
|
+
* no active runtime or no reconnect wait is in progress — timing races (the wait
|
|
4909
|
+
* elapsed right before the click) must not surface as errors. Mirrors the steer seam:
|
|
4910
|
+
* manager → RuntimeSession → core Session → engine.
|
|
4911
|
+
*/
|
|
4912
|
+
retryNow(sessionId) {
|
|
4913
|
+
const entry = this.entries.get(sessionId);
|
|
4914
|
+
if (!entry || entry.status === "idle") return false;
|
|
4915
|
+
const skipped = entry.session.skipReconnectWait();
|
|
4916
|
+
if (skipped) entry.lastActivityMs = Date.now();
|
|
4917
|
+
return skipped;
|
|
4918
|
+
}
|
|
3932
4919
|
/**
|
|
3933
4920
|
* Interrupt the current Task/compaction: pending approvals converge to deny first,
|
|
3934
4921
|
* then the AbortSignal fires. Returns false if nothing is in progress (the route
|
|
@@ -4204,6 +5191,7 @@ var SessionManager = class {
|
|
|
4204
5191
|
}
|
|
4205
5192
|
}
|
|
4206
5193
|
}
|
|
5194
|
+
this.liveTail.observe(entry.sessionId, msg);
|
|
4207
5195
|
this.deps.channels.get(entry.sessionId).publish(msg);
|
|
4208
5196
|
watcher?.observe(msg);
|
|
4209
5197
|
try {
|
|
@@ -4220,6 +5208,7 @@ var SessionManager = class {
|
|
|
4220
5208
|
this.deps.errors?.record({ source: "session", err, ctx, code: "session_run_failed" });
|
|
4221
5209
|
} finally {
|
|
4222
5210
|
watcher?.close();
|
|
5211
|
+
this.liveTail.clear(entry.sessionId);
|
|
4223
5212
|
entry.approvals.denyAll();
|
|
4224
5213
|
entry.status = "idle";
|
|
4225
5214
|
entry.abort = null;
|
|
@@ -4267,7 +5256,7 @@ var SessionManager = class {
|
|
|
4267
5256
|
const childSid = msg.origin[msg.origin.length - 1];
|
|
4268
5257
|
if (children.has(childSid)) return null;
|
|
4269
5258
|
const p = msg.payload;
|
|
4270
|
-
const agentId =
|
|
5259
|
+
const agentId = path9.basename(path9.dirname(p.agent_state));
|
|
4271
5260
|
if (!agentId || agentId === "." || agentId === "..") return null;
|
|
4272
5261
|
const source = asSessionSource(p.source) ?? "subagent";
|
|
4273
5262
|
this.deps.sources.set(childSid, source);
|
|
@@ -4746,7 +5735,7 @@ var UsageRecorder = class {
|
|
|
4746
5735
|
};
|
|
4747
5736
|
|
|
4748
5737
|
// src/services/ids.ts
|
|
4749
|
-
import { randomBytes as
|
|
5738
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
4750
5739
|
var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
|
|
4751
5740
|
var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
|
|
4752
5741
|
var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
|
|
@@ -4822,7 +5811,7 @@ var AdminService = class {
|
|
|
4822
5811
|
};
|
|
4823
5812
|
|
|
4824
5813
|
// src/services/agent-config-service.ts
|
|
4825
|
-
import
|
|
5814
|
+
import fs11 from "fs/promises";
|
|
4826
5815
|
import { parseDocument, parse as parseYaml } from "yaml";
|
|
4827
5816
|
import {
|
|
4828
5817
|
agentsMdPath,
|
|
@@ -4837,8 +5826,8 @@ import {
|
|
|
4837
5826
|
} from "@prismshadow/penguin-core";
|
|
4838
5827
|
|
|
4839
5828
|
// src/services/project-config-service.ts
|
|
4840
|
-
import
|
|
4841
|
-
import
|
|
5829
|
+
import fs10 from "fs/promises";
|
|
5830
|
+
import path10 from "path";
|
|
4842
5831
|
import { parse as parseToml2 } from "smol-toml";
|
|
4843
5832
|
import {
|
|
4844
5833
|
GenerativeModel,
|
|
@@ -4890,11 +5879,24 @@ var ProjectConfigService = class {
|
|
|
4890
5879
|
filePath(projectId) {
|
|
4891
5880
|
return projectConfigPath(this.root, projectId);
|
|
4892
5881
|
}
|
|
5882
|
+
/**
|
|
5883
|
+
* When the Project config (models/credentials) last changed: the config file's mtime as
|
|
5884
|
+
* ISO — an honest persistent source that survives server restarts (every models update
|
|
5885
|
+
* rewrites the file). undefined when the file doesn't exist yet.
|
|
5886
|
+
*/
|
|
5887
|
+
async configUpdatedAt(projectId) {
|
|
5888
|
+
try {
|
|
5889
|
+
const st = await fs10.stat(this.filePath(projectId));
|
|
5890
|
+
return st.mtime.toISOString();
|
|
5891
|
+
} catch {
|
|
5892
|
+
return void 0;
|
|
5893
|
+
}
|
|
5894
|
+
}
|
|
4893
5895
|
/** Reads the raw TOML object; returns an empty object if the file doesn't exist (does not write to disk). */
|
|
4894
5896
|
async readRaw(projectId) {
|
|
4895
5897
|
let raw;
|
|
4896
5898
|
try {
|
|
4897
|
-
raw = await
|
|
5899
|
+
raw = await fs10.readFile(this.filePath(projectId), "utf8");
|
|
4898
5900
|
} catch (err) {
|
|
4899
5901
|
if (err.code === "ENOENT") return {};
|
|
4900
5902
|
throw err;
|
|
@@ -4909,9 +5911,9 @@ var ProjectConfigService = class {
|
|
|
4909
5911
|
*/
|
|
4910
5912
|
async writeRaw(projectId, data) {
|
|
4911
5913
|
const file = this.filePath(projectId);
|
|
4912
|
-
await
|
|
4913
|
-
await
|
|
4914
|
-
await
|
|
5914
|
+
await fs10.mkdir(path10.dirname(file), { recursive: true });
|
|
5915
|
+
await fs10.writeFile(file, renderProjectConfigToml(data), { encoding: "utf8", mode: 384 });
|
|
5916
|
+
await fs10.chmod(file, 384);
|
|
4915
5917
|
}
|
|
4916
5918
|
/**
|
|
4917
5919
|
* Initial config for a newly created Project: display name + preset built-in
|
|
@@ -4955,6 +5957,15 @@ var ProjectConfigService = class {
|
|
|
4955
5957
|
const raw = await this.readRaw(projectId);
|
|
4956
5958
|
return typeof raw.name === "string" ? raw.name : void 0;
|
|
4957
5959
|
}
|
|
5960
|
+
/**
|
|
5961
|
+
* Rewrites the display name, preserving every other field (models, credentials, default
|
|
5962
|
+
* model): read-modify-write of the same toml, like ensurePresetModels. The id itself is
|
|
5963
|
+
* immutable — only this label changes.
|
|
5964
|
+
*/
|
|
5965
|
+
async setName(projectId, name) {
|
|
5966
|
+
const raw = await this.readRaw(projectId);
|
|
5967
|
+
await this.writeRaw(projectId, { ...raw, name });
|
|
5968
|
+
}
|
|
4958
5969
|
/** Paired reference of the default Model; returns undefined if unconfigured (or in the old string format). */
|
|
4959
5970
|
async getDefaultModelRef(projectId) {
|
|
4960
5971
|
const raw = await this.readRaw(projectId);
|
|
@@ -5107,9 +6118,11 @@ var ProjectConfigService = class {
|
|
|
5107
6118
|
provider: ref.provider,
|
|
5108
6119
|
modelId: ref.model_id
|
|
5109
6120
|
});
|
|
6121
|
+
const updatedAt = await this.configUpdatedAt(projectId);
|
|
5110
6122
|
return {
|
|
5111
6123
|
...defaultRef !== void 0 ? { defaultModel: toDto(defaultRef) } : {},
|
|
5112
6124
|
...visionRef !== void 0 ? { visionModel: toDto(visionRef) } : {},
|
|
6125
|
+
...updatedAt !== void 0 ? { updatedAt } : {},
|
|
5113
6126
|
models
|
|
5114
6127
|
};
|
|
5115
6128
|
}
|
|
@@ -5273,7 +6286,7 @@ var AgentConfigService = class {
|
|
|
5273
6286
|
/** Whether the Agent exists (determined by the presence of system_config.yaml, matching the CLI's convention). */
|
|
5274
6287
|
async exists(projectId, agentId) {
|
|
5275
6288
|
try {
|
|
5276
|
-
await
|
|
6289
|
+
await fs11.access(systemConfigPath(this.root, projectId, agentId));
|
|
5277
6290
|
return true;
|
|
5278
6291
|
} catch {
|
|
5279
6292
|
return false;
|
|
@@ -5291,7 +6304,7 @@ var AgentConfigService = class {
|
|
|
5291
6304
|
*/
|
|
5292
6305
|
async readCardMeta(projectId, agentId) {
|
|
5293
6306
|
try {
|
|
5294
|
-
const raw = await
|
|
6307
|
+
const raw = await fs11.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
|
|
5295
6308
|
const parsed = asRecord(parseYaml(raw));
|
|
5296
6309
|
const tools = asRecord(parsed.tools);
|
|
5297
6310
|
const countOf = (v) => Array.isArray(v) ? v.length : 0;
|
|
@@ -5309,14 +6322,14 @@ var AgentConfigService = class {
|
|
|
5309
6322
|
async getConfig(projectId, agentId) {
|
|
5310
6323
|
await this.requireExists(projectId, agentId);
|
|
5311
6324
|
const yamlPath = systemConfigPath(this.root, projectId, agentId);
|
|
5312
|
-
const systemConfigYaml = await
|
|
6325
|
+
const systemConfigYaml = await fs11.readFile(yamlPath, "utf8");
|
|
5313
6326
|
const parsed = asRecord(parseYaml(systemConfigYaml));
|
|
5314
6327
|
const model = asRecord(parsed.model);
|
|
5315
6328
|
const compaction = asRecord(parsed.compaction);
|
|
5316
6329
|
const tools = asRecord(parsed.tools);
|
|
5317
6330
|
let agentsMd = "";
|
|
5318
6331
|
try {
|
|
5319
|
-
agentsMd = await
|
|
6332
|
+
agentsMd = await fs11.readFile(agentsMdPath(this.root, projectId, agentId), "utf8");
|
|
5320
6333
|
} catch {
|
|
5321
6334
|
}
|
|
5322
6335
|
const modelDto = {
|
|
@@ -5359,7 +6372,7 @@ var AgentConfigService = class {
|
|
|
5359
6372
|
await this.applyConfigUpdate(projectId, agentId, req.config);
|
|
5360
6373
|
}
|
|
5361
6374
|
if (req.agentsMd !== void 0) {
|
|
5362
|
-
await
|
|
6375
|
+
await fs11.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
|
|
5363
6376
|
}
|
|
5364
6377
|
}
|
|
5365
6378
|
/**
|
|
@@ -5377,9 +6390,9 @@ var AgentConfigService = class {
|
|
|
5377
6390
|
async applyConfigUpdate(projectId, agentId, config2) {
|
|
5378
6391
|
const cfg = config2;
|
|
5379
6392
|
const yamlPath = systemConfigPath(this.root, projectId, agentId);
|
|
5380
|
-
const doc = parseDocument(await
|
|
5381
|
-
const setIfProvided = (
|
|
5382
|
-
if (value !== void 0) doc.setIn(
|
|
6393
|
+
const doc = parseDocument(await fs11.readFile(yamlPath, "utf8"));
|
|
6394
|
+
const setIfProvided = (path18, value) => {
|
|
6395
|
+
if (value !== void 0) doc.setIn(path18, value);
|
|
5383
6396
|
};
|
|
5384
6397
|
setIfProvided(["name"], optionalString(cfg, "name", { maxLen: 100, label: "name" }));
|
|
5385
6398
|
setIfProvided(
|
|
@@ -5428,7 +6441,7 @@ var AgentConfigService = class {
|
|
|
5428
6441
|
if (cfg.mcpServers !== void 0) {
|
|
5429
6442
|
doc.setIn(["tools", "mcpServers"], validateMcpServers(cfg.mcpServers));
|
|
5430
6443
|
}
|
|
5431
|
-
await
|
|
6444
|
+
await fs11.writeFile(yamlPath, doc.toString(), "utf8");
|
|
5432
6445
|
}
|
|
5433
6446
|
/** Read the Agent vault (agent_state/.vault.toml): values are always masked, plaintext is never sent to the client. */
|
|
5434
6447
|
async getVault(projectId, agentId) {
|
|
@@ -5525,8 +6538,8 @@ function validateMcpServers(value) {
|
|
|
5525
6538
|
}
|
|
5526
6539
|
|
|
5527
6540
|
// src/services/agent-service.ts
|
|
5528
|
-
import
|
|
5529
|
-
import
|
|
6541
|
+
import fs12 from "fs/promises";
|
|
6542
|
+
import path11 from "path";
|
|
5530
6543
|
import {
|
|
5531
6544
|
agentDir,
|
|
5532
6545
|
agentsDir as agentsDir2,
|
|
@@ -5553,7 +6566,7 @@ var AgentService = class {
|
|
|
5553
6566
|
const known = new Map(this.agents.list(projectId).map((r) => [r.agentId, r]));
|
|
5554
6567
|
let entries = [];
|
|
5555
6568
|
try {
|
|
5556
|
-
const dirents = await
|
|
6569
|
+
const dirents = await fs12.readdir(agentsDir2(this.root, projectId), { withFileTypes: true });
|
|
5557
6570
|
entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
|
|
5558
6571
|
} catch {
|
|
5559
6572
|
}
|
|
@@ -5562,7 +6575,7 @@ var AgentService = class {
|
|
|
5562
6575
|
const configPath = systemConfigPath2(this.root, projectId, agentId);
|
|
5563
6576
|
let createdAt;
|
|
5564
6577
|
try {
|
|
5565
|
-
const stat = await
|
|
6578
|
+
const stat = await fs12.stat(configPath);
|
|
5566
6579
|
createdAt = (stat.birthtime.getTime() > 0 ? stat.birthtime : stat.mtime).toISOString();
|
|
5567
6580
|
} catch {
|
|
5568
6581
|
continue;
|
|
@@ -5606,7 +6619,7 @@ var AgentService = class {
|
|
|
5606
6619
|
/** Number of scheduled tasks: count of .toml files under schedule/ (0 if the directory doesn't exist). */
|
|
5607
6620
|
async scheduleCount(projectId, agentId) {
|
|
5608
6621
|
try {
|
|
5609
|
-
const names = await
|
|
6622
|
+
const names = await fs12.readdir(scheduleDir2(this.root, projectId, agentId));
|
|
5610
6623
|
return names.filter((n) => n.endsWith(".toml")).length;
|
|
5611
6624
|
} catch {
|
|
5612
6625
|
return 0;
|
|
@@ -5617,14 +6630,14 @@ var AgentService = class {
|
|
|
5617
6630
|
const base = skillsDir2(this.root, projectId, agentId);
|
|
5618
6631
|
let dirents;
|
|
5619
6632
|
try {
|
|
5620
|
-
dirents = await
|
|
6633
|
+
dirents = await fs12.readdir(base, { withFileTypes: true });
|
|
5621
6634
|
} catch {
|
|
5622
6635
|
return 0;
|
|
5623
6636
|
}
|
|
5624
6637
|
const present = await Promise.all(
|
|
5625
6638
|
dirents.filter((d) => d.isDirectory()).map(async (d) => {
|
|
5626
6639
|
try {
|
|
5627
|
-
await
|
|
6640
|
+
await fs12.access(path11.join(base, d.name, "SKILL.md"));
|
|
5628
6641
|
return true;
|
|
5629
6642
|
} catch {
|
|
5630
6643
|
return false;
|
|
@@ -5642,7 +6655,7 @@ var AgentService = class {
|
|
|
5642
6655
|
const times = await Promise.all(
|
|
5643
6656
|
paths.map(async (p) => {
|
|
5644
6657
|
try {
|
|
5645
|
-
return (await
|
|
6658
|
+
return (await fs12.stat(p)).mtime.getTime();
|
|
5646
6659
|
} catch {
|
|
5647
6660
|
return 0;
|
|
5648
6661
|
}
|
|
@@ -5667,7 +6680,7 @@ var AgentService = class {
|
|
|
5667
6680
|
"Built-in Agents (default_agent) are provisioned with the Project and cannot be deleted from the web."
|
|
5668
6681
|
);
|
|
5669
6682
|
}
|
|
5670
|
-
await
|
|
6683
|
+
await fs12.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
|
|
5671
6684
|
this.agents.delete(projectId, agentId);
|
|
5672
6685
|
}
|
|
5673
6686
|
/**
|
|
@@ -5684,7 +6697,7 @@ var AgentService = class {
|
|
|
5684
6697
|
`Agent id must be 2\u201364 characters: ${SEMANTIC_ID_RULE}.`
|
|
5685
6698
|
);
|
|
5686
6699
|
}
|
|
5687
|
-
const taken = this.agents.exists(projectId, agentId) || await
|
|
6700
|
+
const taken = this.agents.exists(projectId, agentId) || await fs12.stat(agentDir(this.root, projectId, agentId)).then(
|
|
5688
6701
|
() => true,
|
|
5689
6702
|
() => false
|
|
5690
6703
|
);
|
|
@@ -5698,7 +6711,7 @@ var AgentService = class {
|
|
|
5698
6711
|
config: { name: displayName, ...description !== void 0 ? { description } : {} }
|
|
5699
6712
|
});
|
|
5700
6713
|
} catch (err) {
|
|
5701
|
-
await
|
|
6714
|
+
await fs12.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true }).catch(() => {
|
|
5702
6715
|
});
|
|
5703
6716
|
throw err;
|
|
5704
6717
|
}
|
|
@@ -5722,120 +6735,205 @@ var AgentService = class {
|
|
|
5722
6735
|
};
|
|
5723
6736
|
|
|
5724
6737
|
// src/services/benchmark-service.ts
|
|
5725
|
-
import
|
|
5726
|
-
import
|
|
6738
|
+
import fs13 from "fs/promises";
|
|
6739
|
+
import path12 from "path";
|
|
5727
6740
|
import { parse as parseToml3 } from "smol-toml";
|
|
5728
6741
|
import { parse as parseYaml2 } from "yaml";
|
|
5729
6742
|
import { benchmarksDir } from "@prismshadow/penguin-core";
|
|
6743
|
+
var STATEMENT_TITLE_READ_BYTES = 64 * 1024;
|
|
5730
6744
|
function asRecord2(v) {
|
|
5731
6745
|
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
5732
6746
|
}
|
|
5733
6747
|
function numberOr(v) {
|
|
5734
6748
|
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
5735
6749
|
}
|
|
6750
|
+
function scoreOr(v) {
|
|
6751
|
+
const value = numberOr(v);
|
|
6752
|
+
return value !== void 0 && value >= 0 && value <= 100 ? value : void 0;
|
|
6753
|
+
}
|
|
6754
|
+
function nonNegativeOr(v) {
|
|
6755
|
+
const value = numberOr(v);
|
|
6756
|
+
return value !== void 0 && value >= 0 ? value : void 0;
|
|
6757
|
+
}
|
|
6758
|
+
function nonNegativeIntegerOr(v) {
|
|
6759
|
+
const value = nonNegativeOr(v);
|
|
6760
|
+
return value !== void 0 && Number.isInteger(value) ? value : void 0;
|
|
6761
|
+
}
|
|
6762
|
+
function nullableCostOr(v) {
|
|
6763
|
+
if (v === null) return null;
|
|
6764
|
+
return nonNegativeOr(v);
|
|
6765
|
+
}
|
|
5736
6766
|
function stringOr(v) {
|
|
5737
6767
|
return typeof v === "string" && v !== "" ? v : void 0;
|
|
5738
6768
|
}
|
|
6769
|
+
function isWithin(parent, child) {
|
|
6770
|
+
const relative = path12.relative(parent, child);
|
|
6771
|
+
return relative !== "" && !relative.startsWith("..") && !path12.isAbsolute(relative);
|
|
6772
|
+
}
|
|
6773
|
+
function statementTitle(statement, fallback) {
|
|
6774
|
+
const heading = /^#\s+(.+)$/m.exec(statement)?.[1]?.trim();
|
|
6775
|
+
return heading?.replace(/^Case\s+\d+\s*:\s*/i, "") || fallback;
|
|
6776
|
+
}
|
|
6777
|
+
async function readStatementTitle(readme, fallback) {
|
|
6778
|
+
const handle = await fs13.open(readme, "r");
|
|
6779
|
+
try {
|
|
6780
|
+
const buffer = Buffer.alloc(STATEMENT_TITLE_READ_BYTES);
|
|
6781
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
6782
|
+
return statementTitle(buffer.subarray(0, bytesRead).toString("utf8"), fallback);
|
|
6783
|
+
} finally {
|
|
6784
|
+
await handle.close();
|
|
6785
|
+
}
|
|
6786
|
+
}
|
|
5739
6787
|
function toRun(v) {
|
|
5740
6788
|
const r = asRecord2(v);
|
|
5741
|
-
const score =
|
|
5742
|
-
|
|
5743
|
-
const
|
|
5744
|
-
const durationMs = numberOr(r.duration_ms);
|
|
6789
|
+
const score = scoreOr(r.score);
|
|
6790
|
+
const cost = nullableCostOr(r.cost);
|
|
6791
|
+
const durationMs = nonNegativeIntegerOr(r.duration_ms);
|
|
5745
6792
|
const sessionId = stringOr(r.session_id);
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
...durationMs !== void 0 ? { durationMs } : {},
|
|
5750
|
-
...sessionId !== void 0 ? { sessionId } : {}
|
|
5751
|
-
};
|
|
5752
|
-
}
|
|
5753
|
-
function averageOf(runs, pick) {
|
|
5754
|
-
const values = runs.map(pick).filter((v) => v !== void 0);
|
|
5755
|
-
if (values.length === 0) return void 0;
|
|
5756
|
-
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
6793
|
+
if (score === void 0 || cost === void 0 || durationMs === void 0 || !sessionId)
|
|
6794
|
+
return null;
|
|
6795
|
+
return { score, cost, durationMs, sessionId };
|
|
5757
6796
|
}
|
|
5758
6797
|
function toCase(v) {
|
|
5759
6798
|
const cr = asRecord2(v);
|
|
5760
6799
|
const caseId = stringOr(cr.case);
|
|
5761
|
-
|
|
5762
|
-
const
|
|
5763
|
-
const
|
|
5764
|
-
if (score === void 0)
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
const
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
{
|
|
5771
|
-
score,
|
|
5772
|
-
...cost !== void 0 ? { cost } : {},
|
|
5773
|
-
...durationMs !== void 0 ? { durationMs } : {},
|
|
5774
|
-
...sessionId !== void 0 ? { sessionId } : {}
|
|
5775
|
-
}
|
|
5776
|
-
];
|
|
6800
|
+
const score = scoreOr(cr.score);
|
|
6801
|
+
const cost = nullableCostOr(cr.cost);
|
|
6802
|
+
const durationMs = nonNegativeIntegerOr(cr.duration_ms);
|
|
6803
|
+
if (!caseId || score === void 0 || cost === void 0 || durationMs === void 0 || "max_score" in cr || !Array.isArray(cr.runs) || cr.runs.length === 0) {
|
|
6804
|
+
return null;
|
|
6805
|
+
}
|
|
6806
|
+
const parsedRuns = cr.runs.map(toRun);
|
|
6807
|
+
if (parsedRuns.some((run) => run === null)) return null;
|
|
6808
|
+
const runs = parsedRuns;
|
|
5777
6809
|
return {
|
|
5778
6810
|
case: caseId,
|
|
5779
6811
|
score,
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
...sessionId !== void 0 ? { sessionId } : {},
|
|
6812
|
+
cost,
|
|
6813
|
+
durationMs,
|
|
5783
6814
|
runs
|
|
5784
6815
|
};
|
|
5785
6816
|
}
|
|
5786
6817
|
function toEvaluation(v) {
|
|
5787
6818
|
const r = asRecord2(v);
|
|
5788
6819
|
const time = r.time instanceof Date ? r.time.toISOString() : r.time;
|
|
5789
|
-
const score =
|
|
5790
|
-
|
|
5791
|
-
const
|
|
6820
|
+
const score = scoreOr(r.score);
|
|
6821
|
+
const cost = nullableCostOr(r.cost);
|
|
6822
|
+
const durationMs = nonNegativeIntegerOr(r.duration_ms);
|
|
5792
6823
|
const summary = stringOr(r.summary);
|
|
5793
6824
|
const summaryTitle = stringOr(r.summary_title);
|
|
5794
6825
|
const modelId = stringOr(r.model_id);
|
|
5795
6826
|
const provider = stringOr(r.provider);
|
|
5796
|
-
const
|
|
5797
|
-
const
|
|
5798
|
-
|
|
6827
|
+
const thinkingLevel = stringOr(r.thinking_level);
|
|
6828
|
+
const version = nonNegativeIntegerOr(r.version);
|
|
6829
|
+
if (typeof time !== "string" || time === "" || score === void 0 || cost === void 0 || durationMs === void 0 || !modelId || !provider || !thinkingLevel || version === void 0 || version < 1 || !Array.isArray(r.cases) || r.cases.length === 0) {
|
|
6830
|
+
return null;
|
|
6831
|
+
}
|
|
6832
|
+
const parsedCases = r.cases.map(toCase);
|
|
6833
|
+
if (parsedCases.some((item) => item === null)) return null;
|
|
6834
|
+
const cases = parsedCases;
|
|
5799
6835
|
return {
|
|
5800
6836
|
time,
|
|
5801
6837
|
...summaryTitle !== void 0 ? { summaryTitle } : {},
|
|
5802
6838
|
...summary !== void 0 ? { summary } : {},
|
|
5803
|
-
|
|
5804
|
-
|
|
6839
|
+
modelId,
|
|
6840
|
+
provider,
|
|
6841
|
+
thinkingLevel,
|
|
5805
6842
|
score,
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
6843
|
+
version,
|
|
6844
|
+
cost,
|
|
6845
|
+
durationMs,
|
|
5809
6846
|
cases
|
|
5810
6847
|
};
|
|
5811
6848
|
}
|
|
5812
6849
|
var BenchmarkService = class {
|
|
5813
|
-
constructor(root) {
|
|
6850
|
+
constructor(root, workspaceFiles) {
|
|
5814
6851
|
this.root = root;
|
|
6852
|
+
this.workspaceFiles = workspaceFiles;
|
|
5815
6853
|
}
|
|
5816
6854
|
root;
|
|
6855
|
+
workspaceFiles;
|
|
5817
6856
|
async list(projectId, agentId) {
|
|
5818
6857
|
const dir = benchmarksDir(this.root, projectId, agentId);
|
|
5819
6858
|
let items;
|
|
5820
6859
|
try {
|
|
5821
|
-
const entries = await
|
|
6860
|
+
const entries = await fs13.readdir(dir, { withFileTypes: true });
|
|
5822
6861
|
items = entries.map((e) => ({ name: e.name, isDir: e.isDirectory() }));
|
|
5823
6862
|
} catch {
|
|
5824
6863
|
return { benchmarks: [] };
|
|
5825
6864
|
}
|
|
5826
6865
|
const benchmarks = [];
|
|
5827
6866
|
for (const item of items.filter((i) => i.isDir).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
5828
|
-
benchmarks.push(await this.readBenchmark(
|
|
6867
|
+
benchmarks.push(await this.readBenchmark(path12.join(dir, item.name), item.name));
|
|
5829
6868
|
}
|
|
5830
6869
|
return { benchmarks };
|
|
5831
6870
|
}
|
|
6871
|
+
async listCases(projectId, agentId, benchmarkId) {
|
|
6872
|
+
const baseDir = benchmarksDir(this.root, projectId, agentId);
|
|
6873
|
+
const benchDir = path12.join(baseDir, benchmarkId);
|
|
6874
|
+
let entries;
|
|
6875
|
+
let realBaseDir;
|
|
6876
|
+
let realBenchDir;
|
|
6877
|
+
try {
|
|
6878
|
+
[entries, realBaseDir, realBenchDir] = await Promise.all([
|
|
6879
|
+
fs13.readdir(benchDir, { withFileTypes: true }),
|
|
6880
|
+
fs13.realpath(baseDir),
|
|
6881
|
+
fs13.realpath(benchDir)
|
|
6882
|
+
]);
|
|
6883
|
+
} catch {
|
|
6884
|
+
return { cases: [] };
|
|
6885
|
+
}
|
|
6886
|
+
if (!isWithin(realBaseDir, realBenchDir)) return { cases: [] };
|
|
6887
|
+
const cases = [];
|
|
6888
|
+
for (const entry of entries.filter((item) => item.isDirectory() && item.name.startsWith("CASE-")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
6889
|
+
const fallback = { id: entry.name, title: entry.name };
|
|
6890
|
+
try {
|
|
6891
|
+
const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, entry.name);
|
|
6892
|
+
const realReadme = await fs13.realpath(path12.join(statementDir, "README.md"));
|
|
6893
|
+
if (!isWithin(statementDir, realReadme)) throw new Error("README escapes Statement");
|
|
6894
|
+
cases.push({
|
|
6895
|
+
id: entry.name,
|
|
6896
|
+
title: await readStatementTitle(realReadme, entry.name)
|
|
6897
|
+
});
|
|
6898
|
+
} catch {
|
|
6899
|
+
cases.push(fallback);
|
|
6900
|
+
}
|
|
6901
|
+
}
|
|
6902
|
+
return { cases };
|
|
6903
|
+
}
|
|
6904
|
+
async listCaseFiles(projectId, agentId, benchmarkId, caseId, rel) {
|
|
6905
|
+
const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, caseId);
|
|
6906
|
+
return this.workspaceFiles.list(statementDir, rel);
|
|
6907
|
+
}
|
|
6908
|
+
async readCaseFile(projectId, agentId, benchmarkId, caseId, rel, options) {
|
|
6909
|
+
const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, caseId);
|
|
6910
|
+
return this.workspaceFiles.read(statementDir, rel, options);
|
|
6911
|
+
}
|
|
6912
|
+
async statementRoot(projectId, agentId, benchmarkId, caseId) {
|
|
6913
|
+
const benchDir = path12.join(benchmarksDir(this.root, projectId, agentId), benchmarkId);
|
|
6914
|
+
const caseDir = path12.join(benchDir, caseId);
|
|
6915
|
+
const statementDir = path12.join(caseDir, "statement");
|
|
6916
|
+
try {
|
|
6917
|
+
const [realBenchDir, realCaseDir, realStatementDir] = await Promise.all([
|
|
6918
|
+
fs13.realpath(benchDir),
|
|
6919
|
+
fs13.realpath(caseDir),
|
|
6920
|
+
fs13.realpath(statementDir)
|
|
6921
|
+
]);
|
|
6922
|
+
if (!isWithin(realBenchDir, realCaseDir) || path12.dirname(realStatementDir) !== realCaseDir || path12.basename(realStatementDir) !== "statement") {
|
|
6923
|
+
throw new Error("Statement path is not canonical");
|
|
6924
|
+
}
|
|
6925
|
+
return realStatementDir;
|
|
6926
|
+
} catch {
|
|
6927
|
+
throw new HttpError(404, "not_found", "Public Case Statement does not exist.");
|
|
6928
|
+
}
|
|
6929
|
+
}
|
|
5832
6930
|
async readBenchmark(benchDir, id) {
|
|
5833
6931
|
let title = id;
|
|
5834
6932
|
let description;
|
|
5835
6933
|
let runs;
|
|
5836
6934
|
try {
|
|
5837
6935
|
const config2 = asRecord2(
|
|
5838
|
-
parseToml3(await
|
|
6936
|
+
parseToml3(await fs13.readFile(path12.join(benchDir, "benchmark_config.toml"), "utf8"))
|
|
5839
6937
|
);
|
|
5840
6938
|
if (typeof config2.title === "string" && config2.title !== "") title = config2.title;
|
|
5841
6939
|
if (typeof config2.description === "string" && config2.description !== "") {
|
|
@@ -5850,7 +6948,7 @@ var BenchmarkService = class {
|
|
|
5850
6948
|
let evaluations = [];
|
|
5851
6949
|
try {
|
|
5852
6950
|
const scoreboard = asRecord2(
|
|
5853
|
-
parseYaml2(await
|
|
6951
|
+
parseYaml2(await fs13.readFile(path12.join(benchDir, "scoreboard.yaml"), "utf8"))
|
|
5854
6952
|
);
|
|
5855
6953
|
if (Array.isArray(scoreboard.evaluations)) {
|
|
5856
6954
|
evaluations = scoreboard.evaluations.map(toEvaluation).filter((e) => e !== null);
|
|
@@ -5859,8 +6957,8 @@ var BenchmarkService = class {
|
|
|
5859
6957
|
}
|
|
5860
6958
|
let caseCount = 0;
|
|
5861
6959
|
try {
|
|
5862
|
-
const entries = await
|
|
5863
|
-
caseCount = entries.filter((e) => e.isDirectory()).length;
|
|
6960
|
+
const entries = await fs13.readdir(benchDir, { withFileTypes: true });
|
|
6961
|
+
caseCount = entries.filter((e) => e.isDirectory() && e.name.startsWith("CASE-")).length;
|
|
5864
6962
|
} catch {
|
|
5865
6963
|
}
|
|
5866
6964
|
return {
|
|
@@ -5875,9 +6973,9 @@ var BenchmarkService = class {
|
|
|
5875
6973
|
};
|
|
5876
6974
|
|
|
5877
6975
|
// src/services/snapshot-service.ts
|
|
5878
|
-
import { randomBytes as
|
|
5879
|
-
import
|
|
5880
|
-
import
|
|
6976
|
+
import { randomBytes as randomBytes6 } from "crypto";
|
|
6977
|
+
import fs14 from "fs/promises";
|
|
6978
|
+
import path13 from "path";
|
|
5881
6979
|
import * as tar from "tar";
|
|
5882
6980
|
import { parse as parseYaml3 } from "yaml";
|
|
5883
6981
|
import {
|
|
@@ -5890,7 +6988,7 @@ import {
|
|
|
5890
6988
|
} from "@prismshadow/penguin-core";
|
|
5891
6989
|
var VAULT_BASENAME = ".vault.toml";
|
|
5892
6990
|
function isVaultEntry(entryPath) {
|
|
5893
|
-
return
|
|
6991
|
+
return path13.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
|
|
5894
6992
|
}
|
|
5895
6993
|
var SnapshotService = class {
|
|
5896
6994
|
constructor(root) {
|
|
@@ -5901,7 +6999,7 @@ var SnapshotService = class {
|
|
|
5901
6999
|
async currentVersion(projectId, agentId) {
|
|
5902
7000
|
let raw;
|
|
5903
7001
|
try {
|
|
5904
|
-
raw = await
|
|
7002
|
+
raw = await fs14.readFile(systemConfigPath3(this.root, projectId, agentId), "utf8");
|
|
5905
7003
|
} catch {
|
|
5906
7004
|
throw new HttpError(404, "agent_not_found", "Agent does not exist.");
|
|
5907
7005
|
}
|
|
@@ -5914,14 +7012,14 @@ var SnapshotService = class {
|
|
|
5914
7012
|
async ensureSnapshot(projectId, agentId) {
|
|
5915
7013
|
const version = await this.currentVersion(projectId, agentId);
|
|
5916
7014
|
const dir = snapshotsDir(this.root, projectId, agentId);
|
|
5917
|
-
const file =
|
|
7015
|
+
const file = path13.join(dir, `v${version}.tar.gz`);
|
|
5918
7016
|
try {
|
|
5919
|
-
await
|
|
7017
|
+
await fs14.access(file);
|
|
5920
7018
|
return { version, file };
|
|
5921
7019
|
} catch {
|
|
5922
7020
|
}
|
|
5923
|
-
await
|
|
5924
|
-
const tmp = `${file}.tmp-${
|
|
7021
|
+
await fs14.mkdir(dir, { recursive: true });
|
|
7022
|
+
const tmp = `${file}.tmp-${randomBytes6(4).toString("hex")}`;
|
|
5925
7023
|
await tar.create(
|
|
5926
7024
|
{
|
|
5927
7025
|
gzip: true,
|
|
@@ -5932,7 +7030,7 @@ var SnapshotService = class {
|
|
|
5932
7030
|
},
|
|
5933
7031
|
["agent_state"]
|
|
5934
7032
|
);
|
|
5935
|
-
await
|
|
7033
|
+
await fs14.rename(tmp, file);
|
|
5936
7034
|
return { version, file };
|
|
5937
7035
|
}
|
|
5938
7036
|
/** Export: automatically packs a snapshot first if none exists, returns the info needed for download. */
|
|
@@ -5949,13 +7047,13 @@ var SnapshotService = class {
|
|
|
5949
7047
|
async importArchive(projectId, agentId, archive, confirm) {
|
|
5950
7048
|
const current = await this.currentVersion(projectId, agentId);
|
|
5951
7049
|
const base = agentDir2(this.root, projectId, agentId);
|
|
5952
|
-
const staging =
|
|
5953
|
-
await
|
|
7050
|
+
const staging = path13.join(base, `.import-${randomBytes6(6).toString("hex")}`);
|
|
7051
|
+
await fs14.mkdir(staging, { recursive: true });
|
|
5954
7052
|
try {
|
|
5955
|
-
const archiveFile =
|
|
5956
|
-
await
|
|
5957
|
-
const extractDir =
|
|
5958
|
-
await
|
|
7053
|
+
const archiveFile = path13.join(staging, "archive.tar.gz");
|
|
7054
|
+
await fs14.writeFile(archiveFile, archive);
|
|
7055
|
+
const extractDir = path13.join(staging, "extracted");
|
|
7056
|
+
await fs14.mkdir(extractDir, { recursive: true });
|
|
5959
7057
|
try {
|
|
5960
7058
|
await tar.extract({
|
|
5961
7059
|
file: archiveFile,
|
|
@@ -5965,10 +7063,10 @@ var SnapshotService = class {
|
|
|
5965
7063
|
} catch {
|
|
5966
7064
|
throw badRequest("Import failed: not a valid tar.gz snapshot package.");
|
|
5967
7065
|
}
|
|
5968
|
-
const configPath =
|
|
7066
|
+
const configPath = path13.join(extractDir, "agent_state", "system_config.yaml");
|
|
5969
7067
|
let parsed;
|
|
5970
7068
|
try {
|
|
5971
|
-
parsed = parseYaml3(await
|
|
7069
|
+
parsed = parseYaml3(await fs14.readFile(configPath, "utf8"));
|
|
5972
7070
|
} catch {
|
|
5973
7071
|
throw badRequest("Import failed: the package is missing agent_state/system_config.yaml.");
|
|
5974
7072
|
}
|
|
@@ -5991,37 +7089,37 @@ var SnapshotService = class {
|
|
|
5991
7089
|
}
|
|
5992
7090
|
await this.ensureSnapshot(projectId, agentId);
|
|
5993
7091
|
const stateDir = agentStateDir2(this.root, projectId, agentId);
|
|
5994
|
-
const incomingState =
|
|
7092
|
+
const incomingState = path13.join(extractDir, "agent_state");
|
|
5995
7093
|
let vault = null;
|
|
5996
7094
|
try {
|
|
5997
|
-
vault = await
|
|
7095
|
+
vault = await fs14.readFile(agentVaultPath(this.root, projectId, agentId));
|
|
5998
7096
|
} catch {
|
|
5999
7097
|
}
|
|
6000
7098
|
if (vault !== null) {
|
|
6001
|
-
await
|
|
7099
|
+
await fs14.writeFile(path13.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
|
|
6002
7100
|
}
|
|
6003
|
-
const trash =
|
|
6004
|
-
await
|
|
7101
|
+
const trash = path13.join(staging, "replaced-agent_state");
|
|
7102
|
+
await fs14.rename(stateDir, trash);
|
|
6005
7103
|
try {
|
|
6006
|
-
await
|
|
7104
|
+
await fs14.rename(incomingState, stateDir);
|
|
6007
7105
|
} catch (err) {
|
|
6008
|
-
await
|
|
7106
|
+
await fs14.rename(trash, stateDir);
|
|
6009
7107
|
throw err;
|
|
6010
7108
|
}
|
|
6011
7109
|
return { version: incoming };
|
|
6012
7110
|
} finally {
|
|
6013
|
-
await
|
|
7111
|
+
await fs14.rm(staging, { recursive: true, force: true });
|
|
6014
7112
|
}
|
|
6015
7113
|
}
|
|
6016
7114
|
};
|
|
6017
7115
|
|
|
6018
7116
|
// src/services/project-service.ts
|
|
6019
|
-
import
|
|
7117
|
+
import fs15 from "fs/promises";
|
|
6020
7118
|
import { DEFAULT_PROJECT_ID, projectDir, provisionProjectAgents } from "@prismshadow/penguin-core";
|
|
6021
7119
|
var ABORT_SETTLE_TIMEOUT_MS = 5e3;
|
|
6022
|
-
async function dirExists(
|
|
7120
|
+
async function dirExists(path18) {
|
|
6023
7121
|
try {
|
|
6024
|
-
const stat = await
|
|
7122
|
+
const stat = await fs15.stat(path18);
|
|
6025
7123
|
return stat.isDirectory();
|
|
6026
7124
|
} catch {
|
|
6027
7125
|
return false;
|
|
@@ -6146,12 +7244,12 @@ var ProjectService = class {
|
|
|
6146
7244
|
throw err;
|
|
6147
7245
|
}
|
|
6148
7246
|
try {
|
|
6149
|
-
await
|
|
7247
|
+
await fs15.mkdir(projectDir(this.deps.root, projectId), { recursive: true });
|
|
6150
7248
|
await this.deps.projectConfig.writeInitialConfig(projectId, displayName);
|
|
6151
7249
|
await this.provisionBuiltinAgents(projectId);
|
|
6152
7250
|
} catch (err) {
|
|
6153
7251
|
this.deps.projects.delete(projectId);
|
|
6154
|
-
await
|
|
7252
|
+
await fs15.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true }).catch(() => {
|
|
6155
7253
|
});
|
|
6156
7254
|
throw err;
|
|
6157
7255
|
}
|
|
@@ -6184,6 +7282,23 @@ var ProjectService = class {
|
|
|
6184
7282
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6185
7283
|
});
|
|
6186
7284
|
}
|
|
7285
|
+
/**
|
|
7286
|
+
* Rename a Project's display name (owner): the id stays immutable — it names the directory,
|
|
7287
|
+
* the Workspace paths and every stored reference — so only the label in project_config.toml
|
|
7288
|
+
* changes. Returns the refreshed summary so the caller can swap it into its list without a
|
|
7289
|
+
* reload.
|
|
7290
|
+
*/
|
|
7291
|
+
async renameProject(userId, projectId, name) {
|
|
7292
|
+
const row = this.requireProjectOwner(userId, projectId);
|
|
7293
|
+
await this.deps.projectConfig.setName(projectId, name);
|
|
7294
|
+
return {
|
|
7295
|
+
projectId,
|
|
7296
|
+
name,
|
|
7297
|
+
role: "owner",
|
|
7298
|
+
ownerUserId: row.ownerUserId,
|
|
7299
|
+
createdAt: row.createdAt
|
|
7300
|
+
};
|
|
7301
|
+
}
|
|
6187
7302
|
/**
|
|
6188
7303
|
* Delete a Project (owner): default_project is refused; deleting the user's
|
|
6189
7304
|
* **last accessible Project** is refused too (deleting it would leave the list
|
|
@@ -6231,7 +7346,8 @@ var ProjectService = class {
|
|
|
6231
7346
|
this.deps.usage.deleteByProject(projectId);
|
|
6232
7347
|
this.deps.errors.deleteByProject(projectId);
|
|
6233
7348
|
this.deps.schedules.deleteByProject(projectId);
|
|
6234
|
-
|
|
7349
|
+
this.deps.goals.deleteByProject(projectId);
|
|
7350
|
+
await fs15.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
|
|
6235
7351
|
}
|
|
6236
7352
|
// —— Member authorization ——
|
|
6237
7353
|
/** Member list: owner (role=owner) + members. */
|
|
@@ -6302,7 +7418,7 @@ var ProjectService = class {
|
|
|
6302
7418
|
};
|
|
6303
7419
|
|
|
6304
7420
|
// src/services/session-service.ts
|
|
6305
|
-
import
|
|
7421
|
+
import path14 from "path";
|
|
6306
7422
|
import { open, readdir as readdir2 } from "fs/promises";
|
|
6307
7423
|
import {
|
|
6308
7424
|
createAgent as createAgent2,
|
|
@@ -6484,7 +7600,7 @@ var SessionService = class {
|
|
|
6484
7600
|
};
|
|
6485
7601
|
const dir = tracesDir2(this.deps.root, projectId, agentId);
|
|
6486
7602
|
for (const dateDir of await listDirsSafe(dir)) {
|
|
6487
|
-
for (const file of await listFilesSafe(
|
|
7603
|
+
for (const file of await listFilesSafe(path14.join(dir, dateDir))) {
|
|
6488
7604
|
const match = TRACE_FILE_RE.exec(file);
|
|
6489
7605
|
if (match) mark(dateDir, match[1]);
|
|
6490
7606
|
}
|
|
@@ -6600,14 +7716,14 @@ var SessionService = class {
|
|
|
6600
7716
|
const dir = tracesDir2(this.deps.root, projectId, agentId);
|
|
6601
7717
|
const out = /* @__PURE__ */ new Map();
|
|
6602
7718
|
for (const dateDir of await listDirsSafe(dir)) {
|
|
6603
|
-
for (const file of await listFilesSafe(
|
|
7719
|
+
for (const file of await listFilesSafe(path14.join(dir, dateDir))) {
|
|
6604
7720
|
const match = TRACE_FILE_RE.exec(file);
|
|
6605
7721
|
if (!match) continue;
|
|
6606
7722
|
const sessionId = match[1];
|
|
6607
7723
|
const index = Number(match[2]);
|
|
6608
7724
|
const cur = out.get(sessionId);
|
|
6609
7725
|
if (!cur || index < cur.index) {
|
|
6610
|
-
out.set(sessionId, { path:
|
|
7726
|
+
out.set(sessionId, { path: path14.join(dir, dateDir, file), index });
|
|
6611
7727
|
}
|
|
6612
7728
|
}
|
|
6613
7729
|
}
|
|
@@ -6670,15 +7786,18 @@ async function listFilesSafe(dir) {
|
|
|
6670
7786
|
}
|
|
6671
7787
|
|
|
6672
7788
|
// src/services/trace-service.ts
|
|
6673
|
-
import
|
|
6674
|
-
import
|
|
7789
|
+
import fs16 from "fs/promises";
|
|
7790
|
+
import path15 from "path";
|
|
6675
7791
|
import {
|
|
6676
7792
|
agentsDir as agentsDir3,
|
|
7793
|
+
isSessionMeta as isSessionMeta5,
|
|
7794
|
+
parseTraceLines as parseTraceLines2,
|
|
6677
7795
|
parseUserSteeringText,
|
|
6678
7796
|
readTraceTolerant as readTraceTolerant2,
|
|
6679
7797
|
tracesDir as tracesDir3
|
|
6680
7798
|
} from "@prismshadow/penguin-core";
|
|
6681
7799
|
var TRACE_FILE_RE2 = /^(.+)_(\d{3})\.jsonl$/;
|
|
7800
|
+
var IMPORT_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
6682
7801
|
var MAX_SUBAGENT_DEPTH = 4;
|
|
6683
7802
|
function subagentPointer(msg) {
|
|
6684
7803
|
if (msg.type !== "event_msg") return null;
|
|
@@ -6690,7 +7809,7 @@ function subagentPointer(msg) {
|
|
|
6690
7809
|
}
|
|
6691
7810
|
async function listDirs(dir) {
|
|
6692
7811
|
try {
|
|
6693
|
-
const entries = await
|
|
7812
|
+
const entries = await fs16.readdir(dir, { withFileTypes: true });
|
|
6694
7813
|
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
6695
7814
|
} catch {
|
|
6696
7815
|
return [];
|
|
@@ -6698,7 +7817,7 @@ async function listDirs(dir) {
|
|
|
6698
7817
|
}
|
|
6699
7818
|
async function listFiles(dir) {
|
|
6700
7819
|
try {
|
|
6701
|
-
const entries = await
|
|
7820
|
+
const entries = await fs16.readdir(dir, { withFileTypes: true });
|
|
6702
7821
|
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
6703
7822
|
} catch {
|
|
6704
7823
|
return [];
|
|
@@ -6714,10 +7833,10 @@ var TraceService = class {
|
|
|
6714
7833
|
const dir = tracesDir3(this.root, projectId, agentId);
|
|
6715
7834
|
const out = [];
|
|
6716
7835
|
for (const dateDir of await listDirs(dir)) {
|
|
6717
|
-
for (const file of await listFiles(
|
|
7836
|
+
for (const file of await listFiles(path15.join(dir, dateDir))) {
|
|
6718
7837
|
const match = TRACE_FILE_RE2.exec(file);
|
|
6719
7838
|
if (!match || match[1] !== sessionId) continue;
|
|
6720
|
-
out.push({ path:
|
|
7839
|
+
out.push({ path: path15.join(dir, dateDir, file), date: dateDir, index: Number(match[2]) });
|
|
6721
7840
|
}
|
|
6722
7841
|
}
|
|
6723
7842
|
return out.sort((a, b) => a.index - b.index);
|
|
@@ -6726,7 +7845,7 @@ var TraceService = class {
|
|
|
6726
7845
|
async deleteSessionTraces(projectId, agentId, sessionId) {
|
|
6727
7846
|
const files = await this.locateAll(projectId, agentId, sessionId);
|
|
6728
7847
|
for (const file of files) {
|
|
6729
|
-
await
|
|
7848
|
+
await fs16.rm(file.path, { force: true });
|
|
6730
7849
|
}
|
|
6731
7850
|
}
|
|
6732
7851
|
/**
|
|
@@ -6764,7 +7883,7 @@ var TraceService = class {
|
|
|
6764
7883
|
for (const agentId of await listDirs(agentsDir3(this.root, projectId))) {
|
|
6765
7884
|
const dir = tracesDir3(this.root, projectId, agentId);
|
|
6766
7885
|
for (const dateDir of await listDirs(dir)) {
|
|
6767
|
-
for (const file of await listFiles(
|
|
7886
|
+
for (const file of await listFiles(path15.join(dir, dateDir))) {
|
|
6768
7887
|
const match = TRACE_FILE_RE2.exec(file);
|
|
6769
7888
|
if (match && !index.has(match[1])) index.set(match[1], agentId);
|
|
6770
7889
|
}
|
|
@@ -6807,7 +7926,7 @@ var TraceService = class {
|
|
|
6807
7926
|
const files = await this.locateAll(projectId, agentId, sessionId);
|
|
6808
7927
|
const out = [];
|
|
6809
7928
|
for (const file of files) {
|
|
6810
|
-
const stat = await
|
|
7929
|
+
const stat = await fs16.stat(file.path);
|
|
6811
7930
|
out.push({
|
|
6812
7931
|
index: file.index,
|
|
6813
7932
|
date: file.date,
|
|
@@ -6843,6 +7962,7 @@ var TraceService = class {
|
|
|
6843
7962
|
let prevSerialTs = null;
|
|
6844
7963
|
let taskIndex = -1;
|
|
6845
7964
|
let continuation = false;
|
|
7965
|
+
let steeringImages = false;
|
|
6846
7966
|
let sawToolCallThisRequest = false;
|
|
6847
7967
|
let compactionActive = false;
|
|
6848
7968
|
const taskStats = /* @__PURE__ */ new Map();
|
|
@@ -6870,7 +7990,10 @@ var TraceService = class {
|
|
|
6870
7990
|
const hasOrigin = msg.origin !== void 0 && msg.origin.length > 0;
|
|
6871
7991
|
const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
|
|
6872
7992
|
if (isSteeringText) continuation = true;
|
|
6873
|
-
const
|
|
7993
|
+
const isImage = !hasOrigin && msg.type === "model_msg" && p.type === "image_url";
|
|
7994
|
+
if (!hasOrigin && !isSteeringText && !(isImage && steeringImages)) steeringImages = false;
|
|
7995
|
+
if (isSteeringText) steeringImages = true;
|
|
7996
|
+
const startsUserTurn = !hasOrigin && !compactionActive && !isSteeringText && !steeringImages && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
|
|
6874
7997
|
const startsCompactionTurn = !hasOrigin && msg.type === "event_msg" && p.type === "compaction_begin";
|
|
6875
7998
|
if (startsUserTurn || startsCompactionTurn) {
|
|
6876
7999
|
if (pendingFrom === null) pendingFrom = mi;
|
|
@@ -6915,7 +8038,7 @@ var TraceService = class {
|
|
|
6915
8038
|
}
|
|
6916
8039
|
} else if (p.type === "request_end") {
|
|
6917
8040
|
const status = typeof p.status === "string" ? p.status : void 0;
|
|
6918
|
-
const retryable = status === "timeout" || status === "malformed";
|
|
8041
|
+
const retryable = status === "timeout" || status === "malformed" || status === "failed" && !compactionActive;
|
|
6919
8042
|
if (!hasOrigin) {
|
|
6920
8043
|
prevSerialTs = null;
|
|
6921
8044
|
continuation = sawToolCallThisRequest || retryable;
|
|
@@ -7066,11 +8189,11 @@ var TraceService = class {
|
|
|
7066
8189
|
const out = { dates: [] };
|
|
7067
8190
|
for (const date of dates) {
|
|
7068
8191
|
const bySession = /* @__PURE__ */ new Map();
|
|
7069
|
-
for (const file of await listFiles(
|
|
8192
|
+
for (const file of await listFiles(path15.join(dir, date))) {
|
|
7070
8193
|
const match = TRACE_FILE_RE2.exec(file);
|
|
7071
8194
|
if (!match) continue;
|
|
7072
8195
|
const sessionId = match[1];
|
|
7073
|
-
const stat = await
|
|
8196
|
+
const stat = await fs16.stat(path15.join(dir, date, file));
|
|
7074
8197
|
const files = bySession.get(sessionId) ?? [];
|
|
7075
8198
|
files.push({ index: Number(match[2]), sizeBytes: stat.size });
|
|
7076
8199
|
bySession.set(sessionId, files);
|
|
@@ -7088,6 +8211,10 @@ var TraceService = class {
|
|
|
7088
8211
|
return out;
|
|
7089
8212
|
}
|
|
7090
8213
|
async readFileByIndex(projectId, agentId, sessionId, index) {
|
|
8214
|
+
const file = await this.locateByIndex(projectId, agentId, sessionId, index);
|
|
8215
|
+
return readTraceTolerant2(file.path);
|
|
8216
|
+
}
|
|
8217
|
+
async locateByIndex(projectId, agentId, sessionId, index) {
|
|
7091
8218
|
const files = await this.locateAll(projectId, agentId, sessionId);
|
|
7092
8219
|
const file = files.find((f) => f.index === index);
|
|
7093
8220
|
if (!file) {
|
|
@@ -7097,7 +8224,165 @@ var TraceService = class {
|
|
|
7097
8224
|
`This Session has no Trace file with index ${index}.`
|
|
7098
8225
|
);
|
|
7099
8226
|
}
|
|
7100
|
-
return
|
|
8227
|
+
return file;
|
|
8228
|
+
}
|
|
8229
|
+
/** Raw bytes of the Trace file at the given index (export/download: the file is served verbatim). */
|
|
8230
|
+
async readFileRaw(projectId, agentId, sessionId, index) {
|
|
8231
|
+
const file = await this.locateByIndex(projectId, agentId, sessionId, index);
|
|
8232
|
+
return fs16.readFile(file.path);
|
|
8233
|
+
}
|
|
8234
|
+
/**
|
|
8235
|
+
* Imports an uploaded Trace file (raw JSONL content). Validates the content itself —
|
|
8236
|
+
* parseable JSONL whose first record is a `session_meta` carrying a filename-safe
|
|
8237
|
+
* `session_id` (400 invalid_trace otherwise) — then writes it under the Agent's traces
|
|
8238
|
+
* directory as a **new Session**: a session id the Agent already has is rejected with
|
|
8239
|
+
* 409 `trace_session_exists` (splicing a further index into an existing Session would
|
|
8240
|
+
* corrupt its concatenated transcript, silently become its resume point, and could
|
|
8241
|
+
* collide with a live Writer's rotation), so the imported file is always index 1. The
|
|
8242
|
+
* date dir comes from the first record's timestamp (falling back to now), formatted as
|
|
8243
|
+
* a **local** date — the same convention as core's Trace Writer — so an export →
|
|
8244
|
+
* import round-trip lands in the same date dir on non-UTC servers.
|
|
8245
|
+
*/
|
|
8246
|
+
async importTraceFile(projectId, agentId, content) {
|
|
8247
|
+
const invalid = (message) => new HttpError(400, "invalid_trace", message);
|
|
8248
|
+
let records;
|
|
8249
|
+
try {
|
|
8250
|
+
records = parseTraceLines2(content);
|
|
8251
|
+
} catch {
|
|
8252
|
+
throw invalid("The file is not valid Trace JSONL.");
|
|
8253
|
+
}
|
|
8254
|
+
if (records.length === 0) throw invalid("The file contains no Trace records.");
|
|
8255
|
+
const first = records[0];
|
|
8256
|
+
if (!isSessionMeta5(first))
|
|
8257
|
+
throw invalid("The first record of a Trace file must be session_meta.");
|
|
8258
|
+
const sessionId = first.payload.session_id;
|
|
8259
|
+
if (typeof sessionId !== "string" || !IMPORT_SESSION_ID_RE.test(sessionId)) {
|
|
8260
|
+
throw invalid("session_meta carries a missing or invalid session_id.");
|
|
8261
|
+
}
|
|
8262
|
+
const duplicate = () => new HttpError(
|
|
8263
|
+
409,
|
|
8264
|
+
"trace_session_exists",
|
|
8265
|
+
`This Agent already has a Session with id ${sessionId}; a duplicate Trace cannot be imported.`
|
|
8266
|
+
);
|
|
8267
|
+
if ((await this.locateAll(projectId, agentId, sessionId)).length > 0) throw duplicate();
|
|
8268
|
+
const ts = Date.parse(first.timestamp);
|
|
8269
|
+
const date = formatLocalDate(Number.isNaN(ts) ? /* @__PURE__ */ new Date() : new Date(ts));
|
|
8270
|
+
const index = 1;
|
|
8271
|
+
const dir = path15.join(tracesDir3(this.root, projectId, agentId), date);
|
|
8272
|
+
await fs16.mkdir(dir, { recursive: true });
|
|
8273
|
+
const file = path15.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
|
|
8274
|
+
try {
|
|
8275
|
+
await fs16.writeFile(file, content.replace(/\n+$/, "") + "\n", {
|
|
8276
|
+
encoding: "utf8",
|
|
8277
|
+
flag: "wx"
|
|
8278
|
+
});
|
|
8279
|
+
} catch (err) {
|
|
8280
|
+
if (err.code === "EEXIST") throw duplicate();
|
|
8281
|
+
throw err;
|
|
8282
|
+
}
|
|
8283
|
+
return { sessionId, index, date };
|
|
8284
|
+
}
|
|
8285
|
+
};
|
|
8286
|
+
|
|
8287
|
+
// src/services/update-check-service.ts
|
|
8288
|
+
import { BUILD_DATE as BUILD_DATE2, VERSION as VERSION2, compareVersions, normalizeVersion } from "@prismshadow/penguin-core";
|
|
8289
|
+
var REPO_SLUG = "Prism-Shadow/penguin-harness";
|
|
8290
|
+
var LATEST_RELEASE_API = `https://api.github.com/repos/${REPO_SLUG}/releases/latest`;
|
|
8291
|
+
var SUCCESS_TTL_MS = 60 * 60 * 1e3;
|
|
8292
|
+
var FAILURE_TTL_MS = 10 * 60 * 1e3;
|
|
8293
|
+
var UpdateCheckService = class {
|
|
8294
|
+
fetchImpl;
|
|
8295
|
+
now;
|
|
8296
|
+
env;
|
|
8297
|
+
cached = null;
|
|
8298
|
+
inflight = null;
|
|
8299
|
+
constructor(options = {}) {
|
|
8300
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
8301
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
8302
|
+
this.env = options.env ?? process.env;
|
|
8303
|
+
}
|
|
8304
|
+
/**
|
|
8305
|
+
* Never throws; never makes a network call when disabled or (without `force`) while
|
|
8306
|
+
* the cache is fresh. `force` — the web's manual "check for updates" action — skips
|
|
8307
|
+
* the freshness check and performs a real lookup even over a warm cache: the cache
|
|
8308
|
+
* exists to shield GitHub's unauthenticated rate limit from *passive* checks fired
|
|
8309
|
+
* by every sidebar open, and an explicit user click is rare enough to press through
|
|
8310
|
+
* it. Everything else is unchanged: the opt-out stays authoritative (force never
|
|
8311
|
+
* dials out under PENGUIN_UPDATE_CHECK=off), the outcome lands in the same cache
|
|
8312
|
+
* (subsequent passive checks reuse it), and concurrent callers — forced or not —
|
|
8313
|
+
* still share one in-flight lookup.
|
|
8314
|
+
*/
|
|
8315
|
+
async check(force = false) {
|
|
8316
|
+
if (this.env["PENGUIN_UPDATE_CHECK"] === "off") {
|
|
8317
|
+
return {
|
|
8318
|
+
currentVersion: VERSION2,
|
|
8319
|
+
buildDate: BUILD_DATE2,
|
|
8320
|
+
latestVersion: null,
|
|
8321
|
+
updateAvailable: false,
|
|
8322
|
+
releaseUrl: null,
|
|
8323
|
+
publishedAt: null,
|
|
8324
|
+
checkedAt: this.now().toISOString(),
|
|
8325
|
+
disabled: true
|
|
8326
|
+
};
|
|
8327
|
+
}
|
|
8328
|
+
if (!force && this.cached && this.now().getTime() < this.cached.expiresAt) {
|
|
8329
|
+
return this.cached.response;
|
|
8330
|
+
}
|
|
8331
|
+
this.inflight ??= this.lookup().finally(() => {
|
|
8332
|
+
this.inflight = null;
|
|
8333
|
+
});
|
|
8334
|
+
return this.inflight;
|
|
8335
|
+
}
|
|
8336
|
+
/** One real lookup; stores the outcome (success or failure) in the cache with its TTL. */
|
|
8337
|
+
async lookup() {
|
|
8338
|
+
const response = await this.resolveLatest();
|
|
8339
|
+
const ttl = response.error === void 0 ? SUCCESS_TTL_MS : FAILURE_TTL_MS;
|
|
8340
|
+
this.cached = { response, expiresAt: this.now().getTime() + ttl };
|
|
8341
|
+
return response;
|
|
8342
|
+
}
|
|
8343
|
+
/**
|
|
8344
|
+
* Failure taxonomy mirrors the CLI's fetchLatestVersion: an unreachable endpoint or
|
|
8345
|
+
* timeout is "network"; a 403/429 is "rate_limited" (unauthenticated clients hit
|
|
8346
|
+
* GitHub's per-IP limit); any other non-2xx status, an unparsable body, or a body
|
|
8347
|
+
* without a usable tag_name is "bad_response".
|
|
8348
|
+
*/
|
|
8349
|
+
async resolveLatest() {
|
|
8350
|
+
const checkedAt = this.now().toISOString();
|
|
8351
|
+
const base = { currentVersion: VERSION2, buildDate: BUILD_DATE2, checkedAt };
|
|
8352
|
+
const failure = (error) => ({
|
|
8353
|
+
...base,
|
|
8354
|
+
latestVersion: null,
|
|
8355
|
+
updateAvailable: false,
|
|
8356
|
+
releaseUrl: null,
|
|
8357
|
+
publishedAt: null,
|
|
8358
|
+
error
|
|
8359
|
+
});
|
|
8360
|
+
let res;
|
|
8361
|
+
try {
|
|
8362
|
+
res = await this.fetchImpl(LATEST_RELEASE_API, {
|
|
8363
|
+
headers: { accept: "application/vnd.github+json", "user-agent": "penguin-server" },
|
|
8364
|
+
signal: AbortSignal.timeout(15e3)
|
|
8365
|
+
});
|
|
8366
|
+
} catch {
|
|
8367
|
+
return failure("network");
|
|
8368
|
+
}
|
|
8369
|
+
if (res.status === 403 || res.status === 429) return failure("rate_limited");
|
|
8370
|
+
if (!res.ok) return failure("bad_response");
|
|
8371
|
+
let body;
|
|
8372
|
+
try {
|
|
8373
|
+
body = await res.json();
|
|
8374
|
+
} catch {
|
|
8375
|
+
return failure("bad_response");
|
|
8376
|
+
}
|
|
8377
|
+
const tag = typeof body.tag_name === "string" ? normalizeVersion(body.tag_name) : "";
|
|
8378
|
+
if (tag === "") return failure("bad_response");
|
|
8379
|
+
return {
|
|
8380
|
+
...base,
|
|
8381
|
+
latestVersion: tag,
|
|
8382
|
+
updateAvailable: compareVersions(tag, VERSION2) > 0,
|
|
8383
|
+
releaseUrl: typeof body.html_url === "string" ? body.html_url : null,
|
|
8384
|
+
publishedAt: typeof body.published_at === "string" ? body.published_at : null
|
|
8385
|
+
};
|
|
7101
8386
|
}
|
|
7102
8387
|
};
|
|
7103
8388
|
|
|
@@ -7186,6 +8471,33 @@ var UsageService = class {
|
|
|
7186
8471
|
models: this.usage.distinctModels(projectId)
|
|
7187
8472
|
};
|
|
7188
8473
|
}
|
|
8474
|
+
/**
|
|
8475
|
+
* One page of the error detail table, newest first. The dashboard's own response already
|
|
8476
|
+
* carries the first page (`errors.recent`); this serves the "show me earlier ones" paging,
|
|
8477
|
+
* where refetching the whole aggregate to move one page would be wasteful. `total` is the
|
|
8478
|
+
* filtered row count, so the caller knows when it has reached the end.
|
|
8479
|
+
*
|
|
8480
|
+
* Takes the same filter the dashboard applies — date + agent, and admin-only visibility of
|
|
8481
|
+
* unattributed errors — so a page never widens what the summary above it counted.
|
|
8482
|
+
*
|
|
8483
|
+
* Paging is by offset into a newest-first table that grows at its head, so errors recorded
|
|
8484
|
+
* between two page requests shift every row down and a page can re-show rows already seen.
|
|
8485
|
+
* Accepted deliberately: this is a diagnostic table read at a moment in time, not a feed to
|
|
8486
|
+
* be walked exhaustively, and keying off the newest row's id would cost a cursor the caller
|
|
8487
|
+
* has no other use for.
|
|
8488
|
+
*/
|
|
8489
|
+
queryErrors(projectId, q) {
|
|
8490
|
+
const f = {
|
|
8491
|
+
...q.agentId !== void 0 ? { agentId: q.agentId } : {},
|
|
8492
|
+
...q.from !== void 0 ? { from: q.from } : {},
|
|
8493
|
+
...q.to !== void 0 ? { to: q.to } : {},
|
|
8494
|
+
...q.includeGlobalErrors === true ? { includeGlobal: true } : {}
|
|
8495
|
+
};
|
|
8496
|
+
return {
|
|
8497
|
+
items: this.errors.recent(projectId, f, q.limit, q.offset),
|
|
8498
|
+
total: this.errors.summary(projectId, f).total
|
|
8499
|
+
};
|
|
8500
|
+
}
|
|
7189
8501
|
/** Error statistics: summary info (total / unexpected / most common error code) + the last N entries, all filtered by the selected range. */
|
|
7190
8502
|
foldErrors(projectId, f) {
|
|
7191
8503
|
const { total, unexpected } = this.errors.summary(projectId, f);
|
|
@@ -7270,10 +8582,10 @@ var UsageService = class {
|
|
|
7270
8582
|
};
|
|
7271
8583
|
|
|
7272
8584
|
// src/http/routes/preview.ts
|
|
7273
|
-
import { Hono as
|
|
8585
|
+
import { Hono as Hono20 } from "hono";
|
|
7274
8586
|
var DIRECTORY_INDEX = "index.html";
|
|
7275
8587
|
function previewRoutes(deps2) {
|
|
7276
|
-
const app2 = new
|
|
8588
|
+
const app2 = new Hono20();
|
|
7277
8589
|
app2.get("/:token/*", async (c) => {
|
|
7278
8590
|
const token = c.req.param("token");
|
|
7279
8591
|
const payload = token ? deps2.previewTokens.verify(token) : null;
|
|
@@ -7331,13 +8643,14 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7331
8643
|
const errorsRepo = new ErrorsRepo(db);
|
|
7332
8644
|
const prefsRepo = new UiPrefsRepo(db);
|
|
7333
8645
|
const schedulesRepo = new SchedulesRepo(db);
|
|
8646
|
+
const goalsRepo = new GoalsRepo(db);
|
|
7334
8647
|
const projectConfigService = new ProjectConfigService(config2.root);
|
|
7335
8648
|
const agentConfigService = new AgentConfigService(config2.root);
|
|
7336
8649
|
const agentService = new AgentService(config2.root, agentsRepo, agentConfigService);
|
|
7337
8650
|
const traceService = new TraceService(config2.root);
|
|
7338
8651
|
const workspaceFiles = new WorkspaceFilesService();
|
|
7339
8652
|
const previewTokens = createPreviewTokenSigner();
|
|
7340
|
-
const benchmarks = new BenchmarkService(config2.root);
|
|
8653
|
+
const benchmarks = new BenchmarkService(config2.root, workspaceFiles);
|
|
7341
8654
|
const snapshots = new SnapshotService(config2.root);
|
|
7342
8655
|
const usageService = new UsageService(
|
|
7343
8656
|
usageRepo,
|
|
@@ -7345,6 +8658,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7345
8658
|
(projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId),
|
|
7346
8659
|
overrides.now ?? (() => /* @__PURE__ */ new Date())
|
|
7347
8660
|
);
|
|
8661
|
+
const updateCheck = overrides.updateCheck ?? new UpdateCheckService(overrides.now ? { now: overrides.now } : {});
|
|
7348
8662
|
let managerRef;
|
|
7349
8663
|
const channels = new ChannelHub({
|
|
7350
8664
|
isActive: (key) => managerRef !== void 0 && managerRef.statusOf(key) !== "idle"
|
|
@@ -7361,7 +8675,8 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7361
8675
|
recorder,
|
|
7362
8676
|
errors,
|
|
7363
8677
|
titles,
|
|
7364
|
-
log
|
|
8678
|
+
log,
|
|
8679
|
+
goals: goalsRepo
|
|
7365
8680
|
});
|
|
7366
8681
|
managerRef = manager;
|
|
7367
8682
|
const projectService = new ProjectService({
|
|
@@ -7374,6 +8689,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7374
8689
|
usage: usageRepo,
|
|
7375
8690
|
errors: errorsRepo,
|
|
7376
8691
|
schedules: schedulesRepo,
|
|
8692
|
+
goals: goalsRepo,
|
|
7377
8693
|
projectConfig: projectConfigService,
|
|
7378
8694
|
manager
|
|
7379
8695
|
});
|
|
@@ -7426,11 +8742,14 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7426
8742
|
sessionService,
|
|
7427
8743
|
traceService,
|
|
7428
8744
|
usageService,
|
|
8745
|
+
updateCheck,
|
|
7429
8746
|
workspaceFiles,
|
|
7430
8747
|
previewTokens,
|
|
7431
8748
|
benchmarks,
|
|
7432
8749
|
snapshots,
|
|
7433
8750
|
schedulesRepo,
|
|
8751
|
+
goalsRepo,
|
|
8752
|
+
errorsRepo,
|
|
7434
8753
|
scheduler,
|
|
7435
8754
|
channels,
|
|
7436
8755
|
manager,
|
|
@@ -7440,7 +8759,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7440
8759
|
};
|
|
7441
8760
|
}
|
|
7442
8761
|
function createApp(deps2) {
|
|
7443
|
-
const app2 = new
|
|
8762
|
+
const app2 = new Hono21();
|
|
7444
8763
|
app2.onError((err, c) => {
|
|
7445
8764
|
const projectId = attributedProjectId(c, deps2);
|
|
7446
8765
|
deps2.errors.record({
|
|
@@ -7472,18 +8791,23 @@ function createApp(deps2) {
|
|
|
7472
8791
|
await next();
|
|
7473
8792
|
});
|
|
7474
8793
|
}
|
|
7475
|
-
app2.use(
|
|
7476
|
-
|
|
7477
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
8794
|
+
app2.use(
|
|
8795
|
+
"/api/*",
|
|
8796
|
+
bodyLimit({
|
|
8797
|
+
maxSize: MAX_BODY_BYTES,
|
|
8798
|
+
// Its default is a bare text/plain 413; throw the App's own error instead so the response
|
|
8799
|
+
// stays the documented `payload_too_large` body that every client already handles.
|
|
8800
|
+
onError: () => {
|
|
8801
|
+
throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
|
|
8802
|
+
}
|
|
8803
|
+
})
|
|
8804
|
+
);
|
|
7482
8805
|
app2.use("/api/*", jsonOnlyWrites);
|
|
7483
8806
|
app2.route("/api/auth", authRoutes(deps2));
|
|
7484
8807
|
const auth = authMiddleware(deps2.authService);
|
|
7485
8808
|
app2.use("/api/*", auth);
|
|
7486
8809
|
app2.route("/api/me", meRoutes(deps2));
|
|
8810
|
+
app2.route("/api/version", versionRoutes(deps2));
|
|
7487
8811
|
app2.route("/api/admin/users", adminUsersRoutes(deps2));
|
|
7488
8812
|
app2.route("/api/events", eventsRoutes(deps2));
|
|
7489
8813
|
app2.route("/api/skills", skillLibraryRoutes());
|
|
@@ -7503,7 +8827,7 @@ function createApp(deps2) {
|
|
|
7503
8827
|
app2.route("/api/projects/:projectId/usage", usageRoutes(deps2));
|
|
7504
8828
|
app2.route("/api/sessions", sessionsRoutes(deps2));
|
|
7505
8829
|
app2.route("/preview", previewRoutes(deps2));
|
|
7506
|
-
if (
|
|
8830
|
+
if (fs17.existsSync(deps2.config.webDist)) {
|
|
7507
8831
|
registerStaticRoutes(app2, deps2.config.webDist);
|
|
7508
8832
|
}
|
|
7509
8833
|
return app2;
|
|
@@ -7539,16 +8863,16 @@ function registerStaticRoutes(app2, webDist) {
|
|
|
7539
8863
|
return c.json(errorBody("not_found", "Endpoint does not exist."), 404);
|
|
7540
8864
|
}
|
|
7541
8865
|
const rel = reqPath.replace(/^\/+/, "");
|
|
7542
|
-
const resolved =
|
|
7543
|
-
const base =
|
|
7544
|
-
const target = resolved === base || resolved.startsWith(base +
|
|
8866
|
+
const resolved = path16.resolve(webDist, rel === "" ? "index.html" : rel);
|
|
8867
|
+
const base = path16.resolve(webDist);
|
|
8868
|
+
const target = resolved === base || resolved.startsWith(base + path16.sep) ? resolved : path16.join(base, "index.html");
|
|
7545
8869
|
let file = target;
|
|
7546
8870
|
try {
|
|
7547
8871
|
const stat = await fsp.stat(file);
|
|
7548
|
-
if (stat.isDirectory()) file =
|
|
8872
|
+
if (stat.isDirectory()) file = path16.join(file, "index.html");
|
|
7549
8873
|
await fsp.access(file);
|
|
7550
8874
|
} catch {
|
|
7551
|
-
file =
|
|
8875
|
+
file = path16.join(base, "index.html");
|
|
7552
8876
|
}
|
|
7553
8877
|
let content;
|
|
7554
8878
|
try {
|
|
@@ -7556,7 +8880,7 @@ function registerStaticRoutes(app2, webDist) {
|
|
|
7556
8880
|
} catch {
|
|
7557
8881
|
return c.json(errorBody("not_found", "Resource does not exist."), 404);
|
|
7558
8882
|
}
|
|
7559
|
-
const type = CONTENT_TYPES2[
|
|
8883
|
+
const type = CONTENT_TYPES2[path16.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
7560
8884
|
return new Response(new Uint8Array(content), {
|
|
7561
8885
|
status: 200,
|
|
7562
8886
|
headers: { "Content-Type": type }
|
|
@@ -7565,16 +8889,16 @@ function registerStaticRoutes(app2, webDist) {
|
|
|
7565
8889
|
}
|
|
7566
8890
|
|
|
7567
8891
|
// src/config.ts
|
|
7568
|
-
import
|
|
7569
|
-
import
|
|
8892
|
+
import fs18 from "fs";
|
|
8893
|
+
import path17 from "path";
|
|
7570
8894
|
import { fileURLToPath } from "url";
|
|
7571
8895
|
import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
|
|
7572
8896
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
7573
8897
|
function defaultWebDist() {
|
|
7574
|
-
const here =
|
|
7575
|
-
const bundled =
|
|
7576
|
-
if (
|
|
7577
|
-
return
|
|
8898
|
+
const here = path17.dirname(fileURLToPath(import.meta.url));
|
|
8899
|
+
const bundled = path17.resolve(here, "..", "web-dist");
|
|
8900
|
+
if (fs18.existsSync(bundled)) return bundled;
|
|
8901
|
+
return path17.resolve(here, "..", "..", "web", "dist");
|
|
7578
8902
|
}
|
|
7579
8903
|
function normalizePreviewOrigin(raw) {
|
|
7580
8904
|
if (!raw || raw.trim() === "") return null;
|
|
@@ -7599,7 +8923,7 @@ function resolveServerConfig(env = process.env) {
|
|
|
7599
8923
|
root,
|
|
7600
8924
|
host: env.HOST ?? "127.0.0.1",
|
|
7601
8925
|
port,
|
|
7602
|
-
dbPath: env.PENGUIN_WEB_DB ??
|
|
8926
|
+
dbPath: env.PENGUIN_WEB_DB ?? path17.join(root, "web.db"),
|
|
7603
8927
|
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
|
|
7604
8928
|
previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
|
|
7605
8929
|
authSessionTtlMs: 7 * DAY_MS,
|
|
@@ -7614,6 +8938,7 @@ var deps = buildAppDeps(config);
|
|
|
7614
8938
|
var app = createApp(deps);
|
|
7615
8939
|
await deps.authService.seedAdmin();
|
|
7616
8940
|
await deps.scheduler.start();
|
|
8941
|
+
deps.goalsRepo.abortOrphanedActive();
|
|
7617
8942
|
var appHost = loopbackHostRoles(config.host)?.app ?? config.host;
|
|
7618
8943
|
var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
|
7619
8944
|
console.log(`penguin-server started: http://${appHost}:${info.port}`);
|