@prismshadow/penguin-server 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/api/types.d.ts +39 -10
- package/dist/index.js +699 -348
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/web-dist/assets/index-C9EZP9d7.js +101 -0
- package/web-dist/assets/{index-7oqK2MPp.js → index-CukW1Teq.js} +1 -1
- package/web-dist/assets/index-XqmoUN63.css +1 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-DGY2Myoq.js +0 -51
- package/web-dist/assets/index-SaWO9NKB.css +0 -1
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { serve } from "@hono/node-server";
|
|
|
5
5
|
// src/app.ts
|
|
6
6
|
import fs16 from "fs";
|
|
7
7
|
import fsp from "fs/promises";
|
|
8
|
-
import
|
|
8
|
+
import path15 from "path";
|
|
9
9
|
import { Hono as Hono19 } from "hono";
|
|
10
10
|
|
|
11
11
|
// src/db/database.ts
|
|
@@ -15,30 +15,30 @@ import path from "path";
|
|
|
15
15
|
// src/db/schema.ts
|
|
16
16
|
var SCHEMA_SQL = `
|
|
17
17
|
CREATE TABLE IF NOT EXISTS users (
|
|
18
|
-
user_id TEXT PRIMARY KEY, --
|
|
18
|
+
user_id TEXT PRIMARY KEY, -- semantic id doubles as login name: ^[a-z][a-z0-9_-]{1,31}$
|
|
19
19
|
password_hash TEXT NOT NULL, -- scrypt$N$r$p$salt$hash(base64)
|
|
20
|
-
is_admin INTEGER NOT NULL DEFAULT 0, --
|
|
21
|
-
password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1
|
|
20
|
+
is_admin INTEGER NOT NULL DEFAULT 0, -- 1 for the built-in admin (seeded at startup)
|
|
21
|
+
password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=initial password (seeded/admin-set); cleared once the user changes it
|
|
22
22
|
created_at TEXT NOT NULL
|
|
23
23
|
);
|
|
24
24
|
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
25
|
-
token_hash TEXT PRIMARY KEY, -- sha256(token) hex
|
|
25
|
+
token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token
|
|
26
26
|
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
|
27
27
|
created_at TEXT NOT NULL,
|
|
28
|
-
expires_at TEXT NOT NULL -- 7
|
|
28
|
+
expires_at TEXT NOT NULL -- 7-day sliding renewal (topped up when <6 days remain)
|
|
29
29
|
);
|
|
30
30
|
CREATE TABLE IF NOT EXISTS projects (
|
|
31
|
-
project_id TEXT PRIMARY KEY, --
|
|
31
|
+
project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml
|
|
32
32
|
owner_user_id TEXT NOT NULL REFERENCES users(user_id),
|
|
33
33
|
created_at TEXT NOT NULL
|
|
34
34
|
);
|
|
35
|
-
CREATE TABLE IF NOT EXISTS project_members ( --
|
|
35
|
+
CREATE TABLE IF NOT EXISTS project_members ( -- member grants only; owners are not stored here
|
|
36
36
|
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
37
37
|
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
|
38
38
|
created_at TEXT NOT NULL,
|
|
39
39
|
PRIMARY KEY (project_id, user_id)
|
|
40
40
|
);
|
|
41
|
-
CREATE TABLE IF NOT EXISTS agents ( --
|
|
41
|
+
CREATE TABLE IF NOT EXISTS agents ( -- index only; name/description live in system_config.yaml
|
|
42
42
|
project_id TEXT NOT NULL,
|
|
43
43
|
agent_id TEXT NOT NULL,
|
|
44
44
|
created_at TEXT NOT NULL,
|
|
@@ -48,64 +48,63 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
48
48
|
session_id TEXT PRIMARY KEY,
|
|
49
49
|
project_id TEXT NOT NULL,
|
|
50
50
|
agent_id TEXT NOT NULL,
|
|
51
|
-
provider TEXT NOT NULL, --
|
|
52
|
-
model_id TEXT NOT NULL, --
|
|
51
|
+
provider TEXT NOT NULL, -- provider group of the session model (paired with model_id as a model reference)
|
|
52
|
+
model_id TEXT NOT NULL, -- upstream model id (sent to AgentHub as-is; never concatenate <provider>/<id>)
|
|
53
53
|
workspace TEXT NOT NULL,
|
|
54
54
|
approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
|
|
55
|
-
title TEXT, --
|
|
56
|
-
archived_at TEXT, --
|
|
57
|
-
source TEXT, -- \u4F1A\u8BDD\u6765\u6E90\uFF1ANULL=\u7528\u6237\u521B\u5EFA | schedule\uFF08\u5B9A\u65F6\u4EFB\u52A1\uFF09| subagent\uFF08\u5B50\u4F1A\u8BDD\uFF09
|
|
55
|
+
title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
|
|
56
|
+
archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
|
|
58
57
|
created_at TEXT NOT NULL
|
|
59
|
-
);
|
|
58
|
+
); -- session origin is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts)
|
|
60
59
|
CREATE TABLE IF NOT EXISTS usage_records (
|
|
61
60
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
62
61
|
ts TEXT NOT NULL,
|
|
63
|
-
date TEXT NOT NULL, --
|
|
62
|
+
date TEXT NOT NULL, -- local date yyyy-mm-dd (aggregation key; same convention as Trace date directories)
|
|
64
63
|
project_id TEXT NOT NULL,
|
|
65
64
|
agent_id TEXT NOT NULL,
|
|
66
|
-
session_id TEXT NOT NULL, --
|
|
67
|
-
origin_session_id TEXT, --
|
|
68
|
-
provider TEXT NOT NULL, --
|
|
69
|
-
model_id TEXT NOT NULL, --
|
|
65
|
+
session_id TEXT NOT NULL, -- top-level Session (child-session usage counts toward its main Session)
|
|
66
|
+
origin_session_id TEXT, -- immediate source child Session (last of the origin chain); NULL=main session
|
|
67
|
+
provider TEXT NOT NULL, -- provider group: paired with model_id as the attribution key; always GROUP BY provider, model_id
|
|
68
|
+
model_id TEXT NOT NULL, -- upstream model id (paired with provider; same model_id across providers aggregates separately)
|
|
70
69
|
cache_read INTEGER NOT NULL,
|
|
71
70
|
cache_write INTEGER NOT NULL,
|
|
72
71
|
output INTEGER NOT NULL,
|
|
73
|
-
total INTEGER NOT NULL, --
|
|
74
|
-
status TEXT NOT NULL DEFAULT 'completed' --
|
|
75
|
-
); --
|
|
72
|
+
total INTEGER NOT NULL, -- from token_usage.request (one row per Request)
|
|
73
|
+
status TEXT NOT NULL DEFAULT 'completed' -- request outcome: completed=success (with tokens); others=failure (0 tokens, for success rate)
|
|
74
|
+
); -- cost is not stored: computed at query time from current pricing
|
|
76
75
|
CREATE INDEX IF NOT EXISTS idx_usage_project_date ON usage_records(project_id, date);
|
|
77
76
|
CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_records(session_id);
|
|
78
|
-
CREATE TABLE IF NOT EXISTS error_records ( --
|
|
77
|
+
CREATE TABLE IF NOT EXISTS error_records ( -- server-side error capture (the Costs page's "Errors" tab)
|
|
79
78
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
80
79
|
ts TEXT NOT NULL,
|
|
81
|
-
date TEXT NOT NULL, --
|
|
82
|
-
project_id TEXT, --
|
|
80
|
+
date TEXT NOT NULL, -- local date yyyy-mm-dd (same convention as usage_records)
|
|
81
|
+
project_id TEXT, -- nullable: sign-in/registration and process-level errors have no Project context
|
|
83
82
|
agent_id TEXT,
|
|
84
83
|
session_id TEXT,
|
|
85
84
|
source TEXT NOT NULL, -- http | session | usage | title | subagent | process | llm | environment | schedule
|
|
86
|
-
kind TEXT NOT NULL, -- expected
|
|
85
|
+
kind TEXT NOT NULL, -- expected (HttpError, business 4xx) | unexpected (500/runtime)
|
|
87
86
|
code TEXT NOT NULL, -- HttpError.code / internal / session_run_failed / ...
|
|
88
|
-
status INTEGER, -- HTTP
|
|
89
|
-
message TEXT NOT NULL --
|
|
87
|
+
status INTEGER, -- HTTP status code; NULL for non-HTTP sources
|
|
88
|
+
message TEXT NOT NULL -- truncated to 500 chars (no stack stored: stacks go to logs only)
|
|
90
89
|
);
|
|
91
90
|
CREATE INDEX IF NOT EXISTS idx_error_project_date ON error_records(project_id, date);
|
|
92
|
-
CREATE TABLE IF NOT EXISTS schedule_state ( --
|
|
91
|
+
CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files are declarative intent; the system never writes back)
|
|
93
92
|
project_id TEXT NOT NULL,
|
|
94
93
|
agent_id TEXT NOT NULL,
|
|
95
|
-
name TEXT NOT NULL, --
|
|
96
|
-
creator_user_id TEXT, --
|
|
97
|
-
start_at_ms INTEGER NOT NULL, --
|
|
98
|
-
def_hash TEXT NOT NULL, --
|
|
99
|
-
last_slot_ms INTEGER, --
|
|
100
|
-
last_fired_at TEXT, --
|
|
101
|
-
fired_once INTEGER NOT NULL DEFAULT 0, --
|
|
102
|
-
missed INTEGER NOT NULL DEFAULT 0, --
|
|
103
|
-
invalid_reason TEXT, --
|
|
94
|
+
name TEXT NOT NULL, -- file name (without .toml) doubles as the identifier
|
|
95
|
+
creator_user_id TEXT, -- creator (recorded on API create; hand-edited file reconciliation falls back to the Project owner)
|
|
96
|
+
start_at_ms INTEGER NOT NULL, -- defines identity: a start_at change counts as a new task instance, resetting fire state
|
|
97
|
+
def_hash TEXT NOT NULL, -- file content fingerprint: any change clears the invalid flag (the file takes effect again after edits)
|
|
98
|
+
last_slot_ms INTEGER, -- latest due slot already consumed (advances on fire or skip; no re-fire across restarts)
|
|
99
|
+
last_fired_at TEXT, -- last actual fire time (for display)
|
|
100
|
+
fired_once INTEGER NOT NULL DEFAULT 0, -- one-shot task has fired
|
|
101
|
+
missed INTEGER NOT NULL DEFAULT 0, -- one-shot task was missed (flagged by startup/registration reconciliation; not backfilled)
|
|
102
|
+
invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
|
|
104
103
|
PRIMARY KEY (project_id, agent_id, name)
|
|
105
104
|
);
|
|
106
105
|
CREATE TABLE IF NOT EXISTS ui_prefs (
|
|
107
106
|
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
|
108
|
-
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...}
|
|
107
|
+
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
|
|
109
108
|
);
|
|
110
109
|
`;
|
|
111
110
|
|
|
@@ -460,7 +459,7 @@ var SchedulesRepo = class {
|
|
|
460
459
|
).run(args.defHash, args.projectId, args.agentId, args.name);
|
|
461
460
|
}
|
|
462
461
|
const row = this.find(args.projectId, args.agentId, args.name);
|
|
463
|
-
if (!row) throw new Error("schedule_state
|
|
462
|
+
if (!row) throw new Error("Failed to read back schedule_state after registration");
|
|
464
463
|
return { row, fresh };
|
|
465
464
|
}
|
|
466
465
|
/** Advance the consumed scheduled time (advances whether triggered or skipped; restarts don't re-trigger). */
|
|
@@ -525,7 +524,6 @@ function mapRow3(r) {
|
|
|
525
524
|
approvalMode: r.approval_mode,
|
|
526
525
|
title: r.title ?? null,
|
|
527
526
|
archivedAt: r.archived_at ?? null,
|
|
528
|
-
source: r.source ?? null,
|
|
529
527
|
createdAt: r.created_at
|
|
530
528
|
};
|
|
531
529
|
}
|
|
@@ -536,8 +534,8 @@ var SessionsRepo = class {
|
|
|
536
534
|
db;
|
|
537
535
|
insert(row) {
|
|
538
536
|
this.db.prepare(
|
|
539
|
-
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title,
|
|
540
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
|
537
|
+
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
|
|
538
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
541
539
|
).run(
|
|
542
540
|
row.sessionId,
|
|
543
541
|
row.projectId,
|
|
@@ -547,15 +545,14 @@ var SessionsRepo = class {
|
|
|
547
545
|
row.workspace,
|
|
548
546
|
row.approvalMode,
|
|
549
547
|
row.title,
|
|
550
|
-
row.source ?? null,
|
|
551
548
|
row.createdAt
|
|
552
549
|
);
|
|
553
550
|
}
|
|
554
551
|
/** Idempotent insert: used when Trace directory discovery backfills a row (concurrent listing discovering the same Session no longer triggers a UNIQUE violation). */
|
|
555
552
|
insertOrIgnore(row) {
|
|
556
553
|
this.db.prepare(
|
|
557
|
-
`INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title,
|
|
558
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
|
554
|
+
`INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
|
|
555
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
559
556
|
).run(
|
|
560
557
|
row.sessionId,
|
|
561
558
|
row.projectId,
|
|
@@ -565,7 +562,6 @@ var SessionsRepo = class {
|
|
|
565
562
|
row.workspace,
|
|
566
563
|
row.approvalMode,
|
|
567
564
|
row.title,
|
|
568
|
-
row.source ?? null,
|
|
569
565
|
row.createdAt
|
|
570
566
|
);
|
|
571
567
|
}
|
|
@@ -857,15 +853,15 @@ function modelCredentialMissing(modelId) {
|
|
|
857
853
|
return new HttpError(
|
|
858
854
|
400,
|
|
859
855
|
"model_credential_missing",
|
|
860
|
-
|
|
856
|
+
`Model ${modelId} has no API key yet. Configure it on the Models page first.`
|
|
861
857
|
);
|
|
862
858
|
}
|
|
863
859
|
function handleError(err, c) {
|
|
864
860
|
if (err instanceof HttpError) {
|
|
865
861
|
return c.json(errorBody(err.code, err.message), err.status);
|
|
866
862
|
}
|
|
867
|
-
console.error(`[server]
|
|
868
|
-
return c.json(errorBody("internal", "
|
|
863
|
+
console.error(`[server] Unhandled exception: ${err.stack ?? err.message}`);
|
|
864
|
+
return c.json(errorBody("internal", "Internal server error."), 500);
|
|
869
865
|
}
|
|
870
866
|
|
|
871
867
|
// src/auth/middleware.ts
|
|
@@ -875,7 +871,7 @@ function authMiddleware(auth) {
|
|
|
875
871
|
const token = getCookie(c, SESSION_COOKIE);
|
|
876
872
|
const user = token ? auth.authenticate(token) : null;
|
|
877
873
|
if (!user) {
|
|
878
|
-
throw new HttpError(401, "unauthorized", "
|
|
874
|
+
throw new HttpError(401, "unauthorized", "Not signed in or the sign-in has expired.");
|
|
879
875
|
}
|
|
880
876
|
c.set("user", user);
|
|
881
877
|
await next();
|
|
@@ -886,7 +882,11 @@ var jsonOnlyWrites = async (c, next) => {
|
|
|
886
882
|
if (WRITE_METHODS.has(c.req.method)) {
|
|
887
883
|
const contentType = c.req.header("content-type");
|
|
888
884
|
if (contentType && !contentType.toLowerCase().startsWith("application/json")) {
|
|
889
|
-
throw new HttpError(
|
|
885
|
+
throw new HttpError(
|
|
886
|
+
415,
|
|
887
|
+
"unsupported_media_type",
|
|
888
|
+
"Write requests only accept application/json."
|
|
889
|
+
);
|
|
890
890
|
}
|
|
891
891
|
}
|
|
892
892
|
await next();
|
|
@@ -950,7 +950,7 @@ async function verifyPassword(password, stored) {
|
|
|
950
950
|
// src/auth/service.ts
|
|
951
951
|
var MIN_PASSWORD_LENGTH = 8;
|
|
952
952
|
var ADMIN_USER_ID = "admin";
|
|
953
|
-
var ADMIN_INITIAL_PASSWORD = "
|
|
953
|
+
var ADMIN_INITIAL_PASSWORD = "penguin-2026";
|
|
954
954
|
function sha256Hex(value) {
|
|
955
955
|
return createHash("sha256").update(value).digest("hex");
|
|
956
956
|
}
|
|
@@ -995,7 +995,7 @@ var AuthService = class {
|
|
|
995
995
|
const row = this.deps.users.findById(userId);
|
|
996
996
|
const ok = row !== null && await verifyPassword(password, row.passwordHash);
|
|
997
997
|
if (!row || !ok) {
|
|
998
|
-
throw new HttpError(401, "invalid_credentials", "
|
|
998
|
+
throw new HttpError(401, "invalid_credentials", "Incorrect username or password.");
|
|
999
999
|
}
|
|
1000
1000
|
this.deps.authSessions.deleteExpired(this.now().toISOString());
|
|
1001
1001
|
return { user: toUserInfo(row), token: this.issueSession(row.userId) };
|
|
@@ -1004,10 +1004,10 @@ var AuthService = class {
|
|
|
1004
1004
|
async changePassword(userId, oldPassword, newPassword) {
|
|
1005
1005
|
const row = this.deps.users.findById(userId);
|
|
1006
1006
|
if (!row || !await verifyPassword(oldPassword, row.passwordHash)) {
|
|
1007
|
-
throw new HttpError(400, "password_mismatch", "
|
|
1007
|
+
throw new HttpError(400, "password_mismatch", "Current password is incorrect.");
|
|
1008
1008
|
}
|
|
1009
1009
|
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
1010
|
-
throw new HttpError(400, "invalid_password", "
|
|
1010
|
+
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
|
|
1011
1011
|
}
|
|
1012
1012
|
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
|
|
1013
1013
|
}
|
|
@@ -1057,28 +1057,46 @@ function badRequest(message) {
|
|
|
1057
1057
|
function pathParam(c, name) {
|
|
1058
1058
|
const v = c.req.param(name);
|
|
1059
1059
|
if (v === void 0 || v === "") {
|
|
1060
|
-
throw new HttpError(404, "not_found", "
|
|
1060
|
+
throw new HttpError(404, "not_found", "Missing path parameter.");
|
|
1061
1061
|
}
|
|
1062
1062
|
return v;
|
|
1063
1063
|
}
|
|
1064
1064
|
function requireValidId(c, name) {
|
|
1065
1065
|
const v = pathParam(c, name);
|
|
1066
1066
|
if (!isValidId(v)) {
|
|
1067
|
-
throw new HttpError(404, "not_found", "
|
|
1067
|
+
throw new HttpError(404, "not_found", "Resource does not exist or you do not have access.");
|
|
1068
1068
|
}
|
|
1069
1069
|
return v;
|
|
1070
1070
|
}
|
|
1071
1071
|
function positiveIntParam(c, name) {
|
|
1072
|
-
const
|
|
1073
|
-
if (
|
|
1072
|
+
const raw = pathParam(c, name);
|
|
1073
|
+
if (!/^\d+$/.test(raw)) throw badRequest(`${name} must be a positive integer.`);
|
|
1074
|
+
const v = Number.parseInt(raw, 10);
|
|
1075
|
+
if (!Number.isSafeInteger(v) || v < 1) throw badRequest(`${name} must be a positive integer.`);
|
|
1074
1076
|
return v;
|
|
1075
1077
|
}
|
|
1076
1078
|
function paginationQuery(c) {
|
|
1077
1079
|
const offset = Number.parseInt(c.req.query("offset") ?? "0", 10);
|
|
1078
1080
|
const limit = Number.parseInt(c.req.query("limit") ?? "200", 10);
|
|
1079
|
-
if (!Number.isInteger(offset) || offset < 0)
|
|
1081
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
1082
|
+
throw badRequest("offset must be a non-negative integer.");
|
|
1083
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1e3) {
|
|
1084
|
+
throw badRequest("limit must be an integer between 1 and 1000.");
|
|
1085
|
+
}
|
|
1086
|
+
return { offset, limit };
|
|
1087
|
+
}
|
|
1088
|
+
function optionalPagingQuery(c) {
|
|
1089
|
+
const rawLimit = c.req.query("limit");
|
|
1090
|
+
const rawOffset = c.req.query("offset");
|
|
1091
|
+
if (rawLimit === void 0 && rawOffset === void 0) return null;
|
|
1092
|
+
if (rawLimit === void 0) throw badRequest("offset requires limit.");
|
|
1093
|
+
const limit = Number.parseInt(rawLimit, 10);
|
|
1080
1094
|
if (!Number.isInteger(limit) || limit < 1 || limit > 1e3) {
|
|
1081
|
-
throw badRequest("limit
|
|
1095
|
+
throw badRequest("limit must be an integer between 1 and 1000.");
|
|
1096
|
+
}
|
|
1097
|
+
const offset = Number.parseInt(rawOffset ?? "0", 10);
|
|
1098
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
1099
|
+
throw badRequest("offset must be a non-negative integer.");
|
|
1082
1100
|
}
|
|
1083
1101
|
return { offset, limit };
|
|
1084
1102
|
}
|
|
@@ -1087,25 +1105,25 @@ async function readJson(c) {
|
|
|
1087
1105
|
try {
|
|
1088
1106
|
body = await c.req.json();
|
|
1089
1107
|
} catch {
|
|
1090
|
-
throw badRequest("
|
|
1108
|
+
throw badRequest("Request body must be valid JSON.");
|
|
1091
1109
|
}
|
|
1092
1110
|
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
1093
|
-
throw badRequest("
|
|
1111
|
+
throw badRequest("Request body must be a JSON object.");
|
|
1094
1112
|
}
|
|
1095
1113
|
return body;
|
|
1096
1114
|
}
|
|
1097
1115
|
function requireString(obj, key, rule = {}) {
|
|
1098
1116
|
const v = obj[key];
|
|
1099
1117
|
const label = rule.label ?? key;
|
|
1100
|
-
if (typeof v !== "string") throw badRequest(`${label}
|
|
1118
|
+
if (typeof v !== "string") throw badRequest(`${label} must be a string.`);
|
|
1101
1119
|
if (rule.minLen !== void 0 && v.length < rule.minLen) {
|
|
1102
|
-
throw badRequest(`${label}
|
|
1120
|
+
throw badRequest(`${label} must be at least ${rule.minLen} characters.`);
|
|
1103
1121
|
}
|
|
1104
1122
|
if (rule.maxLen !== void 0 && v.length > rule.maxLen) {
|
|
1105
|
-
throw badRequest(`${label}
|
|
1123
|
+
throw badRequest(`${label} must be at most ${rule.maxLen} characters.`);
|
|
1106
1124
|
}
|
|
1107
1125
|
if (rule.pattern !== void 0 && !rule.pattern.test(v)) {
|
|
1108
|
-
throw badRequest(`${label}
|
|
1126
|
+
throw badRequest(`${label} has an invalid format.`);
|
|
1109
1127
|
}
|
|
1110
1128
|
return v;
|
|
1111
1129
|
}
|
|
@@ -1116,7 +1134,7 @@ function optionalString(obj, key, rule = {}) {
|
|
|
1116
1134
|
function requireEnum(obj, key, values, label = key) {
|
|
1117
1135
|
const v = obj[key];
|
|
1118
1136
|
if (typeof v !== "string" || !values.includes(v)) {
|
|
1119
|
-
throw badRequest(`${label}
|
|
1137
|
+
throw badRequest(`${label} must be one of ${values.join(" / ")}.`);
|
|
1120
1138
|
}
|
|
1121
1139
|
return v;
|
|
1122
1140
|
}
|
|
@@ -1128,20 +1146,25 @@ function optionalNumber(obj, key, rule = {}) {
|
|
|
1128
1146
|
const v = obj[key];
|
|
1129
1147
|
if (v === void 0) return void 0;
|
|
1130
1148
|
const label = rule.label ?? key;
|
|
1131
|
-
if (typeof v !== "number" || !Number.isFinite(v)) throw badRequest(`${label}
|
|
1132
|
-
if (rule.integer && !Number.isInteger(v)) throw badRequest(`${label}
|
|
1149
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw badRequest(`${label} must be a number.`);
|
|
1150
|
+
if (rule.integer && !Number.isInteger(v)) throw badRequest(`${label} must be an integer.`);
|
|
1133
1151
|
if (rule.positiveOrMinusOne && !(v > 0 || v === -1)) {
|
|
1134
|
-
throw badRequest(`${label}
|
|
1152
|
+
throw badRequest(`${label} must be greater than 0 or equal to -1.`);
|
|
1135
1153
|
}
|
|
1136
|
-
if (rule.nonNegative && v < 0) throw badRequest(`${label}
|
|
1154
|
+
if (rule.nonNegative && v < 0) throw badRequest(`${label} must not be negative.`);
|
|
1137
1155
|
return v;
|
|
1138
1156
|
}
|
|
1139
1157
|
function optionalDateParam(value, label) {
|
|
1140
1158
|
if (value === void 0 || value === "") return void 0;
|
|
1141
|
-
|
|
1142
|
-
|
|
1159
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
1160
|
+
if (m) {
|
|
1161
|
+
const [, y, mo, d] = m;
|
|
1162
|
+
const dt = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
|
|
1163
|
+
if (!Number.isNaN(dt.getTime()) && dt.getUTCFullYear() === Number(y) && dt.getUTCMonth() + 1 === Number(mo) && dt.getUTCDate() === Number(d)) {
|
|
1164
|
+
return value;
|
|
1165
|
+
}
|
|
1143
1166
|
}
|
|
1144
|
-
|
|
1167
|
+
throw badRequest(`${label} must be a valid date in YYYY-MM-DD format.`);
|
|
1145
1168
|
}
|
|
1146
1169
|
|
|
1147
1170
|
// src/http/routes/admin.ts
|
|
@@ -1149,7 +1172,7 @@ function adminUsersRoutes(deps2) {
|
|
|
1149
1172
|
const app2 = new Hono();
|
|
1150
1173
|
app2.use("*", async (c, next) => {
|
|
1151
1174
|
if (!c.var.user.isAdmin) {
|
|
1152
|
-
throw new HttpError(403, "admin_required", "
|
|
1175
|
+
throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
|
|
1153
1176
|
}
|
|
1154
1177
|
await next();
|
|
1155
1178
|
});
|
|
@@ -1386,7 +1409,7 @@ function membersRoutes(deps2) {
|
|
|
1386
1409
|
import { Hono as Hono7 } from "hono";
|
|
1387
1410
|
function parseRef(value, label) {
|
|
1388
1411
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1389
|
-
throw badRequest(`${label}
|
|
1412
|
+
throw badRequest(`${label} must be a { provider, modelId } object.`);
|
|
1390
1413
|
}
|
|
1391
1414
|
const r = value;
|
|
1392
1415
|
return {
|
|
@@ -1395,10 +1418,10 @@ function parseRef(value, label) {
|
|
|
1395
1418
|
};
|
|
1396
1419
|
}
|
|
1397
1420
|
function parseModelsUpdate(body) {
|
|
1398
|
-
if (!Array.isArray(body.models)) throw badRequest("models
|
|
1421
|
+
if (!Array.isArray(body.models)) throw badRequest("models must be an array.");
|
|
1399
1422
|
const models = body.models.map((item, i) => {
|
|
1400
1423
|
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
1401
|
-
throw badRequest(`models[${i}]
|
|
1424
|
+
throw badRequest(`models[${i}] must be an object.`);
|
|
1402
1425
|
}
|
|
1403
1426
|
const m = item;
|
|
1404
1427
|
const entry = {
|
|
@@ -1415,7 +1438,7 @@ function parseModelsUpdate(body) {
|
|
|
1415
1438
|
};
|
|
1416
1439
|
if (m.displayName !== void 0) {
|
|
1417
1440
|
if (typeof m.displayName !== "string" || m.displayName.length > 100) {
|
|
1418
|
-
throw badRequest(`models[${i}].displayName
|
|
1441
|
+
throw badRequest(`models[${i}].displayName must be a string of at most 100 characters.`);
|
|
1419
1442
|
}
|
|
1420
1443
|
if (m.displayName) entry.displayName = m.displayName;
|
|
1421
1444
|
}
|
|
@@ -1424,31 +1447,37 @@ function parseModelsUpdate(body) {
|
|
|
1424
1447
|
}
|
|
1425
1448
|
if (m.contextWindow !== void 0) {
|
|
1426
1449
|
if (typeof m.contextWindow !== "number" || !(m.contextWindow > 0)) {
|
|
1427
|
-
throw badRequest(`models[${i}].contextWindow
|
|
1450
|
+
throw badRequest(`models[${i}].contextWindow must be a positive number.`);
|
|
1428
1451
|
}
|
|
1429
1452
|
entry.contextWindow = m.contextWindow;
|
|
1430
1453
|
}
|
|
1431
1454
|
if (m.clientType !== void 0) {
|
|
1432
1455
|
if (typeof m.clientType !== "string" || m.clientType.length > 64) {
|
|
1433
|
-
throw badRequest(`models[${i}].clientType
|
|
1456
|
+
throw badRequest(`models[${i}].clientType must be a string of at most 64 characters.`);
|
|
1434
1457
|
}
|
|
1435
1458
|
if (m.clientType) entry.clientType = m.clientType;
|
|
1436
1459
|
}
|
|
1437
1460
|
if (m.vision !== void 0) {
|
|
1438
1461
|
if (typeof m.vision !== "boolean") {
|
|
1439
|
-
throw badRequest(`models[${i}].vision
|
|
1462
|
+
throw badRequest(`models[${i}].vision must be a boolean.`);
|
|
1440
1463
|
}
|
|
1441
1464
|
entry.vision = m.vision;
|
|
1442
1465
|
}
|
|
1466
|
+
if (m.maxTokens !== void 0) {
|
|
1467
|
+
if (typeof m.maxTokens !== "number" || !Number.isInteger(m.maxTokens) || m.maxTokens <= 0) {
|
|
1468
|
+
throw badRequest(`models[${i}].maxTokens must be a positive integer.`);
|
|
1469
|
+
}
|
|
1470
|
+
entry.maxTokens = m.maxTokens;
|
|
1471
|
+
}
|
|
1443
1472
|
if (m.pricing !== void 0) {
|
|
1444
1473
|
const p = m.pricing;
|
|
1445
1474
|
if (p === null || typeof p !== "object" || Array.isArray(p)) {
|
|
1446
|
-
throw badRequest(`models[${i}].pricing
|
|
1475
|
+
throw badRequest(`models[${i}].pricing must be an object.`);
|
|
1447
1476
|
}
|
|
1448
1477
|
for (const key of ["cacheRead", "cacheWrite", "output"]) {
|
|
1449
1478
|
const v = p[key];
|
|
1450
1479
|
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
|
|
1451
|
-
throw badRequest(`models[${i}].pricing.${key}
|
|
1480
|
+
throw badRequest(`models[${i}].pricing.${key} must be a non-negative number.`);
|
|
1452
1481
|
}
|
|
1453
1482
|
}
|
|
1454
1483
|
entry.pricing = {
|
|
@@ -1459,19 +1488,19 @@ function parseModelsUpdate(body) {
|
|
|
1459
1488
|
}
|
|
1460
1489
|
if (m.apiKey !== void 0) {
|
|
1461
1490
|
if (typeof m.apiKey !== "string" || m.apiKey.length === 0) {
|
|
1462
|
-
throw badRequest(`models[${i}].apiKey
|
|
1491
|
+
throw badRequest(`models[${i}].apiKey must be a non-empty string.`);
|
|
1463
1492
|
}
|
|
1464
1493
|
entry.apiKey = m.apiKey;
|
|
1465
1494
|
}
|
|
1466
1495
|
if (m.clearApiKey !== void 0) {
|
|
1467
1496
|
if (typeof m.clearApiKey !== "boolean") {
|
|
1468
|
-
throw badRequest(`models[${i}].clearApiKey
|
|
1497
|
+
throw badRequest(`models[${i}].clearApiKey must be a boolean.`);
|
|
1469
1498
|
}
|
|
1470
1499
|
entry.clearApiKey = m.clearApiKey;
|
|
1471
1500
|
}
|
|
1472
1501
|
if (m.baseUrl !== void 0) {
|
|
1473
1502
|
if (m.baseUrl !== null && typeof m.baseUrl !== "string") {
|
|
1474
|
-
throw badRequest(`models[${i}].baseUrl
|
|
1503
|
+
throw badRequest(`models[${i}].baseUrl must be a string or null.`);
|
|
1475
1504
|
}
|
|
1476
1505
|
entry.baseUrl = m.baseUrl;
|
|
1477
1506
|
}
|
|
@@ -1508,21 +1537,25 @@ function modelsRoutes(deps2) {
|
|
|
1508
1537
|
modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 })
|
|
1509
1538
|
};
|
|
1510
1539
|
if (body.apiKey !== void 0) {
|
|
1511
|
-
if (typeof body.apiKey !== "string") throw badRequest("apiKey
|
|
1540
|
+
if (typeof body.apiKey !== "string") throw badRequest("apiKey must be a string.");
|
|
1512
1541
|
if (body.apiKey) req.apiKey = body.apiKey;
|
|
1513
1542
|
}
|
|
1514
1543
|
if (body.clearApiKey !== void 0) {
|
|
1515
|
-
if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey
|
|
1544
|
+
if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey must be a boolean.");
|
|
1516
1545
|
req.clearApiKey = body.clearApiKey;
|
|
1517
1546
|
}
|
|
1547
|
+
if (body.speed !== void 0) {
|
|
1548
|
+
if (typeof body.speed !== "boolean") throw badRequest("speed must be a boolean.");
|
|
1549
|
+
req.speed = body.speed;
|
|
1550
|
+
}
|
|
1518
1551
|
if (body.baseUrl !== void 0) {
|
|
1519
1552
|
if (body.baseUrl !== null && typeof body.baseUrl !== "string") {
|
|
1520
|
-
throw badRequest("baseUrl
|
|
1553
|
+
throw badRequest("baseUrl must be a string or null.");
|
|
1521
1554
|
}
|
|
1522
1555
|
req.baseUrl = body.baseUrl ? body.baseUrl : null;
|
|
1523
1556
|
}
|
|
1524
1557
|
if (body.clientType !== void 0) {
|
|
1525
|
-
if (typeof body.clientType !== "string") throw badRequest("clientType
|
|
1558
|
+
if (typeof body.clientType !== "string") throw badRequest("clientType must be a string.");
|
|
1526
1559
|
if (body.clientType) req.clientType = body.clientType;
|
|
1527
1560
|
}
|
|
1528
1561
|
return c.json(await deps2.projectConfigService.testModel(projectId, req));
|
|
@@ -1533,10 +1566,10 @@ function modelsRoutes(deps2) {
|
|
|
1533
1566
|
// src/http/routes/vault.ts
|
|
1534
1567
|
import { Hono as Hono8 } from "hono";
|
|
1535
1568
|
function parseVaultUpdate(body) {
|
|
1536
|
-
if (!Array.isArray(body.entries)) throw badRequest("entries
|
|
1569
|
+
if (!Array.isArray(body.entries)) throw badRequest("entries must be an array.");
|
|
1537
1570
|
const entries = body.entries.map((item, i) => {
|
|
1538
1571
|
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
1539
|
-
throw badRequest(`entries[${i}]
|
|
1572
|
+
throw badRequest(`entries[${i}] must be an object.`);
|
|
1540
1573
|
}
|
|
1541
1574
|
const e = item;
|
|
1542
1575
|
const entry = {
|
|
@@ -1544,7 +1577,7 @@ function parseVaultUpdate(body) {
|
|
|
1544
1577
|
};
|
|
1545
1578
|
if (e.value !== void 0) {
|
|
1546
1579
|
if (typeof e.value !== "string" || e.value.length === 0) {
|
|
1547
|
-
throw badRequest(`entries[${i}].value
|
|
1580
|
+
throw badRequest(`entries[${i}].value must be a non-empty string.`);
|
|
1548
1581
|
}
|
|
1549
1582
|
entry.value = e.value;
|
|
1550
1583
|
}
|
|
@@ -1566,6 +1599,7 @@ function vaultRoutes(deps2) {
|
|
|
1566
1599
|
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
1567
1600
|
const req = parseVaultUpdate(await readJson(c));
|
|
1568
1601
|
const res = await deps2.agentConfigService.updateVault(projectId, agentId, req);
|
|
1602
|
+
deps2.manager.invalidateAgentRuntimes(projectId, agentId);
|
|
1569
1603
|
return c.json(res);
|
|
1570
1604
|
});
|
|
1571
1605
|
return app2;
|
|
@@ -1603,72 +1637,77 @@ function parseScheduleFile(name, raw) {
|
|
|
1603
1637
|
} catch (err) {
|
|
1604
1638
|
return {
|
|
1605
1639
|
ok: false,
|
|
1606
|
-
error: `TOML
|
|
1640
|
+
error: `Failed to parse TOML: ${err instanceof Error ? err.message : String(err)}`
|
|
1607
1641
|
};
|
|
1608
1642
|
}
|
|
1609
1643
|
if (parsed === null || typeof parsed !== "object")
|
|
1610
|
-
return { ok: false, error: "
|
|
1644
|
+
return { ok: false, error: "Content is not a TOML table" };
|
|
1611
1645
|
const t = parsed;
|
|
1612
1646
|
const prompt = t["prompt"];
|
|
1613
1647
|
if (typeof prompt !== "string" || prompt.trim() === "") {
|
|
1614
|
-
return { ok: false, error: "
|
|
1648
|
+
return { ok: false, error: "Missing required field prompt" };
|
|
1615
1649
|
}
|
|
1616
1650
|
const enabled = t["enabled"] === void 0 ? false : t["enabled"];
|
|
1617
|
-
if (typeof enabled !== "boolean") return { ok: false, error: "enabled
|
|
1651
|
+
if (typeof enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
|
|
1618
1652
|
const startAt = parseInstant(t["start_at"]);
|
|
1619
|
-
if (startAt === null)
|
|
1653
|
+
if (startAt === null)
|
|
1654
|
+
return { ok: false, error: "start_at is missing or not a valid ISO 8601 instant" };
|
|
1620
1655
|
let period;
|
|
1621
1656
|
let periodMs;
|
|
1622
1657
|
if (t["period"] !== void 0) {
|
|
1623
|
-
if (typeof t["period"] !== "string") return { ok: false, error: "period
|
|
1658
|
+
if (typeof t["period"] !== "string") return { ok: false, error: "period must be a string" };
|
|
1624
1659
|
const ms = parsePeriod(t["period"]);
|
|
1625
|
-
if (ms === null) return { ok: false, error: "period
|
|
1626
|
-
if (ms < MIN_PERIOD_MS) return { ok: false, error: "period
|
|
1660
|
+
if (ms === null) return { ok: false, error: "period must look like 30m / 12h / 7d" };
|
|
1661
|
+
if (ms < MIN_PERIOD_MS) return { ok: false, error: "period is below the 5m minimum" };
|
|
1627
1662
|
period = t["period"].trim();
|
|
1628
1663
|
periodMs = ms;
|
|
1629
1664
|
}
|
|
1630
1665
|
let endAt;
|
|
1631
1666
|
if (t["end_at"] !== void 0) {
|
|
1632
1667
|
const parsedEnd = parseInstant(t["end_at"]);
|
|
1633
|
-
if (parsedEnd === null) return { ok: false, error: "end_at
|
|
1634
|
-
if (parsedEnd.ms <= startAt.ms)
|
|
1668
|
+
if (parsedEnd === null) return { ok: false, error: "end_at is not a valid ISO 8601 instant" };
|
|
1669
|
+
if (parsedEnd.ms <= startAt.ms)
|
|
1670
|
+
return { ok: false, error: "end_at must be later than start_at" };
|
|
1635
1671
|
endAt = parsedEnd;
|
|
1636
1672
|
}
|
|
1637
1673
|
let sessionId;
|
|
1638
1674
|
if (t["session_id"] !== void 0) {
|
|
1639
1675
|
if (typeof t["session_id"] !== "string" || t["session_id"] === "") {
|
|
1640
|
-
return { ok: false, error: "session_id
|
|
1676
|
+
return { ok: false, error: "session_id must be a non-empty string" };
|
|
1641
1677
|
}
|
|
1642
1678
|
sessionId = t["session_id"];
|
|
1643
1679
|
}
|
|
1644
1680
|
let workspace;
|
|
1645
1681
|
if (t["workspace"] !== void 0) {
|
|
1646
1682
|
if (typeof t["workspace"] !== "string" || t["workspace"] === "") {
|
|
1647
|
-
return { ok: false, error: "workspace
|
|
1683
|
+
return { ok: false, error: "workspace must be a non-empty string" };
|
|
1648
1684
|
}
|
|
1649
1685
|
workspace = t["workspace"];
|
|
1650
1686
|
}
|
|
1651
1687
|
let modelId;
|
|
1652
1688
|
if (t["model_id"] !== void 0) {
|
|
1653
1689
|
if (typeof t["model_id"] !== "string" || t["model_id"] === "") {
|
|
1654
|
-
return { ok: false, error: "model_id
|
|
1690
|
+
return { ok: false, error: "model_id must be a non-empty string" };
|
|
1655
1691
|
}
|
|
1656
1692
|
modelId = t["model_id"];
|
|
1657
1693
|
}
|
|
1658
1694
|
let provider;
|
|
1659
1695
|
if (t["provider"] !== void 0) {
|
|
1660
1696
|
if (typeof t["provider"] !== "string" || t["provider"] === "") {
|
|
1661
|
-
return { ok: false, error: "provider
|
|
1697
|
+
return { ok: false, error: "provider must be a non-empty string" };
|
|
1662
1698
|
}
|
|
1663
1699
|
provider = t["provider"];
|
|
1664
1700
|
}
|
|
1665
|
-
if (provider !== void 0 && modelId === void 0) {
|
|
1666
|
-
return { ok: false, error: "provider \u4EC5\u4E0E model_id \u6210\u5BF9\u4F7F\u7528\uFF08\u6A21\u578B\u5F15\u7528\u987B\u6210\u5BF9\u7ED9\u51FA\uFF09" };
|
|
1667
|
-
}
|
|
1668
1701
|
if (sessionId !== void 0 && (workspace !== void 0 || modelId !== void 0 || provider !== void 0)) {
|
|
1669
1702
|
return {
|
|
1670
1703
|
ok: false,
|
|
1671
|
-
error: "
|
|
1704
|
+
error: "Pick one target: workspace and provider / model_id are only for new-Session mode"
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
if (modelId === void 0 !== (provider === void 0)) {
|
|
1708
|
+
return {
|
|
1709
|
+
ok: false,
|
|
1710
|
+
error: "provider and model_id must be given together (a model reference is always a pair); omit both to use the Project's default model"
|
|
1672
1711
|
};
|
|
1673
1712
|
}
|
|
1674
1713
|
return {
|
|
@@ -1763,7 +1802,10 @@ function serializeSchedule(fields) {
|
|
|
1763
1802
|
`;
|
|
1764
1803
|
}
|
|
1765
1804
|
async function validateScheduleModelRef(root, projectId, def) {
|
|
1766
|
-
if (def.modelId === void 0) return null;
|
|
1805
|
+
if (def.modelId === void 0 && def.provider === void 0) return null;
|
|
1806
|
+
if (def.modelId === void 0 || def.provider === void 0) {
|
|
1807
|
+
return "provider and model_id must be given together (a model reference is always a pair); omit both to use the Project's default model";
|
|
1808
|
+
}
|
|
1767
1809
|
try {
|
|
1768
1810
|
const cfg = await loadProjectConfig(root, projectId);
|
|
1769
1811
|
resolveModelRef(cfg, def.modelId, def.provider);
|
|
@@ -1789,7 +1831,7 @@ async function deleteScheduleFile(root, projectId, agentId, name) {
|
|
|
1789
1831
|
|
|
1790
1832
|
// src/http/routes/schedules.ts
|
|
1791
1833
|
function parseUpsertBody(body) {
|
|
1792
|
-
if (typeof body.enabled !== "boolean") throw badRequest("enabled
|
|
1834
|
+
if (typeof body.enabled !== "boolean") throw badRequest("enabled must be a boolean.");
|
|
1793
1835
|
const prompt = requireString(body, "prompt", { minLen: 1, maxLen: 1e5 });
|
|
1794
1836
|
const startAt = requireString(body, "startAt", { minLen: 1, maxLen: 100 });
|
|
1795
1837
|
const period = optionalString(body, "period", { minLen: 1, maxLen: 20 });
|
|
@@ -1849,7 +1891,7 @@ function toItem(def, state, queued, nowMs) {
|
|
|
1849
1891
|
};
|
|
1850
1892
|
}
|
|
1851
1893
|
function requireScheduleName(raw) {
|
|
1852
|
-
if (!raw || !isValidId2(raw)) throw badRequest("
|
|
1894
|
+
if (!raw || !isValidId2(raw)) throw badRequest("Invalid schedule name.");
|
|
1853
1895
|
return raw;
|
|
1854
1896
|
}
|
|
1855
1897
|
function scheduleRoutes(deps2) {
|
|
@@ -1875,7 +1917,7 @@ function scheduleRoutes(deps2) {
|
|
|
1875
1917
|
const body = await readJson(c);
|
|
1876
1918
|
const name = requireScheduleName(requireString(body, "name", { minLen: 1, maxLen: 100 }));
|
|
1877
1919
|
if (await readScheduleFile(deps2.config.root, projectId, agentId, name)) {
|
|
1878
|
-
throw new HttpError(409, "schedule_exists",
|
|
1920
|
+
throw new HttpError(409, "schedule_exists", `Schedule already exists: ${name}`);
|
|
1879
1921
|
}
|
|
1880
1922
|
await upsert(deps2, c.var.user.userId, projectId, agentId, name, body);
|
|
1881
1923
|
return c.json(await readItem(deps2, projectId, agentId, name), 201);
|
|
@@ -1894,7 +1936,7 @@ function scheduleRoutes(deps2) {
|
|
|
1894
1936
|
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
1895
1937
|
const name = requireScheduleName(c.req.param("name"));
|
|
1896
1938
|
if (!await readScheduleFile(deps2.config.root, projectId, agentId, name)) {
|
|
1897
|
-
throw new HttpError(404, "schedule_not_found",
|
|
1939
|
+
throw new HttpError(404, "schedule_not_found", `Schedule does not exist: ${name}`);
|
|
1898
1940
|
}
|
|
1899
1941
|
const body = await readJson(c);
|
|
1900
1942
|
await upsert(deps2, c.var.user.userId, projectId, agentId, name, body);
|
|
@@ -1906,7 +1948,8 @@ function scheduleRoutes(deps2) {
|
|
|
1906
1948
|
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
1907
1949
|
const name = requireScheduleName(c.req.param("name"));
|
|
1908
1950
|
const removed = await deleteScheduleFile(deps2.config.root, projectId, agentId, name);
|
|
1909
|
-
if (!removed)
|
|
1951
|
+
if (!removed)
|
|
1952
|
+
throw new HttpError(404, "schedule_not_found", `Schedule does not exist: ${name}`);
|
|
1910
1953
|
deps2.scheduler.dropEntry(projectId, agentId, name);
|
|
1911
1954
|
return c.body(null, 204);
|
|
1912
1955
|
});
|
|
@@ -1916,9 +1959,9 @@ async function upsert(deps2, userId, projectId, agentId, name, body) {
|
|
|
1916
1959
|
const fields = parseUpsertBody(body);
|
|
1917
1960
|
const raw = serializeSchedule(fields);
|
|
1918
1961
|
const parsed = parseScheduleFile(name, raw);
|
|
1919
|
-
if (!parsed.ok) throw badRequest(
|
|
1962
|
+
if (!parsed.ok) throw badRequest(`Invalid schedule configuration: ${parsed.error}`);
|
|
1920
1963
|
const refError = await validateScheduleModelRef(deps2.config.root, projectId, parsed.def);
|
|
1921
|
-
if (refError !== null) throw badRequest(
|
|
1964
|
+
if (refError !== null) throw badRequest(`Invalid schedule configuration: ${refError}`);
|
|
1922
1965
|
await writeScheduleFile(deps2.config.root, projectId, agentId, name, raw);
|
|
1923
1966
|
deps2.schedulesRepo.registerOrSync({
|
|
1924
1967
|
projectId,
|
|
@@ -1935,8 +1978,8 @@ async function readItem(deps2, projectId, agentId, name) {
|
|
|
1935
1978
|
const entry = entries.find((e) => e.def.name === name);
|
|
1936
1979
|
if (entry) return toItem(entry.def, entry.state, entry.queued, Date.now());
|
|
1937
1980
|
const bad = invalid.find((i) => i.name === name);
|
|
1938
|
-
if (bad) throw badRequest(
|
|
1939
|
-
throw new HttpError(404, "schedule_not_found",
|
|
1981
|
+
if (bad) throw badRequest(`Invalid schedule file: ${bad.error}`);
|
|
1982
|
+
throw new HttpError(404, "schedule_not_found", `Schedule does not exist: ${name}`);
|
|
1940
1983
|
}
|
|
1941
1984
|
|
|
1942
1985
|
// src/http/routes/benchmarks.ts
|
|
@@ -1987,11 +2030,11 @@ function libraryResponse() {
|
|
|
1987
2030
|
}
|
|
1988
2031
|
function parseInstallNames(body) {
|
|
1989
2032
|
if (!Array.isArray(body.names) || body.names.length === 0) {
|
|
1990
|
-
throw badRequest("names
|
|
2033
|
+
throw badRequest("names must be a non-empty array.");
|
|
1991
2034
|
}
|
|
1992
2035
|
return body.names.map((v, i) => {
|
|
1993
2036
|
if (typeof v !== "string" || v.length === 0) {
|
|
1994
|
-
throw badRequest(`names[${i}]
|
|
2037
|
+
throw badRequest(`names[${i}] must be a non-empty string.`);
|
|
1995
2038
|
}
|
|
1996
2039
|
return v;
|
|
1997
2040
|
});
|
|
@@ -2021,7 +2064,7 @@ function agentSkillsRoutes(deps2) {
|
|
|
2021
2064
|
const names = parseInstallNames(await readJson(c));
|
|
2022
2065
|
const skills = names.map((name) => {
|
|
2023
2066
|
const skill = librarySkill(name);
|
|
2024
|
-
if (!skill) throw new HttpError(404, "unknown_skill", `Skill
|
|
2067
|
+
if (!skill) throw new HttpError(404, "unknown_skill", `Skill is not in the library: ${name}`);
|
|
2025
2068
|
return skill;
|
|
2026
2069
|
});
|
|
2027
2070
|
for (const skill of skills) {
|
|
@@ -2038,7 +2081,7 @@ function agentSkillsRoutes(deps2) {
|
|
|
2038
2081
|
try {
|
|
2039
2082
|
await fs2.access(file);
|
|
2040
2083
|
} catch {
|
|
2041
|
-
throw new HttpError(404, "not_found", `Skill
|
|
2084
|
+
throw new HttpError(404, "not_found", `Skill is not installed: ${name}`);
|
|
2042
2085
|
}
|
|
2043
2086
|
await removeSkill(deps2.config.root, projectId, agentId, name);
|
|
2044
2087
|
return c.body(null, 204);
|
|
@@ -2077,10 +2120,11 @@ function agentTransferRoutes(deps2) {
|
|
|
2077
2120
|
try {
|
|
2078
2121
|
archive = Buffer.from(dataBase64, "base64");
|
|
2079
2122
|
} catch {
|
|
2080
|
-
throw badRequest("dataBase64
|
|
2123
|
+
throw badRequest("dataBase64 is not valid base64.");
|
|
2081
2124
|
}
|
|
2082
|
-
if (archive.byteLength === 0) throw badRequest("
|
|
2083
|
-
if (archive.byteLength > MAX_ARCHIVE_BYTES)
|
|
2125
|
+
if (archive.byteLength === 0) throw badRequest("Import package is empty.");
|
|
2126
|
+
if (archive.byteLength > MAX_ARCHIVE_BYTES)
|
|
2127
|
+
throw badRequest("Import package exceeds the 14MB limit.");
|
|
2084
2128
|
const { version } = await deps2.snapshots.importArchive(projectId, agentId, archive, confirm);
|
|
2085
2129
|
const res = { version };
|
|
2086
2130
|
return c.json(res);
|
|
@@ -2175,17 +2219,21 @@ function dirsRoutes(deps2) {
|
|
|
2175
2219
|
const raw = c.req.query("path");
|
|
2176
2220
|
const target = raw && raw.trim() ? raw.trim() : os.homedir();
|
|
2177
2221
|
if (!path4.isAbsolute(target)) {
|
|
2178
|
-
throw new HttpError(400, "dir_not_absolute", "
|
|
2222
|
+
throw new HttpError(400, "dir_not_absolute", "Directory must be an absolute path.");
|
|
2179
2223
|
}
|
|
2180
2224
|
let real;
|
|
2181
2225
|
try {
|
|
2182
2226
|
real = await fs4.realpath(target);
|
|
2183
2227
|
} catch {
|
|
2184
|
-
throw new HttpError(
|
|
2228
|
+
throw new HttpError(
|
|
2229
|
+
404,
|
|
2230
|
+
"dir_not_found",
|
|
2231
|
+
`Directory does not exist or is inaccessible: ${target}.`
|
|
2232
|
+
);
|
|
2185
2233
|
}
|
|
2186
2234
|
const stat = await fs4.stat(real);
|
|
2187
2235
|
if (!stat.isDirectory()) {
|
|
2188
|
-
throw new HttpError(400, "not_a_dir", "
|
|
2236
|
+
throw new HttpError(400, "not_a_dir", "Not a directory.");
|
|
2189
2237
|
}
|
|
2190
2238
|
let dirents = [];
|
|
2191
2239
|
try {
|
|
@@ -2228,7 +2276,7 @@ function agentConfigRoutes(deps2) {
|
|
|
2228
2276
|
if (agentsMd !== void 0) req.agentsMd = agentsMd;
|
|
2229
2277
|
if (body.config !== void 0) {
|
|
2230
2278
|
if (body.config === null || typeof body.config !== "object" || Array.isArray(body.config)) {
|
|
2231
|
-
throw badRequest("config
|
|
2279
|
+
throw badRequest("config must be an object.");
|
|
2232
2280
|
}
|
|
2233
2281
|
req.config = body.config;
|
|
2234
2282
|
}
|
|
@@ -2284,7 +2332,7 @@ function usageRoutes(deps2) {
|
|
|
2284
2332
|
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2285
2333
|
const groupByRaw = c.req.query("groupBy") ?? "date";
|
|
2286
2334
|
if (!GROUP_BYS.includes(groupByRaw)) {
|
|
2287
|
-
throw badRequest(`groupBy
|
|
2335
|
+
throw badRequest(`groupBy must be one of ${GROUP_BYS.join(" / ")}.`);
|
|
2288
2336
|
}
|
|
2289
2337
|
const from = optionalDateParam(c.req.query("from"), "from");
|
|
2290
2338
|
const to = optionalDateParam(c.req.query("to"), "to");
|
|
@@ -2326,12 +2374,16 @@ async function assertWorkspaceAllowed(args) {
|
|
|
2326
2374
|
throw new HttpError(
|
|
2327
2375
|
400,
|
|
2328
2376
|
"workspace_not_found",
|
|
2329
|
-
`Workspace
|
|
2377
|
+
`Workspace does not exist or is inaccessible: ${args.workspace}. Specify an existing directory, or leave it empty to use a temporary directory.`
|
|
2330
2378
|
);
|
|
2331
2379
|
}
|
|
2332
2380
|
const stat = await fs5.stat(ws);
|
|
2333
2381
|
if (!stat.isDirectory()) {
|
|
2334
|
-
throw new HttpError(
|
|
2382
|
+
throw new HttpError(
|
|
2383
|
+
400,
|
|
2384
|
+
"workspace_not_found",
|
|
2385
|
+
`Workspace is not a directory: ${args.workspace}.`
|
|
2386
|
+
);
|
|
2335
2387
|
}
|
|
2336
2388
|
return ws;
|
|
2337
2389
|
}
|
|
@@ -2373,7 +2425,7 @@ var WorkspaceFilesService = class {
|
|
|
2373
2425
|
try {
|
|
2374
2426
|
return await fs6.realpath(path5.resolve(workspace));
|
|
2375
2427
|
} catch {
|
|
2376
|
-
throw new HttpError(404, "workspace_missing", "
|
|
2428
|
+
throw new HttpError(404, "workspace_missing", "This Session's Workspace no longer exists.");
|
|
2377
2429
|
}
|
|
2378
2430
|
}
|
|
2379
2431
|
/**
|
|
@@ -2389,16 +2441,16 @@ var WorkspaceFilesService = class {
|
|
|
2389
2441
|
}
|
|
2390
2442
|
/** Lexical prefix check (a relative path, once resolved, must still be inside the Workspace); returns the absolute target path. */
|
|
2391
2443
|
lexicalTarget(base, rel) {
|
|
2392
|
-
if (rel.includes("\0")) throw badRequest("path
|
|
2444
|
+
if (rel.includes("\0")) throw badRequest("path is invalid.");
|
|
2393
2445
|
const target = path5.resolve(base, rel === "" ? "." : rel);
|
|
2394
2446
|
if (!this.isInside(target, base)) {
|
|
2395
|
-
throw badRequest("path
|
|
2447
|
+
throw badRequest("path must be inside the Workspace.");
|
|
2396
2448
|
}
|
|
2397
2449
|
return target;
|
|
2398
2450
|
}
|
|
2399
2451
|
assertInside(real, realBase) {
|
|
2400
2452
|
if (!this.isInside(real, realBase)) {
|
|
2401
|
-
throw badRequest("path
|
|
2453
|
+
throw badRequest("path must be inside the Workspace.");
|
|
2402
2454
|
}
|
|
2403
2455
|
}
|
|
2404
2456
|
/**
|
|
@@ -2416,7 +2468,7 @@ var WorkspaceFilesService = class {
|
|
|
2416
2468
|
canonical = await fs6.realpath(target);
|
|
2417
2469
|
} catch (err) {
|
|
2418
2470
|
if (err.code === "ENOENT") {
|
|
2419
|
-
throw new HttpError(404, "path_not_found", "
|
|
2471
|
+
throw new HttpError(404, "path_not_found", "File does not exist.");
|
|
2420
2472
|
}
|
|
2421
2473
|
throw err;
|
|
2422
2474
|
}
|
|
@@ -2441,7 +2493,7 @@ var WorkspaceFilesService = class {
|
|
|
2441
2493
|
const realBase = await this.realBase(workspace);
|
|
2442
2494
|
const target = this.lexicalTarget(path5.resolve(workspace), rel);
|
|
2443
2495
|
const name = path5.basename(target);
|
|
2444
|
-
if (name === "" || name === "." || name === "..") throw badRequest("path
|
|
2496
|
+
if (name === "" || name === "." || name === "..") throw badRequest("path must be a file path.");
|
|
2445
2497
|
const parent = path5.dirname(target);
|
|
2446
2498
|
let canonicalParent;
|
|
2447
2499
|
try {
|
|
@@ -2456,7 +2508,7 @@ var WorkspaceFilesService = class {
|
|
|
2456
2508
|
} catch (probeErr) {
|
|
2457
2509
|
if (probeErr.code !== "ENOENT") throw probeErr;
|
|
2458
2510
|
const up = path5.dirname(probe);
|
|
2459
|
-
if (up === probe) throw badRequest("path
|
|
2511
|
+
if (up === probe) throw badRequest("path is invalid.");
|
|
2460
2512
|
probe = up;
|
|
2461
2513
|
}
|
|
2462
2514
|
}
|
|
@@ -2496,10 +2548,10 @@ var WorkspaceFilesService = class {
|
|
|
2496
2548
|
dirents = await fs6.readdir(dir, { withFileTypes: true });
|
|
2497
2549
|
} catch (err) {
|
|
2498
2550
|
if (err.code === "ENOENT") {
|
|
2499
|
-
throw new HttpError(404, "path_not_found", "
|
|
2551
|
+
throw new HttpError(404, "path_not_found", "Directory does not exist.");
|
|
2500
2552
|
}
|
|
2501
2553
|
if (err.code === "ENOTDIR") {
|
|
2502
|
-
throw badRequest("path
|
|
2554
|
+
throw badRequest("path is not a directory.");
|
|
2503
2555
|
}
|
|
2504
2556
|
throw err;
|
|
2505
2557
|
}
|
|
@@ -2535,11 +2587,11 @@ var WorkspaceFilesService = class {
|
|
|
2535
2587
|
try {
|
|
2536
2588
|
stat = await fs6.stat(file);
|
|
2537
2589
|
} catch {
|
|
2538
|
-
throw new HttpError(404, "path_not_found", "
|
|
2590
|
+
throw new HttpError(404, "path_not_found", "File does not exist.");
|
|
2539
2591
|
}
|
|
2540
|
-
if (stat.isDirectory()) throw badRequest("path
|
|
2592
|
+
if (stat.isDirectory()) throw badRequest("path is a directory.");
|
|
2541
2593
|
if (stat.size > MAX_READ_BYTES) {
|
|
2542
|
-
throw new HttpError(413, "file_too_large", "
|
|
2594
|
+
throw new HttpError(413, "file_too_large", "File exceeds the 50MB read limit.");
|
|
2543
2595
|
}
|
|
2544
2596
|
const data = await fs6.readFile(file);
|
|
2545
2597
|
const ext = path5.extname(file).toLowerCase();
|
|
@@ -2559,9 +2611,9 @@ var WorkspaceFilesService = class {
|
|
|
2559
2611
|
* sandbox escapes).
|
|
2560
2612
|
*/
|
|
2561
2613
|
async write(workspace, rel, data) {
|
|
2562
|
-
if (rel === "" || rel.endsWith("/")) throw badRequest("path
|
|
2614
|
+
if (rel === "" || rel.endsWith("/")) throw badRequest("path must be a file path.");
|
|
2563
2615
|
if (data.length > MAX_UPLOAD_BYTES) {
|
|
2564
|
-
throw new HttpError(413, "file_too_large", "
|
|
2616
|
+
throw new HttpError(413, "file_too_large", "Uploaded file exceeds the 14MB limit.");
|
|
2565
2617
|
}
|
|
2566
2618
|
const { dir, name } = await this.resolveWriteParent(workspace, rel);
|
|
2567
2619
|
const file = path5.join(dir, name);
|
|
@@ -2571,9 +2623,10 @@ var WorkspaceFilesService = class {
|
|
|
2571
2623
|
handle = await fs6.open(file, flags, 420);
|
|
2572
2624
|
} catch (err) {
|
|
2573
2625
|
const code = err.code;
|
|
2574
|
-
if (code === "ELOOP") throw badRequest("path
|
|
2575
|
-
if (code === "ENOENT")
|
|
2576
|
-
|
|
2626
|
+
if (code === "ELOOP") throw badRequest("path must not be a symlink.");
|
|
2627
|
+
if (code === "ENOENT")
|
|
2628
|
+
throw new HttpError(404, "path_not_found", "Parent directory does not exist.");
|
|
2629
|
+
if (code === "EISDIR") throw badRequest("path is a directory.");
|
|
2577
2630
|
throw err;
|
|
2578
2631
|
}
|
|
2579
2632
|
try {
|
|
@@ -2597,27 +2650,27 @@ var APPROVAL_MODES = [
|
|
|
2597
2650
|
function parseTaskInput(body) {
|
|
2598
2651
|
const input = body.input;
|
|
2599
2652
|
if (!Array.isArray(input) || input.length === 0) {
|
|
2600
|
-
throw badRequest("input
|
|
2653
|
+
throw badRequest("input must be an array with at least one item.");
|
|
2601
2654
|
}
|
|
2602
2655
|
return input.map((item, i) => {
|
|
2603
2656
|
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
2604
|
-
throw badRequest(`input[${i}]
|
|
2657
|
+
throw badRequest(`input[${i}] must be an object.`);
|
|
2605
2658
|
}
|
|
2606
2659
|
const part = item;
|
|
2607
2660
|
if (part.type === "text") {
|
|
2608
2661
|
if (typeof part.text !== "string" || part.text.length === 0) {
|
|
2609
|
-
throw badRequest(`input[${i}].text
|
|
2662
|
+
throw badRequest(`input[${i}].text must be a non-empty string.`);
|
|
2610
2663
|
}
|
|
2611
2664
|
return userText(part.text);
|
|
2612
2665
|
}
|
|
2613
2666
|
if (part.type === "image_url") {
|
|
2614
2667
|
const url = part.imageUrl;
|
|
2615
2668
|
if (typeof url !== "string" || !(url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://"))) {
|
|
2616
|
-
throw badRequest(`input[${i}].imageUrl
|
|
2669
|
+
throw badRequest(`input[${i}].imageUrl only supports data: or http(s) URLs.`);
|
|
2617
2670
|
}
|
|
2618
2671
|
return imageUrlMessage(url);
|
|
2619
2672
|
}
|
|
2620
|
-
throw badRequest(`input[${i}].type
|
|
2673
|
+
throw badRequest(`input[${i}].type must be one of text / image_url.`);
|
|
2621
2674
|
});
|
|
2622
2675
|
}
|
|
2623
2676
|
function agentSessionsRoutes(deps2) {
|
|
@@ -2627,7 +2680,12 @@ function agentSessionsRoutes(deps2) {
|
|
|
2627
2680
|
const agentId = requireValidId(c, "agentId");
|
|
2628
2681
|
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2629
2682
|
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2630
|
-
const
|
|
2683
|
+
const paging = optionalPagingQuery(c);
|
|
2684
|
+
const sessions = await deps2.sessionService.listSessions(
|
|
2685
|
+
projectId,
|
|
2686
|
+
agentId,
|
|
2687
|
+
...paging ? [paging] : []
|
|
2688
|
+
);
|
|
2631
2689
|
return c.json({ sessions });
|
|
2632
2690
|
});
|
|
2633
2691
|
app2.post("/", async (c) => {
|
|
@@ -2638,8 +2696,10 @@ function agentSessionsRoutes(deps2) {
|
|
|
2638
2696
|
const body = await readJson(c);
|
|
2639
2697
|
const modelId = optionalString(body, "modelId", { minLen: 1, label: "modelId" });
|
|
2640
2698
|
const provider = optionalString(body, "provider", { minLen: 1, label: "provider" });
|
|
2641
|
-
if (
|
|
2642
|
-
throw badRequest(
|
|
2699
|
+
if (modelId === void 0 !== (provider === void 0)) {
|
|
2700
|
+
throw badRequest(
|
|
2701
|
+
"modelId and provider must be given together as a model reference pair: specify both, or neither to use the Project's default model."
|
|
2702
|
+
);
|
|
2643
2703
|
}
|
|
2644
2704
|
const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES);
|
|
2645
2705
|
let workspace = optionalString(body, "workspace", { minLen: 1, label: "workspace" });
|
|
@@ -2664,19 +2724,29 @@ function sessionsRoutes(deps2) {
|
|
|
2664
2724
|
const sessionId = c.req.param("sessionId");
|
|
2665
2725
|
const row = sessionId ? deps2.sessionsRepo.findById(sessionId) : null;
|
|
2666
2726
|
if (!row) {
|
|
2667
|
-
throw new HttpError(
|
|
2727
|
+
throw new HttpError(
|
|
2728
|
+
404,
|
|
2729
|
+
"session_not_found",
|
|
2730
|
+
"Session does not exist or you do not have access."
|
|
2731
|
+
);
|
|
2668
2732
|
}
|
|
2669
2733
|
try {
|
|
2670
2734
|
deps2.projectService.requireProjectAccess(c.var.user.userId, row.projectId);
|
|
2671
2735
|
} catch {
|
|
2672
|
-
throw new HttpError(
|
|
2736
|
+
throw new HttpError(
|
|
2737
|
+
404,
|
|
2738
|
+
"session_not_found",
|
|
2739
|
+
"Session does not exist or you do not have access."
|
|
2740
|
+
);
|
|
2673
2741
|
}
|
|
2674
2742
|
return row;
|
|
2675
2743
|
};
|
|
2676
2744
|
app2.get("/:sessionId", async (c) => {
|
|
2677
2745
|
const row = resolveSession(c);
|
|
2678
2746
|
const hasTrace = await deps2.sessionService.hasTrace(row);
|
|
2679
|
-
return c.json({
|
|
2747
|
+
return c.json({
|
|
2748
|
+
session: await deps2.sessionService.toInfo(row, hasTrace)
|
|
2749
|
+
});
|
|
2680
2750
|
});
|
|
2681
2751
|
app2.patch("/:sessionId", async (c) => {
|
|
2682
2752
|
const row = resolveSession(c);
|
|
@@ -2688,15 +2758,23 @@ function sessionsRoutes(deps2) {
|
|
|
2688
2758
|
let title;
|
|
2689
2759
|
if (titleRaw !== void 0) {
|
|
2690
2760
|
if (typeof titleRaw !== "string") {
|
|
2691
|
-
throw new HttpError(400, "invalid_title", "title
|
|
2761
|
+
throw new HttpError(400, "invalid_title", "title must be a string.");
|
|
2692
2762
|
}
|
|
2693
2763
|
title = titleRaw.trim();
|
|
2694
2764
|
if (!title || title.length > SESSION_TITLE_MAX) {
|
|
2695
|
-
throw new HttpError(
|
|
2765
|
+
throw new HttpError(
|
|
2766
|
+
400,
|
|
2767
|
+
"invalid_title",
|
|
2768
|
+
`title must be 1\u2013${SESSION_TITLE_MAX} characters.`
|
|
2769
|
+
);
|
|
2696
2770
|
}
|
|
2697
2771
|
}
|
|
2698
2772
|
if (approvalMode === void 0 && archived === void 0 && title === void 0) {
|
|
2699
|
-
throw new HttpError(
|
|
2773
|
+
throw new HttpError(
|
|
2774
|
+
400,
|
|
2775
|
+
"no_update",
|
|
2776
|
+
"No updatable field provided (approvalMode / archived / title)."
|
|
2777
|
+
);
|
|
2700
2778
|
}
|
|
2701
2779
|
let updated = { ...row };
|
|
2702
2780
|
if (title !== void 0) {
|
|
@@ -2714,7 +2792,7 @@ function sessionsRoutes(deps2) {
|
|
|
2714
2792
|
}
|
|
2715
2793
|
const hasTrace = await deps2.sessionService.hasTrace(updated);
|
|
2716
2794
|
return c.json({
|
|
2717
|
-
session: deps2.sessionService.toInfo(updated, hasTrace)
|
|
2795
|
+
session: await deps2.sessionService.toInfo(updated, hasTrace)
|
|
2718
2796
|
});
|
|
2719
2797
|
});
|
|
2720
2798
|
app2.delete("/:sessionId", async (c) => {
|
|
@@ -2733,6 +2811,7 @@ function sessionsRoutes(deps2) {
|
|
|
2733
2811
|
{ recursive: true, force: true }
|
|
2734
2812
|
);
|
|
2735
2813
|
deps2.sessionsRepo.deleteById(row.sessionId);
|
|
2814
|
+
deps2.sessionSources.delete(row.sessionId);
|
|
2736
2815
|
} finally {
|
|
2737
2816
|
deps2.manager.endSessionDeletion(row.sessionId);
|
|
2738
2817
|
}
|
|
@@ -2742,7 +2821,7 @@ function sessionsRoutes(deps2) {
|
|
|
2742
2821
|
const row = resolveSession(c);
|
|
2743
2822
|
const fileName = c.req.param("fileName") ?? "";
|
|
2744
2823
|
if (!/^[A-Za-z0-9._-]+$/.test(fileName) || fileName.includes("..")) {
|
|
2745
|
-
throw new HttpError(404, "file_not_found", "
|
|
2824
|
+
throw new HttpError(404, "file_not_found", "File does not exist.");
|
|
2746
2825
|
}
|
|
2747
2826
|
const filePath = path6.join(
|
|
2748
2827
|
scratchpadDir(deps2.config.root, row.projectId, row.agentId),
|
|
@@ -2753,7 +2832,7 @@ function sessionsRoutes(deps2) {
|
|
|
2753
2832
|
try {
|
|
2754
2833
|
bytes = await fs7.readFile(filePath);
|
|
2755
2834
|
} catch {
|
|
2756
|
-
throw new HttpError(404, "file_not_found", "
|
|
2835
|
+
throw new HttpError(404, "file_not_found", "File does not exist.");
|
|
2757
2836
|
}
|
|
2758
2837
|
const MIME_BY_EXT = {
|
|
2759
2838
|
".png": "image/png",
|
|
@@ -2802,7 +2881,11 @@ function sessionsRoutes(deps2) {
|
|
|
2802
2881
|
const decision = requireEnum(body, "decision", ["allow", "deny"]);
|
|
2803
2882
|
const ok = deps2.manager.decideApproval(row.sessionId, pathParam(c, "toolCallId"), decision);
|
|
2804
2883
|
if (!ok) {
|
|
2805
|
-
throw new HttpError(
|
|
2884
|
+
throw new HttpError(
|
|
2885
|
+
404,
|
|
2886
|
+
"approval_not_found",
|
|
2887
|
+
"Approval does not exist or has already been decided."
|
|
2888
|
+
);
|
|
2806
2889
|
}
|
|
2807
2890
|
return c.body(null, 204);
|
|
2808
2891
|
});
|
|
@@ -2825,18 +2908,22 @@ function sessionsRoutes(deps2) {
|
|
|
2825
2908
|
const row = resolveSession(c);
|
|
2826
2909
|
const rel = c.req.query("path") ?? "";
|
|
2827
2910
|
const download = c.req.query("download") === "1";
|
|
2911
|
+
const preview = !download && c.req.query("preview") === "1";
|
|
2828
2912
|
const { data, fileName, contentType, scriptable } = await deps2.workspaceFiles.read(
|
|
2829
2913
|
row.workspace,
|
|
2830
2914
|
rel
|
|
2831
2915
|
);
|
|
2832
2916
|
const disposition = download ? "attachment" : "inline";
|
|
2833
|
-
const effectiveType = !download && scriptable ? "text/plain; charset=utf-8" : contentType;
|
|
2917
|
+
const effectiveType = !download && scriptable && !preview ? "text/plain; charset=utf-8" : contentType;
|
|
2834
2918
|
return new Response(new Uint8Array(data), {
|
|
2835
2919
|
status: 200,
|
|
2836
2920
|
headers: {
|
|
2837
2921
|
"Content-Type": effectiveType,
|
|
2838
2922
|
"Content-Disposition": `${disposition}; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
2839
|
-
"X-Content-Type-Options": "nosniff"
|
|
2923
|
+
"X-Content-Type-Options": "nosniff",
|
|
2924
|
+
...preview && scriptable ? {
|
|
2925
|
+
"Content-Security-Policy": "sandbox allow-scripts allow-popups allow-modals allow-forms"
|
|
2926
|
+
} : {}
|
|
2840
2927
|
}
|
|
2841
2928
|
});
|
|
2842
2929
|
});
|
|
@@ -2846,7 +2933,7 @@ function sessionsRoutes(deps2) {
|
|
|
2846
2933
|
const paths = body.paths;
|
|
2847
2934
|
if (!Array.isArray(paths) || paths.length > STAT_MAX_PATHS || !paths.every((p) => typeof p === "string" && p.length <= STAT_MAX_PATH_LEN)) {
|
|
2848
2935
|
throw badRequest(
|
|
2849
|
-
`paths \
|
|
2936
|
+
`paths must be an array of strings (\u2264${STAT_MAX_PATHS} items, each \u2264${STAT_MAX_PATH_LEN} characters).`
|
|
2850
2937
|
);
|
|
2851
2938
|
}
|
|
2852
2939
|
const existing = await deps2.workspaceFiles.statExisting(row.workspace, paths);
|
|
@@ -2857,11 +2944,11 @@ function sessionsRoutes(deps2) {
|
|
|
2857
2944
|
const rel = c.req.query("path") ?? "";
|
|
2858
2945
|
const body = await readJson(c);
|
|
2859
2946
|
if (typeof body.dataBase64 !== "string") {
|
|
2860
|
-
throw badRequest("dataBase64
|
|
2947
|
+
throw badRequest("dataBase64 must be a base64 string.");
|
|
2861
2948
|
}
|
|
2862
2949
|
const data = Buffer.from(body.dataBase64, "base64");
|
|
2863
2950
|
if (data.length > MAX_UPLOAD_BYTES) {
|
|
2864
|
-
throw new HttpError(413, "file_too_large", "
|
|
2951
|
+
throw new HttpError(413, "file_too_large", "Uploaded file exceeds the 14MB limit.");
|
|
2865
2952
|
}
|
|
2866
2953
|
await deps2.workspaceFiles.write(row.workspace, rel, data);
|
|
2867
2954
|
return c.body(null, 204);
|
|
@@ -2898,8 +2985,8 @@ function sessionsRoutes(deps2) {
|
|
|
2898
2985
|
|
|
2899
2986
|
// src/runtime/channel.ts
|
|
2900
2987
|
import { randomUUID } from "crypto";
|
|
2901
|
-
var DEFAULT_MAX_COUNT =
|
|
2902
|
-
var DEFAULT_MAX_BYTES =
|
|
2988
|
+
var DEFAULT_MAX_COUNT = 1e4;
|
|
2989
|
+
var DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
2903
2990
|
var Channel = class {
|
|
2904
2991
|
/** Channel epoch: generated at instance creation, prefixed onto event ids (necessarily changes after recycle/recreate or restart). */
|
|
2905
2992
|
epoch = randomUUID().slice(0, 8);
|
|
@@ -3163,18 +3250,42 @@ function makeApprove(args) {
|
|
|
3163
3250
|
};
|
|
3164
3251
|
}
|
|
3165
3252
|
|
|
3253
|
+
// src/runtime/session-sources.ts
|
|
3254
|
+
function asSessionSource(v) {
|
|
3255
|
+
return v === "schedule" || v === "subagent" ? v : void 0;
|
|
3256
|
+
}
|
|
3257
|
+
var SessionSources = class {
|
|
3258
|
+
map = /* @__PURE__ */ new Map();
|
|
3259
|
+
/** Records a Session's origin as read from session_meta (`null` = meta seen, user-created). */
|
|
3260
|
+
set(sessionId, source) {
|
|
3261
|
+
this.map.set(sessionId, source);
|
|
3262
|
+
}
|
|
3263
|
+
/** Known origin, `null` for a known user-created Session, `undefined` when this process has not seen the meta. */
|
|
3264
|
+
get(sessionId) {
|
|
3265
|
+
return this.map.get(sessionId);
|
|
3266
|
+
}
|
|
3267
|
+
/** Drops a deleted Session's entry (bulk Agent/Project deletion may leave stale entries; they are never matched again). */
|
|
3268
|
+
delete(sessionId) {
|
|
3269
|
+
this.map.delete(sessionId);
|
|
3270
|
+
}
|
|
3271
|
+
};
|
|
3272
|
+
|
|
3166
3273
|
// src/runtime/stream-error-watcher.ts
|
|
3167
3274
|
import { isEventMessage, isModelMessage, isSessionMeta } from "@prismshadow/penguin-core";
|
|
3168
3275
|
import path7 from "path";
|
|
3169
3276
|
var TOOL_NAMES_MAX = 1e3;
|
|
3170
3277
|
var ORIGIN_CTX_MAX = 200;
|
|
3171
3278
|
var LLM_FAILURES = {
|
|
3172
|
-
failed: { code: "llm_failed", kind: "unexpected", text: "LLM
|
|
3173
|
-
timeout: {
|
|
3279
|
+
failed: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
|
|
3280
|
+
timeout: {
|
|
3281
|
+
code: "llm_timeout",
|
|
3282
|
+
kind: "expected",
|
|
3283
|
+
text: "LLM request timed out (the engine reconnects and retries)."
|
|
3284
|
+
},
|
|
3174
3285
|
malformed: {
|
|
3175
3286
|
code: "llm_malformed",
|
|
3176
3287
|
kind: "expected",
|
|
3177
|
-
text: "LLM
|
|
3288
|
+
text: "LLM response could not be parsed (the engine reconnects and retries)."
|
|
3178
3289
|
}
|
|
3179
3290
|
};
|
|
3180
3291
|
function isLlmFailure(s) {
|
|
@@ -3191,7 +3302,7 @@ function originKey(msg) {
|
|
|
3191
3302
|
return origin && origin.length > 0 ? origin[origin.length - 1] : "";
|
|
3192
3303
|
}
|
|
3193
3304
|
function toolFailureText(output) {
|
|
3194
|
-
if (!output) return "
|
|
3305
|
+
if (!output) return "Tool execution failed (no output).";
|
|
3195
3306
|
if (output.length <= MESSAGE_MAX) return output;
|
|
3196
3307
|
return `\u2026${output.slice(output.length - (MESSAGE_MAX - 1))}`;
|
|
3197
3308
|
}
|
|
@@ -3331,13 +3442,13 @@ var StreamErrorWatcher = class {
|
|
|
3331
3442
|
// src/runtime/session-manager.ts
|
|
3332
3443
|
function compactUnavailable(why) {
|
|
3333
3444
|
const messages = {
|
|
3334
|
-
unsupported: "
|
|
3335
|
-
empty: "
|
|
3336
|
-
just_compacted: "
|
|
3445
|
+
unsupported: "This Agent does not have context compaction configured.",
|
|
3446
|
+
empty: "The current context has nothing to compact (no completed conversation turns yet).",
|
|
3447
|
+
just_compacted: "The context was just compacted and there is no new conversation since; no need to compact again."
|
|
3337
3448
|
};
|
|
3338
3449
|
return new HttpError(409, "nothing_to_compact", messages[why]);
|
|
3339
3450
|
}
|
|
3340
|
-
function createCoreSessionLoader(root) {
|
|
3451
|
+
function createCoreSessionLoader(root, sources) {
|
|
3341
3452
|
return {
|
|
3342
3453
|
async load(row) {
|
|
3343
3454
|
const agent = await createAgent({
|
|
@@ -3364,14 +3475,17 @@ function createCoreSessionLoader(root) {
|
|
|
3364
3475
|
throw new HttpError(
|
|
3365
3476
|
409,
|
|
3366
3477
|
"workspace_missing",
|
|
3367
|
-
|
|
3478
|
+
`This Session's Workspace no longer exists: ${row.workspace}, so it cannot continue. Create a new Session.`
|
|
3368
3479
|
);
|
|
3369
3480
|
}
|
|
3481
|
+
const knownSource = sources?.get(row.sessionId);
|
|
3370
3482
|
try {
|
|
3371
3483
|
return await agent.createSession({
|
|
3372
3484
|
workspaceDir: row.workspace,
|
|
3373
3485
|
modelId: row.modelId,
|
|
3374
|
-
provider: row.provider
|
|
3486
|
+
provider: row.provider,
|
|
3487
|
+
// The rebuilt Session re-records a known origin in its fresh session_meta.
|
|
3488
|
+
...knownSource != null ? { source: knownSource } : {}
|
|
3375
3489
|
});
|
|
3376
3490
|
} catch (err) {
|
|
3377
3491
|
if (isMissingCredential(err)) throw modelCredentialMissing(row.modelId);
|
|
@@ -3391,6 +3505,7 @@ function toUnrecoverableError(err) {
|
|
|
3391
3505
|
var ENTRY_IDLE_MS = 30 * 60 * 1e3;
|
|
3392
3506
|
var ENTRY_SWEEP_INTERVAL_MS = 60 * 1e3;
|
|
3393
3507
|
var TITLE_EXCERPT_LIMIT = 4e3;
|
|
3508
|
+
var EARLY_TITLE_BODY_CHARS = 1e3;
|
|
3394
3509
|
function agentKey(projectId, agentId) {
|
|
3395
3510
|
return `${projectId}\0${agentId}`;
|
|
3396
3511
|
}
|
|
@@ -3447,6 +3562,8 @@ var SessionManager = class {
|
|
|
3447
3562
|
deletingAgents = /* @__PURE__ */ new Set();
|
|
3448
3563
|
/** Sessions currently being deleted (guards against the entry/Trace file being rebuilt and reviving it inside the deletion race window). */
|
|
3449
3564
|
deletingSessions = /* @__PURE__ */ new Set();
|
|
3565
|
+
/** Per-Agent config generation (key = agentKey), bumped by invalidateAgentRuntimes on vault updates. */
|
|
3566
|
+
agentGenerations = /* @__PURE__ */ new Map();
|
|
3450
3567
|
sweepTimer;
|
|
3451
3568
|
// —— Query surface (used by Session listing / Agent active-count / SSE subscription replay) ——
|
|
3452
3569
|
statusOf(sessionId) {
|
|
@@ -3479,9 +3596,23 @@ var SessionManager = class {
|
|
|
3479
3596
|
approvals: new ApprovalRegistry(),
|
|
3480
3597
|
abort: null,
|
|
3481
3598
|
running: null,
|
|
3599
|
+
generation: this.generationOf(row.projectId, row.agentId),
|
|
3482
3600
|
lastActivityMs: Date.now()
|
|
3483
3601
|
});
|
|
3484
3602
|
}
|
|
3603
|
+
/**
|
|
3604
|
+
* After an Agent's vault is updated: bump the Agent's config generation so every
|
|
3605
|
+
* runtime built before the update is discarded on its next idle access and
|
|
3606
|
+
* re-resumed via the loader — resume re-reads agent_state/.vault.toml, so the next
|
|
3607
|
+
* Task on any of this Agent's Sessions runs with the new values (history is
|
|
3608
|
+
* preserved through the Trace). A Task already in flight is neither aborted nor
|
|
3609
|
+
* hot-swapped: it keeps the values it started with, and its entry is rebuilt on
|
|
3610
|
+
* the first access after it returns to idle (see ensureEntry).
|
|
3611
|
+
*/
|
|
3612
|
+
invalidateAgentRuntimes(projectId, agentId) {
|
|
3613
|
+
const key = agentKey(projectId, agentId);
|
|
3614
|
+
this.agentGenerations.set(key, this.generationOf(projectId, agentId) + 1);
|
|
3615
|
+
}
|
|
3485
3616
|
// —— Task / compaction drive ——
|
|
3486
3617
|
/**
|
|
3487
3618
|
* Start a Task: get-or-load → 409
|
|
@@ -3657,38 +3788,70 @@ var SessionManager = class {
|
|
|
3657
3788
|
// —— Internal ——
|
|
3658
3789
|
assertOpen() {
|
|
3659
3790
|
if (this.closed) {
|
|
3660
|
-
throw new HttpError(
|
|
3791
|
+
throw new HttpError(
|
|
3792
|
+
503,
|
|
3793
|
+
"shutting_down",
|
|
3794
|
+
"Server is shutting down; not accepting new Tasks."
|
|
3795
|
+
);
|
|
3661
3796
|
}
|
|
3662
3797
|
}
|
|
3663
3798
|
/** The Agent owning this Session is being deleted → 409 (guards against directory recreation inside the deletion race window). */
|
|
3664
3799
|
assertAgentNotDeleting(sessionId) {
|
|
3665
3800
|
const row = this.deps.sessions.findById(sessionId);
|
|
3666
3801
|
if (row && this.deletingAgents.has(agentKey(row.projectId, row.agentId))) {
|
|
3667
|
-
throw new HttpError(
|
|
3802
|
+
throw new HttpError(
|
|
3803
|
+
409,
|
|
3804
|
+
"agent_deleting",
|
|
3805
|
+
"This Agent is being deleted; not accepting new Tasks."
|
|
3806
|
+
);
|
|
3668
3807
|
}
|
|
3669
3808
|
}
|
|
3670
3809
|
/** This Session is being deleted → 409 (guards against the entry/Trace being rebuilt and reviving it inside the deletion race window). */
|
|
3671
3810
|
assertSessionNotDeleting(sessionId) {
|
|
3672
3811
|
if (this.deletingSessions.has(sessionId)) {
|
|
3673
|
-
throw new HttpError(
|
|
3812
|
+
throw new HttpError(
|
|
3813
|
+
409,
|
|
3814
|
+
"session_deleting",
|
|
3815
|
+
"This Session is being deleted; not accepting new Tasks."
|
|
3816
|
+
);
|
|
3674
3817
|
}
|
|
3675
3818
|
}
|
|
3676
3819
|
assertIdle(entry) {
|
|
3677
3820
|
if (entry.status === "running") {
|
|
3678
|
-
throw new HttpError(409, "task_in_progress", "
|
|
3821
|
+
throw new HttpError(409, "task_in_progress", "This Session already has a Task in progress.");
|
|
3679
3822
|
}
|
|
3680
3823
|
if (entry.status === "compacting") {
|
|
3681
|
-
throw new HttpError(
|
|
3824
|
+
throw new HttpError(
|
|
3825
|
+
409,
|
|
3826
|
+
"compacting",
|
|
3827
|
+
"This Session is compacting its context; not accepting new input."
|
|
3828
|
+
);
|
|
3682
3829
|
}
|
|
3683
3830
|
}
|
|
3831
|
+
generationOf(projectId, agentId) {
|
|
3832
|
+
return this.agentGenerations.get(agentKey(projectId, agentId)) ?? 0;
|
|
3833
|
+
}
|
|
3684
3834
|
/** get-or-resume-or-heal: use directly on an active-table hit; otherwise load via the loader, updating the index's primary key on self-heal. */
|
|
3685
3835
|
async ensureEntry(sessionId) {
|
|
3686
3836
|
const existing = this.entries.get(sessionId);
|
|
3687
|
-
if (existing)
|
|
3837
|
+
if (existing) {
|
|
3838
|
+
if (existing.generation === this.generationOf(existing.projectId, existing.agentId)) {
|
|
3839
|
+
return existing;
|
|
3840
|
+
}
|
|
3841
|
+
if (existing.status !== "idle" || existing.running !== null || existing.approvals.size !== 0) {
|
|
3842
|
+
return existing;
|
|
3843
|
+
}
|
|
3844
|
+
this.entries.delete(sessionId);
|
|
3845
|
+
}
|
|
3688
3846
|
const row = this.deps.sessions.findById(sessionId);
|
|
3689
3847
|
if (!row) {
|
|
3690
|
-
throw new HttpError(
|
|
3848
|
+
throw new HttpError(
|
|
3849
|
+
404,
|
|
3850
|
+
"session_not_found",
|
|
3851
|
+
"Session does not exist or you do not have access."
|
|
3852
|
+
);
|
|
3691
3853
|
}
|
|
3854
|
+
const generation = this.generationOf(row.projectId, row.agentId);
|
|
3692
3855
|
const session = await this.deps.loader.load(row);
|
|
3693
3856
|
this.assertSessionNotDeleting(row.sessionId);
|
|
3694
3857
|
this.assertAgentNotDeleting(row.sessionId);
|
|
@@ -3708,6 +3871,7 @@ var SessionManager = class {
|
|
|
3708
3871
|
approvals: new ApprovalRegistry(),
|
|
3709
3872
|
abort: null,
|
|
3710
3873
|
running: null,
|
|
3874
|
+
generation,
|
|
3711
3875
|
lastActivityMs: Date.now()
|
|
3712
3876
|
};
|
|
3713
3877
|
this.entries.set(currentId, entry);
|
|
@@ -3720,6 +3884,8 @@ var SessionManager = class {
|
|
|
3720
3884
|
* a title): it collects model text for automatic title generation.
|
|
3721
3885
|
*/
|
|
3722
3886
|
async drive(entry, gen, titleSource) {
|
|
3887
|
+
let earlyTitleFired = false;
|
|
3888
|
+
let mainBodyChars = 0;
|
|
3723
3889
|
const ctx = {
|
|
3724
3890
|
projectId: entry.projectId,
|
|
3725
3891
|
agentId: entry.agentId,
|
|
@@ -3755,7 +3921,7 @@ var SessionManager = class {
|
|
|
3755
3921
|
}
|
|
3756
3922
|
} catch (err) {
|
|
3757
3923
|
this.log(
|
|
3758
|
-
`[subagent]
|
|
3924
|
+
`[subagent] Failed to register child session: ${err instanceof Error ? err.message : String(err)}`
|
|
3759
3925
|
);
|
|
3760
3926
|
this.deps.errors?.record({
|
|
3761
3927
|
source: "subagent",
|
|
@@ -3771,18 +3937,30 @@ var SessionManager = class {
|
|
|
3771
3937
|
child.assistantExcerpt += (child.assistantExcerpt ? "\n" : "") + nested.text;
|
|
3772
3938
|
}
|
|
3773
3939
|
}
|
|
3940
|
+
if (!earlyTitleFired && titleSource?.userExcerpt.trim()) {
|
|
3941
|
+
const p = msg.payload;
|
|
3942
|
+
if ((!msg.origin || msg.origin.length === 0) && msg.type === "model_msg" && p.type === "text" && p.role === "assistant" && p.text) {
|
|
3943
|
+
mainBodyChars += p.text.length;
|
|
3944
|
+
if (mainBodyChars >= EARLY_TITLE_BODY_CHARS) {
|
|
3945
|
+
earlyTitleFired = true;
|
|
3946
|
+
this.deps.titles?.maybeGenerate(ctx, entry.session, {
|
|
3947
|
+
fallbackText: titleSource.userExcerpt
|
|
3948
|
+
});
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3774
3952
|
this.deps.channels.get(entry.sessionId).publish(msg);
|
|
3775
3953
|
watcher?.observe(msg);
|
|
3776
3954
|
try {
|
|
3777
3955
|
await this.deps.recorder.record(ctx, msg);
|
|
3778
3956
|
} catch (err) {
|
|
3779
|
-
this.log(`[usage]
|
|
3957
|
+
this.log(`[usage] Insert failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3780
3958
|
this.deps.errors?.record({ source: "usage", err, ctx, code: "usage_insert_failed" });
|
|
3781
3959
|
}
|
|
3782
3960
|
}
|
|
3783
3961
|
} catch (err) {
|
|
3784
3962
|
this.log(
|
|
3785
|
-
`[session]
|
|
3963
|
+
`[session] Run failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`
|
|
3786
3964
|
);
|
|
3787
3965
|
this.deps.errors?.record({ source: "session", err, ctx, code: "session_run_failed" });
|
|
3788
3966
|
} finally {
|
|
@@ -3835,6 +4013,8 @@ var SessionManager = class {
|
|
|
3835
4013
|
const p = msg.payload;
|
|
3836
4014
|
const agentId = path8.basename(path8.dirname(p.agent_state));
|
|
3837
4015
|
if (!agentId || agentId === "." || agentId === "..") return null;
|
|
4016
|
+
const source = asSessionSource(p.source) ?? "subagent";
|
|
4017
|
+
this.deps.sources.set(childSid, source);
|
|
3838
4018
|
this.deps.sessions.insertOrIgnore({
|
|
3839
4019
|
sessionId: childSid,
|
|
3840
4020
|
projectId: entry.projectId,
|
|
@@ -3846,7 +4026,6 @@ var SessionManager = class {
|
|
|
3846
4026
|
// inserted with defaults (matches the convention for Sessions discovered by the CLI).
|
|
3847
4027
|
approvalMode: "allow-all",
|
|
3848
4028
|
title: null,
|
|
3849
|
-
source: "subagent",
|
|
3850
4029
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3851
4030
|
});
|
|
3852
4031
|
this.publishEvent(entry, {
|
|
@@ -3854,7 +4033,7 @@ var SessionManager = class {
|
|
|
3854
4033
|
projectId: entry.projectId,
|
|
3855
4034
|
agentId,
|
|
3856
4035
|
sessionId: childSid,
|
|
3857
|
-
source
|
|
4036
|
+
source
|
|
3858
4037
|
});
|
|
3859
4038
|
const child = {
|
|
3860
4039
|
sessionId: childSid,
|
|
@@ -3913,7 +4092,7 @@ var Scheduler = class {
|
|
|
3913
4092
|
now;
|
|
3914
4093
|
intervalMs;
|
|
3915
4094
|
timer = null;
|
|
3916
|
-
/** key = `${projectId}
|
|
4095
|
+
/** key = `${projectId}\0${agentId}\0${name}` */
|
|
3917
4096
|
pending = /* @__PURE__ */ new Map();
|
|
3918
4097
|
ticking = false;
|
|
3919
4098
|
/** Start: run one reconcile immediately (startup semantics: no backfill), then enter the periodic tick. */
|
|
@@ -4010,7 +4189,7 @@ var Scheduler = class {
|
|
|
4010
4189
|
if (!file.parsed.ok) {
|
|
4011
4190
|
this.deps.errors.record({
|
|
4012
4191
|
source: "schedule",
|
|
4013
|
-
err: new Error(
|
|
4192
|
+
err: new Error(`Invalid schedule file: ${file.name}.toml \u2014 ${file.parsed.error}`),
|
|
4014
4193
|
code: "schedule_invalid_file",
|
|
4015
4194
|
ctx: { projectId, agentId }
|
|
4016
4195
|
});
|
|
@@ -4021,7 +4200,7 @@ var Scheduler = class {
|
|
|
4021
4200
|
if (refError !== null) {
|
|
4022
4201
|
this.deps.errors.record({
|
|
4023
4202
|
source: "schedule",
|
|
4024
|
-
err: new Error(
|
|
4203
|
+
err: new Error(`Invalid schedule file: ${file.name}.toml \u2014 ${refError}`),
|
|
4025
4204
|
code: "schedule_invalid_file",
|
|
4026
4205
|
ctx: { projectId, agentId }
|
|
4027
4206
|
});
|
|
@@ -4081,7 +4260,9 @@ var Scheduler = class {
|
|
|
4081
4260
|
if (!row || row.projectId !== projectId || row.agentId !== agentId) {
|
|
4082
4261
|
this.deps.errors.record({
|
|
4083
4262
|
source: "schedule",
|
|
4084
|
-
err: new Error(
|
|
4263
|
+
err: new Error(
|
|
4264
|
+
`Schedule ${def.name} is bound to a Session that does not exist: ${def.sessionId}`
|
|
4265
|
+
),
|
|
4085
4266
|
code: "schedule_session_missing",
|
|
4086
4267
|
ctx: { projectId, agentId, sessionId: def.sessionId }
|
|
4087
4268
|
});
|
|
@@ -4179,7 +4360,12 @@ var Scheduler = class {
|
|
|
4179
4360
|
};
|
|
4180
4361
|
|
|
4181
4362
|
// src/runtime/title-generator.ts
|
|
4182
|
-
import {
|
|
4363
|
+
import {
|
|
4364
|
+
emptyTokenCounts,
|
|
4365
|
+
sanitizeTitle,
|
|
4366
|
+
stripConversationMarkers,
|
|
4367
|
+
tokenUsage
|
|
4368
|
+
} from "@prismshadow/penguin-core";
|
|
4183
4369
|
var TitleGenerator = class {
|
|
4184
4370
|
constructor(deps2) {
|
|
4185
4371
|
this.deps = deps2;
|
|
@@ -4195,7 +4381,7 @@ var TitleGenerator = class {
|
|
|
4195
4381
|
if (this.inflight.has(ctx.sessionId)) return;
|
|
4196
4382
|
this.inflight.add(ctx.sessionId);
|
|
4197
4383
|
void this.generate(ctx, session, req).catch((err) => {
|
|
4198
|
-
this.log(`[title]
|
|
4384
|
+
this.log(`[title] Generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4199
4385
|
this.deps.errors?.record({ source: "title", err, ctx, code: "title_failed" });
|
|
4200
4386
|
}).finally(() => {
|
|
4201
4387
|
this.inflight.delete(ctx.sessionId);
|
|
@@ -4212,7 +4398,9 @@ var TitleGenerator = class {
|
|
|
4212
4398
|
try {
|
|
4213
4399
|
await this.deps.recorder.record(ctx, tokenUsage(emptyTokenCounts(), res.usage));
|
|
4214
4400
|
} catch (err) {
|
|
4215
|
-
this.log(
|
|
4401
|
+
this.log(
|
|
4402
|
+
`[title] Usage insert failed: ${err instanceof Error ? err.message : String(err)}`
|
|
4403
|
+
);
|
|
4216
4404
|
this.deps.errors?.record({
|
|
4217
4405
|
source: "title",
|
|
4218
4406
|
err,
|
|
@@ -4222,7 +4410,7 @@ var TitleGenerator = class {
|
|
|
4222
4410
|
}
|
|
4223
4411
|
}
|
|
4224
4412
|
} catch (err) {
|
|
4225
|
-
this.log(`[title]
|
|
4413
|
+
this.log(`[title] Model request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4226
4414
|
this.deps.errors?.record({ source: "title", err, ctx, code: "title_llm_failed" });
|
|
4227
4415
|
}
|
|
4228
4416
|
const finalTitle = title ?? fallbackTitle(req.fallbackText);
|
|
@@ -4237,7 +4425,7 @@ var TitleGenerator = class {
|
|
|
4237
4425
|
}
|
|
4238
4426
|
};
|
|
4239
4427
|
function fallbackTitle(text) {
|
|
4240
|
-
const firstLine = text.split("\n").find((l) => l.trim().length > 0);
|
|
4428
|
+
const firstLine = stripConversationMarkers(text).split("\n").find((l) => l.trim().length > 0);
|
|
4241
4429
|
if (!firstLine) return null;
|
|
4242
4430
|
return sanitizeTitle(firstLine) ?? firstLine.trim().slice(0, 30);
|
|
4243
4431
|
}
|
|
@@ -4317,7 +4505,7 @@ var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
|
|
|
4317
4505
|
var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
|
|
4318
4506
|
var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
|
|
4319
4507
|
var PROJECT_ID_MAX_LENGTH = 64;
|
|
4320
|
-
var SEMANTIC_ID_RULE = "
|
|
4508
|
+
var SEMANTIC_ID_RULE = "starts with a lowercase letter; lowercase letters, digits and underscores only";
|
|
4321
4509
|
|
|
4322
4510
|
// src/services/admin-service.ts
|
|
4323
4511
|
var AdminService = class {
|
|
@@ -4332,13 +4520,17 @@ var AdminService = class {
|
|
|
4332
4520
|
}
|
|
4333
4521
|
async createUser(userId, password) {
|
|
4334
4522
|
if (!USERNAME_PATTERN.test(userId)) {
|
|
4335
|
-
throw new HttpError(
|
|
4523
|
+
throw new HttpError(
|
|
4524
|
+
400,
|
|
4525
|
+
"invalid_user_id",
|
|
4526
|
+
`Username must be 2\u201332 characters: ${SEMANTIC_ID_RULE}.`
|
|
4527
|
+
);
|
|
4336
4528
|
}
|
|
4337
4529
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
4338
|
-
throw new HttpError(400, "invalid_password", "
|
|
4530
|
+
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
|
|
4339
4531
|
}
|
|
4340
4532
|
if (this.deps.users.findById(userId)) {
|
|
4341
|
-
throw new HttpError(409, "user_exists",
|
|
4533
|
+
throw new HttpError(409, "user_exists", `User already exists: ${userId}.`);
|
|
4342
4534
|
}
|
|
4343
4535
|
const user = {
|
|
4344
4536
|
userId,
|
|
@@ -4359,10 +4551,10 @@ var AdminService = class {
|
|
|
4359
4551
|
/** Reset another user's password: flags it as initial and clears all their sessions (prompts a password change on next login). */
|
|
4360
4552
|
async resetPassword(userId, password) {
|
|
4361
4553
|
if (!this.deps.users.findById(userId)) {
|
|
4362
|
-
throw new HttpError(404, "user_not_found",
|
|
4554
|
+
throw new HttpError(404, "user_not_found", `User does not exist: ${userId}.`);
|
|
4363
4555
|
}
|
|
4364
4556
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
4365
|
-
throw new HttpError(400, "invalid_password", "
|
|
4557
|
+
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
|
|
4366
4558
|
}
|
|
4367
4559
|
this.deps.users.updatePassword(userId, await hashPassword(password), true);
|
|
4368
4560
|
this.deps.authSessions.deleteByUser(userId);
|
|
@@ -4371,10 +4563,10 @@ var AdminService = class {
|
|
|
4371
4563
|
async deleteUser(userId) {
|
|
4372
4564
|
const target = this.deps.users.findById(userId);
|
|
4373
4565
|
if (!target) {
|
|
4374
|
-
throw new HttpError(404, "user_not_found",
|
|
4566
|
+
throw new HttpError(404, "user_not_found", `User does not exist: ${userId}.`);
|
|
4375
4567
|
}
|
|
4376
4568
|
if (target.isAdmin) {
|
|
4377
|
-
throw new HttpError(409, "cannot_delete_admin", "
|
|
4569
|
+
throw new HttpError(409, "cannot_delete_admin", "The built-in admin cannot be deleted.");
|
|
4378
4570
|
}
|
|
4379
4571
|
for (const project of this.deps.projects.listByOwner(userId)) {
|
|
4380
4572
|
await this.deps.projectService.destroyProject(project.projectId);
|
|
@@ -4441,6 +4633,8 @@ function refKey(provider, modelId) {
|
|
|
4441
4633
|
function showRef(provider, modelId) {
|
|
4442
4634
|
return `(provider=${provider}, model_id=${modelId})`;
|
|
4443
4635
|
}
|
|
4636
|
+
var PROBE_PROMPT = 'ping - reply with the single word "pong" and nothing else. Do not think or explain.\n<think></think>';
|
|
4637
|
+
var SPEED_PROBE_PROMPT = "Count from 1 to 50 as a comma-separated list, and nothing else. Do not think or explain.\n<think></think>";
|
|
4444
4638
|
var ProjectConfigService = class {
|
|
4445
4639
|
constructor(root) {
|
|
4446
4640
|
this.root = root;
|
|
@@ -4537,12 +4731,19 @@ var ProjectConfigService = class {
|
|
|
4537
4731
|
* as a pair in the request body; sends one minimal request using that model's
|
|
4538
4732
|
* config (optionally overridden with an unsaved apiKey / baseUrl) — no tools, no
|
|
4539
4733
|
* system prompt, thinking disabled, a tiny output cap, 20s timeout — just to see
|
|
4540
|
-
* whether
|
|
4734
|
+
* whether the endpoint answers. The model id sent to AgentHub is `modelId`
|
|
4541
4735
|
* itself (the upstream id verbatim; client_type inference follows it).
|
|
4542
4736
|
*
|
|
4737
|
+
* A reasoning-heavy model may ignore the disabled thinking level and burn the
|
|
4738
|
+
* whole tiny output cap on thinking (finish_reason=length with no text — AgentHub
|
|
4739
|
+
* raises EmptyResponseError, collapsed to a malformed outcome): the endpoint
|
|
4740
|
+
* demonstrably streamed model output, which is everything a connectivity test
|
|
4741
|
+
* proves, so that case counts as ok too (see probeVerdict).
|
|
4742
|
+
*
|
|
4543
4743
|
* Never throws: the LLM layer collapses auth/parameter/network errors into an
|
|
4544
4744
|
* `LLMOutcome`, which is translated here into ok / message. Consumes very few
|
|
4545
|
-
* Tokens (single-digit output
|
|
4745
|
+
* Tokens (single-digit output; speed mode spends up to its 64-token cap to have a
|
|
4746
|
+
* window worth timing), and writes no Trace and records no usage.
|
|
4546
4747
|
*/
|
|
4547
4748
|
async testModel(projectId, req) {
|
|
4548
4749
|
const raw = await this.readRaw(projectId);
|
|
@@ -4561,18 +4762,39 @@ var ProjectConfigService = class {
|
|
|
4561
4762
|
...clientType ? { clientType } : {},
|
|
4562
4763
|
tools: [],
|
|
4563
4764
|
thinkingLevel: "none",
|
|
4564
|
-
|
|
4765
|
+
// Speed mode pairs a raised cap with a prompt that keeps generating (see
|
|
4766
|
+
// SPEED_PROBE_PROMPT), so the stream lasts long enough for TTFT/TPS to describe
|
|
4767
|
+
// decoding rather than one round trip; the plain connectivity test keeps the
|
|
4768
|
+
// single-digit-token budget.
|
|
4769
|
+
maxTokens: req.speed ? 64 : 16,
|
|
4565
4770
|
requestTimeoutMs: 2e4
|
|
4566
4771
|
});
|
|
4567
|
-
const gen = llm.streamGenerate({
|
|
4772
|
+
const gen = llm.streamGenerate({
|
|
4773
|
+
newMessages: [userText3(req.speed ? SPEED_PROBE_PROMPT : PROBE_PROMPT)]
|
|
4774
|
+
});
|
|
4775
|
+
let sawContent = false;
|
|
4776
|
+
let firstContentAt = null;
|
|
4777
|
+
let outputTokens = 0;
|
|
4568
4778
|
for (; ; ) {
|
|
4569
4779
|
const step = await gen.next();
|
|
4570
4780
|
if (step.done) {
|
|
4571
|
-
const
|
|
4572
|
-
if (
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4781
|
+
const verdict = probeVerdict(step.value, sawContent);
|
|
4782
|
+
if (!verdict.ok) return verdict;
|
|
4783
|
+
const res = { ok: true, latencyMs: Date.now() - startedAt };
|
|
4784
|
+
if (firstContentAt !== null) {
|
|
4785
|
+
res.ttftMs = firstContentAt - startedAt;
|
|
4786
|
+
const tps = probeTps(outputTokens, Date.now() - firstContentAt);
|
|
4787
|
+
if (tps !== void 0) res.tps = tps;
|
|
4788
|
+
}
|
|
4789
|
+
return res;
|
|
4790
|
+
}
|
|
4791
|
+
if (isProbeContent(step.value)) {
|
|
4792
|
+
sawContent = true;
|
|
4793
|
+
if (firstContentAt === null) firstContentAt = Date.now();
|
|
4794
|
+
}
|
|
4795
|
+
const p = step.value.payload;
|
|
4796
|
+
if (p.type === "token_usage" && typeof p.request?.output === "number") {
|
|
4797
|
+
outputTokens = p.request.output;
|
|
4576
4798
|
}
|
|
4577
4799
|
}
|
|
4578
4800
|
} catch (err) {
|
|
@@ -4608,6 +4830,7 @@ var ProjectConfigService = class {
|
|
|
4608
4830
|
const cat = catalogEntryFor(provider, modelId);
|
|
4609
4831
|
const envKey = resolveModelEnv(modelId, clientType)?.envKey;
|
|
4610
4832
|
const vision = typeof m.vision === "boolean" ? m.vision : cat?.supportsVision;
|
|
4833
|
+
const maxTokens = optNum(m.max_tokens);
|
|
4611
4834
|
const displayName = optStr(m.display_name) ?? cat?.displayName;
|
|
4612
4835
|
const apiKey = optStr(m.api_key);
|
|
4613
4836
|
const credBaseUrl = optStr(m.base_url);
|
|
@@ -4620,6 +4843,7 @@ var ProjectConfigService = class {
|
|
|
4620
4843
|
...optNum(m.context_window) !== void 0 ? { contextWindow: optNum(m.context_window) } : {},
|
|
4621
4844
|
...clientType ? { clientType } : {},
|
|
4622
4845
|
...vision !== void 0 ? { vision } : {},
|
|
4846
|
+
...maxTokens !== void 0 ? { maxTokens } : {},
|
|
4623
4847
|
...envKey ? { envKey } : {},
|
|
4624
4848
|
...pricingDto ? { pricing: pricingDto } : {},
|
|
4625
4849
|
...apiKey !== void 0 || credBaseUrl !== void 0 ? {
|
|
@@ -4662,7 +4886,7 @@ var ProjectConfigService = class {
|
|
|
4662
4886
|
const key = refKey(entry.provider, entry.modelId);
|
|
4663
4887
|
if (seen.has(key)) {
|
|
4664
4888
|
throw badRequest(
|
|
4665
|
-
`models
|
|
4889
|
+
`models contains a duplicate model reference: ${showRef(entry.provider, entry.modelId)}.`
|
|
4666
4890
|
);
|
|
4667
4891
|
}
|
|
4668
4892
|
seen.add(key);
|
|
@@ -4678,6 +4902,7 @@ var ProjectConfigService = class {
|
|
|
4678
4902
|
delete next2.context_window;
|
|
4679
4903
|
delete next2.client_type;
|
|
4680
4904
|
delete next2.vision;
|
|
4905
|
+
delete next2.max_tokens;
|
|
4681
4906
|
delete next2.pricing;
|
|
4682
4907
|
delete next2.display_name;
|
|
4683
4908
|
delete next2.request_model_id;
|
|
@@ -4688,6 +4913,7 @@ var ProjectConfigService = class {
|
|
|
4688
4913
|
if (entry.contextWindow !== void 0) next2.context_window = entry.contextWindow;
|
|
4689
4914
|
if (entry.clientType) next2.client_type = entry.clientType;
|
|
4690
4915
|
if (entry.vision !== void 0) next2.vision = entry.vision;
|
|
4916
|
+
if (entry.maxTokens !== void 0) next2.max_tokens = entry.maxTokens;
|
|
4691
4917
|
if (entry.pricing !== void 0) {
|
|
4692
4918
|
next2.pricing = {
|
|
4693
4919
|
unit: "usd_per_mtok",
|
|
@@ -4712,7 +4938,7 @@ var ProjectConfigService = class {
|
|
|
4712
4938
|
if (req.defaultModel !== void 0) {
|
|
4713
4939
|
if (!seen.has(refKey(req.defaultModel.provider, req.defaultModel.modelId))) {
|
|
4714
4940
|
throw badRequest(
|
|
4715
|
-
`defaultModel
|
|
4941
|
+
`defaultModel must be included in models: ${showRef(req.defaultModel.provider, req.defaultModel.modelId)}.`
|
|
4716
4942
|
);
|
|
4717
4943
|
}
|
|
4718
4944
|
defaultModel = req.defaultModel;
|
|
@@ -4732,12 +4958,12 @@ var ProjectConfigService = class {
|
|
|
4732
4958
|
if (req.visionModel !== void 0) {
|
|
4733
4959
|
if (!seen.has(refKey(req.visionModel.provider, req.visionModel.modelId))) {
|
|
4734
4960
|
throw badRequest(
|
|
4735
|
-
`visionModel
|
|
4961
|
+
`visionModel must be included in models: ${showRef(req.visionModel.provider, req.visionModel.modelId)}.`
|
|
4736
4962
|
);
|
|
4737
4963
|
}
|
|
4738
4964
|
if (targetOf(req.visionModel)?.vision === false) {
|
|
4739
4965
|
throw badRequest(
|
|
4740
|
-
`visionModel
|
|
4966
|
+
`visionModel must not point to a model annotated as not supporting images: ${showRef(req.visionModel.provider, req.visionModel.modelId)}.`
|
|
4741
4967
|
);
|
|
4742
4968
|
}
|
|
4743
4969
|
visionModel = req.visionModel;
|
|
@@ -4767,6 +4993,24 @@ var ProjectConfigService = class {
|
|
|
4767
4993
|
return this.getModels(projectId);
|
|
4768
4994
|
}
|
|
4769
4995
|
};
|
|
4996
|
+
function isProbeContent(msg) {
|
|
4997
|
+
const p = msg.payload;
|
|
4998
|
+
if (p.type === "partial_thinking" || p.type === "thinking") return Boolean(p.thinking);
|
|
4999
|
+
if (p.type === "partial_text" || p.type === "text") return Boolean(p.text);
|
|
5000
|
+
return false;
|
|
5001
|
+
}
|
|
5002
|
+
function probeVerdict(outcome, sawContent) {
|
|
5003
|
+
if (outcome.status === "completed") return { ok: true };
|
|
5004
|
+
if (outcome.status === "malformed" && sawContent) return { ok: true };
|
|
5005
|
+
const detail = "message" in outcome && outcome.message ? outcome.message : outcome.status;
|
|
5006
|
+
return { ok: false, message: String(detail).slice(0, 300) };
|
|
5007
|
+
}
|
|
5008
|
+
var PROBE_TPS_MIN_TOKENS = 16;
|
|
5009
|
+
var PROBE_TPS_MIN_WINDOW_MS = 100;
|
|
5010
|
+
function probeTps(outputTokens, windowMs) {
|
|
5011
|
+
if (outputTokens < PROBE_TPS_MIN_TOKENS || windowMs <= PROBE_TPS_MIN_WINDOW_MS) return void 0;
|
|
5012
|
+
return Math.round(outputTokens / (windowMs / 1e3) * 10) / 10;
|
|
5013
|
+
}
|
|
4770
5014
|
|
|
4771
5015
|
// src/services/agent-config-service.ts
|
|
4772
5016
|
var THINKING_LEVELS = ["none", "low", "medium", "high", "xhigh"];
|
|
@@ -4790,7 +5034,7 @@ var AgentConfigService = class {
|
|
|
4790
5034
|
}
|
|
4791
5035
|
async requireExists(projectId, agentId) {
|
|
4792
5036
|
if (!await this.exists(projectId, agentId)) {
|
|
4793
|
-
throw new HttpError(404, "agent_not_found", "Agent
|
|
5037
|
+
throw new HttpError(404, "agent_not_found", "Agent does not exist.");
|
|
4794
5038
|
}
|
|
4795
5039
|
}
|
|
4796
5040
|
/**
|
|
@@ -4875,8 +5119,8 @@ var AgentConfigService = class {
|
|
|
4875
5119
|
const cfg = config2;
|
|
4876
5120
|
const yamlPath = systemConfigPath(this.root, projectId, agentId);
|
|
4877
5121
|
const doc = parseDocument(await fs10.readFile(yamlPath, "utf8"));
|
|
4878
|
-
const setIfProvided = (
|
|
4879
|
-
if (value !== void 0) doc.setIn(
|
|
5122
|
+
const setIfProvided = (path17, value) => {
|
|
5123
|
+
if (value !== void 0) doc.setIn(path17, value);
|
|
4880
5124
|
};
|
|
4881
5125
|
setIfProvided(["name"], optionalString(cfg, "name", { maxLen: 100, label: "name" }));
|
|
4882
5126
|
setIfProvided(
|
|
@@ -4952,23 +5196,25 @@ var AgentConfigService = class {
|
|
|
4952
5196
|
for (const entry of req.entries) {
|
|
4953
5197
|
if (!isValidVaultKey(entry.key)) {
|
|
4954
5198
|
throw badRequest(
|
|
4955
|
-
`vault
|
|
5199
|
+
`Invalid vault key name: ${entry.key} (letters, digits and underscores only, and must not start with a digit).`
|
|
4956
5200
|
);
|
|
4957
5201
|
}
|
|
4958
5202
|
if (seen.has(entry.key)) {
|
|
4959
|
-
throw badRequest(`entries
|
|
5203
|
+
throw badRequest(`entries contains a duplicate key: ${entry.key}.`);
|
|
4960
5204
|
}
|
|
4961
5205
|
seen.add(entry.key);
|
|
4962
5206
|
const prevValue = prev[entry.key];
|
|
4963
5207
|
if (entry.value !== void 0) {
|
|
4964
5208
|
if (entry.value.length > VAULT_VALUE_MAX_LENGTH) {
|
|
4965
|
-
throw badRequest(
|
|
5209
|
+
throw badRequest(
|
|
5210
|
+
`Vault value too long: ${entry.key} (limit ${VAULT_VALUE_MAX_LENGTH} characters).`
|
|
5211
|
+
);
|
|
4966
5212
|
}
|
|
4967
5213
|
nextVault[entry.key] = entry.value;
|
|
4968
5214
|
} else if (prevValue !== void 0) {
|
|
4969
5215
|
nextVault[entry.key] = prevValue;
|
|
4970
5216
|
} else {
|
|
4971
|
-
throw badRequest(
|
|
5217
|
+
throw badRequest(`New key ${entry.key} must provide a value.`);
|
|
4972
5218
|
}
|
|
4973
5219
|
}
|
|
4974
5220
|
await saveAgentVault(this.root, projectId, agentId, nextVault);
|
|
@@ -4976,20 +5222,20 @@ var AgentConfigService = class {
|
|
|
4976
5222
|
}
|
|
4977
5223
|
};
|
|
4978
5224
|
function validateToolsBuiltin(value) {
|
|
4979
|
-
if (!Array.isArray(value)) throw badRequest("toolsBuiltin
|
|
5225
|
+
if (!Array.isArray(value)) throw badRequest("toolsBuiltin must be an array.");
|
|
4980
5226
|
return value.map((item, i) => {
|
|
4981
5227
|
const t = asRecord(item);
|
|
4982
5228
|
if (typeof t.name !== "string" || t.name.length === 0) {
|
|
4983
|
-
throw badRequest(`toolsBuiltin[${i}].name
|
|
5229
|
+
throw badRequest(`toolsBuiltin[${i}].name must be a non-empty string.`);
|
|
4984
5230
|
}
|
|
4985
5231
|
if (typeof t.description !== "string") {
|
|
4986
|
-
throw badRequest(`toolsBuiltin[${i}].description
|
|
5232
|
+
throw badRequest(`toolsBuiltin[${i}].description must be a string.`);
|
|
4987
5233
|
}
|
|
4988
5234
|
if (t.permission !== void 0 && t.permission !== "r" && t.permission !== "rw") {
|
|
4989
|
-
throw badRequest(`toolsBuiltin[${i}].permission
|
|
5235
|
+
throw badRequest(`toolsBuiltin[${i}].permission must be one of r / rw.`);
|
|
4990
5236
|
}
|
|
4991
5237
|
if (t.forModel !== void 0 && t.forModel !== "vision" && t.forModel !== "text-only") {
|
|
4992
|
-
throw badRequest(`toolsBuiltin[${i}].forModel
|
|
5238
|
+
throw badRequest(`toolsBuiltin[${i}].forModel must be one of vision / text-only.`);
|
|
4993
5239
|
}
|
|
4994
5240
|
optionalNumber(t, "timeoutMs", {
|
|
4995
5241
|
integer: true,
|
|
@@ -5005,14 +5251,14 @@ function validateToolsBuiltin(value) {
|
|
|
5005
5251
|
});
|
|
5006
5252
|
}
|
|
5007
5253
|
function validateMcpServers(value) {
|
|
5008
|
-
if (!Array.isArray(value)) throw badRequest("mcpServers
|
|
5254
|
+
if (!Array.isArray(value)) throw badRequest("mcpServers must be an array.");
|
|
5009
5255
|
return value.map((item, i) => {
|
|
5010
5256
|
const s = asRecord(item);
|
|
5011
5257
|
if (typeof s.name !== "string" || s.name.length === 0) {
|
|
5012
|
-
throw badRequest(`mcpServers[${i}].name
|
|
5258
|
+
throw badRequest(`mcpServers[${i}].name must be a non-empty string.`);
|
|
5013
5259
|
}
|
|
5014
5260
|
if (s.config === null || typeof s.config !== "object" || Array.isArray(s.config)) {
|
|
5015
|
-
throw badRequest(`mcpServers[${i}].config
|
|
5261
|
+
throw badRequest(`mcpServers[${i}].config must be an object.`);
|
|
5016
5262
|
}
|
|
5017
5263
|
return s;
|
|
5018
5264
|
});
|
|
@@ -5020,6 +5266,7 @@ function validateMcpServers(value) {
|
|
|
5020
5266
|
|
|
5021
5267
|
// src/services/agent-service.ts
|
|
5022
5268
|
import fs11 from "fs/promises";
|
|
5269
|
+
import path10 from "path";
|
|
5023
5270
|
import {
|
|
5024
5271
|
agentDir,
|
|
5025
5272
|
agentsDir as agentsDir2,
|
|
@@ -5029,6 +5276,7 @@ import {
|
|
|
5029
5276
|
isValidId as isValidId3,
|
|
5030
5277
|
loadAgentVault as loadAgentVault2,
|
|
5031
5278
|
scheduleDir as scheduleDir2,
|
|
5279
|
+
skillsDir as skillsDir2,
|
|
5032
5280
|
systemConfigPath as systemConfigPath2
|
|
5033
5281
|
} from "@prismshadow/penguin-core";
|
|
5034
5282
|
var AgentService = class {
|
|
@@ -5068,11 +5316,12 @@ var AgentService = class {
|
|
|
5068
5316
|
);
|
|
5069
5317
|
return Promise.all(
|
|
5070
5318
|
sorted.map(async (row) => {
|
|
5071
|
-
const [meta, updatedAt, vaultKeyCount, scheduleCount] = await Promise.all([
|
|
5319
|
+
const [meta, updatedAt, vaultKeyCount, scheduleCount, skillCount] = await Promise.all([
|
|
5072
5320
|
this.agentConfig.readCardMeta(projectId, row.agentId),
|
|
5073
5321
|
this.configUpdatedAt(projectId, row.agentId),
|
|
5074
5322
|
this.vaultKeyCount(projectId, row.agentId),
|
|
5075
|
-
this.scheduleCount(projectId, row.agentId)
|
|
5323
|
+
this.scheduleCount(projectId, row.agentId),
|
|
5324
|
+
this.skillCount(projectId, row.agentId)
|
|
5076
5325
|
]);
|
|
5077
5326
|
return {
|
|
5078
5327
|
agentId: row.agentId,
|
|
@@ -5080,7 +5329,8 @@ var AgentService = class {
|
|
|
5080
5329
|
createdAt: row.createdAt,
|
|
5081
5330
|
...updatedAt !== void 0 ? { updatedAt } : {},
|
|
5082
5331
|
vaultKeyCount,
|
|
5083
|
-
scheduleCount
|
|
5332
|
+
scheduleCount,
|
|
5333
|
+
skillCount
|
|
5084
5334
|
};
|
|
5085
5335
|
})
|
|
5086
5336
|
);
|
|
@@ -5102,6 +5352,27 @@ var AgentService = class {
|
|
|
5102
5352
|
return 0;
|
|
5103
5353
|
}
|
|
5104
5354
|
}
|
|
5355
|
+
/** Number of installed Skills: count of skills/<name>/ directories containing a SKILL.md (0 if the directory doesn't exist). */
|
|
5356
|
+
async skillCount(projectId, agentId) {
|
|
5357
|
+
const base = skillsDir2(this.root, projectId, agentId);
|
|
5358
|
+
let dirents;
|
|
5359
|
+
try {
|
|
5360
|
+
dirents = await fs11.readdir(base, { withFileTypes: true });
|
|
5361
|
+
} catch {
|
|
5362
|
+
return 0;
|
|
5363
|
+
}
|
|
5364
|
+
const present = await Promise.all(
|
|
5365
|
+
dirents.filter((d) => d.isDirectory()).map(async (d) => {
|
|
5366
|
+
try {
|
|
5367
|
+
await fs11.access(path10.join(base, d.name, "SKILL.md"));
|
|
5368
|
+
return true;
|
|
5369
|
+
} catch {
|
|
5370
|
+
return false;
|
|
5371
|
+
}
|
|
5372
|
+
})
|
|
5373
|
+
);
|
|
5374
|
+
return present.filter(Boolean).length;
|
|
5375
|
+
}
|
|
5105
5376
|
/** Last config modification time: the later of system_config.yaml and AGENTS.md mtime; omitted if neither is readable. */
|
|
5106
5377
|
async configUpdatedAt(projectId, agentId) {
|
|
5107
5378
|
const paths = [
|
|
@@ -5133,7 +5404,7 @@ var AgentService = class {
|
|
|
5133
5404
|
throw new HttpError(
|
|
5134
5405
|
409,
|
|
5135
5406
|
"cannot_delete_builtin_agent",
|
|
5136
|
-
"
|
|
5407
|
+
"Built-in Agents (default_agent) are provisioned with the Project and cannot be deleted from the web."
|
|
5137
5408
|
);
|
|
5138
5409
|
}
|
|
5139
5410
|
await fs11.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
|
|
@@ -5147,14 +5418,18 @@ var AgentService = class {
|
|
|
5147
5418
|
*/
|
|
5148
5419
|
async createAgent(projectId, agentId, name, description) {
|
|
5149
5420
|
if (!SEMANTIC_ID_PATTERN.test(agentId)) {
|
|
5150
|
-
throw new HttpError(
|
|
5421
|
+
throw new HttpError(
|
|
5422
|
+
400,
|
|
5423
|
+
"invalid_agent_id",
|
|
5424
|
+
`Agent id must be 2\u201364 characters: ${SEMANTIC_ID_RULE}.`
|
|
5425
|
+
);
|
|
5151
5426
|
}
|
|
5152
5427
|
const taken = this.agents.exists(projectId, agentId) || await fs11.stat(agentDir(this.root, projectId, agentId)).then(
|
|
5153
5428
|
() => true,
|
|
5154
5429
|
() => false
|
|
5155
5430
|
);
|
|
5156
5431
|
if (taken) {
|
|
5157
|
-
throw new HttpError(409, "agent_exists", `Agent id
|
|
5432
|
+
throw new HttpError(409, "agent_exists", `Agent id is already taken: ${agentId}.`);
|
|
5158
5433
|
}
|
|
5159
5434
|
const displayName = name ?? agentId;
|
|
5160
5435
|
await coreCreateAgent({ root: this.root, projectId, agentId });
|
|
@@ -5179,14 +5454,16 @@ var AgentService = class {
|
|
|
5179
5454
|
toolCount: meta.toolCount,
|
|
5180
5455
|
version: meta.version,
|
|
5181
5456
|
vaultKeyCount: 0,
|
|
5182
|
-
scheduleCount: 0
|
|
5457
|
+
scheduleCount: 0,
|
|
5458
|
+
// Read the real count: coreCreateAgent seeds the default skill set for default_agent.
|
|
5459
|
+
skillCount: await this.skillCount(projectId, agentId)
|
|
5183
5460
|
};
|
|
5184
5461
|
}
|
|
5185
5462
|
};
|
|
5186
5463
|
|
|
5187
5464
|
// src/services/benchmark-service.ts
|
|
5188
5465
|
import fs12 from "fs/promises";
|
|
5189
|
-
import
|
|
5466
|
+
import path11 from "path";
|
|
5190
5467
|
import { parse as parseToml3 } from "smol-toml";
|
|
5191
5468
|
import { parse as parseYaml2 } from "yaml";
|
|
5192
5469
|
import { benchmarksDir } from "@prismshadow/penguin-core";
|
|
@@ -5288,7 +5565,7 @@ var BenchmarkService = class {
|
|
|
5288
5565
|
}
|
|
5289
5566
|
const benchmarks = [];
|
|
5290
5567
|
for (const item of items.filter((i) => i.isDir).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
5291
|
-
benchmarks.push(await this.readBenchmark(
|
|
5568
|
+
benchmarks.push(await this.readBenchmark(path11.join(dir, item.name), item.name));
|
|
5292
5569
|
}
|
|
5293
5570
|
return { benchmarks };
|
|
5294
5571
|
}
|
|
@@ -5298,7 +5575,7 @@ var BenchmarkService = class {
|
|
|
5298
5575
|
let runs;
|
|
5299
5576
|
try {
|
|
5300
5577
|
const config2 = asRecord2(
|
|
5301
|
-
parseToml3(await fs12.readFile(
|
|
5578
|
+
parseToml3(await fs12.readFile(path11.join(benchDir, "benchmark_config.toml"), "utf8"))
|
|
5302
5579
|
);
|
|
5303
5580
|
if (typeof config2.title === "string" && config2.title !== "") title = config2.title;
|
|
5304
5581
|
if (typeof config2.description === "string" && config2.description !== "") {
|
|
@@ -5313,7 +5590,7 @@ var BenchmarkService = class {
|
|
|
5313
5590
|
let evaluations = [];
|
|
5314
5591
|
try {
|
|
5315
5592
|
const scoreboard = asRecord2(
|
|
5316
|
-
parseYaml2(await fs12.readFile(
|
|
5593
|
+
parseYaml2(await fs12.readFile(path11.join(benchDir, "scoreboard.yaml"), "utf8"))
|
|
5317
5594
|
);
|
|
5318
5595
|
if (Array.isArray(scoreboard.evaluations)) {
|
|
5319
5596
|
evaluations = scoreboard.evaluations.map(toEvaluation).filter((e) => e !== null);
|
|
@@ -5340,7 +5617,7 @@ var BenchmarkService = class {
|
|
|
5340
5617
|
// src/services/snapshot-service.ts
|
|
5341
5618
|
import { randomBytes as randomBytes4 } from "crypto";
|
|
5342
5619
|
import fs13 from "fs/promises";
|
|
5343
|
-
import
|
|
5620
|
+
import path12 from "path";
|
|
5344
5621
|
import * as tar from "tar";
|
|
5345
5622
|
import { parse as parseYaml3 } from "yaml";
|
|
5346
5623
|
import {
|
|
@@ -5353,7 +5630,7 @@ import {
|
|
|
5353
5630
|
} from "@prismshadow/penguin-core";
|
|
5354
5631
|
var VAULT_BASENAME = ".vault.toml";
|
|
5355
5632
|
function isVaultEntry(entryPath) {
|
|
5356
|
-
return
|
|
5633
|
+
return path12.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
|
|
5357
5634
|
}
|
|
5358
5635
|
var SnapshotService = class {
|
|
5359
5636
|
constructor(root) {
|
|
@@ -5366,7 +5643,7 @@ var SnapshotService = class {
|
|
|
5366
5643
|
try {
|
|
5367
5644
|
raw = await fs13.readFile(systemConfigPath3(this.root, projectId, agentId), "utf8");
|
|
5368
5645
|
} catch {
|
|
5369
|
-
throw new HttpError(404, "agent_not_found", "Agent
|
|
5646
|
+
throw new HttpError(404, "agent_not_found", "Agent does not exist.");
|
|
5370
5647
|
}
|
|
5371
5648
|
const parsed = parseYaml3(raw);
|
|
5372
5649
|
return agentStateVersion2({
|
|
@@ -5377,7 +5654,7 @@ var SnapshotService = class {
|
|
|
5377
5654
|
async ensureSnapshot(projectId, agentId) {
|
|
5378
5655
|
const version = await this.currentVersion(projectId, agentId);
|
|
5379
5656
|
const dir = snapshotsDir(this.root, projectId, agentId);
|
|
5380
|
-
const file =
|
|
5657
|
+
const file = path12.join(dir, `v${version}.tar.gz`);
|
|
5381
5658
|
try {
|
|
5382
5659
|
await fs13.access(file);
|
|
5383
5660
|
return { version, file };
|
|
@@ -5412,12 +5689,12 @@ var SnapshotService = class {
|
|
|
5412
5689
|
async importArchive(projectId, agentId, archive, confirm) {
|
|
5413
5690
|
const current = await this.currentVersion(projectId, agentId);
|
|
5414
5691
|
const base = agentDir2(this.root, projectId, agentId);
|
|
5415
|
-
const staging =
|
|
5692
|
+
const staging = path12.join(base, `.import-${randomBytes4(6).toString("hex")}`);
|
|
5416
5693
|
await fs13.mkdir(staging, { recursive: true });
|
|
5417
5694
|
try {
|
|
5418
|
-
const archiveFile =
|
|
5695
|
+
const archiveFile = path12.join(staging, "archive.tar.gz");
|
|
5419
5696
|
await fs13.writeFile(archiveFile, archive);
|
|
5420
|
-
const extractDir =
|
|
5697
|
+
const extractDir = path12.join(staging, "extracted");
|
|
5421
5698
|
await fs13.mkdir(extractDir, { recursive: true });
|
|
5422
5699
|
try {
|
|
5423
5700
|
await tar.extract({
|
|
@@ -5426,21 +5703,21 @@ var SnapshotService = class {
|
|
|
5426
5703
|
filter: (p) => !isVaultEntry(p)
|
|
5427
5704
|
});
|
|
5428
5705
|
} catch {
|
|
5429
|
-
throw badRequest("
|
|
5706
|
+
throw badRequest("Import failed: not a valid tar.gz snapshot package.");
|
|
5430
5707
|
}
|
|
5431
|
-
const configPath =
|
|
5708
|
+
const configPath = path12.join(extractDir, "agent_state", "system_config.yaml");
|
|
5432
5709
|
let parsed;
|
|
5433
5710
|
try {
|
|
5434
5711
|
parsed = parseYaml3(await fs13.readFile(configPath, "utf8"));
|
|
5435
5712
|
} catch {
|
|
5436
|
-
throw badRequest("
|
|
5713
|
+
throw badRequest("Import failed: the package is missing agent_state/system_config.yaml.");
|
|
5437
5714
|
}
|
|
5438
5715
|
if (parsed === null || typeof parsed !== "object" || typeof parsed.system_prompt !== "string") {
|
|
5439
|
-
throw badRequest("
|
|
5716
|
+
throw badRequest("Import failed: the package's system_config.yaml is invalid.");
|
|
5440
5717
|
}
|
|
5441
5718
|
const incomingRaw = parsed.version;
|
|
5442
5719
|
if (incomingRaw !== void 0 && (!Number.isInteger(incomingRaw) || incomingRaw < 1)) {
|
|
5443
|
-
throw badRequest("
|
|
5720
|
+
throw badRequest("Import failed: the package's version is invalid.");
|
|
5444
5721
|
}
|
|
5445
5722
|
const incoming = agentStateVersion2({
|
|
5446
5723
|
version: typeof incomingRaw === "number" ? incomingRaw : void 0
|
|
@@ -5449,21 +5726,21 @@ var SnapshotService = class {
|
|
|
5449
5726
|
throw new HttpError(
|
|
5450
5727
|
409,
|
|
5451
5728
|
"version_conflict",
|
|
5452
|
-
|
|
5729
|
+
`Package version v${incoming} is not higher than the current v${current}; confirmation is required to import.`
|
|
5453
5730
|
);
|
|
5454
5731
|
}
|
|
5455
5732
|
await this.ensureSnapshot(projectId, agentId);
|
|
5456
5733
|
const stateDir = agentStateDir2(this.root, projectId, agentId);
|
|
5457
|
-
const incomingState =
|
|
5734
|
+
const incomingState = path12.join(extractDir, "agent_state");
|
|
5458
5735
|
let vault = null;
|
|
5459
5736
|
try {
|
|
5460
5737
|
vault = await fs13.readFile(agentVaultPath(this.root, projectId, agentId));
|
|
5461
5738
|
} catch {
|
|
5462
5739
|
}
|
|
5463
5740
|
if (vault !== null) {
|
|
5464
|
-
await fs13.writeFile(
|
|
5741
|
+
await fs13.writeFile(path12.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
|
|
5465
5742
|
}
|
|
5466
|
-
const trash =
|
|
5743
|
+
const trash = path12.join(staging, "replaced-agent_state");
|
|
5467
5744
|
await fs13.rename(stateDir, trash);
|
|
5468
5745
|
try {
|
|
5469
5746
|
await fs13.rename(incomingState, stateDir);
|
|
@@ -5482,9 +5759,9 @@ var SnapshotService = class {
|
|
|
5482
5759
|
import fs14 from "fs/promises";
|
|
5483
5760
|
import { DEFAULT_PROJECT_ID, projectDir, provisionProjectAgents } from "@prismshadow/penguin-core";
|
|
5484
5761
|
var ABORT_SETTLE_TIMEOUT_MS = 5e3;
|
|
5485
|
-
async function dirExists(
|
|
5762
|
+
async function dirExists(path17) {
|
|
5486
5763
|
try {
|
|
5487
|
-
const stat = await fs14.stat(
|
|
5764
|
+
const stat = await fs14.stat(path17);
|
|
5488
5765
|
return stat.isDirectory();
|
|
5489
5766
|
} catch {
|
|
5490
5767
|
return false;
|
|
@@ -5514,7 +5791,11 @@ var ProjectService = class {
|
|
|
5514
5791
|
requireProjectAccess(userId, projectId) {
|
|
5515
5792
|
const row = this.resolveAccess(userId, projectId);
|
|
5516
5793
|
if (!row) {
|
|
5517
|
-
throw new HttpError(
|
|
5794
|
+
throw new HttpError(
|
|
5795
|
+
404,
|
|
5796
|
+
"project_not_found",
|
|
5797
|
+
"Project does not exist or you do not have access."
|
|
5798
|
+
);
|
|
5518
5799
|
}
|
|
5519
5800
|
return row;
|
|
5520
5801
|
}
|
|
@@ -5531,7 +5812,11 @@ var ProjectService = class {
|
|
|
5531
5812
|
requireProjectOwner(userId, projectId) {
|
|
5532
5813
|
const row = this.requireProjectAccess(userId, projectId);
|
|
5533
5814
|
if (row.role !== "owner") {
|
|
5534
|
-
throw new HttpError(
|
|
5815
|
+
throw new HttpError(
|
|
5816
|
+
403,
|
|
5817
|
+
"owner_required",
|
|
5818
|
+
"Only the Project owner can perform this operation."
|
|
5819
|
+
);
|
|
5535
5820
|
}
|
|
5536
5821
|
return row;
|
|
5537
5822
|
}
|
|
@@ -5573,7 +5858,7 @@ var ProjectService = class {
|
|
|
5573
5858
|
throw new HttpError(
|
|
5574
5859
|
400,
|
|
5575
5860
|
"invalid_project_id",
|
|
5576
|
-
`Project id
|
|
5861
|
+
`Project id must be 2\u201364 characters: ${SEMANTIC_ID_RULE} (the hyphen is reserved as the user namespace separator).`
|
|
5577
5862
|
);
|
|
5578
5863
|
}
|
|
5579
5864
|
} else {
|
|
@@ -5583,12 +5868,12 @@ var ProjectService = class {
|
|
|
5583
5868
|
throw new HttpError(
|
|
5584
5869
|
400,
|
|
5585
5870
|
"project_id_prefix_required",
|
|
5586
|
-
`Project id
|
|
5871
|
+
`Project id must start with ${prefix}, followed by lowercase letters, digits or underscores (at most ${PROJECT_ID_MAX_LENGTH} in total).`
|
|
5587
5872
|
);
|
|
5588
5873
|
}
|
|
5589
5874
|
}
|
|
5590
5875
|
if (this.deps.projects.findById(projectId) !== null || await dirExists(projectDir(this.deps.root, projectId))) {
|
|
5591
|
-
throw new HttpError(409, "project_exists", `Project id
|
|
5876
|
+
throw new HttpError(409, "project_exists", `Project id is already taken: ${projectId}.`);
|
|
5592
5877
|
}
|
|
5593
5878
|
const displayName = name ?? projectId;
|
|
5594
5879
|
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -5596,7 +5881,7 @@ var ProjectService = class {
|
|
|
5596
5881
|
this.deps.projects.insert({ projectId, ownerUserId: owner.userId, createdAt });
|
|
5597
5882
|
} catch (err) {
|
|
5598
5883
|
if (err instanceof Error && err.message.includes("UNIQUE")) {
|
|
5599
|
-
throw new HttpError(409, "project_exists", `Project id
|
|
5884
|
+
throw new HttpError(409, "project_exists", `Project id is already taken: ${projectId}.`);
|
|
5600
5885
|
}
|
|
5601
5886
|
throw err;
|
|
5602
5887
|
}
|
|
@@ -5653,14 +5938,14 @@ var ProjectService = class {
|
|
|
5653
5938
|
throw new HttpError(
|
|
5654
5939
|
409,
|
|
5655
5940
|
"cannot_delete_default_project",
|
|
5656
|
-
"default_project
|
|
5941
|
+
"default_project is shared with the CLI and cannot be deleted from the web."
|
|
5657
5942
|
);
|
|
5658
5943
|
}
|
|
5659
5944
|
if (this.deps.projects.listAccessible(userId).length <= 1) {
|
|
5660
5945
|
throw new HttpError(
|
|
5661
5946
|
409,
|
|
5662
5947
|
"cannot_delete_last_project",
|
|
5663
|
-
"
|
|
5948
|
+
"This is the account's last Project; deleting it would leave no Project available. Create a new Project first."
|
|
5664
5949
|
);
|
|
5665
5950
|
}
|
|
5666
5951
|
await this.destroyProject(projectId);
|
|
@@ -5707,13 +5992,21 @@ var ProjectService = class {
|
|
|
5707
5992
|
const project = this.requireProjectOwner(userId, projectId);
|
|
5708
5993
|
const target = this.deps.users.findById(targetUserId);
|
|
5709
5994
|
if (!target) {
|
|
5710
|
-
throw new HttpError(404, "user_not_found",
|
|
5995
|
+
throw new HttpError(404, "user_not_found", `User does not exist: ${targetUserId}.`);
|
|
5711
5996
|
}
|
|
5712
5997
|
if (target.userId === project.ownerUserId) {
|
|
5713
|
-
throw new HttpError(
|
|
5998
|
+
throw new HttpError(
|
|
5999
|
+
409,
|
|
6000
|
+
"already_owner",
|
|
6001
|
+
"The owner does not need to grant access to themselves."
|
|
6002
|
+
);
|
|
5714
6003
|
}
|
|
5715
6004
|
if (this.deps.members.isMember(projectId, target.userId)) {
|
|
5716
|
-
throw new HttpError(
|
|
6005
|
+
throw new HttpError(
|
|
6006
|
+
409,
|
|
6007
|
+
"already_member",
|
|
6008
|
+
`${targetUserId} is already a member of this Project.`
|
|
6009
|
+
);
|
|
5717
6010
|
}
|
|
5718
6011
|
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5719
6012
|
this.deps.members.insert({ projectId, userId: target.userId, createdAt });
|
|
@@ -5723,7 +6016,7 @@ var ProjectService = class {
|
|
|
5723
6016
|
removeMember(userId, projectId, targetUserId) {
|
|
5724
6017
|
this.requireProjectOwner(userId, projectId);
|
|
5725
6018
|
if (!this.deps.members.isMember(projectId, targetUserId)) {
|
|
5726
|
-
throw new HttpError(404, "member_not_found",
|
|
6019
|
+
throw new HttpError(404, "member_not_found", `This Project has no member: ${targetUserId}.`);
|
|
5727
6020
|
}
|
|
5728
6021
|
this.deps.members.delete(projectId, targetUserId);
|
|
5729
6022
|
}
|
|
@@ -5749,7 +6042,7 @@ var ProjectService = class {
|
|
|
5749
6042
|
};
|
|
5750
6043
|
|
|
5751
6044
|
// src/services/session-service.ts
|
|
5752
|
-
import
|
|
6045
|
+
import path13 from "path";
|
|
5753
6046
|
import { readdir as readdir2 } from "fs/promises";
|
|
5754
6047
|
import {
|
|
5755
6048
|
createAgent as createAgent2,
|
|
@@ -5771,8 +6064,13 @@ var SessionService = class {
|
|
|
5771
6064
|
this.deps = deps2;
|
|
5772
6065
|
}
|
|
5773
6066
|
deps;
|
|
5774
|
-
/**
|
|
5775
|
-
|
|
6067
|
+
/**
|
|
6068
|
+
* DB row -> SessionInfo (run status and pending approval count come from session-manager).
|
|
6069
|
+
* Async because `source` is derived from session_meta: a registry miss (Session predating
|
|
6070
|
+
* this process) falls back to reading the Trace head once (see sourceOf).
|
|
6071
|
+
*/
|
|
6072
|
+
async toInfo(row, hasTrace) {
|
|
6073
|
+
const source = await this.sourceOf(row, hasTrace);
|
|
5776
6074
|
return {
|
|
5777
6075
|
sessionId: row.sessionId,
|
|
5778
6076
|
projectId: row.projectId,
|
|
@@ -5782,7 +6080,7 @@ var SessionService = class {
|
|
|
5782
6080
|
workspace: row.workspace,
|
|
5783
6081
|
approvalMode: row.approvalMode,
|
|
5784
6082
|
...row.title !== null ? { title: row.title } : {},
|
|
5785
|
-
...
|
|
6083
|
+
...source !== void 0 ? { source } : {},
|
|
5786
6084
|
createdAt: row.createdAt,
|
|
5787
6085
|
status: this.deps.manager.statusOf(row.sessionId),
|
|
5788
6086
|
pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
|
|
@@ -5790,13 +6088,36 @@ var SessionService = class {
|
|
|
5790
6088
|
archived: (row.archivedAt ?? null) !== null
|
|
5791
6089
|
};
|
|
5792
6090
|
}
|
|
6091
|
+
/**
|
|
6092
|
+
* A Session's origin, with session_meta as the single source of truth: the in-process
|
|
6093
|
+
* registry answers first (populated at creation / subagent registration / adoption);
|
|
6094
|
+
* on a miss (a Session created before this process started) the earliest Trace shard's
|
|
6095
|
+
* session_meta is read once and cached. A Session with no Trace yet stays unknown and
|
|
6096
|
+
* is NOT cached negatively — its meta may appear with the first run.
|
|
6097
|
+
*/
|
|
6098
|
+
async sourceOf(row, hasTrace) {
|
|
6099
|
+
const known = this.deps.sources.get(row.sessionId);
|
|
6100
|
+
if (known !== void 0) return known ?? void 0;
|
|
6101
|
+
if (!hasTrace) return void 0;
|
|
6102
|
+
const meta = await this.readTraceMeta(row.projectId, row.agentId, row.sessionId);
|
|
6103
|
+
if (!meta) return void 0;
|
|
6104
|
+
const source = asSessionSource(meta.payload.source) ?? null;
|
|
6105
|
+
this.deps.sources.set(row.sessionId, source);
|
|
6106
|
+
return source ?? void 0;
|
|
6107
|
+
}
|
|
5793
6108
|
/** Whether this Session already has a Trace record (a Task has been run). */
|
|
5794
6109
|
async hasTrace(row) {
|
|
5795
6110
|
const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId);
|
|
5796
6111
|
return ids.has(row.sessionId);
|
|
5797
6112
|
}
|
|
5798
|
-
/**
|
|
5799
|
-
|
|
6113
|
+
/**
|
|
6114
|
+
* List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional
|
|
6115
|
+
* `paging` returns just that slice (the sidebar pages with limit+1 to detect "has
|
|
6116
|
+
* more"); slicing happens before toInfo, so per-request source derivation (lazy
|
|
6117
|
+
* Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
|
|
6118
|
+
* the whole directory — the union and global ordering need every id.
|
|
6119
|
+
*/
|
|
6120
|
+
async listSessions(projectId, agentId, paging) {
|
|
5800
6121
|
const traceIds = await this.discoverTraceSessionIds(projectId, agentId);
|
|
5801
6122
|
const rows = new Map(
|
|
5802
6123
|
this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r])
|
|
@@ -5806,9 +6127,11 @@ var SessionService = class {
|
|
|
5806
6127
|
const discovered = await this.adoptTraceSession(projectId, agentId, sessionId);
|
|
5807
6128
|
if (discovered) rows.set(sessionId, discovered);
|
|
5808
6129
|
}
|
|
5809
|
-
|
|
6130
|
+
const sorted = [...rows.values()].sort(
|
|
5810
6131
|
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId)
|
|
5811
|
-
)
|
|
6132
|
+
);
|
|
6133
|
+
const page = paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted;
|
|
6134
|
+
return Promise.all(page.map((row) => this.toInfo(row, traceIds.has(row.sessionId))));
|
|
5812
6135
|
}
|
|
5813
6136
|
/**
|
|
5814
6137
|
* Session stats (Agents list card): total count = size of the union of DB index
|
|
@@ -5828,7 +6151,7 @@ var SessionService = class {
|
|
|
5828
6151
|
};
|
|
5829
6152
|
const dir = tracesDir2(this.deps.root, projectId, agentId);
|
|
5830
6153
|
for (const dateDir of await listDirsSafe(dir)) {
|
|
5831
|
-
for (const file of await listFilesSafe(
|
|
6154
|
+
for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
|
|
5832
6155
|
const match = TRACE_FILE_RE.exec(file);
|
|
5833
6156
|
if (match) mark(dateDir, match[1]);
|
|
5834
6157
|
}
|
|
@@ -5847,23 +6170,32 @@ var SessionService = class {
|
|
|
5847
6170
|
return { sessionCount: all.size, activity };
|
|
5848
6171
|
}
|
|
5849
6172
|
/**
|
|
5850
|
-
* Create a Session: model reference `(provider, modelId)`
|
|
5851
|
-
*
|
|
5852
|
-
*
|
|
5853
|
-
*
|
|
6173
|
+
* Create a Session: the model reference is a complete `(provider, modelId)` pair.
|
|
6174
|
+
* Half a reference is a client error, never something to resolve — the missing half
|
|
6175
|
+
* is never guessed, since a guessed provider would send an entry's credential to a
|
|
6176
|
+
* vendor nobody named. Omitting both falls back to the Project's default reference
|
|
6177
|
+
* (400 prompting to configure a model first if there is none). `workspace` is already
|
|
5854
6178
|
* validated by the route guard. The new Session is added to the active table
|
|
5855
6179
|
* (idle).
|
|
5856
6180
|
*/
|
|
5857
6181
|
async createSession(args) {
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
6182
|
+
if (args.modelId === void 0 !== (args.provider === void 0)) {
|
|
6183
|
+
throw badRequest(
|
|
6184
|
+
"modelId and provider must be given together as a (provider, modelId) pair: specify both, or neither to use the Project's default model."
|
|
6185
|
+
);
|
|
6186
|
+
}
|
|
6187
|
+
let modelId;
|
|
6188
|
+
let provider;
|
|
6189
|
+
if (args.modelId !== void 0 && args.provider !== void 0) {
|
|
6190
|
+
modelId = args.modelId;
|
|
6191
|
+
provider = args.provider;
|
|
6192
|
+
} else {
|
|
5861
6193
|
const def = await this.deps.projectConfig.getDefaultModelRef(args.projectId);
|
|
5862
6194
|
if (def === void 0) {
|
|
5863
6195
|
throw new HttpError(
|
|
5864
6196
|
400,
|
|
5865
6197
|
"no_default_model",
|
|
5866
|
-
"
|
|
6198
|
+
"This Project has no default model yet. Add a model on the Models page and set it as the default first."
|
|
5867
6199
|
);
|
|
5868
6200
|
}
|
|
5869
6201
|
modelId = def.model_id;
|
|
@@ -5878,8 +6210,10 @@ var SessionService = class {
|
|
|
5878
6210
|
try {
|
|
5879
6211
|
session = await agent.createSession({
|
|
5880
6212
|
modelId,
|
|
5881
|
-
|
|
5882
|
-
...args.workspace !== void 0 ? { workspaceDir: args.workspace } : {}
|
|
6213
|
+
provider,
|
|
6214
|
+
...args.workspace !== void 0 ? { workspaceDir: args.workspace } : {},
|
|
6215
|
+
// The origin is also recorded in core session_meta (Trace), not just the index row.
|
|
6216
|
+
...args.source !== void 0 ? { source: args.source } : {}
|
|
5883
6217
|
});
|
|
5884
6218
|
} catch (err) {
|
|
5885
6219
|
if (isMissingCredential(err)) throw modelCredentialMissing(modelId);
|
|
@@ -5889,6 +6223,11 @@ var SessionService = class {
|
|
|
5889
6223
|
err instanceof Error ? err.message : String(err)
|
|
5890
6224
|
);
|
|
5891
6225
|
}
|
|
6226
|
+
const metaMsg = session.metaMessage;
|
|
6227
|
+
this.deps.sources.set(
|
|
6228
|
+
session.sessionId,
|
|
6229
|
+
isSessionMeta4(metaMsg) ? asSessionSource(metaMsg.payload.source) ?? null : null
|
|
6230
|
+
);
|
|
5892
6231
|
const row = {
|
|
5893
6232
|
sessionId: session.sessionId,
|
|
5894
6233
|
projectId: args.projectId,
|
|
@@ -5898,8 +6237,7 @@ var SessionService = class {
|
|
|
5898
6237
|
workspace: session.workspaceDir,
|
|
5899
6238
|
approvalMode: args.approvalMode ?? "allow-all",
|
|
5900
6239
|
title: null,
|
|
5901
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5902
|
-
...args.source !== void 0 ? { source: args.source } : {}
|
|
6240
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5903
6241
|
};
|
|
5904
6242
|
this.deps.sessions.insert(row);
|
|
5905
6243
|
this.deps.manager.adopt(row, session);
|
|
@@ -5910,24 +6248,28 @@ var SessionService = class {
|
|
|
5910
6248
|
const dir = tracesDir2(this.deps.root, projectId, agentId);
|
|
5911
6249
|
const ids = /* @__PURE__ */ new Set();
|
|
5912
6250
|
for (const dateDir of await listDirsSafe(dir)) {
|
|
5913
|
-
for (const file of await listFilesSafe(
|
|
6251
|
+
for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
|
|
5914
6252
|
const match = TRACE_FILE_RE.exec(file);
|
|
5915
6253
|
if (match) ids.add(match[1]);
|
|
5916
6254
|
}
|
|
5917
6255
|
}
|
|
5918
6256
|
return ids;
|
|
5919
6257
|
}
|
|
5920
|
-
/**
|
|
5921
|
-
|
|
6258
|
+
/**
|
|
6259
|
+
* session_meta from the earliest Trace shard of a Session (the shard whose head carries
|
|
6260
|
+
* the original meta); null when there is no readable Trace or it has no meta. Shared by
|
|
6261
|
+
* adoption backfill and lazy `source` resolution.
|
|
6262
|
+
*/
|
|
6263
|
+
async readTraceMeta(projectId, agentId, sessionId) {
|
|
5922
6264
|
const dir = tracesDir2(this.deps.root, projectId, agentId);
|
|
5923
6265
|
let earliest = null;
|
|
5924
6266
|
for (const dateDir of await listDirsSafe(dir)) {
|
|
5925
|
-
for (const file of await listFilesSafe(
|
|
6267
|
+
for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
|
|
5926
6268
|
const match = TRACE_FILE_RE.exec(file);
|
|
5927
6269
|
if (!match || match[1] !== sessionId) continue;
|
|
5928
6270
|
const index = Number(match[2]);
|
|
5929
6271
|
if (!earliest || index < earliest.index) {
|
|
5930
|
-
earliest = { path:
|
|
6272
|
+
earliest = { path: path13.join(dir, dateDir, file), index };
|
|
5931
6273
|
}
|
|
5932
6274
|
}
|
|
5933
6275
|
}
|
|
@@ -5938,9 +6280,14 @@ var SessionService = class {
|
|
|
5938
6280
|
} catch {
|
|
5939
6281
|
return null;
|
|
5940
6282
|
}
|
|
5941
|
-
|
|
6283
|
+
return messages.find(isSessionMeta4) ?? null;
|
|
6284
|
+
}
|
|
6285
|
+
/** Adopts a Session that exists only in the Trace directory: reads session_meta from the first line of the earliest index file. */
|
|
6286
|
+
async adoptTraceSession(projectId, agentId, sessionId) {
|
|
6287
|
+
const meta = await this.readTraceMeta(projectId, agentId, sessionId);
|
|
5942
6288
|
if (!meta) return null;
|
|
5943
6289
|
if (typeof meta.payload.provider !== "string") return null;
|
|
6290
|
+
this.deps.sources.set(sessionId, asSessionSource(meta.payload.source) ?? null);
|
|
5944
6291
|
const row = {
|
|
5945
6292
|
sessionId,
|
|
5946
6293
|
projectId,
|
|
@@ -5980,7 +6327,7 @@ async function listFilesSafe(dir) {
|
|
|
5980
6327
|
|
|
5981
6328
|
// src/services/trace-service.ts
|
|
5982
6329
|
import fs15 from "fs/promises";
|
|
5983
|
-
import
|
|
6330
|
+
import path14 from "path";
|
|
5984
6331
|
import { agentsDir as agentsDir3, readTraceTolerant as readTraceTolerant2, tracesDir as tracesDir3 } from "@prismshadow/penguin-core";
|
|
5985
6332
|
var TRACE_FILE_RE2 = /^(.+)_(\d{3})\.jsonl$/;
|
|
5986
6333
|
var MAX_SUBAGENT_DEPTH = 4;
|
|
@@ -6018,10 +6365,10 @@ var TraceService = class {
|
|
|
6018
6365
|
const dir = tracesDir3(this.root, projectId, agentId);
|
|
6019
6366
|
const out = [];
|
|
6020
6367
|
for (const dateDir of await listDirs(dir)) {
|
|
6021
|
-
for (const file of await listFiles(
|
|
6368
|
+
for (const file of await listFiles(path14.join(dir, dateDir))) {
|
|
6022
6369
|
const match = TRACE_FILE_RE2.exec(file);
|
|
6023
6370
|
if (!match || match[1] !== sessionId) continue;
|
|
6024
|
-
out.push({ path:
|
|
6371
|
+
out.push({ path: path14.join(dir, dateDir, file), date: dateDir, index: Number(match[2]) });
|
|
6025
6372
|
}
|
|
6026
6373
|
}
|
|
6027
6374
|
return out.sort((a, b) => a.index - b.index);
|
|
@@ -6068,7 +6415,7 @@ var TraceService = class {
|
|
|
6068
6415
|
for (const agentId of await listDirs(agentsDir3(this.root, projectId))) {
|
|
6069
6416
|
const dir = tracesDir3(this.root, projectId, agentId);
|
|
6070
6417
|
for (const dateDir of await listDirs(dir)) {
|
|
6071
|
-
for (const file of await listFiles(
|
|
6418
|
+
for (const file of await listFiles(path14.join(dir, dateDir))) {
|
|
6072
6419
|
const match = TRACE_FILE_RE2.exec(file);
|
|
6073
6420
|
if (match && !index.has(match[1])) index.set(match[1], agentId);
|
|
6074
6421
|
}
|
|
@@ -6368,11 +6715,11 @@ var TraceService = class {
|
|
|
6368
6715
|
const out = { dates: [] };
|
|
6369
6716
|
for (const date of dates) {
|
|
6370
6717
|
const bySession = /* @__PURE__ */ new Map();
|
|
6371
|
-
for (const file of await listFiles(
|
|
6718
|
+
for (const file of await listFiles(path14.join(dir, date))) {
|
|
6372
6719
|
const match = TRACE_FILE_RE2.exec(file);
|
|
6373
6720
|
if (!match) continue;
|
|
6374
6721
|
const sessionId = match[1];
|
|
6375
|
-
const stat = await fs15.stat(
|
|
6722
|
+
const stat = await fs15.stat(path14.join(dir, date, file));
|
|
6376
6723
|
const files = bySession.get(sessionId) ?? [];
|
|
6377
6724
|
files.push({ index: Number(match[2]), sizeBytes: stat.size });
|
|
6378
6725
|
bySession.set(sessionId, files);
|
|
@@ -6396,7 +6743,7 @@ var TraceService = class {
|
|
|
6396
6743
|
throw new HttpError(
|
|
6397
6744
|
404,
|
|
6398
6745
|
"trace_not_found",
|
|
6399
|
-
|
|
6746
|
+
`This Session has no Trace file with index ${index}.`
|
|
6400
6747
|
);
|
|
6401
6748
|
}
|
|
6402
6749
|
return readTraceTolerant2(file.path);
|
|
@@ -6606,10 +6953,12 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
6606
6953
|
const recorder = new UsageRecorder(usageRepo, overrides.now ?? (() => /* @__PURE__ */ new Date()));
|
|
6607
6954
|
const errors = new ErrorRecorder(errorsRepo, overrides.now ?? (() => /* @__PURE__ */ new Date()));
|
|
6608
6955
|
const titles = overrides.titles ?? new TitleGenerator({ sessions: sessionsRepo, channels, recorder, errors, log });
|
|
6956
|
+
const sessionSources = new SessionSources();
|
|
6609
6957
|
const manager = new SessionManager({
|
|
6610
6958
|
sessions: sessionsRepo,
|
|
6611
6959
|
channels,
|
|
6612
|
-
loader: overrides.loader ?? createCoreSessionLoader(config2.root),
|
|
6960
|
+
loader: overrides.loader ?? createCoreSessionLoader(config2.root, sessionSources),
|
|
6961
|
+
sources: sessionSources,
|
|
6613
6962
|
recorder,
|
|
6614
6963
|
errors,
|
|
6615
6964
|
titles,
|
|
@@ -6648,7 +6997,8 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
6648
6997
|
root: config2.root,
|
|
6649
6998
|
sessions: sessionsRepo,
|
|
6650
6999
|
manager,
|
|
6651
|
-
projectConfig: projectConfigService
|
|
7000
|
+
projectConfig: projectConfigService,
|
|
7001
|
+
sources: sessionSources
|
|
6652
7002
|
});
|
|
6653
7003
|
const scheduler = new Scheduler({
|
|
6654
7004
|
root: config2.root,
|
|
@@ -6684,6 +7034,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
6684
7034
|
scheduler,
|
|
6685
7035
|
channels,
|
|
6686
7036
|
manager,
|
|
7037
|
+
sessionSources,
|
|
6687
7038
|
errors,
|
|
6688
7039
|
log
|
|
6689
7040
|
};
|
|
@@ -6699,7 +7050,7 @@ function createApp(deps2) {
|
|
|
6699
7050
|
});
|
|
6700
7051
|
return handleError(err, c);
|
|
6701
7052
|
});
|
|
6702
|
-
app2.notFound((c) => c.json(errorBody("not_found", "
|
|
7053
|
+
app2.notFound((c) => c.json(errorBody("not_found", "Endpoint does not exist."), 404));
|
|
6703
7054
|
app2.use("*", async (c, next) => {
|
|
6704
7055
|
const start = performance.now();
|
|
6705
7056
|
await next();
|
|
@@ -6709,7 +7060,7 @@ function createApp(deps2) {
|
|
|
6709
7060
|
app2.use("/api/*", async (c, next) => {
|
|
6710
7061
|
const contentLength = Number(c.req.header("content-length") ?? 0);
|
|
6711
7062
|
if (contentLength > MAX_BODY_BYTES) {
|
|
6712
|
-
throw new HttpError(413, "payload_too_large", "
|
|
7063
|
+
throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
|
|
6713
7064
|
}
|
|
6714
7065
|
await next();
|
|
6715
7066
|
});
|
|
@@ -6769,27 +7120,27 @@ function registerStaticRoutes(app2, webDist) {
|
|
|
6769
7120
|
app2.get("*", async (c) => {
|
|
6770
7121
|
const reqPath = decodeURIComponent(c.req.path);
|
|
6771
7122
|
if (reqPath.startsWith("/api/")) {
|
|
6772
|
-
return c.json(errorBody("not_found", "
|
|
7123
|
+
return c.json(errorBody("not_found", "Endpoint does not exist."), 404);
|
|
6773
7124
|
}
|
|
6774
7125
|
const rel = reqPath.replace(/^\/+/, "");
|
|
6775
|
-
const resolved =
|
|
6776
|
-
const base =
|
|
6777
|
-
const target = resolved === base || resolved.startsWith(base +
|
|
7126
|
+
const resolved = path15.resolve(webDist, rel === "" ? "index.html" : rel);
|
|
7127
|
+
const base = path15.resolve(webDist);
|
|
7128
|
+
const target = resolved === base || resolved.startsWith(base + path15.sep) ? resolved : path15.join(base, "index.html");
|
|
6778
7129
|
let file = target;
|
|
6779
7130
|
try {
|
|
6780
7131
|
const stat = await fsp.stat(file);
|
|
6781
|
-
if (stat.isDirectory()) file =
|
|
7132
|
+
if (stat.isDirectory()) file = path15.join(file, "index.html");
|
|
6782
7133
|
await fsp.access(file);
|
|
6783
7134
|
} catch {
|
|
6784
|
-
file =
|
|
7135
|
+
file = path15.join(base, "index.html");
|
|
6785
7136
|
}
|
|
6786
7137
|
let content;
|
|
6787
7138
|
try {
|
|
6788
7139
|
content = await fsp.readFile(file);
|
|
6789
7140
|
} catch {
|
|
6790
|
-
return c.json(errorBody("not_found", "
|
|
7141
|
+
return c.json(errorBody("not_found", "Resource does not exist."), 404);
|
|
6791
7142
|
}
|
|
6792
|
-
const type = CONTENT_TYPES2[
|
|
7143
|
+
const type = CONTENT_TYPES2[path15.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
6793
7144
|
return new Response(new Uint8Array(content), {
|
|
6794
7145
|
status: 200,
|
|
6795
7146
|
headers: { "Content-Type": type }
|
|
@@ -6799,27 +7150,27 @@ function registerStaticRoutes(app2, webDist) {
|
|
|
6799
7150
|
|
|
6800
7151
|
// src/config.ts
|
|
6801
7152
|
import fs17 from "fs";
|
|
6802
|
-
import
|
|
7153
|
+
import path16 from "path";
|
|
6803
7154
|
import { fileURLToPath } from "url";
|
|
6804
|
-
import { resolveRoot } from "@prismshadow/penguin-core";
|
|
7155
|
+
import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
|
|
6805
7156
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
6806
7157
|
function defaultWebDist() {
|
|
6807
|
-
const here =
|
|
6808
|
-
const bundled =
|
|
7158
|
+
const here = path16.dirname(fileURLToPath(import.meta.url));
|
|
7159
|
+
const bundled = path16.resolve(here, "..", "web-dist");
|
|
6809
7160
|
if (fs17.existsSync(bundled)) return bundled;
|
|
6810
|
-
return
|
|
7161
|
+
return path16.resolve(here, "..", "..", "web", "dist");
|
|
6811
7162
|
}
|
|
6812
7163
|
function resolveServerConfig(env = process.env) {
|
|
6813
7164
|
const root = env.PENGUIN_HOME ?? resolveRoot();
|
|
6814
|
-
const port = Number(env.PORT ||
|
|
7165
|
+
const port = Number(env.PORT || DEFAULT_SERVER_PORT);
|
|
6815
7166
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
6816
|
-
throw new Error(
|
|
7167
|
+
throw new Error(`Invalid port configuration PORT=${env.PORT}`);
|
|
6817
7168
|
}
|
|
6818
7169
|
return {
|
|
6819
7170
|
root,
|
|
6820
7171
|
host: env.HOST ?? "127.0.0.1",
|
|
6821
7172
|
port,
|
|
6822
|
-
dbPath: env.PENGUIN_WEB_DB ??
|
|
7173
|
+
dbPath: env.PENGUIN_WEB_DB ?? path16.join(root, "web.db"),
|
|
6823
7174
|
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
|
|
6824
7175
|
authSessionTtlMs: 7 * DAY_MS,
|
|
6825
7176
|
authSessionRenewMs: 6 * DAY_MS
|
|
@@ -6834,15 +7185,15 @@ var app = createApp(deps);
|
|
|
6834
7185
|
await deps.authService.seedAdmin();
|
|
6835
7186
|
await deps.scheduler.start();
|
|
6836
7187
|
var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
|
6837
|
-
console.log(`penguin-server
|
|
6838
|
-
console.log(
|
|
7188
|
+
console.log(`penguin-server started: http://${config.host}:${info.port}`);
|
|
7189
|
+
console.log(`Data root: ${config.root}`);
|
|
6839
7190
|
console.log(`SQLite: ${config.dbPath}`);
|
|
6840
7191
|
});
|
|
6841
7192
|
var shuttingDown = false;
|
|
6842
7193
|
async function shutdown(signal, exitCode = 0) {
|
|
6843
7194
|
if (shuttingDown) return;
|
|
6844
7195
|
shuttingDown = true;
|
|
6845
|
-
console.log(
|
|
7196
|
+
console.log(`Received ${signal}, shutting down\u2026`);
|
|
6846
7197
|
deps.scheduler.stop();
|
|
6847
7198
|
await deps.manager.shutdown(5e3);
|
|
6848
7199
|
deps.channels.dispose();
|
|
@@ -6855,13 +7206,13 @@ async function shutdown(signal, exitCode = 0) {
|
|
|
6855
7206
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
6856
7207
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
6857
7208
|
process.on("uncaughtException", (err) => {
|
|
6858
|
-
console.error(`[server]
|
|
7209
|
+
console.error(`[server] Uncaught exception: ${err.stack ?? err.message}`);
|
|
6859
7210
|
deps.errors.record({ source: "process", err, code: "uncaught_exception" });
|
|
6860
7211
|
void shutdown("uncaughtException", 1).catch(() => process.exit(1));
|
|
6861
7212
|
});
|
|
6862
7213
|
process.on("unhandledRejection", (reason) => {
|
|
6863
7214
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
6864
|
-
console.error(`[server]
|
|
7215
|
+
console.error(`[server] Unhandled promise rejection: ${err.stack ?? err.message}`);
|
|
6865
7216
|
deps.errors.record({ source: "process", err, code: "unhandled_rejection" });
|
|
6866
7217
|
});
|
|
6867
7218
|
//# sourceMappingURL=index.js.map
|