@prismshadow/penguin-server 0.1.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/api/types.d.ts +193 -7
- package/dist/chunk-V6KH4FRG.js +76 -0
- package/dist/chunk-V6KH4FRG.js.map +1 -0
- package/dist/index.js +3829 -2047
- package/dist/index.js.map +1 -1
- package/dist/lock.d.ts +18 -0
- package/dist/lock.js +17 -0
- package/dist/lock.js.map +1 -0
- package/package.json +9 -4
- package/web-dist/assets/index-BvejhGl5.css +1 -0
- package/web-dist/assets/{index-DibOt1-H.js → index-DTZ3P5wj.js} +1 -1
- package/web-dist/assets/index-yzk1uoHO.js +172 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-Da8gVBDc.css +0 -1
- package/web-dist/assets/index-nay7sGjx.js +0 -171
package/README.md
CHANGED
|
@@ -36,8 +36,8 @@ pnpm --filter @prismshadow/penguin-server start # node dist/index.js
|
|
|
36
36
|
## Security notes (known MVP limits)
|
|
37
37
|
|
|
38
38
|
- **CSRF**: session cookie is `SameSite=Lax` and writes accept only `Content-Type: application/json`; no CSRF token yet.
|
|
39
|
-
- **
|
|
40
|
-
- **Built-in admin
|
|
39
|
+
- **Login throttling**: per-username exponential backoff after 5 consecutive failures (1s doubling to a 60s cap, `429 too_many_attempts` inside the window, reset on success; in-memory, so a restart clears it). Unknown usernames are throttled identically, so it is not an account-existence oracle. A reverse proxy can still add IP-level limits for public deployments.
|
|
40
|
+
- **Built-in admin `admin` starts with a random initial password** of the form `penguin-1234`, printed once to the server console at seed time (`PENGUIN_SEED_ADMIN_PASSWORD` pins it for tests/e2e): change it immediately (a banner keeps reminding until you do).
|
|
41
41
|
- Passwords use `node:crypto` scrypt (`scrypt$N$r$p$salt$hash`, timingSafeEqual); login sessions renew on a 7-day sliding window; the DB stores only the token's sha256.
|
|
42
42
|
- Model credentials live in the Project's hidden 0600 config file; the API always masks them.
|
|
43
43
|
- Behind a reverse proxy, disable response buffering for SSE paths (the server already sends `X-Accel-Buffering: no`) and forward `x-forwarded-proto` to enable Secure cookies.
|
package/dist/api/types.d.ts
CHANGED
|
@@ -57,9 +57,24 @@ interface MeResponse {
|
|
|
57
57
|
* request, since it depends on the host the caller is using.
|
|
58
58
|
*/
|
|
59
59
|
previewIsolated: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Whether this server runs in desktop mode (spawned by the desktop shell with
|
|
62
|
+
* PENGUIN_DESKTOP_TOKEN). The web app then hides the logout entry, the
|
|
63
|
+
* initial-password banner and the self-update entry, and omits the old-password
|
|
64
|
+
* field when changing the password. See design § "桌面端原型".
|
|
65
|
+
*/
|
|
66
|
+
desktopMode: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* How THIS session was established. Distinct from desktopMode: a browser signed into a
|
|
69
|
+
* desktop-mode server holds a "password" session and must still provide the old
|
|
70
|
+
* password when changing it — only "desktop" sessions (opened by the shell's one-shot
|
|
71
|
+
* token) may omit it.
|
|
72
|
+
*/
|
|
73
|
+
sessionVia: "password" | "desktop";
|
|
60
74
|
}
|
|
61
75
|
interface PasswordChangeRequest {
|
|
62
|
-
|
|
76
|
+
/** Omitted only by desktop-established sessions (desktop mode); required otherwise. */
|
|
77
|
+
oldPassword?: string;
|
|
63
78
|
/** At least 8 characters. */
|
|
64
79
|
newPassword: string;
|
|
65
80
|
}
|
|
@@ -85,6 +100,12 @@ interface UiPrefs {
|
|
|
85
100
|
lastProjectId?: string;
|
|
86
101
|
/** Whether the "no API key configured" guide has already been shown: once ever (on first visit to the chat page). */
|
|
87
102
|
credentialGuideSeen?: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Also list CLI-created Sessions in the sidebar (`cli=1` on the sessions list). Default
|
|
105
|
+
* off: the list then serves web rows straight from the DB, with no Trace-directory
|
|
106
|
+
* scanning (#139).
|
|
107
|
+
*/
|
|
108
|
+
showCliSessions?: boolean;
|
|
88
109
|
[key: string]: unknown;
|
|
89
110
|
}
|
|
90
111
|
interface PrefsResponse {
|
|
@@ -284,6 +305,42 @@ interface ModelTestResponse {
|
|
|
284
305
|
tps?: number;
|
|
285
306
|
message?: string;
|
|
286
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* PUT /api/projects/:p/models/default (owner): narrow default-model switch — flips the same
|
|
310
|
+
* top-level `default_model` the models page's whole-table PUT writes, without resending the
|
|
311
|
+
* table (and thus without touching credentials). The pair must name a configured model
|
|
312
|
+
* entry, exactly like the whole-table route's defaultModel validation.
|
|
313
|
+
*/
|
|
314
|
+
interface DefaultModelUpdateRequest {
|
|
315
|
+
provider: string;
|
|
316
|
+
modelId: string;
|
|
317
|
+
}
|
|
318
|
+
/** Response mirrors what GET models reports as `defaultModel`. */
|
|
319
|
+
interface DefaultModelResponse {
|
|
320
|
+
defaultModel: ModelRefDto;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Per-Project new-chat defaults: prefill for the chat draft page. Every key is optional —
|
|
324
|
+
* an absent key means "not set" (the pre-existing behavior). Serves as the GET response,
|
|
325
|
+
* the PUT request body (whole-block replace: an omitted key clears it) and the PUT
|
|
326
|
+
* response (the stored block). The default MODEL is deliberately not here: it stays the
|
|
327
|
+
* top-level `default_model` served/written via the models routes (single-sourced with the
|
|
328
|
+
* models page).
|
|
329
|
+
*/
|
|
330
|
+
interface ChatDefaultsDto {
|
|
331
|
+
/** Preselected Agent; must reference an existing Agent of the Project (400 unknown_agent). */
|
|
332
|
+
agentId?: string;
|
|
333
|
+
/** Prefilled Workspace directory; absent/empty = auto temp directory. */
|
|
334
|
+
workspace?: string;
|
|
335
|
+
/** Prefilled approval mode; absent = the built-in "allow-all". */
|
|
336
|
+
approvalMode?: ApprovalMode;
|
|
337
|
+
/**
|
|
338
|
+
* Fallback thinking level for Agents whose config has no explicit `model.thinking_level`
|
|
339
|
+
* (resolution chain: Agent explicit > this project default > built-in "medium"). Never
|
|
340
|
+
* "none" — only the four selectable tiers.
|
|
341
|
+
*/
|
|
342
|
+
thinkingLevel?: Exclude<ThinkingLevelName, "none">;
|
|
343
|
+
}
|
|
287
344
|
/** Read-only vault entry display: key name + masked value; plaintext is never sent. */
|
|
288
345
|
interface VaultEntryInfo {
|
|
289
346
|
key: string;
|
|
@@ -507,15 +564,57 @@ interface MessagesLiveTail {
|
|
|
507
564
|
cursor: string;
|
|
508
565
|
fragments: OmniMessage[];
|
|
509
566
|
}
|
|
567
|
+
/**
|
|
568
|
+
* Pagination envelope of a windowed `GET /messages` (`tailLimit` / `before` requests
|
|
569
|
+
* only; the parameterless full read never carries it). A window is a run of whole
|
|
570
|
+
* message-bearing units — one unit = one Task in the Web reducer's sense, opened by a
|
|
571
|
+
* main-session user prompt — cut so that no pairing (tool_call/output), compaction span
|
|
572
|
+
* or steering group ever splits across windows.
|
|
573
|
+
*/
|
|
574
|
+
interface MessagesPageInfo {
|
|
575
|
+
/**
|
|
576
|
+
* Cursor of this window's first unit (`<shardIndex>:<ordinal>`): pass it back as
|
|
577
|
+
* `before=` to fetch the previous window. Stable across requests and compaction —
|
|
578
|
+
* rotation opens a NEW shard and closed shards are immutable. Absent = this window
|
|
579
|
+
* reaches the very beginning of the transcript (no older history).
|
|
580
|
+
*/
|
|
581
|
+
before?: string;
|
|
582
|
+
/**
|
|
583
|
+
* Outline turns (the Web conversation outline's entry rule) opened BEFORE this
|
|
584
|
+
* window: the client offsets its global `第 N 轮` numbering by this, so a partial
|
|
585
|
+
* window never mis-numbers. 0 when the window starts at the beginning.
|
|
586
|
+
*/
|
|
587
|
+
earlierTurns: number;
|
|
588
|
+
/**
|
|
589
|
+
* Cumulative stats accrued before this window, seeded into the client's stats
|
|
590
|
+
* tracker so header chips and per-turn cumulative rows equal a full load:
|
|
591
|
+
* finished-Task elapsed, subagent token totals, and the last main-session
|
|
592
|
+
* session/context token readings.
|
|
593
|
+
*/
|
|
594
|
+
prior: {
|
|
595
|
+
subagentTokens: number;
|
|
596
|
+
elapsedMs: number;
|
|
597
|
+
sessionTokens: number;
|
|
598
|
+
contextTokens: number;
|
|
599
|
+
};
|
|
600
|
+
}
|
|
510
601
|
/** Message history: the full messages and events from concatenating all of this Session's Trace files in order (excludes partial_*). */
|
|
511
602
|
interface MessagesResponse {
|
|
512
603
|
messages: OmniMessage[];
|
|
513
604
|
/**
|
|
514
605
|
* Present only while the Session is running/compacting: the in-progress stream tail
|
|
515
606
|
* (open streaming fragments + the channel cursor they cover), so a client joining
|
|
516
|
-
* mid-stream can render the currently streaming message. Omitted when idle.
|
|
607
|
+
* mid-stream can render the currently streaming message. Omitted when idle. On
|
|
608
|
+
* windowed requests it rides TAIL pages only — a `before` page is immutable history
|
|
609
|
+
* and never carries it.
|
|
517
610
|
*/
|
|
518
611
|
live?: MessagesLiveTail;
|
|
612
|
+
/**
|
|
613
|
+
* Present exactly on windowed requests (`tailLimit` / `before`): `messages` is then
|
|
614
|
+
* the requested window (subagent pointers inside it expanded as usual) rather than
|
|
615
|
+
* the full transcript. See MessagesPageInfo.
|
|
616
|
+
*/
|
|
617
|
+
page?: MessagesPageInfo;
|
|
519
618
|
}
|
|
520
619
|
/**
|
|
521
620
|
* A single Prompt's input parts: text, image (data: / http(s) URL), or an uploaded file.
|
|
@@ -593,16 +692,41 @@ interface TaskCreateResponse {
|
|
|
593
692
|
* task POST).
|
|
594
693
|
*/
|
|
595
694
|
interface SteerRequest {
|
|
596
|
-
/** Message text (trimmed server-side); may be empty when `images` carries the message. */
|
|
695
|
+
/** Message text (trimmed server-side); may be empty when `images` or `files` carries the message. */
|
|
597
696
|
text: string;
|
|
598
697
|
/**
|
|
599
698
|
* Images sent with the steering message (`data:` or http(s) URLs, same rule as
|
|
600
699
|
* `TaskInputPart.image_url`): delivered as user image messages right behind the
|
|
601
700
|
* `[user_steering]` text. A model without vision receives them as scratchpad path lines
|
|
602
|
-
* instead, exactly as it would a Prompt's images. At least one of `text` / `images`
|
|
603
|
-
* be non-empty.
|
|
701
|
+
* instead, exactly as it would a Prompt's images. At least one of `text` / `images` /
|
|
702
|
+
* `files` must be non-empty.
|
|
604
703
|
*/
|
|
605
704
|
images?: string[];
|
|
705
|
+
/**
|
|
706
|
+
* File attachments riding the steering message — the same shape, caps and handling as a
|
|
707
|
+
* task input's `{type:"file"}` parts: written into the Session scratchpad and delivered
|
|
708
|
+
* as `[attached file: <path>]` lines on the `[user_steering]` text, so a file-only draft
|
|
709
|
+
* steers exactly like an image-only one instead of falling back to the follow-up queue.
|
|
710
|
+
*/
|
|
711
|
+
files?: {
|
|
712
|
+
fileName: string;
|
|
713
|
+
dataUrl: string;
|
|
714
|
+
}[];
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* One steering message queued on the server but not yet delivered to the model (delivery
|
|
718
|
+
* happens at the next input assembly between turns). Carried on `task_state` events and the
|
|
719
|
+
* SSE subscribe snapshot so the composer's "steering queued" hint — including what was sent —
|
|
720
|
+
* survives reloads; entries leave the list as their `[user_steering]` message appears on the
|
|
721
|
+
* stream, and the whole list drops when the run exits (core discards undelivered steering).
|
|
722
|
+
*/
|
|
723
|
+
interface PendingSteeringInfo {
|
|
724
|
+
/** The message text as accepted (trimmed); may be empty when images/files carry the message. */
|
|
725
|
+
text: string;
|
|
726
|
+
/** Number of images that rode along. */
|
|
727
|
+
images: number;
|
|
728
|
+
/** Number of file attachments that rode along. */
|
|
729
|
+
files: number;
|
|
606
730
|
}
|
|
607
731
|
interface ApprovalDecisionRequest {
|
|
608
732
|
decision: "allow" | "deny";
|
|
@@ -632,6 +756,8 @@ type ServerEvent =
|
|
|
632
756
|
type: "task_state";
|
|
633
757
|
state: SessionStatus;
|
|
634
758
|
queued?: number;
|
|
759
|
+
/** Steering messages queued but not yet delivered (absent = none): lets the composer's hint and its content survive reloads. */
|
|
760
|
+
pendingSteering?: PendingSteeringInfo[];
|
|
635
761
|
}
|
|
636
762
|
/** The model-generated title after the first turn has been persisted (for in-place list updates). */
|
|
637
763
|
| {
|
|
@@ -920,9 +1046,56 @@ interface AgentTraceDateGroup {
|
|
|
920
1046
|
date: string;
|
|
921
1047
|
sessions: AgentTraceSessionGroup[];
|
|
922
1048
|
}
|
|
923
|
-
/**
|
|
1049
|
+
/** One Trace file in the session-centric listing (`date` carried per file: one Session's shards can span date directories). */
|
|
1050
|
+
interface AgentTraceSessionFile {
|
|
1051
|
+
index: number;
|
|
1052
|
+
date: string;
|
|
1053
|
+
sizeBytes: number;
|
|
1054
|
+
}
|
|
1055
|
+
/** One Session's Trace files merged across date directories (the paginated listing's unit). */
|
|
1056
|
+
interface AgentTraceSessionEntry {
|
|
1057
|
+
sessionId: string;
|
|
1058
|
+
/**
|
|
1059
|
+
* Display title, resolved only for the returned page: the sessions DB title when one
|
|
1060
|
+
* exists, else derived from the Session's first user prompt (bounded head-read of the
|
|
1061
|
+
* earliest shard); absent when neither yields one (the client falls back to its
|
|
1062
|
+
* default title — raw session ids are never rendered).
|
|
1063
|
+
*/
|
|
1064
|
+
title?: string;
|
|
1065
|
+
/**
|
|
1066
|
+
* Sidebar category of this Session, from the same bounded classification the listing
|
|
1067
|
+
* filters and counts with: archived exactly from the DB row; origin from the shared
|
|
1068
|
+
* in-process registry / previously observed session_meta; a DB-untracked Session this
|
|
1069
|
+
* process has not yet head-read falls into `active` until a page surfaces it (its
|
|
1070
|
+
* head-read then registers the true origin for subsequent requests).
|
|
1071
|
+
*/
|
|
1072
|
+
category: SessionCategory;
|
|
1073
|
+
/** Workspace path locked at creation (DB row or observed session_meta); "" when unknown — the client's merged temp-group fallback. */
|
|
1074
|
+
workspace: string;
|
|
1075
|
+
/** Sorted by index ascending (a higher index is newer). */
|
|
1076
|
+
files: AgentTraceSessionFile[];
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Agent-level Trace browsing structure. Without `limit` the response is the legacy full
|
|
1080
|
+
* drill-down (`dates`: Agent → date → Session → Trace file, reverse chronological) and the
|
|
1081
|
+
* paging fields are absent. With `offset`/`limit` the response is session-group-centric:
|
|
1082
|
+
* `sessions` carries the requested slice (newest first by sessionId desc — ids embed a
|
|
1083
|
+
* timestamp, so that is reverse chronological) with titles and classification,
|
|
1084
|
+
* `totalSessions` the session-group count (within `category` when one is given, so paging
|
|
1085
|
+
* and the count agree), `counts` / `workspaceCounts` the per-category totals over ALL of
|
|
1086
|
+
* the Agent's session groups (folder labels / workspace-mode group headers), and `dates`
|
|
1087
|
+
* stays empty (per-file stats are only taken for the returned page).
|
|
1088
|
+
*/
|
|
924
1089
|
interface AgentTracesResponse {
|
|
925
1090
|
dates: AgentTraceDateGroup[];
|
|
1091
|
+
/** Present only when the request paginates: the requested slice of Session groups, newest first. */
|
|
1092
|
+
sessions?: AgentTraceSessionEntry[];
|
|
1093
|
+
/** Present only when the request paginates: session-group count of the paged (category-filtered) set. */
|
|
1094
|
+
totalSessions?: number;
|
|
1095
|
+
/** Present only when the request paginates: per-category totals over all of the Agent's session groups. */
|
|
1096
|
+
counts?: SessionCategoryCounts;
|
|
1097
|
+
/** Present only when the request paginates: `counts` broken down by Workspace path ("" = unknown). */
|
|
1098
|
+
workspaceCounts?: Record<string, SessionCategoryCounts>;
|
|
926
1099
|
}
|
|
927
1100
|
interface TraceImportRequest {
|
|
928
1101
|
/** Base64 of the Trace file content (JSON Lines; the first record must be `session_meta`). */
|
|
@@ -1174,6 +1347,7 @@ interface BenchmarkSummary {
|
|
|
1174
1347
|
interface BenchmarksResponse {
|
|
1175
1348
|
benchmarks: BenchmarkSummary[];
|
|
1176
1349
|
}
|
|
1350
|
+
type CaseMaterial = "statement" | "rubric";
|
|
1177
1351
|
/** Public Benchmark Case metadata. Rubric and Gold content are never included. */
|
|
1178
1352
|
interface BenchmarkCaseSummary {
|
|
1179
1353
|
id: string;
|
|
@@ -1216,6 +1390,18 @@ interface AgentSkillsResponse {
|
|
|
1216
1390
|
interface SkillInstallRequest {
|
|
1217
1391
|
names: string[];
|
|
1218
1392
|
}
|
|
1393
|
+
/**
|
|
1394
|
+
* POST /api/projects/:p/agents/:a/skills/archive: install one Skill from an uploaded zip.
|
|
1395
|
+
* Layout: SKILL.md at the zip root, or exactly one top-level directory containing SKILL.md
|
|
1396
|
+
* (the directory name is then the Skill name). 201 returns the refreshed installed list
|
|
1397
|
+
* (AgentSkillsResponse); an already-installed name without `overwrite` is 409 `skill_exists`.
|
|
1398
|
+
*/
|
|
1399
|
+
interface SkillArchiveInstallRequest {
|
|
1400
|
+
/** Base64-encoded zip archive (decoded size capped at 14MB, same as the Agent snapshot import). */
|
|
1401
|
+
dataBase64: string;
|
|
1402
|
+
/** Replace an already-installed Skill of the same name (deletes its directory first). */
|
|
1403
|
+
overwrite?: boolean;
|
|
1404
|
+
}
|
|
1219
1405
|
/** GET /api/version: the running server's release identity (from core's VERSION / BUILD_DATE). */
|
|
1220
1406
|
interface VersionResponse {
|
|
1221
1407
|
version: string;
|
|
@@ -1265,4 +1451,4 @@ interface UpdateRunResponse {
|
|
|
1265
1451
|
needsRestart: boolean;
|
|
1266
1452
|
}
|
|
1267
1453
|
|
|
1268
|
-
export type { AdminPasswordResetRequest, AdminUserCreateRequest, AdminUserCreateResponse, AdminUsersResponse, AgentCompactionConfigDto, AgentConfigDto, AgentConfigResponse, AgentConfigUpdateRequest, AgentCreateRequest, AgentCreateResponse, AgentImportRequest, AgentImportResponse, AgentModelConfigDto, AgentSkillsResponse, AgentSummary, AgentTraceDateGroup, AgentTraceFileRef, AgentTraceSessionGroup, AgentTracesResponse, AgentsResponse, ApprovalDecisionRequest, ApprovalMode, AuthLoginRequest, AuthResponse, BenchmarkCaseScore, BenchmarkCaseSummary, BenchmarkCasesResponse, BenchmarkEvaluation, BenchmarkRunScore, BenchmarkSummary, BenchmarksResponse, CredentialInfo, DirEntryInfo, DirListResponse, ErrorBody, FilesStatRequest, FilesStatResponse, GoalResponse, GoalServerEvent, GoalStateView, MeResponse, MemberAddRequest, MemberAddResponse, MemberInfo, MembersResponse, MessagesLiveTail, MessagesResponse, ModelInfo, ModelPricingDto, ModelRefDto, ModelTestRequest, ModelTestResponse, ModelUpdateEntry, ModelsResponse, ModelsUpdateRequest, PasswordChangeRequest, PrefsResponse, ProjectCreateRequest, ProjectCreateResponse, ProjectRole, ProjectSummary, ProjectUpdateRequest, ProjectUpdateResponse, ProjectsResponse, RequestSpan, RetryNowResponse, ScheduleItem, ScheduleServerEvent, ScheduleStatus, ScheduleUpsertRequest, SchedulesResponse, ServerEvent, SessionCategory, SessionCategoryCounts, SessionCreateRequest, SessionCreateResponse, SessionInfo, SessionPatchRequest, SessionResponse, SessionSource, SessionStatus, SessionTracesResponse, SessionsResponse, SkillGroupItem, SkillInstallRequest, SkillLibraryResponse, SkillMetadataItem, SteerRequest, TaskCreateRequest, TaskCreateResponse, TaskInputPart, ToolCallSpan, TraceAnalysisResponse, TraceEventsResponse, TraceFileInfo, TraceImportRequest, TraceImportResponse, TraceModelSegment, TraceTaskStats, TraceToolSpan, UiPrefs, UpdateCheckResponse, UpdateRunResponse, UsageAgentCount, UsageBucket, UsageErrorCount, UsageErrorItem, UsageErrors, UsageErrorsPage, UsageGroupBy, UsageGroupRow, UsageResponse, UsageSuccessRate, UsageTrendPoint, UsageTrendPointInTrace, UserInfo, VaultEntryInfo, VaultEntryUpdate, VaultResponse, VaultUpdateRequest, VersionResponse, WorkspaceFileEntry, WorkspaceFilesResponse };
|
|
1454
|
+
export type { AdminPasswordResetRequest, AdminUserCreateRequest, AdminUserCreateResponse, AdminUsersResponse, AgentCompactionConfigDto, AgentConfigDto, AgentConfigResponse, AgentConfigUpdateRequest, AgentCreateRequest, AgentCreateResponse, AgentImportRequest, AgentImportResponse, AgentModelConfigDto, AgentSkillsResponse, AgentSummary, AgentTraceDateGroup, AgentTraceFileRef, AgentTraceSessionEntry, AgentTraceSessionFile, AgentTraceSessionGroup, AgentTracesResponse, AgentsResponse, ApprovalDecisionRequest, ApprovalMode, AuthLoginRequest, AuthResponse, BenchmarkCaseScore, BenchmarkCaseSummary, BenchmarkCasesResponse, BenchmarkEvaluation, BenchmarkRunScore, BenchmarkSummary, BenchmarksResponse, CaseMaterial, ChatDefaultsDto, CredentialInfo, DefaultModelResponse, DefaultModelUpdateRequest, DirEntryInfo, DirListResponse, ErrorBody, FilesStatRequest, FilesStatResponse, GoalResponse, GoalServerEvent, GoalStateView, MeResponse, MemberAddRequest, MemberAddResponse, MemberInfo, MembersResponse, MessagesLiveTail, MessagesPageInfo, MessagesResponse, ModelInfo, ModelPricingDto, ModelRefDto, ModelTestRequest, ModelTestResponse, ModelUpdateEntry, ModelsResponse, ModelsUpdateRequest, PasswordChangeRequest, PendingSteeringInfo, PrefsResponse, ProjectCreateRequest, ProjectCreateResponse, ProjectRole, ProjectSummary, ProjectUpdateRequest, ProjectUpdateResponse, ProjectsResponse, RequestSpan, RetryNowResponse, ScheduleItem, ScheduleServerEvent, ScheduleStatus, ScheduleUpsertRequest, SchedulesResponse, ServerEvent, SessionCategory, SessionCategoryCounts, SessionCreateRequest, SessionCreateResponse, SessionInfo, SessionPatchRequest, SessionResponse, SessionSource, SessionStatus, SessionTracesResponse, SessionsResponse, SkillArchiveInstallRequest, SkillGroupItem, SkillInstallRequest, SkillLibraryResponse, SkillMetadataItem, SteerRequest, TaskCreateRequest, TaskCreateResponse, TaskInputPart, ToolCallSpan, TraceAnalysisResponse, TraceEventsResponse, TraceFileInfo, TraceImportRequest, TraceImportResponse, TraceModelSegment, TraceTaskStats, TraceToolSpan, UiPrefs, UpdateCheckResponse, UpdateRunResponse, UsageAgentCount, UsageBucket, UsageErrorCount, UsageErrorItem, UsageErrors, UsageErrorsPage, UsageGroupBy, UsageGroupRow, UsageResponse, UsageSuccessRate, UsageTrendPoint, UsageTrendPointInTrace, UserInfo, VaultEntryInfo, VaultEntryUpdate, VaultResponse, VaultUpdateRequest, VersionResponse, WorkspaceFileEntry, WorkspaceFilesResponse };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// src/lock.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import net from "net";
|
|
4
|
+
import path from "path";
|
|
5
|
+
var PROBE_TIMEOUT_MS = 500;
|
|
6
|
+
function serverLockPath(root) {
|
|
7
|
+
return path.join(root, "server.lock");
|
|
8
|
+
}
|
|
9
|
+
function readServerLock(root) {
|
|
10
|
+
let raw;
|
|
11
|
+
try {
|
|
12
|
+
raw = fs.readFileSync(serverLockPath(root), "utf8");
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || typeof parsed.port !== "number" || !Number.isInteger(parsed.port)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return { pid: parsed.pid, port: parsed.port, startedAt: String(parsed.startedAt ?? "") };
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function pidAlive(pid) {
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return err.code === "EPERM";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function portAccepts(port) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const socket = net.connect({ host: "127.0.0.1", port, timeout: PROBE_TIMEOUT_MS });
|
|
37
|
+
const done = (ok) => {
|
|
38
|
+
socket.destroy();
|
|
39
|
+
resolve(ok);
|
|
40
|
+
};
|
|
41
|
+
socket.once("connect", () => done(true));
|
|
42
|
+
socket.once("timeout", () => done(false));
|
|
43
|
+
socket.once("error", () => done(false));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function isServerLockAlive(lock) {
|
|
47
|
+
return pidAlive(lock.pid) && await portAccepts(lock.port);
|
|
48
|
+
}
|
|
49
|
+
async function liveServerLock(root) {
|
|
50
|
+
const lock = readServerLock(root);
|
|
51
|
+
if (!lock) return null;
|
|
52
|
+
return await isServerLockAlive(lock) ? lock : null;
|
|
53
|
+
}
|
|
54
|
+
function acquireServerLock(root, lock) {
|
|
55
|
+
const target = serverLockPath(root);
|
|
56
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
57
|
+
fs.writeFileSync(tmp, JSON.stringify(lock) + "\n");
|
|
58
|
+
fs.renameSync(tmp, target);
|
|
59
|
+
}
|
|
60
|
+
function releaseServerLock(root) {
|
|
61
|
+
try {
|
|
62
|
+
const lock = readServerLock(root);
|
|
63
|
+
if (lock && lock.pid === process.pid) fs.rmSync(serverLockPath(root));
|
|
64
|
+
} catch {
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
serverLockPath,
|
|
70
|
+
readServerLock,
|
|
71
|
+
isServerLockAlive,
|
|
72
|
+
liveServerLock,
|
|
73
|
+
acquireServerLock,
|
|
74
|
+
releaseServerLock
|
|
75
|
+
};
|
|
76
|
+
//# sourceMappingURL=chunk-V6KH4FRG.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/lock.ts"],"sourcesContent":["/**\n * Root-level server instance lock (`<root>/server.lock`).\n *\n * web.db is single-process / single-writer (see db/database.ts), and two servers on one\n * data root would also double-run the schedule scheduler — so a data root admits one\n * server at a time. The lock records {pid, port, startedAt}; liveness requires BOTH the\n * pid to be alive AND the recorded port to accept a TCP connection, because either signal\n * alone false-positives (pids get recycled, ports get taken by unrelated processes). A\n * lock that fails the liveness check is stale and is simply overwritten by the next\n * server.\n *\n * Published as `@prismshadow/penguin-server/lock` (side-effect-free) so the CLI and the\n * desktop shell can pre-check a root without importing the package entry, which starts\n * listening. Docs: design § \"桌面端原型 · 数据根与实例互斥\".\n */\nimport fs from \"node:fs\";\nimport net from \"node:net\";\nimport path from \"node:path\";\n\nexport interface ServerLock {\n pid: number;\n port: number;\n startedAt: string;\n}\n\n/** TCP probe budget: loopback either connects immediately or the port is dead. */\nconst PROBE_TIMEOUT_MS = 500;\n\nexport function serverLockPath(root: string): string {\n return path.join(root, \"server.lock\");\n}\n\n/** Reads the lock file; a missing or malformed file reads as \"no lock\". */\nexport function readServerLock(root: string): ServerLock | null {\n let raw: string;\n try {\n raw = fs.readFileSync(serverLockPath(root), \"utf8\");\n } catch {\n return null;\n }\n try {\n const parsed = JSON.parse(raw) as Partial<ServerLock>;\n if (\n typeof parsed.pid !== \"number\" ||\n !Number.isInteger(parsed.pid) ||\n typeof parsed.port !== \"number\" ||\n !Number.isInteger(parsed.port)\n ) {\n return null;\n }\n return { pid: parsed.pid, port: parsed.port, startedAt: String(parsed.startedAt ?? \"\") };\n } catch {\n return null;\n }\n}\n\nfunction pidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (err) {\n // EPERM = the process exists but belongs to another user — still alive.\n return (err as NodeJS.ErrnoException).code === \"EPERM\";\n }\n}\n\nfunction portAccepts(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n // 127.0.0.1 rather than localhost: this is a raw TCP liveness probe, not an App\n // request, and the server binds 127.0.0.1 (plus ::1) on loopback setups.\n const socket = net.connect({ host: \"127.0.0.1\", port, timeout: PROBE_TIMEOUT_MS });\n const done = (ok: boolean) => {\n socket.destroy();\n resolve(ok);\n };\n socket.once(\"connect\", () => done(true));\n socket.once(\"timeout\", () => done(false));\n socket.once(\"error\", () => done(false));\n });\n}\n\n/** True when the lock's process is alive AND its port accepts connections. */\nexport async function isServerLockAlive(lock: ServerLock): Promise<boolean> {\n return pidAlive(lock.pid) && (await portAccepts(lock.port));\n}\n\n/** Convenience for pre-checks: the live lock on this root, or null (absent or stale). */\nexport async function liveServerLock(root: string): Promise<ServerLock | null> {\n const lock = readServerLock(root);\n if (!lock) return null;\n return (await isServerLockAlive(lock)) ? lock : null;\n}\n\n/** Writes the lock atomically (tmp + rename; the parent directory must already exist). */\nexport function acquireServerLock(root: string, lock: ServerLock): void {\n const target = serverLockPath(root);\n const tmp = `${target}.${process.pid}.tmp`;\n fs.writeFileSync(tmp, JSON.stringify(lock) + \"\\n\");\n fs.renameSync(tmp, target);\n}\n\n/** Removes the lock if it is still ours (best-effort; never throws on shutdown paths). */\nexport function releaseServerLock(root: string): void {\n try {\n const lock = readServerLock(root);\n if (lock && lock.pid === process.pid) fs.rmSync(serverLockPath(root));\n } catch {\n // Best-effort: a stale leftover is overwritten by the next server anyway.\n }\n}\n"],"mappings":";AAeA,OAAO,QAAQ;AACf,OAAO,SAAS;AAChB,OAAO,UAAU;AASjB,IAAM,mBAAmB;AAElB,SAAS,eAAe,MAAsB;AACnD,SAAO,KAAK,KAAK,MAAM,aAAa;AACtC;AAGO,SAAS,eAAe,MAAiC;AAC9D,MAAI;AACJ,MAAI;AACF,UAAM,GAAG,aAAa,eAAe,IAAI,GAAG,MAAM;AAAA,EACpD,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,QACE,OAAO,OAAO,QAAQ,YACtB,CAAC,OAAO,UAAU,OAAO,GAAG,KAC5B,OAAO,OAAO,SAAS,YACvB,CAAC,OAAO,UAAU,OAAO,IAAI,GAC7B;AACA,aAAO;AAAA,IACT;AACA,WAAO,EAAE,KAAK,OAAO,KAAK,MAAM,OAAO,MAAM,WAAW,OAAO,OAAO,aAAa,EAAE,EAAE;AAAA,EACzF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,SAAS,KAAsB;AACtC,MAAI;AACF,YAAQ,KAAK,KAAK,CAAC;AACnB,WAAO;AAAA,EACT,SAAS,KAAK;AAEZ,WAAQ,IAA8B,SAAS;AAAA,EACjD;AACF;AAEA,SAAS,YAAY,MAAgC;AACnD,SAAO,IAAI,QAAQ,CAAC,YAAY;AAG9B,UAAM,SAAS,IAAI,QAAQ,EAAE,MAAM,aAAa,MAAM,SAAS,iBAAiB,CAAC;AACjF,UAAM,OAAO,CAAC,OAAgB;AAC5B,aAAO,QAAQ;AACf,cAAQ,EAAE;AAAA,IACZ;AACA,WAAO,KAAK,WAAW,MAAM,KAAK,IAAI,CAAC;AACvC,WAAO,KAAK,WAAW,MAAM,KAAK,KAAK,CAAC;AACxC,WAAO,KAAK,SAAS,MAAM,KAAK,KAAK,CAAC;AAAA,EACxC,CAAC;AACH;AAGA,eAAsB,kBAAkB,MAAoC;AAC1E,SAAO,SAAS,KAAK,GAAG,KAAM,MAAM,YAAY,KAAK,IAAI;AAC3D;AAGA,eAAsB,eAAe,MAA0C;AAC7E,QAAM,OAAO,eAAe,IAAI;AAChC,MAAI,CAAC,KAAM,QAAO;AAClB,SAAQ,MAAM,kBAAkB,IAAI,IAAK,OAAO;AAClD;AAGO,SAAS,kBAAkB,MAAc,MAAwB;AACtE,QAAM,SAAS,eAAe,IAAI;AAClC,QAAM,MAAM,GAAG,MAAM,IAAI,QAAQ,GAAG;AACpC,KAAG,cAAc,KAAK,KAAK,UAAU,IAAI,IAAI,IAAI;AACjD,KAAG,WAAW,KAAK,MAAM;AAC3B;AAGO,SAAS,kBAAkB,MAAoB;AACpD,MAAI;AACF,UAAM,OAAO,eAAe,IAAI;AAChC,QAAI,QAAQ,KAAK,QAAQ,QAAQ,IAAK,IAAG,OAAO,eAAe,IAAI,CAAC;AAAA,EACtE,QAAQ;AAAA,EAER;AACF;","names":[]}
|