@pinet/slack-bridge 0.2.2 → 0.2.6
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 +316 -451
- package/dist/activity-log.d.ts +10 -3
- package/dist/agent-event-runtime.d.ts +0 -2
- package/dist/agent-event-runtime.js +0 -1
- package/dist/agent-prompt-guidance.d.ts +1 -6
- package/dist/agent-prompt-guidance.js +1 -6
- package/dist/broker/adapters/slack.d.ts +4 -1
- package/dist/broker/adapters/slack.js +16 -9
- package/dist/broker/client.d.ts +21 -4
- package/dist/broker/client.js +65 -25
- package/dist/broker/hibernation-activation-authority.d.ts +22 -0
- package/dist/broker/hibernation-activation-authority.js +51 -0
- package/dist/broker/hibernation-activation.d.ts +75 -0
- package/dist/broker/hibernation-activation.js +95 -0
- package/dist/broker/hibernation-command-router.d.ts +54 -0
- package/dist/broker/hibernation-command-router.js +124 -0
- package/dist/broker/hibernation-runtime-adapters.d.ts +79 -0
- package/dist/broker/hibernation-runtime-adapters.js +473 -0
- package/dist/broker/hibernation-runtime-helpers.d.ts +146 -0
- package/dist/broker/hibernation-runtime-helpers.js +243 -0
- package/dist/broker/index.d.ts +16 -1
- package/dist/broker/index.js +68 -10
- package/dist/broker/lock-conflict.d.ts +109 -0
- package/dist/broker/lock-conflict.js +409 -0
- package/dist/broker/socket-server.d.ts +26 -0
- package/dist/broker/socket-server.js +287 -4
- package/dist/broker-delivery.d.ts +1 -0
- package/dist/broker-delivery.js +10 -1
- package/dist/broker-runtime.d.ts +14 -2
- package/dist/broker-runtime.js +114 -3
- package/dist/canvases.d.ts +12 -2
- package/dist/canvases.js +1 -3
- package/dist/compaction-gate.d.ts +7 -0
- package/dist/compaction-gate.js +35 -0
- package/dist/deploy-manifest.d.ts +8 -2
- package/dist/deploy-manifest.js +9 -7
- package/dist/follower-runtime.d.ts +5 -2
- package/dist/follower-runtime.js +39 -5
- package/dist/git-metadata.d.ts +5 -9
- package/dist/git-metadata.js +23 -42
- package/dist/guardrails.js +5 -0
- package/dist/helpers.d.ts +77 -27
- package/dist/helpers.js +150 -39
- package/dist/hibernation-config.d.ts +13 -0
- package/dist/hibernation-config.js +14 -0
- package/dist/home-tab.d.ts +13 -2
- package/dist/imessage-tools.d.ts +4 -1
- package/dist/inbox-drain-runtime.d.ts +1 -0
- package/dist/inbox-drain-runtime.js +5 -1
- package/dist/index.js +341 -108
- package/dist/pinet-commands.d.ts +14 -1
- package/dist/pinet-commands.js +93 -22
- package/dist/pinet-mesh-ops.d.ts +6 -4
- package/dist/pinet-mesh-ops.js +9 -1
- package/dist/pinet-runtime-composition.d.ts +2 -1
- package/dist/pinet-runtime-composition.js +14 -3
- package/dist/pinet-session-formatting.d.ts +26 -2
- package/dist/pinet-session-formatting.js +2 -0
- package/dist/pinet-tools.d.ts +14 -0
- package/dist/pinet-tools.js +355 -50
- package/dist/prompts/broker/default.md +3 -3
- package/dist/prompts/broker/tmux.md +3 -3
- package/dist/runtime-agent-context.d.ts +3 -3
- package/dist/runtime-agent-context.js +12 -6
- package/dist/runtime-mode.js +4 -0
- package/dist/session-ui-runtime.js +3 -1
- package/dist/single-player-runtime.d.ts +1 -1
- package/dist/single-player-runtime.js +21 -5
- package/dist/slack-access.d.ts +10 -2
- package/dist/slack-access.js +64 -22
- package/dist/slack-block-kit.d.ts +44 -2
- package/dist/slack-block-kit.js +5 -5
- package/dist/slack-file-access.d.ts +12 -1
- package/dist/slack-file-access.js +4 -4
- package/dist/slack-message-context.d.ts +9 -2
- package/dist/slack-modals.d.ts +4 -1
- package/dist/slack-modals.js +5 -5
- package/dist/slack-pinet-runtime-adapter.d.ts +1 -0
- package/dist/slack-pinet-runtime-adapter.js +10 -0
- package/dist/slack-presence.d.ts +5 -4
- package/dist/slack-request-runtime.d.ts +3 -2
- package/dist/slack-scope-diagnostics.d.ts +27 -2
- package/dist/slack-socket-dedup.d.ts +8 -5
- package/dist/slack-tools.d.ts +10 -0
- package/dist/slack-tools.js +51 -64
- package/dist/slack-upload.d.ts +17 -1
- package/dist/subtree-broker-runtime.d.ts +83 -2
- package/dist/subtree-broker-runtime.js +774 -109
- package/dist/tool-output-limits.d.ts +11 -0
- package/dist/tool-output-limits.js +20 -0
- package/dist/tool-registration-runtime.d.ts +3 -0
- package/dist/tool-registration-runtime.js +27 -0
- package/package.json +8 -8
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Pure, side-effect-free helpers for the live hibernation runtime adapters.
|
|
2
|
+
//
|
|
3
|
+
// These back the real `HibernationProcessController` / `HibernationTmuxController`
|
|
4
|
+
// (which shell out to `ps`, `kill`, `git`, and `tmux`) and the worker-side wake
|
|
5
|
+
// fence ingestion. Keeping the string/parse logic pure makes the security- and
|
|
6
|
+
// correctness-critical pieces (VCS identity derivation, wake-fence env parsing,
|
|
7
|
+
// resume-launcher construction) unit-testable without spawning processes.
|
|
8
|
+
import { parsePinetStableId } from "../pinet-session-formatting.js";
|
|
9
|
+
/** Single-quote a value for safe embedding in a POSIX shell launcher script. */
|
|
10
|
+
export function shellQuote(value) {
|
|
11
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Derive a canonical `owner/repo` VCS identity from a git remote URL. Supports
|
|
15
|
+
* scp-style (`git@github.com:owner/repo.git`), URL (`https://host/owner/repo`,
|
|
16
|
+
* `ssh://git@host/owner/repo`, `git://host/owner/repo`), and bare `owner/repo`
|
|
17
|
+
* forms. Returns null when no `owner/repo` can be derived.
|
|
18
|
+
*
|
|
19
|
+
* This is the ONLY identity the repo allowlist authorizes against, and it is
|
|
20
|
+
* derived from the runtime's actual git REMOTE — never from filesystem directory
|
|
21
|
+
* names — so distinct roots that merely share their final path segments (or a
|
|
22
|
+
* worktree vs. its clone) never collapse onto, or diverge from, one authorization
|
|
23
|
+
* identity.
|
|
24
|
+
*/
|
|
25
|
+
export function deriveVcsIdentity(remoteUrl) {
|
|
26
|
+
const raw = remoteUrl?.trim();
|
|
27
|
+
if (!raw)
|
|
28
|
+
return null;
|
|
29
|
+
// Normalize: drop a trailing `.git` and any trailing slashes.
|
|
30
|
+
const normalized = raw.replace(/\.git$/i, "").replace(/\/+$/, "");
|
|
31
|
+
let pathPart = null;
|
|
32
|
+
const scp = /^[^@\s/]+@[^:\s/]+:(.+)$/.exec(normalized); // git@host:owner/repo
|
|
33
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/(.+)$/i.exec(normalized); // scheme://host/owner/repo
|
|
34
|
+
if (scp) {
|
|
35
|
+
pathPart = scp[1];
|
|
36
|
+
}
|
|
37
|
+
else if (url) {
|
|
38
|
+
pathPart = url[1];
|
|
39
|
+
}
|
|
40
|
+
else if (normalized.includes("/") && !normalized.includes(":")) {
|
|
41
|
+
pathPart = normalized; // bare owner/repo (or deeper path)
|
|
42
|
+
}
|
|
43
|
+
if (!pathPart)
|
|
44
|
+
return null;
|
|
45
|
+
const segments = pathPart.split("/").filter(Boolean);
|
|
46
|
+
if (segments.length < 2)
|
|
47
|
+
return null;
|
|
48
|
+
const owner = segments[segments.length - 2];
|
|
49
|
+
const repo = segments[segments.length - 1];
|
|
50
|
+
if (!owner || !repo)
|
|
51
|
+
return null;
|
|
52
|
+
return `${owner}/${repo}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Parse resident set size (bytes) from `ps -o rss= -p <pid>` output. macOS/Linux
|
|
56
|
+
* `ps` reports RSS in kibibytes; returns null when no numeric value is present
|
|
57
|
+
* (e.g. the process already exited).
|
|
58
|
+
*/
|
|
59
|
+
export function parseRssBytesFromPs(psOutput) {
|
|
60
|
+
const match = /\d+/.exec(psOutput ?? "");
|
|
61
|
+
if (!match)
|
|
62
|
+
return null;
|
|
63
|
+
const kib = Number.parseInt(match[0], 10);
|
|
64
|
+
return Number.isFinite(kib) ? kib * 1024 : null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Build the broker-resolvable, redaction-safe session resume reference for a
|
|
68
|
+
* worker from its stable id. A worker's stable id embeds the absolute path to
|
|
69
|
+
* its Pi session `.jsonl` (`<host>:session:<path>`); the resume ref is stored as
|
|
70
|
+
* `session:<path>` so `redactRuntimeSpec` digests it to `session:#<fingerprint>`
|
|
71
|
+
* on every operator/JSON surface while the broker can still recover the path to
|
|
72
|
+
* respawn `pi --session <path>`. Returns null for stable ids without a session
|
|
73
|
+
* path (e.g. `cwd:`/`leaf:` kinds), which are not resumable.
|
|
74
|
+
*/
|
|
75
|
+
export function sessionResumeRefFromStableId(stableId) {
|
|
76
|
+
const parsed = parsePinetStableId(stableId);
|
|
77
|
+
// Only the `session` kind carries a resumable Pi session `.jsonl`; `cwd`/`leaf`
|
|
78
|
+
// stable ids also expose a filesystem locator but have no resumable session.
|
|
79
|
+
if (!parsed || parsed.kind !== "session" || !parsed.hasPath)
|
|
80
|
+
return null;
|
|
81
|
+
return `session:${parsed.locator}`;
|
|
82
|
+
}
|
|
83
|
+
/** Recover the absolute session `.jsonl` path from a `session:<path>` resume ref. */
|
|
84
|
+
export function resumePathFromSessionRef(sessionResumeRef) {
|
|
85
|
+
const ref = sessionResumeRef?.trim();
|
|
86
|
+
if (!ref)
|
|
87
|
+
return null;
|
|
88
|
+
const separator = ref.indexOf(":");
|
|
89
|
+
if (separator <= 0)
|
|
90
|
+
return null;
|
|
91
|
+
if (ref.slice(0, separator) !== "session")
|
|
92
|
+
return null;
|
|
93
|
+
const path = ref.slice(separator + 1).trim();
|
|
94
|
+
return path.length > 0 ? path : null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Environment variables the respawn launcher exports so the woken runtime can
|
|
98
|
+
* present its single-use wake fence on registration. Ordinary (non-wake) spawns
|
|
99
|
+
* never set these, so ordinary registration stays fence-free and backward
|
|
100
|
+
* compatible.
|
|
101
|
+
*/
|
|
102
|
+
export function buildWakeFenceEnv(input) {
|
|
103
|
+
return {
|
|
104
|
+
PINET_WAKE_LEASE_ID: input.wakeLeaseId,
|
|
105
|
+
PINET_WAKE_FENCE_TOKEN: String(input.fenceToken),
|
|
106
|
+
PINET_WAKE_RESERVED_GENERATION: String(input.reservedGeneration),
|
|
107
|
+
PINET_WAKE_RESERVATION_NONCE: input.reservationNonce,
|
|
108
|
+
PINET_WAKE_CORRELATION_ID: input.correlationId,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Parse a canonical positive decimal safe integer, or null. Unlike
|
|
113
|
+
* `Number.parseInt`, which silently coerces `"12abc"`→12, `" 12"`→12, `"+12"`→12
|
|
114
|
+
* and accepts leading-zero forms, this accepts ONLY a bare run of decimal digits
|
|
115
|
+
* with no sign, no leading zero, no surrounding whitespace, and a value within
|
|
116
|
+
* the safe-integer range. A garbled or hostile fence value therefore fails
|
|
117
|
+
* closed to an ordinary (fence-free) registration rather than silently
|
|
118
|
+
* round-tripping a corrupted generation/token.
|
|
119
|
+
*/
|
|
120
|
+
function parseCanonicalPositiveInt(raw) {
|
|
121
|
+
if (raw == null || !/^[1-9][0-9]*$/.test(raw))
|
|
122
|
+
return null;
|
|
123
|
+
const value = Number(raw);
|
|
124
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Parse a wake fence from a woken worker's environment (the boundary between the
|
|
128
|
+
* respawn launcher and the follower's register RPC). Returns null unless ALL
|
|
129
|
+
* required fields are present and well-formed — the numeric fence token and
|
|
130
|
+
* reserved generation must be canonical positive decimal safe integers — so a
|
|
131
|
+
* partial/garbled environment fails closed to an ordinary (fence-free)
|
|
132
|
+
* registration rather than a malformed fenced one.
|
|
133
|
+
*/
|
|
134
|
+
export function parseWakeFenceEnv(env) {
|
|
135
|
+
const wakeLeaseId = env.PINET_WAKE_LEASE_ID?.trim();
|
|
136
|
+
const reservationNonce = env.PINET_WAKE_RESERVATION_NONCE?.trim();
|
|
137
|
+
const fenceToken = parseCanonicalPositiveInt(env.PINET_WAKE_FENCE_TOKEN);
|
|
138
|
+
const reservedGeneration = parseCanonicalPositiveInt(env.PINET_WAKE_RESERVED_GENERATION);
|
|
139
|
+
if (!wakeLeaseId || !reservationNonce)
|
|
140
|
+
return null;
|
|
141
|
+
if (fenceToken == null || reservedGeneration == null)
|
|
142
|
+
return null;
|
|
143
|
+
return { wakeLeaseId, fenceToken, reservedGeneration, reservationNonce };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Compose a durable {@link AgentRuntimeSpecInput} from broker-known spawn facts.
|
|
147
|
+
*
|
|
148
|
+
* Fails closed (returns null) when the identity is not a resumable Pi session
|
|
149
|
+
* (`sessionResumeRef` cannot be derived) or any operational locator the broker
|
|
150
|
+
* must act on (tmux socket/session/target, repo root) is missing — a spec that
|
|
151
|
+
* could not be safely hibernated/woken is never recorded. `expectedHost` is
|
|
152
|
+
* taken from the stable id's host prefix; `argv` mirrors the resume launch
|
|
153
|
+
* (`pi -e <entry> --session <path>`) for provenance; the resume path itself is
|
|
154
|
+
* only ever recovered from the redaction-safe `sessionResumeRef`.
|
|
155
|
+
*/
|
|
156
|
+
export function buildRuntimeSpecInput(facts) {
|
|
157
|
+
const sessionResumeRef = sessionResumeRefFromStableId(facts.stableId);
|
|
158
|
+
if (!sessionResumeRef)
|
|
159
|
+
return null;
|
|
160
|
+
const resumePath = resumePathFromSessionRef(sessionResumeRef);
|
|
161
|
+
if (!resumePath || !facts.repoRoot)
|
|
162
|
+
return null;
|
|
163
|
+
const common = {
|
|
164
|
+
agentId: facts.agentId,
|
|
165
|
+
stableId: facts.stableId,
|
|
166
|
+
brokerOwnerId: facts.brokerOwnerId,
|
|
167
|
+
cwd: facts.cwd || facts.repoRoot,
|
|
168
|
+
repoRoot: facts.repoRoot,
|
|
169
|
+
worktreePath: facts.worktreePath || facts.repoRoot,
|
|
170
|
+
executable: "pi",
|
|
171
|
+
argv: ["-e", facts.extensionEntryPath, "--session", resumePath],
|
|
172
|
+
envAllowlist: Array.from(new Set(facts.envAllowlist.filter((name) => name.length > 0))),
|
|
173
|
+
sessionResumeRef,
|
|
174
|
+
configFingerprint: facts.configFingerprint || "unknown",
|
|
175
|
+
expectedHost: parsePinetStableId(facts.stableId)?.host ?? "",
|
|
176
|
+
expectedUser: facts.expectedUser,
|
|
177
|
+
launchSource: facts.launchSource || "subtree-broker-tmux",
|
|
178
|
+
vcsIdentity: facts.vcsIdentity,
|
|
179
|
+
};
|
|
180
|
+
if (facts.runtimeKind === "herdr") {
|
|
181
|
+
if (!facts.herdrSession ||
|
|
182
|
+
!facts.herdrConfigDir ||
|
|
183
|
+
!facts.herdrPaneId ||
|
|
184
|
+
!Number.isInteger(facts.herdrShellPid) ||
|
|
185
|
+
facts.herdrShellPid <= 0) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
...common,
|
|
190
|
+
runtimeKind: "herdr",
|
|
191
|
+
herdrSession: facts.herdrSession,
|
|
192
|
+
herdrConfigDir: facts.herdrConfigDir,
|
|
193
|
+
herdrPaneId: facts.herdrPaneId,
|
|
194
|
+
herdrShellPid: facts.herdrShellPid,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (!facts.tmuxSocket || !facts.tmuxSession || !facts.tmuxTarget)
|
|
198
|
+
return null;
|
|
199
|
+
return {
|
|
200
|
+
...common,
|
|
201
|
+
runtimeKind: "tmux",
|
|
202
|
+
tmuxSocket: facts.tmuxSocket,
|
|
203
|
+
tmuxSession: facts.tmuxSession,
|
|
204
|
+
tmuxTarget: facts.tmuxTarget,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Build the launcher script a wake attempt runs via `tmux respawn-pane` to bring
|
|
209
|
+
* back the fenced runtime. It re-establishes the repo cwd and launch environment,
|
|
210
|
+
* exports the wake fence, and resumes the exact session with `pi --session
|
|
211
|
+
* <path>` (no startup prompt — the session already carries the worker's context;
|
|
212
|
+
* injecting a prompt would append a spurious user turn). The woken Pi therefore
|
|
213
|
+
* re-registers under the SAME stable id (same session path) and presents the
|
|
214
|
+
* fence for atomic generation acceptance.
|
|
215
|
+
*
|
|
216
|
+
* The script is secret-bearing (it exports mesh/Slack credential VALUES), so it
|
|
217
|
+
* removes its own file (`rm -f -- "$0"`) as its FIRST executable action — before
|
|
218
|
+
* `cd` and before exporting ANY secret. Under `set -euo pipefail` a later failure
|
|
219
|
+
* (e.g. a missing/unmounted repo making `cd` exit) therefore cannot leave a
|
|
220
|
+
* secret-bearing file behind, closing the “tmux accepted launch, launcher began,
|
|
221
|
+
* pre-delete setup failed” retention window. The launcher fd stays open across the
|
|
222
|
+
* unlink (POSIX keeps the inode until the fd closes), so `exec pi` still runs from
|
|
223
|
+
* the now-unnamed inode even though the secrets are exported after the unlink. The
|
|
224
|
+
* broker separately unlinks on the failure path (where the script never ran) and
|
|
225
|
+
* materializes launchers only in a private, owner-only directory.
|
|
226
|
+
*/
|
|
227
|
+
export function buildResumeLauncherScript(input) {
|
|
228
|
+
// Self-delete FIRST (before cd and before any secret export) so no failure in
|
|
229
|
+
// the remaining setup can leave the secret-bearing launcher on disk.
|
|
230
|
+
const lines = ["#!/bin/bash", "set -euo pipefail", `rm -f -- "$0"`];
|
|
231
|
+
lines.push(`cd ${shellQuote(input.repoPath)}`);
|
|
232
|
+
for (const [key, value] of Object.entries(input.inheritedEnv)) {
|
|
233
|
+
if (value !== undefined && value !== "")
|
|
234
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
235
|
+
}
|
|
236
|
+
for (const [key, value] of Object.entries(input.pinetEnv)) {
|
|
237
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
238
|
+
}
|
|
239
|
+
lines.push(`export PI_NICKNAME=${shellQuote(input.nickname)}`);
|
|
240
|
+
lines.push(`exec pi -e ${shellQuote(input.extensionEntryPath)} --session ${shellQuote(input.sessionPath)}`);
|
|
241
|
+
lines.push("");
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
package/dist/broker/index.d.ts
CHANGED
|
@@ -5,7 +5,10 @@ import { LeaderLock } from "./leader.js";
|
|
|
5
5
|
import type { MessageAdapter } from "./types.js";
|
|
6
6
|
export { BrokerDB } from "./schema.js";
|
|
7
7
|
export { BrokerSocketServer } from "./socket-server.js";
|
|
8
|
-
export { LeaderLock } from "./leader.js";
|
|
8
|
+
export { LeaderLock, inspectBrokerLock, readBrokerLockOwner, getProcessStartTime, } from "./leader.js";
|
|
9
|
+
export type { BrokerLockInspection, BrokerLockOwner, BrokerLockProbes } from "./leader.js";
|
|
10
|
+
export { BrokerLockConflictError, classifyBrokerLockConflict, formatBrokerLockConflictMessage, probeBrokerSocket, replaceBrokerOwner, requestBrokerShutdown, } from "./lock-conflict.js";
|
|
11
|
+
export type { BrokerLockConflict, BrokerLockConflictClassification, BrokerShutdownRequestResult, BrokerSocketProbeResult, ReplaceBrokerOwnerOptions, ReplaceBrokerOwnerOutcome, ReplaceBrokerOwnerResult, } from "./lock-conflict.js";
|
|
9
12
|
export type { ListenTarget } from "./socket-server.js";
|
|
10
13
|
export type { AgentMessageCallback, AgentRegistrationResolver } from "./socket-server.js";
|
|
11
14
|
export type { AgentInfo, ThreadInfo, ScheduledWakeupInfo, ScheduledWakeupDelivery, BrokerMessage, InboxEntry, InboundMessage, OutboundMessage, AdapterCapabilityRequest, AdapterCapabilityResult, MessageAdapter, JsonRpcRequest, JsonRpcResponse, TaskAssignmentInfo, TaskAssignmentStatus, } from "./types.js";
|
|
@@ -18,6 +21,17 @@ export interface BrokerOptions {
|
|
|
18
21
|
lockPath?: string;
|
|
19
22
|
meshSecret?: string;
|
|
20
23
|
meshSecretPath?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Runs after DB initialization but BEFORE the socket server begins listening
|
|
26
|
+
* (i.e. before any client can connect or register). Use for startup
|
|
27
|
+
* reconciliation — e.g. stranded-wake recovery — that must deterministically
|
|
28
|
+
* complete before an incoming registration can race it. If it throws, the
|
|
29
|
+
* broker is torn down (DB closed, lock released) and the error propagates so a
|
|
30
|
+
* failed reconciliation never leaves a half-open, already-listening broker.
|
|
31
|
+
*/
|
|
32
|
+
beforeListen?: (ctx: {
|
|
33
|
+
db: BrokerDB;
|
|
34
|
+
}) => void | Promise<void>;
|
|
21
35
|
}
|
|
22
36
|
export interface Broker {
|
|
23
37
|
db: BrokerDB;
|
|
@@ -25,6 +39,7 @@ export interface Broker {
|
|
|
25
39
|
lock: LeaderLock;
|
|
26
40
|
adapters: MessageAdapter[];
|
|
27
41
|
addAdapter(adapter: MessageAdapter): void;
|
|
42
|
+
removeAdapters(adapters: readonly MessageAdapter[]): Promise<void>;
|
|
28
43
|
stop(): Promise<void>;
|
|
29
44
|
}
|
|
30
45
|
/**
|
package/dist/broker/index.js
CHANGED
|
@@ -4,10 +4,12 @@ import { loadOrCreateMeshSecret } from "./auth.js";
|
|
|
4
4
|
import { BrokerSocketServer } from "./socket-server.js";
|
|
5
5
|
import { assertLoopbackTcpHost } from "./raw-tcp-loopback.js";
|
|
6
6
|
import { LeaderLock } from "./leader.js";
|
|
7
|
+
import { BrokerLockConflictError, classifyBrokerLockConflict } from "./lock-conflict.js";
|
|
7
8
|
import { getDefaultSocketPath } from "./paths.js";
|
|
8
9
|
export { BrokerDB } from "./schema.js";
|
|
9
10
|
export { BrokerSocketServer } from "./socket-server.js";
|
|
10
|
-
export { LeaderLock } from "./leader.js";
|
|
11
|
+
export { LeaderLock, inspectBrokerLock, readBrokerLockOwner, getProcessStartTime, } from "./leader.js";
|
|
12
|
+
export { BrokerLockConflictError, classifyBrokerLockConflict, formatBrokerLockConflictMessage, probeBrokerSocket, replaceBrokerOwner, requestBrokerShutdown, } from "./lock-conflict.js";
|
|
11
13
|
/**
|
|
12
14
|
* Start the broker: acquire leader lock, initialize SQLite, start the Unix socket server.
|
|
13
15
|
* Only one broker may run at a time — enforced by a PID lock file.
|
|
@@ -24,9 +26,29 @@ export async function startBroker(options = {}) {
|
|
|
24
26
|
assertLoopbackTcpHost(target.host, "broker listen target");
|
|
25
27
|
}
|
|
26
28
|
// ── Leader lock: prevent split-brain (issue #119) ────
|
|
29
|
+
// On conflict, classify the lock owner (issue #951) so callers can offer a
|
|
30
|
+
// real recovery path instead of a generic failure.
|
|
27
31
|
const lock = new LeaderLock(options.lockPath);
|
|
28
32
|
if (!lock.tryAcquire()) {
|
|
29
|
-
|
|
33
|
+
const conflict = await classifyBrokerLockConflict({
|
|
34
|
+
lockPath: lock.getLockPath(),
|
|
35
|
+
target,
|
|
36
|
+
});
|
|
37
|
+
// The owner may have died between the acquire attempt and classification
|
|
38
|
+
// — retry once when the conflict became reclaimable.
|
|
39
|
+
if (conflict.kind === "reclaimable" && lock.tryAcquire()) {
|
|
40
|
+
// Raced a dying owner; we now hold the lock.
|
|
41
|
+
}
|
|
42
|
+
else if (conflict.kind === "conflict") {
|
|
43
|
+
throw new BrokerLockConflictError({
|
|
44
|
+
classification: conflict.classification,
|
|
45
|
+
owner: conflict.owner,
|
|
46
|
+
probe: conflict.probe,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
throw new Error("Another pinet broker is already running. Only one broker may be active at a time.");
|
|
51
|
+
}
|
|
30
52
|
}
|
|
31
53
|
const db = new BrokerDB(options.dbPath);
|
|
32
54
|
try {
|
|
@@ -53,6 +75,19 @@ export async function startBroker(options = {}) {
|
|
|
53
75
|
const server = new BrokerSocketServer(db, target, {
|
|
54
76
|
...(resolvedMeshSecret ? { meshSecret: resolvedMeshSecret } : {}),
|
|
55
77
|
});
|
|
78
|
+
// Run startup reconciliation strictly BEFORE the socket opens, so nothing can
|
|
79
|
+
// connect/register until it completes. A failure here must not leave a
|
|
80
|
+
// half-open broker: tear down before rethrowing (the server has not started).
|
|
81
|
+
if (options.beforeListen) {
|
|
82
|
+
try {
|
|
83
|
+
await options.beforeListen({ db });
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
db.close();
|
|
87
|
+
lock.release();
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
56
91
|
try {
|
|
57
92
|
await server.start();
|
|
58
93
|
}
|
|
@@ -71,19 +106,42 @@ export async function startBroker(options = {}) {
|
|
|
71
106
|
adapters.push(adapter);
|
|
72
107
|
server.setOutboundMessageAdapters(adapters);
|
|
73
108
|
},
|
|
74
|
-
async
|
|
75
|
-
|
|
109
|
+
async removeAdapters(removedAdapters) {
|
|
110
|
+
const removed = new Set(removedAdapters);
|
|
111
|
+
const retained = adapters.filter((adapter) => !removed.has(adapter));
|
|
112
|
+
adapters.length = 0;
|
|
113
|
+
adapters.push(...retained);
|
|
114
|
+
server.setOutboundMessageAdapters(adapters);
|
|
115
|
+
const disconnectErrors = [];
|
|
116
|
+
for (const adapter of removedAdapters) {
|
|
76
117
|
try {
|
|
77
118
|
await adapter.disconnect();
|
|
78
119
|
}
|
|
79
|
-
catch {
|
|
80
|
-
|
|
120
|
+
catch (error) {
|
|
121
|
+
disconnectErrors.push(error instanceof Error ? error : new Error(String(error)));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (disconnectErrors.length > 0) {
|
|
125
|
+
throw new AggregateError(disconnectErrors, "Failed to disconnect broker adapters");
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
async stop() {
|
|
129
|
+
try {
|
|
130
|
+
await this.removeAdapters([...adapters]);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
try {
|
|
134
|
+
await server.stop();
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
try {
|
|
138
|
+
db.close();
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
lock.release();
|
|
142
|
+
}
|
|
81
143
|
}
|
|
82
144
|
}
|
|
83
|
-
adapters.length = 0;
|
|
84
|
-
await server.stop();
|
|
85
|
-
db.close();
|
|
86
|
-
lock.release();
|
|
87
145
|
},
|
|
88
146
|
};
|
|
89
147
|
return broker;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { type BrokerLockInspection, type BrokerLockOwner, type BrokerLockProbes } from "./leader.js";
|
|
2
|
+
import type { ListenTarget } from "./socket-server.js";
|
|
3
|
+
export declare const DEFAULT_PROBE_TIMEOUT_MS = 2000;
|
|
4
|
+
export declare const DEFAULT_SHUTDOWN_RPC_TIMEOUT_MS = 5000;
|
|
5
|
+
export declare const DEFAULT_GRACEFUL_WAIT_MS = 8000;
|
|
6
|
+
export declare const DEFAULT_TERMINATE_WAIT_MS = 8000;
|
|
7
|
+
export declare const DEFAULT_REPLACE_POLL_INTERVAL_MS = 250;
|
|
8
|
+
export type BrokerSocketProbeResult = "healthy" | "unreachable" | "unresponsive";
|
|
9
|
+
export interface ProbeBrokerSocketOptions {
|
|
10
|
+
target?: ListenTarget;
|
|
11
|
+
socketPath?: string;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Bounded liveness probe for a broker socket. Any well-formed JSON-RPC
|
|
16
|
+
* response — including an auth error — proves the broker event loop is
|
|
17
|
+
* serving, so it classifies as healthy.
|
|
18
|
+
*/
|
|
19
|
+
export declare function probeBrokerSocket(options?: ProbeBrokerSocketOptions): Promise<BrokerSocketProbeResult>;
|
|
20
|
+
export type BrokerShutdownRequestResult = "accepted"
|
|
21
|
+
/** The broker responded with method-not-found — it predates the RPC. */
|
|
22
|
+
| "unsupported"
|
|
23
|
+
/** The broker responded with an error (e.g. auth rejection) — it is alive and refusing. */
|
|
24
|
+
| "rejected" | "unreachable"
|
|
25
|
+
/** Connected but no usable response — the broker looks hung. */
|
|
26
|
+
| "failed";
|
|
27
|
+
export interface RequestBrokerShutdownOptions {
|
|
28
|
+
target?: ListenTarget;
|
|
29
|
+
socketPath?: string;
|
|
30
|
+
meshSecret?: string | null;
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Ask a running broker to shut down gracefully via the authenticated
|
|
35
|
+
* `admin.shutdown` RPC. Brokers that predate the RPC report `unsupported`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function requestBrokerShutdown(options?: RequestBrokerShutdownOptions): Promise<BrokerShutdownRequestResult>;
|
|
38
|
+
export type BrokerLockConflictClassification = "active-broker" | "unresponsive-broker";
|
|
39
|
+
export type BrokerLockConflict = {
|
|
40
|
+
kind: "reclaimable";
|
|
41
|
+
inspection: BrokerLockInspection;
|
|
42
|
+
probe: null;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "conflict";
|
|
45
|
+
classification: BrokerLockConflictClassification;
|
|
46
|
+
owner: BrokerLockOwner;
|
|
47
|
+
probe: BrokerSocketProbeResult;
|
|
48
|
+
};
|
|
49
|
+
export interface ClassifyBrokerLockConflictOptions {
|
|
50
|
+
lockPath?: string;
|
|
51
|
+
target?: ListenTarget;
|
|
52
|
+
socketPath?: string;
|
|
53
|
+
probeTimeoutMs?: number;
|
|
54
|
+
probes?: BrokerLockProbes;
|
|
55
|
+
probeSocket?: (target: ListenTarget, timeoutMs: number) => Promise<BrokerSocketProbeResult>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Combine lock-owner inspection with a bounded socket probe to classify a
|
|
59
|
+
* leader-lock conflict.
|
|
60
|
+
*/
|
|
61
|
+
export declare function classifyBrokerLockConflict(options?: ClassifyBrokerLockConflictOptions): Promise<BrokerLockConflict>;
|
|
62
|
+
export interface BrokerLockConflictErrorInput {
|
|
63
|
+
classification: BrokerLockConflictClassification;
|
|
64
|
+
owner: BrokerLockOwner | null;
|
|
65
|
+
probe: BrokerSocketProbeResult | null;
|
|
66
|
+
}
|
|
67
|
+
export declare function formatBrokerLockConflictMessage(input: BrokerLockConflictErrorInput): string;
|
|
68
|
+
/**
|
|
69
|
+
* Thrown by `startBroker` when the leader lock is held by another live
|
|
70
|
+
* process, carrying enough context for callers to present a per-state
|
|
71
|
+
* recovery path instead of a generic failure.
|
|
72
|
+
*/
|
|
73
|
+
export declare class BrokerLockConflictError extends Error {
|
|
74
|
+
readonly classification: BrokerLockConflictClassification;
|
|
75
|
+
readonly owner: BrokerLockOwner | null;
|
|
76
|
+
readonly probe: BrokerSocketProbeResult | null;
|
|
77
|
+
constructor(input: BrokerLockConflictErrorInput);
|
|
78
|
+
}
|
|
79
|
+
export type ReplaceBrokerOwnerOutcome = "no-conflict" | "replaced-graceful" | "replaced-terminated" | "owner-changed" | "failed";
|
|
80
|
+
export interface ReplaceBrokerOwnerResult {
|
|
81
|
+
outcome: ReplaceBrokerOwnerOutcome;
|
|
82
|
+
owner: BrokerLockOwner | null;
|
|
83
|
+
steps: string[];
|
|
84
|
+
error: string | null;
|
|
85
|
+
}
|
|
86
|
+
export interface ReplaceBrokerOwnerOptions {
|
|
87
|
+
lockPath?: string;
|
|
88
|
+
target?: ListenTarget;
|
|
89
|
+
socketPath?: string;
|
|
90
|
+
meshSecret?: string | null;
|
|
91
|
+
gracefulWaitMs?: number;
|
|
92
|
+
terminateWaitMs?: number;
|
|
93
|
+
pollIntervalMs?: number;
|
|
94
|
+
shutdownRpcTimeoutMs?: number;
|
|
95
|
+
probes?: BrokerLockProbes;
|
|
96
|
+
requestShutdown?: (options: RequestBrokerShutdownOptions) => Promise<BrokerShutdownRequestResult>;
|
|
97
|
+
kill?: (pid: number, signal: NodeJS.Signals) => void;
|
|
98
|
+
sleep?: (ms: number) => Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Conservatively replace the current broker lock owner:
|
|
102
|
+
*
|
|
103
|
+
* 1. No live owner → nothing to do (a normal start reclaims stale state).
|
|
104
|
+
* 2. Live owner → request graceful `admin.shutdown` and wait (bounded).
|
|
105
|
+
* 3. Fallback → re-verify the owner fence (pid + process start time +
|
|
106
|
+
* instance id), SIGTERM the verified owner, and wait (bounded).
|
|
107
|
+
* 4. Never SIGKILL; abort if the owner identity changes mid-flight.
|
|
108
|
+
*/
|
|
109
|
+
export declare function replaceBrokerOwner(options?: ReplaceBrokerOwnerOptions): Promise<ReplaceBrokerOwnerResult>;
|