@haywoodmarx/agent-pager 0.1.0
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/README.md +63 -0
- package/dist/src/broker/address.d.ts +49 -0
- package/dist/src/broker/address.js +110 -0
- package/dist/src/broker/broker.d.ts +665 -0
- package/dist/src/broker/broker.js +2270 -0
- package/dist/src/broker/database.d.ts +81 -0
- package/dist/src/broker/database.js +69 -0
- package/dist/src/broker/operator-projection.d.ts +291 -0
- package/dist/src/broker/operator-projection.js +722 -0
- package/dist/src/broker/protocol.d.ts +237 -0
- package/dist/src/broker/protocol.js +311 -0
- package/dist/src/broker/schema.d.ts +94 -0
- package/dist/src/broker/schema.js +454 -0
- package/dist/src/broker/snapshot.d.ts +171 -0
- package/dist/src/broker/snapshot.js +78 -0
- package/dist/src/configuration-defaults.d.ts +64 -0
- package/dist/src/configuration-defaults.js +48 -0
- package/dist/src/configuration.d.ts +76 -0
- package/dist/src/configuration.js +156 -0
- package/dist/src/implementations/claude/environment.d.ts +8 -0
- package/dist/src/implementations/claude/environment.js +9 -0
- package/dist/src/implementations/claude/gateway-cli.d.ts +2 -0
- package/dist/src/implementations/claude/gateway-cli.js +136 -0
- package/dist/src/implementations/claude/gateway-identity.d.ts +134 -0
- package/dist/src/implementations/claude/gateway-identity.js +103 -0
- package/dist/src/implementations/claude/gateway.d.ts +148 -0
- package/dist/src/implementations/claude/gateway.js +236 -0
- package/dist/src/implementations/claude/instructions.d.ts +33 -0
- package/dist/src/implementations/claude/instructions.js +64 -0
- package/dist/src/implementations/claude/manifest.d.ts +33 -0
- package/dist/src/implementations/claude/manifest.js +108 -0
- package/dist/src/implementations/claude/plugin-directory.d.ts +80 -0
- package/dist/src/implementations/claude/plugin-directory.js +137 -0
- package/dist/src/implementations/claude/session-continuation.d.ts +25 -0
- package/dist/src/implementations/claude/session-continuation.js +125 -0
- package/dist/src/implementations/claude/session-identity.d.ts +25 -0
- package/dist/src/implementations/claude/session-identity.js +31 -0
- package/dist/src/implementations/claude/session-start-cli.d.ts +2 -0
- package/dist/src/implementations/claude/session-start-cli.js +16 -0
- package/dist/src/implementations/claude/socket-delivery.d.ts +52 -0
- package/dist/src/implementations/claude/socket-delivery.js +159 -0
- package/dist/src/implementations/model.d.ts +162 -0
- package/dist/src/implementations/model.js +1 -0
- package/dist/src/implementations/opencode/adapter.d.ts +71 -0
- package/dist/src/implementations/opencode/adapter.js +94 -0
- package/dist/src/implementations/opencode/environment.d.ts +8 -0
- package/dist/src/implementations/opencode/environment.js +9 -0
- package/dist/src/implementations/opencode/manifest.d.ts +30 -0
- package/dist/src/implementations/opencode/manifest.js +93 -0
- package/dist/src/implementations/opencode/plugin-api.d.ts +120 -0
- package/dist/src/implementations/opencode/plugin-api.js +1 -0
- package/dist/src/implementations/opencode/plugin.d.ts +38 -0
- package/dist/src/implementations/opencode/plugin.js +303 -0
- package/dist/src/implementations/opencode/self-close-tool.d.ts +9 -0
- package/dist/src/implementations/opencode/self-close-tool.js +31 -0
- package/dist/src/implementations/orca/terminal.d.ts +48 -0
- package/dist/src/implementations/orca/terminal.js +154 -0
- package/dist/src/implementations/pi/broker-client.d.ts +56 -0
- package/dist/src/implementations/pi/broker-client.js +156 -0
- package/dist/src/implementations/pi/environment.d.ts +8 -0
- package/dist/src/implementations/pi/environment.js +9 -0
- package/dist/src/implementations/pi/extension.d.ts +57 -0
- package/dist/src/implementations/pi/extension.js +253 -0
- package/dist/src/implementations/pi/host-slots.d.ts +18 -0
- package/dist/src/implementations/pi/host-slots.js +27 -0
- package/dist/src/implementations/pi/manifest.d.ts +11 -0
- package/dist/src/implementations/pi/manifest.js +61 -0
- package/dist/src/implementations/pi/self-close-tool.d.ts +7 -0
- package/dist/src/implementations/pi/self-close-tool.js +27 -0
- package/dist/src/implementations/registry.d.ts +47 -0
- package/dist/src/implementations/registry.js +157 -0
- package/dist/src/implementations/session-key.d.ts +13 -0
- package/dist/src/implementations/session-key.js +19 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.js +26 -0
- package/dist/src/launch/broker-lock.d.ts +257 -0
- package/dist/src/launch/broker-lock.js +644 -0
- package/dist/src/launch/cli.d.ts +37 -0
- package/dist/src/launch/cli.js +317 -0
- package/dist/src/launch/environment.d.ts +45 -0
- package/dist/src/launch/environment.js +48 -0
- package/dist/src/launch/launch.d.ts +235 -0
- package/dist/src/launch/launch.js +223 -0
- package/dist/src/launch/on-demand.d.ts +202 -0
- package/dist/src/launch/on-demand.js +435 -0
- package/dist/src/launch/runtime-composition.d.ts +88 -0
- package/dist/src/launch/runtime-composition.js +253 -0
- package/dist/src/launch/runtime.d.ts +79 -0
- package/dist/src/launch/runtime.js +196 -0
- package/dist/src/launch/service-system.d.ts +11 -0
- package/dist/src/launch/service-system.js +39 -0
- package/dist/src/launch/service.d.ts +173 -0
- package/dist/src/launch/service.js +492 -0
- package/dist/src/launch/skill.d.ts +57 -0
- package/dist/src/launch/skill.js +81 -0
- package/dist/src/launch/terminal.d.ts +26 -0
- package/dist/src/launch/terminal.js +43 -0
- package/dist/src/launch/workspace-branch.d.ts +10 -0
- package/dist/src/launch/workspace-branch.js +29 -0
- package/dist/src/messaging/delivery-client.d.ts +144 -0
- package/dist/src/messaging/delivery-client.js +261 -0
- package/dist/src/messaging/delivery.d.ts +75 -0
- package/dist/src/messaging/delivery.js +22 -0
- package/dist/src/messaging/inbox-socket.d.ts +98 -0
- package/dist/src/messaging/inbox-socket.js +278 -0
- package/dist/src/messaging/mcp.d.ts +53 -0
- package/dist/src/messaging/mcp.js +501 -0
- package/dist/src/messaging/notifications.d.ts +231 -0
- package/dist/src/messaging/notifications.js +489 -0
- package/dist/src/messaging/push-delivery.d.ts +77 -0
- package/dist/src/messaging/push-delivery.js +187 -0
- package/dist/src/messaging/tools.d.ts +11 -0
- package/dist/src/messaging/tools.js +102 -0
- package/dist/src/messaging/wake.d.ts +23 -0
- package/dist/src/messaging/wake.js +39 -0
- package/dist/src/operator/agent-projection.d.ts +102 -0
- package/dist/src/operator/agent-projection.js +249 -0
- package/dist/src/operator/commands.d.ts +50 -0
- package/dist/src/operator/commands.js +199 -0
- package/dist/src/operator/diagnostics.d.ts +21 -0
- package/dist/src/operator/diagnostics.js +22 -0
- package/dist/src/operator/protocol.d.ts +511 -0
- package/dist/src/operator/protocol.js +49 -0
- package/dist/src/operator/repository-system.d.ts +14 -0
- package/dist/src/operator/repository-system.js +150 -0
- package/dist/src/operator/repository.d.ts +79 -0
- package/dist/src/operator/repository.js +72 -0
- package/dist/src/operator/traffic-projection.d.ts +19 -0
- package/dist/src/operator/traffic-projection.js +229 -0
- package/dist/src/operator/transport.d.ts +87 -0
- package/dist/src/operator/transport.js +549 -0
- package/dist/src/operator/update-store.d.ts +53 -0
- package/dist/src/operator/update-store.js +162 -0
- package/dist/src/operator/updates.d.ts +94 -0
- package/dist/src/operator/updates.js +280 -0
- package/dist/src/operator/validation.d.ts +114 -0
- package/dist/src/operator/validation.js +916 -0
- package/dist/src/shared/guards.d.ts +59 -0
- package/dist/src/shared/guards.js +119 -0
- package/dist/src/shared/http.d.ts +30 -0
- package/dist/src/shared/http.js +50 -0
- package/dist/src/shared/row.d.ts +2 -0
- package/dist/src/shared/row.js +1 -0
- package/dist/src/shared/shell.d.ts +6 -0
- package/dist/src/shared/shell.js +8 -0
- package/dist/src/tui/actions.d.ts +166 -0
- package/dist/src/tui/actions.js +253 -0
- package/dist/src/tui/agent-tree.d.ts +125 -0
- package/dist/src/tui/agent-tree.js +663 -0
- package/dist/src/tui/app.d.ts +38 -0
- package/dist/src/tui/app.js +378 -0
- package/dist/src/tui/chrome.d.ts +86 -0
- package/dist/src/tui/chrome.js +85 -0
- package/dist/src/tui/client.d.ts +41 -0
- package/dist/src/tui/client.js +279 -0
- package/dist/src/tui/composition.d.ts +41 -0
- package/dist/src/tui/composition.js +146 -0
- package/dist/src/tui/message-views.d.ts +94 -0
- package/dist/src/tui/message-views.js +515 -0
- package/dist/src/tui/model.d.ts +414 -0
- package/dist/src/tui/model.js +758 -0
- package/dist/src/tui/presentation.d.ts +350 -0
- package/dist/src/tui/presentation.js +541 -0
- package/dist/src/tui/production.d.ts +79 -0
- package/dist/src/tui/production.js +253 -0
- package/dist/src/tui/run-terminal.d.ts +32 -0
- package/dist/src/tui/run-terminal.js +54 -0
- package/dist/src/tui/runtime.d.ts +113 -0
- package/dist/src/tui/runtime.js +189 -0
- package/dist/src/tui/theme.d.ts +49 -0
- package/dist/src/tui/theme.js +55 -0
- package/dist/src/tui/transport.d.ts +31 -0
- package/dist/src/tui/transport.js +255 -0
- package/dist/src/version.d.ts +11 -0
- package/dist/src/version.js +52 -0
- package/package.json +142 -0
- package/skills/pager/SKILL.md +112 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { PROTOCOL_VERSION, ProtocolError, SCHEMA_MARKER } from "../broker/protocol.js";
|
|
6
|
+
import { LOOPBACK_HOST } from "../configuration-defaults.js";
|
|
7
|
+
import { PAGER_VERSION } from "../version.js";
|
|
8
|
+
import { satisfiesBunVersionFloor } from "./runtime.js";
|
|
9
|
+
/** Identifies the ownership schema understood by this release. */
|
|
10
|
+
export const SERVICE_PROTOCOL_VERSION = 1;
|
|
11
|
+
/** Identifies the package build that owns production service state. */
|
|
12
|
+
export const SERVICE_BUILD_ID = `agent-pager@${PAGER_VERSION}`;
|
|
13
|
+
/** Identifies the production runtime required to own SQLite. */
|
|
14
|
+
export const SERVICE_RUNTIME = "bun";
|
|
15
|
+
/**
|
|
16
|
+
* Returns the identity published by the current Bun service build.
|
|
17
|
+
* @returns - Runtime, build, and wire-protocol ownership fields.
|
|
18
|
+
*/
|
|
19
|
+
export function currentBrokerServiceIdentity() {
|
|
20
|
+
return {
|
|
21
|
+
serviceProtocolVersion: SERVICE_PROTOCOL_VERSION,
|
|
22
|
+
runtime: SERVICE_RUNTIME,
|
|
23
|
+
runtimeVersion: Bun.version,
|
|
24
|
+
buildId: SERVICE_BUILD_ID,
|
|
25
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
26
|
+
schemaMarker: SCHEMA_MARKER,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Sole per-field currency check for {@link BrokerServiceIdentity}. */
|
|
30
|
+
const BROKER_SERVICE_IDENTITY_CHECKS = {
|
|
31
|
+
serviceProtocolVersion: (actual) => actual === SERVICE_PROTOCOL_VERSION,
|
|
32
|
+
runtime: (actual) => actual === SERVICE_RUNTIME,
|
|
33
|
+
runtimeVersion: (actual) => typeof actual === "string" && satisfiesBunVersionFloor(actual),
|
|
34
|
+
buildId: (actual) => actual === SERVICE_BUILD_ID,
|
|
35
|
+
protocolVersion: (actual) => actual === PROTOCOL_VERSION,
|
|
36
|
+
schemaMarker: (actual) => actual === SCHEMA_MARKER,
|
|
37
|
+
};
|
|
38
|
+
function hasCurrentBrokerServiceIdentity(candidate) {
|
|
39
|
+
return Object.keys(BROKER_SERVICE_IDENTITY_CHECKS).every((field) => BROKER_SERVICE_IDENTITY_CHECKS[field](candidate[field]));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Report whether a lock belongs to the current production service contract.
|
|
43
|
+
* @param lock - Candidate lock identity from an untrusted adjacent file.
|
|
44
|
+
* @returns - True only for the current Bun build and wire protocol.
|
|
45
|
+
*/
|
|
46
|
+
export function brokerLockHasCurrentIdentity(lock) {
|
|
47
|
+
return lock !== undefined && hasCurrentBrokerServiceIdentity(lock);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Validate an untrusted health payload against the current service contract.
|
|
51
|
+
* @param value - Parsed health response or lock-shaped object.
|
|
52
|
+
* @returns - True when it names a supported Bun and the current build/protocol.
|
|
53
|
+
*/
|
|
54
|
+
export function isCurrentBrokerServiceIdentity(value) {
|
|
55
|
+
return (typeof value === "object" && value !== null && hasCurrentBrokerServiceIdentity(value));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Report whether a lock names a live, current-build service that has left its starting epoch.
|
|
59
|
+
* @param lock - Candidate lock identity from an untrusted adjacent file.
|
|
60
|
+
* @param isAlive - Operating-system liveness boundary.
|
|
61
|
+
* @returns - True only for a live service whose published endpoints a reader may use.
|
|
62
|
+
*/
|
|
63
|
+
export function brokerLockIsLive(lock, isAlive) {
|
|
64
|
+
return lock !== undefined && brokerLockHasCurrentIdentity(lock) && isAlive(lock.pid) && lock.epoch !== "starting";
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Read only the process id from any structurally valid current or legacy lock.
|
|
68
|
+
* @param lockPath - File containing a candidate ownership record.
|
|
69
|
+
* @returns - Positive process id when the file carries one.
|
|
70
|
+
*/
|
|
71
|
+
export function readBrokerOwnerPid(lockPath) {
|
|
72
|
+
return readBrokerOwner(lockPath)?.pid;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Read the owning process from any structurally valid current or legacy lock.
|
|
76
|
+
*
|
|
77
|
+
* Reads only the fields every build has written, so a lock left by a build whose full record this
|
|
78
|
+
* one no longer accepts still names its owner.
|
|
79
|
+
* @param lockPath - File containing a candidate ownership record.
|
|
80
|
+
* @returns - The owner's positive process id, with the start time the lock recorded for it when it
|
|
81
|
+
* recorded one.
|
|
82
|
+
*/
|
|
83
|
+
export function readBrokerOwner(lockPath) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
86
|
+
if (typeof parsed !== "object" || parsed === null || !("pid" in parsed))
|
|
87
|
+
return undefined;
|
|
88
|
+
const { pid } = parsed;
|
|
89
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0)
|
|
90
|
+
return undefined;
|
|
91
|
+
const startedAt = "ownerStartedAt" in parsed && typeof parsed.ownerStartedAt === "string" ? parsed.ownerStartedAt : undefined;
|
|
92
|
+
return { pid, ...(startedAt === undefined ? {} : { startedAt }) };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolve the service lock beside its durable database.
|
|
100
|
+
* @param databasePath - Durable SQLite file whose owning service publishes the lock.
|
|
101
|
+
* @returns - Path of the lock file beside the database.
|
|
102
|
+
*/
|
|
103
|
+
export function brokerLockPath(databasePath) {
|
|
104
|
+
return `${databasePath}.lock`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Read a valid broker lock record without importing the SQLite-backed service.
|
|
108
|
+
* @param lockPath - File containing the candidate lock record.
|
|
109
|
+
* @returns - Valid parsed lock data when the file is well-formed.
|
|
110
|
+
*/
|
|
111
|
+
export function readBrokerLock(lockPath) {
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
114
|
+
return isBrokerLock(parsed) ? parsed : undefined;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Structural gate, not a currency check: `serviceProtocolVersion`, `runtime`, and `protocolVersion`
|
|
122
|
+
* must equal the current build's because a mismatch there means the rest of the shape cannot be
|
|
123
|
+
* trusted to mean what the fields below assume it means. `buildId` and `schemaMarker` identify which
|
|
124
|
+
* build and database schema wrote the record — readable from a foreign build's lock so a reclaim
|
|
125
|
+
* decision can report it — so both are shape-checked only; {@link brokerLockHasCurrentIdentity} is
|
|
126
|
+
* where their currency against the running build is decided.
|
|
127
|
+
* @param value - Candidate lock record parsed from an untrusted adjacent file.
|
|
128
|
+
* @returns - True when the value is structurally a lock record this build can read.
|
|
129
|
+
*/
|
|
130
|
+
function isBrokerLock(value) {
|
|
131
|
+
return (typeof value === "object" &&
|
|
132
|
+
value !== null &&
|
|
133
|
+
"pid" in value &&
|
|
134
|
+
typeof value.pid === "number" &&
|
|
135
|
+
Number.isSafeInteger(value.pid) &&
|
|
136
|
+
value.pid > 0 &&
|
|
137
|
+
"epoch" in value &&
|
|
138
|
+
typeof value.epoch === "string" &&
|
|
139
|
+
"serviceProtocolVersion" in value &&
|
|
140
|
+
value.serviceProtocolVersion === SERVICE_PROTOCOL_VERSION &&
|
|
141
|
+
"runtime" in value &&
|
|
142
|
+
value.runtime === SERVICE_RUNTIME &&
|
|
143
|
+
"runtimeVersion" in value &&
|
|
144
|
+
typeof value.runtimeVersion === "string" &&
|
|
145
|
+
/^\d+\.\d+\.\d+/u.test(value.runtimeVersion) &&
|
|
146
|
+
"buildId" in value &&
|
|
147
|
+
typeof value.buildId === "string" &&
|
|
148
|
+
"protocolVersion" in value &&
|
|
149
|
+
value.protocolVersion === PROTOCOL_VERSION &&
|
|
150
|
+
"schemaMarker" in value &&
|
|
151
|
+
typeof value.schemaMarker === "string" &&
|
|
152
|
+
(!("holder" in value) || typeof value.holder === "string") &&
|
|
153
|
+
(!("ownerStartedAt" in value) || typeof value.ownerStartedAt === "string") &&
|
|
154
|
+
(!("mcpUrl" in value) || isLoopbackUrl(value.mcpUrl, "/mcp")) &&
|
|
155
|
+
(!("snapshotUrl" in value) || isLoopbackUrl(value.snapshotUrl)) &&
|
|
156
|
+
(!("operatorUrl" in value) || isLoopbackTcpUrl(value.operatorUrl)));
|
|
157
|
+
}
|
|
158
|
+
function isLoopbackTcpUrl(value) {
|
|
159
|
+
if (typeof value !== "string")
|
|
160
|
+
return false;
|
|
161
|
+
try {
|
|
162
|
+
const url = new URL(value);
|
|
163
|
+
return (url.protocol === "tcp:" &&
|
|
164
|
+
(url.hostname === LOOPBACK_HOST || url.hostname === "[::1]" || url.hostname === "::1") &&
|
|
165
|
+
url.pathname === "");
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function isLoopbackUrl(value, pathname) {
|
|
172
|
+
if (typeof value !== "string")
|
|
173
|
+
return false;
|
|
174
|
+
try {
|
|
175
|
+
const url = new URL(value);
|
|
176
|
+
return (url.protocol === "http:" &&
|
|
177
|
+
url.hostname === LOOPBACK_HOST &&
|
|
178
|
+
(pathname === undefined || url.pathname === pathname));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Bounds how many times acquisition re-attempts after losing a lock-file race. */
|
|
185
|
+
export const BROKER_LOCK_ACQUISITION_ATTEMPTS = 5;
|
|
186
|
+
/** Sets the entropy of a lock holder token, in bytes. */
|
|
187
|
+
const HOLDER_TOKEN_BYTES = 12;
|
|
188
|
+
/** Delays one re-attempt after losing a lock-file race, in milliseconds. */
|
|
189
|
+
const RECLAIM_RETRY_DELAY_MS = 20;
|
|
190
|
+
/** Names the reading behind a Linux start time, which pairs boot time with ticks since boot. */
|
|
191
|
+
const LINUX_START_TIME_TAG = "l1";
|
|
192
|
+
/** Names the reading behind a macOS start time, which is an instant in milliseconds. */
|
|
193
|
+
const DARWIN_START_TIME_TAG = "d1";
|
|
194
|
+
/** Reports that the lock is held by a live service of the current build. */
|
|
195
|
+
export class BrokerServiceAlreadyRunningError extends ProtocolError {
|
|
196
|
+
pid;
|
|
197
|
+
/**
|
|
198
|
+
* Construct the failure naming the live owner a second service must defer to.
|
|
199
|
+
* @param pid - Process id recorded by the lock this process could not take.
|
|
200
|
+
*/
|
|
201
|
+
constructor(
|
|
202
|
+
/** Identifies the live process that owns the lock. */
|
|
203
|
+
pid) {
|
|
204
|
+
super("conflict", `Broker service already running with pid ${String(pid)}`);
|
|
205
|
+
this.pid = pid;
|
|
206
|
+
this.name = "BrokerServiceAlreadyRunningError";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Reports that the lock is held by a live service this build must not displace. */
|
|
210
|
+
export class BrokerServiceLockOwnedError extends ProtocolError {
|
|
211
|
+
pid;
|
|
212
|
+
/**
|
|
213
|
+
* Construct the failure naming the incompatible owner an operator must resolve.
|
|
214
|
+
* @param pid - Process id recorded by the lock this process could not take.
|
|
215
|
+
*/
|
|
216
|
+
constructor(
|
|
217
|
+
/** Identifies the live process that owns the lock. */
|
|
218
|
+
pid) {
|
|
219
|
+
super("validation_failed", `Broker service lock is owned by pid ${String(pid)}; run pager exit to stop the legacy or incompatible service, then retry`);
|
|
220
|
+
this.pid = pid;
|
|
221
|
+
this.name = "BrokerServiceLockOwnedError";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Reports that acquisition kept losing the lock file to another writer. */
|
|
225
|
+
export class BrokerLockContendedError extends ProtocolError {
|
|
226
|
+
lockPath;
|
|
227
|
+
attempts;
|
|
228
|
+
blockedBy;
|
|
229
|
+
/**
|
|
230
|
+
* Construct the failure that ends a bounded acquisition rather than retrying forever.
|
|
231
|
+
* @param lockPath - Lock file this process was trying to take.
|
|
232
|
+
* @param attempts - Number of acquisition attempts that were made and lost.
|
|
233
|
+
* @param blockedBy - File that was still held or recreated on the final attempt, which is the one
|
|
234
|
+
* an operator has to deal with and is not always the lock itself.
|
|
235
|
+
*/
|
|
236
|
+
constructor(
|
|
237
|
+
/** Names the contended lock file. */
|
|
238
|
+
lockPath,
|
|
239
|
+
/** Records how many attempts were made before giving up. */
|
|
240
|
+
attempts,
|
|
241
|
+
/** Names the file that blocked the final attempt. */
|
|
242
|
+
blockedBy) {
|
|
243
|
+
super("conflict", `Broker service lock ${lockPath} could not be taken in ${String(attempts)} attempts because ${blockedBy} ` +
|
|
244
|
+
`was held or recreated by another writer every time; find and stop whatever owns that file, then retry`);
|
|
245
|
+
this.lockPath = lockPath;
|
|
246
|
+
this.attempts = attempts;
|
|
247
|
+
this.blockedBy = blockedBy;
|
|
248
|
+
this.name = "BrokerLockContendedError";
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Reports that the lock this process acquired is no longer the one on disk. */
|
|
252
|
+
export class BrokerLockLostError extends ProtocolError {
|
|
253
|
+
lockPath;
|
|
254
|
+
/**
|
|
255
|
+
* Construct the failure that stops a service publishing endpoints it no longer owns.
|
|
256
|
+
* @param lockPath - Lock file whose holder token stopped matching this acquisition.
|
|
257
|
+
*/
|
|
258
|
+
constructor(
|
|
259
|
+
/** Names the lock file this process no longer holds. */
|
|
260
|
+
lockPath) {
|
|
261
|
+
super("conflict", `Broker service lock ${lockPath} is no longer held by this process; another service has taken ownership of the database`);
|
|
262
|
+
this.lockPath = lockPath;
|
|
263
|
+
this.name = "BrokerLockLostError";
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Report whether a process id names a running process.
|
|
268
|
+
* @param pid - Process id to probe.
|
|
269
|
+
* @returns - True unless the operating system reports no such process.
|
|
270
|
+
*/
|
|
271
|
+
export function processIsAlive(pid) {
|
|
272
|
+
try {
|
|
273
|
+
process.kill(pid, 0);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
// Only ESRCH proves absence. EPERM means the process exists and belongs to another user, and
|
|
278
|
+
// reclaiming a lock on that evidence would displace a running service.
|
|
279
|
+
return !isErrno(error, "ESRCH");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Read a process's start time from the platform, where the platform can report one.
|
|
284
|
+
*
|
|
285
|
+
* The value is tagged with the reading that produced it. A record carrying a tag this build does not
|
|
286
|
+
* produce describes a moment measured some other way, and comparing the two would be meaningless.
|
|
287
|
+
* @param pid - Process id to describe.
|
|
288
|
+
* @returns - The start time, or an explicit absence on a platform that cannot supply one.
|
|
289
|
+
*/
|
|
290
|
+
export function processStartedAt(pid) {
|
|
291
|
+
try {
|
|
292
|
+
if (process.platform === "linux") {
|
|
293
|
+
const stat = readFileSync(`/proc/${String(pid)}/stat`, "utf8");
|
|
294
|
+
// The command field is parenthesised and may itself contain spaces and brackets, so the
|
|
295
|
+
// numeric fields are only unambiguous after the last ")".
|
|
296
|
+
const startTicks = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
|
297
|
+
// Those ticks are counted from boot, so processes either side of a reboot can carry the same
|
|
298
|
+
// count. Pairing them with the boot time makes the reading absolute without needing the
|
|
299
|
+
// kernel's tick rate.
|
|
300
|
+
const bootTime = /^btime (\d+)$/mu.exec(readFileSync("/proc/stat", "utf8"))?.[1];
|
|
301
|
+
return startTicks === undefined || startTicks === "" || bootTime === undefined
|
|
302
|
+
? { known: false }
|
|
303
|
+
: { known: true, value: `${LINUX_START_TIME_TAG}:${bootTime}:${startTicks}` };
|
|
304
|
+
}
|
|
305
|
+
if (process.platform === "darwin") {
|
|
306
|
+
// ps renders lstart through LC_TIME and TZ, so the same process reads differently from a
|
|
307
|
+
// launchd service and from a user shell. Both are pinned, and the rendering is reduced to an
|
|
308
|
+
// instant, so no two spellings of one moment can reach a comparison.
|
|
309
|
+
const rendered = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
310
|
+
encoding: "utf8",
|
|
311
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
312
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
313
|
+
}).trim();
|
|
314
|
+
const started = Date.parse(`${rendered} UTC`);
|
|
315
|
+
return Number.isFinite(started)
|
|
316
|
+
? { known: true, value: `${DARWIN_START_TIME_TAG}:${String(started)}` }
|
|
317
|
+
: { known: false };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return { known: false };
|
|
322
|
+
}
|
|
323
|
+
return { known: false };
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Report whether a recorded start time still describes whichever process now holds a process id.
|
|
327
|
+
* @param recorded - Start time captured when the record was written.
|
|
328
|
+
* @param actual - Start time read from the platform now.
|
|
329
|
+
* @returns - False only when both readings are the same kind and disagree.
|
|
330
|
+
*/
|
|
331
|
+
export function startTimeStillMatches(recorded, actual) {
|
|
332
|
+
if (!actual.known)
|
|
333
|
+
return true;
|
|
334
|
+
// Every disagreement this cannot account for has to read as the same process. Reclaiming is the
|
|
335
|
+
// destructive answer, and a record written by a build that measured start times some other way is
|
|
336
|
+
// no evidence that the process id has been reused.
|
|
337
|
+
if (startTimeReading(actual.value) !== startTimeReading(recorded))
|
|
338
|
+
return true;
|
|
339
|
+
if (actual.value === recorded)
|
|
340
|
+
return true;
|
|
341
|
+
// A Linux reading pairs a boot time with a tick count. The ticks come straight from the process
|
|
342
|
+
// and are exact, so a difference there is a different process. The boot time is derived from the
|
|
343
|
+
// clock and two readings of one boot need not agree to the second, so a difference in that alone
|
|
344
|
+
// says nothing, and the safe answer to saying nothing is to leave the owner alone.
|
|
345
|
+
if (startTimeReading(actual.value) === LINUX_START_TIME_TAG) {
|
|
346
|
+
return linuxStartTicks(actual.value) === linuxStartTicks(recorded);
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
function linuxStartTicks(value) {
|
|
351
|
+
return value.split(":")[2];
|
|
352
|
+
}
|
|
353
|
+
function startTimeReading(value) {
|
|
354
|
+
const separator = value.indexOf(":");
|
|
355
|
+
return separator < 0 ? "" : value.slice(0, separator);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Build the operating-system boundaries used by a production lock acquisition.
|
|
359
|
+
* @returns - Ports backed by the operating system's process table, a random token source, and a sleep that blocks the thread.
|
|
360
|
+
*/
|
|
361
|
+
export function systemBrokerLockPorts() {
|
|
362
|
+
return {
|
|
363
|
+
processIsAlive,
|
|
364
|
+
processStartedAt,
|
|
365
|
+
newHolderToken: () => randomBytes(HOLDER_TOKEN_BYTES).toString("hex"),
|
|
366
|
+
sleep: (milliseconds) => {
|
|
367
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Owns one process's exclusive claim on a broker database.
|
|
373
|
+
*
|
|
374
|
+
* Creation links a staged record into place and fails on a name another process holds, so
|
|
375
|
+
* uncontended acquisition already selects one winner. Reclaiming a stale lock cannot be, because it
|
|
376
|
+
* spans a read and an unlink: two processes that each read the same dead owner would each remove
|
|
377
|
+
* whatever lock was present by the time they got to the unlink, including the other's fresh one. A
|
|
378
|
+
* separate reclaim file, claimed the same way, serialises that span, and the owner is re-read inside
|
|
379
|
+
* it, so a reclaimer that lost the race sees the winner rather than deleting it.
|
|
380
|
+
*/
|
|
381
|
+
export class BrokerLockGuard {
|
|
382
|
+
#lockPath;
|
|
383
|
+
#reclaimPath;
|
|
384
|
+
#ports;
|
|
385
|
+
#holder;
|
|
386
|
+
#startedAt = { known: false };
|
|
387
|
+
/**
|
|
388
|
+
* Construct a guard over one lock file.
|
|
389
|
+
* @param lockPath - Lock file adjacent to the database this process wants to own.
|
|
390
|
+
* @param ports - Operating-system boundaries, replaced only by this module's own tests.
|
|
391
|
+
*/
|
|
392
|
+
constructor(lockPath, ports = systemBrokerLockPorts()) {
|
|
393
|
+
this.#lockPath = lockPath;
|
|
394
|
+
this.#reclaimPath = `${lockPath}.reclaim`;
|
|
395
|
+
this.#ports = ports;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Names the lock file this guard claims.
|
|
399
|
+
* @returns - Path the guard was constructed over.
|
|
400
|
+
*/
|
|
401
|
+
get path() {
|
|
402
|
+
return this.#lockPath;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Take exclusive ownership of the database, reclaiming a lock whose owner is gone.
|
|
406
|
+
*
|
|
407
|
+
* Terminates in every case: each attempt either wins the lock, throws because a live owner holds
|
|
408
|
+
* it, or consumes one of a fixed number of attempts.
|
|
409
|
+
* @throws {BrokerServiceAlreadyRunningError} - A live service of this build holds the lock.
|
|
410
|
+
* @throws {BrokerServiceLockOwnedError} - A live service this build must not displace holds the lock.
|
|
411
|
+
* @throws {BrokerLockContendedError} - Another writer kept recreating the lock.
|
|
412
|
+
*/
|
|
413
|
+
acquire() {
|
|
414
|
+
mkdirSync(dirname(this.#lockPath), { recursive: true });
|
|
415
|
+
this.#startedAt = this.#ports.processStartedAt(process.pid);
|
|
416
|
+
let unreadableLockReads = 0;
|
|
417
|
+
let unreadableReclaimReads = 0;
|
|
418
|
+
let blockedBy = this.#lockPath;
|
|
419
|
+
for (let attempt = 0; attempt < BROKER_LOCK_ACQUISITION_ATTEMPTS; attempt += 1) {
|
|
420
|
+
const holder = this.#ports.newHolderToken();
|
|
421
|
+
if (this.#create(holder)) {
|
|
422
|
+
this.#holder = holder;
|
|
423
|
+
// A reclaimer killed after removing the lock and before creating its own leaves its reclaim
|
|
424
|
+
// file behind, and nothing on the uncontended path would ever look at it again.
|
|
425
|
+
if (existsSync(this.#reclaimPath))
|
|
426
|
+
this.#dropAbandonedReclaimFile(0);
|
|
427
|
+
this.#sweepStagingFiles();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const observed = this.#observe();
|
|
431
|
+
if (observed.state === "live")
|
|
432
|
+
throw ownerError(observed.pid, observed.current);
|
|
433
|
+
if (observed.state === "absent")
|
|
434
|
+
continue;
|
|
435
|
+
if (observed.state === "unreadable") {
|
|
436
|
+
unreadableLockReads += 1;
|
|
437
|
+
// A writer outside this build may replace the lock without an atomic rename, so a single
|
|
438
|
+
// unreadable read can be a half-written document rather than an abandoned one.
|
|
439
|
+
if (unreadableLockReads < 2) {
|
|
440
|
+
this.#ports.sleep(RECLAIM_RETRY_DELAY_MS);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
unreadableLockReads = 0;
|
|
446
|
+
}
|
|
447
|
+
const outcome = this.#reclaim(holder, unreadableReclaimReads);
|
|
448
|
+
if (outcome === "acquired") {
|
|
449
|
+
this.#holder = holder;
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
unreadableReclaimReads = outcome === "reclaim-unreadable" ? unreadableReclaimReads + 1 : 0;
|
|
453
|
+
blockedBy = outcome === "lock-taken" ? this.#lockPath : this.#reclaimPath;
|
|
454
|
+
this.#ports.sleep(RECLAIM_RETRY_DELAY_MS);
|
|
455
|
+
}
|
|
456
|
+
throw new BrokerLockContendedError(this.#lockPath, BROKER_LOCK_ACQUISITION_ATTEMPTS, blockedBy);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Replace the acquired lock with the record readers adopt.
|
|
460
|
+
*
|
|
461
|
+
* Written to a scratch file and renamed, so no reader can observe a partial document and treat
|
|
462
|
+
* the database as unowned.
|
|
463
|
+
* @param lock - Ownership record naming this process's published endpoints.
|
|
464
|
+
* @throws {BrokerLockLostError} - The lock on disk is no longer the one this guard acquired.
|
|
465
|
+
*/
|
|
466
|
+
publish(lock) {
|
|
467
|
+
const holder = this.#holder;
|
|
468
|
+
if (holder === undefined || readLockHolder(this.#lockPath) !== holder) {
|
|
469
|
+
throw new BrokerLockLostError(this.#lockPath);
|
|
470
|
+
}
|
|
471
|
+
const stagingPath = `${this.#lockPath}.${holder}.tmp`;
|
|
472
|
+
try {
|
|
473
|
+
writeFileSync(stagingPath, JSON.stringify({
|
|
474
|
+
...lock,
|
|
475
|
+
holder,
|
|
476
|
+
...(this.#startedAt.known ? { ownerStartedAt: this.#startedAt.value } : {}),
|
|
477
|
+
}), { mode: 0o600 });
|
|
478
|
+
renameSync(stagingPath, this.#lockPath);
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
removeIfPresent(stagingPath);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Give up ownership, leaving any lock this guard does not hold untouched.
|
|
486
|
+
*
|
|
487
|
+
* A service that failed to acquire, or whose lock was reclaimed while it shut down, must not
|
|
488
|
+
* delete the record naming whichever process owns the database now.
|
|
489
|
+
*/
|
|
490
|
+
release() {
|
|
491
|
+
const holder = this.#holder;
|
|
492
|
+
this.#holder = undefined;
|
|
493
|
+
if (holder === undefined || readLockHolder(this.#lockPath) !== holder)
|
|
494
|
+
return;
|
|
495
|
+
removeIfPresent(this.#lockPath);
|
|
496
|
+
}
|
|
497
|
+
#create(holder) {
|
|
498
|
+
return this.#claim(this.#lockPath, holder, JSON.stringify({
|
|
499
|
+
...currentBrokerServiceIdentity(),
|
|
500
|
+
pid: process.pid,
|
|
501
|
+
epoch: "starting",
|
|
502
|
+
holder,
|
|
503
|
+
...(this.#startedAt.known ? { ownerStartedAt: this.#startedAt.value } : {}),
|
|
504
|
+
}));
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Claim a name for this process, or report that another process already holds it.
|
|
508
|
+
*
|
|
509
|
+
* Staging the record and then linking it into place publishes the name and its contents in one
|
|
510
|
+
* step, so a reader never sees the name without a record. `link` fails `EEXIST` on a name another
|
|
511
|
+
* process holds, so at most one claimant takes it.
|
|
512
|
+
* @param path - Name to claim.
|
|
513
|
+
* @param holder - Token identifying this attempt, which keeps the staging file to itself.
|
|
514
|
+
* @param content - Record the name carries from the instant it exists.
|
|
515
|
+
* @returns - True when this process took the name, false when another process holds it.
|
|
516
|
+
*/
|
|
517
|
+
#claim(path, holder, content) {
|
|
518
|
+
const stagingPath = `${path}.${holder}.new`;
|
|
519
|
+
try {
|
|
520
|
+
writeFileSync(stagingPath, content, { mode: 0o600 });
|
|
521
|
+
try {
|
|
522
|
+
linkSync(stagingPath, path);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
// EEXIST is another process holding the name. ENOENT is this attempt's staging file having
|
|
526
|
+
// been swept as a crash leftover, which loses the attempt rather than failing it.
|
|
527
|
+
if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT"))
|
|
528
|
+
return false;
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
removeIfPresent(stagingPath);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Clear staging files left behind by a process killed between writing one and linking it.
|
|
539
|
+
*
|
|
540
|
+
* Their names are unique to the attempt that made them, so nothing else revisits them and they
|
|
541
|
+
* would accumulate in the state directory. Removing one a live claim still holds costs that claim
|
|
542
|
+
* its attempt and no more, because its link then fails and it retries under a new name.
|
|
543
|
+
*/
|
|
544
|
+
#sweepStagingFiles() {
|
|
545
|
+
const directory = dirname(this.#lockPath);
|
|
546
|
+
const prefix = `${basename(this.#lockPath)}.`;
|
|
547
|
+
for (const entry of readdirSync(directory)) {
|
|
548
|
+
if (!entry.startsWith(prefix))
|
|
549
|
+
continue;
|
|
550
|
+
if (entry.endsWith(".new") || entry.endsWith(".tmp"))
|
|
551
|
+
removeIfPresent(join(directory, entry));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
#observe() {
|
|
555
|
+
const record = readJsonObject(this.#lockPath);
|
|
556
|
+
if (!record)
|
|
557
|
+
return existsSync(this.#lockPath) ? { state: "unreadable" } : { state: "absent" };
|
|
558
|
+
const pid = ownerPidOf(record);
|
|
559
|
+
if (pid === undefined)
|
|
560
|
+
return { state: "unreadable" };
|
|
561
|
+
if (!this.#ownerIsLive(pid, record))
|
|
562
|
+
return { state: "stale" };
|
|
563
|
+
return { state: "live", pid, current: brokerLockHasCurrentIdentity(readBrokerLock(this.#lockPath)) };
|
|
564
|
+
}
|
|
565
|
+
#ownerIsLive(pid, record) {
|
|
566
|
+
if (!this.#ports.processIsAlive(pid))
|
|
567
|
+
return false;
|
|
568
|
+
const recorded = record.ownerStartedAt;
|
|
569
|
+
if (typeof recorded !== "string")
|
|
570
|
+
return true;
|
|
571
|
+
return startTimeStillMatches(recorded, this.#ports.processStartedAt(pid));
|
|
572
|
+
}
|
|
573
|
+
#reclaim(holder, unreadableReclaimReads) {
|
|
574
|
+
if (!this.#takeReclaimFile(holder))
|
|
575
|
+
return this.#dropAbandonedReclaimFile(unreadableReclaimReads);
|
|
576
|
+
try {
|
|
577
|
+
const observed = this.#observe();
|
|
578
|
+
if (observed.state === "live")
|
|
579
|
+
throw ownerError(observed.pid, observed.current);
|
|
580
|
+
if (observed.state !== "absent")
|
|
581
|
+
removeIfPresent(this.#lockPath);
|
|
582
|
+
return this.#create(holder) ? "acquired" : "lock-taken";
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
removeIfPresent(this.#reclaimPath);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
#takeReclaimFile(holder) {
|
|
589
|
+
return this.#claim(this.#reclaimPath, holder, JSON.stringify({
|
|
590
|
+
pid: process.pid,
|
|
591
|
+
holder,
|
|
592
|
+
...(this.#startedAt.known ? { ownerStartedAt: this.#startedAt.value } : {}),
|
|
593
|
+
}));
|
|
594
|
+
}
|
|
595
|
+
#dropAbandonedReclaimFile(unreadableReclaimReads) {
|
|
596
|
+
const record = readJsonObject(this.#reclaimPath);
|
|
597
|
+
if (!record) {
|
|
598
|
+
// The reclaim file's name and record appear in one step, so an unreadable one is a foreign
|
|
599
|
+
// writer, corruption, or a file removed since the failed link, not a holder mid-write. Deleting it on a single reading could still put
|
|
600
|
+
// two processes inside the span this file exists to serialise, so only a reading that stays
|
|
601
|
+
// unreadable across attempts is treated as abandonment.
|
|
602
|
+
if (unreadableReclaimReads >= 1)
|
|
603
|
+
removeIfPresent(this.#reclaimPath);
|
|
604
|
+
return "reclaim-unreadable";
|
|
605
|
+
}
|
|
606
|
+
const pid = ownerPidOf(record);
|
|
607
|
+
if (pid === undefined || !this.#ownerIsLive(pid, record))
|
|
608
|
+
removeIfPresent(this.#reclaimPath);
|
|
609
|
+
return "reclaim-held";
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function ownerError(pid, current) {
|
|
613
|
+
return current ? new BrokerServiceAlreadyRunningError(pid) : new BrokerServiceLockOwnedError(pid);
|
|
614
|
+
}
|
|
615
|
+
function ownerPidOf(record) {
|
|
616
|
+
const pid = record.pid;
|
|
617
|
+
return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
|
618
|
+
}
|
|
619
|
+
function readJsonObject(path) {
|
|
620
|
+
try {
|
|
621
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
622
|
+
return typeof parsed === "object" && parsed !== null ? parsed : undefined;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function readLockHolder(lockPath) {
|
|
629
|
+
const record = readJsonObject(lockPath);
|
|
630
|
+
return record && typeof record.holder === "string" ? record.holder : undefined;
|
|
631
|
+
}
|
|
632
|
+
function removeIfPresent(path) {
|
|
633
|
+
try {
|
|
634
|
+
unlinkSync(path);
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
// Whatever this call would have removed is already gone, which is the outcome it wanted.
|
|
638
|
+
if (!isErrno(error, "ENOENT"))
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function isErrno(error, code) {
|
|
643
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
644
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import type { ImplementationId } from "../implementations/model.js";
|
|
3
|
+
import { type ImplementationRegistry } from "../implementations/registry.js";
|
|
4
|
+
/**
|
|
5
|
+
* Run the operational CLI.
|
|
6
|
+
*
|
|
7
|
+
* Most commands write one `{ ok, … }` envelope to stdout on success. An invocation naming no command at all renders the operator TUI on the terminal instead of
|
|
8
|
+
* writing an envelope; the bare `<implementation-id>` wrapper hands or inherits stdio to the child
|
|
9
|
+
* process, and the wrapper's own failure path writes to stderr instead of stdout. `--help`, `-h`,
|
|
10
|
+
* and `help` write only to stderr, through `usage()`. Any other thrown failure is caught here and
|
|
11
|
+
* written to stderr as `{ ok: false, … }`.
|
|
12
|
+
* @param argv - Command tokens supplied after the executable name.
|
|
13
|
+
*/
|
|
14
|
+
export declare function main(argv?: string[]): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Reject installed implementation identities that would be shadowed by a reserved subcommand.
|
|
17
|
+
* @param implementations - Registry whose open ids share the first CLI argument slot.
|
|
18
|
+
*/
|
|
19
|
+
export declare function assertNoReservedImplementationIds(implementations: ImplementationRegistry): void;
|
|
20
|
+
/**
|
|
21
|
+
* Parse a bare implementation wrapper command without naming a concrete provider.
|
|
22
|
+
* @param command - First command token already known not to be a reserved subcommand.
|
|
23
|
+
* @param args - Wrapper options and implementation passthrough arguments.
|
|
24
|
+
* @param implementations - Registry used to distinguish valid-but-uninstalled ids.
|
|
25
|
+
* @returns - Resolved implementation, optional stable session name, and passthrough arguments.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseWrapperCommand(command: string, args: string[], implementations: ImplementationRegistry): {
|
|
28
|
+
selectedImplementation: ImplementationId;
|
|
29
|
+
sessionName?: string;
|
|
30
|
+
passthrough: string[];
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Return the canonical encoded file URL used to compare a CLI process entrypoint.
|
|
34
|
+
* @param path - Existing filesystem path supplied by the process argument vector.
|
|
35
|
+
* @returns - URL-safe identity after resolving filesystem links.
|
|
36
|
+
*/
|
|
37
|
+
export declare function canonicalFileUrl(path: string): string;
|