@prismshadow/penguin-server 0.2.0 → 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 +156 -4
- package/dist/chunk-V6KH4FRG.js +76 -0
- package/dist/chunk-V6KH4FRG.js.map +1 -0
- package/dist/index.js +3696 -2074
- 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-BgHHHLga.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-BOvgZOwz.js +0 -173
- package/web-dist/assets/index-DGdg-i_E.css +0 -1
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
|
}
|
|
@@ -290,6 +305,42 @@ interface ModelTestResponse {
|
|
|
290
305
|
tps?: number;
|
|
291
306
|
message?: string;
|
|
292
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
|
+
}
|
|
293
344
|
/** Read-only vault entry display: key name + masked value; plaintext is never sent. */
|
|
294
345
|
interface VaultEntryInfo {
|
|
295
346
|
key: string;
|
|
@@ -513,15 +564,57 @@ interface MessagesLiveTail {
|
|
|
513
564
|
cursor: string;
|
|
514
565
|
fragments: OmniMessage[];
|
|
515
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
|
+
}
|
|
516
601
|
/** Message history: the full messages and events from concatenating all of this Session's Trace files in order (excludes partial_*). */
|
|
517
602
|
interface MessagesResponse {
|
|
518
603
|
messages: OmniMessage[];
|
|
519
604
|
/**
|
|
520
605
|
* Present only while the Session is running/compacting: the in-progress stream tail
|
|
521
606
|
* (open streaming fragments + the channel cursor they cover), so a client joining
|
|
522
|
-
* 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.
|
|
523
610
|
*/
|
|
524
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;
|
|
525
618
|
}
|
|
526
619
|
/**
|
|
527
620
|
* A single Prompt's input parts: text, image (data: / http(s) URL), or an uploaded file.
|
|
@@ -953,9 +1046,56 @@ interface AgentTraceDateGroup {
|
|
|
953
1046
|
date: string;
|
|
954
1047
|
sessions: AgentTraceSessionGroup[];
|
|
955
1048
|
}
|
|
956
|
-
/**
|
|
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
|
+
*/
|
|
957
1089
|
interface AgentTracesResponse {
|
|
958
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>;
|
|
959
1099
|
}
|
|
960
1100
|
interface TraceImportRequest {
|
|
961
1101
|
/** Base64 of the Trace file content (JSON Lines; the first record must be `session_meta`). */
|
|
@@ -1250,6 +1390,18 @@ interface AgentSkillsResponse {
|
|
|
1250
1390
|
interface SkillInstallRequest {
|
|
1251
1391
|
names: string[];
|
|
1252
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
|
+
}
|
|
1253
1405
|
/** GET /api/version: the running server's release identity (from core's VERSION / BUILD_DATE). */
|
|
1254
1406
|
interface VersionResponse {
|
|
1255
1407
|
version: string;
|
|
@@ -1299,4 +1451,4 @@ interface UpdateRunResponse {
|
|
|
1299
1451
|
needsRestart: boolean;
|
|
1300
1452
|
}
|
|
1301
1453
|
|
|
1302
|
-
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, CaseMaterial, 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, 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, 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":[]}
|