@prismshadow/penguin-server 0.1.2 → 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 +168 -2
- package/dist/index.js +864 -26
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/web-dist/assets/{index-zCEa-kcQ.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-BwOLjdOw.css +0 -1
- package/web-dist/assets/index-Ddrpqgqu.js +0 -104
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();
|
|
@@ -1514,6 +1604,12 @@ function membersRoutes(deps2) {
|
|
|
1514
1604
|
|
|
1515
1605
|
// src/http/routes/models.ts
|
|
1516
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
|
+
}
|
|
1517
1613
|
function parseRef(value, label) {
|
|
1518
1614
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1519
1615
|
throw badRequest(`${label} must be a { provider, modelId } object.`);
|
|
@@ -1633,7 +1729,10 @@ function modelsRoutes(deps2) {
|
|
|
1633
1729
|
const projectId = requireValidId(c, "projectId");
|
|
1634
1730
|
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
1635
1731
|
const req = parseModelsUpdate(await readJson(c));
|
|
1636
|
-
|
|
1732
|
+
const res = await deps2.projectConfigService.updateModels(projectId, req);
|
|
1733
|
+
deps2.manager.invalidateProjectRuntimes(projectId);
|
|
1734
|
+
publishCredentialsUpdated(deps2, projectId);
|
|
1735
|
+
return c.json(res);
|
|
1637
1736
|
});
|
|
1638
1737
|
app2.post("/test", async (c) => {
|
|
1639
1738
|
const projectId = requireValidId(c, "projectId");
|
|
@@ -2305,6 +2404,9 @@ function agentsRoutes(deps2) {
|
|
|
2305
2404
|
await settleWithin(runnings, 5e3);
|
|
2306
2405
|
await deps2.agentService.deleteAgent(projectId, agentId);
|
|
2307
2406
|
deps2.sessionsRepo.deleteByAgent(projectId, agentId);
|
|
2407
|
+
deps2.goalsRepo.deleteByAgent(projectId, agentId);
|
|
2408
|
+
deps2.schedulesRepo.deleteByAgent(projectId, agentId);
|
|
2409
|
+
deps2.errorsRepo.deleteByAgent(projectId, agentId);
|
|
2308
2410
|
} finally {
|
|
2309
2411
|
deps2.manager.endAgentDeletion(projectId, agentId);
|
|
2310
2412
|
}
|
|
@@ -2410,6 +2512,7 @@ function agentConfigRoutes(deps2) {
|
|
|
2410
2512
|
|
|
2411
2513
|
// src/http/routes/agent-traces.ts
|
|
2412
2514
|
import { Hono as Hono16 } from "hono";
|
|
2515
|
+
var MAX_TRACE_BYTES = 14 * 1024 * 1024;
|
|
2413
2516
|
function agentTracesRoutes(deps2) {
|
|
2414
2517
|
const app2 = new Hono16();
|
|
2415
2518
|
app2.get("/", async (c) => {
|
|
@@ -2437,6 +2540,41 @@ function agentTracesRoutes(deps2) {
|
|
|
2437
2540
|
const index = positiveIntParam(c, "index");
|
|
2438
2541
|
return c.json(await deps2.traceService.analyze(projectId, agentId, sessionId, index));
|
|
2439
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
|
+
});
|
|
2440
2578
|
return app2;
|
|
2441
2579
|
}
|
|
2442
2580
|
|
|
@@ -2735,6 +2873,10 @@ var WorkspaceFilesService = class {
|
|
|
2735
2873
|
}
|
|
2736
2874
|
const { dir, name } = await this.resolveWriteParent(workspace, rel);
|
|
2737
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
|
+
}
|
|
2738
2880
|
const flags = fsc.O_WRONLY | fsc.O_CREAT | fsc.O_TRUNC | (fsc.O_NOFOLLOW ?? 0);
|
|
2739
2881
|
let handle;
|
|
2740
2882
|
try {
|
|
@@ -2798,6 +2940,18 @@ function parseTaskInput(body) {
|
|
|
2798
2940
|
throw badRequest(`input[${i}].type must be one of text / image_url.`);
|
|
2799
2941
|
});
|
|
2800
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
|
+
}
|
|
2801
2955
|
function agentSessionsRoutes(deps2) {
|
|
2802
2956
|
const app2 = new Hono18();
|
|
2803
2957
|
app2.get("/", async (c) => {
|
|
@@ -2952,6 +3106,7 @@ function sessionsRoutes(deps2) {
|
|
|
2952
3106
|
{ recursive: true, force: true }
|
|
2953
3107
|
);
|
|
2954
3108
|
deps2.sessionsRepo.deleteById(row.sessionId);
|
|
3109
|
+
deps2.goalsRepo.deleteBySession(row.sessionId);
|
|
2955
3110
|
deps2.sessionSources.delete(row.sessionId);
|
|
2956
3111
|
} finally {
|
|
2957
3112
|
deps2.manager.endSessionDeletion(row.sessionId);
|
|
@@ -2990,12 +3145,19 @@ function sessionsRoutes(deps2) {
|
|
|
2990
3145
|
});
|
|
2991
3146
|
app2.get("/:sessionId/messages", async (c) => {
|
|
2992
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
|
+
}
|
|
2993
3155
|
const messages = await deps2.traceService.readMessages(
|
|
2994
3156
|
row.projectId,
|
|
2995
3157
|
row.agentId,
|
|
2996
3158
|
row.sessionId
|
|
2997
3159
|
);
|
|
2998
|
-
return c.json({ messages });
|
|
3160
|
+
return c.json({ messages, ...live !== void 0 ? { live } : {} });
|
|
2999
3161
|
});
|
|
3000
3162
|
app2.get("/:sessionId/stream", (c) => {
|
|
3001
3163
|
const row = resolveSession(c);
|
|
@@ -3017,8 +3179,22 @@ function sessionsRoutes(deps2) {
|
|
|
3017
3179
|
app2.post("/:sessionId/tasks", async (c) => {
|
|
3018
3180
|
const row = resolveSession(c);
|
|
3019
3181
|
const body = await readJson(c);
|
|
3020
|
-
const
|
|
3182
|
+
const goal = parseGoalField(body);
|
|
3021
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);
|
|
3022
3198
|
const queueIfBusy = body.queueIfBusy === true;
|
|
3023
3199
|
const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
|
|
3024
3200
|
...thinkingLevel !== void 0 ? { thinkingLevel } : {},
|
|
@@ -3034,6 +3210,20 @@ function sessionsRoutes(deps2) {
|
|
|
3034
3210
|
deps2.manager.steer(row.sessionId, text);
|
|
3035
3211
|
return c.body(null, 202);
|
|
3036
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
|
+
});
|
|
3226
|
+
});
|
|
3037
3227
|
app2.post("/:sessionId/approvals/:toolCallId", async (c) => {
|
|
3038
3228
|
const row = resolveSession(c);
|
|
3039
3229
|
const body = await readJson(c);
|
|
@@ -3053,6 +3243,11 @@ function sessionsRoutes(deps2) {
|
|
|
3053
3243
|
const aborted = deps2.manager.abortTask(row.sessionId);
|
|
3054
3244
|
return c.body(null, aborted ? 202 : 204);
|
|
3055
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
|
+
});
|
|
3056
3251
|
app2.post("/:sessionId/compact", async (c) => {
|
|
3057
3252
|
const row = resolveSession(c);
|
|
3058
3253
|
const { sessionId } = await deps2.manager.startCompact(row.sessionId);
|
|
@@ -3167,6 +3362,104 @@ function sessionsRoutes(deps2) {
|
|
|
3167
3362
|
return app2;
|
|
3168
3363
|
}
|
|
3169
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
|
+
|
|
3170
3463
|
// src/runtime/channel.ts
|
|
3171
3464
|
import { randomUUID } from "crypto";
|
|
3172
3465
|
var DEFAULT_MAX_COUNT = 1e4;
|
|
@@ -3191,6 +3484,16 @@ var Channel = class {
|
|
|
3191
3484
|
get subscriberCount() {
|
|
3192
3485
|
return this.listeners.size;
|
|
3193
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
|
+
}
|
|
3194
3497
|
/** Broadcast an event: number it, buffer it (evicting the oldest), notify all subscribers. */
|
|
3195
3498
|
publish(data, event) {
|
|
3196
3499
|
const entry = this.makeEvent(data, event);
|
|
@@ -3363,7 +3666,11 @@ import path8 from "path";
|
|
|
3363
3666
|
import {
|
|
3364
3667
|
createAgent,
|
|
3365
3668
|
findLatestTraceFile,
|
|
3669
|
+
goalFinishedOf,
|
|
3670
|
+
goalTokenDelta,
|
|
3671
|
+
isGoalRoundInput,
|
|
3366
3672
|
isSessionMeta as isSessionMeta2,
|
|
3673
|
+
stripLeadingMarkerBlocks,
|
|
3367
3674
|
tracesDir
|
|
3368
3675
|
} from "@prismshadow/penguin-core";
|
|
3369
3676
|
|
|
@@ -3434,6 +3741,165 @@ function makeApprove(args) {
|
|
|
3434
3741
|
};
|
|
3435
3742
|
}
|
|
3436
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
|
+
|
|
3437
3903
|
// src/runtime/session-sources.ts
|
|
3438
3904
|
function asSessionSource(v) {
|
|
3439
3905
|
return v === "schedule" || v === "subagent" ? v : void 0;
|
|
@@ -3461,6 +3927,9 @@ var TOOL_NAMES_MAX = 1e3;
|
|
|
3461
3927
|
var ORIGIN_CTX_MAX = 200;
|
|
3462
3928
|
var LLM_FAILURES = {
|
|
3463
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)." },
|
|
3464
3933
|
timeout: {
|
|
3465
3934
|
code: "llm_timeout",
|
|
3466
3935
|
kind: "expected",
|
|
@@ -3473,7 +3942,7 @@ var LLM_FAILURES = {
|
|
|
3473
3942
|
}
|
|
3474
3943
|
};
|
|
3475
3944
|
function isLlmFailure(s) {
|
|
3476
|
-
return s === "failed" || s === "timeout" || s === "malformed";
|
|
3945
|
+
return s === "failed" || s === "timeout" || s === "malformed" || s === "auth";
|
|
3477
3946
|
}
|
|
3478
3947
|
function isToolFailure(s) {
|
|
3479
3948
|
return s === "failed" || s === "timeout";
|
|
@@ -3497,7 +3966,7 @@ var StreamErrorWatcher = class {
|
|
|
3497
3966
|
}
|
|
3498
3967
|
errors;
|
|
3499
3968
|
ctx;
|
|
3500
|
-
/** 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). */
|
|
3501
3970
|
pending = /* @__PURE__ */ new Map();
|
|
3502
3971
|
/** Names of in-flight tool calls: `origin \0 tool_call_id` → tool name (dequeued once output arrives). */
|
|
3503
3972
|
toolNames = /* @__PURE__ */ new Map();
|
|
@@ -3561,7 +4030,14 @@ var StreamErrorWatcher = class {
|
|
|
3561
4030
|
const key = originKey(msg);
|
|
3562
4031
|
if (p.type === "request_end") {
|
|
3563
4032
|
this.flush(key);
|
|
3564
|
-
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
|
+
}
|
|
3565
4041
|
return;
|
|
3566
4042
|
}
|
|
3567
4043
|
if (p.type === "request_begin") {
|
|
@@ -3579,12 +4055,12 @@ var StreamErrorWatcher = class {
|
|
|
3579
4055
|
* up from it (see file header).
|
|
3580
4056
|
*/
|
|
3581
4057
|
flush(key, reason) {
|
|
3582
|
-
const
|
|
3583
|
-
if (
|
|
4058
|
+
const entry = this.pending.get(key);
|
|
4059
|
+
if (entry === void 0) return;
|
|
3584
4060
|
this.pending.delete(key);
|
|
3585
|
-
const spec = LLM_FAILURES[status];
|
|
4061
|
+
const spec = LLM_FAILURES[entry.status];
|
|
3586
4062
|
const trimmed = reason?.trim();
|
|
3587
|
-
const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : spec.text;
|
|
4063
|
+
const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : entry.message ?? spec.text;
|
|
3588
4064
|
this.errors.record({
|
|
3589
4065
|
source: "llm",
|
|
3590
4066
|
err: message,
|
|
@@ -3748,6 +4224,8 @@ var SessionManager = class {
|
|
|
3748
4224
|
deletingSessions = /* @__PURE__ */ new Set();
|
|
3749
4225
|
/** Per-Agent config generation (key = agentKey), bumped by invalidateAgentRuntimes on vault updates. */
|
|
3750
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();
|
|
3751
4229
|
sweepTimer;
|
|
3752
4230
|
// —— Query surface (used by Session listing / Agent active-count / SSE subscription replay) ——
|
|
3753
4231
|
statusOf(sessionId) {
|
|
@@ -3763,6 +4241,15 @@ var SessionManager = class {
|
|
|
3763
4241
|
pendingFollowUpCount(sessionId) {
|
|
3764
4242
|
return this.entries.get(sessionId)?.followUps.length ?? 0;
|
|
3765
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
|
+
}
|
|
3766
4253
|
/** Number of Sessions for this Agent that are currently running / compacting. */
|
|
3767
4254
|
activeCountForAgent(projectId, agentId) {
|
|
3768
4255
|
let n = 0;
|
|
@@ -3802,6 +4289,21 @@ var SessionManager = class {
|
|
|
3802
4289
|
const key = agentKey(projectId, agentId);
|
|
3803
4290
|
this.agentGenerations.set(key, this.generationOf(projectId, agentId) + 1);
|
|
3804
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
|
+
}
|
|
3805
4307
|
// —— Task / compaction drive ——
|
|
3806
4308
|
/**
|
|
3807
4309
|
* Start a Task: get-or-load → 409
|
|
@@ -3834,6 +4336,135 @@ var SessionManager = class {
|
|
|
3834
4336
|
return { sessionId: entry.sessionId, queued: false };
|
|
3835
4337
|
});
|
|
3836
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();
|
|
4357
|
+
const ac = new AbortController();
|
|
4358
|
+
entry.status = "running";
|
|
4359
|
+
entry.abort = ac;
|
|
4360
|
+
entry.lastActivityMs = Date.now();
|
|
4361
|
+
this.publishState(entry, "running");
|
|
4362
|
+
const approve = makeApprove({
|
|
4363
|
+
getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
|
|
4364
|
+
toolPermission: (name) => entry.session.toolPermission(name),
|
|
4365
|
+
registry: entry.approvals,
|
|
4366
|
+
publishRequest: (pending) => this.publishEvent(entry, {
|
|
4367
|
+
type: "approval_request",
|
|
4368
|
+
toolCall: pending.toolCall,
|
|
4369
|
+
...pending.origin !== void 0 ? { origin: pending.origin } : {}
|
|
4370
|
+
})
|
|
4371
|
+
});
|
|
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 });
|
|
4394
|
+
return { sessionId: entry.sessionId };
|
|
4395
|
+
});
|
|
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
|
+
}
|
|
3837
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. */
|
|
3838
4469
|
launchTask(entry, input, thinkingLevel) {
|
|
3839
4470
|
const channel = this.deps.channels.get(entry.sessionId);
|
|
@@ -3929,6 +4560,21 @@ var SessionManager = class {
|
|
|
3929
4560
|
}
|
|
3930
4561
|
entry.lastActivityMs = Date.now();
|
|
3931
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
|
+
}
|
|
3932
4578
|
/**
|
|
3933
4579
|
* Interrupt the current Task/compaction: pending approvals converge to deny first,
|
|
3934
4580
|
* then the AbortSignal fires. Returns false if nothing is in progress (the route
|
|
@@ -4204,6 +4850,7 @@ var SessionManager = class {
|
|
|
4204
4850
|
}
|
|
4205
4851
|
}
|
|
4206
4852
|
}
|
|
4853
|
+
this.liveTail.observe(entry.sessionId, msg);
|
|
4207
4854
|
this.deps.channels.get(entry.sessionId).publish(msg);
|
|
4208
4855
|
watcher?.observe(msg);
|
|
4209
4856
|
try {
|
|
@@ -4220,6 +4867,7 @@ var SessionManager = class {
|
|
|
4220
4867
|
this.deps.errors?.record({ source: "session", err, ctx, code: "session_run_failed" });
|
|
4221
4868
|
} finally {
|
|
4222
4869
|
watcher?.close();
|
|
4870
|
+
this.liveTail.clear(entry.sessionId);
|
|
4223
4871
|
entry.approvals.denyAll();
|
|
4224
4872
|
entry.status = "idle";
|
|
4225
4873
|
entry.abort = null;
|
|
@@ -4890,6 +5538,19 @@ var ProjectConfigService = class {
|
|
|
4890
5538
|
filePath(projectId) {
|
|
4891
5539
|
return projectConfigPath(this.root, projectId);
|
|
4892
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
|
+
}
|
|
4893
5554
|
/** Reads the raw TOML object; returns an empty object if the file doesn't exist (does not write to disk). */
|
|
4894
5555
|
async readRaw(projectId) {
|
|
4895
5556
|
let raw;
|
|
@@ -5107,9 +5768,11 @@ var ProjectConfigService = class {
|
|
|
5107
5768
|
provider: ref.provider,
|
|
5108
5769
|
modelId: ref.model_id
|
|
5109
5770
|
});
|
|
5771
|
+
const updatedAt = await this.configUpdatedAt(projectId);
|
|
5110
5772
|
return {
|
|
5111
5773
|
...defaultRef !== void 0 ? { defaultModel: toDto(defaultRef) } : {},
|
|
5112
5774
|
...visionRef !== void 0 ? { visionModel: toDto(visionRef) } : {},
|
|
5775
|
+
...updatedAt !== void 0 ? { updatedAt } : {},
|
|
5113
5776
|
models
|
|
5114
5777
|
};
|
|
5115
5778
|
}
|
|
@@ -6231,6 +6894,7 @@ var ProjectService = class {
|
|
|
6231
6894
|
this.deps.usage.deleteByProject(projectId);
|
|
6232
6895
|
this.deps.errors.deleteByProject(projectId);
|
|
6233
6896
|
this.deps.schedules.deleteByProject(projectId);
|
|
6897
|
+
this.deps.goals.deleteByProject(projectId);
|
|
6234
6898
|
await fs14.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
|
|
6235
6899
|
}
|
|
6236
6900
|
// —— Member authorization ——
|
|
@@ -6674,11 +7338,14 @@ import fs15 from "fs/promises";
|
|
|
6674
7338
|
import path14 from "path";
|
|
6675
7339
|
import {
|
|
6676
7340
|
agentsDir as agentsDir3,
|
|
7341
|
+
isSessionMeta as isSessionMeta5,
|
|
7342
|
+
parseTraceLines as parseTraceLines2,
|
|
6677
7343
|
parseUserSteeringText,
|
|
6678
7344
|
readTraceTolerant as readTraceTolerant2,
|
|
6679
7345
|
tracesDir as tracesDir3
|
|
6680
7346
|
} from "@prismshadow/penguin-core";
|
|
6681
7347
|
var TRACE_FILE_RE2 = /^(.+)_(\d{3})\.jsonl$/;
|
|
7348
|
+
var IMPORT_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
6682
7349
|
var MAX_SUBAGENT_DEPTH = 4;
|
|
6683
7350
|
function subagentPointer(msg) {
|
|
6684
7351
|
if (msg.type !== "event_msg") return null;
|
|
@@ -7088,6 +7755,10 @@ var TraceService = class {
|
|
|
7088
7755
|
return out;
|
|
7089
7756
|
}
|
|
7090
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) {
|
|
7091
7762
|
const files = await this.locateAll(projectId, agentId, sessionId);
|
|
7092
7763
|
const file = files.find((f) => f.index === index);
|
|
7093
7764
|
if (!file) {
|
|
@@ -7097,7 +7768,165 @@ var TraceService = class {
|
|
|
7097
7768
|
`This Session has no Trace file with index ${index}.`
|
|
7098
7769
|
);
|
|
7099
7770
|
}
|
|
7100
|
-
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
|
+
};
|
|
7101
7930
|
}
|
|
7102
7931
|
};
|
|
7103
7932
|
|
|
@@ -7270,10 +8099,10 @@ var UsageService = class {
|
|
|
7270
8099
|
};
|
|
7271
8100
|
|
|
7272
8101
|
// src/http/routes/preview.ts
|
|
7273
|
-
import { Hono as
|
|
8102
|
+
import { Hono as Hono20 } from "hono";
|
|
7274
8103
|
var DIRECTORY_INDEX = "index.html";
|
|
7275
8104
|
function previewRoutes(deps2) {
|
|
7276
|
-
const app2 = new
|
|
8105
|
+
const app2 = new Hono20();
|
|
7277
8106
|
app2.get("/:token/*", async (c) => {
|
|
7278
8107
|
const token = c.req.param("token");
|
|
7279
8108
|
const payload = token ? deps2.previewTokens.verify(token) : null;
|
|
@@ -7331,6 +8160,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7331
8160
|
const errorsRepo = new ErrorsRepo(db);
|
|
7332
8161
|
const prefsRepo = new UiPrefsRepo(db);
|
|
7333
8162
|
const schedulesRepo = new SchedulesRepo(db);
|
|
8163
|
+
const goalsRepo = new GoalsRepo(db);
|
|
7334
8164
|
const projectConfigService = new ProjectConfigService(config2.root);
|
|
7335
8165
|
const agentConfigService = new AgentConfigService(config2.root);
|
|
7336
8166
|
const agentService = new AgentService(config2.root, agentsRepo, agentConfigService);
|
|
@@ -7345,6 +8175,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7345
8175
|
(projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId),
|
|
7346
8176
|
overrides.now ?? (() => /* @__PURE__ */ new Date())
|
|
7347
8177
|
);
|
|
8178
|
+
const updateCheck = overrides.updateCheck ?? new UpdateCheckService(overrides.now ? { now: overrides.now } : {});
|
|
7348
8179
|
let managerRef;
|
|
7349
8180
|
const channels = new ChannelHub({
|
|
7350
8181
|
isActive: (key) => managerRef !== void 0 && managerRef.statusOf(key) !== "idle"
|
|
@@ -7361,7 +8192,8 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7361
8192
|
recorder,
|
|
7362
8193
|
errors,
|
|
7363
8194
|
titles,
|
|
7364
|
-
log
|
|
8195
|
+
log,
|
|
8196
|
+
goals: goalsRepo
|
|
7365
8197
|
});
|
|
7366
8198
|
managerRef = manager;
|
|
7367
8199
|
const projectService = new ProjectService({
|
|
@@ -7374,6 +8206,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7374
8206
|
usage: usageRepo,
|
|
7375
8207
|
errors: errorsRepo,
|
|
7376
8208
|
schedules: schedulesRepo,
|
|
8209
|
+
goals: goalsRepo,
|
|
7377
8210
|
projectConfig: projectConfigService,
|
|
7378
8211
|
manager
|
|
7379
8212
|
});
|
|
@@ -7426,11 +8259,14 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7426
8259
|
sessionService,
|
|
7427
8260
|
traceService,
|
|
7428
8261
|
usageService,
|
|
8262
|
+
updateCheck,
|
|
7429
8263
|
workspaceFiles,
|
|
7430
8264
|
previewTokens,
|
|
7431
8265
|
benchmarks,
|
|
7432
8266
|
snapshots,
|
|
7433
8267
|
schedulesRepo,
|
|
8268
|
+
goalsRepo,
|
|
8269
|
+
errorsRepo,
|
|
7434
8270
|
scheduler,
|
|
7435
8271
|
channels,
|
|
7436
8272
|
manager,
|
|
@@ -7440,7 +8276,7 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
7440
8276
|
};
|
|
7441
8277
|
}
|
|
7442
8278
|
function createApp(deps2) {
|
|
7443
|
-
const app2 = new
|
|
8279
|
+
const app2 = new Hono21();
|
|
7444
8280
|
app2.onError((err, c) => {
|
|
7445
8281
|
const projectId = attributedProjectId(c, deps2);
|
|
7446
8282
|
deps2.errors.record({
|
|
@@ -7484,6 +8320,7 @@ function createApp(deps2) {
|
|
|
7484
8320
|
const auth = authMiddleware(deps2.authService);
|
|
7485
8321
|
app2.use("/api/*", auth);
|
|
7486
8322
|
app2.route("/api/me", meRoutes(deps2));
|
|
8323
|
+
app2.route("/api/version", versionRoutes(deps2));
|
|
7487
8324
|
app2.route("/api/admin/users", adminUsersRoutes(deps2));
|
|
7488
8325
|
app2.route("/api/events", eventsRoutes(deps2));
|
|
7489
8326
|
app2.route("/api/skills", skillLibraryRoutes());
|
|
@@ -7614,6 +8451,7 @@ var deps = buildAppDeps(config);
|
|
|
7614
8451
|
var app = createApp(deps);
|
|
7615
8452
|
await deps.authService.seedAdmin();
|
|
7616
8453
|
await deps.scheduler.start();
|
|
8454
|
+
deps.goalsRepo.abortOrphanedActive();
|
|
7617
8455
|
var appHost = loopbackHostRoles(config.host)?.app ?? config.host;
|
|
7618
8456
|
var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
|
7619
8457
|
console.log(`penguin-server started: http://${appHost}:${info.port}`);
|