@centerforagenticai/pi-multi-account 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +29 -0
- package/README.md +999 -0
- package/config/models/pi-multi-account.v1.json +32 -0
- package/config/subscription-plans.v1.json +122 -0
- package/package.json +76 -0
- package/packages/pi-anthropic-oauth/LICENSE +21 -0
- package/packages/pi-anthropic-oauth/package.json +54 -0
- package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
- package/packages/pi-anthropic-oauth/src/context.ts +116 -0
- package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
- package/packages/pi-anthropic-oauth/src/index.ts +37 -0
- package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
- package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
- package/packages/pi-antigravity/LICENSE +21 -0
- package/packages/pi-antigravity/package.json +77 -0
- package/packages/pi-antigravity/src/auth/index.ts +14 -0
- package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
- package/packages/pi-antigravity/src/client/client.ts +561 -0
- package/packages/pi-antigravity/src/client/index.ts +1 -0
- package/packages/pi-antigravity/src/context.ts +110 -0
- package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
- package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
- package/packages/pi-antigravity/src/image/image.ts +336 -0
- package/packages/pi-antigravity/src/image/index.ts +1 -0
- package/packages/pi-antigravity/src/index.ts +280 -0
- package/packages/pi-antigravity/src/models/discovery.ts +154 -0
- package/packages/pi-antigravity/src/models/grouping.ts +424 -0
- package/packages/pi-antigravity/src/models/index.ts +3 -0
- package/packages/pi-antigravity/src/models/models.ts +500 -0
- package/packages/pi-antigravity/src/stream/index.ts +1 -0
- package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
- package/packages/pi-antigravity/src/types/enums.ts +42 -0
- package/packages/pi-antigravity/src/types/index.ts +2 -0
- package/packages/pi-antigravity/src/types/types.ts +292 -0
- package/packages/pi-antigravity/src/usage/index.ts +1 -0
- package/packages/pi-antigravity/src/usage/usage.ts +416 -0
- package/packages/pi-antigravity/src/utils/http.ts +91 -0
- package/packages/pi-antigravity/src/utils/index.ts +3 -0
- package/packages/pi-antigravity/src/utils/security.ts +73 -0
- package/packages/pi-antigravity/src/utils/util.ts +132 -0
- package/scripts/multi-account.mjs +44 -0
- package/src/account-labels.ts +223 -0
- package/src/account-plan-assignment.ts +340 -0
- package/src/account-rate-history.ts +372 -0
- package/src/anthropic-adaptive-stream.ts +531 -0
- package/src/anthropic-alias-stream.ts +140 -0
- package/src/anthropic-context-compat.ts +80 -0
- package/src/api-pricing.ts +579 -0
- package/src/bounded-file-lines.ts +97 -0
- package/src/catalog-rebinding.ts +177 -0
- package/src/catalog-registration-probe.ts +111 -0
- package/src/codex-adapter.ts +345 -0
- package/src/codex-model-defaults.ts +785 -0
- package/src/command-completions.ts +404 -0
- package/src/commands.ts +2000 -0
- package/src/compaction.ts +14 -0
- package/src/config.ts +1317 -0
- package/src/continuation.ts +569 -0
- package/src/cooldowns.ts +110 -0
- package/src/cost-digest-store.ts +332 -0
- package/src/cost-digest.ts +1044 -0
- package/src/cost-history.ts +251 -0
- package/src/cost-period-closer.ts +160 -0
- package/src/cost-report-json.ts +318 -0
- package/src/cost-report-reader.ts +368 -0
- package/src/cost-report-render.ts +207 -0
- package/src/cost-report.ts +1104 -0
- package/src/coverage-attestation.ts +397 -0
- package/src/credential-lifecycle.ts +169 -0
- package/src/credential-refresh.ts +248 -0
- package/src/declaration-notice-marker.ts +238 -0
- package/src/diagnostic-store.ts +276 -0
- package/src/diagnostics.ts +309 -0
- package/src/discovery.ts +471 -0
- package/src/duration.ts +13 -0
- package/src/error-classification.ts +256 -0
- package/src/fuzzy.ts +15 -0
- package/src/group-policy.ts +81 -0
- package/src/history-store.ts +897 -0
- package/src/index.ts +5572 -0
- package/src/lifecycle.ts +378 -0
- package/src/logical-dispatch.ts +279 -0
- package/src/logical-model-selector.ts +254 -0
- package/src/logical-model-switcher.ts +430 -0
- package/src/logical-provider-attribution.ts +544 -0
- package/src/logical-provider.ts +1237 -0
- package/src/logical-route-indicator.ts +215 -0
- package/src/machine-lease.ts +445 -0
- package/src/model-support.ts +66 -0
- package/src/models-declaration.ts +1091 -0
- package/src/openai-adapter.ts +117 -0
- package/src/openrouter-budget.ts +304 -0
- package/src/openrouter-fallback.ts +146 -0
- package/src/period-boundaries.ts +376 -0
- package/src/pi-anthropic-oauth.d.ts +6 -0
- package/src/preflight.ts +253 -0
- package/src/pricing-cache.ts +235 -0
- package/src/project-identity.ts +100 -0
- package/src/provider-registration.ts +942 -0
- package/src/rate-formula.ts +163 -0
- package/src/recovery-engine.ts +853 -0
- package/src/recovery-output.ts +837 -0
- package/src/recovery-plan.ts +239 -0
- package/src/report-range.ts +203 -0
- package/src/route-resolver.ts +789 -0
- package/src/routing-config-transaction.ts +232 -0
- package/src/routing.ts +1163 -0
- package/src/runtime-state.ts +630 -0
- package/src/session-account-groups.ts +284 -0
- package/src/session-restore.ts +287 -0
- package/src/shared-usage.ts +1392 -0
- package/src/standalone-cli.ts +720 -0
- package/src/status-view.ts +578 -0
- package/src/subscription-plan-catalog.ts +346 -0
- package/src/tier-model-resolver.ts +46 -0
- package/src/upstream-anthropic.ts +315 -0
- package/src/upstream-antigravity.ts +327 -0
- package/src/usage-fetch.ts +1634 -0
- package/src/usage.ts +1026 -0
- package/src/vendor.ts +87 -0
- package/src/warmer.ts +231 -0
- package/src/watchdog.ts +219 -0
- package/src/window-history.ts +270 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { normalizeLabel } from "./account-labels.js";
|
|
2
|
+
import type { ManagedFamily } from "./config.js";
|
|
3
|
+
import { LOGICAL_PROVIDER_ID } from "./models-declaration.js";
|
|
4
|
+
|
|
5
|
+
export type LogicalRouteIndicatorPhase =
|
|
6
|
+
| "waiting"
|
|
7
|
+
| "trying"
|
|
8
|
+
| "completed"
|
|
9
|
+
| "failed"
|
|
10
|
+
| "cancelled";
|
|
11
|
+
|
|
12
|
+
export type LogicalRouteIndicatorRoute = Readonly<{
|
|
13
|
+
providerId: string;
|
|
14
|
+
family: ManagedFamily;
|
|
15
|
+
}>;
|
|
16
|
+
|
|
17
|
+
export type LogicalRouteUsage =
|
|
18
|
+
| Readonly<{
|
|
19
|
+
status: "fresh";
|
|
20
|
+
utilization?: number;
|
|
21
|
+
remainingRequests?: number;
|
|
22
|
+
remainingTokens?: number;
|
|
23
|
+
recoveryAtMs?: number;
|
|
24
|
+
}>
|
|
25
|
+
| Readonly<{ status: "stale" }>
|
|
26
|
+
| Readonly<{ status: "missing" }>;
|
|
27
|
+
|
|
28
|
+
export interface LogicalRouteIndicatorAttempt {
|
|
29
|
+
completed(): void;
|
|
30
|
+
failed(): void;
|
|
31
|
+
cancelled(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LogicalRouteIndicator {
|
|
35
|
+
modelSelected(providerId: string | undefined): void;
|
|
36
|
+
beginAttempt(route: LogicalRouteIndicatorRoute): LogicalRouteIndicatorAttempt;
|
|
37
|
+
usageChanged(providerId: string): void;
|
|
38
|
+
settle(): void;
|
|
39
|
+
shutdown(): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LogicalRouteIndicatorDependencies {
|
|
43
|
+
publish(text: string | undefined): void;
|
|
44
|
+
usage(providerId: string, nowMs: number): LogicalRouteUsage;
|
|
45
|
+
configuredLabel(providerId: string): string | undefined;
|
|
46
|
+
now(): number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type DisplayedAttempt = Readonly<{
|
|
50
|
+
phase: Exclude<LogicalRouteIndicatorPhase, "waiting">;
|
|
51
|
+
route: LogicalRouteIndicatorRoute;
|
|
52
|
+
}>;
|
|
53
|
+
|
|
54
|
+
const NOOP_ATTEMPT: LogicalRouteIndicatorAttempt = Object.freeze({
|
|
55
|
+
completed() {},
|
|
56
|
+
failed() {},
|
|
57
|
+
cancelled() {},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function validUtilization(value: number | undefined): value is number {
|
|
61
|
+
return (
|
|
62
|
+
typeof value === "number" &&
|
|
63
|
+
Number.isFinite(value) &&
|
|
64
|
+
value >= 0 &&
|
|
65
|
+
value <= 1
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validCount(value: number | undefined): value is number {
|
|
70
|
+
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function compactCount(value: number): string {
|
|
74
|
+
if (value < 1_000) return String(value);
|
|
75
|
+
if (value < 1_000_000) {
|
|
76
|
+
const thousands = value / 1_000;
|
|
77
|
+
return `${thousands < 100 ? thousands.toFixed(1) : Math.round(thousands)}k`;
|
|
78
|
+
}
|
|
79
|
+
const millions = value / 1_000_000;
|
|
80
|
+
return `${millions < 100 ? millions.toFixed(1) : Math.round(millions)}M`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function compactDuration(milliseconds: number): string {
|
|
84
|
+
const minute = 60_000;
|
|
85
|
+
const hour = 60 * minute;
|
|
86
|
+
const day = 24 * hour;
|
|
87
|
+
if (milliseconds < hour) return `${Math.max(1, Math.ceil(milliseconds / minute))}m`;
|
|
88
|
+
if (milliseconds < 48 * hour) return `${Math.ceil(milliseconds / hour)}h`;
|
|
89
|
+
return `${Math.ceil(milliseconds / day)}d`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function usageText(usage: LogicalRouteUsage, nowMs: number): string {
|
|
93
|
+
if (usage.status === "missing") return "usage unknown";
|
|
94
|
+
if (usage.status === "stale") return "usage stale";
|
|
95
|
+
|
|
96
|
+
const parts: string[] = [];
|
|
97
|
+
if (validUtilization(usage.utilization)) {
|
|
98
|
+
parts.push(`${Math.round((1 - usage.utilization) * 100)}% left`);
|
|
99
|
+
} else {
|
|
100
|
+
if (validCount(usage.remainingRequests)) {
|
|
101
|
+
parts.push(`${compactCount(usage.remainingRequests)} req left`);
|
|
102
|
+
}
|
|
103
|
+
if (validCount(usage.remainingTokens)) {
|
|
104
|
+
parts.push(`${compactCount(usage.remainingTokens)} tok left`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (parts.length === 0) return "usage stale";
|
|
108
|
+
if (
|
|
109
|
+
typeof usage.recoveryAtMs === "number" &&
|
|
110
|
+
Number.isFinite(usage.recoveryAtMs) &&
|
|
111
|
+
usage.recoveryAtMs > nowMs
|
|
112
|
+
) {
|
|
113
|
+
parts.push(`resets in ${compactDuration(usage.recoveryAtMs - nowMs)}`);
|
|
114
|
+
}
|
|
115
|
+
return parts.join(" · ");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Creates one host-neutral route indicator for one extension session. */
|
|
119
|
+
export function createLogicalRouteIndicator(
|
|
120
|
+
dependencies: LogicalRouteIndicatorDependencies,
|
|
121
|
+
): LogicalRouteIndicator {
|
|
122
|
+
let generation = 0;
|
|
123
|
+
let logicalSelected = false;
|
|
124
|
+
let displayed: "waiting" | DisplayedAttempt | undefined;
|
|
125
|
+
|
|
126
|
+
const publish = (text: string | undefined): void => {
|
|
127
|
+
try {
|
|
128
|
+
dependencies.publish(text);
|
|
129
|
+
} catch {
|
|
130
|
+
// The widget is observational; its sink cannot affect provider lifecycle.
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const render = (): void => {
|
|
135
|
+
if (!logicalSelected || displayed === undefined) {
|
|
136
|
+
publish(undefined);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (displayed === "waiting") {
|
|
140
|
+
publish("unified(waiting)");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const nowMs = dependencies.now();
|
|
144
|
+
let usage: LogicalRouteUsage = { status: "missing" };
|
|
145
|
+
try {
|
|
146
|
+
usage = dependencies.usage(displayed.route.providerId, nowMs);
|
|
147
|
+
} catch {
|
|
148
|
+
// A display projection failure is unknown usage, never a routing failure.
|
|
149
|
+
}
|
|
150
|
+
let label: string | undefined;
|
|
151
|
+
try {
|
|
152
|
+
label = normalizeLabel(
|
|
153
|
+
dependencies.configuredLabel(displayed.route.providerId),
|
|
154
|
+
);
|
|
155
|
+
} catch {
|
|
156
|
+
// Labels are optional presentation data.
|
|
157
|
+
}
|
|
158
|
+
publish(
|
|
159
|
+
`unified(${displayed.route.providerId} · ${usageText(usage, nowMs)}` +
|
|
160
|
+
(label === undefined ? ")" : ` · ${label})`),
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
modelSelected(providerId) {
|
|
166
|
+
generation += 1;
|
|
167
|
+
logicalSelected = providerId === LOGICAL_PROVIDER_ID;
|
|
168
|
+
displayed = logicalSelected ? "waiting" : undefined;
|
|
169
|
+
render();
|
|
170
|
+
},
|
|
171
|
+
beginAttempt(route) {
|
|
172
|
+
const attemptGeneration = generation;
|
|
173
|
+
if (!logicalSelected) return NOOP_ATTEMPT;
|
|
174
|
+
const attemptRoute: LogicalRouteIndicatorRoute = Object.freeze({
|
|
175
|
+
providerId: route.providerId,
|
|
176
|
+
family: route.family,
|
|
177
|
+
});
|
|
178
|
+
displayed = { phase: "trying", route: attemptRoute };
|
|
179
|
+
render();
|
|
180
|
+
const settleAttempt = (phase: DisplayedAttempt["phase"]): void => {
|
|
181
|
+
if (!logicalSelected || generation !== attemptGeneration) return;
|
|
182
|
+
displayed = { phase, route: attemptRoute };
|
|
183
|
+
render();
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
completed: () => settleAttempt("completed"),
|
|
187
|
+
failed: () => settleAttempt("failed"),
|
|
188
|
+
cancelled: () => settleAttempt("cancelled"),
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
usageChanged(providerId) {
|
|
192
|
+
if (
|
|
193
|
+
!logicalSelected ||
|
|
194
|
+
typeof displayed !== "object" ||
|
|
195
|
+
displayed.route.providerId !== providerId
|
|
196
|
+
) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
render();
|
|
200
|
+
},
|
|
201
|
+
settle() {
|
|
202
|
+
generation += 1;
|
|
203
|
+
if (typeof displayed === "object" && displayed.phase === "trying") {
|
|
204
|
+
displayed = { phase: "failed", route: displayed.route };
|
|
205
|
+
render();
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
shutdown() {
|
|
209
|
+
generation += 1;
|
|
210
|
+
logicalSelected = false;
|
|
211
|
+
displayed = undefined;
|
|
212
|
+
publish(undefined);
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
fstatSync,
|
|
5
|
+
ftruncateSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
fsyncSync,
|
|
8
|
+
linkSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
openSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
17
|
+
import { hostname as readHostname } from "node:os";
|
|
18
|
+
import { dirname } from "node:path";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
21
|
+
const MAX_RECORD_BYTES = 2_048;
|
|
22
|
+
const TOKEN_PATTERN = /^[a-f0-9]{16,128}$/;
|
|
23
|
+
|
|
24
|
+
export interface MachineLeaseRecord {
|
|
25
|
+
readonly pid: number;
|
|
26
|
+
readonly hostname: string;
|
|
27
|
+
readonly token: string;
|
|
28
|
+
readonly acquiredAtMs: number;
|
|
29
|
+
readonly expiresAtMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MachineLeaseOptions {
|
|
33
|
+
readonly lockPath: string;
|
|
34
|
+
readonly ttlMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Wall clock used for expiry and for the malformed-record age gate. It must
|
|
37
|
+
* be the same clock as filesystem mtime: the age gate subtracts an mtime
|
|
38
|
+
* from this, so a clock running ahead of the filesystem ages every record
|
|
39
|
+
* and can reclaim a live one.
|
|
40
|
+
*/
|
|
41
|
+
readonly now?: () => number;
|
|
42
|
+
readonly pid?: number;
|
|
43
|
+
readonly hostname?: string;
|
|
44
|
+
readonly tokenFactory?: () => string;
|
|
45
|
+
/** Test seam for pausing a stale reclaim before its atomic rename. */
|
|
46
|
+
readonly beforeReclaimRename?: () => void;
|
|
47
|
+
/** Test seam for pausing a stale reclaim before it restores the moved record. */
|
|
48
|
+
readonly beforeReclaimRestore?: () => void;
|
|
49
|
+
/** Test seam for inspecting the complete record after fsync and before publication. */
|
|
50
|
+
readonly beforePublish?: (temporaryPath: string) => void;
|
|
51
|
+
/** Test seam for racing the moved record between release's rename and its verification. */
|
|
52
|
+
readonly beforeReleaseVerify?: (tombstonePath: string) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Reclaim a lock whose stored record cannot be read, so a contentless or
|
|
55
|
+
* corrupt lock recovers instead of refusing every caller forever. The
|
|
56
|
+
* reclaim is additionally gated on a regular file, a finite mtime, an age of
|
|
57
|
+
* at least this lease's ttl, and the same move-verify-unlink identity check
|
|
58
|
+
* as a stale reclaim, so it can never displace a live holder.
|
|
59
|
+
*
|
|
60
|
+
* That guarantee assumes `now` is the same wall clock as filesystem mtime.
|
|
61
|
+
* The age gate compares the two directly, so a caller injecting a clock
|
|
62
|
+
* ahead of the filesystem defeats it. For a valid record the gate is
|
|
63
|
+
* equivalent to the expiry check anyway, because mtime is the write time
|
|
64
|
+
* and `expiresAtMs` is that write time plus the ttl, so reclaiming a
|
|
65
|
+
* malformed record grants nothing an expired one would not.
|
|
66
|
+
*/
|
|
67
|
+
readonly reclaimMalformed?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface MachineLeaseHandle {
|
|
71
|
+
readonly record: MachineLeaseRecord;
|
|
72
|
+
/** The caller must renew before this interval elapses. */
|
|
73
|
+
readonly renewalIntervalMs: number;
|
|
74
|
+
renew(): boolean;
|
|
75
|
+
release(): boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function assertFiniteTimestamp(value: number, name: string): void {
|
|
79
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
80
|
+
throw new TypeError(`${name} must be a finite non-negative timestamp.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validRecord(value: unknown): value is MachineLeaseRecord {
|
|
85
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
const record = value as Record<string, unknown>;
|
|
89
|
+
return (
|
|
90
|
+
Number.isSafeInteger(record.pid) &&
|
|
91
|
+
(record.pid as number) > 0 &&
|
|
92
|
+
typeof record.hostname === "string" &&
|
|
93
|
+
record.hostname.length > 0 &&
|
|
94
|
+
record.hostname.length <= 255 &&
|
|
95
|
+
typeof record.token === "string" &&
|
|
96
|
+
TOKEN_PATTERN.test(record.token) &&
|
|
97
|
+
typeof record.acquiredAtMs === "number" &&
|
|
98
|
+
Number.isFinite(record.acquiredAtMs) &&
|
|
99
|
+
typeof record.expiresAtMs === "number" &&
|
|
100
|
+
Number.isFinite(record.expiresAtMs) &&
|
|
101
|
+
record.acquiredAtMs >= 0 &&
|
|
102
|
+
record.expiresAtMs > record.acquiredAtMs
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface RecordSnapshot {
|
|
107
|
+
readonly record: MachineLeaseRecord | undefined;
|
|
108
|
+
readonly raw: string | undefined;
|
|
109
|
+
readonly device: number;
|
|
110
|
+
readonly inode: number;
|
|
111
|
+
readonly size: number;
|
|
112
|
+
readonly mtimeMs: number;
|
|
113
|
+
readonly regular: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function snapshotMetadata(identity: ReturnType<typeof fstatSync>): Pick<RecordSnapshot, "device" | "inode" | "size" | "mtimeMs" | "regular"> {
|
|
117
|
+
return {
|
|
118
|
+
device: Number(identity.dev),
|
|
119
|
+
inode: Number(identity.ino),
|
|
120
|
+
size: Number(identity.size),
|
|
121
|
+
mtimeMs: Number(identity.mtimeMs),
|
|
122
|
+
regular: identity.isFile(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readRecordSnapshot(lockPath: string): RecordSnapshot | undefined {
|
|
127
|
+
let descriptor: number | undefined;
|
|
128
|
+
let metadata: Pick<RecordSnapshot, "device" | "inode" | "size" | "mtimeMs" | "regular"> | undefined;
|
|
129
|
+
try {
|
|
130
|
+
metadata = snapshotMetadata(lstatSync(lockPath));
|
|
131
|
+
if (!metadata.regular) return { record: undefined, raw: undefined, ...metadata };
|
|
132
|
+
descriptor = openSync(lockPath, "r");
|
|
133
|
+
metadata = snapshotMetadata(fstatSync(descriptor));
|
|
134
|
+
const raw = readFileSync(descriptor, "utf8");
|
|
135
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_RECORD_BYTES) {
|
|
136
|
+
return { record: undefined, raw: undefined, ...metadata };
|
|
137
|
+
}
|
|
138
|
+
let parsed: unknown;
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(raw);
|
|
141
|
+
} catch {
|
|
142
|
+
parsed = undefined;
|
|
143
|
+
}
|
|
144
|
+
return { record: validRecord(parsed) ? parsed : undefined, raw, ...metadata };
|
|
145
|
+
} catch {
|
|
146
|
+
// Retain stat metadata for a regular file whose contents are currently
|
|
147
|
+
// unreadable. This is the only evidence history may use for guarded reclaim.
|
|
148
|
+
return metadata === undefined ? undefined : { record: undefined, raw: undefined, ...metadata };
|
|
149
|
+
} finally {
|
|
150
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sameSnapshot(left: RecordSnapshot, right: RecordSnapshot): boolean {
|
|
155
|
+
return (
|
|
156
|
+
sameIdentity(left, right) &&
|
|
157
|
+
left.size === right.size &&
|
|
158
|
+
left.mtimeMs === right.mtimeMs &&
|
|
159
|
+
left.regular === right.regular &&
|
|
160
|
+
left.raw === right.raw
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sameIdentity(left: RecordSnapshot, right: RecordSnapshot): boolean {
|
|
165
|
+
return left.device === right.device && left.inode === right.inode;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Puts a record this caller moved aside back at `lockPath`, without ever
|
|
170
|
+
* displacing whatever owns that path now. `rename(2)` cannot express that: it
|
|
171
|
+
* never fails on an existing destination, so it would silently destroy a
|
|
172
|
+
* replacement holder's live lock. Linking refuses with EEXIST instead, and the
|
|
173
|
+
* moved copy is this caller's own isolated file, so it is dropped rather than
|
|
174
|
+
* left as an orphan beside the live lock.
|
|
175
|
+
*/
|
|
176
|
+
function restoreMovedRecord(movedPath: string, lockPath: string): void {
|
|
177
|
+
try {
|
|
178
|
+
linkSync(movedPath, lockPath);
|
|
179
|
+
unlinkSync(movedPath);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return;
|
|
182
|
+
try {
|
|
183
|
+
unlinkSync(movedPath);
|
|
184
|
+
} catch {
|
|
185
|
+
// Isolated cleanup of this caller's own copy is best-effort.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function writeRecord(
|
|
191
|
+
lockPath: string,
|
|
192
|
+
record: MachineLeaseRecord,
|
|
193
|
+
expected?: RecordSnapshot,
|
|
194
|
+
): boolean {
|
|
195
|
+
const encoded = `${JSON.stringify(record)}\n`;
|
|
196
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_RECORD_BYTES) {
|
|
197
|
+
throw new RangeError("machine lease record exceeds its size bound.");
|
|
198
|
+
}
|
|
199
|
+
const descriptor = openSync(lockPath, "r+");
|
|
200
|
+
try {
|
|
201
|
+
if (expected !== undefined) {
|
|
202
|
+
const identity = fstatSync(descriptor);
|
|
203
|
+
if (identity.dev !== expected.device || identity.ino !== expected.inode) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
ftruncateSync(descriptor, 0);
|
|
208
|
+
writeFileSync(descriptor, encoded, { encoding: "utf8" });
|
|
209
|
+
fsyncSync(descriptor);
|
|
210
|
+
return true;
|
|
211
|
+
} finally {
|
|
212
|
+
closeSync(descriptor);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function validateOptions(
|
|
217
|
+
options: MachineLeaseOptions,
|
|
218
|
+
): Required<
|
|
219
|
+
Pick<
|
|
220
|
+
MachineLeaseOptions,
|
|
221
|
+
"ttlMs" | "now" | "pid" | "hostname" | "tokenFactory"
|
|
222
|
+
>
|
|
223
|
+
> {
|
|
224
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
225
|
+
if (!Number.isFinite(ttlMs) || ttlMs < 2_000 || ttlMs > 86_400_000) {
|
|
226
|
+
throw new RangeError(
|
|
227
|
+
"machine lease ttlMs must be between 2000 and 86400000.",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const pid = options.pid ?? process.pid;
|
|
231
|
+
if (!Number.isSafeInteger(pid) || pid < 1) {
|
|
232
|
+
throw new RangeError("machine lease pid must be a positive safe integer.");
|
|
233
|
+
}
|
|
234
|
+
const hostname = options.hostname ?? readHostname();
|
|
235
|
+
if (hostname.length === 0 || hostname.length > 255) {
|
|
236
|
+
throw new RangeError(
|
|
237
|
+
"machine lease hostname must contain 1 through 255 characters.",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
ttlMs,
|
|
242
|
+
now: options.now ?? Date.now,
|
|
243
|
+
pid,
|
|
244
|
+
hostname,
|
|
245
|
+
tokenFactory:
|
|
246
|
+
options.tokenFactory ?? (() => randomBytes(16).toString("hex")),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Acquires the one machine-global lease represented by `lockPath`.
|
|
252
|
+
*
|
|
253
|
+
* Acquisition uses exclusive creation rather than a read-then-write sequence.
|
|
254
|
+
* A stale, valid record may be removed and then retried. Any caller may opt into
|
|
255
|
+
* identity-checked malformed-record reclaim with `reclaimMalformed`; leases that
|
|
256
|
+
* do not opt in leave malformed records alone and stay refused. Losing the race
|
|
257
|
+
* returns undefined without diagnostics: most callers are not holders.
|
|
258
|
+
*/
|
|
259
|
+
export function acquireMachineLease(
|
|
260
|
+
options: MachineLeaseOptions,
|
|
261
|
+
): MachineLeaseHandle | undefined {
|
|
262
|
+
const normalized = validateOptions(options);
|
|
263
|
+
const directory = dirname(options.lockPath);
|
|
264
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
265
|
+
chmodSync(directory, 0o700);
|
|
266
|
+
|
|
267
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
268
|
+
const nowMs = normalized.now();
|
|
269
|
+
assertFiniteTimestamp(nowMs, "now");
|
|
270
|
+
const token = normalized.tokenFactory();
|
|
271
|
+
if (!TOKEN_PATTERN.test(token)) {
|
|
272
|
+
throw new TypeError(
|
|
273
|
+
"machine lease tokenFactory returned an invalid token.",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const record: MachineLeaseRecord = {
|
|
277
|
+
pid: normalized.pid,
|
|
278
|
+
hostname: normalized.hostname,
|
|
279
|
+
token,
|
|
280
|
+
acquiredAtMs: nowMs,
|
|
281
|
+
expiresAtMs: nowMs + normalized.ttlMs,
|
|
282
|
+
};
|
|
283
|
+
const temporaryPath = `${options.lockPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
284
|
+
let descriptor: number | undefined;
|
|
285
|
+
try {
|
|
286
|
+
descriptor = openSync(temporaryPath, "wx", 0o600);
|
|
287
|
+
writeFileSync(descriptor, `${JSON.stringify(record)}\n`, { encoding: "utf8" });
|
|
288
|
+
fsyncSync(descriptor);
|
|
289
|
+
closeSync(descriptor);
|
|
290
|
+
descriptor = undefined;
|
|
291
|
+
options.beforePublish?.(temporaryPath);
|
|
292
|
+
linkSync(temporaryPath, options.lockPath);
|
|
293
|
+
return createHandle(
|
|
294
|
+
options.lockPath,
|
|
295
|
+
record,
|
|
296
|
+
normalized.ttlMs,
|
|
297
|
+
normalized.now,
|
|
298
|
+
options.beforeReleaseVerify,
|
|
299
|
+
);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
302
|
+
const observed = readRecordSnapshot(options.lockPath);
|
|
303
|
+
if (!observed) return undefined;
|
|
304
|
+
if (observed.record !== undefined) {
|
|
305
|
+
if (observed.record.expiresAtMs > nowMs) return undefined;
|
|
306
|
+
} else {
|
|
307
|
+
const malformedAgeMs = nowMs - observed.mtimeMs;
|
|
308
|
+
if (
|
|
309
|
+
!options.reclaimMalformed ||
|
|
310
|
+
!observed.regular ||
|
|
311
|
+
!Number.isFinite(observed.mtimeMs) ||
|
|
312
|
+
malformedAgeMs < normalized.ttlMs
|
|
313
|
+
) return undefined;
|
|
314
|
+
}
|
|
315
|
+
// Move the observed record out of the way atomically, then verify that
|
|
316
|
+
// the moved inode and all ownership fields are still the record we saw.
|
|
317
|
+
// A contender may reclaim and replace the path while this caller stalls;
|
|
318
|
+
// never unlink that fresh holder's record.
|
|
319
|
+
try {
|
|
320
|
+
options.beforeReclaimRename?.();
|
|
321
|
+
} catch {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
const stalePath = `${options.lockPath}.${process.pid}.${randomUUID()}.stale`;
|
|
325
|
+
try {
|
|
326
|
+
renameSync(options.lockPath, stalePath);
|
|
327
|
+
} catch (renameError) {
|
|
328
|
+
if ((renameError as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
const moved = readRecordSnapshot(stalePath);
|
|
332
|
+
if (!moved || !sameSnapshot(observed, moved)) {
|
|
333
|
+
try {
|
|
334
|
+
options.beforeReclaimRestore?.();
|
|
335
|
+
} catch {
|
|
336
|
+
// A failing seam must not change the restore below.
|
|
337
|
+
}
|
|
338
|
+
restoreMovedRecord(stalePath, options.lockPath);
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
unlinkSync(stalePath);
|
|
343
|
+
} catch {
|
|
344
|
+
restoreMovedRecord(stalePath, options.lockPath);
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
} finally {
|
|
348
|
+
if (descriptor !== undefined) {
|
|
349
|
+
try { closeSync(descriptor); } catch { /* cleanup continues */ }
|
|
350
|
+
}
|
|
351
|
+
try { unlinkSync(temporaryPath); } catch { /* already linked or absent */ }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function createHandle(
|
|
358
|
+
lockPath: string,
|
|
359
|
+
initial: MachineLeaseRecord,
|
|
360
|
+
ttlMs: number,
|
|
361
|
+
now: () => number,
|
|
362
|
+
beforeReleaseVerify?: (tombstonePath: string) => void,
|
|
363
|
+
): MachineLeaseHandle {
|
|
364
|
+
let record = initial;
|
|
365
|
+
let released = false;
|
|
366
|
+
return {
|
|
367
|
+
get record() {
|
|
368
|
+
return record;
|
|
369
|
+
},
|
|
370
|
+
renewalIntervalMs: Math.max(1_000, Math.floor(ttlMs / 3)),
|
|
371
|
+
renew(): boolean {
|
|
372
|
+
if (released) return false;
|
|
373
|
+
const current = readRecordSnapshot(lockPath);
|
|
374
|
+
if (!current?.record || current.record.token !== record.token) return false;
|
|
375
|
+
const nowMs = now();
|
|
376
|
+
try {
|
|
377
|
+
assertFiniteTimestamp(nowMs, "now");
|
|
378
|
+
if (current.record.expiresAtMs <= nowMs) return false;
|
|
379
|
+
const renewed: MachineLeaseRecord = {
|
|
380
|
+
...record,
|
|
381
|
+
acquiredAtMs: current.record.acquiredAtMs,
|
|
382
|
+
expiresAtMs: nowMs + ttlMs,
|
|
383
|
+
};
|
|
384
|
+
if (!writeRecord(lockPath, renewed, current)) return false;
|
|
385
|
+
const after = readRecordSnapshot(lockPath);
|
|
386
|
+
if (
|
|
387
|
+
!after?.record ||
|
|
388
|
+
after.record.token !== record.token ||
|
|
389
|
+
!sameIdentity(after, current)
|
|
390
|
+
) {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
record = after.record;
|
|
394
|
+
return true;
|
|
395
|
+
} catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
release(): boolean {
|
|
400
|
+
if (released) return false;
|
|
401
|
+
const current = readRecordSnapshot(lockPath);
|
|
402
|
+
if (!current?.record || current.record.token !== record.token) {
|
|
403
|
+
released = true;
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
const tombstone = `${lockPath}.${process.pid}.${randomUUID()}.release`;
|
|
407
|
+
try {
|
|
408
|
+
renameSync(lockPath, tombstone);
|
|
409
|
+
} catch {
|
|
410
|
+
released = true;
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
beforeReleaseVerify?.(tombstone);
|
|
415
|
+
} catch {
|
|
416
|
+
// A failing seam must not change the verification below.
|
|
417
|
+
}
|
|
418
|
+
const moved = readRecordSnapshot(tombstone);
|
|
419
|
+
if (
|
|
420
|
+
!moved?.record ||
|
|
421
|
+
moved.record.token !== record.token ||
|
|
422
|
+
!sameIdentity(moved, current)
|
|
423
|
+
) {
|
|
424
|
+
// This caller moved a record it does not own, so put it back -- but
|
|
425
|
+
// only if nothing has taken the path since. A replacement's live lock
|
|
426
|
+
// is never overwritten, and the moved copy is never left orphaned.
|
|
427
|
+
restoreMovedRecord(tombstone, lockPath);
|
|
428
|
+
released = true;
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
unlinkSync(tombstone);
|
|
433
|
+
released = true;
|
|
434
|
+
return true;
|
|
435
|
+
} catch {
|
|
436
|
+
// The tombstone survived, so this lease record is restored under the
|
|
437
|
+
// same rule: keep it only while the path is still free.
|
|
438
|
+
restoreMovedRecord(tombstone, lockPath);
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export const MACHINE_LEASE_TTL_MS = DEFAULT_TTL_MS;
|