@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.
Files changed (93) hide show
  1. package/README.md +316 -451
  2. package/dist/activity-log.d.ts +10 -3
  3. package/dist/agent-event-runtime.d.ts +0 -2
  4. package/dist/agent-event-runtime.js +0 -1
  5. package/dist/agent-prompt-guidance.d.ts +1 -6
  6. package/dist/agent-prompt-guidance.js +1 -6
  7. package/dist/broker/adapters/slack.d.ts +4 -1
  8. package/dist/broker/adapters/slack.js +16 -9
  9. package/dist/broker/client.d.ts +21 -4
  10. package/dist/broker/client.js +65 -25
  11. package/dist/broker/hibernation-activation-authority.d.ts +22 -0
  12. package/dist/broker/hibernation-activation-authority.js +51 -0
  13. package/dist/broker/hibernation-activation.d.ts +75 -0
  14. package/dist/broker/hibernation-activation.js +95 -0
  15. package/dist/broker/hibernation-command-router.d.ts +54 -0
  16. package/dist/broker/hibernation-command-router.js +124 -0
  17. package/dist/broker/hibernation-runtime-adapters.d.ts +79 -0
  18. package/dist/broker/hibernation-runtime-adapters.js +473 -0
  19. package/dist/broker/hibernation-runtime-helpers.d.ts +146 -0
  20. package/dist/broker/hibernation-runtime-helpers.js +243 -0
  21. package/dist/broker/index.d.ts +16 -1
  22. package/dist/broker/index.js +68 -10
  23. package/dist/broker/lock-conflict.d.ts +109 -0
  24. package/dist/broker/lock-conflict.js +409 -0
  25. package/dist/broker/socket-server.d.ts +26 -0
  26. package/dist/broker/socket-server.js +287 -4
  27. package/dist/broker-delivery.d.ts +1 -0
  28. package/dist/broker-delivery.js +10 -1
  29. package/dist/broker-runtime.d.ts +14 -2
  30. package/dist/broker-runtime.js +114 -3
  31. package/dist/canvases.d.ts +12 -2
  32. package/dist/canvases.js +1 -3
  33. package/dist/compaction-gate.d.ts +7 -0
  34. package/dist/compaction-gate.js +35 -0
  35. package/dist/deploy-manifest.d.ts +8 -2
  36. package/dist/deploy-manifest.js +9 -7
  37. package/dist/follower-runtime.d.ts +5 -2
  38. package/dist/follower-runtime.js +39 -5
  39. package/dist/git-metadata.d.ts +5 -9
  40. package/dist/git-metadata.js +23 -42
  41. package/dist/guardrails.js +5 -0
  42. package/dist/helpers.d.ts +77 -27
  43. package/dist/helpers.js +150 -39
  44. package/dist/hibernation-config.d.ts +13 -0
  45. package/dist/hibernation-config.js +14 -0
  46. package/dist/home-tab.d.ts +13 -2
  47. package/dist/imessage-tools.d.ts +4 -1
  48. package/dist/inbox-drain-runtime.d.ts +1 -0
  49. package/dist/inbox-drain-runtime.js +5 -1
  50. package/dist/index.js +341 -108
  51. package/dist/pinet-commands.d.ts +14 -1
  52. package/dist/pinet-commands.js +93 -22
  53. package/dist/pinet-mesh-ops.d.ts +6 -4
  54. package/dist/pinet-mesh-ops.js +9 -1
  55. package/dist/pinet-runtime-composition.d.ts +2 -1
  56. package/dist/pinet-runtime-composition.js +14 -3
  57. package/dist/pinet-session-formatting.d.ts +26 -2
  58. package/dist/pinet-session-formatting.js +2 -0
  59. package/dist/pinet-tools.d.ts +14 -0
  60. package/dist/pinet-tools.js +355 -50
  61. package/dist/prompts/broker/default.md +3 -3
  62. package/dist/prompts/broker/tmux.md +3 -3
  63. package/dist/runtime-agent-context.d.ts +3 -3
  64. package/dist/runtime-agent-context.js +12 -6
  65. package/dist/runtime-mode.js +4 -0
  66. package/dist/session-ui-runtime.js +3 -1
  67. package/dist/single-player-runtime.d.ts +1 -1
  68. package/dist/single-player-runtime.js +21 -5
  69. package/dist/slack-access.d.ts +10 -2
  70. package/dist/slack-access.js +64 -22
  71. package/dist/slack-block-kit.d.ts +44 -2
  72. package/dist/slack-block-kit.js +5 -5
  73. package/dist/slack-file-access.d.ts +12 -1
  74. package/dist/slack-file-access.js +4 -4
  75. package/dist/slack-message-context.d.ts +9 -2
  76. package/dist/slack-modals.d.ts +4 -1
  77. package/dist/slack-modals.js +5 -5
  78. package/dist/slack-pinet-runtime-adapter.d.ts +1 -0
  79. package/dist/slack-pinet-runtime-adapter.js +10 -0
  80. package/dist/slack-presence.d.ts +5 -4
  81. package/dist/slack-request-runtime.d.ts +3 -2
  82. package/dist/slack-scope-diagnostics.d.ts +27 -2
  83. package/dist/slack-socket-dedup.d.ts +8 -5
  84. package/dist/slack-tools.d.ts +10 -0
  85. package/dist/slack-tools.js +51 -64
  86. package/dist/slack-upload.d.ts +17 -1
  87. package/dist/subtree-broker-runtime.d.ts +83 -2
  88. package/dist/subtree-broker-runtime.js +774 -109
  89. package/dist/tool-output-limits.d.ts +11 -0
  90. package/dist/tool-output-limits.js +20 -0
  91. package/dist/tool-registration-runtime.d.ts +3 -0
  92. package/dist/tool-registration-runtime.js +27 -0
  93. 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
+ }
@@ -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
  /**
@@ -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
- throw new Error("Another pinet broker is already running. Only one broker may be active at a time.");
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 stop() {
75
- for (const adapter of adapters) {
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
- // best effort
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>;