@prismshadow/penguin-server 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/types.d.ts +107 -20
- package/dist/index.js +914 -267
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/web-dist/assets/index-BOvgZOwz.js +173 -0
- package/web-dist/assets/{index-7gbWDtby.js → index-BgHHHLga.js} +1 -1
- package/web-dist/assets/index-DGdg-i_E.css +1 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-Bnva9EYm.js +0 -102
- package/web-dist/assets/index-DMcMNwnl.css +0 -1
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
|
|
8
|
+
import path16 from "path";
|
|
9
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";
|
|
@@ -54,8 +55,11 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
54
55
|
approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
|
|
55
56
|
title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
|
|
56
57
|
archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
|
|
58
|
+
client TEXT, -- creating client: 'web' (created via the Web App) | 'cli' (adopted from a CLI Trace); NULL = legacy row, treated as web
|
|
59
|
+
has_trace INTEGER NOT NULL DEFAULT 0, -- cache: a Trace record exists (set at task start / adoption / subagent registration; lazily backfilled by list hydration)
|
|
57
60
|
created_at TEXT NOT NULL
|
|
58
|
-
); --
|
|
61
|
+
); -- the schedule/subagent SOURCE is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts); "client" is a different, DB-only axis (who created the row)
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_agent_created ON sessions(project_id, agent_id, created_at DESC);
|
|
59
63
|
CREATE TABLE IF NOT EXISTS usage_records (
|
|
60
64
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
61
65
|
ts TEXT NOT NULL,
|
|
@@ -132,8 +136,15 @@ function openDatabase(dbPath) {
|
|
|
132
136
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
133
137
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
134
138
|
db.exec(SCHEMA_SQL);
|
|
139
|
+
ensureColumn(db, "sessions", "client", "TEXT");
|
|
140
|
+
ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
|
|
135
141
|
return db;
|
|
136
142
|
}
|
|
143
|
+
function ensureColumn(db, table, column, ddl) {
|
|
144
|
+
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
145
|
+
if (cols.some((c) => c.name === column)) return;
|
|
146
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
|
|
147
|
+
}
|
|
137
148
|
|
|
138
149
|
// src/db/repos/agents.ts
|
|
139
150
|
var AgentsRepo = class {
|
|
@@ -299,13 +310,13 @@ var ErrorsRepo = class {
|
|
|
299
310
|
};
|
|
300
311
|
}
|
|
301
312
|
/** The most recent `limit` entries (reverse chronological order). */
|
|
302
|
-
recent(projectId, f = {}, limit = 20) {
|
|
313
|
+
recent(projectId, f = {}, limit = 20, offset = 0) {
|
|
303
314
|
const { where, params } = this.conds(projectId, f);
|
|
304
315
|
const rows = this.db.prepare(
|
|
305
316
|
`SELECT ts, source, code, kind, message
|
|
306
317
|
FROM error_records WHERE ${where}
|
|
307
|
-
ORDER BY id DESC LIMIT :limit`
|
|
308
|
-
).all({ ...params, limit });
|
|
318
|
+
ORDER BY id DESC LIMIT :limit OFFSET :offset`
|
|
319
|
+
).all({ ...params, limit, offset });
|
|
309
320
|
return rows.map((r) => ({
|
|
310
321
|
ts: r.ts,
|
|
311
322
|
source: r.source,
|
|
@@ -614,6 +625,8 @@ function mapRow4(r) {
|
|
|
614
625
|
approvalMode: r.approval_mode,
|
|
615
626
|
title: r.title ?? null,
|
|
616
627
|
archivedAt: r.archived_at ?? null,
|
|
628
|
+
client: r.client ?? null,
|
|
629
|
+
hasTrace: r.has_trace === 1,
|
|
617
630
|
createdAt: r.created_at
|
|
618
631
|
};
|
|
619
632
|
}
|
|
@@ -624,8 +637,8 @@ var SessionsRepo = class {
|
|
|
624
637
|
db;
|
|
625
638
|
insert(row) {
|
|
626
639
|
this.db.prepare(
|
|
627
|
-
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
|
|
628
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
640
|
+
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
|
|
641
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
629
642
|
).run(
|
|
630
643
|
row.sessionId,
|
|
631
644
|
row.projectId,
|
|
@@ -635,14 +648,16 @@ var SessionsRepo = class {
|
|
|
635
648
|
row.workspace,
|
|
636
649
|
row.approvalMode,
|
|
637
650
|
row.title,
|
|
651
|
+
row.client ?? null,
|
|
652
|
+
row.hasTrace ? 1 : 0,
|
|
638
653
|
row.createdAt
|
|
639
654
|
);
|
|
640
655
|
}
|
|
641
656
|
/** Idempotent insert: used when Trace directory discovery backfills a row (concurrent listing discovering the same Session no longer triggers a UNIQUE violation). */
|
|
642
657
|
insertOrIgnore(row) {
|
|
643
658
|
this.db.prepare(
|
|
644
|
-
`INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
|
|
645
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
659
|
+
`INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
|
|
660
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
646
661
|
).run(
|
|
647
662
|
row.sessionId,
|
|
648
663
|
row.projectId,
|
|
@@ -652,15 +667,30 @@ var SessionsRepo = class {
|
|
|
652
667
|
row.workspace,
|
|
653
668
|
row.approvalMode,
|
|
654
669
|
row.title,
|
|
670
|
+
row.client ?? null,
|
|
671
|
+
row.hasTrace ? 1 : 0,
|
|
655
672
|
row.createdAt
|
|
656
673
|
);
|
|
657
674
|
}
|
|
675
|
+
/** Flip the has_trace cache once a Trace record exists (task start / discovery hydration); idempotent. */
|
|
676
|
+
markHasTrace(sessionId) {
|
|
677
|
+
this.db.prepare("UPDATE sessions SET has_trace = 1 WHERE session_id = ?").run(sessionId);
|
|
678
|
+
}
|
|
658
679
|
findById(sessionId) {
|
|
659
680
|
const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
660
681
|
return r ? mapRow4(r) : null;
|
|
661
682
|
}
|
|
662
|
-
|
|
663
|
-
|
|
683
|
+
/**
|
|
684
|
+
* An Agent's rows, newest first (the list order the sidebar shows; served by
|
|
685
|
+
* idx_sessions_agent_created). `webOnly` keeps only web-created rows — NULL counts as
|
|
686
|
+
* web (legacy rows predate the column and the user chose to grandfather them as visible).
|
|
687
|
+
*/
|
|
688
|
+
listByAgent(projectId, agentId, opts = {}) {
|
|
689
|
+
const filter = opts.webOnly ? " AND (client IS NULL OR client = 'web')" : "";
|
|
690
|
+
const rows = this.db.prepare(
|
|
691
|
+
`SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?${filter}
|
|
692
|
+
ORDER BY created_at DESC, session_id DESC`
|
|
693
|
+
).all(projectId, agentId);
|
|
664
694
|
return rows.map(mapRow4);
|
|
665
695
|
}
|
|
666
696
|
listByProject(projectId) {
|
|
@@ -1563,6 +1593,14 @@ function projectsRoutes(deps2) {
|
|
|
1563
1593
|
const project = await deps2.projectService.createProject(c.var.user, projectId, name);
|
|
1564
1594
|
return c.json({ project }, 201);
|
|
1565
1595
|
});
|
|
1596
|
+
app2.patch("/:projectId", async (c) => {
|
|
1597
|
+
const projectId = requireValidId(c, "projectId");
|
|
1598
|
+
const body = await readJson(c);
|
|
1599
|
+
const name = requireString(body, "name", { maxLen: 100, label: "name" }).trim();
|
|
1600
|
+
if (name === "") throw badRequest("name must be at least 1 characters.");
|
|
1601
|
+
const project = await deps2.projectService.renameProject(c.var.user.userId, projectId, name);
|
|
1602
|
+
return c.json({ project });
|
|
1603
|
+
});
|
|
1566
1604
|
app2.delete("/:projectId", async (c) => {
|
|
1567
1605
|
await deps2.projectService.deleteProject(c.var.user.userId, requireValidId(c, "projectId"));
|
|
1568
1606
|
return c.body(null, 204);
|
|
@@ -2190,6 +2228,57 @@ async function readItem(deps2, projectId, agentId, name) {
|
|
|
2190
2228
|
|
|
2191
2229
|
// src/http/routes/benchmarks.ts
|
|
2192
2230
|
import { Hono as Hono10 } from "hono";
|
|
2231
|
+
var TEXT_PREVIEW_BYTES = 256 * 1024;
|
|
2232
|
+
function listCaseFiles(deps2, material) {
|
|
2233
|
+
return async (c) => {
|
|
2234
|
+
const projectId = requireValidId(c, "projectId");
|
|
2235
|
+
const agentId = requireValidId(c, "agentId");
|
|
2236
|
+
const benchmarkId = requireValidId(c, "benchmarkId");
|
|
2237
|
+
const caseId = requireValidId(c, "caseId");
|
|
2238
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2239
|
+
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2240
|
+
return c.json(
|
|
2241
|
+
await deps2.benchmarks.listCaseFiles(
|
|
2242
|
+
projectId,
|
|
2243
|
+
agentId,
|
|
2244
|
+
benchmarkId,
|
|
2245
|
+
caseId,
|
|
2246
|
+
c.req.query("path") ?? "",
|
|
2247
|
+
material
|
|
2248
|
+
)
|
|
2249
|
+
);
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
function readCaseFile(deps2, material) {
|
|
2253
|
+
return async (c) => {
|
|
2254
|
+
const projectId = requireValidId(c, "projectId");
|
|
2255
|
+
const agentId = requireValidId(c, "agentId");
|
|
2256
|
+
const benchmarkId = requireValidId(c, "benchmarkId");
|
|
2257
|
+
const caseId = requireValidId(c, "caseId");
|
|
2258
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2259
|
+
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2260
|
+
const download = c.req.query("download") === "1";
|
|
2261
|
+
const boundedPreview = !download && c.req.query("preview") === "1";
|
|
2262
|
+
const { data, fileName, contentType, scriptable, truncated } = await deps2.benchmarks.readCaseFile(
|
|
2263
|
+
projectId,
|
|
2264
|
+
agentId,
|
|
2265
|
+
benchmarkId,
|
|
2266
|
+
caseId,
|
|
2267
|
+
c.req.query("path") ?? "",
|
|
2268
|
+
material,
|
|
2269
|
+
boundedPreview ? { maxBytes: TEXT_PREVIEW_BYTES } : void 0
|
|
2270
|
+
);
|
|
2271
|
+
return new Response(new Uint8Array(data), {
|
|
2272
|
+
status: 200,
|
|
2273
|
+
headers: {
|
|
2274
|
+
"Content-Type": !download && scriptable ? "text/plain; charset=utf-8" : contentType,
|
|
2275
|
+
"Content-Disposition": `${download ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
2276
|
+
"X-Content-Type-Options": "nosniff",
|
|
2277
|
+
...truncated ? { "X-Content-Truncated": "1" } : {}
|
|
2278
|
+
}
|
|
2279
|
+
});
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2193
2282
|
function benchmarksRoutes(deps2) {
|
|
2194
2283
|
const app2 = new Hono10();
|
|
2195
2284
|
app2.get("/", async (c) => {
|
|
@@ -2199,6 +2288,18 @@ function benchmarksRoutes(deps2) {
|
|
|
2199
2288
|
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2200
2289
|
return c.json(await deps2.benchmarks.list(projectId, agentId));
|
|
2201
2290
|
});
|
|
2291
|
+
app2.get("/:benchmarkId/cases", async (c) => {
|
|
2292
|
+
const projectId = requireValidId(c, "projectId");
|
|
2293
|
+
const agentId = requireValidId(c, "agentId");
|
|
2294
|
+
const benchmarkId = requireValidId(c, "benchmarkId");
|
|
2295
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2296
|
+
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2297
|
+
return c.json(await deps2.benchmarks.listCases(projectId, agentId, benchmarkId));
|
|
2298
|
+
});
|
|
2299
|
+
app2.get("/:benchmarkId/cases/:caseId/files", listCaseFiles(deps2, "statement"));
|
|
2300
|
+
app2.get("/:benchmarkId/cases/:caseId/files/content", readCaseFile(deps2, "statement"));
|
|
2301
|
+
app2.get("/:benchmarkId/cases/:caseId/rubric/files", listCaseFiles(deps2, "rubric"));
|
|
2302
|
+
app2.get("/:benchmarkId/cases/:caseId/rubric/files/content", readCaseFile(deps2, "rubric"));
|
|
2202
2303
|
return app2;
|
|
2203
2304
|
}
|
|
2204
2305
|
|
|
@@ -2611,12 +2712,32 @@ function usageRoutes(deps2) {
|
|
|
2611
2712
|
})
|
|
2612
2713
|
);
|
|
2613
2714
|
});
|
|
2715
|
+
app2.get("/errors", (c) => {
|
|
2716
|
+
const projectId = requireValidId(c, "projectId");
|
|
2717
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2718
|
+
const { offset, limit } = paginationQuery(c);
|
|
2719
|
+
const from = optionalDateParam(c.req.query("from"), "from");
|
|
2720
|
+
const to = optionalDateParam(c.req.query("to"), "to");
|
|
2721
|
+
const agentId = c.req.query("agentId");
|
|
2722
|
+
return c.json(
|
|
2723
|
+
deps2.usageService.queryErrors(projectId, {
|
|
2724
|
+
offset,
|
|
2725
|
+
limit,
|
|
2726
|
+
// Same admin-only rule as the dashboard: a regular member seeing another tenant's
|
|
2727
|
+
// unattributed errors would be a cross-tenant leak.
|
|
2728
|
+
includeGlobalErrors: c.var.user.isAdmin,
|
|
2729
|
+
...from !== void 0 ? { from } : {},
|
|
2730
|
+
...to !== void 0 ? { to } : {},
|
|
2731
|
+
...agentId !== void 0 && agentId !== "" ? { agentId } : {}
|
|
2732
|
+
})
|
|
2733
|
+
);
|
|
2734
|
+
});
|
|
2614
2735
|
return app2;
|
|
2615
2736
|
}
|
|
2616
2737
|
|
|
2617
2738
|
// src/http/routes/sessions.ts
|
|
2618
|
-
import
|
|
2619
|
-
import
|
|
2739
|
+
import fs8 from "fs/promises";
|
|
2740
|
+
import path7 from "path";
|
|
2620
2741
|
import { Hono as Hono18 } from "hono";
|
|
2621
2742
|
import { imageUrlMessage, scratchpadDir, userText } from "@prismshadow/penguin-core";
|
|
2622
2743
|
|
|
@@ -2796,9 +2917,14 @@ var WorkspaceFilesService = class {
|
|
|
2796
2917
|
);
|
|
2797
2918
|
return unique.filter((_, i) => exists[i]);
|
|
2798
2919
|
}
|
|
2799
|
-
/**
|
|
2920
|
+
/**
|
|
2921
|
+
* List a directory: dirs come first, each group sorted by name. Entries whose
|
|
2922
|
+
* canonical target leaves the Workspace are omitted, so listing cannot expose
|
|
2923
|
+
* metadata for an out-of-bounds symlink.
|
|
2924
|
+
*/
|
|
2800
2925
|
async list(workspace, rel) {
|
|
2801
2926
|
const dir = await this.resolveRead(workspace, rel);
|
|
2927
|
+
const realBase = await this.realBase(workspace);
|
|
2802
2928
|
let dirents;
|
|
2803
2929
|
try {
|
|
2804
2930
|
dirents = await fs6.readdir(dir, { withFileTypes: true });
|
|
@@ -2811,33 +2937,31 @@ var WorkspaceFilesService = class {
|
|
|
2811
2937
|
}
|
|
2812
2938
|
throw err;
|
|
2813
2939
|
}
|
|
2814
|
-
const
|
|
2940
|
+
const listed = await Promise.all(
|
|
2815
2941
|
dirents.map(async (d) => {
|
|
2816
|
-
let sizeBytes = 0;
|
|
2817
|
-
let mtime = "";
|
|
2818
|
-
let isDir = d.isDirectory();
|
|
2819
2942
|
try {
|
|
2820
|
-
const
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2943
|
+
const canonical = await fs6.realpath(path5.join(dir, d.name));
|
|
2944
|
+
this.assertInside(canonical, realBase);
|
|
2945
|
+
const stat = await fs6.stat(canonical);
|
|
2946
|
+
return {
|
|
2947
|
+
name: d.name,
|
|
2948
|
+
kind: stat.isDirectory() ? "dir" : "file",
|
|
2949
|
+
sizeBytes: stat.size,
|
|
2950
|
+
mtime: stat.mtime.toISOString()
|
|
2951
|
+
};
|
|
2824
2952
|
} catch {
|
|
2953
|
+
return null;
|
|
2825
2954
|
}
|
|
2826
|
-
return {
|
|
2827
|
-
name: d.name,
|
|
2828
|
-
kind: isDir ? "dir" : "file",
|
|
2829
|
-
sizeBytes,
|
|
2830
|
-
mtime
|
|
2831
|
-
};
|
|
2832
2955
|
})
|
|
2833
2956
|
);
|
|
2957
|
+
const entries = listed.filter((entry) => entry !== null);
|
|
2834
2958
|
entries.sort(
|
|
2835
2959
|
(a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1
|
|
2836
2960
|
);
|
|
2837
2961
|
return { path: rel, entries };
|
|
2838
2962
|
}
|
|
2839
2963
|
/** Read a file (preview/download): IO on the canonical path (resolveRead has already eliminated symlink escapes). */
|
|
2840
|
-
async read(workspace, rel) {
|
|
2964
|
+
async read(workspace, rel, options) {
|
|
2841
2965
|
const file = await this.resolveRead(workspace, rel);
|
|
2842
2966
|
let stat;
|
|
2843
2967
|
try {
|
|
@@ -2846,16 +2970,35 @@ var WorkspaceFilesService = class {
|
|
|
2846
2970
|
throw new HttpError(404, "path_not_found", "File does not exist.");
|
|
2847
2971
|
}
|
|
2848
2972
|
if (stat.isDirectory()) throw badRequest("path is a directory.");
|
|
2849
|
-
|
|
2973
|
+
const maxBytes = options?.maxBytes;
|
|
2974
|
+
if (maxBytes !== void 0 && (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_READ_BYTES)) {
|
|
2975
|
+
throw badRequest("maxBytes must be a positive integer within the read limit.");
|
|
2976
|
+
}
|
|
2977
|
+
if (maxBytes === void 0 && stat.size > MAX_READ_BYTES) {
|
|
2850
2978
|
throw new HttpError(413, "file_too_large", "File exceeds the 50MB read limit.");
|
|
2851
2979
|
}
|
|
2852
|
-
|
|
2980
|
+
let data;
|
|
2981
|
+
let truncated = false;
|
|
2982
|
+
if (maxBytes !== void 0 && stat.size > maxBytes) {
|
|
2983
|
+
const handle = await fs6.open(file, "r");
|
|
2984
|
+
try {
|
|
2985
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
2986
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
|
2987
|
+
data = buffer.subarray(0, bytesRead);
|
|
2988
|
+
truncated = true;
|
|
2989
|
+
} finally {
|
|
2990
|
+
await handle.close();
|
|
2991
|
+
}
|
|
2992
|
+
} else {
|
|
2993
|
+
data = await fs6.readFile(file);
|
|
2994
|
+
}
|
|
2853
2995
|
const ext = path5.extname(file).toLowerCase();
|
|
2854
2996
|
return {
|
|
2855
2997
|
data,
|
|
2856
2998
|
fileName: path5.basename(file),
|
|
2857
2999
|
contentType: CONTENT_TYPES[ext] ?? "application/octet-stream",
|
|
2858
|
-
scriptable: ext === ".html" || ext === ".htm" || ext === ".svg"
|
|
3000
|
+
scriptable: ext === ".html" || ext === ".htm" || ext === ".svg",
|
|
3001
|
+
...truncated ? { truncated: true } : {}
|
|
2859
3002
|
};
|
|
2860
3003
|
}
|
|
2861
3004
|
/**
|
|
@@ -2897,6 +3040,143 @@ var WorkspaceFilesService = class {
|
|
|
2897
3040
|
}
|
|
2898
3041
|
};
|
|
2899
3042
|
|
|
3043
|
+
// src/services/task-attachments.ts
|
|
3044
|
+
import fs7 from "fs/promises";
|
|
3045
|
+
import path6 from "path";
|
|
3046
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
3047
|
+
import {
|
|
3048
|
+
appendAttachmentLines,
|
|
3049
|
+
attachedFileLine,
|
|
3050
|
+
modelVisiblePath
|
|
3051
|
+
} from "@prismshadow/penguin-core";
|
|
3052
|
+
var MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
3053
|
+
var MAX_ATTACHMENT_COUNT = 20;
|
|
3054
|
+
var MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024;
|
|
3055
|
+
var MAX_STEM_BYTES = 80;
|
|
3056
|
+
var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
3057
|
+
var INVISIBLE_CHAR = new RegExp("\\p{C}", "u");
|
|
3058
|
+
function unsafeNameChar(ch) {
|
|
3059
|
+
if (/[A-Za-z0-9._-]/.test(ch)) return false;
|
|
3060
|
+
return ch.codePointAt(0) < 128 || INVISIBLE_CHAR.test(ch);
|
|
3061
|
+
}
|
|
3062
|
+
function sanitizeSegment(value) {
|
|
3063
|
+
return Array.from(value, (ch) => unsafeNameChar(ch) ? "-" : ch).join("");
|
|
3064
|
+
}
|
|
3065
|
+
function truncateBytes(value, maxBytes) {
|
|
3066
|
+
if (Buffer.byteLength(value) <= maxBytes) return value;
|
|
3067
|
+
let out = "";
|
|
3068
|
+
for (const ch of value) {
|
|
3069
|
+
if (Buffer.byteLength(out) + Buffer.byteLength(ch) > maxBytes) break;
|
|
3070
|
+
out += ch;
|
|
3071
|
+
}
|
|
3072
|
+
return out;
|
|
3073
|
+
}
|
|
3074
|
+
var MAX_NAME_ATTEMPTS = 16;
|
|
3075
|
+
function parseAttachmentPart(part, index, field = "input") {
|
|
3076
|
+
const fileName = part.fileName;
|
|
3077
|
+
if (typeof fileName !== "string" || fileName.length === 0 || fileName.includes("/") || fileName.includes("\\") || fileName.includes("..") || fileName.includes("\0")) {
|
|
3078
|
+
throw badRequest(
|
|
3079
|
+
`${field}[${index}].fileName must be a non-empty file name without path separators or "..".`
|
|
3080
|
+
);
|
|
3081
|
+
}
|
|
3082
|
+
const dataUrl = part.dataUrl;
|
|
3083
|
+
const match = typeof dataUrl === "string" ? /^data:[^,]*;base64,([A-Za-z0-9+/=\s]+)$/.exec(dataUrl) : null;
|
|
3084
|
+
if (!match) {
|
|
3085
|
+
throw badRequest(`${field}[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
|
|
3086
|
+
}
|
|
3087
|
+
const bytes = Buffer.from(match[1], "base64");
|
|
3088
|
+
if (bytes.length === 0) {
|
|
3089
|
+
throw badRequest(`${field}[${index}].dataUrl decodes to an empty file.`);
|
|
3090
|
+
}
|
|
3091
|
+
if (bytes.length > MAX_ATTACHMENT_BYTES) {
|
|
3092
|
+
throw new HttpError(
|
|
3093
|
+
413,
|
|
3094
|
+
"file_too_large",
|
|
3095
|
+
`Attached file exceeds the ${MAX_ATTACHMENT_BYTES / (1024 * 1024)}MB limit.`
|
|
3096
|
+
);
|
|
3097
|
+
}
|
|
3098
|
+
return { fileName, bytes };
|
|
3099
|
+
}
|
|
3100
|
+
function assertAttachmentBudget(attachments) {
|
|
3101
|
+
if (attachments.length > MAX_ATTACHMENT_COUNT) {
|
|
3102
|
+
throw new HttpError(
|
|
3103
|
+
413,
|
|
3104
|
+
"too_many_files",
|
|
3105
|
+
`A message may carry at most ${MAX_ATTACHMENT_COUNT} attached files.`
|
|
3106
|
+
);
|
|
3107
|
+
}
|
|
3108
|
+
let total = 0;
|
|
3109
|
+
for (const a of attachments) total += a.bytes.length;
|
|
3110
|
+
if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
|
|
3111
|
+
throw new HttpError(
|
|
3112
|
+
413,
|
|
3113
|
+
"payload_too_large",
|
|
3114
|
+
`Attached files exceed the ${MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)}MB total limit for one message.`
|
|
3115
|
+
);
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
function scratchpadName(fileName) {
|
|
3119
|
+
const ext = sanitizeSegment(path6.extname(fileName));
|
|
3120
|
+
const rawStem = fileName.slice(0, fileName.length - path6.extname(fileName).length);
|
|
3121
|
+
const stem = truncateBytes(sanitizeSegment(rawStem), MAX_STEM_BYTES).replace(/[. ]+$/, "");
|
|
3122
|
+
if (!stem || /^-+$/.test(stem)) return `file${ext}`;
|
|
3123
|
+
return WINDOWS_RESERVED.test(stem) ? `_${stem}${ext}` : `${stem}${ext}`;
|
|
3124
|
+
}
|
|
3125
|
+
async function writeAttachment(dir, attachment) {
|
|
3126
|
+
const base = scratchpadName(attachment.fileName);
|
|
3127
|
+
const ext = path6.extname(base);
|
|
3128
|
+
const stem = base.slice(0, base.length - ext.length);
|
|
3129
|
+
for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) {
|
|
3130
|
+
const name = attempt === 0 ? base : `${stem}-${randomBytes4(3).toString("hex")}${ext}`;
|
|
3131
|
+
const file = path6.join(dir, name);
|
|
3132
|
+
try {
|
|
3133
|
+
await fs7.writeFile(file, attachment.bytes, { flag: "wx" });
|
|
3134
|
+
return file;
|
|
3135
|
+
} catch (err) {
|
|
3136
|
+
if (err.code !== "EEXIST") throw err;
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
throw new Error(
|
|
3140
|
+
`failed to allocate a unique attachment file name under ${dir} after ${MAX_NAME_ATTEMPTS} attempts`
|
|
3141
|
+
);
|
|
3142
|
+
}
|
|
3143
|
+
async function openScratchpadDir(root, sessionId) {
|
|
3144
|
+
const dir = path6.join(root, sessionId);
|
|
3145
|
+
await fs7.mkdir(dir, { recursive: true });
|
|
3146
|
+
const canonicalRoot = await fs7.realpath(root);
|
|
3147
|
+
const rel = path6.relative(canonicalRoot, await fs7.realpath(dir));
|
|
3148
|
+
if (rel !== sessionId) {
|
|
3149
|
+
throw new Error(
|
|
3150
|
+
`session scratchpad ${dir} resolves outside the agent scratchpad root; refusing to write attachments`
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
return dir;
|
|
3154
|
+
}
|
|
3155
|
+
async function removeAttachments(files) {
|
|
3156
|
+
await Promise.all(files.map((f) => fs7.rm(f, { force: true }).catch(() => {
|
|
3157
|
+
})));
|
|
3158
|
+
}
|
|
3159
|
+
async function attachFilesToInput(messages, attachments, root, sessionId) {
|
|
3160
|
+
if (attachments.length === 0) return { input: messages, written: [] };
|
|
3161
|
+
const dir = await openScratchpadDir(root, sessionId);
|
|
3162
|
+
const written = [];
|
|
3163
|
+
try {
|
|
3164
|
+
for (const attachment of attachments) {
|
|
3165
|
+
written.push(await writeAttachment(dir, attachment));
|
|
3166
|
+
}
|
|
3167
|
+
} catch (err) {
|
|
3168
|
+
await removeAttachments(written);
|
|
3169
|
+
throw err;
|
|
3170
|
+
}
|
|
3171
|
+
return {
|
|
3172
|
+
input: appendAttachmentLines(
|
|
3173
|
+
messages,
|
|
3174
|
+
written.map((filePath) => attachedFileLine(modelVisiblePath(filePath)))
|
|
3175
|
+
),
|
|
3176
|
+
written
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
|
|
2900
3180
|
// src/http/routes/sessions.ts
|
|
2901
3181
|
var SESSION_TITLE_MAX = 120;
|
|
2902
3182
|
var STAT_MAX_PATHS = 100;
|
|
@@ -2914,12 +3194,33 @@ var SESSION_CATEGORIES = [
|
|
|
2914
3194
|
"schedule",
|
|
2915
3195
|
"archived"
|
|
2916
3196
|
];
|
|
3197
|
+
var IMAGE_DATA_URL = /^data:[^;,]+;base64,[A-Za-z0-9+/=\s]+$/;
|
|
3198
|
+
function requireImageUrl(url, field) {
|
|
3199
|
+
if (typeof url === "string") {
|
|
3200
|
+
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
|
3201
|
+
if (IMAGE_DATA_URL.test(url)) return url;
|
|
3202
|
+
}
|
|
3203
|
+
throw badRequest(
|
|
3204
|
+
`${field} must be an http(s) URL or a base64 data: URL (data:<mime>;base64,<bytes>).`
|
|
3205
|
+
);
|
|
3206
|
+
}
|
|
3207
|
+
function resolveScratchpadFile(dir, fileName) {
|
|
3208
|
+
if (!fileName || fileName === "." || fileName === "..") return null;
|
|
3209
|
+
for (const ch of fileName) {
|
|
3210
|
+
const code = ch.codePointAt(0);
|
|
3211
|
+
if (code < 32 || code === 127 || ch === "/" || ch === "\\") return null;
|
|
3212
|
+
}
|
|
3213
|
+
const resolved = path7.resolve(dir, fileName);
|
|
3214
|
+
return path7.dirname(resolved) === path7.resolve(dir) ? resolved : null;
|
|
3215
|
+
}
|
|
2917
3216
|
function parseTaskInput(body) {
|
|
2918
3217
|
const input = body.input;
|
|
2919
3218
|
if (!Array.isArray(input) || input.length === 0) {
|
|
2920
3219
|
throw badRequest("input must be an array with at least one item.");
|
|
2921
3220
|
}
|
|
2922
|
-
|
|
3221
|
+
const messages = [];
|
|
3222
|
+
const attachments = [];
|
|
3223
|
+
input.forEach((item, i) => {
|
|
2923
3224
|
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
2924
3225
|
throw badRequest(`input[${i}] must be an object.`);
|
|
2925
3226
|
}
|
|
@@ -2928,17 +3229,41 @@ function parseTaskInput(body) {
|
|
|
2928
3229
|
if (typeof part.text !== "string" || part.text.length === 0) {
|
|
2929
3230
|
throw badRequest(`input[${i}].text must be a non-empty string.`);
|
|
2930
3231
|
}
|
|
2931
|
-
|
|
3232
|
+
messages.push(userText(part.text));
|
|
3233
|
+
return;
|
|
2932
3234
|
}
|
|
2933
3235
|
if (part.type === "image_url") {
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
3236
|
+
messages.push(imageUrlMessage(requireImageUrl(part.imageUrl, `input[${i}].imageUrl`)));
|
|
3237
|
+
return;
|
|
3238
|
+
}
|
|
3239
|
+
if (part.type === "file") {
|
|
3240
|
+
attachments.push(parseAttachmentPart(part, i));
|
|
3241
|
+
assertAttachmentBudget(attachments);
|
|
3242
|
+
return;
|
|
2939
3243
|
}
|
|
2940
|
-
throw badRequest(`input[${i}].type must be one of text / image_url.`);
|
|
3244
|
+
throw badRequest(`input[${i}].type must be one of text / image_url / file.`);
|
|
2941
3245
|
});
|
|
3246
|
+
return { messages, attachments };
|
|
3247
|
+
}
|
|
3248
|
+
function parseSteerImages(body) {
|
|
3249
|
+
const images = body.images;
|
|
3250
|
+
if (images === void 0) return [];
|
|
3251
|
+
if (!Array.isArray(images)) throw badRequest("images must be an array.");
|
|
3252
|
+
return images.map((url, i) => requireImageUrl(url, `images[${i}]`));
|
|
3253
|
+
}
|
|
3254
|
+
function parseSteerFiles(body) {
|
|
3255
|
+
const files = body.files;
|
|
3256
|
+
if (files === void 0) return [];
|
|
3257
|
+
if (!Array.isArray(files)) throw badRequest("files must be an array.");
|
|
3258
|
+
const attachments = [];
|
|
3259
|
+
files.forEach((item, i) => {
|
|
3260
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
3261
|
+
throw badRequest(`files[${i}] must be an object.`);
|
|
3262
|
+
}
|
|
3263
|
+
attachments.push(parseAttachmentPart(item, i, "files"));
|
|
3264
|
+
assertAttachmentBudget(attachments);
|
|
3265
|
+
});
|
|
3266
|
+
return attachments;
|
|
2942
3267
|
}
|
|
2943
3268
|
function parseGoalField(body) {
|
|
2944
3269
|
const goal = body.goal;
|
|
@@ -2966,13 +3291,16 @@ function agentSessionsRoutes(deps2) {
|
|
|
2966
3291
|
}
|
|
2967
3292
|
const rawCounts = c.req.query("counts");
|
|
2968
3293
|
if (rawCounts !== void 0 && rawCounts !== "1") throw badRequest("counts only accepts 1.");
|
|
3294
|
+
const rawCli = c.req.query("cli");
|
|
3295
|
+
if (rawCli !== void 0 && rawCli !== "1") throw badRequest("cli only accepts 1.");
|
|
2969
3296
|
const { sessions, counts, workspaceCounts } = await deps2.sessionService.listSessions(
|
|
2970
3297
|
projectId,
|
|
2971
3298
|
agentId,
|
|
2972
3299
|
{
|
|
2973
3300
|
...paging ? { paging } : {},
|
|
2974
3301
|
...rawCategory !== void 0 ? { category: rawCategory } : {},
|
|
2975
|
-
...rawCounts !== void 0 ? { withCounts: true } : {}
|
|
3302
|
+
...rawCounts !== void 0 ? { withCounts: true } : {},
|
|
3303
|
+
...rawCli !== void 0 ? { includeCli: true } : {}
|
|
2976
3304
|
}
|
|
2977
3305
|
);
|
|
2978
3306
|
return c.json({
|
|
@@ -3101,8 +3429,8 @@ function sessionsRoutes(deps2) {
|
|
|
3101
3429
|
]);
|
|
3102
3430
|
}
|
|
3103
3431
|
await deps2.traceService.deleteSessionTraces(row.projectId, row.agentId, row.sessionId);
|
|
3104
|
-
await
|
|
3105
|
-
|
|
3432
|
+
await fs8.rm(
|
|
3433
|
+
path7.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
|
|
3106
3434
|
{ recursive: true, force: true }
|
|
3107
3435
|
);
|
|
3108
3436
|
deps2.sessionsRepo.deleteById(row.sessionId);
|
|
@@ -3116,17 +3444,14 @@ function sessionsRoutes(deps2) {
|
|
|
3116
3444
|
app2.get("/:sessionId/scratchpad/:fileName", async (c) => {
|
|
3117
3445
|
const row = resolveSession(c);
|
|
3118
3446
|
const fileName = c.req.param("fileName") ?? "";
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
}
|
|
3122
|
-
const filePath = path6.join(
|
|
3123
|
-
scratchpadDir(deps2.config.root, row.projectId, row.agentId),
|
|
3124
|
-
row.sessionId,
|
|
3447
|
+
const filePath = resolveScratchpadFile(
|
|
3448
|
+
path7.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
|
|
3125
3449
|
fileName
|
|
3126
3450
|
);
|
|
3451
|
+
if (!filePath) throw new HttpError(404, "file_not_found", "File does not exist.");
|
|
3127
3452
|
let bytes;
|
|
3128
3453
|
try {
|
|
3129
|
-
bytes = await
|
|
3454
|
+
bytes = await fs8.readFile(filePath);
|
|
3130
3455
|
} catch {
|
|
3131
3456
|
throw new HttpError(404, "file_not_found", "File does not exist.");
|
|
3132
3457
|
}
|
|
@@ -3137,9 +3462,21 @@ function sessionsRoutes(deps2) {
|
|
|
3137
3462
|
".gif": "image/gif",
|
|
3138
3463
|
".webp": "image/webp"
|
|
3139
3464
|
};
|
|
3140
|
-
const mime = MIME_BY_EXT[
|
|
3465
|
+
const mime = MIME_BY_EXT[path7.extname(fileName).toLowerCase()];
|
|
3141
3466
|
return c.body(new Uint8Array(bytes), 200, {
|
|
3142
|
-
"content-type": mime,
|
|
3467
|
+
"content-type": mime ?? "application/octet-stream",
|
|
3468
|
+
// nosniff: the composer's file attachments land in this same directory, so the bytes
|
|
3469
|
+
// here are arbitrary user content served from the App's own origin — without it a
|
|
3470
|
+
// browser could sniff an `application/octet-stream` upload back into HTML and run it
|
|
3471
|
+
// same-origin (the same defense workspace file reads apply).
|
|
3472
|
+
"x-content-type-options": "nosniff",
|
|
3473
|
+
// Second, independent layer for everything that fell off the allowlist: the only reason
|
|
3474
|
+
// this endpoint is fetched inline is the conversation's <img> tags, so anything that is
|
|
3475
|
+
// not one of those images is served as a download and never renders as a document —
|
|
3476
|
+
// nosniff alone would be the whole defense otherwise.
|
|
3477
|
+
...mime === void 0 ? {
|
|
3478
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`
|
|
3479
|
+
} : {},
|
|
3143
3480
|
"cache-control": "private, max-age=31536000, immutable"
|
|
3144
3481
|
});
|
|
3145
3482
|
});
|
|
@@ -3162,11 +3499,15 @@ function sessionsRoutes(deps2) {
|
|
|
3162
3499
|
app2.get("/:sessionId/stream", (c) => {
|
|
3163
3500
|
const row = resolveSession(c);
|
|
3164
3501
|
const channel = deps2.channels.get(row.sessionId);
|
|
3502
|
+
const pendingSteering = deps2.manager.pendingSteeringOf(row.sessionId);
|
|
3165
3503
|
const initialEvents = [
|
|
3166
3504
|
{
|
|
3167
3505
|
type: "task_state",
|
|
3168
3506
|
state: deps2.manager.statusOf(row.sessionId),
|
|
3169
|
-
queued: deps2.manager.pendingFollowUpCount(row.sessionId)
|
|
3507
|
+
queued: deps2.manager.pendingFollowUpCount(row.sessionId),
|
|
3508
|
+
// Undelivered steering rides the snapshot too, so the composer's "steering queued"
|
|
3509
|
+
// hint (and what it says) survives a reload.
|
|
3510
|
+
...pendingSteering.length > 0 ? { pendingSteering } : {}
|
|
3170
3511
|
},
|
|
3171
3512
|
...deps2.manager.pendingApprovals(row.sessionId).map((p) => ({
|
|
3172
3513
|
type: "approval_request",
|
|
@@ -3182,32 +3523,66 @@ function sessionsRoutes(deps2) {
|
|
|
3182
3523
|
const goal = parseGoalField(body);
|
|
3183
3524
|
const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
|
|
3184
3525
|
if (goal) {
|
|
3185
|
-
const
|
|
3186
|
-
const text =
|
|
3187
|
-
if (!text
|
|
3188
|
-
throw badRequest("goal mode requires
|
|
3526
|
+
const { messages, attachments } = parseTaskInput(body);
|
|
3527
|
+
const text = messages.filter((m) => m.payload.type === "text").map((m) => m.payload.text).join("\n").trim();
|
|
3528
|
+
if (!text) {
|
|
3529
|
+
throw badRequest("goal mode requires a non-empty text objective.");
|
|
3189
3530
|
}
|
|
3190
|
-
|
|
3191
|
-
|
|
3531
|
+
if (attachments.length > 0) {
|
|
3532
|
+
throw badRequest("goal mode accepts text and images only (no file attachments).");
|
|
3533
|
+
}
|
|
3534
|
+
const { sessionId } = await deps2.manager.startGoal(row.sessionId, {
|
|
3535
|
+
input: messages,
|
|
3192
3536
|
budget: goal.budget,
|
|
3193
3537
|
...thinkingLevel !== void 0 ? { thinkingLevel } : {}
|
|
3194
3538
|
});
|
|
3195
|
-
return c.json({ sessionId
|
|
3539
|
+
return c.json({ sessionId }, 202);
|
|
3196
3540
|
}
|
|
3197
|
-
const
|
|
3541
|
+
const parsed = parseTaskInput(body);
|
|
3198
3542
|
const queueIfBusy = body.queueIfBusy === true;
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3543
|
+
deps2.manager.assertCanAcceptTask(row.sessionId, { queueIfBusy });
|
|
3544
|
+
const { input, written } = await attachFilesToInput(
|
|
3545
|
+
parsed.messages,
|
|
3546
|
+
parsed.attachments,
|
|
3547
|
+
scratchpadDir(deps2.config.root, row.projectId, row.agentId),
|
|
3548
|
+
row.sessionId
|
|
3549
|
+
);
|
|
3550
|
+
try {
|
|
3551
|
+
const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
|
|
3552
|
+
...thinkingLevel !== void 0 ? { thinkingLevel } : {},
|
|
3553
|
+
queueIfBusy
|
|
3554
|
+
});
|
|
3555
|
+
return c.json({ sessionId, queued }, 202);
|
|
3556
|
+
} catch (err) {
|
|
3557
|
+
await removeAttachments(written);
|
|
3558
|
+
throw err;
|
|
3559
|
+
}
|
|
3204
3560
|
});
|
|
3205
3561
|
app2.post("/:sessionId/steer", async (c) => {
|
|
3206
3562
|
const row = resolveSession(c);
|
|
3207
3563
|
const body = await readJson(c);
|
|
3208
3564
|
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
3209
|
-
|
|
3210
|
-
|
|
3565
|
+
const images = parseSteerImages(body);
|
|
3566
|
+
const files = parseSteerFiles(body);
|
|
3567
|
+
if (!text && images.length === 0 && files.length === 0) {
|
|
3568
|
+
throw badRequest("text, images or files must carry the steering message.");
|
|
3569
|
+
}
|
|
3570
|
+
const { input, written } = await attachFilesToInput(
|
|
3571
|
+
[...text ? [userText(text)] : [], ...images.map((url) => imageUrlMessage(url))],
|
|
3572
|
+
files,
|
|
3573
|
+
scratchpadDir(deps2.config.root, row.projectId, row.agentId),
|
|
3574
|
+
row.sessionId
|
|
3575
|
+
);
|
|
3576
|
+
try {
|
|
3577
|
+
deps2.manager.steer(row.sessionId, input, {
|
|
3578
|
+
text,
|
|
3579
|
+
images: images.length,
|
|
3580
|
+
files: files.length
|
|
3581
|
+
});
|
|
3582
|
+
} catch (err) {
|
|
3583
|
+
await removeAttachments(written);
|
|
3584
|
+
throw err;
|
|
3585
|
+
}
|
|
3211
3586
|
return c.body(null, 202);
|
|
3212
3587
|
});
|
|
3213
3588
|
app2.get("/:sessionId/goal", (c) => {
|
|
@@ -3661,8 +4036,8 @@ var ErrorRecorder = class {
|
|
|
3661
4036
|
};
|
|
3662
4037
|
|
|
3663
4038
|
// src/runtime/session-manager.ts
|
|
3664
|
-
import
|
|
3665
|
-
import
|
|
4039
|
+
import fs9 from "fs/promises";
|
|
4040
|
+
import path9 from "path";
|
|
3666
4041
|
import {
|
|
3667
4042
|
createAgent,
|
|
3668
4043
|
findLatestTraceFile,
|
|
@@ -3670,6 +4045,7 @@ import {
|
|
|
3670
4045
|
goalTokenDelta,
|
|
3671
4046
|
isGoalRoundInput,
|
|
3672
4047
|
isSessionMeta as isSessionMeta2,
|
|
4048
|
+
parseUserSteeringText,
|
|
3673
4049
|
stripLeadingMarkerBlocks,
|
|
3674
4050
|
tracesDir
|
|
3675
4051
|
} from "@prismshadow/penguin-core";
|
|
@@ -3922,14 +4298,26 @@ var SessionSources = class {
|
|
|
3922
4298
|
|
|
3923
4299
|
// src/runtime/stream-error-watcher.ts
|
|
3924
4300
|
import { isEventMessage, isModelMessage, isSessionMeta } from "@prismshadow/penguin-core";
|
|
3925
|
-
import
|
|
4301
|
+
import path8 from "path";
|
|
3926
4302
|
var TOOL_NAMES_MAX = 1e3;
|
|
3927
4303
|
var ORIGIN_CTX_MAX = 200;
|
|
3928
4304
|
var LLM_FAILURES = {
|
|
3929
|
-
|
|
3930
|
-
//
|
|
3931
|
-
//
|
|
3932
|
-
|
|
4305
|
+
// Reached here only when no further attempt followed: the ladder ran out (or the run ended
|
|
4306
|
+
// on it), so the user lost the turn and someone should look. A `failed` that was retried
|
|
4307
|
+
// takes LLM_FAILED_RETRIED instead.
|
|
4308
|
+
failed: {
|
|
4309
|
+
code: "llm_failed",
|
|
4310
|
+
kind: "unexpected",
|
|
4311
|
+
text: "LLM request failed and the retries did not recover it."
|
|
4312
|
+
},
|
|
4313
|
+
// Its own code, not the failed bucket: dedup is `(source, code, Project)` over a short
|
|
4314
|
+
// window, and `llm_failed_retried` can now fire on any recovered blip — sharing a bucket
|
|
4315
|
+
// would let a genuine credential failure be dropped as a duplicate of one.
|
|
4316
|
+
auth: {
|
|
4317
|
+
code: "llm_auth",
|
|
4318
|
+
kind: "unexpected",
|
|
4319
|
+
text: "LLM request rejected: the provider did not accept the credentials."
|
|
4320
|
+
},
|
|
3933
4321
|
timeout: {
|
|
3934
4322
|
code: "llm_timeout",
|
|
3935
4323
|
kind: "expected",
|
|
@@ -3941,12 +4329,23 @@ var LLM_FAILURES = {
|
|
|
3941
4329
|
text: "LLM response could not be parsed (the engine reconnects and retries)."
|
|
3942
4330
|
}
|
|
3943
4331
|
};
|
|
4332
|
+
var LLM_FAILED_RETRIED = {
|
|
4333
|
+
code: "llm_failed_retried",
|
|
4334
|
+
kind: "expected",
|
|
4335
|
+
text: "LLM request failed (the engine reconnects and retries)."
|
|
4336
|
+
};
|
|
3944
4337
|
function isLlmFailure(s) {
|
|
3945
4338
|
return s === "failed" || s === "timeout" || s === "malformed" || s === "auth";
|
|
3946
4339
|
}
|
|
3947
4340
|
function isToolFailure(s) {
|
|
3948
4341
|
return s === "failed" || s === "timeout";
|
|
3949
4342
|
}
|
|
4343
|
+
var COMMAND_TOOLS = /* @__PURE__ */ new Set(["exec_command", "input_command"]);
|
|
4344
|
+
var ORDINARY_EXIT_NOTE = /\[exit code: [^\]\n]*\]\s*$/;
|
|
4345
|
+
function isOrdinaryCommandExit(name, output) {
|
|
4346
|
+
if (name !== void 0 && !COMMAND_TOOLS.has(name)) return false;
|
|
4347
|
+
return ORDINARY_EXIT_NOTE.test(output);
|
|
4348
|
+
}
|
|
3950
4349
|
function isUserAbortReason(reason) {
|
|
3951
4350
|
return /^aborted\b/i.test(reason);
|
|
3952
4351
|
}
|
|
@@ -4004,7 +4403,7 @@ var StreamErrorWatcher = class {
|
|
|
4004
4403
|
registerOrigin(msg) {
|
|
4005
4404
|
const key = originKey(msg);
|
|
4006
4405
|
if (!key) return;
|
|
4007
|
-
const agentId =
|
|
4406
|
+
const agentId = path8.basename(path8.dirname(msg.payload.agent_state));
|
|
4008
4407
|
if (!agentId || agentId === "." || agentId === "..") return;
|
|
4009
4408
|
this.originCtx.delete(key);
|
|
4010
4409
|
this.originCtx.set(key, { agentId, sessionId: key });
|
|
@@ -4041,7 +4440,7 @@ var StreamErrorWatcher = class {
|
|
|
4041
4440
|
return;
|
|
4042
4441
|
}
|
|
4043
4442
|
if (p.type === "request_begin") {
|
|
4044
|
-
this.flush(key);
|
|
4443
|
+
this.flush(key, null, true);
|
|
4045
4444
|
return;
|
|
4046
4445
|
}
|
|
4047
4446
|
if (p.type === "abort") {
|
|
@@ -4050,15 +4449,21 @@ var StreamErrorWatcher = class {
|
|
|
4050
4449
|
}
|
|
4051
4450
|
/**
|
|
4052
4451
|
* Persist a pending LLM failure (no-op if none is pending); `reason` is the abort
|
|
4053
|
-
* message that arrived afterward
|
|
4452
|
+
* message that arrived afterward, and `retried` says whether another attempt followed
|
|
4453
|
+
* (only a `request_begin` proves that). Pending state is already bucketed by origin, so
|
|
4054
4454
|
* `key` is exactly "the session that produced this failure" — attribution is looked
|
|
4055
4455
|
* up from it (see file header).
|
|
4456
|
+
*
|
|
4457
|
+
* `retried` defaults to false so every other resolution — an abort, the defensive flush at
|
|
4458
|
+
* the next request_end, or close() at the end of the run — is read as "nothing came after
|
|
4459
|
+
* this failure". That is the conservative direction: at worst a recovered blip is escalated,
|
|
4460
|
+
* never a lost turn silently downgraded.
|
|
4056
4461
|
*/
|
|
4057
|
-
flush(key, reason) {
|
|
4462
|
+
flush(key, reason, retried = false) {
|
|
4058
4463
|
const entry = this.pending.get(key);
|
|
4059
4464
|
if (entry === void 0) return;
|
|
4060
4465
|
this.pending.delete(key);
|
|
4061
|
-
const spec = LLM_FAILURES[entry.status];
|
|
4466
|
+
const spec = retried && entry.status === "failed" ? LLM_FAILED_RETRIED : LLM_FAILURES[entry.status];
|
|
4062
4467
|
const trimmed = reason?.trim();
|
|
4063
4468
|
const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : entry.message ?? spec.text;
|
|
4064
4469
|
this.errors.record({
|
|
@@ -4088,9 +4493,11 @@ var StreamErrorWatcher = class {
|
|
|
4088
4493
|
const name = this.toolNames.get(key);
|
|
4089
4494
|
this.toolNames.delete(key);
|
|
4090
4495
|
if (!isToolFailure(p.stop_reason)) return;
|
|
4496
|
+
const output = p.output ?? "";
|
|
4497
|
+
if (isOrdinaryCommandExit(name, output)) return;
|
|
4091
4498
|
this.errors.record({
|
|
4092
4499
|
source: "environment",
|
|
4093
|
-
err: toolFailureText(
|
|
4500
|
+
err: toolFailureText(output),
|
|
4094
4501
|
ctx: this.ctxFor(origin),
|
|
4095
4502
|
// The tool name goes into code: so the stats dashboard's "most common error code" and table can show which tool failed.
|
|
4096
4503
|
code: `tool_${p.stop_reason}:${name ?? "unknown"}`,
|
|
@@ -4129,7 +4536,7 @@ function createCoreSessionLoader(root, sources) {
|
|
|
4129
4536
|
}
|
|
4130
4537
|
}
|
|
4131
4538
|
try {
|
|
4132
|
-
const stat = await
|
|
4539
|
+
const stat = await fs9.stat(row.workspace);
|
|
4133
4540
|
if (!stat.isDirectory()) throw new Error("not a directory");
|
|
4134
4541
|
} catch {
|
|
4135
4542
|
throw new HttpError(
|
|
@@ -4169,6 +4576,11 @@ var EARLY_TITLE_BODY_CHARS = 1e3;
|
|
|
4169
4576
|
function agentKey(projectId, agentId) {
|
|
4170
4577
|
return `${projectId}\0${agentId}`;
|
|
4171
4578
|
}
|
|
4579
|
+
function isDeliveredSteering(msg) {
|
|
4580
|
+
const p = msg.payload;
|
|
4581
|
+
if (msg.type !== "model_msg" || p.type !== "text" || p.role !== "user") return false;
|
|
4582
|
+
return typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
|
|
4583
|
+
}
|
|
4172
4584
|
function runSubagentCall(msg) {
|
|
4173
4585
|
const p = msg.payload;
|
|
4174
4586
|
if (msg.type !== "model_msg" || p.type !== "tool_call" || p.name !== "run_subagent") return null;
|
|
@@ -4241,6 +4653,10 @@ var SessionManager = class {
|
|
|
4241
4653
|
pendingFollowUpCount(sessionId) {
|
|
4242
4654
|
return this.entries.get(sessionId)?.followUps.length ?? 0;
|
|
4243
4655
|
}
|
|
4656
|
+
/** Steering messages queued but not yet delivered to the model (display mirror; see RuntimeEntry.pendingSteering). */
|
|
4657
|
+
pendingSteeringOf(sessionId) {
|
|
4658
|
+
return this.entries.get(sessionId)?.pendingSteering ?? [];
|
|
4659
|
+
}
|
|
4244
4660
|
/**
|
|
4245
4661
|
* Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
|
|
4246
4662
|
* streaming fragment, carrying the full accumulated content so far (see live-tail.ts).
|
|
@@ -4273,6 +4689,7 @@ var SessionManager = class {
|
|
|
4273
4689
|
running: null,
|
|
4274
4690
|
generation: this.generationOf(row.projectId, row.agentId),
|
|
4275
4691
|
followUps: [],
|
|
4692
|
+
pendingSteering: [],
|
|
4276
4693
|
lastActivityMs: Date.now()
|
|
4277
4694
|
});
|
|
4278
4695
|
}
|
|
@@ -4305,6 +4722,23 @@ var SessionManager = class {
|
|
|
4305
4722
|
for (const agentId of agentIds) this.invalidateAgentRuntimes(projectId, agentId);
|
|
4306
4723
|
}
|
|
4307
4724
|
// —— Task / compaction drive ——
|
|
4725
|
+
/**
|
|
4726
|
+
* Cheap, lock-free rehearsal of the 409/503 conditions startTask checks, throwing exactly the
|
|
4727
|
+
* same HttpErrors. **Advisory only**: it neither takes the Session lock nor loads an entry, so
|
|
4728
|
+
* a session that isn't in the active table reads as acceptable and a status change racing this
|
|
4729
|
+
* call is not caught — the authoritative check is still the one inside startTask.
|
|
4730
|
+
*
|
|
4731
|
+
* It exists so a caller that has irreversible work to do first (POST /tasks writes the
|
|
4732
|
+
* message's file attachments to disk) can find out about the ordinary "a Task is already
|
|
4733
|
+
* running" rejection before doing it, instead of undoing it afterwards.
|
|
4734
|
+
*/
|
|
4735
|
+
assertCanAcceptTask(sessionId, opts) {
|
|
4736
|
+
this.assertOpen();
|
|
4737
|
+
this.assertAgentNotDeleting(sessionId);
|
|
4738
|
+
this.assertSessionNotDeleting(sessionId);
|
|
4739
|
+
const entry = this.entries.get(sessionId);
|
|
4740
|
+
if (entry && !opts?.queueIfBusy) this.assertIdle(entry);
|
|
4741
|
+
}
|
|
4308
4742
|
/**
|
|
4309
4743
|
* Start a Task: get-or-load → 409
|
|
4310
4744
|
* mutual-exclusion check → publish the input messages first → drive run in the
|
|
@@ -4543,22 +4977,30 @@ var SessionManager = class {
|
|
|
4543
4977
|
return entry.approvals.decide(toolCallId, decision);
|
|
4544
4978
|
}
|
|
4545
4979
|
/**
|
|
4546
|
-
* Mid-run steering: forward the
|
|
4547
|
-
* turns as a standalone `[user_steering]` user message
|
|
4548
|
-
*
|
|
4549
|
-
* Session isn't running a Task (idle / compacting / not loaded)
|
|
4550
|
-
* race window — the caller falls back to submitting a normal
|
|
4980
|
+
* Mid-run steering: forward the message to the running Session (core delivers it between
|
|
4981
|
+
* turns as a standalone `[user_steering]` user message followed by its images — no SSE
|
|
4982
|
+
* event of its own; the messages arrive through the stream the drive loop already
|
|
4983
|
+
* publishes). 409 when the Session isn't running a Task (idle / compacting / not loaded)
|
|
4984
|
+
* or the run finished in the race window — the caller falls back to submitting a normal
|
|
4985
|
+
* task, which carries the same text and images.
|
|
4986
|
+
*
|
|
4987
|
+
* `info` is the queued message's display summary: mirrored on the entry and broadcast via
|
|
4988
|
+
* `task_state` (and the SSE subscribe snapshot) until the delivered `[user_steering]`
|
|
4989
|
+
* message is observed on the stream — that is what keeps the composer's "steering queued"
|
|
4990
|
+
* hint, content included, alive across reloads.
|
|
4551
4991
|
*/
|
|
4552
|
-
steer(sessionId,
|
|
4992
|
+
steer(sessionId, input, info) {
|
|
4553
4993
|
const entry = this.entries.get(sessionId);
|
|
4554
|
-
if (!entry || entry.status !== "running" || !entry.session.steer(
|
|
4994
|
+
if (!entry || entry.status !== "running" || !entry.session.steer(input)) {
|
|
4555
4995
|
throw new HttpError(
|
|
4556
4996
|
409,
|
|
4557
4997
|
"not_running",
|
|
4558
4998
|
"This Session has no Task in progress; send the message as a new task instead."
|
|
4559
4999
|
);
|
|
4560
5000
|
}
|
|
5001
|
+
entry.pendingSteering.push(info);
|
|
4561
5002
|
entry.lastActivityMs = Date.now();
|
|
5003
|
+
this.publishState(entry, entry.status);
|
|
4562
5004
|
}
|
|
4563
5005
|
/**
|
|
4564
5006
|
* "Retry now" on the reconnect countdown: skip the in-progress backoff wait and fire
|
|
@@ -4773,6 +5215,7 @@ var SessionManager = class {
|
|
|
4773
5215
|
running: null,
|
|
4774
5216
|
generation,
|
|
4775
5217
|
followUps: [],
|
|
5218
|
+
pendingSteering: [],
|
|
4776
5219
|
lastActivityMs: Date.now()
|
|
4777
5220
|
};
|
|
4778
5221
|
this.entries.set(currentId, entry);
|
|
@@ -4785,6 +5228,7 @@ var SessionManager = class {
|
|
|
4785
5228
|
* a title): it collects model text for automatic title generation.
|
|
4786
5229
|
*/
|
|
4787
5230
|
async drive(entry, gen, titleSource) {
|
|
5231
|
+
this.deps.sessions.markHasTrace(entry.sessionId);
|
|
4788
5232
|
let earlyTitleFired = false;
|
|
4789
5233
|
let mainBodyChars = 0;
|
|
4790
5234
|
const ctx = {
|
|
@@ -4810,6 +5254,10 @@ var SessionManager = class {
|
|
|
4810
5254
|
if (denied) subagentPrompts.delete(denied);
|
|
4811
5255
|
const settled = settledToolCallId(msg);
|
|
4812
5256
|
if (settled) subagentPrompts.delete(settled);
|
|
5257
|
+
if (entry.pendingSteering.length > 0 && isDeliveredSteering(msg)) {
|
|
5258
|
+
entry.pendingSteering.shift();
|
|
5259
|
+
this.publishState(entry, entry.status);
|
|
5260
|
+
}
|
|
4813
5261
|
} else if (isSessionMeta2(msg)) {
|
|
4814
5262
|
try {
|
|
4815
5263
|
const child = this.registerChildSession(entry, msg, children);
|
|
@@ -4872,6 +5320,7 @@ var SessionManager = class {
|
|
|
4872
5320
|
entry.status = "idle";
|
|
4873
5321
|
entry.abort = null;
|
|
4874
5322
|
entry.running = null;
|
|
5323
|
+
entry.pendingSteering = [];
|
|
4875
5324
|
entry.lastActivityMs = Date.now();
|
|
4876
5325
|
this.publishState(entry, "idle");
|
|
4877
5326
|
if (titleSource && titleSource.userExcerpt.trim()) {
|
|
@@ -4915,7 +5364,7 @@ var SessionManager = class {
|
|
|
4915
5364
|
const childSid = msg.origin[msg.origin.length - 1];
|
|
4916
5365
|
if (children.has(childSid)) return null;
|
|
4917
5366
|
const p = msg.payload;
|
|
4918
|
-
const agentId =
|
|
5367
|
+
const agentId = path9.basename(path9.dirname(p.agent_state));
|
|
4919
5368
|
if (!agentId || agentId === "." || agentId === "..") return null;
|
|
4920
5369
|
const source = asSessionSource(p.source) ?? "subagent";
|
|
4921
5370
|
this.deps.sources.set(childSid, source);
|
|
@@ -4930,6 +5379,8 @@ var SessionManager = class {
|
|
|
4930
5379
|
// inserted with defaults (matches the convention for Sessions discovered by the CLI).
|
|
4931
5380
|
approvalMode: "allow-all",
|
|
4932
5381
|
title: null,
|
|
5382
|
+
// Spawned by this server's run (client NULL = web); its Trace exists by construction.
|
|
5383
|
+
hasTrace: true,
|
|
4933
5384
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4934
5385
|
});
|
|
4935
5386
|
this.publishEvent(entry, {
|
|
@@ -4950,7 +5401,12 @@ var SessionManager = class {
|
|
|
4950
5401
|
return child;
|
|
4951
5402
|
}
|
|
4952
5403
|
publishState(entry, state) {
|
|
4953
|
-
this.publishEvent(entry, {
|
|
5404
|
+
this.publishEvent(entry, {
|
|
5405
|
+
type: "task_state",
|
|
5406
|
+
state,
|
|
5407
|
+
queued: entry.followUps.length,
|
|
5408
|
+
...entry.pendingSteering.length > 0 ? { pendingSteering: entry.pendingSteering } : {}
|
|
5409
|
+
});
|
|
4954
5410
|
}
|
|
4955
5411
|
publishEvent(entry, event) {
|
|
4956
5412
|
this.deps.channels.get(entry.sessionId).publish(event, "server_event");
|
|
@@ -5394,7 +5850,7 @@ var UsageRecorder = class {
|
|
|
5394
5850
|
};
|
|
5395
5851
|
|
|
5396
5852
|
// src/services/ids.ts
|
|
5397
|
-
import { randomBytes as
|
|
5853
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
5398
5854
|
var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
|
|
5399
5855
|
var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
|
|
5400
5856
|
var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
|
|
@@ -5470,7 +5926,7 @@ var AdminService = class {
|
|
|
5470
5926
|
};
|
|
5471
5927
|
|
|
5472
5928
|
// src/services/agent-config-service.ts
|
|
5473
|
-
import
|
|
5929
|
+
import fs11 from "fs/promises";
|
|
5474
5930
|
import { parseDocument, parse as parseYaml } from "yaml";
|
|
5475
5931
|
import {
|
|
5476
5932
|
agentsMdPath,
|
|
@@ -5485,8 +5941,8 @@ import {
|
|
|
5485
5941
|
} from "@prismshadow/penguin-core";
|
|
5486
5942
|
|
|
5487
5943
|
// src/services/project-config-service.ts
|
|
5488
|
-
import
|
|
5489
|
-
import
|
|
5944
|
+
import fs10 from "fs/promises";
|
|
5945
|
+
import path10 from "path";
|
|
5490
5946
|
import { parse as parseToml2 } from "smol-toml";
|
|
5491
5947
|
import {
|
|
5492
5948
|
GenerativeModel,
|
|
@@ -5545,7 +6001,7 @@ var ProjectConfigService = class {
|
|
|
5545
6001
|
*/
|
|
5546
6002
|
async configUpdatedAt(projectId) {
|
|
5547
6003
|
try {
|
|
5548
|
-
const st = await
|
|
6004
|
+
const st = await fs10.stat(this.filePath(projectId));
|
|
5549
6005
|
return st.mtime.toISOString();
|
|
5550
6006
|
} catch {
|
|
5551
6007
|
return void 0;
|
|
@@ -5555,7 +6011,7 @@ var ProjectConfigService = class {
|
|
|
5555
6011
|
async readRaw(projectId) {
|
|
5556
6012
|
let raw;
|
|
5557
6013
|
try {
|
|
5558
|
-
raw = await
|
|
6014
|
+
raw = await fs10.readFile(this.filePath(projectId), "utf8");
|
|
5559
6015
|
} catch (err) {
|
|
5560
6016
|
if (err.code === "ENOENT") return {};
|
|
5561
6017
|
throw err;
|
|
@@ -5570,9 +6026,9 @@ var ProjectConfigService = class {
|
|
|
5570
6026
|
*/
|
|
5571
6027
|
async writeRaw(projectId, data) {
|
|
5572
6028
|
const file = this.filePath(projectId);
|
|
5573
|
-
await
|
|
5574
|
-
await
|
|
5575
|
-
await
|
|
6029
|
+
await fs10.mkdir(path10.dirname(file), { recursive: true });
|
|
6030
|
+
await fs10.writeFile(file, renderProjectConfigToml(data), { encoding: "utf8", mode: 384 });
|
|
6031
|
+
await fs10.chmod(file, 384);
|
|
5576
6032
|
}
|
|
5577
6033
|
/**
|
|
5578
6034
|
* Initial config for a newly created Project: display name + preset built-in
|
|
@@ -5616,6 +6072,15 @@ var ProjectConfigService = class {
|
|
|
5616
6072
|
const raw = await this.readRaw(projectId);
|
|
5617
6073
|
return typeof raw.name === "string" ? raw.name : void 0;
|
|
5618
6074
|
}
|
|
6075
|
+
/**
|
|
6076
|
+
* Rewrites the display name, preserving every other field (models, credentials, default
|
|
6077
|
+
* model): read-modify-write of the same toml, like ensurePresetModels. The id itself is
|
|
6078
|
+
* immutable — only this label changes.
|
|
6079
|
+
*/
|
|
6080
|
+
async setName(projectId, name) {
|
|
6081
|
+
const raw = await this.readRaw(projectId);
|
|
6082
|
+
await this.writeRaw(projectId, { ...raw, name });
|
|
6083
|
+
}
|
|
5619
6084
|
/** Paired reference of the default Model; returns undefined if unconfigured (or in the old string format). */
|
|
5620
6085
|
async getDefaultModelRef(projectId) {
|
|
5621
6086
|
const raw = await this.readRaw(projectId);
|
|
@@ -5936,7 +6401,7 @@ var AgentConfigService = class {
|
|
|
5936
6401
|
/** Whether the Agent exists (determined by the presence of system_config.yaml, matching the CLI's convention). */
|
|
5937
6402
|
async exists(projectId, agentId) {
|
|
5938
6403
|
try {
|
|
5939
|
-
await
|
|
6404
|
+
await fs11.access(systemConfigPath(this.root, projectId, agentId));
|
|
5940
6405
|
return true;
|
|
5941
6406
|
} catch {
|
|
5942
6407
|
return false;
|
|
@@ -5954,7 +6419,7 @@ var AgentConfigService = class {
|
|
|
5954
6419
|
*/
|
|
5955
6420
|
async readCardMeta(projectId, agentId) {
|
|
5956
6421
|
try {
|
|
5957
|
-
const raw = await
|
|
6422
|
+
const raw = await fs11.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
|
|
5958
6423
|
const parsed = asRecord(parseYaml(raw));
|
|
5959
6424
|
const tools = asRecord(parsed.tools);
|
|
5960
6425
|
const countOf = (v) => Array.isArray(v) ? v.length : 0;
|
|
@@ -5972,14 +6437,14 @@ var AgentConfigService = class {
|
|
|
5972
6437
|
async getConfig(projectId, agentId) {
|
|
5973
6438
|
await this.requireExists(projectId, agentId);
|
|
5974
6439
|
const yamlPath = systemConfigPath(this.root, projectId, agentId);
|
|
5975
|
-
const systemConfigYaml = await
|
|
6440
|
+
const systemConfigYaml = await fs11.readFile(yamlPath, "utf8");
|
|
5976
6441
|
const parsed = asRecord(parseYaml(systemConfigYaml));
|
|
5977
6442
|
const model = asRecord(parsed.model);
|
|
5978
6443
|
const compaction = asRecord(parsed.compaction);
|
|
5979
6444
|
const tools = asRecord(parsed.tools);
|
|
5980
6445
|
let agentsMd = "";
|
|
5981
6446
|
try {
|
|
5982
|
-
agentsMd = await
|
|
6447
|
+
agentsMd = await fs11.readFile(agentsMdPath(this.root, projectId, agentId), "utf8");
|
|
5983
6448
|
} catch {
|
|
5984
6449
|
}
|
|
5985
6450
|
const modelDto = {
|
|
@@ -6022,7 +6487,7 @@ var AgentConfigService = class {
|
|
|
6022
6487
|
await this.applyConfigUpdate(projectId, agentId, req.config);
|
|
6023
6488
|
}
|
|
6024
6489
|
if (req.agentsMd !== void 0) {
|
|
6025
|
-
await
|
|
6490
|
+
await fs11.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
|
|
6026
6491
|
}
|
|
6027
6492
|
}
|
|
6028
6493
|
/**
|
|
@@ -6040,9 +6505,9 @@ var AgentConfigService = class {
|
|
|
6040
6505
|
async applyConfigUpdate(projectId, agentId, config2) {
|
|
6041
6506
|
const cfg = config2;
|
|
6042
6507
|
const yamlPath = systemConfigPath(this.root, projectId, agentId);
|
|
6043
|
-
const doc = parseDocument(await
|
|
6044
|
-
const setIfProvided = (
|
|
6045
|
-
if (value !== void 0) doc.setIn(
|
|
6508
|
+
const doc = parseDocument(await fs11.readFile(yamlPath, "utf8"));
|
|
6509
|
+
const setIfProvided = (path18, value) => {
|
|
6510
|
+
if (value !== void 0) doc.setIn(path18, value);
|
|
6046
6511
|
};
|
|
6047
6512
|
setIfProvided(["name"], optionalString(cfg, "name", { maxLen: 100, label: "name" }));
|
|
6048
6513
|
setIfProvided(
|
|
@@ -6091,7 +6556,7 @@ var AgentConfigService = class {
|
|
|
6091
6556
|
if (cfg.mcpServers !== void 0) {
|
|
6092
6557
|
doc.setIn(["tools", "mcpServers"], validateMcpServers(cfg.mcpServers));
|
|
6093
6558
|
}
|
|
6094
|
-
await
|
|
6559
|
+
await fs11.writeFile(yamlPath, doc.toString(), "utf8");
|
|
6095
6560
|
}
|
|
6096
6561
|
/** Read the Agent vault (agent_state/.vault.toml): values are always masked, plaintext is never sent to the client. */
|
|
6097
6562
|
async getVault(projectId, agentId) {
|
|
@@ -6188,8 +6653,8 @@ function validateMcpServers(value) {
|
|
|
6188
6653
|
}
|
|
6189
6654
|
|
|
6190
6655
|
// src/services/agent-service.ts
|
|
6191
|
-
import
|
|
6192
|
-
import
|
|
6656
|
+
import fs12 from "fs/promises";
|
|
6657
|
+
import path11 from "path";
|
|
6193
6658
|
import {
|
|
6194
6659
|
agentDir,
|
|
6195
6660
|
agentsDir as agentsDir2,
|
|
@@ -6216,7 +6681,7 @@ var AgentService = class {
|
|
|
6216
6681
|
const known = new Map(this.agents.list(projectId).map((r) => [r.agentId, r]));
|
|
6217
6682
|
let entries = [];
|
|
6218
6683
|
try {
|
|
6219
|
-
const dirents = await
|
|
6684
|
+
const dirents = await fs12.readdir(agentsDir2(this.root, projectId), { withFileTypes: true });
|
|
6220
6685
|
entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
|
|
6221
6686
|
} catch {
|
|
6222
6687
|
}
|
|
@@ -6225,7 +6690,7 @@ var AgentService = class {
|
|
|
6225
6690
|
const configPath = systemConfigPath2(this.root, projectId, agentId);
|
|
6226
6691
|
let createdAt;
|
|
6227
6692
|
try {
|
|
6228
|
-
const stat = await
|
|
6693
|
+
const stat = await fs12.stat(configPath);
|
|
6229
6694
|
createdAt = (stat.birthtime.getTime() > 0 ? stat.birthtime : stat.mtime).toISOString();
|
|
6230
6695
|
} catch {
|
|
6231
6696
|
continue;
|
|
@@ -6269,7 +6734,7 @@ var AgentService = class {
|
|
|
6269
6734
|
/** Number of scheduled tasks: count of .toml files under schedule/ (0 if the directory doesn't exist). */
|
|
6270
6735
|
async scheduleCount(projectId, agentId) {
|
|
6271
6736
|
try {
|
|
6272
|
-
const names = await
|
|
6737
|
+
const names = await fs12.readdir(scheduleDir2(this.root, projectId, agentId));
|
|
6273
6738
|
return names.filter((n) => n.endsWith(".toml")).length;
|
|
6274
6739
|
} catch {
|
|
6275
6740
|
return 0;
|
|
@@ -6280,14 +6745,14 @@ var AgentService = class {
|
|
|
6280
6745
|
const base = skillsDir2(this.root, projectId, agentId);
|
|
6281
6746
|
let dirents;
|
|
6282
6747
|
try {
|
|
6283
|
-
dirents = await
|
|
6748
|
+
dirents = await fs12.readdir(base, { withFileTypes: true });
|
|
6284
6749
|
} catch {
|
|
6285
6750
|
return 0;
|
|
6286
6751
|
}
|
|
6287
6752
|
const present = await Promise.all(
|
|
6288
6753
|
dirents.filter((d) => d.isDirectory()).map(async (d) => {
|
|
6289
6754
|
try {
|
|
6290
|
-
await
|
|
6755
|
+
await fs12.access(path11.join(base, d.name, "SKILL.md"));
|
|
6291
6756
|
return true;
|
|
6292
6757
|
} catch {
|
|
6293
6758
|
return false;
|
|
@@ -6305,7 +6770,7 @@ var AgentService = class {
|
|
|
6305
6770
|
const times = await Promise.all(
|
|
6306
6771
|
paths.map(async (p) => {
|
|
6307
6772
|
try {
|
|
6308
|
-
return (await
|
|
6773
|
+
return (await fs12.stat(p)).mtime.getTime();
|
|
6309
6774
|
} catch {
|
|
6310
6775
|
return 0;
|
|
6311
6776
|
}
|
|
@@ -6330,7 +6795,7 @@ var AgentService = class {
|
|
|
6330
6795
|
"Built-in Agents (default_agent) are provisioned with the Project and cannot be deleted from the web."
|
|
6331
6796
|
);
|
|
6332
6797
|
}
|
|
6333
|
-
await
|
|
6798
|
+
await fs12.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
|
|
6334
6799
|
this.agents.delete(projectId, agentId);
|
|
6335
6800
|
}
|
|
6336
6801
|
/**
|
|
@@ -6347,7 +6812,7 @@ var AgentService = class {
|
|
|
6347
6812
|
`Agent id must be 2\u201364 characters: ${SEMANTIC_ID_RULE}.`
|
|
6348
6813
|
);
|
|
6349
6814
|
}
|
|
6350
|
-
const taken = this.agents.exists(projectId, agentId) || await
|
|
6815
|
+
const taken = this.agents.exists(projectId, agentId) || await fs12.stat(agentDir(this.root, projectId, agentId)).then(
|
|
6351
6816
|
() => true,
|
|
6352
6817
|
() => false
|
|
6353
6818
|
);
|
|
@@ -6361,7 +6826,7 @@ var AgentService = class {
|
|
|
6361
6826
|
config: { name: displayName, ...description !== void 0 ? { description } : {} }
|
|
6362
6827
|
});
|
|
6363
6828
|
} catch (err) {
|
|
6364
|
-
await
|
|
6829
|
+
await fs12.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true }).catch(() => {
|
|
6365
6830
|
});
|
|
6366
6831
|
throw err;
|
|
6367
6832
|
}
|
|
@@ -6385,120 +6850,223 @@ var AgentService = class {
|
|
|
6385
6850
|
};
|
|
6386
6851
|
|
|
6387
6852
|
// src/services/benchmark-service.ts
|
|
6388
|
-
import
|
|
6389
|
-
import
|
|
6853
|
+
import fs13 from "fs/promises";
|
|
6854
|
+
import path12 from "path";
|
|
6390
6855
|
import { parse as parseToml3 } from "smol-toml";
|
|
6391
6856
|
import { parse as parseYaml2 } from "yaml";
|
|
6392
6857
|
import { benchmarksDir } from "@prismshadow/penguin-core";
|
|
6858
|
+
var STATEMENT_TITLE_READ_BYTES = 64 * 1024;
|
|
6393
6859
|
function asRecord2(v) {
|
|
6394
6860
|
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
6395
6861
|
}
|
|
6396
6862
|
function numberOr(v) {
|
|
6397
6863
|
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
6398
6864
|
}
|
|
6865
|
+
function scoreOr(v) {
|
|
6866
|
+
const value = numberOr(v);
|
|
6867
|
+
return value !== void 0 && value >= 0 && value <= 100 ? value : void 0;
|
|
6868
|
+
}
|
|
6869
|
+
function nonNegativeOr(v) {
|
|
6870
|
+
const value = numberOr(v);
|
|
6871
|
+
return value !== void 0 && value >= 0 ? value : void 0;
|
|
6872
|
+
}
|
|
6873
|
+
function nonNegativeIntegerOr(v) {
|
|
6874
|
+
const value = nonNegativeOr(v);
|
|
6875
|
+
return value !== void 0 && Number.isInteger(value) ? value : void 0;
|
|
6876
|
+
}
|
|
6877
|
+
function nullableCostOr(v) {
|
|
6878
|
+
if (v === null) return null;
|
|
6879
|
+
return nonNegativeOr(v);
|
|
6880
|
+
}
|
|
6399
6881
|
function stringOr(v) {
|
|
6400
6882
|
return typeof v === "string" && v !== "" ? v : void 0;
|
|
6401
6883
|
}
|
|
6884
|
+
function isWithin(parent, child) {
|
|
6885
|
+
const relative = path12.relative(parent, child);
|
|
6886
|
+
return relative !== "" && !relative.startsWith("..") && !path12.isAbsolute(relative);
|
|
6887
|
+
}
|
|
6888
|
+
function statementTitle(statement, fallback) {
|
|
6889
|
+
const heading = /^#\s+(.+)$/m.exec(statement)?.[1]?.trim();
|
|
6890
|
+
return heading?.replace(/^Case\s+\d+\s*:\s*/i, "") || fallback;
|
|
6891
|
+
}
|
|
6892
|
+
async function readStatementTitle(readme, fallback) {
|
|
6893
|
+
const handle = await fs13.open(readme, "r");
|
|
6894
|
+
try {
|
|
6895
|
+
const buffer = Buffer.alloc(STATEMENT_TITLE_READ_BYTES);
|
|
6896
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
6897
|
+
return statementTitle(buffer.subarray(0, bytesRead).toString("utf8"), fallback);
|
|
6898
|
+
} finally {
|
|
6899
|
+
await handle.close();
|
|
6900
|
+
}
|
|
6901
|
+
}
|
|
6402
6902
|
function toRun(v) {
|
|
6403
6903
|
const r = asRecord2(v);
|
|
6404
|
-
const score =
|
|
6405
|
-
|
|
6406
|
-
const
|
|
6407
|
-
const durationMs = numberOr(r.duration_ms);
|
|
6904
|
+
const score = scoreOr(r.score);
|
|
6905
|
+
const cost = nullableCostOr(r.cost);
|
|
6906
|
+
const durationMs = nonNegativeIntegerOr(r.duration_ms);
|
|
6408
6907
|
const sessionId = stringOr(r.session_id);
|
|
6409
|
-
|
|
6410
|
-
|
|
6411
|
-
|
|
6412
|
-
...durationMs !== void 0 ? { durationMs } : {},
|
|
6413
|
-
...sessionId !== void 0 ? { sessionId } : {}
|
|
6414
|
-
};
|
|
6415
|
-
}
|
|
6416
|
-
function averageOf(runs, pick) {
|
|
6417
|
-
const values = runs.map(pick).filter((v) => v !== void 0);
|
|
6418
|
-
if (values.length === 0) return void 0;
|
|
6419
|
-
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
6908
|
+
if (score === void 0 || cost === void 0 || durationMs === void 0 || !sessionId)
|
|
6909
|
+
return null;
|
|
6910
|
+
return { score, cost, durationMs, sessionId };
|
|
6420
6911
|
}
|
|
6421
6912
|
function toCase(v) {
|
|
6422
6913
|
const cr = asRecord2(v);
|
|
6423
6914
|
const caseId = stringOr(cr.case);
|
|
6424
|
-
|
|
6425
|
-
const
|
|
6426
|
-
const
|
|
6427
|
-
if (score === void 0)
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
const
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
{
|
|
6434
|
-
score,
|
|
6435
|
-
...cost !== void 0 ? { cost } : {},
|
|
6436
|
-
...durationMs !== void 0 ? { durationMs } : {},
|
|
6437
|
-
...sessionId !== void 0 ? { sessionId } : {}
|
|
6438
|
-
}
|
|
6439
|
-
];
|
|
6915
|
+
const score = scoreOr(cr.score);
|
|
6916
|
+
const cost = nullableCostOr(cr.cost);
|
|
6917
|
+
const durationMs = nonNegativeIntegerOr(cr.duration_ms);
|
|
6918
|
+
if (!caseId || score === void 0 || cost === void 0 || durationMs === void 0 || "max_score" in cr || !Array.isArray(cr.runs) || cr.runs.length === 0) {
|
|
6919
|
+
return null;
|
|
6920
|
+
}
|
|
6921
|
+
const parsedRuns = cr.runs.map(toRun);
|
|
6922
|
+
if (parsedRuns.some((run) => run === null)) return null;
|
|
6923
|
+
const runs = parsedRuns;
|
|
6440
6924
|
return {
|
|
6441
6925
|
case: caseId,
|
|
6442
6926
|
score,
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
...sessionId !== void 0 ? { sessionId } : {},
|
|
6927
|
+
cost,
|
|
6928
|
+
durationMs,
|
|
6446
6929
|
runs
|
|
6447
6930
|
};
|
|
6448
6931
|
}
|
|
6449
6932
|
function toEvaluation(v) {
|
|
6450
6933
|
const r = asRecord2(v);
|
|
6451
6934
|
const time = r.time instanceof Date ? r.time.toISOString() : r.time;
|
|
6452
|
-
const score =
|
|
6453
|
-
|
|
6454
|
-
const
|
|
6935
|
+
const score = scoreOr(r.score);
|
|
6936
|
+
const cost = nullableCostOr(r.cost);
|
|
6937
|
+
const durationMs = nonNegativeIntegerOr(r.duration_ms);
|
|
6455
6938
|
const summary = stringOr(r.summary);
|
|
6456
6939
|
const summaryTitle = stringOr(r.summary_title);
|
|
6457
6940
|
const modelId = stringOr(r.model_id);
|
|
6458
6941
|
const provider = stringOr(r.provider);
|
|
6459
|
-
const
|
|
6460
|
-
const
|
|
6461
|
-
|
|
6942
|
+
const thinkingLevel = stringOr(r.thinking_level);
|
|
6943
|
+
const version = nonNegativeIntegerOr(r.version);
|
|
6944
|
+
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) {
|
|
6945
|
+
return null;
|
|
6946
|
+
}
|
|
6947
|
+
const parsedCases = r.cases.map(toCase);
|
|
6948
|
+
if (parsedCases.some((item) => item === null)) return null;
|
|
6949
|
+
const cases = parsedCases;
|
|
6462
6950
|
return {
|
|
6463
6951
|
time,
|
|
6464
6952
|
...summaryTitle !== void 0 ? { summaryTitle } : {},
|
|
6465
6953
|
...summary !== void 0 ? { summary } : {},
|
|
6466
|
-
|
|
6467
|
-
|
|
6954
|
+
modelId,
|
|
6955
|
+
provider,
|
|
6956
|
+
thinkingLevel,
|
|
6468
6957
|
score,
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6958
|
+
version,
|
|
6959
|
+
cost,
|
|
6960
|
+
durationMs,
|
|
6472
6961
|
cases
|
|
6473
6962
|
};
|
|
6474
6963
|
}
|
|
6475
6964
|
var BenchmarkService = class {
|
|
6476
|
-
constructor(root) {
|
|
6965
|
+
constructor(root, workspaceFiles) {
|
|
6477
6966
|
this.root = root;
|
|
6967
|
+
this.workspaceFiles = workspaceFiles;
|
|
6478
6968
|
}
|
|
6479
6969
|
root;
|
|
6970
|
+
workspaceFiles;
|
|
6480
6971
|
async list(projectId, agentId) {
|
|
6481
6972
|
const dir = benchmarksDir(this.root, projectId, agentId);
|
|
6482
6973
|
let items;
|
|
6483
6974
|
try {
|
|
6484
|
-
const entries = await
|
|
6975
|
+
const entries = await fs13.readdir(dir, { withFileTypes: true });
|
|
6485
6976
|
items = entries.map((e) => ({ name: e.name, isDir: e.isDirectory() }));
|
|
6486
6977
|
} catch {
|
|
6487
6978
|
return { benchmarks: [] };
|
|
6488
6979
|
}
|
|
6489
6980
|
const benchmarks = [];
|
|
6490
6981
|
for (const item of items.filter((i) => i.isDir).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
6491
|
-
benchmarks.push(await this.readBenchmark(
|
|
6982
|
+
benchmarks.push(await this.readBenchmark(path12.join(dir, item.name), item.name));
|
|
6492
6983
|
}
|
|
6493
6984
|
return { benchmarks };
|
|
6494
6985
|
}
|
|
6986
|
+
async listCases(projectId, agentId, benchmarkId) {
|
|
6987
|
+
const baseDir = benchmarksDir(this.root, projectId, agentId);
|
|
6988
|
+
const benchDir = path12.join(baseDir, benchmarkId);
|
|
6989
|
+
let entries;
|
|
6990
|
+
let realBaseDir;
|
|
6991
|
+
let realBenchDir;
|
|
6992
|
+
try {
|
|
6993
|
+
[entries, realBaseDir, realBenchDir] = await Promise.all([
|
|
6994
|
+
fs13.readdir(benchDir, { withFileTypes: true }),
|
|
6995
|
+
fs13.realpath(baseDir),
|
|
6996
|
+
fs13.realpath(benchDir)
|
|
6997
|
+
]);
|
|
6998
|
+
} catch {
|
|
6999
|
+
return { cases: [] };
|
|
7000
|
+
}
|
|
7001
|
+
if (!isWithin(realBaseDir, realBenchDir)) return { cases: [] };
|
|
7002
|
+
const cases = [];
|
|
7003
|
+
for (const entry of entries.filter((item) => item.isDirectory() && item.name.startsWith("CASE-")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
7004
|
+
const fallback = { id: entry.name, title: entry.name };
|
|
7005
|
+
try {
|
|
7006
|
+
const statementDir = await this.caseMaterialRoot(
|
|
7007
|
+
projectId,
|
|
7008
|
+
agentId,
|
|
7009
|
+
benchmarkId,
|
|
7010
|
+
entry.name,
|
|
7011
|
+
"statement"
|
|
7012
|
+
);
|
|
7013
|
+
const realReadme = await fs13.realpath(path12.join(statementDir, "README.md"));
|
|
7014
|
+
if (!isWithin(statementDir, realReadme)) throw new Error("README escapes Statement");
|
|
7015
|
+
cases.push({
|
|
7016
|
+
id: entry.name,
|
|
7017
|
+
title: await readStatementTitle(realReadme, entry.name)
|
|
7018
|
+
});
|
|
7019
|
+
} catch {
|
|
7020
|
+
cases.push(fallback);
|
|
7021
|
+
}
|
|
7022
|
+
}
|
|
7023
|
+
return { cases };
|
|
7024
|
+
}
|
|
7025
|
+
async listCaseFiles(projectId, agentId, benchmarkId, caseId, rel, material) {
|
|
7026
|
+
const materialRoot = await this.caseMaterialRoot(
|
|
7027
|
+
projectId,
|
|
7028
|
+
agentId,
|
|
7029
|
+
benchmarkId,
|
|
7030
|
+
caseId,
|
|
7031
|
+
material
|
|
7032
|
+
);
|
|
7033
|
+
return this.workspaceFiles.list(materialRoot, rel);
|
|
7034
|
+
}
|
|
7035
|
+
async readCaseFile(projectId, agentId, benchmarkId, caseId, rel, material, options) {
|
|
7036
|
+
const materialRoot = await this.caseMaterialRoot(
|
|
7037
|
+
projectId,
|
|
7038
|
+
agentId,
|
|
7039
|
+
benchmarkId,
|
|
7040
|
+
caseId,
|
|
7041
|
+
material
|
|
7042
|
+
);
|
|
7043
|
+
return this.workspaceFiles.read(materialRoot, rel, options);
|
|
7044
|
+
}
|
|
7045
|
+
async caseMaterialRoot(projectId, agentId, benchmarkId, caseId, material) {
|
|
7046
|
+
const benchDir = path12.join(benchmarksDir(this.root, projectId, agentId), benchmarkId);
|
|
7047
|
+
const caseDir = path12.join(benchDir, caseId);
|
|
7048
|
+
const materialRoot = path12.join(caseDir, material);
|
|
7049
|
+
try {
|
|
7050
|
+
const [realBenchDir, realCaseDir, realMaterialRoot] = await Promise.all([
|
|
7051
|
+
fs13.realpath(benchDir),
|
|
7052
|
+
fs13.realpath(caseDir),
|
|
7053
|
+
fs13.realpath(materialRoot)
|
|
7054
|
+
]);
|
|
7055
|
+
if (!isWithin(realBenchDir, realCaseDir) || path12.dirname(realMaterialRoot) !== realCaseDir || path12.basename(realMaterialRoot) !== material) {
|
|
7056
|
+
throw new Error("Case material is not canonical");
|
|
7057
|
+
}
|
|
7058
|
+
return realMaterialRoot;
|
|
7059
|
+
} catch {
|
|
7060
|
+
throw new HttpError(404, "not_found", `Case ${material} does not exist.`);
|
|
7061
|
+
}
|
|
7062
|
+
}
|
|
6495
7063
|
async readBenchmark(benchDir, id) {
|
|
6496
7064
|
let title = id;
|
|
6497
7065
|
let description;
|
|
6498
7066
|
let runs;
|
|
6499
7067
|
try {
|
|
6500
7068
|
const config2 = asRecord2(
|
|
6501
|
-
parseToml3(await
|
|
7069
|
+
parseToml3(await fs13.readFile(path12.join(benchDir, "benchmark_config.toml"), "utf8"))
|
|
6502
7070
|
);
|
|
6503
7071
|
if (typeof config2.title === "string" && config2.title !== "") title = config2.title;
|
|
6504
7072
|
if (typeof config2.description === "string" && config2.description !== "") {
|
|
@@ -6513,7 +7081,7 @@ var BenchmarkService = class {
|
|
|
6513
7081
|
let evaluations = [];
|
|
6514
7082
|
try {
|
|
6515
7083
|
const scoreboard = asRecord2(
|
|
6516
|
-
parseYaml2(await
|
|
7084
|
+
parseYaml2(await fs13.readFile(path12.join(benchDir, "scoreboard.yaml"), "utf8"))
|
|
6517
7085
|
);
|
|
6518
7086
|
if (Array.isArray(scoreboard.evaluations)) {
|
|
6519
7087
|
evaluations = scoreboard.evaluations.map(toEvaluation).filter((e) => e !== null);
|
|
@@ -6522,8 +7090,8 @@ var BenchmarkService = class {
|
|
|
6522
7090
|
}
|
|
6523
7091
|
let caseCount = 0;
|
|
6524
7092
|
try {
|
|
6525
|
-
const entries = await
|
|
6526
|
-
caseCount = entries.filter((e) => e.isDirectory()).length;
|
|
7093
|
+
const entries = await fs13.readdir(benchDir, { withFileTypes: true });
|
|
7094
|
+
caseCount = entries.filter((e) => e.isDirectory() && e.name.startsWith("CASE-")).length;
|
|
6527
7095
|
} catch {
|
|
6528
7096
|
}
|
|
6529
7097
|
return {
|
|
@@ -6538,9 +7106,9 @@ var BenchmarkService = class {
|
|
|
6538
7106
|
};
|
|
6539
7107
|
|
|
6540
7108
|
// src/services/snapshot-service.ts
|
|
6541
|
-
import { randomBytes as
|
|
6542
|
-
import
|
|
6543
|
-
import
|
|
7109
|
+
import { randomBytes as randomBytes6 } from "crypto";
|
|
7110
|
+
import fs14 from "fs/promises";
|
|
7111
|
+
import path13 from "path";
|
|
6544
7112
|
import * as tar from "tar";
|
|
6545
7113
|
import { parse as parseYaml3 } from "yaml";
|
|
6546
7114
|
import {
|
|
@@ -6553,7 +7121,7 @@ import {
|
|
|
6553
7121
|
} from "@prismshadow/penguin-core";
|
|
6554
7122
|
var VAULT_BASENAME = ".vault.toml";
|
|
6555
7123
|
function isVaultEntry(entryPath) {
|
|
6556
|
-
return
|
|
7124
|
+
return path13.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
|
|
6557
7125
|
}
|
|
6558
7126
|
var SnapshotService = class {
|
|
6559
7127
|
constructor(root) {
|
|
@@ -6564,7 +7132,7 @@ var SnapshotService = class {
|
|
|
6564
7132
|
async currentVersion(projectId, agentId) {
|
|
6565
7133
|
let raw;
|
|
6566
7134
|
try {
|
|
6567
|
-
raw = await
|
|
7135
|
+
raw = await fs14.readFile(systemConfigPath3(this.root, projectId, agentId), "utf8");
|
|
6568
7136
|
} catch {
|
|
6569
7137
|
throw new HttpError(404, "agent_not_found", "Agent does not exist.");
|
|
6570
7138
|
}
|
|
@@ -6577,14 +7145,14 @@ var SnapshotService = class {
|
|
|
6577
7145
|
async ensureSnapshot(projectId, agentId) {
|
|
6578
7146
|
const version = await this.currentVersion(projectId, agentId);
|
|
6579
7147
|
const dir = snapshotsDir(this.root, projectId, agentId);
|
|
6580
|
-
const file =
|
|
7148
|
+
const file = path13.join(dir, `v${version}.tar.gz`);
|
|
6581
7149
|
try {
|
|
6582
|
-
await
|
|
7150
|
+
await fs14.access(file);
|
|
6583
7151
|
return { version, file };
|
|
6584
7152
|
} catch {
|
|
6585
7153
|
}
|
|
6586
|
-
await
|
|
6587
|
-
const tmp = `${file}.tmp-${
|
|
7154
|
+
await fs14.mkdir(dir, { recursive: true });
|
|
7155
|
+
const tmp = `${file}.tmp-${randomBytes6(4).toString("hex")}`;
|
|
6588
7156
|
await tar.create(
|
|
6589
7157
|
{
|
|
6590
7158
|
gzip: true,
|
|
@@ -6595,7 +7163,7 @@ var SnapshotService = class {
|
|
|
6595
7163
|
},
|
|
6596
7164
|
["agent_state"]
|
|
6597
7165
|
);
|
|
6598
|
-
await
|
|
7166
|
+
await fs14.rename(tmp, file);
|
|
6599
7167
|
return { version, file };
|
|
6600
7168
|
}
|
|
6601
7169
|
/** Export: automatically packs a snapshot first if none exists, returns the info needed for download. */
|
|
@@ -6612,13 +7180,13 @@ var SnapshotService = class {
|
|
|
6612
7180
|
async importArchive(projectId, agentId, archive, confirm) {
|
|
6613
7181
|
const current = await this.currentVersion(projectId, agentId);
|
|
6614
7182
|
const base = agentDir2(this.root, projectId, agentId);
|
|
6615
|
-
const staging =
|
|
6616
|
-
await
|
|
7183
|
+
const staging = path13.join(base, `.import-${randomBytes6(6).toString("hex")}`);
|
|
7184
|
+
await fs14.mkdir(staging, { recursive: true });
|
|
6617
7185
|
try {
|
|
6618
|
-
const archiveFile =
|
|
6619
|
-
await
|
|
6620
|
-
const extractDir =
|
|
6621
|
-
await
|
|
7186
|
+
const archiveFile = path13.join(staging, "archive.tar.gz");
|
|
7187
|
+
await fs14.writeFile(archiveFile, archive);
|
|
7188
|
+
const extractDir = path13.join(staging, "extracted");
|
|
7189
|
+
await fs14.mkdir(extractDir, { recursive: true });
|
|
6622
7190
|
try {
|
|
6623
7191
|
await tar.extract({
|
|
6624
7192
|
file: archiveFile,
|
|
@@ -6628,10 +7196,10 @@ var SnapshotService = class {
|
|
|
6628
7196
|
} catch {
|
|
6629
7197
|
throw badRequest("Import failed: not a valid tar.gz snapshot package.");
|
|
6630
7198
|
}
|
|
6631
|
-
const configPath =
|
|
7199
|
+
const configPath = path13.join(extractDir, "agent_state", "system_config.yaml");
|
|
6632
7200
|
let parsed;
|
|
6633
7201
|
try {
|
|
6634
|
-
parsed = parseYaml3(await
|
|
7202
|
+
parsed = parseYaml3(await fs14.readFile(configPath, "utf8"));
|
|
6635
7203
|
} catch {
|
|
6636
7204
|
throw badRequest("Import failed: the package is missing agent_state/system_config.yaml.");
|
|
6637
7205
|
}
|
|
@@ -6654,37 +7222,37 @@ var SnapshotService = class {
|
|
|
6654
7222
|
}
|
|
6655
7223
|
await this.ensureSnapshot(projectId, agentId);
|
|
6656
7224
|
const stateDir = agentStateDir2(this.root, projectId, agentId);
|
|
6657
|
-
const incomingState =
|
|
7225
|
+
const incomingState = path13.join(extractDir, "agent_state");
|
|
6658
7226
|
let vault = null;
|
|
6659
7227
|
try {
|
|
6660
|
-
vault = await
|
|
7228
|
+
vault = await fs14.readFile(agentVaultPath(this.root, projectId, agentId));
|
|
6661
7229
|
} catch {
|
|
6662
7230
|
}
|
|
6663
7231
|
if (vault !== null) {
|
|
6664
|
-
await
|
|
7232
|
+
await fs14.writeFile(path13.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
|
|
6665
7233
|
}
|
|
6666
|
-
const trash =
|
|
6667
|
-
await
|
|
7234
|
+
const trash = path13.join(staging, "replaced-agent_state");
|
|
7235
|
+
await fs14.rename(stateDir, trash);
|
|
6668
7236
|
try {
|
|
6669
|
-
await
|
|
7237
|
+
await fs14.rename(incomingState, stateDir);
|
|
6670
7238
|
} catch (err) {
|
|
6671
|
-
await
|
|
7239
|
+
await fs14.rename(trash, stateDir);
|
|
6672
7240
|
throw err;
|
|
6673
7241
|
}
|
|
6674
7242
|
return { version: incoming };
|
|
6675
7243
|
} finally {
|
|
6676
|
-
await
|
|
7244
|
+
await fs14.rm(staging, { recursive: true, force: true });
|
|
6677
7245
|
}
|
|
6678
7246
|
}
|
|
6679
7247
|
};
|
|
6680
7248
|
|
|
6681
7249
|
// src/services/project-service.ts
|
|
6682
|
-
import
|
|
7250
|
+
import fs15 from "fs/promises";
|
|
6683
7251
|
import { DEFAULT_PROJECT_ID, projectDir, provisionProjectAgents } from "@prismshadow/penguin-core";
|
|
6684
7252
|
var ABORT_SETTLE_TIMEOUT_MS = 5e3;
|
|
6685
|
-
async function dirExists(
|
|
7253
|
+
async function dirExists(path18) {
|
|
6686
7254
|
try {
|
|
6687
|
-
const stat = await
|
|
7255
|
+
const stat = await fs15.stat(path18);
|
|
6688
7256
|
return stat.isDirectory();
|
|
6689
7257
|
} catch {
|
|
6690
7258
|
return false;
|
|
@@ -6809,12 +7377,12 @@ var ProjectService = class {
|
|
|
6809
7377
|
throw err;
|
|
6810
7378
|
}
|
|
6811
7379
|
try {
|
|
6812
|
-
await
|
|
7380
|
+
await fs15.mkdir(projectDir(this.deps.root, projectId), { recursive: true });
|
|
6813
7381
|
await this.deps.projectConfig.writeInitialConfig(projectId, displayName);
|
|
6814
7382
|
await this.provisionBuiltinAgents(projectId);
|
|
6815
7383
|
} catch (err) {
|
|
6816
7384
|
this.deps.projects.delete(projectId);
|
|
6817
|
-
await
|
|
7385
|
+
await fs15.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true }).catch(() => {
|
|
6818
7386
|
});
|
|
6819
7387
|
throw err;
|
|
6820
7388
|
}
|
|
@@ -6847,6 +7415,23 @@ var ProjectService = class {
|
|
|
6847
7415
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6848
7416
|
});
|
|
6849
7417
|
}
|
|
7418
|
+
/**
|
|
7419
|
+
* Rename a Project's display name (owner): the id stays immutable — it names the directory,
|
|
7420
|
+
* the Workspace paths and every stored reference — so only the label in project_config.toml
|
|
7421
|
+
* changes. Returns the refreshed summary so the caller can swap it into its list without a
|
|
7422
|
+
* reload.
|
|
7423
|
+
*/
|
|
7424
|
+
async renameProject(userId, projectId, name) {
|
|
7425
|
+
const row = this.requireProjectOwner(userId, projectId);
|
|
7426
|
+
await this.deps.projectConfig.setName(projectId, name);
|
|
7427
|
+
return {
|
|
7428
|
+
projectId,
|
|
7429
|
+
name,
|
|
7430
|
+
role: "owner",
|
|
7431
|
+
ownerUserId: row.ownerUserId,
|
|
7432
|
+
createdAt: row.createdAt
|
|
7433
|
+
};
|
|
7434
|
+
}
|
|
6850
7435
|
/**
|
|
6851
7436
|
* Delete a Project (owner): default_project is refused; deleting the user's
|
|
6852
7437
|
* **last accessible Project** is refused too (deleting it would leave the list
|
|
@@ -6895,7 +7480,7 @@ var ProjectService = class {
|
|
|
6895
7480
|
this.deps.errors.deleteByProject(projectId);
|
|
6896
7481
|
this.deps.schedules.deleteByProject(projectId);
|
|
6897
7482
|
this.deps.goals.deleteByProject(projectId);
|
|
6898
|
-
await
|
|
7483
|
+
await fs15.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
|
|
6899
7484
|
}
|
|
6900
7485
|
// —— Member authorization ——
|
|
6901
7486
|
/** Member list: owner (role=owner) + members. */
|
|
@@ -6966,7 +7551,7 @@ var ProjectService = class {
|
|
|
6966
7551
|
};
|
|
6967
7552
|
|
|
6968
7553
|
// src/services/session-service.ts
|
|
6969
|
-
import
|
|
7554
|
+
import path14 from "path";
|
|
6970
7555
|
import { open, readdir as readdir2 } from "fs/promises";
|
|
6971
7556
|
import {
|
|
6972
7557
|
createAgent as createAgent2,
|
|
@@ -7072,11 +7657,19 @@ var SessionService = class {
|
|
|
7072
7657
|
return await this.sourceOf(row, hasTrace, traces) ?? "active";
|
|
7073
7658
|
}
|
|
7074
7659
|
/**
|
|
7075
|
-
* List
|
|
7076
|
-
* `
|
|
7077
|
-
*
|
|
7078
|
-
*
|
|
7079
|
-
*
|
|
7660
|
+
* List, sorted by createdAt descending. The default serves **web sessions straight from
|
|
7661
|
+
* the DB** (`client` web/NULL rows) with no Trace directory scanning — the answer to
|
|
7662
|
+
* many-session sidebar reloads re-walking the filesystem on every request (#139). One
|
|
7663
|
+
* lazy discovery walk still runs for a list call that contains rows this process has not
|
|
7664
|
+
* classified yet (no in-process source entry): it supplies the Trace locations for the
|
|
7665
|
+
* one-time head reads and backfills the `has_trace` cache; once every row is classified,
|
|
7666
|
+
* list calls touch only the DB. `includeCli` widens the list to DB ∪ Trace directory
|
|
7667
|
+
* discovery (adopting unmanaged CLI Traces as `client: "cli"` rows), which inherently
|
|
7668
|
+
* scans — that path is opt-in via the "show CLI sessions" preference.
|
|
7669
|
+
*
|
|
7670
|
+
* Optional `paging` returns just that slice (the sidebar pages with limit+1 to detect
|
|
7671
|
+
* "has more"); slicing happens before toInfo, so per-request source derivation (lazy
|
|
7672
|
+
* Trace-head reads) stays bounded by the page size.
|
|
7080
7673
|
*
|
|
7081
7674
|
* `category` filters to one sidebar bucket **before** paging, so offset/limit page
|
|
7082
7675
|
* within the category. Filtering needs each walked row's category (a possible
|
|
@@ -7087,20 +7680,32 @@ var SessionService = class {
|
|
|
7087
7680
|
* folders (and a workspace group can know its own share) without loading them.
|
|
7088
7681
|
*/
|
|
7089
7682
|
async listSessions(projectId, agentId, opts = {}) {
|
|
7090
|
-
const { paging, category, withCounts } = opts;
|
|
7091
|
-
const traces = await this.discoverTraces(projectId, agentId);
|
|
7683
|
+
const { paging, category, withCounts, includeCli } = opts;
|
|
7092
7684
|
const rows = new Map(
|
|
7093
|
-
this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r])
|
|
7685
|
+
this.deps.sessions.listByAgent(projectId, agentId, { webOnly: !includeCli }).map((r) => [r.sessionId, r])
|
|
7094
7686
|
);
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7687
|
+
let traces;
|
|
7688
|
+
if (includeCli) {
|
|
7689
|
+
traces = await this.discoverTraces(projectId, agentId);
|
|
7690
|
+
for (const [sessionId, location] of traces) {
|
|
7691
|
+
if (rows.has(sessionId)) continue;
|
|
7692
|
+
const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
|
|
7693
|
+
if (discovered) rows.set(sessionId, discovered);
|
|
7694
|
+
}
|
|
7695
|
+
} else if ([...rows.values()].some((r) => this.deps.sources.get(r.sessionId) === void 0)) {
|
|
7696
|
+
traces = await this.discoverTraces(projectId, agentId);
|
|
7697
|
+
for (const row of rows.values()) {
|
|
7698
|
+
if (!row.hasTrace && traces.has(row.sessionId)) {
|
|
7699
|
+
row.hasTrace = true;
|
|
7700
|
+
this.deps.sessions.markHasTrace(row.sessionId);
|
|
7701
|
+
}
|
|
7702
|
+
}
|
|
7099
7703
|
}
|
|
7100
7704
|
const sorted = [...rows.values()].sort(
|
|
7101
7705
|
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId)
|
|
7102
7706
|
);
|
|
7103
|
-
const
|
|
7707
|
+
const rowHasTrace = (row) => traces ? traces.has(row.sessionId) : row.hasTrace === true;
|
|
7708
|
+
const toPage = (page) => Promise.all(page.map((row) => this.toInfo(row, rowHasTrace(row), traces)));
|
|
7104
7709
|
if (category === void 0 && !withCounts) {
|
|
7105
7710
|
return {
|
|
7106
7711
|
sessions: await toPage(
|
|
@@ -7114,7 +7719,7 @@ var SessionService = class {
|
|
|
7114
7719
|
const matched = [];
|
|
7115
7720
|
for (const row of sorted) {
|
|
7116
7721
|
if (!withCounts && matched.length >= want) break;
|
|
7117
|
-
const cat = await this.categoryOf(row,
|
|
7722
|
+
const cat = await this.categoryOf(row, rowHasTrace(row), traces);
|
|
7118
7723
|
counts[cat] += 1;
|
|
7119
7724
|
if (withCounts) {
|
|
7120
7725
|
const ws = workspaceCounts[row.workspace] ??= {
|
|
@@ -7148,7 +7753,7 @@ var SessionService = class {
|
|
|
7148
7753
|
};
|
|
7149
7754
|
const dir = tracesDir2(this.deps.root, projectId, agentId);
|
|
7150
7755
|
for (const dateDir of await listDirsSafe(dir)) {
|
|
7151
|
-
for (const file of await listFilesSafe(
|
|
7756
|
+
for (const file of await listFilesSafe(path14.join(dir, dateDir))) {
|
|
7152
7757
|
const match = TRACE_FILE_RE.exec(file);
|
|
7153
7758
|
if (match) mark(dateDir, match[1]);
|
|
7154
7759
|
}
|
|
@@ -7234,6 +7839,9 @@ var SessionService = class {
|
|
|
7234
7839
|
workspace: session.workspaceDir,
|
|
7235
7840
|
approvalMode: args.approvalMode ?? "allow-all",
|
|
7236
7841
|
title: null,
|
|
7842
|
+
// Everything created through this service is the Web App's (schedule runs included);
|
|
7843
|
+
// "cli" is stamped only by Trace adoption. NULL means a legacy row, treated as web.
|
|
7844
|
+
client: "web",
|
|
7237
7845
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7238
7846
|
};
|
|
7239
7847
|
this.deps.sessions.insert(row);
|
|
@@ -7264,14 +7872,14 @@ var SessionService = class {
|
|
|
7264
7872
|
const dir = tracesDir2(this.deps.root, projectId, agentId);
|
|
7265
7873
|
const out = /* @__PURE__ */ new Map();
|
|
7266
7874
|
for (const dateDir of await listDirsSafe(dir)) {
|
|
7267
|
-
for (const file of await listFilesSafe(
|
|
7875
|
+
for (const file of await listFilesSafe(path14.join(dir, dateDir))) {
|
|
7268
7876
|
const match = TRACE_FILE_RE.exec(file);
|
|
7269
7877
|
if (!match) continue;
|
|
7270
7878
|
const sessionId = match[1];
|
|
7271
7879
|
const index = Number(match[2]);
|
|
7272
7880
|
const cur = out.get(sessionId);
|
|
7273
7881
|
if (!cur || index < cur.index) {
|
|
7274
|
-
out.set(sessionId, { path:
|
|
7882
|
+
out.set(sessionId, { path: path14.join(dir, dateDir, file), index });
|
|
7275
7883
|
}
|
|
7276
7884
|
}
|
|
7277
7885
|
}
|
|
@@ -7306,6 +7914,10 @@ var SessionService = class {
|
|
|
7306
7914
|
// The approval mode for an unmanaged Session (started via the CLI) isn't in the Trace, so it's backfilled with the default value.
|
|
7307
7915
|
approvalMode: "allow-all",
|
|
7308
7916
|
title: null,
|
|
7917
|
+
// Adopted = a Trace this server never created, i.e. the CLI's: the default list
|
|
7918
|
+
// (web-only) excludes these rows; the "show CLI sessions" preference includes them.
|
|
7919
|
+
client: "cli",
|
|
7920
|
+
hasTrace: true,
|
|
7309
7921
|
createdAt: sessionIdCreatedAt(sessionId) ?? meta.timestamp
|
|
7310
7922
|
};
|
|
7311
7923
|
this.deps.sessions.insertOrIgnore(row);
|
|
@@ -7334,13 +7946,13 @@ async function listFilesSafe(dir) {
|
|
|
7334
7946
|
}
|
|
7335
7947
|
|
|
7336
7948
|
// src/services/trace-service.ts
|
|
7337
|
-
import
|
|
7338
|
-
import
|
|
7949
|
+
import fs16 from "fs/promises";
|
|
7950
|
+
import path15 from "path";
|
|
7339
7951
|
import {
|
|
7340
7952
|
agentsDir as agentsDir3,
|
|
7341
7953
|
isSessionMeta as isSessionMeta5,
|
|
7342
7954
|
parseTraceLines as parseTraceLines2,
|
|
7343
|
-
parseUserSteeringText,
|
|
7955
|
+
parseUserSteeringText as parseUserSteeringText2,
|
|
7344
7956
|
readTraceTolerant as readTraceTolerant2,
|
|
7345
7957
|
tracesDir as tracesDir3
|
|
7346
7958
|
} from "@prismshadow/penguin-core";
|
|
@@ -7357,7 +7969,7 @@ function subagentPointer(msg) {
|
|
|
7357
7969
|
}
|
|
7358
7970
|
async function listDirs(dir) {
|
|
7359
7971
|
try {
|
|
7360
|
-
const entries = await
|
|
7972
|
+
const entries = await fs16.readdir(dir, { withFileTypes: true });
|
|
7361
7973
|
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
7362
7974
|
} catch {
|
|
7363
7975
|
return [];
|
|
@@ -7365,7 +7977,7 @@ async function listDirs(dir) {
|
|
|
7365
7977
|
}
|
|
7366
7978
|
async function listFiles(dir) {
|
|
7367
7979
|
try {
|
|
7368
|
-
const entries = await
|
|
7980
|
+
const entries = await fs16.readdir(dir, { withFileTypes: true });
|
|
7369
7981
|
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
7370
7982
|
} catch {
|
|
7371
7983
|
return [];
|
|
@@ -7381,10 +7993,10 @@ var TraceService = class {
|
|
|
7381
7993
|
const dir = tracesDir3(this.root, projectId, agentId);
|
|
7382
7994
|
const out = [];
|
|
7383
7995
|
for (const dateDir of await listDirs(dir)) {
|
|
7384
|
-
for (const file of await listFiles(
|
|
7996
|
+
for (const file of await listFiles(path15.join(dir, dateDir))) {
|
|
7385
7997
|
const match = TRACE_FILE_RE2.exec(file);
|
|
7386
7998
|
if (!match || match[1] !== sessionId) continue;
|
|
7387
|
-
out.push({ path:
|
|
7999
|
+
out.push({ path: path15.join(dir, dateDir, file), date: dateDir, index: Number(match[2]) });
|
|
7388
8000
|
}
|
|
7389
8001
|
}
|
|
7390
8002
|
return out.sort((a, b) => a.index - b.index);
|
|
@@ -7393,7 +8005,7 @@ var TraceService = class {
|
|
|
7393
8005
|
async deleteSessionTraces(projectId, agentId, sessionId) {
|
|
7394
8006
|
const files = await this.locateAll(projectId, agentId, sessionId);
|
|
7395
8007
|
for (const file of files) {
|
|
7396
|
-
await
|
|
8008
|
+
await fs16.rm(file.path, { force: true });
|
|
7397
8009
|
}
|
|
7398
8010
|
}
|
|
7399
8011
|
/**
|
|
@@ -7431,7 +8043,7 @@ var TraceService = class {
|
|
|
7431
8043
|
for (const agentId of await listDirs(agentsDir3(this.root, projectId))) {
|
|
7432
8044
|
const dir = tracesDir3(this.root, projectId, agentId);
|
|
7433
8045
|
for (const dateDir of await listDirs(dir)) {
|
|
7434
|
-
for (const file of await listFiles(
|
|
8046
|
+
for (const file of await listFiles(path15.join(dir, dateDir))) {
|
|
7435
8047
|
const match = TRACE_FILE_RE2.exec(file);
|
|
7436
8048
|
if (match && !index.has(match[1])) index.set(match[1], agentId);
|
|
7437
8049
|
}
|
|
@@ -7474,7 +8086,7 @@ var TraceService = class {
|
|
|
7474
8086
|
const files = await this.locateAll(projectId, agentId, sessionId);
|
|
7475
8087
|
const out = [];
|
|
7476
8088
|
for (const file of files) {
|
|
7477
|
-
const stat = await
|
|
8089
|
+
const stat = await fs16.stat(file.path);
|
|
7478
8090
|
out.push({
|
|
7479
8091
|
index: file.index,
|
|
7480
8092
|
date: file.date,
|
|
@@ -7510,6 +8122,7 @@ var TraceService = class {
|
|
|
7510
8122
|
let prevSerialTs = null;
|
|
7511
8123
|
let taskIndex = -1;
|
|
7512
8124
|
let continuation = false;
|
|
8125
|
+
let steeringImages = false;
|
|
7513
8126
|
let sawToolCallThisRequest = false;
|
|
7514
8127
|
let compactionActive = false;
|
|
7515
8128
|
const taskStats = /* @__PURE__ */ new Map();
|
|
@@ -7535,9 +8148,12 @@ var TraceService = class {
|
|
|
7535
8148
|
const msg = messages[mi];
|
|
7536
8149
|
const p = msg.payload;
|
|
7537
8150
|
const hasOrigin = msg.origin !== void 0 && msg.origin.length > 0;
|
|
7538
|
-
const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" &&
|
|
8151
|
+
const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" && parseUserSteeringText2(p.text) !== null;
|
|
7539
8152
|
if (isSteeringText) continuation = true;
|
|
7540
|
-
const
|
|
8153
|
+
const isImage = !hasOrigin && msg.type === "model_msg" && p.type === "image_url";
|
|
8154
|
+
if (!hasOrigin && !isSteeringText && !(isImage && steeringImages)) steeringImages = false;
|
|
8155
|
+
if (isSteeringText) steeringImages = true;
|
|
8156
|
+
const startsUserTurn = !hasOrigin && !compactionActive && !isSteeringText && !steeringImages && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
|
|
7541
8157
|
const startsCompactionTurn = !hasOrigin && msg.type === "event_msg" && p.type === "compaction_begin";
|
|
7542
8158
|
if (startsUserTurn || startsCompactionTurn) {
|
|
7543
8159
|
if (pendingFrom === null) pendingFrom = mi;
|
|
@@ -7582,7 +8198,7 @@ var TraceService = class {
|
|
|
7582
8198
|
}
|
|
7583
8199
|
} else if (p.type === "request_end") {
|
|
7584
8200
|
const status = typeof p.status === "string" ? p.status : void 0;
|
|
7585
|
-
const retryable = status === "timeout" || status === "malformed";
|
|
8201
|
+
const retryable = status === "timeout" || status === "malformed" || status === "failed" && !compactionActive;
|
|
7586
8202
|
if (!hasOrigin) {
|
|
7587
8203
|
prevSerialTs = null;
|
|
7588
8204
|
continuation = sawToolCallThisRequest || retryable;
|
|
@@ -7733,11 +8349,11 @@ var TraceService = class {
|
|
|
7733
8349
|
const out = { dates: [] };
|
|
7734
8350
|
for (const date of dates) {
|
|
7735
8351
|
const bySession = /* @__PURE__ */ new Map();
|
|
7736
|
-
for (const file of await listFiles(
|
|
8352
|
+
for (const file of await listFiles(path15.join(dir, date))) {
|
|
7737
8353
|
const match = TRACE_FILE_RE2.exec(file);
|
|
7738
8354
|
if (!match) continue;
|
|
7739
8355
|
const sessionId = match[1];
|
|
7740
|
-
const stat = await
|
|
8356
|
+
const stat = await fs16.stat(path15.join(dir, date, file));
|
|
7741
8357
|
const files = bySession.get(sessionId) ?? [];
|
|
7742
8358
|
files.push({ index: Number(match[2]), sizeBytes: stat.size });
|
|
7743
8359
|
bySession.set(sessionId, files);
|
|
@@ -7773,7 +8389,7 @@ var TraceService = class {
|
|
|
7773
8389
|
/** Raw bytes of the Trace file at the given index (export/download: the file is served verbatim). */
|
|
7774
8390
|
async readFileRaw(projectId, agentId, sessionId, index) {
|
|
7775
8391
|
const file = await this.locateByIndex(projectId, agentId, sessionId, index);
|
|
7776
|
-
return
|
|
8392
|
+
return fs16.readFile(file.path);
|
|
7777
8393
|
}
|
|
7778
8394
|
/**
|
|
7779
8395
|
* Imports an uploaded Trace file (raw JSONL content). Validates the content itself —
|
|
@@ -7812,11 +8428,11 @@ var TraceService = class {
|
|
|
7812
8428
|
const ts = Date.parse(first.timestamp);
|
|
7813
8429
|
const date = formatLocalDate(Number.isNaN(ts) ? /* @__PURE__ */ new Date() : new Date(ts));
|
|
7814
8430
|
const index = 1;
|
|
7815
|
-
const dir =
|
|
7816
|
-
await
|
|
7817
|
-
const file =
|
|
8431
|
+
const dir = path15.join(tracesDir3(this.root, projectId, agentId), date);
|
|
8432
|
+
await fs16.mkdir(dir, { recursive: true });
|
|
8433
|
+
const file = path15.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
|
|
7818
8434
|
try {
|
|
7819
|
-
await
|
|
8435
|
+
await fs16.writeFile(file, content.replace(/\n+$/, "") + "\n", {
|
|
7820
8436
|
encoding: "utf8",
|
|
7821
8437
|
flag: "wx"
|
|
7822
8438
|
});
|
|
@@ -8015,6 +8631,33 @@ var UsageService = class {
|
|
|
8015
8631
|
models: this.usage.distinctModels(projectId)
|
|
8016
8632
|
};
|
|
8017
8633
|
}
|
|
8634
|
+
/**
|
|
8635
|
+
* One page of the error detail table, newest first. The dashboard's own response already
|
|
8636
|
+
* carries the first page (`errors.recent`); this serves the "show me earlier ones" paging,
|
|
8637
|
+
* where refetching the whole aggregate to move one page would be wasteful. `total` is the
|
|
8638
|
+
* filtered row count, so the caller knows when it has reached the end.
|
|
8639
|
+
*
|
|
8640
|
+
* Takes the same filter the dashboard applies — date + agent, and admin-only visibility of
|
|
8641
|
+
* unattributed errors — so a page never widens what the summary above it counted.
|
|
8642
|
+
*
|
|
8643
|
+
* Paging is by offset into a newest-first table that grows at its head, so errors recorded
|
|
8644
|
+
* between two page requests shift every row down and a page can re-show rows already seen.
|
|
8645
|
+
* Accepted deliberately: this is a diagnostic table read at a moment in time, not a feed to
|
|
8646
|
+
* be walked exhaustively, and keying off the newest row's id would cost a cursor the caller
|
|
8647
|
+
* has no other use for.
|
|
8648
|
+
*/
|
|
8649
|
+
queryErrors(projectId, q) {
|
|
8650
|
+
const f = {
|
|
8651
|
+
...q.agentId !== void 0 ? { agentId: q.agentId } : {},
|
|
8652
|
+
...q.from !== void 0 ? { from: q.from } : {},
|
|
8653
|
+
...q.to !== void 0 ? { to: q.to } : {},
|
|
8654
|
+
...q.includeGlobalErrors === true ? { includeGlobal: true } : {}
|
|
8655
|
+
};
|
|
8656
|
+
return {
|
|
8657
|
+
items: this.errors.recent(projectId, f, q.limit, q.offset),
|
|
8658
|
+
total: this.errors.summary(projectId, f).total
|
|
8659
|
+
};
|
|
8660
|
+
}
|
|
8018
8661
|
/** Error statistics: summary info (total / unexpected / most common error code) + the last N entries, all filtered by the selected range. */
|
|
8019
8662
|
foldErrors(projectId, f) {
|
|
8020
8663
|
const { total, unexpected } = this.errors.summary(projectId, f);
|
|
@@ -8167,7 +8810,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
8167
8810
|
const traceService = new TraceService(config2.root);
|
|
8168
8811
|
const workspaceFiles = new WorkspaceFilesService();
|
|
8169
8812
|
const previewTokens = createPreviewTokenSigner();
|
|
8170
|
-
const benchmarks = new BenchmarkService(config2.root);
|
|
8813
|
+
const benchmarks = new BenchmarkService(config2.root, workspaceFiles);
|
|
8171
8814
|
const snapshots = new SnapshotService(config2.root);
|
|
8172
8815
|
const usageService = new UsageService(
|
|
8173
8816
|
usageRepo,
|
|
@@ -8308,13 +8951,17 @@ function createApp(deps2) {
|
|
|
8308
8951
|
await next();
|
|
8309
8952
|
});
|
|
8310
8953
|
}
|
|
8311
|
-
app2.use(
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
8954
|
+
app2.use(
|
|
8955
|
+
"/api/*",
|
|
8956
|
+
bodyLimit({
|
|
8957
|
+
maxSize: MAX_BODY_BYTES,
|
|
8958
|
+
// Its default is a bare text/plain 413; throw the App's own error instead so the response
|
|
8959
|
+
// stays the documented `payload_too_large` body that every client already handles.
|
|
8960
|
+
onError: () => {
|
|
8961
|
+
throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
|
|
8962
|
+
}
|
|
8963
|
+
})
|
|
8964
|
+
);
|
|
8318
8965
|
app2.use("/api/*", jsonOnlyWrites);
|
|
8319
8966
|
app2.route("/api/auth", authRoutes(deps2));
|
|
8320
8967
|
const auth = authMiddleware(deps2.authService);
|
|
@@ -8340,7 +8987,7 @@ function createApp(deps2) {
|
|
|
8340
8987
|
app2.route("/api/projects/:projectId/usage", usageRoutes(deps2));
|
|
8341
8988
|
app2.route("/api/sessions", sessionsRoutes(deps2));
|
|
8342
8989
|
app2.route("/preview", previewRoutes(deps2));
|
|
8343
|
-
if (
|
|
8990
|
+
if (fs17.existsSync(deps2.config.webDist)) {
|
|
8344
8991
|
registerStaticRoutes(app2, deps2.config.webDist);
|
|
8345
8992
|
}
|
|
8346
8993
|
return app2;
|
|
@@ -8376,16 +9023,16 @@ function registerStaticRoutes(app2, webDist) {
|
|
|
8376
9023
|
return c.json(errorBody("not_found", "Endpoint does not exist."), 404);
|
|
8377
9024
|
}
|
|
8378
9025
|
const rel = reqPath.replace(/^\/+/, "");
|
|
8379
|
-
const resolved =
|
|
8380
|
-
const base =
|
|
8381
|
-
const target = resolved === base || resolved.startsWith(base +
|
|
9026
|
+
const resolved = path16.resolve(webDist, rel === "" ? "index.html" : rel);
|
|
9027
|
+
const base = path16.resolve(webDist);
|
|
9028
|
+
const target = resolved === base || resolved.startsWith(base + path16.sep) ? resolved : path16.join(base, "index.html");
|
|
8382
9029
|
let file = target;
|
|
8383
9030
|
try {
|
|
8384
9031
|
const stat = await fsp.stat(file);
|
|
8385
|
-
if (stat.isDirectory()) file =
|
|
9032
|
+
if (stat.isDirectory()) file = path16.join(file, "index.html");
|
|
8386
9033
|
await fsp.access(file);
|
|
8387
9034
|
} catch {
|
|
8388
|
-
file =
|
|
9035
|
+
file = path16.join(base, "index.html");
|
|
8389
9036
|
}
|
|
8390
9037
|
let content;
|
|
8391
9038
|
try {
|
|
@@ -8393,7 +9040,7 @@ function registerStaticRoutes(app2, webDist) {
|
|
|
8393
9040
|
} catch {
|
|
8394
9041
|
return c.json(errorBody("not_found", "Resource does not exist."), 404);
|
|
8395
9042
|
}
|
|
8396
|
-
const type = CONTENT_TYPES2[
|
|
9043
|
+
const type = CONTENT_TYPES2[path16.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
8397
9044
|
return new Response(new Uint8Array(content), {
|
|
8398
9045
|
status: 200,
|
|
8399
9046
|
headers: { "Content-Type": type }
|
|
@@ -8402,16 +9049,16 @@ function registerStaticRoutes(app2, webDist) {
|
|
|
8402
9049
|
}
|
|
8403
9050
|
|
|
8404
9051
|
// src/config.ts
|
|
8405
|
-
import
|
|
8406
|
-
import
|
|
9052
|
+
import fs18 from "fs";
|
|
9053
|
+
import path17 from "path";
|
|
8407
9054
|
import { fileURLToPath } from "url";
|
|
8408
9055
|
import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
|
|
8409
9056
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
8410
9057
|
function defaultWebDist() {
|
|
8411
|
-
const here =
|
|
8412
|
-
const bundled =
|
|
8413
|
-
if (
|
|
8414
|
-
return
|
|
9058
|
+
const here = path17.dirname(fileURLToPath(import.meta.url));
|
|
9059
|
+
const bundled = path17.resolve(here, "..", "web-dist");
|
|
9060
|
+
if (fs18.existsSync(bundled)) return bundled;
|
|
9061
|
+
return path17.resolve(here, "..", "..", "web", "dist");
|
|
8415
9062
|
}
|
|
8416
9063
|
function normalizePreviewOrigin(raw) {
|
|
8417
9064
|
if (!raw || raw.trim() === "") return null;
|
|
@@ -8436,7 +9083,7 @@ function resolveServerConfig(env = process.env) {
|
|
|
8436
9083
|
root,
|
|
8437
9084
|
host: env.HOST ?? "127.0.0.1",
|
|
8438
9085
|
port,
|
|
8439
|
-
dbPath: env.PENGUIN_WEB_DB ??
|
|
9086
|
+
dbPath: env.PENGUIN_WEB_DB ?? path17.join(root, "web.db"),
|
|
8440
9087
|
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
|
|
8441
9088
|
previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
|
|
8442
9089
|
authSessionTtlMs: 7 * DAY_MS,
|