@prismshadow/penguin-server 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/types.d.ts +232 -4
- package/dist/index.js +1374 -98
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/web-dist/assets/{index-CukW1Teq.js → index-7gbWDtby.js} +1 -1
- package/web-dist/assets/index-Bnva9EYm.js +102 -0
- package/web-dist/assets/index-DMcMNwnl.css +1 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-C9EZP9d7.js +0 -101
- package/web-dist/assets/index-XqmoUN63.css +0 -1
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { serve } from "@hono/node-server";
|
|
|
6
6
|
import fs16 from "fs";
|
|
7
7
|
import fsp from "fs/promises";
|
|
8
8
|
import path15 from "path";
|
|
9
|
-
import { Hono as
|
|
9
|
+
import { Hono as Hono21 } from "hono";
|
|
10
10
|
|
|
11
11
|
// src/db/database.ts
|
|
12
12
|
import { mkdirSync } from "fs";
|
|
@@ -102,6 +102,20 @@ CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files
|
|
|
102
102
|
invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
|
|
103
103
|
PRIMARY KEY (project_id, agent_id, name)
|
|
104
104
|
);
|
|
105
|
+
CREATE TABLE IF NOT EXISTS goal_state ( -- goal-mode runtime state (GOAL.yaml on disk is the model-facing protocol; this row is what the UI reads)
|
|
106
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display
|
|
107
|
+
session_id TEXT NOT NULL,
|
|
108
|
+
project_id TEXT NOT NULL,
|
|
109
|
+
agent_id TEXT NOT NULL,
|
|
110
|
+
objective TEXT NOT NULL,
|
|
111
|
+
status TEXT NOT NULL, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active)
|
|
112
|
+
budget INTEGER NOT NULL, -- token budget; -1 = unlimited
|
|
113
|
+
used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting)
|
|
114
|
+
rounds INTEGER NOT NULL DEFAULT 0,
|
|
115
|
+
created_at TEXT NOT NULL,
|
|
116
|
+
updated_at TEXT NOT NULL
|
|
117
|
+
);
|
|
118
|
+
CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);
|
|
105
119
|
CREATE TABLE IF NOT EXISTS ui_prefs (
|
|
106
120
|
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
|
107
121
|
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
|
|
@@ -300,6 +314,10 @@ var ErrorsRepo = class {
|
|
|
300
314
|
message: r.message
|
|
301
315
|
}));
|
|
302
316
|
}
|
|
317
|
+
/** Cascading cleanup on Agent deletion (unattributed errors carry no agent_id and are unaffected). */
|
|
318
|
+
deleteByAgent(projectId, agentId) {
|
|
319
|
+
this.db.prepare("DELETE FROM error_records WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
|
|
320
|
+
}
|
|
303
321
|
/** Cascading cleanup on Project deletion (unattributed errors belong to no Project and are unaffected). */
|
|
304
322
|
deleteByProject(projectId) {
|
|
305
323
|
this.db.prepare("DELETE FROM error_records WHERE project_id = ?").run(projectId);
|
|
@@ -385,8 +403,77 @@ var ProjectsRepo = class {
|
|
|
385
403
|
}
|
|
386
404
|
};
|
|
387
405
|
|
|
388
|
-
// src/db/repos/
|
|
406
|
+
// src/db/repos/goals.ts
|
|
389
407
|
function mapRow2(r) {
|
|
408
|
+
return {
|
|
409
|
+
id: Number(r.id),
|
|
410
|
+
sessionId: r.session_id,
|
|
411
|
+
projectId: r.project_id,
|
|
412
|
+
agentId: r.agent_id,
|
|
413
|
+
objective: r.objective,
|
|
414
|
+
status: r.status,
|
|
415
|
+
budget: Number(r.budget),
|
|
416
|
+
used: Number(r.used),
|
|
417
|
+
rounds: Number(r.rounds),
|
|
418
|
+
createdAt: r.created_at,
|
|
419
|
+
updatedAt: r.updated_at
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
var GoalsRepo = class {
|
|
423
|
+
constructor(db) {
|
|
424
|
+
this.db = db;
|
|
425
|
+
}
|
|
426
|
+
db;
|
|
427
|
+
/** Register a new goal run (status active, counters at zero); returns the new row id. */
|
|
428
|
+
create(args) {
|
|
429
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
430
|
+
const result = this.db.prepare(
|
|
431
|
+
`INSERT INTO goal_state
|
|
432
|
+
(session_id, project_id, agent_id, objective, status, budget, used, rounds, created_at, updated_at)
|
|
433
|
+
VALUES (?, ?, ?, ?, 'active', ?, 0, 0, ?, ?)`
|
|
434
|
+
).run(args.sessionId, args.projectId, args.agentId, args.objective, args.budget, now, now);
|
|
435
|
+
return Number(result.lastInsertRowid);
|
|
436
|
+
}
|
|
437
|
+
/** Per-round progress refresh (round just started; `used` mirrors the runner's accounting so far). */
|
|
438
|
+
progress(id, rounds, used) {
|
|
439
|
+
this.db.prepare("UPDATE goal_state SET rounds = ?, used = ?, updated_at = ? WHERE id = ?").run(rounds, used, (/* @__PURE__ */ new Date()).toISOString(), id);
|
|
440
|
+
}
|
|
441
|
+
/** Record the terminal outcome. */
|
|
442
|
+
finish(id, status, rounds, used) {
|
|
443
|
+
this.db.prepare(
|
|
444
|
+
"UPDATE goal_state SET status = ?, rounds = ?, used = ?, updated_at = ? WHERE id = ?"
|
|
445
|
+
).run(status, rounds, used, (/* @__PURE__ */ new Date()).toISOString(), id);
|
|
446
|
+
}
|
|
447
|
+
/** The Session's most recent goal run (what the chat page's banner restores from); null if it never ran one. */
|
|
448
|
+
latestForSession(sessionId) {
|
|
449
|
+
const r = this.db.prepare("SELECT * FROM goal_state WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
|
|
450
|
+
return r ? mapRow2(r) : null;
|
|
451
|
+
}
|
|
452
|
+
deleteBySession(sessionId) {
|
|
453
|
+
this.db.prepare("DELETE FROM goal_state WHERE session_id = ?").run(sessionId);
|
|
454
|
+
}
|
|
455
|
+
deleteByAgent(projectId, agentId) {
|
|
456
|
+
this.db.prepare("DELETE FROM goal_state WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
|
|
457
|
+
}
|
|
458
|
+
deleteByProject(projectId) {
|
|
459
|
+
this.db.prepare("DELETE FROM goal_state WHERE project_id = ?").run(projectId);
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Startup reconciliation: a goal runs only in SessionManager memory, so a hard crash
|
|
463
|
+
* (SIGKILL, power loss) leaves its row stuck `active` with no runner behind it — the banner
|
|
464
|
+
* would then restore a forever-"running" goal. Called once at boot, before the server accepts
|
|
465
|
+
* connections (nothing can be running yet), so every remaining `active` row is an orphan:
|
|
466
|
+
* mark them `aborted`. The on-disk GOAL.yaml is left `active` as the documented resume point.
|
|
467
|
+
* Returns the number of rows reconciled.
|
|
468
|
+
*/
|
|
469
|
+
abortOrphanedActive() {
|
|
470
|
+
const result = this.db.prepare("UPDATE goal_state SET status = 'aborted', updated_at = ? WHERE status = 'active'").run((/* @__PURE__ */ new Date()).toISOString());
|
|
471
|
+
return Number(result.changes);
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// src/db/repos/schedules.ts
|
|
476
|
+
function mapRow3(r) {
|
|
390
477
|
return {
|
|
391
478
|
projectId: r.project_id,
|
|
392
479
|
agentId: r.agent_id,
|
|
@@ -408,11 +495,11 @@ var SchedulesRepo = class {
|
|
|
408
495
|
db;
|
|
409
496
|
find(projectId, agentId, name) {
|
|
410
497
|
const r = this.db.prepare("SELECT * FROM schedule_state WHERE project_id = ? AND agent_id = ? AND name = ?").get(projectId, agentId, name);
|
|
411
|
-
return r ?
|
|
498
|
+
return r ? mapRow3(r) : null;
|
|
412
499
|
}
|
|
413
500
|
listByAgent(projectId, agentId) {
|
|
414
501
|
const rows = this.db.prepare("SELECT * FROM schedule_state WHERE project_id = ? AND agent_id = ? ORDER BY name").all(projectId, agentId);
|
|
415
|
-
return rows.map((r) =>
|
|
502
|
+
return rows.map((r) => mapRow3(r));
|
|
416
503
|
}
|
|
417
504
|
/**
|
|
418
505
|
* Register or sync a task's runtime state, returning the synced row plus a `fresh`
|
|
@@ -507,13 +594,16 @@ var SchedulesRepo = class {
|
|
|
507
594
|
}
|
|
508
595
|
return removed;
|
|
509
596
|
}
|
|
597
|
+
deleteByAgent(projectId, agentId) {
|
|
598
|
+
this.db.prepare("DELETE FROM schedule_state WHERE project_id = ? AND agent_id = ?").run(projectId, agentId);
|
|
599
|
+
}
|
|
510
600
|
deleteByProject(projectId) {
|
|
511
601
|
this.db.prepare("DELETE FROM schedule_state WHERE project_id = ?").run(projectId);
|
|
512
602
|
}
|
|
513
603
|
};
|
|
514
604
|
|
|
515
605
|
// src/db/repos/sessions.ts
|
|
516
|
-
function
|
|
606
|
+
function mapRow4(r) {
|
|
517
607
|
return {
|
|
518
608
|
sessionId: r.session_id,
|
|
519
609
|
projectId: r.project_id,
|
|
@@ -567,15 +657,15 @@ var SessionsRepo = class {
|
|
|
567
657
|
}
|
|
568
658
|
findById(sessionId) {
|
|
569
659
|
const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
570
|
-
return r ?
|
|
660
|
+
return r ? mapRow4(r) : null;
|
|
571
661
|
}
|
|
572
662
|
listByAgent(projectId, agentId) {
|
|
573
663
|
const rows = this.db.prepare("SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?").all(projectId, agentId);
|
|
574
|
-
return rows.map(
|
|
664
|
+
return rows.map(mapRow4);
|
|
575
665
|
}
|
|
576
666
|
listByProject(projectId) {
|
|
577
667
|
const rows = this.db.prepare("SELECT * FROM sessions WHERE project_id = ?").all(projectId);
|
|
578
|
-
return rows.map(
|
|
668
|
+
return rows.map(mapRow4);
|
|
579
669
|
}
|
|
580
670
|
updateApprovalMode(sessionId, mode) {
|
|
581
671
|
this.db.prepare("UPDATE sessions SET approval_mode = ? WHERE session_id = ?").run(mode, sessionId);
|
|
@@ -780,7 +870,7 @@ var UsageRepo = class {
|
|
|
780
870
|
};
|
|
781
871
|
|
|
782
872
|
// src/db/repos/users.ts
|
|
783
|
-
function
|
|
873
|
+
function mapRow5(r) {
|
|
784
874
|
return {
|
|
785
875
|
userId: r.user_id,
|
|
786
876
|
passwordHash: r.password_hash,
|
|
@@ -807,12 +897,12 @@ var UsersRepo = class {
|
|
|
807
897
|
}
|
|
808
898
|
findById(userId) {
|
|
809
899
|
const r = this.db.prepare("SELECT * FROM users WHERE user_id = ?").get(userId);
|
|
810
|
-
return r ?
|
|
900
|
+
return r ? mapRow5(r) : null;
|
|
811
901
|
}
|
|
812
902
|
/** All users (for the admin user backend), ordered by creation time ascending. */
|
|
813
903
|
list() {
|
|
814
904
|
const rows = this.db.prepare("SELECT * FROM users ORDER BY created_at ASC, user_id ASC").all();
|
|
815
|
-
return rows.map(
|
|
905
|
+
return rows.map(mapRow5);
|
|
816
906
|
}
|
|
817
907
|
count() {
|
|
818
908
|
const r = this.db.prepare("SELECT COUNT(*) AS n FROM users").get();
|
|
@@ -1154,6 +1244,12 @@ function optionalNumber(obj, key, rule = {}) {
|
|
|
1154
1244
|
if (rule.nonNegative && v < 0) throw badRequest(`${label} must not be negative.`);
|
|
1155
1245
|
return v;
|
|
1156
1246
|
}
|
|
1247
|
+
function optionalBoolean(obj, key, label = key) {
|
|
1248
|
+
const v = obj[key];
|
|
1249
|
+
if (v === void 0) return void 0;
|
|
1250
|
+
if (typeof v !== "boolean") throw badRequest(`${label} must be a boolean.`);
|
|
1251
|
+
return v;
|
|
1252
|
+
}
|
|
1157
1253
|
function optionalDateParam(value, label) {
|
|
1158
1254
|
if (value === void 0 || value === "") return void 0;
|
|
1159
1255
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
@@ -1233,10 +1329,111 @@ function authRoutes(deps2) {
|
|
|
1233
1329
|
|
|
1234
1330
|
// src/http/routes/me.ts
|
|
1235
1331
|
import { Hono as Hono3 } from "hono";
|
|
1332
|
+
|
|
1333
|
+
// src/services/preview-token.ts
|
|
1334
|
+
import { createHmac, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1335
|
+
var PREVIEW_TOKEN_TTL_MS = 10 * 60 * 1e3;
|
|
1336
|
+
function b64url(buf) {
|
|
1337
|
+
return buf.toString("base64url");
|
|
1338
|
+
}
|
|
1339
|
+
function createPreviewTokenSigner(secret = randomBytes3(32)) {
|
|
1340
|
+
const mac = (body) => createHmac("sha256", secret).update(body).digest();
|
|
1341
|
+
return {
|
|
1342
|
+
sign(payload) {
|
|
1343
|
+
const body = b64url(Buffer.from(JSON.stringify(payload), "utf8"));
|
|
1344
|
+
return `${body}.${b64url(mac(body))}`;
|
|
1345
|
+
},
|
|
1346
|
+
verify(token) {
|
|
1347
|
+
const dot = token.indexOf(".");
|
|
1348
|
+
if (dot <= 0 || dot === token.length - 1) return null;
|
|
1349
|
+
const body = token.slice(0, dot);
|
|
1350
|
+
const provided = Buffer.from(token.slice(dot + 1), "base64url");
|
|
1351
|
+
const expected = mac(body);
|
|
1352
|
+
if (provided.length !== expected.length || !timingSafeEqual2(provided, expected)) return null;
|
|
1353
|
+
let payload;
|
|
1354
|
+
try {
|
|
1355
|
+
payload = JSON.parse(
|
|
1356
|
+
Buffer.from(body, "base64url").toString("utf8")
|
|
1357
|
+
);
|
|
1358
|
+
} catch {
|
|
1359
|
+
return null;
|
|
1360
|
+
}
|
|
1361
|
+
if (typeof payload?.sessionId !== "string" || typeof payload?.host !== "string" || typeof payload?.expiresAt !== "number") {
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
if (Date.now() >= payload.expiresAt) return null;
|
|
1365
|
+
return payload;
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
function hostOnly(hostHeader) {
|
|
1370
|
+
const trimmed = hostHeader.trim();
|
|
1371
|
+
if (trimmed.startsWith("[")) {
|
|
1372
|
+
const end = trimmed.indexOf("]");
|
|
1373
|
+
return end === -1 ? trimmed : trimmed.slice(0, end + 1);
|
|
1374
|
+
}
|
|
1375
|
+
const colon = trimmed.lastIndexOf(":");
|
|
1376
|
+
return colon === -1 ? trimmed : trimmed.slice(0, colon);
|
|
1377
|
+
}
|
|
1378
|
+
function loopbackCounterpart(host) {
|
|
1379
|
+
const h = host.toLowerCase();
|
|
1380
|
+
if (h === "127.0.0.1") return "localhost";
|
|
1381
|
+
if (h === "localhost") return "127.0.0.1";
|
|
1382
|
+
if (h === "[::1]") return "127.0.0.1";
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
function loopbackHostRoles(bindHost) {
|
|
1386
|
+
const h = bindHost.trim().toLowerCase();
|
|
1387
|
+
if (h !== "127.0.0.1" && h !== "localhost") return null;
|
|
1388
|
+
const app2 = "localhost";
|
|
1389
|
+
const preview = loopbackCounterpart(app2);
|
|
1390
|
+
if (!preview) return null;
|
|
1391
|
+
return { app: app2, preview };
|
|
1392
|
+
}
|
|
1393
|
+
function requestAuthority(requestUrl, hostHeader) {
|
|
1394
|
+
const header = (hostHeader ?? "").trim();
|
|
1395
|
+
if (header !== "") return header;
|
|
1396
|
+
try {
|
|
1397
|
+
return new URL(requestUrl).host;
|
|
1398
|
+
} catch {
|
|
1399
|
+
return "";
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
function resolvePreviewTarget(requestUrl, hostHeader, configuredOrigin, serverBind) {
|
|
1403
|
+
const requestHost = hostOnly(requestAuthority(requestUrl, hostHeader)).toLowerCase();
|
|
1404
|
+
if (configuredOrigin) {
|
|
1405
|
+
const url = new URL(configuredOrigin);
|
|
1406
|
+
if (url.hostname.toLowerCase() === requestHost) return null;
|
|
1407
|
+
return { origin: url.origin, host: url.hostname };
|
|
1408
|
+
}
|
|
1409
|
+
if (requestHost === "") return null;
|
|
1410
|
+
const counterpart = loopbackCounterpart(requestHost);
|
|
1411
|
+
if (!counterpart) return null;
|
|
1412
|
+
if (!loopbackHostRoles(serverBind.host)) return null;
|
|
1413
|
+
let protocol;
|
|
1414
|
+
try {
|
|
1415
|
+
protocol = new URL(requestUrl).protocol;
|
|
1416
|
+
} catch {
|
|
1417
|
+
protocol = "http:";
|
|
1418
|
+
}
|
|
1419
|
+
const suffix = protocol === "http:" && serverBind.port === 80 || protocol === "https:" && serverBind.port === 443 ? "" : `:${serverBind.port}`;
|
|
1420
|
+
return { origin: `${protocol}//${counterpart}${suffix}`, host: counterpart };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// src/http/routes/me.ts
|
|
1236
1424
|
function meRoutes(deps2) {
|
|
1237
1425
|
const app2 = new Hono3();
|
|
1238
1426
|
app2.get("/", (c) => {
|
|
1239
|
-
|
|
1427
|
+
const target = resolvePreviewTarget(
|
|
1428
|
+
c.req.url,
|
|
1429
|
+
c.req.header("host"),
|
|
1430
|
+
deps2.config.previewOrigin,
|
|
1431
|
+
deps2.config
|
|
1432
|
+
);
|
|
1433
|
+
return c.json({
|
|
1434
|
+
user: toUserInfo(c.var.user),
|
|
1435
|
+
previewIsolated: target !== null
|
|
1436
|
+
});
|
|
1240
1437
|
});
|
|
1241
1438
|
app2.put("/password", async (c) => {
|
|
1242
1439
|
const body = await readJson(c);
|
|
@@ -1407,6 +1604,12 @@ function membersRoutes(deps2) {
|
|
|
1407
1604
|
|
|
1408
1605
|
// src/http/routes/models.ts
|
|
1409
1606
|
import { Hono as Hono7 } from "hono";
|
|
1607
|
+
function publishCredentialsUpdated(deps2, projectId) {
|
|
1608
|
+
const event = { type: "credentials_updated" };
|
|
1609
|
+
for (const row of deps2.sessionsRepo.listByProject(projectId)) {
|
|
1610
|
+
deps2.channels.peek(row.sessionId)?.publish(event, "server_event");
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1410
1613
|
function parseRef(value, label) {
|
|
1411
1614
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1412
1615
|
throw badRequest(`${label} must be a { provider, modelId } object.`);
|
|
@@ -1526,7 +1729,10 @@ function modelsRoutes(deps2) {
|
|
|
1526
1729
|
const projectId = requireValidId(c, "projectId");
|
|
1527
1730
|
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
1528
1731
|
const req = parseModelsUpdate(await readJson(c));
|
|
1529
|
-
|
|
1732
|
+
const res = await deps2.projectConfigService.updateModels(projectId, req);
|
|
1733
|
+
deps2.manager.invalidateProjectRuntimes(projectId);
|
|
1734
|
+
publishCredentialsUpdated(deps2, projectId);
|
|
1735
|
+
return c.json(res);
|
|
1530
1736
|
});
|
|
1531
1737
|
app2.post("/test", async (c) => {
|
|
1532
1738
|
const projectId = requireValidId(c, "projectId");
|
|
@@ -2198,6 +2404,9 @@ function agentsRoutes(deps2) {
|
|
|
2198
2404
|
await settleWithin(runnings, 5e3);
|
|
2199
2405
|
await deps2.agentService.deleteAgent(projectId, agentId);
|
|
2200
2406
|
deps2.sessionsRepo.deleteByAgent(projectId, agentId);
|
|
2407
|
+
deps2.goalsRepo.deleteByAgent(projectId, agentId);
|
|
2408
|
+
deps2.schedulesRepo.deleteByAgent(projectId, agentId);
|
|
2409
|
+
deps2.errorsRepo.deleteByAgent(projectId, agentId);
|
|
2201
2410
|
} finally {
|
|
2202
2411
|
deps2.manager.endAgentDeletion(projectId, agentId);
|
|
2203
2412
|
}
|
|
@@ -2287,11 +2496,23 @@ function agentConfigRoutes(deps2) {
|
|
|
2287
2496
|
activeSessionCount: deps2.manager.activeCountForAgent(projectId, agentId)
|
|
2288
2497
|
});
|
|
2289
2498
|
});
|
|
2499
|
+
app2.post("/reset", async (c) => {
|
|
2500
|
+
const projectId = requireValidId(c, "projectId");
|
|
2501
|
+
const agentId = requireValidId(c, "agentId");
|
|
2502
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2503
|
+
await deps2.agentConfigService.resetConfig(projectId, agentId);
|
|
2504
|
+
const view = await deps2.agentConfigService.getConfig(projectId, agentId);
|
|
2505
|
+
return c.json({
|
|
2506
|
+
...view,
|
|
2507
|
+
activeSessionCount: deps2.manager.activeCountForAgent(projectId, agentId)
|
|
2508
|
+
});
|
|
2509
|
+
});
|
|
2290
2510
|
return app2;
|
|
2291
2511
|
}
|
|
2292
2512
|
|
|
2293
2513
|
// src/http/routes/agent-traces.ts
|
|
2294
2514
|
import { Hono as Hono16 } from "hono";
|
|
2515
|
+
var MAX_TRACE_BYTES = 14 * 1024 * 1024;
|
|
2295
2516
|
function agentTracesRoutes(deps2) {
|
|
2296
2517
|
const app2 = new Hono16();
|
|
2297
2518
|
app2.get("/", async (c) => {
|
|
@@ -2319,6 +2540,41 @@ function agentTracesRoutes(deps2) {
|
|
|
2319
2540
|
const index = positiveIntParam(c, "index");
|
|
2320
2541
|
return c.json(await deps2.traceService.analyze(projectId, agentId, sessionId, index));
|
|
2321
2542
|
});
|
|
2543
|
+
app2.get("/:sessionId/:index/download", async (c) => {
|
|
2544
|
+
const projectId = requireValidId(c, "projectId");
|
|
2545
|
+
const agentId = requireValidId(c, "agentId");
|
|
2546
|
+
const sessionId = requireValidId(c, "sessionId");
|
|
2547
|
+
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2548
|
+
const index = positiveIntParam(c, "index");
|
|
2549
|
+
const bytes = await deps2.traceService.readFileRaw(projectId, agentId, sessionId, index);
|
|
2550
|
+
const fileName = `${sessionId}_${String(index).padStart(3, "0")}.jsonl`;
|
|
2551
|
+
return new Response(new Uint8Array(bytes), {
|
|
2552
|
+
headers: {
|
|
2553
|
+
"Content-Type": "application/x-ndjson",
|
|
2554
|
+
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
2555
|
+
"X-Content-Type-Options": "nosniff"
|
|
2556
|
+
}
|
|
2557
|
+
});
|
|
2558
|
+
});
|
|
2559
|
+
app2.post("/import", async (c) => {
|
|
2560
|
+
const projectId = requireValidId(c, "projectId");
|
|
2561
|
+
const agentId = requireValidId(c, "agentId");
|
|
2562
|
+
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
2563
|
+
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2564
|
+
const body = await readJson(c);
|
|
2565
|
+
const dataBase64 = requireString(body, "dataBase64", { minLen: 1, maxLen: 20 * 1024 * 1024 });
|
|
2566
|
+
const bytes = Buffer.from(dataBase64, "base64");
|
|
2567
|
+
if (bytes.byteLength === 0) throw badRequest("Import file is empty.");
|
|
2568
|
+
if (bytes.byteLength > MAX_TRACE_BYTES) {
|
|
2569
|
+
throw badRequest("Import file exceeds the 14MB limit.");
|
|
2570
|
+
}
|
|
2571
|
+
const res = await deps2.traceService.importTraceFile(
|
|
2572
|
+
projectId,
|
|
2573
|
+
agentId,
|
|
2574
|
+
bytes.toString("utf8")
|
|
2575
|
+
);
|
|
2576
|
+
return c.json(res);
|
|
2577
|
+
});
|
|
2322
2578
|
return app2;
|
|
2323
2579
|
}
|
|
2324
2580
|
|
|
@@ -2617,6 +2873,10 @@ var WorkspaceFilesService = class {
|
|
|
2617
2873
|
}
|
|
2618
2874
|
const { dir, name } = await this.resolveWriteParent(workspace, rel);
|
|
2619
2875
|
const file = path5.join(dir, name);
|
|
2876
|
+
if (process.platform === "win32") {
|
|
2877
|
+
const st = await fs6.lstat(file).catch(() => null);
|
|
2878
|
+
if (st?.isSymbolicLink()) throw badRequest("path must not be a symlink.");
|
|
2879
|
+
}
|
|
2620
2880
|
const flags = fsc.O_WRONLY | fsc.O_CREAT | fsc.O_TRUNC | (fsc.O_NOFOLLOW ?? 0);
|
|
2621
2881
|
let handle;
|
|
2622
2882
|
try {
|
|
@@ -2647,6 +2907,13 @@ var APPROVAL_MODES = [
|
|
|
2647
2907
|
"read-only",
|
|
2648
2908
|
"always-ask"
|
|
2649
2909
|
];
|
|
2910
|
+
var THINKING_LEVELS = ["none", "low", "medium", "high", "xhigh"];
|
|
2911
|
+
var SESSION_CATEGORIES = [
|
|
2912
|
+
"active",
|
|
2913
|
+
"subagent",
|
|
2914
|
+
"schedule",
|
|
2915
|
+
"archived"
|
|
2916
|
+
];
|
|
2650
2917
|
function parseTaskInput(body) {
|
|
2651
2918
|
const input = body.input;
|
|
2652
2919
|
if (!Array.isArray(input) || input.length === 0) {
|
|
@@ -2673,6 +2940,18 @@ function parseTaskInput(body) {
|
|
|
2673
2940
|
throw badRequest(`input[${i}].type must be one of text / image_url.`);
|
|
2674
2941
|
});
|
|
2675
2942
|
}
|
|
2943
|
+
function parseGoalField(body) {
|
|
2944
|
+
const goal = body.goal;
|
|
2945
|
+
if (goal === void 0) return null;
|
|
2946
|
+
if (goal === null || typeof goal !== "object" || Array.isArray(goal)) {
|
|
2947
|
+
throw badRequest("goal must be an object.");
|
|
2948
|
+
}
|
|
2949
|
+
const budget = goal.budget;
|
|
2950
|
+
if (budget !== void 0 && (typeof budget !== "number" || !Number.isInteger(budget) || budget <= 0 && budget !== -1)) {
|
|
2951
|
+
throw badRequest("goal.budget must be a positive integer, or -1 for unlimited.");
|
|
2952
|
+
}
|
|
2953
|
+
return { budget: budget ?? -1 };
|
|
2954
|
+
}
|
|
2676
2955
|
function agentSessionsRoutes(deps2) {
|
|
2677
2956
|
const app2 = new Hono18();
|
|
2678
2957
|
app2.get("/", async (c) => {
|
|
@@ -2681,12 +2960,26 @@ function agentSessionsRoutes(deps2) {
|
|
|
2681
2960
|
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
2682
2961
|
await deps2.agentConfigService.requireExists(projectId, agentId);
|
|
2683
2962
|
const paging = optionalPagingQuery(c);
|
|
2684
|
-
const
|
|
2963
|
+
const rawCategory = c.req.query("category");
|
|
2964
|
+
if (rawCategory !== void 0 && !SESSION_CATEGORIES.includes(rawCategory)) {
|
|
2965
|
+
throw badRequest(`category must be one of ${SESSION_CATEGORIES.join(" / ")}.`);
|
|
2966
|
+
}
|
|
2967
|
+
const rawCounts = c.req.query("counts");
|
|
2968
|
+
if (rawCounts !== void 0 && rawCounts !== "1") throw badRequest("counts only accepts 1.");
|
|
2969
|
+
const { sessions, counts, workspaceCounts } = await deps2.sessionService.listSessions(
|
|
2685
2970
|
projectId,
|
|
2686
2971
|
agentId,
|
|
2687
|
-
|
|
2972
|
+
{
|
|
2973
|
+
...paging ? { paging } : {},
|
|
2974
|
+
...rawCategory !== void 0 ? { category: rawCategory } : {},
|
|
2975
|
+
...rawCounts !== void 0 ? { withCounts: true } : {}
|
|
2976
|
+
}
|
|
2688
2977
|
);
|
|
2689
|
-
return c.json({
|
|
2978
|
+
return c.json({
|
|
2979
|
+
sessions,
|
|
2980
|
+
...counts ? { counts } : {},
|
|
2981
|
+
...workspaceCounts ? { workspaceCounts } : {}
|
|
2982
|
+
});
|
|
2690
2983
|
});
|
|
2691
2984
|
app2.post("/", async (c) => {
|
|
2692
2985
|
const projectId = requireValidId(c, "projectId");
|
|
@@ -2744,8 +3037,10 @@ function sessionsRoutes(deps2) {
|
|
|
2744
3037
|
app2.get("/:sessionId", async (c) => {
|
|
2745
3038
|
const row = resolveSession(c);
|
|
2746
3039
|
const hasTrace = await deps2.sessionService.hasTrace(row);
|
|
3040
|
+
const info = await deps2.sessionService.toInfo(row, hasTrace);
|
|
3041
|
+
const tracePath = hasTrace ? await deps2.sessionService.latestTracePath(row) : void 0;
|
|
2747
3042
|
return c.json({
|
|
2748
|
-
session:
|
|
3043
|
+
session: { ...info, ...tracePath !== void 0 ? { tracePath } : {} }
|
|
2749
3044
|
});
|
|
2750
3045
|
});
|
|
2751
3046
|
app2.patch("/:sessionId", async (c) => {
|
|
@@ -2811,6 +3106,7 @@ function sessionsRoutes(deps2) {
|
|
|
2811
3106
|
{ recursive: true, force: true }
|
|
2812
3107
|
);
|
|
2813
3108
|
deps2.sessionsRepo.deleteById(row.sessionId);
|
|
3109
|
+
deps2.goalsRepo.deleteBySession(row.sessionId);
|
|
2814
3110
|
deps2.sessionSources.delete(row.sessionId);
|
|
2815
3111
|
} finally {
|
|
2816
3112
|
deps2.manager.endSessionDeletion(row.sessionId);
|
|
@@ -2849,18 +3145,29 @@ function sessionsRoutes(deps2) {
|
|
|
2849
3145
|
});
|
|
2850
3146
|
app2.get("/:sessionId/messages", async (c) => {
|
|
2851
3147
|
const row = resolveSession(c);
|
|
3148
|
+
let live;
|
|
3149
|
+
if (deps2.manager.statusOf(row.sessionId) !== "idle") {
|
|
3150
|
+
live = {
|
|
3151
|
+
cursor: deps2.channels.get(row.sessionId).lastEventId,
|
|
3152
|
+
fragments: deps2.manager.liveFragments(row.sessionId)
|
|
3153
|
+
};
|
|
3154
|
+
}
|
|
2852
3155
|
const messages = await deps2.traceService.readMessages(
|
|
2853
3156
|
row.projectId,
|
|
2854
3157
|
row.agentId,
|
|
2855
3158
|
row.sessionId
|
|
2856
3159
|
);
|
|
2857
|
-
return c.json({ messages });
|
|
3160
|
+
return c.json({ messages, ...live !== void 0 ? { live } : {} });
|
|
2858
3161
|
});
|
|
2859
3162
|
app2.get("/:sessionId/stream", (c) => {
|
|
2860
3163
|
const row = resolveSession(c);
|
|
2861
3164
|
const channel = deps2.channels.get(row.sessionId);
|
|
2862
3165
|
const initialEvents = [
|
|
2863
|
-
{
|
|
3166
|
+
{
|
|
3167
|
+
type: "task_state",
|
|
3168
|
+
state: deps2.manager.statusOf(row.sessionId),
|
|
3169
|
+
queued: deps2.manager.pendingFollowUpCount(row.sessionId)
|
|
3170
|
+
},
|
|
2864
3171
|
...deps2.manager.pendingApprovals(row.sessionId).map((p) => ({
|
|
2865
3172
|
type: "approval_request",
|
|
2866
3173
|
toolCall: p.toolCall,
|
|
@@ -2871,9 +3178,51 @@ function sessionsRoutes(deps2) {
|
|
|
2871
3178
|
});
|
|
2872
3179
|
app2.post("/:sessionId/tasks", async (c) => {
|
|
2873
3180
|
const row = resolveSession(c);
|
|
2874
|
-
const
|
|
2875
|
-
const
|
|
2876
|
-
|
|
3181
|
+
const body = await readJson(c);
|
|
3182
|
+
const goal = parseGoalField(body);
|
|
3183
|
+
const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
|
|
3184
|
+
if (goal) {
|
|
3185
|
+
const input2 = parseTaskInput(body);
|
|
3186
|
+
const text = input2.filter((m) => m.payload.type === "text").map((m) => m.payload.text).join("\n").trim();
|
|
3187
|
+
if (!text || input2.some((m) => m.payload.type !== "text")) {
|
|
3188
|
+
throw badRequest("goal mode requires text-only input (the objective).");
|
|
3189
|
+
}
|
|
3190
|
+
const { sessionId: sessionId2 } = await deps2.manager.startGoal(row.sessionId, {
|
|
3191
|
+
input: input2,
|
|
3192
|
+
budget: goal.budget,
|
|
3193
|
+
...thinkingLevel !== void 0 ? { thinkingLevel } : {}
|
|
3194
|
+
});
|
|
3195
|
+
return c.json({ sessionId: sessionId2 }, 202);
|
|
3196
|
+
}
|
|
3197
|
+
const input = parseTaskInput(body);
|
|
3198
|
+
const queueIfBusy = body.queueIfBusy === true;
|
|
3199
|
+
const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
|
|
3200
|
+
...thinkingLevel !== void 0 ? { thinkingLevel } : {},
|
|
3201
|
+
queueIfBusy
|
|
3202
|
+
});
|
|
3203
|
+
return c.json({ sessionId, queued }, 202);
|
|
3204
|
+
});
|
|
3205
|
+
app2.post("/:sessionId/steer", async (c) => {
|
|
3206
|
+
const row = resolveSession(c);
|
|
3207
|
+
const body = await readJson(c);
|
|
3208
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
3209
|
+
if (!text) throw badRequest("text must be a non-empty string.");
|
|
3210
|
+
deps2.manager.steer(row.sessionId, text);
|
|
3211
|
+
return c.body(null, 202);
|
|
3212
|
+
});
|
|
3213
|
+
app2.get("/:sessionId/goal", (c) => {
|
|
3214
|
+
const row = resolveSession(c);
|
|
3215
|
+
const g = deps2.goalsRepo.latestForSession(row.sessionId);
|
|
3216
|
+
return c.json({
|
|
3217
|
+
goal: g ? {
|
|
3218
|
+
objective: g.objective,
|
|
3219
|
+
status: g.status,
|
|
3220
|
+
budget: g.budget,
|
|
3221
|
+
used: g.used,
|
|
3222
|
+
rounds: g.rounds,
|
|
3223
|
+
updatedAt: g.updatedAt
|
|
3224
|
+
} : null
|
|
3225
|
+
});
|
|
2877
3226
|
});
|
|
2878
3227
|
app2.post("/:sessionId/approvals/:toolCallId", async (c) => {
|
|
2879
3228
|
const row = resolveSession(c);
|
|
@@ -2894,6 +3243,11 @@ function sessionsRoutes(deps2) {
|
|
|
2894
3243
|
const aborted = deps2.manager.abortTask(row.sessionId);
|
|
2895
3244
|
return c.body(null, aborted ? 202 : 204);
|
|
2896
3245
|
});
|
|
3246
|
+
app2.post("/:sessionId/retry-now", (c) => {
|
|
3247
|
+
const row = resolveSession(c);
|
|
3248
|
+
const skipped = deps2.manager.retryNow(row.sessionId);
|
|
3249
|
+
return c.json({ skipped });
|
|
3250
|
+
});
|
|
2897
3251
|
app2.post("/:sessionId/compact", async (c) => {
|
|
2898
3252
|
const row = resolveSession(c);
|
|
2899
3253
|
const { sessionId } = await deps2.manager.startCompact(row.sessionId);
|
|
@@ -2927,6 +3281,31 @@ function sessionsRoutes(deps2) {
|
|
|
2927
3281
|
}
|
|
2928
3282
|
});
|
|
2929
3283
|
});
|
|
3284
|
+
app2.get("/:sessionId/files/preview-redirect", async (c) => {
|
|
3285
|
+
const row = resolveSession(c);
|
|
3286
|
+
const rel = c.req.query("path") ?? "";
|
|
3287
|
+
const [exists] = await deps2.workspaceFiles.statExisting(row.workspace, [rel]);
|
|
3288
|
+
if (!exists) throw new HttpError(404, "file_not_found", "File does not exist.");
|
|
3289
|
+
const target = resolvePreviewTarget(
|
|
3290
|
+
c.req.url,
|
|
3291
|
+
c.req.header("host"),
|
|
3292
|
+
deps2.config.previewOrigin,
|
|
3293
|
+
deps2.config
|
|
3294
|
+
);
|
|
3295
|
+
if (!target) {
|
|
3296
|
+
return c.redirect(
|
|
3297
|
+
`/api/sessions/${row.sessionId}/files/content?path=${encodeURIComponent(rel)}&preview=1`,
|
|
3298
|
+
302
|
|
3299
|
+
);
|
|
3300
|
+
}
|
|
3301
|
+
const token = deps2.previewTokens.sign({
|
|
3302
|
+
sessionId: row.sessionId,
|
|
3303
|
+
host: target.host,
|
|
3304
|
+
expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS
|
|
3305
|
+
});
|
|
3306
|
+
const encoded = rel.split("/").map(encodeURIComponent).join("/");
|
|
3307
|
+
return c.redirect(`${target.origin}/preview/${token}/${encoded}`, 302);
|
|
3308
|
+
});
|
|
2930
3309
|
app2.post("/:sessionId/files/stat", async (c) => {
|
|
2931
3310
|
const row = resolveSession(c);
|
|
2932
3311
|
const body = await readJson(c);
|
|
@@ -2983,6 +3362,104 @@ function sessionsRoutes(deps2) {
|
|
|
2983
3362
|
return app2;
|
|
2984
3363
|
}
|
|
2985
3364
|
|
|
3365
|
+
// src/http/routes/version.ts
|
|
3366
|
+
import { spawn } from "child_process";
|
|
3367
|
+
import { BUILD_DATE, VERSION } from "@prismshadow/penguin-core";
|
|
3368
|
+
import { Hono as Hono19 } from "hono";
|
|
3369
|
+
var OUTPUT_CAP = 64 * 1024;
|
|
3370
|
+
var UPDATE_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
3371
|
+
var REFUSAL_MARKERS = [
|
|
3372
|
+
"runs from a source checkout",
|
|
3373
|
+
// sourceCheckout
|
|
3374
|
+
"Cannot tell how this penguin was installed",
|
|
3375
|
+
// unknownInstall
|
|
3376
|
+
"could not be identified",
|
|
3377
|
+
// npmUnknownManager
|
|
3378
|
+
"does not run on Windows",
|
|
3379
|
+
// windowsUnsupported
|
|
3380
|
+
"cannot run your package manager for you"
|
|
3381
|
+
// windowsGlobalInstall
|
|
3382
|
+
];
|
|
3383
|
+
function classifyUpdateRun(exitCode, output) {
|
|
3384
|
+
if (exitCode !== 0) return { status: "failed", output, needsRestart: false };
|
|
3385
|
+
if (REFUSAL_MARKERS.some((marker) => output.includes(marker))) {
|
|
3386
|
+
return { status: "unsupported", output, needsRestart: false };
|
|
3387
|
+
}
|
|
3388
|
+
return { status: "updated", output, needsRestart: true };
|
|
3389
|
+
}
|
|
3390
|
+
function runSelfUpdate(cliEntry) {
|
|
3391
|
+
return new Promise((resolve) => {
|
|
3392
|
+
const child = spawn(process.execPath, [cliEntry, "update", "--yes"], {
|
|
3393
|
+
env: { ...process.env, PENGUIN_LANG: "en" },
|
|
3394
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
3395
|
+
});
|
|
3396
|
+
let output = "";
|
|
3397
|
+
const append = (chunk) => {
|
|
3398
|
+
output = (output + chunk.toString("utf8")).slice(-OUTPUT_CAP);
|
|
3399
|
+
};
|
|
3400
|
+
child.stdout.on("data", append);
|
|
3401
|
+
child.stderr.on("data", append);
|
|
3402
|
+
let timedOut = false;
|
|
3403
|
+
const timer = setTimeout(() => {
|
|
3404
|
+
timedOut = true;
|
|
3405
|
+
child.kill("SIGTERM");
|
|
3406
|
+
setTimeout(() => child.kill("SIGKILL"), 1e4).unref();
|
|
3407
|
+
}, UPDATE_TIMEOUT_MS);
|
|
3408
|
+
timer.unref();
|
|
3409
|
+
child.on("error", (err) => {
|
|
3410
|
+
clearTimeout(timer);
|
|
3411
|
+
resolve({
|
|
3412
|
+
status: "failed",
|
|
3413
|
+
output: `${output}
|
|
3414
|
+
[failed to run the update command: ${err.message}]`.trim(),
|
|
3415
|
+
needsRestart: false
|
|
3416
|
+
});
|
|
3417
|
+
});
|
|
3418
|
+
child.on("close", (code) => {
|
|
3419
|
+
clearTimeout(timer);
|
|
3420
|
+
if (timedOut) {
|
|
3421
|
+
resolve({
|
|
3422
|
+
status: "failed",
|
|
3423
|
+
output: `${output}
|
|
3424
|
+
[the update run was killed after ${UPDATE_TIMEOUT_MS / 6e4} minutes]`.trim(),
|
|
3425
|
+
needsRestart: false
|
|
3426
|
+
});
|
|
3427
|
+
return;
|
|
3428
|
+
}
|
|
3429
|
+
resolve(classifyUpdateRun(code ?? -1, output.trim()));
|
|
3430
|
+
});
|
|
3431
|
+
});
|
|
3432
|
+
}
|
|
3433
|
+
function versionRoutes(deps2) {
|
|
3434
|
+
const app2 = new Hono19();
|
|
3435
|
+
let inflightRun = null;
|
|
3436
|
+
app2.get("/", (c) => {
|
|
3437
|
+
return c.json({ version: VERSION, buildDate: BUILD_DATE });
|
|
3438
|
+
});
|
|
3439
|
+
app2.get("/update-check", async (c) => {
|
|
3440
|
+
return c.json(await deps2.updateCheck.check(c.req.query("force") === "1"));
|
|
3441
|
+
});
|
|
3442
|
+
app2.post("/update", async (c) => {
|
|
3443
|
+
if (!c.var.user.isAdmin) {
|
|
3444
|
+
throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
|
|
3445
|
+
}
|
|
3446
|
+
const cliEntry = process.env.PENGUIN_CLI_ENTRY;
|
|
3447
|
+
if (!cliEntry) {
|
|
3448
|
+
return c.json({
|
|
3449
|
+
status: "unsupported",
|
|
3450
|
+
reason: "not_launched_via_cli",
|
|
3451
|
+
output: "",
|
|
3452
|
+
needsRestart: false
|
|
3453
|
+
});
|
|
3454
|
+
}
|
|
3455
|
+
inflightRun ??= runSelfUpdate(cliEntry).finally(() => {
|
|
3456
|
+
inflightRun = null;
|
|
3457
|
+
});
|
|
3458
|
+
return c.json(await inflightRun);
|
|
3459
|
+
});
|
|
3460
|
+
return app2;
|
|
3461
|
+
}
|
|
3462
|
+
|
|
2986
3463
|
// src/runtime/channel.ts
|
|
2987
3464
|
import { randomUUID } from "crypto";
|
|
2988
3465
|
var DEFAULT_MAX_COUNT = 1e4;
|
|
@@ -3007,6 +3484,16 @@ var Channel = class {
|
|
|
3007
3484
|
get subscriberCount() {
|
|
3008
3485
|
return this.listeners.size;
|
|
3009
3486
|
}
|
|
3487
|
+
/**
|
|
3488
|
+
* Id of the most recently assigned event (`<epoch>-<seq>`; seq 0 when none was assigned
|
|
3489
|
+
* yet). Unicast (sendTo) seqs count too — the value is a position marker, not a buffer
|
|
3490
|
+
* lookup key. Used as the live-tail cursor on GET /messages: captured in the same
|
|
3491
|
+
* synchronous tick as the fragment snapshot, it tells the client which buffered events
|
|
3492
|
+
* the snapshot already covers.
|
|
3493
|
+
*/
|
|
3494
|
+
get lastEventId() {
|
|
3495
|
+
return `${this.epoch}-${this.nextSeq - 1}`;
|
|
3496
|
+
}
|
|
3010
3497
|
/** Broadcast an event: number it, buffer it (evicting the oldest), notify all subscribers. */
|
|
3011
3498
|
publish(data, event) {
|
|
3012
3499
|
const entry = this.makeEvent(data, event);
|
|
@@ -3179,7 +3666,11 @@ import path8 from "path";
|
|
|
3179
3666
|
import {
|
|
3180
3667
|
createAgent,
|
|
3181
3668
|
findLatestTraceFile,
|
|
3669
|
+
goalFinishedOf,
|
|
3670
|
+
goalTokenDelta,
|
|
3671
|
+
isGoalRoundInput,
|
|
3182
3672
|
isSessionMeta as isSessionMeta2,
|
|
3673
|
+
stripLeadingMarkerBlocks,
|
|
3183
3674
|
tracesDir
|
|
3184
3675
|
} from "@prismshadow/penguin-core";
|
|
3185
3676
|
|
|
@@ -3250,6 +3741,165 @@ function makeApprove(args) {
|
|
|
3250
3741
|
};
|
|
3251
3742
|
}
|
|
3252
3743
|
|
|
3744
|
+
// src/runtime/live-tail.ts
|
|
3745
|
+
import { isPartialPayload } from "@prismshadow/penguin-core";
|
|
3746
|
+
var FRAGMENT_CAP = 512 * 1024;
|
|
3747
|
+
var FRAGMENT_CAP_SLACK = 64 * 1024;
|
|
3748
|
+
function partialKindFor(p) {
|
|
3749
|
+
switch (p.type) {
|
|
3750
|
+
case "text":
|
|
3751
|
+
return p.role === "assistant" ? "partial_text" : null;
|
|
3752
|
+
case "thinking":
|
|
3753
|
+
return "partial_thinking";
|
|
3754
|
+
case "tool_call":
|
|
3755
|
+
return "partial_tool_call";
|
|
3756
|
+
case "tool_call_output":
|
|
3757
|
+
return "partial_tool_call_output";
|
|
3758
|
+
default:
|
|
3759
|
+
return null;
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
function fragmentKey(origin, kind, toolCallId) {
|
|
3763
|
+
return `${(origin ?? []).join("/")}\0${kind}::${toolCallId}`;
|
|
3764
|
+
}
|
|
3765
|
+
function toolCallIdOf(p) {
|
|
3766
|
+
const id = p.tool_call_id;
|
|
3767
|
+
return typeof id === "string" ? id : "";
|
|
3768
|
+
}
|
|
3769
|
+
function appendDelta(frag, p) {
|
|
3770
|
+
switch (p.type) {
|
|
3771
|
+
case "partial_text":
|
|
3772
|
+
frag.buffer += p.text;
|
|
3773
|
+
break;
|
|
3774
|
+
case "partial_thinking":
|
|
3775
|
+
frag.buffer += p.thinking;
|
|
3776
|
+
break;
|
|
3777
|
+
case "partial_tool_call":
|
|
3778
|
+
frag.buffer += p.arguments;
|
|
3779
|
+
if (p.name) frag.name = p.name;
|
|
3780
|
+
frag.toolCallId = p.tool_call_id;
|
|
3781
|
+
break;
|
|
3782
|
+
case "partial_tool_call_output":
|
|
3783
|
+
frag.buffer += p.output;
|
|
3784
|
+
if (p.images && p.images.length > 0) frag.images = p.images;
|
|
3785
|
+
frag.toolCallId = p.tool_call_id;
|
|
3786
|
+
break;
|
|
3787
|
+
}
|
|
3788
|
+
if (frag.buffer.length > FRAGMENT_CAP + FRAGMENT_CAP_SLACK) {
|
|
3789
|
+
frag.buffer = frag.buffer.slice(frag.buffer.length - FRAGMENT_CAP);
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
function startPayload(frag) {
|
|
3793
|
+
switch (frag.kind) {
|
|
3794
|
+
case "partial_text":
|
|
3795
|
+
return { type: "partial_text", role: "assistant", event_type: "start", text: frag.buffer };
|
|
3796
|
+
case "partial_thinking":
|
|
3797
|
+
return {
|
|
3798
|
+
type: "partial_thinking",
|
|
3799
|
+
role: "assistant",
|
|
3800
|
+
event_type: "start",
|
|
3801
|
+
thinking: frag.buffer
|
|
3802
|
+
};
|
|
3803
|
+
case "partial_tool_call":
|
|
3804
|
+
return {
|
|
3805
|
+
type: "partial_tool_call",
|
|
3806
|
+
role: "assistant",
|
|
3807
|
+
event_type: "start",
|
|
3808
|
+
name: frag.name ?? "",
|
|
3809
|
+
arguments: frag.buffer,
|
|
3810
|
+
tool_call_id: frag.toolCallId ?? ""
|
|
3811
|
+
};
|
|
3812
|
+
case "partial_tool_call_output":
|
|
3813
|
+
return {
|
|
3814
|
+
type: "partial_tool_call_output",
|
|
3815
|
+
role: "user",
|
|
3816
|
+
event_type: "start",
|
|
3817
|
+
output: frag.buffer,
|
|
3818
|
+
...frag.images !== void 0 && frag.images.length > 0 ? { images: frag.images } : {},
|
|
3819
|
+
tool_call_id: frag.toolCallId ?? ""
|
|
3820
|
+
};
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
var LiveTailTracker = class {
|
|
3824
|
+
/** sessionId → open fragments keyed by fragmentKey, in the order they were opened. */
|
|
3825
|
+
sessions = /* @__PURE__ */ new Map();
|
|
3826
|
+
/** Feed one published message (call in the same synchronous tick as the channel publish). */
|
|
3827
|
+
observe(sessionId, msg) {
|
|
3828
|
+
if (msg.type !== "model_msg") return;
|
|
3829
|
+
const p = msg.payload;
|
|
3830
|
+
if (!isPartialPayload(p)) {
|
|
3831
|
+
const kind = partialKindFor(p);
|
|
3832
|
+
if (kind === null) return;
|
|
3833
|
+
const open3 = this.sessions.get(sessionId);
|
|
3834
|
+
if (!open3) return;
|
|
3835
|
+
open3.delete(fragmentKey(msg.origin, kind, toolCallIdOf(p)));
|
|
3836
|
+
if (open3.size === 0) this.sessions.delete(sessionId);
|
|
3837
|
+
return;
|
|
3838
|
+
}
|
|
3839
|
+
const key = fragmentKey(msg.origin, p.type, toolCallIdOf(p));
|
|
3840
|
+
let open2 = this.sessions.get(sessionId);
|
|
3841
|
+
if (p.event_type === "start") {
|
|
3842
|
+
if (!open2) {
|
|
3843
|
+
open2 = /* @__PURE__ */ new Map();
|
|
3844
|
+
this.sessions.set(sessionId, open2);
|
|
3845
|
+
}
|
|
3846
|
+
const frag2 = {
|
|
3847
|
+
kind: p.type,
|
|
3848
|
+
timestamp: msg.timestamp,
|
|
3849
|
+
buffer: "",
|
|
3850
|
+
...msg.origin && msg.origin.length > 0 ? { origin: [...msg.origin] } : {}
|
|
3851
|
+
};
|
|
3852
|
+
open2.delete(key);
|
|
3853
|
+
open2.set(key, frag2);
|
|
3854
|
+
appendDelta(frag2, p);
|
|
3855
|
+
return;
|
|
3856
|
+
}
|
|
3857
|
+
let frag = open2?.get(key);
|
|
3858
|
+
if (!frag) {
|
|
3859
|
+
if (p.event_type === "stop") return;
|
|
3860
|
+
if (!open2) {
|
|
3861
|
+
open2 = /* @__PURE__ */ new Map();
|
|
3862
|
+
this.sessions.set(sessionId, open2);
|
|
3863
|
+
}
|
|
3864
|
+
frag = {
|
|
3865
|
+
kind: p.type,
|
|
3866
|
+
timestamp: msg.timestamp,
|
|
3867
|
+
buffer: "",
|
|
3868
|
+
...msg.origin && msg.origin.length > 0 ? { origin: [...msg.origin] } : {}
|
|
3869
|
+
};
|
|
3870
|
+
open2.set(key, frag);
|
|
3871
|
+
}
|
|
3872
|
+
appendDelta(frag, p);
|
|
3873
|
+
if (p.event_type === "stop") {
|
|
3874
|
+
open2.delete(key);
|
|
3875
|
+
if (open2.size === 0) this.sessions.delete(sessionId);
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
/**
|
|
3879
|
+
* Synthetic `partial_* start` messages for every open fragment (in open order), each
|
|
3880
|
+
* carrying the full accumulated content, the original origin chain, and the original
|
|
3881
|
+
* start timestamp. Empty when the session has no open fragments.
|
|
3882
|
+
*/
|
|
3883
|
+
fragments(sessionId) {
|
|
3884
|
+
const open2 = this.sessions.get(sessionId);
|
|
3885
|
+
if (!open2) return [];
|
|
3886
|
+
const out = [];
|
|
3887
|
+
for (const frag of open2.values()) {
|
|
3888
|
+
out.push({
|
|
3889
|
+
timestamp: frag.timestamp,
|
|
3890
|
+
type: "model_msg",
|
|
3891
|
+
payload: startPayload(frag),
|
|
3892
|
+
...frag.origin !== void 0 ? { origin: [...frag.origin] } : {}
|
|
3893
|
+
});
|
|
3894
|
+
}
|
|
3895
|
+
return out;
|
|
3896
|
+
}
|
|
3897
|
+
/** Drop all fragment state for a session (the run ended; nothing will continue these fragments). */
|
|
3898
|
+
clear(sessionId) {
|
|
3899
|
+
this.sessions.delete(sessionId);
|
|
3900
|
+
}
|
|
3901
|
+
};
|
|
3902
|
+
|
|
3253
3903
|
// src/runtime/session-sources.ts
|
|
3254
3904
|
function asSessionSource(v) {
|
|
3255
3905
|
return v === "schedule" || v === "subagent" ? v : void 0;
|
|
@@ -3277,6 +3927,9 @@ var TOOL_NAMES_MAX = 1e3;
|
|
|
3277
3927
|
var ORIGIN_CTX_MAX = 200;
|
|
3278
3928
|
var LLM_FAILURES = {
|
|
3279
3929
|
failed: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
|
|
3930
|
+
// Credentials rejection shares the failed bucket (no new taxonomy): same code/kind, the
|
|
3931
|
+
// real reason arrives via the abort reason or the event's own message as usual.
|
|
3932
|
+
auth: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
|
|
3280
3933
|
timeout: {
|
|
3281
3934
|
code: "llm_timeout",
|
|
3282
3935
|
kind: "expected",
|
|
@@ -3289,7 +3942,7 @@ var LLM_FAILURES = {
|
|
|
3289
3942
|
}
|
|
3290
3943
|
};
|
|
3291
3944
|
function isLlmFailure(s) {
|
|
3292
|
-
return s === "failed" || s === "timeout" || s === "malformed";
|
|
3945
|
+
return s === "failed" || s === "timeout" || s === "malformed" || s === "auth";
|
|
3293
3946
|
}
|
|
3294
3947
|
function isToolFailure(s) {
|
|
3295
3948
|
return s === "failed" || s === "timeout";
|
|
@@ -3313,7 +3966,7 @@ var StreamErrorWatcher = class {
|
|
|
3313
3966
|
}
|
|
3314
3967
|
errors;
|
|
3315
3968
|
ctx;
|
|
3316
|
-
/** LLM failures awaiting a real reason: origin → failure state (see file header; each session has at most one in-flight Request). */
|
|
3969
|
+
/** LLM failures awaiting a real reason: origin → failure state + the request_end's own detail (see file header; each session has at most one in-flight Request). */
|
|
3317
3970
|
pending = /* @__PURE__ */ new Map();
|
|
3318
3971
|
/** Names of in-flight tool calls: `origin \0 tool_call_id` → tool name (dequeued once output arrives). */
|
|
3319
3972
|
toolNames = /* @__PURE__ */ new Map();
|
|
@@ -3377,7 +4030,14 @@ var StreamErrorWatcher = class {
|
|
|
3377
4030
|
const key = originKey(msg);
|
|
3378
4031
|
if (p.type === "request_end") {
|
|
3379
4032
|
this.flush(key);
|
|
3380
|
-
if (isLlmFailure(p.status))
|
|
4033
|
+
if (isLlmFailure(p.status)) {
|
|
4034
|
+
this.pending.set(key, {
|
|
4035
|
+
status: p.status,
|
|
4036
|
+
// The event's own failure detail (LLMOutcome.message): the message of record when
|
|
4037
|
+
// no abort follows (the retry path).
|
|
4038
|
+
...typeof p.message === "string" && p.message.trim() ? { message: p.message } : {}
|
|
4039
|
+
});
|
|
4040
|
+
}
|
|
3381
4041
|
return;
|
|
3382
4042
|
}
|
|
3383
4043
|
if (p.type === "request_begin") {
|
|
@@ -3395,12 +4055,12 @@ var StreamErrorWatcher = class {
|
|
|
3395
4055
|
* up from it (see file header).
|
|
3396
4056
|
*/
|
|
3397
4057
|
flush(key, reason) {
|
|
3398
|
-
const
|
|
3399
|
-
if (
|
|
4058
|
+
const entry = this.pending.get(key);
|
|
4059
|
+
if (entry === void 0) return;
|
|
3400
4060
|
this.pending.delete(key);
|
|
3401
|
-
const spec = LLM_FAILURES[status];
|
|
4061
|
+
const spec = LLM_FAILURES[entry.status];
|
|
3402
4062
|
const trimmed = reason?.trim();
|
|
3403
|
-
const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : spec.text;
|
|
4063
|
+
const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : entry.message ?? spec.text;
|
|
3404
4064
|
this.errors.record({
|
|
3405
4065
|
source: "llm",
|
|
3406
4066
|
err: message,
|
|
@@ -3564,6 +4224,8 @@ var SessionManager = class {
|
|
|
3564
4224
|
deletingSessions = /* @__PURE__ */ new Set();
|
|
3565
4225
|
/** Per-Agent config generation (key = agentKey), bumped by invalidateAgentRuntimes on vault updates. */
|
|
3566
4226
|
agentGenerations = /* @__PURE__ */ new Map();
|
|
4227
|
+
/** Open streaming fragments of running sessions (fed by drive, served to GET /messages; see live-tail.ts). */
|
|
4228
|
+
liveTail = new LiveTailTracker();
|
|
3567
4229
|
sweepTimer;
|
|
3568
4230
|
// —— Query surface (used by Session listing / Agent active-count / SSE subscription replay) ——
|
|
3569
4231
|
statusOf(sessionId) {
|
|
@@ -3575,6 +4237,19 @@ var SessionManager = class {
|
|
|
3575
4237
|
pendingApprovals(sessionId) {
|
|
3576
4238
|
return this.entries.get(sessionId)?.approvals.list() ?? [];
|
|
3577
4239
|
}
|
|
4240
|
+
/** Number of queued follow-up tasks (`queueIfBusy`) awaiting auto-start. */
|
|
4241
|
+
pendingFollowUpCount(sessionId) {
|
|
4242
|
+
return this.entries.get(sessionId)?.followUps.length ?? 0;
|
|
4243
|
+
}
|
|
4244
|
+
/**
|
|
4245
|
+
* Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
|
|
4246
|
+
* streaming fragment, carrying the full accumulated content so far (see live-tail.ts).
|
|
4247
|
+
* Empty when idle or when nothing is streaming. GET /messages attaches this (with a
|
|
4248
|
+
* channel cursor) so a client joining mid-stream can render the in-progress message.
|
|
4249
|
+
*/
|
|
4250
|
+
liveFragments(sessionId) {
|
|
4251
|
+
return this.liveTail.fragments(sessionId);
|
|
4252
|
+
}
|
|
3578
4253
|
/** Number of Sessions for this Agent that are currently running / compacting. */
|
|
3579
4254
|
activeCountForAgent(projectId, agentId) {
|
|
3580
4255
|
let n = 0;
|
|
@@ -3597,6 +4272,7 @@ var SessionManager = class {
|
|
|
3597
4272
|
abort: null,
|
|
3598
4273
|
running: null,
|
|
3599
4274
|
generation: this.generationOf(row.projectId, row.agentId),
|
|
4275
|
+
followUps: [],
|
|
3600
4276
|
lastActivityMs: Date.now()
|
|
3601
4277
|
});
|
|
3602
4278
|
}
|
|
@@ -3613,28 +4289,77 @@ var SessionManager = class {
|
|
|
3613
4289
|
const key = agentKey(projectId, agentId);
|
|
3614
4290
|
this.agentGenerations.set(key, this.generationOf(projectId, agentId) + 1);
|
|
3615
4291
|
}
|
|
4292
|
+
/**
|
|
4293
|
+
* After a Project's models/credentials change: invalidate every cached runtime in this
|
|
4294
|
+
* Project, so the next Task re-resumes with the new api_key / base_url. Same
|
|
4295
|
+
* effective-value semantics as invalidateAgentRuntimes — no hot swap into a Task already
|
|
4296
|
+
* in flight. Iterating the active table is complete here, not a shortcut: the generation
|
|
4297
|
+
* map only matters for runtimes that are already cached, and an Agent with no cached
|
|
4298
|
+
* entry builds fresh through the loader anyway.
|
|
4299
|
+
*/
|
|
4300
|
+
invalidateProjectRuntimes(projectId) {
|
|
4301
|
+
const agentIds = /* @__PURE__ */ new Set();
|
|
4302
|
+
for (const e of this.entries.values()) {
|
|
4303
|
+
if (e.projectId === projectId) agentIds.add(e.agentId);
|
|
4304
|
+
}
|
|
4305
|
+
for (const agentId of agentIds) this.invalidateAgentRuntimes(projectId, agentId);
|
|
4306
|
+
}
|
|
3616
4307
|
// —— Task / compaction drive ——
|
|
3617
4308
|
/**
|
|
3618
4309
|
* Start a Task: get-or-load → 409
|
|
3619
4310
|
* mutual-exclusion check → publish the input messages first → drive run in the
|
|
3620
4311
|
* background. Returns the current actual session_id (the new id after self-heal).
|
|
4312
|
+
* `opts.thinkingLevel` (optional, validated by the route) rides into this run's
|
|
4313
|
+
* `session.run` options — a per-turn parameter, applied to this Task only.
|
|
4314
|
+
* With `queueIfBusy`, a busy session (running/compacting) enqueues the input as a
|
|
4315
|
+
* follow-up instead of 409: it auto-starts as an ordinary next task once the session
|
|
4316
|
+
* returns to idle (`queued: true` in the result; see startQueuedFollowUp), keeping its
|
|
4317
|
+
* thinkingLevel for that auto-start.
|
|
3621
4318
|
*/
|
|
3622
|
-
async startTask(sessionId, input) {
|
|
4319
|
+
async startTask(sessionId, input, opts) {
|
|
3623
4320
|
return this.withLock(sessionId, async () => {
|
|
3624
4321
|
this.assertOpen();
|
|
3625
4322
|
this.assertAgentNotDeleting(sessionId);
|
|
3626
4323
|
this.assertSessionNotDeleting(sessionId);
|
|
3627
4324
|
const entry = await this.ensureEntry(sessionId);
|
|
4325
|
+
if (entry.status !== "idle" && opts?.queueIfBusy) {
|
|
4326
|
+
entry.followUps.push({
|
|
4327
|
+
input,
|
|
4328
|
+
...opts.thinkingLevel !== void 0 ? { thinkingLevel: opts.thinkingLevel } : {}
|
|
4329
|
+
});
|
|
4330
|
+
entry.lastActivityMs = Date.now();
|
|
4331
|
+
this.publishState(entry, entry.status);
|
|
4332
|
+
return { sessionId: entry.sessionId, queued: true };
|
|
4333
|
+
}
|
|
3628
4334
|
this.assertIdle(entry);
|
|
3629
|
-
|
|
4335
|
+
this.launchTask(entry, input, opts?.thinkingLevel);
|
|
4336
|
+
return { sessionId: entry.sessionId, queued: false };
|
|
4337
|
+
});
|
|
4338
|
+
}
|
|
4339
|
+
/**
|
|
4340
|
+
* Start a goal run: like startTask, but the run is core goal mode — one
|
|
4341
|
+
* `session.run(input, { goal })` call loops rounds until a terminal state, and the
|
|
4342
|
+
* Session stays `running` for the whole goal (every round), so the existing abort
|
|
4343
|
+
* endpoint interrupts the entire loop and schedules queue behind it as usual. Round
|
|
4344
|
+
* inputs are yielded by core and published like any streamed message; progress
|
|
4345
|
+
* additionally goes out as goal_* server events and into goal_state (when a repo is
|
|
4346
|
+
* wired).
|
|
4347
|
+
*/
|
|
4348
|
+
async startGoal(sessionId, args) {
|
|
4349
|
+
return this.withLock(sessionId, async () => {
|
|
4350
|
+
this.assertOpen();
|
|
4351
|
+
this.assertAgentNotDeleting(sessionId);
|
|
4352
|
+
this.assertSessionNotDeleting(sessionId);
|
|
4353
|
+
const entry = await this.ensureEntry(sessionId);
|
|
4354
|
+
this.assertIdle(entry);
|
|
4355
|
+
const text = args.input.filter(isPlainText("user")).map((m) => m.payload.text).join("\n");
|
|
4356
|
+
const objective = stripLeadingMarkerBlocks(text).trim() || text.trim();
|
|
3630
4357
|
const ac = new AbortController();
|
|
3631
4358
|
entry.status = "running";
|
|
3632
4359
|
entry.abort = ac;
|
|
3633
4360
|
entry.lastActivityMs = Date.now();
|
|
3634
|
-
for (const msg of input) channel.publish(msg);
|
|
3635
4361
|
this.publishState(entry, "running");
|
|
3636
4362
|
const approve = makeApprove({
|
|
3637
|
-
// Re-reads approval_mode from the DB on every decision (a PATCH takes effect immediately).
|
|
3638
4363
|
getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
|
|
3639
4364
|
toolPermission: (name) => entry.session.toolPermission(name),
|
|
3640
4365
|
registry: entry.approvals,
|
|
@@ -3644,12 +4369,153 @@ var SessionManager = class {
|
|
|
3644
4369
|
...pending.origin !== void 0 ? { origin: pending.origin } : {}
|
|
3645
4370
|
})
|
|
3646
4371
|
});
|
|
3647
|
-
const
|
|
3648
|
-
|
|
3649
|
-
|
|
4372
|
+
const goalId = this.deps.goals?.create({
|
|
4373
|
+
sessionId: entry.sessionId,
|
|
4374
|
+
projectId: entry.projectId,
|
|
4375
|
+
agentId: entry.agentId,
|
|
4376
|
+
objective,
|
|
4377
|
+
budget: args.budget
|
|
4378
|
+
});
|
|
4379
|
+
this.publishEvent(entry, {
|
|
4380
|
+
type: "goal_started",
|
|
4381
|
+
sessionId: entry.sessionId,
|
|
4382
|
+
objective,
|
|
4383
|
+
budget: args.budget
|
|
4384
|
+
});
|
|
4385
|
+
const gen = this.goalStream(entry, {
|
|
4386
|
+
input: args.input,
|
|
4387
|
+
budget: args.budget,
|
|
4388
|
+
...args.thinkingLevel !== void 0 ? { thinkingLevel: args.thinkingLevel } : {},
|
|
4389
|
+
approve,
|
|
4390
|
+
signal: ac.signal,
|
|
4391
|
+
...goalId !== void 0 ? { goalId } : {}
|
|
4392
|
+
});
|
|
4393
|
+
entry.running = this.drive(entry, gen, { userExcerpt: objective });
|
|
3650
4394
|
return { sessionId: entry.sessionId };
|
|
3651
4395
|
});
|
|
3652
4396
|
}
|
|
4397
|
+
/**
|
|
4398
|
+
* Taps core's goal-mode stream for `drive`: round boundaries (the injected `[goal]`
|
|
4399
|
+
* inputs) become goal_round events + goal_state refreshes, and the terminal
|
|
4400
|
+
* `goal_finished` event message becomes the goal_finished server event + the run-state
|
|
4401
|
+
* row's final status. Token numbers mirror core's own accounting (same
|
|
4402
|
+
* `goalTokenDelta`), so the UI shows exactly what the budget check uses.
|
|
4403
|
+
*/
|
|
4404
|
+
async *goalStream(entry, args) {
|
|
4405
|
+
const gen = entry.session.run(args.input, {
|
|
4406
|
+
approve: args.approve,
|
|
4407
|
+
signal: args.signal,
|
|
4408
|
+
...args.thinkingLevel !== void 0 ? { thinkingLevel: args.thinkingLevel } : {},
|
|
4409
|
+
goal: { budget: args.budget }
|
|
4410
|
+
});
|
|
4411
|
+
let round = 0;
|
|
4412
|
+
let used = 0;
|
|
4413
|
+
let finished = false;
|
|
4414
|
+
try {
|
|
4415
|
+
for await (const msg of gen) {
|
|
4416
|
+
used += goalTokenDelta(msg);
|
|
4417
|
+
if (isGoalRoundInput(msg)) {
|
|
4418
|
+
round++;
|
|
4419
|
+
if (args.goalId !== void 0) this.deps.goals?.progress(args.goalId, round, used);
|
|
4420
|
+
this.publishEvent(entry, {
|
|
4421
|
+
type: "goal_round",
|
|
4422
|
+
sessionId: entry.sessionId,
|
|
4423
|
+
round,
|
|
4424
|
+
used,
|
|
4425
|
+
budget: args.budget
|
|
4426
|
+
});
|
|
4427
|
+
}
|
|
4428
|
+
const outcome = goalFinishedOf(msg);
|
|
4429
|
+
if (outcome) {
|
|
4430
|
+
finished = true;
|
|
4431
|
+
if (args.goalId !== void 0) {
|
|
4432
|
+
this.deps.goals?.finish(
|
|
4433
|
+
args.goalId,
|
|
4434
|
+
outcome.outcome,
|
|
4435
|
+
outcome.rounds,
|
|
4436
|
+
outcome.tokensUsed
|
|
4437
|
+
);
|
|
4438
|
+
}
|
|
4439
|
+
this.publishEvent(entry, {
|
|
4440
|
+
type: "goal_finished",
|
|
4441
|
+
sessionId: entry.sessionId,
|
|
4442
|
+
outcome: outcome.outcome,
|
|
4443
|
+
rounds: outcome.rounds,
|
|
4444
|
+
used: outcome.tokensUsed
|
|
4445
|
+
});
|
|
4446
|
+
}
|
|
4447
|
+
yield msg;
|
|
4448
|
+
}
|
|
4449
|
+
if (!finished) {
|
|
4450
|
+
this.finishAborted(entry, args.goalId, round, used);
|
|
4451
|
+
}
|
|
4452
|
+
} catch (err) {
|
|
4453
|
+
if (!finished) this.finishAborted(entry, args.goalId, round, used);
|
|
4454
|
+
throw err;
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
/** Closes a goal's run state as aborted (stream cut off / infrastructure failure). */
|
|
4458
|
+
finishAborted(entry, goalId, round, used) {
|
|
4459
|
+
if (goalId !== void 0) this.deps.goals?.finish(goalId, "aborted", round, used);
|
|
4460
|
+
this.publishEvent(entry, {
|
|
4461
|
+
type: "goal_finished",
|
|
4462
|
+
sessionId: entry.sessionId,
|
|
4463
|
+
outcome: "aborted",
|
|
4464
|
+
rounds: round,
|
|
4465
|
+
used
|
|
4466
|
+
});
|
|
4467
|
+
}
|
|
4468
|
+
/** Shared task launch (fresh tasks and auto-started follow-ups): flips to running, publishes the input, and drives the run with the per-turn thinking level (if any). Caller holds the session lock and has verified idle. */
|
|
4469
|
+
launchTask(entry, input, thinkingLevel) {
|
|
4470
|
+
const channel = this.deps.channels.get(entry.sessionId);
|
|
4471
|
+
const ac = new AbortController();
|
|
4472
|
+
entry.status = "running";
|
|
4473
|
+
entry.abort = ac;
|
|
4474
|
+
entry.lastActivityMs = Date.now();
|
|
4475
|
+
for (const msg of input) channel.publish(msg);
|
|
4476
|
+
this.publishState(entry, "running");
|
|
4477
|
+
const approve = makeApprove({
|
|
4478
|
+
// Re-reads approval_mode from the DB on every decision (a PATCH takes effect immediately).
|
|
4479
|
+
getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
|
|
4480
|
+
toolPermission: (name) => entry.session.toolPermission(name),
|
|
4481
|
+
registry: entry.approvals,
|
|
4482
|
+
publishRequest: (pending) => this.publishEvent(entry, {
|
|
4483
|
+
type: "approval_request",
|
|
4484
|
+
toolCall: pending.toolCall,
|
|
4485
|
+
...pending.origin !== void 0 ? { origin: pending.origin } : {}
|
|
4486
|
+
})
|
|
4487
|
+
});
|
|
4488
|
+
const gen = entry.session.run(input, {
|
|
4489
|
+
approve,
|
|
4490
|
+
signal: ac.signal,
|
|
4491
|
+
...thinkingLevel !== void 0 ? { thinkingLevel } : {}
|
|
4492
|
+
});
|
|
4493
|
+
const userExcerpt = input.filter(isPlainText("user")).map((m) => m.payload.text).join("\n");
|
|
4494
|
+
entry.running = this.drive(entry, gen, { userExcerpt });
|
|
4495
|
+
}
|
|
4496
|
+
/**
|
|
4497
|
+
* Auto-start of queued follow-ups: called from drive's finally whenever a Task or
|
|
4498
|
+
* compaction finishes (abort included — follow-ups are future tasks the user queued, so
|
|
4499
|
+
* an abort of the current run does not discard them). Re-validates everything under the
|
|
4500
|
+
* session lock (another task may have snuck in, the server may be shutting down, the
|
|
4501
|
+
* session may be getting deleted) and launches exactly one queued input — the next
|
|
4502
|
+
* finish picks up the one after, keeping order and one-at-a-time semantics.
|
|
4503
|
+
*/
|
|
4504
|
+
async startQueuedFollowUp(sessionId) {
|
|
4505
|
+
try {
|
|
4506
|
+
await this.withLock(sessionId, async () => {
|
|
4507
|
+
if (this.closed || this.deletingSessions.has(sessionId)) return;
|
|
4508
|
+
const row = this.deps.sessions.findById(sessionId);
|
|
4509
|
+
if (row && this.deletingAgents.has(agentKey(row.projectId, row.agentId))) return;
|
|
4510
|
+
const entry = this.entries.get(sessionId);
|
|
4511
|
+
if (!entry || entry.status !== "idle" || entry.followUps.length === 0) return;
|
|
4512
|
+
const next = entry.followUps.shift();
|
|
4513
|
+
this.launchTask(entry, next.input, next.thinkingLevel);
|
|
4514
|
+
});
|
|
4515
|
+
} catch (err) {
|
|
4516
|
+
this.log(`[followup] auto-start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4517
|
+
}
|
|
4518
|
+
}
|
|
3653
4519
|
/** Manually compact the context: 409 if already running; compaction output also flows into the SSE channel. */
|
|
3654
4520
|
async startCompact(sessionId) {
|
|
3655
4521
|
return this.withLock(sessionId, async () => {
|
|
@@ -3676,6 +4542,39 @@ var SessionManager = class {
|
|
|
3676
4542
|
if (!entry) return false;
|
|
3677
4543
|
return entry.approvals.decide(toolCallId, decision);
|
|
3678
4544
|
}
|
|
4545
|
+
/**
|
|
4546
|
+
* Mid-run steering: forward the text to the running Session (core delivers it between
|
|
4547
|
+
* turns as a standalone `[user_steering]` user message — no SSE event of its own; the
|
|
4548
|
+
* message arrives through the stream the drive loop already publishes). 409 when the
|
|
4549
|
+
* Session isn't running a Task (idle / compacting / not loaded) or the run finished in the
|
|
4550
|
+
* race window — the caller falls back to submitting a normal task.
|
|
4551
|
+
*/
|
|
4552
|
+
steer(sessionId, text) {
|
|
4553
|
+
const entry = this.entries.get(sessionId);
|
|
4554
|
+
if (!entry || entry.status !== "running" || !entry.session.steer(text)) {
|
|
4555
|
+
throw new HttpError(
|
|
4556
|
+
409,
|
|
4557
|
+
"not_running",
|
|
4558
|
+
"This Session has no Task in progress; send the message as a new task instead."
|
|
4559
|
+
);
|
|
4560
|
+
}
|
|
4561
|
+
entry.lastActivityMs = Date.now();
|
|
4562
|
+
}
|
|
4563
|
+
/**
|
|
4564
|
+
* "Retry now" on the reconnect countdown: skip the in-progress backoff wait and fire
|
|
4565
|
+
* the next retry immediately (the attempt counter is unchanged — the skipped wait does
|
|
4566
|
+
* not consume an extra attempt). Returns false as a benign no-op when the session has
|
|
4567
|
+
* no active runtime or no reconnect wait is in progress — timing races (the wait
|
|
4568
|
+
* elapsed right before the click) must not surface as errors. Mirrors the steer seam:
|
|
4569
|
+
* manager → RuntimeSession → core Session → engine.
|
|
4570
|
+
*/
|
|
4571
|
+
retryNow(sessionId) {
|
|
4572
|
+
const entry = this.entries.get(sessionId);
|
|
4573
|
+
if (!entry || entry.status === "idle") return false;
|
|
4574
|
+
const skipped = entry.session.skipReconnectWait();
|
|
4575
|
+
if (skipped) entry.lastActivityMs = Date.now();
|
|
4576
|
+
return skipped;
|
|
4577
|
+
}
|
|
3679
4578
|
/**
|
|
3680
4579
|
* Interrupt the current Task/compaction: pending approvals converge to deny first,
|
|
3681
4580
|
* then the AbortSignal fires. Returns false if nothing is in progress (the route
|
|
@@ -3781,6 +4680,7 @@ var SessionManager = class {
|
|
|
3781
4680
|
sweepIdle(now = Date.now(), idleMs = ENTRY_IDLE_MS) {
|
|
3782
4681
|
for (const [key, entry] of this.entries) {
|
|
3783
4682
|
if (entry.status !== "idle" || entry.approvals.size !== 0 || entry.running !== null) continue;
|
|
4683
|
+
if (entry.followUps.length > 0) continue;
|
|
3784
4684
|
if (now - entry.lastActivityMs <= idleMs) continue;
|
|
3785
4685
|
this.entries.delete(key);
|
|
3786
4686
|
}
|
|
@@ -3872,6 +4772,7 @@ var SessionManager = class {
|
|
|
3872
4772
|
abort: null,
|
|
3873
4773
|
running: null,
|
|
3874
4774
|
generation,
|
|
4775
|
+
followUps: [],
|
|
3875
4776
|
lastActivityMs: Date.now()
|
|
3876
4777
|
};
|
|
3877
4778
|
this.entries.set(currentId, entry);
|
|
@@ -3949,6 +4850,7 @@ var SessionManager = class {
|
|
|
3949
4850
|
}
|
|
3950
4851
|
}
|
|
3951
4852
|
}
|
|
4853
|
+
this.liveTail.observe(entry.sessionId, msg);
|
|
3952
4854
|
this.deps.channels.get(entry.sessionId).publish(msg);
|
|
3953
4855
|
watcher?.observe(msg);
|
|
3954
4856
|
try {
|
|
@@ -3965,6 +4867,7 @@ var SessionManager = class {
|
|
|
3965
4867
|
this.deps.errors?.record({ source: "session", err, ctx, code: "session_run_failed" });
|
|
3966
4868
|
} finally {
|
|
3967
4869
|
watcher?.close();
|
|
4870
|
+
this.liveTail.clear(entry.sessionId);
|
|
3968
4871
|
entry.approvals.denyAll();
|
|
3969
4872
|
entry.status = "idle";
|
|
3970
4873
|
entry.abort = null;
|
|
@@ -3976,6 +4879,7 @@ var SessionManager = class {
|
|
|
3976
4879
|
fallbackText: titleSource.userExcerpt
|
|
3977
4880
|
});
|
|
3978
4881
|
}
|
|
4882
|
+
if (entry.followUps.length > 0) void this.startQueuedFollowUp(entry.sessionId);
|
|
3979
4883
|
for (const child of children.values()) {
|
|
3980
4884
|
if (!child.prompt.trim()) continue;
|
|
3981
4885
|
this.deps.titles?.maybeGenerate(
|
|
@@ -4046,7 +4950,7 @@ var SessionManager = class {
|
|
|
4046
4950
|
return child;
|
|
4047
4951
|
}
|
|
4048
4952
|
publishState(entry, state) {
|
|
4049
|
-
this.publishEvent(entry, { type: "task_state", state });
|
|
4953
|
+
this.publishEvent(entry, { type: "task_state", state, queued: entry.followUps.length });
|
|
4050
4954
|
}
|
|
4051
4955
|
publishEvent(entry, event) {
|
|
4052
4956
|
this.deps.channels.get(entry.sessionId).publish(event, "server_event");
|
|
@@ -4069,19 +4973,9 @@ var SessionManager = class {
|
|
|
4069
4973
|
// src/runtime/scheduler.ts
|
|
4070
4974
|
import { createHash as createHash3 } from "crypto";
|
|
4071
4975
|
import { readdir } from "fs/promises";
|
|
4072
|
-
import { agentsDir, userText as userText2 } from "@prismshadow/penguin-core";
|
|
4976
|
+
import { agentsDir, buildScheduledMessage, userText as userText2 } from "@prismshadow/penguin-core";
|
|
4073
4977
|
var TICK_INTERVAL_MS = 3e4;
|
|
4074
|
-
|
|
4075
|
-
return [
|
|
4076
|
-
"<scheduled_task>",
|
|
4077
|
-
"This message was sent automatically by a scheduled task; its origin is listed below and the task prompt follows.",
|
|
4078
|
-
`schedule: ${name}`,
|
|
4079
|
-
`fired_at: ${firedAt}`,
|
|
4080
|
-
"</scheduled_task>",
|
|
4081
|
-
"",
|
|
4082
|
-
prompt
|
|
4083
|
-
].join("\n");
|
|
4084
|
-
}
|
|
4978
|
+
var scheduledMessage = buildScheduledMessage;
|
|
4085
4979
|
var Scheduler = class {
|
|
4086
4980
|
constructor(deps2) {
|
|
4087
4981
|
this.deps = deps2;
|
|
@@ -4500,7 +5394,7 @@ var UsageRecorder = class {
|
|
|
4500
5394
|
};
|
|
4501
5395
|
|
|
4502
5396
|
// src/services/ids.ts
|
|
4503
|
-
import { randomBytes as
|
|
5397
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
4504
5398
|
var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
|
|
4505
5399
|
var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
|
|
4506
5400
|
var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
|
|
@@ -4585,6 +5479,7 @@ import {
|
|
|
4585
5479
|
VAULT_VALUE_MAX_LENGTH,
|
|
4586
5480
|
isValidVaultKey,
|
|
4587
5481
|
loadAgentVault,
|
|
5482
|
+
resetSystemConfigToDefaults,
|
|
4588
5483
|
saveAgentVault,
|
|
4589
5484
|
systemConfigPath
|
|
4590
5485
|
} from "@prismshadow/penguin-core";
|
|
@@ -4643,6 +5538,19 @@ var ProjectConfigService = class {
|
|
|
4643
5538
|
filePath(projectId) {
|
|
4644
5539
|
return projectConfigPath(this.root, projectId);
|
|
4645
5540
|
}
|
|
5541
|
+
/**
|
|
5542
|
+
* When the Project config (models/credentials) last changed: the config file's mtime as
|
|
5543
|
+
* ISO — an honest persistent source that survives server restarts (every models update
|
|
5544
|
+
* rewrites the file). undefined when the file doesn't exist yet.
|
|
5545
|
+
*/
|
|
5546
|
+
async configUpdatedAt(projectId) {
|
|
5547
|
+
try {
|
|
5548
|
+
const st = await fs9.stat(this.filePath(projectId));
|
|
5549
|
+
return st.mtime.toISOString();
|
|
5550
|
+
} catch {
|
|
5551
|
+
return void 0;
|
|
5552
|
+
}
|
|
5553
|
+
}
|
|
4646
5554
|
/** Reads the raw TOML object; returns an empty object if the file doesn't exist (does not write to disk). */
|
|
4647
5555
|
async readRaw(projectId) {
|
|
4648
5556
|
let raw;
|
|
@@ -4860,9 +5768,11 @@ var ProjectConfigService = class {
|
|
|
4860
5768
|
provider: ref.provider,
|
|
4861
5769
|
modelId: ref.model_id
|
|
4862
5770
|
});
|
|
5771
|
+
const updatedAt = await this.configUpdatedAt(projectId);
|
|
4863
5772
|
return {
|
|
4864
5773
|
...defaultRef !== void 0 ? { defaultModel: toDto(defaultRef) } : {},
|
|
4865
5774
|
...visionRef !== void 0 ? { visionModel: toDto(visionRef) } : {},
|
|
5775
|
+
...updatedAt !== void 0 ? { updatedAt } : {},
|
|
4866
5776
|
models
|
|
4867
5777
|
};
|
|
4868
5778
|
}
|
|
@@ -5013,7 +5923,7 @@ function probeTps(outputTokens, windowMs) {
|
|
|
5013
5923
|
}
|
|
5014
5924
|
|
|
5015
5925
|
// src/services/agent-config-service.ts
|
|
5016
|
-
var
|
|
5926
|
+
var THINKING_LEVELS2 = ["none", "low", "medium", "high", "xhigh"];
|
|
5017
5927
|
var COMPACTION_MODES = ["summarize", "discard"];
|
|
5018
5928
|
function asRecord(v) {
|
|
5019
5929
|
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
@@ -5115,6 +6025,18 @@ var AgentConfigService = class {
|
|
|
5115
6025
|
await fs10.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
|
|
5116
6026
|
}
|
|
5117
6027
|
}
|
|
6028
|
+
/**
|
|
6029
|
+
* Overwrites system_config.yaml with the current code defaults (core's
|
|
6030
|
+
* resetSystemConfigToDefaults) — same semantics as updating an installed skill:
|
|
6031
|
+
* only the identity fields (name / description / version) survive; the system
|
|
6032
|
+
* prompt, max_turns, model/compaction settings and the tool list (incl. MCP
|
|
6033
|
+
* servers) become the current defaults. AGENTS.md, skills and the vault are
|
|
6034
|
+
* untouched.
|
|
6035
|
+
*/
|
|
6036
|
+
async resetConfig(projectId, agentId) {
|
|
6037
|
+
await this.requireExists(projectId, agentId);
|
|
6038
|
+
await resetSystemConfigToDefaults(this.root, projectId, agentId);
|
|
6039
|
+
}
|
|
5118
6040
|
async applyConfigUpdate(projectId, agentId, config2) {
|
|
5119
6041
|
const cfg = config2;
|
|
5120
6042
|
const yamlPath = systemConfigPath(this.root, projectId, agentId);
|
|
@@ -5143,7 +6065,7 @@ var AgentConfigService = class {
|
|
|
5143
6065
|
);
|
|
5144
6066
|
setIfProvided(
|
|
5145
6067
|
["model", "thinking_level"],
|
|
5146
|
-
optionalEnum(model, "thinkingLevel",
|
|
6068
|
+
optionalEnum(model, "thinkingLevel", THINKING_LEVELS2)
|
|
5147
6069
|
);
|
|
5148
6070
|
setIfProvided(
|
|
5149
6071
|
["model", "timeoutMs"],
|
|
@@ -5237,6 +6159,7 @@ function validateToolsBuiltin(value) {
|
|
|
5237
6159
|
if (t.forModel !== void 0 && t.forModel !== "vision" && t.forModel !== "text-only") {
|
|
5238
6160
|
throw badRequest(`toolsBuiltin[${i}].forModel must be one of vision / text-only.`);
|
|
5239
6161
|
}
|
|
6162
|
+
optionalBoolean(t, "call_description", `toolsBuiltin[${i}].call_description`);
|
|
5240
6163
|
optionalNumber(t, "timeoutMs", {
|
|
5241
6164
|
integer: true,
|
|
5242
6165
|
positiveOrMinusOne: true,
|
|
@@ -5615,7 +6538,7 @@ var BenchmarkService = class {
|
|
|
5615
6538
|
};
|
|
5616
6539
|
|
|
5617
6540
|
// src/services/snapshot-service.ts
|
|
5618
|
-
import { randomBytes as
|
|
6541
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
5619
6542
|
import fs13 from "fs/promises";
|
|
5620
6543
|
import path12 from "path";
|
|
5621
6544
|
import * as tar from "tar";
|
|
@@ -5661,7 +6584,7 @@ var SnapshotService = class {
|
|
|
5661
6584
|
} catch {
|
|
5662
6585
|
}
|
|
5663
6586
|
await fs13.mkdir(dir, { recursive: true });
|
|
5664
|
-
const tmp = `${file}.tmp-${
|
|
6587
|
+
const tmp = `${file}.tmp-${randomBytes5(4).toString("hex")}`;
|
|
5665
6588
|
await tar.create(
|
|
5666
6589
|
{
|
|
5667
6590
|
gzip: true,
|
|
@@ -5689,7 +6612,7 @@ var SnapshotService = class {
|
|
|
5689
6612
|
async importArchive(projectId, agentId, archive, confirm) {
|
|
5690
6613
|
const current = await this.currentVersion(projectId, agentId);
|
|
5691
6614
|
const base = agentDir2(this.root, projectId, agentId);
|
|
5692
|
-
const staging = path12.join(base, `.import-${
|
|
6615
|
+
const staging = path12.join(base, `.import-${randomBytes5(6).toString("hex")}`);
|
|
5693
6616
|
await fs13.mkdir(staging, { recursive: true });
|
|
5694
6617
|
try {
|
|
5695
6618
|
const archiveFile = path12.join(staging, "archive.tar.gz");
|
|
@@ -5971,6 +6894,7 @@ var ProjectService = class {
|
|
|
5971
6894
|
this.deps.usage.deleteByProject(projectId);
|
|
5972
6895
|
this.deps.errors.deleteByProject(projectId);
|
|
5973
6896
|
this.deps.schedules.deleteByProject(projectId);
|
|
6897
|
+
this.deps.goals.deleteByProject(projectId);
|
|
5974
6898
|
await fs14.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
|
|
5975
6899
|
}
|
|
5976
6900
|
// —— Member authorization ——
|
|
@@ -6043,15 +6967,39 @@ var ProjectService = class {
|
|
|
6043
6967
|
|
|
6044
6968
|
// src/services/session-service.ts
|
|
6045
6969
|
import path13 from "path";
|
|
6046
|
-
import { readdir as readdir2 } from "fs/promises";
|
|
6970
|
+
import { open, readdir as readdir2 } from "fs/promises";
|
|
6047
6971
|
import {
|
|
6048
6972
|
createAgent as createAgent2,
|
|
6973
|
+
findLatestTraceFile as findLatestTraceFile2,
|
|
6049
6974
|
isSessionMeta as isSessionMeta4,
|
|
6975
|
+
parseTraceLines,
|
|
6050
6976
|
readTraceTolerant,
|
|
6051
6977
|
tracesDir as tracesDir2
|
|
6052
6978
|
} from "@prismshadow/penguin-core";
|
|
6053
6979
|
var TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
|
|
6054
6980
|
var SESSION_ID_TS_RE = /^session-(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-[0-9a-f]{8}$/;
|
|
6981
|
+
var TRACE_HEAD_BYTES = 256 * 1024;
|
|
6982
|
+
async function readTraceHead(filePath) {
|
|
6983
|
+
const fh = await open(filePath, "r");
|
|
6984
|
+
let text;
|
|
6985
|
+
let truncated;
|
|
6986
|
+
try {
|
|
6987
|
+
const { buffer, bytesRead } = await fh.read(
|
|
6988
|
+
Buffer.allocUnsafe(TRACE_HEAD_BYTES),
|
|
6989
|
+
0,
|
|
6990
|
+
TRACE_HEAD_BYTES,
|
|
6991
|
+
0
|
|
6992
|
+
);
|
|
6993
|
+
text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
6994
|
+
truncated = bytesRead === TRACE_HEAD_BYTES;
|
|
6995
|
+
} finally {
|
|
6996
|
+
await fh.close();
|
|
6997
|
+
}
|
|
6998
|
+
if (!truncated) return parseTraceLines(text);
|
|
6999
|
+
const nl = text.lastIndexOf("\n");
|
|
7000
|
+
if (nl === -1) return readTraceTolerant(filePath);
|
|
7001
|
+
return parseTraceLines(text.slice(0, nl + 1));
|
|
7002
|
+
}
|
|
6055
7003
|
function sessionIdCreatedAt(sessionId) {
|
|
6056
7004
|
const m = SESSION_ID_TS_RE.exec(sessionId);
|
|
6057
7005
|
if (!m) return null;
|
|
@@ -6067,10 +7015,11 @@ var SessionService = class {
|
|
|
6067
7015
|
/**
|
|
6068
7016
|
* DB row -> SessionInfo (run status and pending approval count come from session-manager).
|
|
6069
7017
|
* 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).
|
|
7018
|
+
* this process) falls back to reading the Trace head once (see sourceOf). `traces` is the
|
|
7019
|
+
* list flow's one-walk discovery result; without it a miss locates the shard itself.
|
|
6071
7020
|
*/
|
|
6072
|
-
async toInfo(row, hasTrace) {
|
|
6073
|
-
const source = await this.sourceOf(row, hasTrace);
|
|
7021
|
+
async toInfo(row, hasTrace, traces) {
|
|
7022
|
+
const source = await this.sourceOf(row, hasTrace, traces);
|
|
6074
7023
|
return {
|
|
6075
7024
|
sessionId: row.sessionId,
|
|
6076
7025
|
projectId: row.projectId,
|
|
@@ -6084,6 +7033,7 @@ var SessionService = class {
|
|
|
6084
7033
|
createdAt: row.createdAt,
|
|
6085
7034
|
status: this.deps.manager.statusOf(row.sessionId),
|
|
6086
7035
|
pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
|
|
7036
|
+
pendingFollowUpCount: this.deps.manager.pendingFollowUpCount(row.sessionId),
|
|
6087
7037
|
hasTrace,
|
|
6088
7038
|
archived: (row.archivedAt ?? null) !== null
|
|
6089
7039
|
};
|
|
@@ -6095,11 +7045,13 @@ var SessionService = class {
|
|
|
6095
7045
|
* session_meta is read once and cached. A Session with no Trace yet stays unknown and
|
|
6096
7046
|
* is NOT cached negatively — its meta may appear with the first run.
|
|
6097
7047
|
*/
|
|
6098
|
-
async sourceOf(row, hasTrace) {
|
|
7048
|
+
async sourceOf(row, hasTrace, traces) {
|
|
6099
7049
|
const known = this.deps.sources.get(row.sessionId);
|
|
6100
7050
|
if (known !== void 0) return known ?? void 0;
|
|
6101
7051
|
if (!hasTrace) return void 0;
|
|
6102
|
-
const
|
|
7052
|
+
const location = traces?.get(row.sessionId) ?? (await this.discoverTraces(row.projectId, row.agentId)).get(row.sessionId);
|
|
7053
|
+
if (!location) return void 0;
|
|
7054
|
+
const meta = await this.readTraceMeta(location);
|
|
6103
7055
|
if (!meta) return void 0;
|
|
6104
7056
|
const source = asSessionSource(meta.payload.source) ?? null;
|
|
6105
7057
|
this.deps.sources.set(row.sessionId, source);
|
|
@@ -6107,8 +7059,17 @@ var SessionService = class {
|
|
|
6107
7059
|
}
|
|
6108
7060
|
/** Whether this Session already has a Trace record (a Task has been run). */
|
|
6109
7061
|
async hasTrace(row) {
|
|
6110
|
-
|
|
6111
|
-
|
|
7062
|
+
return (await this.discoverTraces(row.projectId, row.agentId)).has(row.sessionId);
|
|
7063
|
+
}
|
|
7064
|
+
/**
|
|
7065
|
+
* The list category of a row: archived wins (an explicit user action), then the
|
|
7066
|
+
* origin's bucket, and no/unknown source is `active` — the same precedence the
|
|
7067
|
+
* sidebar's partition applies to loaded rows, so server filtering and client
|
|
7068
|
+
* rendering can never disagree.
|
|
7069
|
+
*/
|
|
7070
|
+
async categoryOf(row, hasTrace, traces) {
|
|
7071
|
+
if ((row.archivedAt ?? null) !== null) return "archived";
|
|
7072
|
+
return await this.sourceOf(row, hasTrace, traces) ?? "active";
|
|
6112
7073
|
}
|
|
6113
7074
|
/**
|
|
6114
7075
|
* List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional
|
|
@@ -6116,22 +7077,58 @@ var SessionService = class {
|
|
|
6116
7077
|
* more"); slicing happens before toInfo, so per-request source derivation (lazy
|
|
6117
7078
|
* Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
|
|
6118
7079
|
* the whole directory — the union and global ordering need every id.
|
|
7080
|
+
*
|
|
7081
|
+
* `category` filters to one sidebar bucket **before** paging, so offset/limit page
|
|
7082
|
+
* within the category. Filtering needs each walked row's category (a possible
|
|
7083
|
+
* Trace-head read per row, cached in the sources registry); without `withCounts`
|
|
7084
|
+
* the walk stops as soon as the requested page is complete. `withCounts` classifies
|
|
7085
|
+
* every row and returns per-category totals over the whole list — plus the same
|
|
7086
|
+
* totals broken down by Workspace path — so the sidebar can label the collapsed
|
|
7087
|
+
* folders (and a workspace group can know its own share) without loading them.
|
|
6119
7088
|
*/
|
|
6120
|
-
async listSessions(projectId, agentId,
|
|
6121
|
-
const
|
|
7089
|
+
async listSessions(projectId, agentId, opts = {}) {
|
|
7090
|
+
const { paging, category, withCounts } = opts;
|
|
7091
|
+
const traces = await this.discoverTraces(projectId, agentId);
|
|
6122
7092
|
const rows = new Map(
|
|
6123
7093
|
this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r])
|
|
6124
7094
|
);
|
|
6125
|
-
for (const sessionId of
|
|
7095
|
+
for (const [sessionId, location] of traces) {
|
|
6126
7096
|
if (rows.has(sessionId)) continue;
|
|
6127
|
-
const discovered = await this.adoptTraceSession(projectId, agentId, sessionId);
|
|
7097
|
+
const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
|
|
6128
7098
|
if (discovered) rows.set(sessionId, discovered);
|
|
6129
7099
|
}
|
|
6130
7100
|
const sorted = [...rows.values()].sort(
|
|
6131
7101
|
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId)
|
|
6132
7102
|
);
|
|
6133
|
-
const
|
|
6134
|
-
|
|
7103
|
+
const toPage = (page) => Promise.all(page.map((row) => this.toInfo(row, traces.has(row.sessionId), traces)));
|
|
7104
|
+
if (category === void 0 && !withCounts) {
|
|
7105
|
+
return {
|
|
7106
|
+
sessions: await toPage(
|
|
7107
|
+
paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted
|
|
7108
|
+
)
|
|
7109
|
+
};
|
|
7110
|
+
}
|
|
7111
|
+
const want = paging ? paging.offset + paging.limit : Infinity;
|
|
7112
|
+
const counts = { active: 0, subagent: 0, schedule: 0, archived: 0 };
|
|
7113
|
+
const workspaceCounts = {};
|
|
7114
|
+
const matched = [];
|
|
7115
|
+
for (const row of sorted) {
|
|
7116
|
+
if (!withCounts && matched.length >= want) break;
|
|
7117
|
+
const cat = await this.categoryOf(row, traces.has(row.sessionId), traces);
|
|
7118
|
+
counts[cat] += 1;
|
|
7119
|
+
if (withCounts) {
|
|
7120
|
+
const ws = workspaceCounts[row.workspace] ??= {
|
|
7121
|
+
active: 0,
|
|
7122
|
+
subagent: 0,
|
|
7123
|
+
schedule: 0,
|
|
7124
|
+
archived: 0
|
|
7125
|
+
};
|
|
7126
|
+
ws[cat] += 1;
|
|
7127
|
+
}
|
|
7128
|
+
if ((category === void 0 || cat === category) && matched.length < want) matched.push(row);
|
|
7129
|
+
}
|
|
7130
|
+
const sessions = await toPage(paging ? matched.slice(paging.offset, want) : matched);
|
|
7131
|
+
return withCounts ? { sessions, counts, workspaceCounts } : { sessions };
|
|
6135
7132
|
}
|
|
6136
7133
|
/**
|
|
6137
7134
|
* Session stats (Agents list card): total count = size of the union of DB index
|
|
@@ -6243,48 +7240,59 @@ var SessionService = class {
|
|
|
6243
7240
|
this.deps.manager.adopt(row, session);
|
|
6244
7241
|
return this.toInfo(row, false);
|
|
6245
7242
|
}
|
|
6246
|
-
/**
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
7243
|
+
/**
|
|
7244
|
+
* Absolute path of a Session's **latest** Trace file (the current context shard);
|
|
7245
|
+
* undefined when no Trace exists. Costs a directory walk, so only the single-session
|
|
7246
|
+
* GET includes it in the DTO (see SessionInfo.tracePath) — the web's `/model` switch
|
|
7247
|
+
* hands it to the new session's `[model_switch_from]` block so the model can read the
|
|
7248
|
+
* source history itself when it needs it.
|
|
7249
|
+
*/
|
|
7250
|
+
async latestTracePath(row) {
|
|
7251
|
+
const located = await findLatestTraceFile2(
|
|
7252
|
+
tracesDir2(this.deps.root, row.projectId, row.agentId),
|
|
7253
|
+
row.sessionId
|
|
7254
|
+
);
|
|
7255
|
+
return located?.path;
|
|
6257
7256
|
}
|
|
6258
7257
|
/**
|
|
6259
|
-
*
|
|
6260
|
-
* the original
|
|
6261
|
-
*
|
|
7258
|
+
* One walk over the Trace directory: session_id → its **earliest** shard (the shard
|
|
7259
|
+
* whose head carries the original session_meta). Discovery (which Sessions have
|
|
7260
|
+
* records) and the meta-read location come out of a single pass, so classifying every
|
|
7261
|
+
* row (`counts=1`) costs one directory walk total instead of one per Session.
|
|
6262
7262
|
*/
|
|
6263
|
-
async
|
|
7263
|
+
async discoverTraces(projectId, agentId) {
|
|
6264
7264
|
const dir = tracesDir2(this.deps.root, projectId, agentId);
|
|
6265
|
-
|
|
7265
|
+
const out = /* @__PURE__ */ new Map();
|
|
6266
7266
|
for (const dateDir of await listDirsSafe(dir)) {
|
|
6267
7267
|
for (const file of await listFilesSafe(path13.join(dir, dateDir))) {
|
|
6268
7268
|
const match = TRACE_FILE_RE.exec(file);
|
|
6269
|
-
if (!match
|
|
7269
|
+
if (!match) continue;
|
|
7270
|
+
const sessionId = match[1];
|
|
6270
7271
|
const index = Number(match[2]);
|
|
6271
|
-
|
|
6272
|
-
|
|
7272
|
+
const cur = out.get(sessionId);
|
|
7273
|
+
if (!cur || index < cur.index) {
|
|
7274
|
+
out.set(sessionId, { path: path13.join(dir, dateDir, file), index });
|
|
6273
7275
|
}
|
|
6274
7276
|
}
|
|
6275
7277
|
}
|
|
6276
|
-
|
|
7278
|
+
return out;
|
|
7279
|
+
}
|
|
7280
|
+
/**
|
|
7281
|
+
* session_meta from a located Trace shard head; null when unreadable or it has no
|
|
7282
|
+
* meta. Shared by adoption backfill and lazy `source` resolution.
|
|
7283
|
+
*/
|
|
7284
|
+
async readTraceMeta(location) {
|
|
6277
7285
|
let messages;
|
|
6278
7286
|
try {
|
|
6279
|
-
messages = await
|
|
7287
|
+
messages = await readTraceHead(location.path);
|
|
6280
7288
|
} catch {
|
|
6281
7289
|
return null;
|
|
6282
7290
|
}
|
|
6283
7291
|
return messages.find(isSessionMeta4) ?? null;
|
|
6284
7292
|
}
|
|
6285
7293
|
/** 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(
|
|
7294
|
+
async adoptTraceSession(projectId, agentId, sessionId, location) {
|
|
7295
|
+
const meta = await this.readTraceMeta(location);
|
|
6288
7296
|
if (!meta) return null;
|
|
6289
7297
|
if (typeof meta.payload.provider !== "string") return null;
|
|
6290
7298
|
this.deps.sources.set(sessionId, asSessionSource(meta.payload.source) ?? null);
|
|
@@ -6328,8 +7336,16 @@ async function listFilesSafe(dir) {
|
|
|
6328
7336
|
// src/services/trace-service.ts
|
|
6329
7337
|
import fs15 from "fs/promises";
|
|
6330
7338
|
import path14 from "path";
|
|
6331
|
-
import {
|
|
7339
|
+
import {
|
|
7340
|
+
agentsDir as agentsDir3,
|
|
7341
|
+
isSessionMeta as isSessionMeta5,
|
|
7342
|
+
parseTraceLines as parseTraceLines2,
|
|
7343
|
+
parseUserSteeringText,
|
|
7344
|
+
readTraceTolerant as readTraceTolerant2,
|
|
7345
|
+
tracesDir as tracesDir3
|
|
7346
|
+
} from "@prismshadow/penguin-core";
|
|
6332
7347
|
var TRACE_FILE_RE2 = /^(.+)_(\d{3})\.jsonl$/;
|
|
7348
|
+
var IMPORT_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
6333
7349
|
var MAX_SUBAGENT_DEPTH = 4;
|
|
6334
7350
|
function subagentPointer(msg) {
|
|
6335
7351
|
if (msg.type !== "event_msg") return null;
|
|
@@ -6519,7 +7535,9 @@ var TraceService = class {
|
|
|
6519
7535
|
const msg = messages[mi];
|
|
6520
7536
|
const p = msg.payload;
|
|
6521
7537
|
const hasOrigin = msg.origin !== void 0 && msg.origin.length > 0;
|
|
6522
|
-
const
|
|
7538
|
+
const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
|
|
7539
|
+
if (isSteeringText) continuation = true;
|
|
7540
|
+
const startsUserTurn = !hasOrigin && !compactionActive && !isSteeringText && msg.type === "model_msg" && (p.type === "text" && p.role === "user" || p.type === "image_url");
|
|
6523
7541
|
const startsCompactionTurn = !hasOrigin && msg.type === "event_msg" && p.type === "compaction_begin";
|
|
6524
7542
|
if (startsUserTurn || startsCompactionTurn) {
|
|
6525
7543
|
if (pendingFrom === null) pendingFrom = mi;
|
|
@@ -6737,6 +7755,10 @@ var TraceService = class {
|
|
|
6737
7755
|
return out;
|
|
6738
7756
|
}
|
|
6739
7757
|
async readFileByIndex(projectId, agentId, sessionId, index) {
|
|
7758
|
+
const file = await this.locateByIndex(projectId, agentId, sessionId, index);
|
|
7759
|
+
return readTraceTolerant2(file.path);
|
|
7760
|
+
}
|
|
7761
|
+
async locateByIndex(projectId, agentId, sessionId, index) {
|
|
6740
7762
|
const files = await this.locateAll(projectId, agentId, sessionId);
|
|
6741
7763
|
const file = files.find((f) => f.index === index);
|
|
6742
7764
|
if (!file) {
|
|
@@ -6746,7 +7768,165 @@ var TraceService = class {
|
|
|
6746
7768
|
`This Session has no Trace file with index ${index}.`
|
|
6747
7769
|
);
|
|
6748
7770
|
}
|
|
6749
|
-
return
|
|
7771
|
+
return file;
|
|
7772
|
+
}
|
|
7773
|
+
/** Raw bytes of the Trace file at the given index (export/download: the file is served verbatim). */
|
|
7774
|
+
async readFileRaw(projectId, agentId, sessionId, index) {
|
|
7775
|
+
const file = await this.locateByIndex(projectId, agentId, sessionId, index);
|
|
7776
|
+
return fs15.readFile(file.path);
|
|
7777
|
+
}
|
|
7778
|
+
/**
|
|
7779
|
+
* Imports an uploaded Trace file (raw JSONL content). Validates the content itself —
|
|
7780
|
+
* parseable JSONL whose first record is a `session_meta` carrying a filename-safe
|
|
7781
|
+
* `session_id` (400 invalid_trace otherwise) — then writes it under the Agent's traces
|
|
7782
|
+
* directory as a **new Session**: a session id the Agent already has is rejected with
|
|
7783
|
+
* 409 `trace_session_exists` (splicing a further index into an existing Session would
|
|
7784
|
+
* corrupt its concatenated transcript, silently become its resume point, and could
|
|
7785
|
+
* collide with a live Writer's rotation), so the imported file is always index 1. The
|
|
7786
|
+
* date dir comes from the first record's timestamp (falling back to now), formatted as
|
|
7787
|
+
* a **local** date — the same convention as core's Trace Writer — so an export →
|
|
7788
|
+
* import round-trip lands in the same date dir on non-UTC servers.
|
|
7789
|
+
*/
|
|
7790
|
+
async importTraceFile(projectId, agentId, content) {
|
|
7791
|
+
const invalid = (message) => new HttpError(400, "invalid_trace", message);
|
|
7792
|
+
let records;
|
|
7793
|
+
try {
|
|
7794
|
+
records = parseTraceLines2(content);
|
|
7795
|
+
} catch {
|
|
7796
|
+
throw invalid("The file is not valid Trace JSONL.");
|
|
7797
|
+
}
|
|
7798
|
+
if (records.length === 0) throw invalid("The file contains no Trace records.");
|
|
7799
|
+
const first = records[0];
|
|
7800
|
+
if (!isSessionMeta5(first))
|
|
7801
|
+
throw invalid("The first record of a Trace file must be session_meta.");
|
|
7802
|
+
const sessionId = first.payload.session_id;
|
|
7803
|
+
if (typeof sessionId !== "string" || !IMPORT_SESSION_ID_RE.test(sessionId)) {
|
|
7804
|
+
throw invalid("session_meta carries a missing or invalid session_id.");
|
|
7805
|
+
}
|
|
7806
|
+
const duplicate = () => new HttpError(
|
|
7807
|
+
409,
|
|
7808
|
+
"trace_session_exists",
|
|
7809
|
+
`This Agent already has a Session with id ${sessionId}; a duplicate Trace cannot be imported.`
|
|
7810
|
+
);
|
|
7811
|
+
if ((await this.locateAll(projectId, agentId, sessionId)).length > 0) throw duplicate();
|
|
7812
|
+
const ts = Date.parse(first.timestamp);
|
|
7813
|
+
const date = formatLocalDate(Number.isNaN(ts) ? /* @__PURE__ */ new Date() : new Date(ts));
|
|
7814
|
+
const index = 1;
|
|
7815
|
+
const dir = path14.join(tracesDir3(this.root, projectId, agentId), date);
|
|
7816
|
+
await fs15.mkdir(dir, { recursive: true });
|
|
7817
|
+
const file = path14.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
|
|
7818
|
+
try {
|
|
7819
|
+
await fs15.writeFile(file, content.replace(/\n+$/, "") + "\n", {
|
|
7820
|
+
encoding: "utf8",
|
|
7821
|
+
flag: "wx"
|
|
7822
|
+
});
|
|
7823
|
+
} catch (err) {
|
|
7824
|
+
if (err.code === "EEXIST") throw duplicate();
|
|
7825
|
+
throw err;
|
|
7826
|
+
}
|
|
7827
|
+
return { sessionId, index, date };
|
|
7828
|
+
}
|
|
7829
|
+
};
|
|
7830
|
+
|
|
7831
|
+
// src/services/update-check-service.ts
|
|
7832
|
+
import { BUILD_DATE as BUILD_DATE2, VERSION as VERSION2, compareVersions, normalizeVersion } from "@prismshadow/penguin-core";
|
|
7833
|
+
var REPO_SLUG = "Prism-Shadow/penguin-harness";
|
|
7834
|
+
var LATEST_RELEASE_API = `https://api.github.com/repos/${REPO_SLUG}/releases/latest`;
|
|
7835
|
+
var SUCCESS_TTL_MS = 60 * 60 * 1e3;
|
|
7836
|
+
var FAILURE_TTL_MS = 10 * 60 * 1e3;
|
|
7837
|
+
var UpdateCheckService = class {
|
|
7838
|
+
fetchImpl;
|
|
7839
|
+
now;
|
|
7840
|
+
env;
|
|
7841
|
+
cached = null;
|
|
7842
|
+
inflight = null;
|
|
7843
|
+
constructor(options = {}) {
|
|
7844
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
7845
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
7846
|
+
this.env = options.env ?? process.env;
|
|
7847
|
+
}
|
|
7848
|
+
/**
|
|
7849
|
+
* Never throws; never makes a network call when disabled or (without `force`) while
|
|
7850
|
+
* the cache is fresh. `force` — the web's manual "check for updates" action — skips
|
|
7851
|
+
* the freshness check and performs a real lookup even over a warm cache: the cache
|
|
7852
|
+
* exists to shield GitHub's unauthenticated rate limit from *passive* checks fired
|
|
7853
|
+
* by every sidebar open, and an explicit user click is rare enough to press through
|
|
7854
|
+
* it. Everything else is unchanged: the opt-out stays authoritative (force never
|
|
7855
|
+
* dials out under PENGUIN_UPDATE_CHECK=off), the outcome lands in the same cache
|
|
7856
|
+
* (subsequent passive checks reuse it), and concurrent callers — forced or not —
|
|
7857
|
+
* still share one in-flight lookup.
|
|
7858
|
+
*/
|
|
7859
|
+
async check(force = false) {
|
|
7860
|
+
if (this.env["PENGUIN_UPDATE_CHECK"] === "off") {
|
|
7861
|
+
return {
|
|
7862
|
+
currentVersion: VERSION2,
|
|
7863
|
+
buildDate: BUILD_DATE2,
|
|
7864
|
+
latestVersion: null,
|
|
7865
|
+
updateAvailable: false,
|
|
7866
|
+
releaseUrl: null,
|
|
7867
|
+
publishedAt: null,
|
|
7868
|
+
checkedAt: this.now().toISOString(),
|
|
7869
|
+
disabled: true
|
|
7870
|
+
};
|
|
7871
|
+
}
|
|
7872
|
+
if (!force && this.cached && this.now().getTime() < this.cached.expiresAt) {
|
|
7873
|
+
return this.cached.response;
|
|
7874
|
+
}
|
|
7875
|
+
this.inflight ??= this.lookup().finally(() => {
|
|
7876
|
+
this.inflight = null;
|
|
7877
|
+
});
|
|
7878
|
+
return this.inflight;
|
|
7879
|
+
}
|
|
7880
|
+
/** One real lookup; stores the outcome (success or failure) in the cache with its TTL. */
|
|
7881
|
+
async lookup() {
|
|
7882
|
+
const response = await this.resolveLatest();
|
|
7883
|
+
const ttl = response.error === void 0 ? SUCCESS_TTL_MS : FAILURE_TTL_MS;
|
|
7884
|
+
this.cached = { response, expiresAt: this.now().getTime() + ttl };
|
|
7885
|
+
return response;
|
|
7886
|
+
}
|
|
7887
|
+
/**
|
|
7888
|
+
* Failure taxonomy mirrors the CLI's fetchLatestVersion: an unreachable endpoint or
|
|
7889
|
+
* timeout is "network"; a 403/429 is "rate_limited" (unauthenticated clients hit
|
|
7890
|
+
* GitHub's per-IP limit); any other non-2xx status, an unparsable body, or a body
|
|
7891
|
+
* without a usable tag_name is "bad_response".
|
|
7892
|
+
*/
|
|
7893
|
+
async resolveLatest() {
|
|
7894
|
+
const checkedAt = this.now().toISOString();
|
|
7895
|
+
const base = { currentVersion: VERSION2, buildDate: BUILD_DATE2, checkedAt };
|
|
7896
|
+
const failure = (error) => ({
|
|
7897
|
+
...base,
|
|
7898
|
+
latestVersion: null,
|
|
7899
|
+
updateAvailable: false,
|
|
7900
|
+
releaseUrl: null,
|
|
7901
|
+
publishedAt: null,
|
|
7902
|
+
error
|
|
7903
|
+
});
|
|
7904
|
+
let res;
|
|
7905
|
+
try {
|
|
7906
|
+
res = await this.fetchImpl(LATEST_RELEASE_API, {
|
|
7907
|
+
headers: { accept: "application/vnd.github+json", "user-agent": "penguin-server" },
|
|
7908
|
+
signal: AbortSignal.timeout(15e3)
|
|
7909
|
+
});
|
|
7910
|
+
} catch {
|
|
7911
|
+
return failure("network");
|
|
7912
|
+
}
|
|
7913
|
+
if (res.status === 403 || res.status === 429) return failure("rate_limited");
|
|
7914
|
+
if (!res.ok) return failure("bad_response");
|
|
7915
|
+
let body;
|
|
7916
|
+
try {
|
|
7917
|
+
body = await res.json();
|
|
7918
|
+
} catch {
|
|
7919
|
+
return failure("bad_response");
|
|
7920
|
+
}
|
|
7921
|
+
const tag = typeof body.tag_name === "string" ? normalizeVersion(body.tag_name) : "";
|
|
7922
|
+
if (tag === "") return failure("bad_response");
|
|
7923
|
+
return {
|
|
7924
|
+
...base,
|
|
7925
|
+
latestVersion: tag,
|
|
7926
|
+
updateAvailable: compareVersions(tag, VERSION2) > 0,
|
|
7927
|
+
releaseUrl: typeof body.html_url === "string" ? body.html_url : null,
|
|
7928
|
+
publishedAt: typeof body.published_at === "string" ? body.published_at : null
|
|
7929
|
+
};
|
|
6750
7930
|
}
|
|
6751
7931
|
};
|
|
6752
7932
|
|
|
@@ -6918,6 +8098,53 @@ var UsageService = class {
|
|
|
6918
8098
|
}
|
|
6919
8099
|
};
|
|
6920
8100
|
|
|
8101
|
+
// src/http/routes/preview.ts
|
|
8102
|
+
import { Hono as Hono20 } from "hono";
|
|
8103
|
+
var DIRECTORY_INDEX = "index.html";
|
|
8104
|
+
function previewRoutes(deps2) {
|
|
8105
|
+
const app2 = new Hono20();
|
|
8106
|
+
app2.get("/:token/*", async (c) => {
|
|
8107
|
+
const token = c.req.param("token");
|
|
8108
|
+
const payload = token ? deps2.previewTokens.verify(token) : null;
|
|
8109
|
+
if (!payload) return c.text("Not found", 404);
|
|
8110
|
+
const authority = requestAuthority(c.req.url, c.req.header("host"));
|
|
8111
|
+
if (hostOnly(authority).toLowerCase() !== payload.host.toLowerCase()) {
|
|
8112
|
+
return c.text("Not found", 404);
|
|
8113
|
+
}
|
|
8114
|
+
const row = deps2.sessionsRepo.findById(payload.sessionId);
|
|
8115
|
+
if (!row) return c.text("Not found", 404);
|
|
8116
|
+
const prefix = `/preview/${token}/`;
|
|
8117
|
+
const raw = c.req.path.startsWith(prefix) ? c.req.path.slice(prefix.length) : "";
|
|
8118
|
+
let rel;
|
|
8119
|
+
try {
|
|
8120
|
+
rel = decodeURIComponent(raw);
|
|
8121
|
+
} catch {
|
|
8122
|
+
return c.text("Not found", 404);
|
|
8123
|
+
}
|
|
8124
|
+
if (rel === "" || rel.endsWith("/")) rel += DIRECTORY_INDEX;
|
|
8125
|
+
let file;
|
|
8126
|
+
try {
|
|
8127
|
+
file = await deps2.workspaceFiles.read(row.workspace, rel);
|
|
8128
|
+
} catch {
|
|
8129
|
+
return c.text("Not found", 404);
|
|
8130
|
+
}
|
|
8131
|
+
return new Response(new Uint8Array(file.data), {
|
|
8132
|
+
status: 200,
|
|
8133
|
+
headers: {
|
|
8134
|
+
"Content-Type": file.contentType,
|
|
8135
|
+
"X-Content-Type-Options": "nosniff",
|
|
8136
|
+
// Without this, third-party requests made by the page leak the token-bearing
|
|
8137
|
+
// URL through Referer — a risk that only exists because this origin exists to
|
|
8138
|
+
// let third-party embeds run.
|
|
8139
|
+
"Referrer-Policy": "no-referrer",
|
|
8140
|
+
// Previews are per-token and short-lived; never let a shared cache keep them.
|
|
8141
|
+
"Cache-Control": "no-store"
|
|
8142
|
+
}
|
|
8143
|
+
});
|
|
8144
|
+
});
|
|
8145
|
+
return app2;
|
|
8146
|
+
}
|
|
8147
|
+
|
|
6921
8148
|
// src/app.ts
|
|
6922
8149
|
var MAX_BODY_BYTES = 20 * 1024 * 1024;
|
|
6923
8150
|
function buildAppDeps(config2, overrides = {}) {
|
|
@@ -6933,11 +8160,13 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
6933
8160
|
const errorsRepo = new ErrorsRepo(db);
|
|
6934
8161
|
const prefsRepo = new UiPrefsRepo(db);
|
|
6935
8162
|
const schedulesRepo = new SchedulesRepo(db);
|
|
8163
|
+
const goalsRepo = new GoalsRepo(db);
|
|
6936
8164
|
const projectConfigService = new ProjectConfigService(config2.root);
|
|
6937
8165
|
const agentConfigService = new AgentConfigService(config2.root);
|
|
6938
8166
|
const agentService = new AgentService(config2.root, agentsRepo, agentConfigService);
|
|
6939
8167
|
const traceService = new TraceService(config2.root);
|
|
6940
8168
|
const workspaceFiles = new WorkspaceFilesService();
|
|
8169
|
+
const previewTokens = createPreviewTokenSigner();
|
|
6941
8170
|
const benchmarks = new BenchmarkService(config2.root);
|
|
6942
8171
|
const snapshots = new SnapshotService(config2.root);
|
|
6943
8172
|
const usageService = new UsageService(
|
|
@@ -6946,6 +8175,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
6946
8175
|
(projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId),
|
|
6947
8176
|
overrides.now ?? (() => /* @__PURE__ */ new Date())
|
|
6948
8177
|
);
|
|
8178
|
+
const updateCheck = overrides.updateCheck ?? new UpdateCheckService(overrides.now ? { now: overrides.now } : {});
|
|
6949
8179
|
let managerRef;
|
|
6950
8180
|
const channels = new ChannelHub({
|
|
6951
8181
|
isActive: (key) => managerRef !== void 0 && managerRef.statusOf(key) !== "idle"
|
|
@@ -6962,7 +8192,8 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
6962
8192
|
recorder,
|
|
6963
8193
|
errors,
|
|
6964
8194
|
titles,
|
|
6965
|
-
log
|
|
8195
|
+
log,
|
|
8196
|
+
goals: goalsRepo
|
|
6966
8197
|
});
|
|
6967
8198
|
managerRef = manager;
|
|
6968
8199
|
const projectService = new ProjectService({
|
|
@@ -6975,6 +8206,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
6975
8206
|
usage: usageRepo,
|
|
6976
8207
|
errors: errorsRepo,
|
|
6977
8208
|
schedules: schedulesRepo,
|
|
8209
|
+
goals: goalsRepo,
|
|
6978
8210
|
projectConfig: projectConfigService,
|
|
6979
8211
|
manager
|
|
6980
8212
|
});
|
|
@@ -7027,10 +8259,14 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7027
8259
|
sessionService,
|
|
7028
8260
|
traceService,
|
|
7029
8261
|
usageService,
|
|
8262
|
+
updateCheck,
|
|
7030
8263
|
workspaceFiles,
|
|
8264
|
+
previewTokens,
|
|
7031
8265
|
benchmarks,
|
|
7032
8266
|
snapshots,
|
|
7033
8267
|
schedulesRepo,
|
|
8268
|
+
goalsRepo,
|
|
8269
|
+
errorsRepo,
|
|
7034
8270
|
scheduler,
|
|
7035
8271
|
channels,
|
|
7036
8272
|
manager,
|
|
@@ -7040,7 +8276,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7040
8276
|
};
|
|
7041
8277
|
}
|
|
7042
8278
|
function createApp(deps2) {
|
|
7043
|
-
const app2 = new
|
|
8279
|
+
const app2 = new Hono21();
|
|
7044
8280
|
app2.onError((err, c) => {
|
|
7045
8281
|
const projectId = attributedProjectId(c, deps2);
|
|
7046
8282
|
deps2.errors.record({
|
|
@@ -7057,6 +8293,21 @@ function createApp(deps2) {
|
|
|
7057
8293
|
const ms = Math.round(performance.now() - start);
|
|
7058
8294
|
deps2.log(`${c.req.method} ${c.req.path} ${c.res.status} ${ms}ms`);
|
|
7059
8295
|
});
|
|
8296
|
+
const previewRoles = deps2.config.previewOrigin ? null : loopbackHostRoles(deps2.config.host);
|
|
8297
|
+
if (previewRoles) {
|
|
8298
|
+
app2.use("*", async (c, next) => {
|
|
8299
|
+
const host = hostOnly(requestAuthority(c.req.url, c.req.header("host"))).toLowerCase();
|
|
8300
|
+
if (host === previewRoles.preview && !c.req.path.startsWith("/preview/")) {
|
|
8301
|
+
if (c.req.path.startsWith("/api/")) {
|
|
8302
|
+
throw new HttpError(401, "unauthorized", "The API is not served on the preview host.");
|
|
8303
|
+
}
|
|
8304
|
+
const url = new URL(c.req.url);
|
|
8305
|
+
url.hostname = previewRoles.app;
|
|
8306
|
+
return c.redirect(url.toString(), 302);
|
|
8307
|
+
}
|
|
8308
|
+
await next();
|
|
8309
|
+
});
|
|
8310
|
+
}
|
|
7060
8311
|
app2.use("/api/*", async (c, next) => {
|
|
7061
8312
|
const contentLength = Number(c.req.header("content-length") ?? 0);
|
|
7062
8313
|
if (contentLength > MAX_BODY_BYTES) {
|
|
@@ -7069,6 +8320,7 @@ function createApp(deps2) {
|
|
|
7069
8320
|
const auth = authMiddleware(deps2.authService);
|
|
7070
8321
|
app2.use("/api/*", auth);
|
|
7071
8322
|
app2.route("/api/me", meRoutes(deps2));
|
|
8323
|
+
app2.route("/api/version", versionRoutes(deps2));
|
|
7072
8324
|
app2.route("/api/admin/users", adminUsersRoutes(deps2));
|
|
7073
8325
|
app2.route("/api/events", eventsRoutes(deps2));
|
|
7074
8326
|
app2.route("/api/skills", skillLibraryRoutes());
|
|
@@ -7087,6 +8339,7 @@ function createApp(deps2) {
|
|
|
7087
8339
|
app2.route("/api/projects/:projectId/agents/:agentId/sessions", agentSessionsRoutes(deps2));
|
|
7088
8340
|
app2.route("/api/projects/:projectId/usage", usageRoutes(deps2));
|
|
7089
8341
|
app2.route("/api/sessions", sessionsRoutes(deps2));
|
|
8342
|
+
app2.route("/preview", previewRoutes(deps2));
|
|
7090
8343
|
if (fs16.existsSync(deps2.config.webDist)) {
|
|
7091
8344
|
registerStaticRoutes(app2, deps2.config.webDist);
|
|
7092
8345
|
}
|
|
@@ -7160,6 +8413,19 @@ function defaultWebDist() {
|
|
|
7160
8413
|
if (fs17.existsSync(bundled)) return bundled;
|
|
7161
8414
|
return path16.resolve(here, "..", "..", "web", "dist");
|
|
7162
8415
|
}
|
|
8416
|
+
function normalizePreviewOrigin(raw) {
|
|
8417
|
+
if (!raw || raw.trim() === "") return null;
|
|
8418
|
+
let url;
|
|
8419
|
+
try {
|
|
8420
|
+
url = new URL(raw.trim());
|
|
8421
|
+
} catch {
|
|
8422
|
+
throw new Error(`Invalid PENGUIN_PREVIEW_ORIGIN=${raw} (expected an absolute origin)`);
|
|
8423
|
+
}
|
|
8424
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
8425
|
+
throw new Error(`Invalid PENGUIN_PREVIEW_ORIGIN=${raw} (only http/https are supported)`);
|
|
8426
|
+
}
|
|
8427
|
+
return url.origin;
|
|
8428
|
+
}
|
|
7163
8429
|
function resolveServerConfig(env = process.env) {
|
|
7164
8430
|
const root = env.PENGUIN_HOME ?? resolveRoot();
|
|
7165
8431
|
const port = Number(env.PORT || DEFAULT_SERVER_PORT);
|
|
@@ -7172,6 +8438,7 @@ function resolveServerConfig(env = process.env) {
|
|
|
7172
8438
|
port,
|
|
7173
8439
|
dbPath: env.PENGUIN_WEB_DB ?? path16.join(root, "web.db"),
|
|
7174
8440
|
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
|
|
8441
|
+
previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
|
|
7175
8442
|
authSessionTtlMs: 7 * DAY_MS,
|
|
7176
8443
|
authSessionRenewMs: 6 * DAY_MS
|
|
7177
8444
|
};
|
|
@@ -7184,11 +8451,19 @@ var deps = buildAppDeps(config);
|
|
|
7184
8451
|
var app = createApp(deps);
|
|
7185
8452
|
await deps.authService.seedAdmin();
|
|
7186
8453
|
await deps.scheduler.start();
|
|
8454
|
+
deps.goalsRepo.abortOrphanedActive();
|
|
8455
|
+
var appHost = loopbackHostRoles(config.host)?.app ?? config.host;
|
|
7187
8456
|
var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
|
7188
|
-
console.log(`penguin-server started: http://${
|
|
8457
|
+
console.log(`penguin-server started: http://${appHost}:${info.port}`);
|
|
7189
8458
|
console.log(`Data root: ${config.root}`);
|
|
7190
8459
|
console.log(`SQLite: ${config.dbPath}`);
|
|
7191
8460
|
});
|
|
8461
|
+
var ipv6Loopback = config.host === "127.0.0.1" || config.host === "localhost" ? serve({ fetch: app.fetch, hostname: "::1", port: config.port }) : null;
|
|
8462
|
+
ipv6Loopback?.on("error", (err) => {
|
|
8463
|
+
console.warn(
|
|
8464
|
+
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`
|
|
8465
|
+
);
|
|
8466
|
+
});
|
|
7192
8467
|
var shuttingDown = false;
|
|
7193
8468
|
async function shutdown(signal, exitCode = 0) {
|
|
7194
8469
|
if (shuttingDown) return;
|
|
@@ -7197,6 +8472,7 @@ async function shutdown(signal, exitCode = 0) {
|
|
|
7197
8472
|
deps.scheduler.stop();
|
|
7198
8473
|
await deps.manager.shutdown(5e3);
|
|
7199
8474
|
deps.channels.dispose();
|
|
8475
|
+
ipv6Loopback?.close();
|
|
7200
8476
|
server.close(() => {
|
|
7201
8477
|
deps.db.close();
|
|
7202
8478
|
process.exit(exitCode);
|