@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the detached View State Coordinator (issue #91, spec D3).
|
|
3
|
+
*
|
|
4
|
+
* Every semantic-state writer (dashboard service, job-runner, state-runner,
|
|
5
|
+
* CLI) submits `{type:"state_command"}` envelopes through `sendStateCommand`
|
|
6
|
+
* instead of writing state.json/status.json directly. The client resolves the
|
|
7
|
+
* coordinator endpoint, ensures a coordinator is live (spawning one if needed
|
|
8
|
+
* — idempotent via the coordinator's own lease), and waits for the matching
|
|
9
|
+
* `state_command_result`.
|
|
10
|
+
*
|
|
11
|
+
* Ambiguity contract (binding, from the Task 4 review): the coordinator can
|
|
12
|
+
* crash mid-command (e.g. an fs error) WITHOUT replying. The client then sees
|
|
13
|
+
* a connection reset or a timeout. Both mean the command MAY already be
|
|
14
|
+
* journaled and will replay on coordinator restart, so these outcomes are
|
|
15
|
+
* AMBIGUOUS: they resolve `{status:"rejected", reason:"timeout"|
|
|
16
|
+
* "connection_reset", materializedRevision:0}` and the client NEVER retries
|
|
17
|
+
* with a fresh commandId. Retrying with the SAME commandId after reconnect is
|
|
18
|
+
* safe (the coordinator dedupes) but is the caller's decision — which is why
|
|
19
|
+
* the caller may pin `command.commandId`.
|
|
20
|
+
*
|
|
21
|
+
* `materializedRevision: 0` on client-side outcomes means "no revision was
|
|
22
|
+
* observed" — it is not a store revision. Real coordinator replies always
|
|
23
|
+
* carry the actual revision (≥ 1 after any applied command; decided
|
|
24
|
+
* rejections carry the current revision).
|
|
25
|
+
*/
|
|
26
|
+
import { createConnection } from "node:net";
|
|
27
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { appendDiagnosticOnce } from "./diagnostics.mjs";
|
|
30
|
+
import { newRunId } from "./ids.mjs";
|
|
31
|
+
import { launchCoordinator } from "./launch.mjs";
|
|
32
|
+
import { isAlive } from "./pid.mjs";
|
|
33
|
+
import { readProcessStartToken } from "./process-identity-probe.mjs";
|
|
34
|
+
import * as P from "./paths.mjs";
|
|
35
|
+
import { COORDINATOR_PROTOCOL_VERSION } from "./coordinator-protocol.mjs";
|
|
36
|
+
|
|
37
|
+
const COORDINATOR_SCRIPT = fileURLToPath(
|
|
38
|
+
new URL("../../runner/state-coordinator.mjs", import.meta.url),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const PROBE_TIMEOUT_MS = 1_000;
|
|
42
|
+
const ENSURE_WINDOW_MS = 10_000;
|
|
43
|
+
const ENSURE_POLL_MS = 100;
|
|
44
|
+
const COMMAND_TIMEOUT_MS = 5_000;
|
|
45
|
+
|
|
46
|
+
/** @typedef {{ status: "applied"|"rejected", reason: string|null, materializedRevision: number }} StateCommandResult */
|
|
47
|
+
/** @typedef {{ ok: boolean, instanceId?: string, pid?: number|null, error?: "coordinator_disabled"|"coordinator_unavailable"|"coordinator_stale_protocol" }} EnsureResult */
|
|
48
|
+
/** @typedef {{ token: string|null, pid: number|null, instanceId: string|null, startToken: string|null }} LeaseRecord */
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the coordinator is switched off for this process (tests/legacy
|
|
52
|
+
* escape hatch). `AGENT_BOARD_COORDINATOR=off` makes every command resolve
|
|
53
|
+
* `coordinator_disabled` so callers fall back to the pre-coordinator path.
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function coordinatorDisabled() {
|
|
57
|
+
return /^(0|false|off|no)$/i.test(String(process.env.AGENT_BOARD_COORDINATOR ?? "").trim());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One probe round-trip: connect, ping, wait for the pong. The coordinator
|
|
62
|
+
* binds its socket only after boot replay, so a pong proves full readiness.
|
|
63
|
+
* @param {string} socketPath
|
|
64
|
+
* @param {(path: string) => import("node:net").Socket} connect
|
|
65
|
+
* @param {number} timeoutMs
|
|
66
|
+
* @returns {Promise<{ instanceId: string, protocolVersion: number }|null>} null on timeout/error;
|
|
67
|
+
* a pong without a numeric protocolVersion counts as version 1 (pre-#107 baseline)
|
|
68
|
+
*/
|
|
69
|
+
function probeOnce(socketPath, connect, timeoutMs) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
let settled = false;
|
|
72
|
+
let buffer = "";
|
|
73
|
+
/** @type {import("node:net").Socket|null} */
|
|
74
|
+
let socket = null;
|
|
75
|
+
const finish = (instanceId) => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
try { socket?.destroy(); } catch { /* best effort */ }
|
|
80
|
+
resolve(instanceId);
|
|
81
|
+
};
|
|
82
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
83
|
+
timer.unref?.();
|
|
84
|
+
try {
|
|
85
|
+
socket = connect(socketPath);
|
|
86
|
+
} catch {
|
|
87
|
+
finish(null);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// Missing filesystem sockets surface as async errors, not throw-on-connect
|
|
91
|
+
// (and Windows pipes never exist as files — never gate on existsSync).
|
|
92
|
+
socket.on("error", () => finish(null));
|
|
93
|
+
socket.on("connect", () => {
|
|
94
|
+
try {
|
|
95
|
+
socket?.write(JSON.stringify({ type: "ping" }) + "\n");
|
|
96
|
+
} catch {
|
|
97
|
+
finish(null);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
socket.on("data", (chunk) => {
|
|
101
|
+
buffer += chunk.toString("utf8");
|
|
102
|
+
const lines = buffer.split("\n");
|
|
103
|
+
buffer = lines.pop() ?? "";
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
if (!line.trim()) continue;
|
|
106
|
+
try {
|
|
107
|
+
const msg = JSON.parse(line);
|
|
108
|
+
if (msg?.type === "pong" && typeof msg.instanceId === "string") {
|
|
109
|
+
finish({
|
|
110
|
+
instanceId: msg.instanceId,
|
|
111
|
+
protocolVersion: typeof msg.protocolVersion === "number" ? msg.protocolVersion : 1,
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// malformed line — keep waiting for the pong
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* First argument that is a non-empty string, else null. Only a non-empty
|
|
125
|
+
* string identifies a process instance — "" and non-strings are treated as
|
|
126
|
+
* "field missing" so a malformed record can never read as a verified identity.
|
|
127
|
+
* @param {...unknown} values
|
|
128
|
+
* @returns {string|null}
|
|
129
|
+
*/
|
|
130
|
+
function firstNonEmptyString(...values) {
|
|
131
|
+
for (const value of values) {
|
|
132
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read the coordinator lease's owner.json in a decision-friendly shape. The
|
|
139
|
+
* lease is written by whichever process owns the "_coordinator" lock, so it
|
|
140
|
+
* identifies the live coordinator — but its pid alone is NOT proof that the
|
|
141
|
+
* lease owner is the endpoint that answered our ping (issue #239): the pid may
|
|
142
|
+
* have been reused by an unrelated process. `instanceId` (written since issue
|
|
143
|
+
* #239, same value as the coordinator's pong) is what
|
|
144
|
+
* `judgeReplacementTarget` compares to close that gap.
|
|
145
|
+
*
|
|
146
|
+
* Tolerates every historical record shape: the lock layer passes `identity`
|
|
147
|
+
* through verbatim, so records written before #239 have no `instanceId` and
|
|
148
|
+
* non-Linux records carry `startToken: null`. `instanceId` is read from
|
|
149
|
+
* `identity` first, then from the top level (compatibility path for a future
|
|
150
|
+
* shape).
|
|
151
|
+
* @param {string} root
|
|
152
|
+
* @returns {LeaseRecord|null} null when the lease is missing, unparsable, or
|
|
153
|
+
* not a JSON object
|
|
154
|
+
*/
|
|
155
|
+
export function readCoordinatorLeaseRecord(root) {
|
|
156
|
+
try {
|
|
157
|
+
const lockPath = P.viewLockPath(root, "_coordinator", "state-coordinator");
|
|
158
|
+
const owner = JSON.parse(readFileSync(`${lockPath}/owner.json`, "utf8"));
|
|
159
|
+
if (!owner || typeof owner !== "object" || Array.isArray(owner)) return null;
|
|
160
|
+
const identity = owner.identity;
|
|
161
|
+
const pid = Number(identity?.pid ?? owner.pid);
|
|
162
|
+
return {
|
|
163
|
+
token: typeof owner.token === "string" ? owner.token : null,
|
|
164
|
+
pid: Number.isFinite(pid) && pid > 0 ? pid : null,
|
|
165
|
+
instanceId: firstNonEmptyString(identity?.instanceId, owner.instanceId),
|
|
166
|
+
startToken: firstNonEmptyString(identity?.startToken),
|
|
167
|
+
};
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Decide whether the lease owner may be signalled, given the lease record and
|
|
175
|
+
* the instanceId from the pong we just received (issue #239). Pure: no fs, no
|
|
176
|
+
* process, no platform checks — the caller injects the legacy live-token read.
|
|
177
|
+
*
|
|
178
|
+
* Decision order (first match wins):
|
|
179
|
+
* 1. unreadable record / invalid pid → skip("lease_unreadable")
|
|
180
|
+
* 2. recorded instanceId present → equal: signal, else skip("lease_owner_mismatch")
|
|
181
|
+
* 3. legacy record without startToken → skip("identity_unverified")
|
|
182
|
+
* 4. currentStartToken not a usable
|
|
183
|
+
* string (unreadable, or a missed
|
|
184
|
+
* await handing us a Promise) → skip("identity_unverified")
|
|
185
|
+
* 5. startToken differs → skip("pid_reused")
|
|
186
|
+
* 6. otherwise → signal
|
|
187
|
+
*
|
|
188
|
+
* Empty-string instanceId/startToken count as missing, so a malformed record
|
|
189
|
+
* stays on the skip side instead of matching an empty probe value.
|
|
190
|
+
* @param {{ recorded: LeaseRecord|null, probedInstanceId: string, currentStartToken?: string|null }} args
|
|
191
|
+
* @returns {{ action: "signal" } | { action: "skip", reason: "lease_unreadable"|"lease_owner_mismatch"|"pid_reused"|"identity_unverified" }}
|
|
192
|
+
*/
|
|
193
|
+
export function judgeReplacementTarget({ recorded, probedInstanceId, currentStartToken = null }) {
|
|
194
|
+
if (!recorded || typeof recorded.pid !== "number" || !Number.isFinite(recorded.pid) || recorded.pid <= 0) {
|
|
195
|
+
return { action: "skip", reason: "lease_unreadable" };
|
|
196
|
+
}
|
|
197
|
+
const recordedInstanceId = firstNonEmptyString(recorded.instanceId);
|
|
198
|
+
if (recordedInstanceId !== null) {
|
|
199
|
+
return recordedInstanceId === probedInstanceId
|
|
200
|
+
? { action: "signal" }
|
|
201
|
+
: { action: "skip", reason: "lease_owner_mismatch" };
|
|
202
|
+
}
|
|
203
|
+
const recordedStartToken = firstNonEmptyString(recorded.startToken);
|
|
204
|
+
if (recordedStartToken === null) return { action: "skip", reason: "identity_unverified" };
|
|
205
|
+
if (typeof currentStartToken !== "string" || currentStartToken.length === 0) {
|
|
206
|
+
return { action: "skip", reason: "identity_unverified" };
|
|
207
|
+
}
|
|
208
|
+
if (currentStartToken !== recordedStartToken) return { action: "skip", reason: "pid_reused" };
|
|
209
|
+
return { action: "signal" };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Process-local dedup keys for the replacement-skip diagnostic (issue #239 D5).
|
|
214
|
+
* A stale coordinator that cannot be replaced keeps failing on every
|
|
215
|
+
* `sendStateCommand` ensure, and `appendDiagnosticOnce` tails the whole JSONL
|
|
216
|
+
* file (`readJsonlTail` → `readJsonl`), so a repeat must return before any disk
|
|
217
|
+
* access. One entry per root × reason × identity pair for the process lifetime;
|
|
218
|
+
* the key space is small and bounded by the board roots in use.
|
|
219
|
+
* @type {Set<string>}
|
|
220
|
+
*/
|
|
221
|
+
const reportedReplacementSkips = new Set();
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Record that the coordinator replacement was deliberately skipped (issue #239
|
|
225
|
+
* D5). `reason` is one of the spec's `SkipReason` values. Best-effort by
|
|
226
|
+
* contract: the skip paths run inside `ensureCoordinator`, which
|
|
227
|
+
* `sendStateCommand` does not wrap, so every error here (malformed args,
|
|
228
|
+
* unreadable/unwritable JSONL) is swallowed instead of thrown.
|
|
229
|
+
*
|
|
230
|
+
* Two-layer dedup: this process-local Set filters the hot path without touching
|
|
231
|
+
* disk, then `appendDiagnosticOnce` dedupes across processes/restarts. The root
|
|
232
|
+
* existence check comes before the append because appending creates parent
|
|
233
|
+
* directories — a diagnostic must never resurrect a deleted root (issue #241
|
|
234
|
+
* class of leftovers). The key is recorded even when the write is skipped, so a
|
|
235
|
+
* state that was already handled never probes the disk again.
|
|
236
|
+
* @param {string} root
|
|
237
|
+
* @param {{ reason: string, recordedInstanceId?: string|null, probedInstanceId: string }} details
|
|
238
|
+
* @returns {void}
|
|
239
|
+
*/
|
|
240
|
+
function reportReplacementSkip(root, details) {
|
|
241
|
+
try {
|
|
242
|
+
const { reason, recordedInstanceId = null, probedInstanceId } = details;
|
|
243
|
+
const key = `${root}|${reason}|${recordedInstanceId ?? "null"}|${probedInstanceId}`;
|
|
244
|
+
if (reportedReplacementSkips.has(key)) return;
|
|
245
|
+
reportedReplacementSkips.add(key);
|
|
246
|
+
if (!existsSync(root)) return;
|
|
247
|
+
appendDiagnosticOnce(root, "_coordinator", {
|
|
248
|
+
source: "coordinator-client",
|
|
249
|
+
level: "warn",
|
|
250
|
+
code: "coordinator_replacement_skipped",
|
|
251
|
+
message: `coordinator replacement skipped: ${reason}`,
|
|
252
|
+
details: { reason, recordedInstanceId: recordedInstanceId ?? null, probedInstanceId },
|
|
253
|
+
}, { match: ["reason", "recordedInstanceId", "probedInstanceId"] });
|
|
254
|
+
} catch {
|
|
255
|
+
// Diagnostics are best-effort: a failed write must never break the caller.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Grace window for a SIGTERMed stale coordinator to exit (issue #239 D4). */
|
|
260
|
+
const REPLACE_TIMEOUT_MS = 3_000;
|
|
261
|
+
/** Liveness poll interval while waiting for a SIGTERMed coordinator to exit. */
|
|
262
|
+
const REPLACE_POLL_MS = 50;
|
|
263
|
+
|
|
264
|
+
/** @typedef {{ replaced: true } | { replaced: false, reason: SkipReason }} ReplaceOutcome */
|
|
265
|
+
/** @typedef {"lease_unreadable"|"lease_owner_mismatch"|"pid_reused"|"identity_unverified"|"lease_changed"|"instance_unresponsive"|"replacement_raced"|"replace_timeout"} SkipReason */
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Await `ms` with a ref'd timer, deliberately NOT unref'd (issue #239 D4).
|
|
269
|
+
* `ensureCoordinator` is awaited by detached one-shot callers whose send point
|
|
270
|
+
* holds no other ref'd handle; an unref'd sleep lets such a process exit with
|
|
271
|
+
* code 0 mid-wait, so `ensureCoordinator` never returns, the replacement is
|
|
272
|
+
* never spawned and the queued state command is silently dropped. The wait is
|
|
273
|
+
* bounded by `replaceTimeoutMs` (≤3s), so a ref'd timer cannot keep a caller
|
|
274
|
+
* alive beyond the replacement budget.
|
|
275
|
+
* @param {number} ms
|
|
276
|
+
* @returns {Promise<void>}
|
|
277
|
+
*/
|
|
278
|
+
function replaceSleep(ms) {
|
|
279
|
+
return new Promise((resolve) => {
|
|
280
|
+
setTimeout(resolve, ms);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Whether a lease record names a pid that may be signalled. Mirrors the
|
|
286
|
+
* `lease_unreadable` boundary of `judgeReplacementTarget` for the records read
|
|
287
|
+
* outside the decision itself.
|
|
288
|
+
* @param {LeaseRecord|null} record
|
|
289
|
+
* @returns {boolean}
|
|
290
|
+
*/
|
|
291
|
+
function hasUsableLeasePid(record) {
|
|
292
|
+
return Boolean(record)
|
|
293
|
+
&& typeof record.pid === "number"
|
|
294
|
+
&& Number.isFinite(record.pid)
|
|
295
|
+
&& record.pid > 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Terminate a stale-protocol coordinator and wait for it to exit (issue #108),
|
|
300
|
+
* with the signal gated on a proven lease identity (issue #239). SIGTERM
|
|
301
|
+
* triggers the coordinator's graceful shutdown; its crash safety
|
|
302
|
+
* (fsync-before-materialize journal + boot replay) makes the kill safe even
|
|
303
|
+
* mid-command — an in-flight reply is the documented ambiguous outcome.
|
|
304
|
+
*
|
|
305
|
+
* Identity contract: the pong that proved the endpoint stale came from one
|
|
306
|
+
* process, while `owner.json` names the lease owner. They are the same process
|
|
307
|
+
* only when the recorded `identity.instanceId` matches `expectedInstanceId`
|
|
308
|
+
* (D2) — a mismatch or an unverifiable legacy record never signals. Before
|
|
309
|
+
* signalling, the endpoint is re-probed and the lease re-read (D3), so a
|
|
310
|
+
* takeover or lease handover that happened between judging and signalling wins
|
|
311
|
+
* over the kill. After the signal the caller waits, bounded by
|
|
312
|
+
* `replaceTimeoutMs`, for the pid to exit (D4): a successor must not be spawned
|
|
313
|
+
* while the old owner can still hold the lease.
|
|
314
|
+
*
|
|
315
|
+
* Every skip return reports through `reportSkip` (D5) and resolves a
|
|
316
|
+
* `ReplaceOutcome`. The function never throws: the diagnostic writer and the
|
|
317
|
+
* `kill` call are both wrapped (the latter routes ESRCH to "already replaced"
|
|
318
|
+
* and other errors into wait-for-exit).
|
|
319
|
+
* @param {string} root
|
|
320
|
+
* @param {string} socketPath
|
|
321
|
+
* @param {string} expectedInstanceId `instanceId` from the pong that was judged
|
|
322
|
+
* stale — the only identity whose process may be signalled
|
|
323
|
+
* @param {{
|
|
324
|
+
* probe?: (socketPath: string, connect: (path: string) => import("node:net").Socket, timeoutMs: number) => Promise<{instanceId: string, protocolVersion: number}|null>,
|
|
325
|
+
* connect?: (path: string) => import("node:net").Socket,
|
|
326
|
+
* probeTimeoutMs?: number,
|
|
327
|
+
* readLease?: (root: string) => LeaseRecord|null,
|
|
328
|
+
* kill?: (pid: number, signal: string) => void,
|
|
329
|
+
* isAlive?: (pid: number) => boolean,
|
|
330
|
+
* readStartToken?: (pid: number) => Promise<string|null>,
|
|
331
|
+
* clock?: () => number,
|
|
332
|
+
* sleep?: (ms: number) => Promise<void>,
|
|
333
|
+
* replaceTimeoutMs?: number,
|
|
334
|
+
* reportSkip?: (details: { reason: SkipReason, recordedInstanceId: string|null, probedInstanceId: string }) => void,
|
|
335
|
+
* }} [opts]
|
|
336
|
+
* @returns {Promise<ReplaceOutcome>}
|
|
337
|
+
*/
|
|
338
|
+
export async function replaceStaleCoordinator(root, socketPath, expectedInstanceId, opts = {}) {
|
|
339
|
+
const connect = opts.connect ?? createConnection;
|
|
340
|
+
const probeTimeoutMs = opts.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
|
|
341
|
+
const probe = opts.probe ?? probeOnce;
|
|
342
|
+
const readLease = opts.readLease ?? readCoordinatorLeaseRecord;
|
|
343
|
+
const kill = opts.kill ?? process.kill;
|
|
344
|
+
const isAliveFn = opts.isAlive ?? isAlive;
|
|
345
|
+
const readStartToken = opts.readStartToken ?? readProcessStartToken;
|
|
346
|
+
const clock = opts.clock ?? Date.now;
|
|
347
|
+
const sleep = opts.sleep ?? replaceSleep;
|
|
348
|
+
const replaceTimeoutMs = opts.replaceTimeoutMs ?? REPLACE_TIMEOUT_MS;
|
|
349
|
+
const reportSkip = opts.reportSkip ?? ((details) => reportReplacementSkip(root, details));
|
|
350
|
+
|
|
351
|
+
const recorded = readLease(root);
|
|
352
|
+
const recordedInstanceId = firstNonEmptyString(recorded?.instanceId);
|
|
353
|
+
/**
|
|
354
|
+
* Report a deliberate skip and resolve the matching outcome. Best-effort by
|
|
355
|
+
* contract: a throwing writer must never escape into `ensureCoordinator`,
|
|
356
|
+
* which does not wrap this call.
|
|
357
|
+
* @param {SkipReason} reason
|
|
358
|
+
* @param {string|null} [instanceId] lease-owner instance at decision time
|
|
359
|
+
* @returns {ReplaceOutcome}
|
|
360
|
+
*/
|
|
361
|
+
const skip = (reason, instanceId = recordedInstanceId) => {
|
|
362
|
+
try {
|
|
363
|
+
reportSkip({
|
|
364
|
+
reason,
|
|
365
|
+
recordedInstanceId: instanceId,
|
|
366
|
+
probedInstanceId: expectedInstanceId,
|
|
367
|
+
});
|
|
368
|
+
} catch {
|
|
369
|
+
// Diagnostics are best-effort: a failed write never changes the outcome.
|
|
370
|
+
}
|
|
371
|
+
return { replaced: false, reason };
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// Legacy records (pre-#239) carry no instanceId; on Linux the live /proc
|
|
375
|
+
// start token is then the only proof that the lease pid is still the process
|
|
376
|
+
// the endpoint served (D6 / J3). The read is async by contract: an injected
|
|
377
|
+
// implementation returning a Promise that is not awaited would compare as
|
|
378
|
+
// unequal, so the judge normalizes a non-string token to
|
|
379
|
+
// `identity_unverified` rather than `pid_reused`.
|
|
380
|
+
const currentStartToken = recordedInstanceId === null && firstNonEmptyString(recorded?.startToken) !== null
|
|
381
|
+
? await readStartToken(/** @type {LeaseRecord} */ (recorded).pid)
|
|
382
|
+
: null;
|
|
383
|
+
|
|
384
|
+
const verdict = judgeReplacementTarget({ recorded, probedInstanceId: expectedInstanceId, currentStartToken });
|
|
385
|
+
if (verdict.action === "skip") return skip(verdict.reason);
|
|
386
|
+
const target = /** @type {LeaseRecord} */ (recorded);
|
|
387
|
+
const pid = /** @type {number} */ (target.pid);
|
|
388
|
+
|
|
389
|
+
// D3 step 1: re-probe the endpoint. A pong proves the instance that was
|
|
390
|
+
// judged stale is still serving, so its pid cannot have been reused yet.
|
|
391
|
+
// A DIFFERENT instance means the replacement raced with another client: a
|
|
392
|
+
// current-protocol instance already took over (nothing left to replace — the
|
|
393
|
+
// caller may continue), an old-protocol one must not be killed by us.
|
|
394
|
+
const reprobed = await probe(socketPath, connect, probeTimeoutMs);
|
|
395
|
+
if (reprobed && reprobed.instanceId !== expectedInstanceId) {
|
|
396
|
+
if (reprobed.protocolVersion >= COORDINATOR_PROTOCOL_VERSION) return { replaced: true };
|
|
397
|
+
return skip("replacement_raced");
|
|
398
|
+
}
|
|
399
|
+
if (!reprobed) {
|
|
400
|
+
// No valid pong within the timeout is NOT proof the process is gone.
|
|
401
|
+
// Re-read the lease: no usable record means no evidence to signal, a
|
|
402
|
+
// live owner is a slow instance we must not race, and a dead owner lets
|
|
403
|
+
// the next launch take the lease through the normal reclaim path.
|
|
404
|
+
const fresh = readLease(root);
|
|
405
|
+
if (!hasUsableLeasePid(fresh)) return skip("lease_unreadable", null);
|
|
406
|
+
const freshRecord = /** @type {LeaseRecord} */ (fresh);
|
|
407
|
+
if (isAliveFn(/** @type {number} */ (freshRecord.pid))) {
|
|
408
|
+
return skip("instance_unresponsive", firstNonEmptyString(freshRecord.instanceId));
|
|
409
|
+
}
|
|
410
|
+
return { replaced: true };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// D3 step 2: the lease must still describe the owner we judged. A changed
|
|
414
|
+
// token or instanceId means another coordinator (or a recovery) owns the
|
|
415
|
+
// lease now — signalling the old pid could hit an unrelated process.
|
|
416
|
+
const confirmed = readLease(root);
|
|
417
|
+
if (!confirmed || confirmed.token !== target.token || firstNonEmptyString(confirmed.instanceId) !== recordedInstanceId) {
|
|
418
|
+
return skip("lease_changed");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// D4: the last fallible system call. ESRCH means the target exited between
|
|
422
|
+
// the checks — already replaced. EPERM and anything else leave the target
|
|
423
|
+
// possibly alive, so fail closed into wait-for-exit below.
|
|
424
|
+
try {
|
|
425
|
+
kill(pid, "SIGTERM");
|
|
426
|
+
} catch (err) {
|
|
427
|
+
if (/** @type {NodeJS.ErrnoException} */ (err)?.code === "ESRCH") return { replaced: true };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// D4: wait for the old process to actually exit before the caller spawns a
|
|
431
|
+
// successor. The tick budget keeps the wait finite even when an injected
|
|
432
|
+
// clock never advances; `clock` ends it early when wall time runs out first.
|
|
433
|
+
const deadline = clock() + replaceTimeoutMs;
|
|
434
|
+
const maxTicks = Math.max(1, Math.ceil(replaceTimeoutMs / REPLACE_POLL_MS));
|
|
435
|
+
for (let tick = 0; tick < maxTicks; tick++) {
|
|
436
|
+
if (!isAliveFn(pid)) return { replaced: true };
|
|
437
|
+
if (clock() >= deadline) break;
|
|
438
|
+
await sleep(REPLACE_POLL_MS);
|
|
439
|
+
}
|
|
440
|
+
return skip("replace_timeout");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Make sure a coordinator is live for this board root: probe the endpoint and,
|
|
445
|
+
* on failure, spawn one and poll until it answers (the lease guarantees a
|
|
446
|
+
* single owner even under concurrent spawns — the loser exits silently).
|
|
447
|
+
* Repeated/concurrent calls are safe and cheap once a coordinator is up.
|
|
448
|
+
*
|
|
449
|
+
* Protocol gate (issue #108): a live coordinator reporting a protocol version
|
|
450
|
+
* older than this client's build is TERMINATED and respawned — otherwise a
|
|
451
|
+
* detached old-build instance survives every extension update and rejects
|
|
452
|
+
* every new command kind (`unknown_kind`), silently stranding state writes.
|
|
453
|
+
* If the stale instance cannot be replaced (unkillable, socket stuck), report
|
|
454
|
+
* `coordinator_stale_protocol` instead of pretending it is healthy.
|
|
455
|
+
*
|
|
456
|
+
* Replacement seams (issue #239): the identity-gated replacement accepts every
|
|
457
|
+
* injectable dependency of `replaceStaleCoordinator` (`probe`, `kill`,
|
|
458
|
+
* `isAlive`, `readStartToken`, `clock`, `sleep`, `replaceTimeoutMs`,
|
|
459
|
+
* `reportSkip`) plus the re-probe plumbing (`connect`, `probeTimeoutMs`); this
|
|
460
|
+
* function forwards them verbatim. Defaults live in `replaceStaleCoordinator`,
|
|
461
|
+
* so an omitted option keeps the production behaviour.
|
|
462
|
+
*
|
|
463
|
+
* Root-lifetime note (issue #241): a root that does not exist yet is no longer
|
|
464
|
+
* lazily created — the boot guard makes the spawned coordinator exit 0, so a
|
|
465
|
+
* `ensureCoordinator` call on a missing root waits the full `ENSURE_WINDOW_MS`
|
|
466
|
+
* (~10 s) and returns `coordinator_unavailable`. No in-repo caller does that;
|
|
467
|
+
* external one-shot callers that relied on a lazily-created board are affected.
|
|
468
|
+
*
|
|
469
|
+
* @param {string} root
|
|
470
|
+
* @param {{
|
|
471
|
+
* runnerScript?: string,
|
|
472
|
+
* node?: string,
|
|
473
|
+
* probeTimeoutMs?: number,
|
|
474
|
+
* ensureWindowMs?: number,
|
|
475
|
+
* pollMs?: number,
|
|
476
|
+
* connect?: (path: string) => import("node:net").Socket,
|
|
477
|
+
* probe?: (socketPath: string, connect: (path: string) => import("node:net").Socket, timeoutMs: number) => Promise<{instanceId: string, protocolVersion: number}|null>,
|
|
478
|
+
* kill?: (pid: number, signal: string) => void,
|
|
479
|
+
* isAlive?: (pid: number) => boolean,
|
|
480
|
+
* readStartToken?: (pid: number) => Promise<string|null>,
|
|
481
|
+
* clock?: () => number,
|
|
482
|
+
* sleep?: (ms: number) => Promise<void>,
|
|
483
|
+
* replaceTimeoutMs?: number,
|
|
484
|
+
* reportSkip?: (details: { reason: SkipReason, recordedInstanceId: string|null, probedInstanceId: string }) => void,
|
|
485
|
+
* }} [opts]
|
|
486
|
+
* @returns {Promise<EnsureResult>}
|
|
487
|
+
*/
|
|
488
|
+
export async function ensureCoordinator(root, opts = {}) {
|
|
489
|
+
if (coordinatorDisabled()) return { ok: false, error: "coordinator_disabled" };
|
|
490
|
+
const connect = opts.connect ?? createConnection;
|
|
491
|
+
const probeTimeoutMs = opts.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
|
|
492
|
+
const windowMs = opts.ensureWindowMs ?? ENSURE_WINDOW_MS;
|
|
493
|
+
const pollMs = opts.pollMs ?? ENSURE_POLL_MS;
|
|
494
|
+
const socketPath = P.coordinatorEndpointPathFor(process.platform, root);
|
|
495
|
+
|
|
496
|
+
const first = await probeOnce(socketPath, connect, probeTimeoutMs);
|
|
497
|
+
if (first) {
|
|
498
|
+
if (first.protocolVersion >= COORDINATOR_PROTOCOL_VERSION) return { ok: true, instanceId: first.instanceId };
|
|
499
|
+
// Issue #239: only the instance that answered our ping may be replaced —
|
|
500
|
+
// its instanceId is the identity the lease must prove. A skip is already
|
|
501
|
+
// reported with its reason by the replacement orchestration itself.
|
|
502
|
+
const outcome = await replaceStaleCoordinator(root, socketPath, first.instanceId, {
|
|
503
|
+
connect,
|
|
504
|
+
probeTimeoutMs,
|
|
505
|
+
probe: opts.probe,
|
|
506
|
+
kill: opts.kill,
|
|
507
|
+
isAlive: opts.isAlive,
|
|
508
|
+
readStartToken: opts.readStartToken,
|
|
509
|
+
clock: opts.clock,
|
|
510
|
+
sleep: opts.sleep,
|
|
511
|
+
replaceTimeoutMs: opts.replaceTimeoutMs,
|
|
512
|
+
reportSkip: opts.reportSkip,
|
|
513
|
+
});
|
|
514
|
+
if (!outcome.replaced) return { ok: false, error: "coordinator_stale_protocol" };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const launched = launchCoordinator(root, { runnerScript: opts.runnerScript ?? COORDINATOR_SCRIPT, node: opts.node });
|
|
518
|
+
const deadline = Date.now() + windowMs;
|
|
519
|
+
while (Date.now() < deadline) {
|
|
520
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
521
|
+
const probe = await probeOnce(socketPath, connect, probeTimeoutMs);
|
|
522
|
+
if (probe && probe.protocolVersion >= COORDINATOR_PROTOCOL_VERSION) {
|
|
523
|
+
return { ok: true, instanceId: probe.instanceId, pid: launched.pid };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return { ok: false, error: "coordinator_unavailable", pid: launched.pid };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Submit one state command to the coordinator and wait for its result.
|
|
531
|
+
* Never throws; every failure path resolves a rejected result (see the
|
|
532
|
+
* ambiguity contract in the module doc).
|
|
533
|
+
* @param {string} root
|
|
534
|
+
* @param {object} command Command fields (`kind`, `viewId`, `runId`, `source`,
|
|
535
|
+
* `expectedRevision`, `payload`); an explicit `commandId` pins the identity
|
|
536
|
+
* for safe same-id retries, otherwise one is generated via `newRunId()`.
|
|
537
|
+
* @param {{ timeoutMs?: number, runnerScript?: string, node?: string, socketPath?: string, connect?: (path: string) => import("node:net").Socket, ensure?: typeof ensureCoordinator }} [opts]
|
|
538
|
+
* @returns {Promise<StateCommandResult>}
|
|
539
|
+
*/
|
|
540
|
+
export async function sendStateCommand(root, command, opts = {}) {
|
|
541
|
+
if (coordinatorDisabled()) {
|
|
542
|
+
return { status: "rejected", reason: "coordinator_disabled", materializedRevision: 0 };
|
|
543
|
+
}
|
|
544
|
+
const ensure = opts.ensure ?? ensureCoordinator;
|
|
545
|
+
const ensured = await ensure(root, {
|
|
546
|
+
runnerScript: opts.runnerScript,
|
|
547
|
+
node: opts.node,
|
|
548
|
+
connect: opts.connect,
|
|
549
|
+
});
|
|
550
|
+
if (!ensured.ok) {
|
|
551
|
+
return { status: "rejected", reason: ensured.error ?? "coordinator_unavailable", materializedRevision: 0 };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const timeoutMs = opts.timeoutMs ?? COMMAND_TIMEOUT_MS;
|
|
555
|
+
const connect = opts.connect ?? createConnection;
|
|
556
|
+
const socketPath = opts.socketPath ?? P.coordinatorEndpointPathFor(process.platform, root);
|
|
557
|
+
const commandId = typeof command.commandId === "string" && command.commandId
|
|
558
|
+
? command.commandId
|
|
559
|
+
: newRunId();
|
|
560
|
+
const envelope = { ...command, type: "state_command", commandId };
|
|
561
|
+
|
|
562
|
+
return await new Promise((resolve) => {
|
|
563
|
+
let settled = false;
|
|
564
|
+
let wrote = false;
|
|
565
|
+
let buffer = "";
|
|
566
|
+
/** @type {import("node:net").Socket|null} */
|
|
567
|
+
let socket = null;
|
|
568
|
+
const finish = (result) => {
|
|
569
|
+
if (settled) return;
|
|
570
|
+
settled = true;
|
|
571
|
+
clearTimeout(timer);
|
|
572
|
+
try { socket?.destroy(); } catch { /* best effort */ }
|
|
573
|
+
resolve(result);
|
|
574
|
+
};
|
|
575
|
+
const timer = setTimeout(() => finish({ status: "rejected", reason: "timeout", materializedRevision: 0 }), timeoutMs);
|
|
576
|
+
timer.unref?.();
|
|
577
|
+
try {
|
|
578
|
+
socket = connect(socketPath);
|
|
579
|
+
} catch {
|
|
580
|
+
// synchronous connect failure — nothing was sent, so not ambiguous
|
|
581
|
+
finish({ status: "rejected", reason: "connection_failed", materializedRevision: 0 });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
socket.on("error", () => {
|
|
585
|
+
// A reset AFTER the envelope was written is ambiguous (the command may
|
|
586
|
+
// be journaled); a failure before that is a plain delivery failure.
|
|
587
|
+
finish(wrote
|
|
588
|
+
? { status: "rejected", reason: "connection_reset", materializedRevision: 0 }
|
|
589
|
+
: { status: "rejected", reason: "connection_failed", materializedRevision: 0 });
|
|
590
|
+
});
|
|
591
|
+
socket.on("close", () => {
|
|
592
|
+
finish(wrote
|
|
593
|
+
? { status: "rejected", reason: "connection_reset", materializedRevision: 0 }
|
|
594
|
+
: { status: "rejected", reason: "connection_failed", materializedRevision: 0 });
|
|
595
|
+
});
|
|
596
|
+
socket.on("connect", () => {
|
|
597
|
+
try {
|
|
598
|
+
socket?.write(JSON.stringify(envelope) + "\n");
|
|
599
|
+
wrote = true;
|
|
600
|
+
} catch {
|
|
601
|
+
finish({ status: "rejected", reason: "connection_failed", materializedRevision: 0 });
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
socket.on("data", (chunk) => {
|
|
605
|
+
buffer += chunk.toString("utf8");
|
|
606
|
+
const lines = buffer.split("\n");
|
|
607
|
+
buffer = lines.pop() ?? "";
|
|
608
|
+
for (const line of lines) {
|
|
609
|
+
if (!line.trim()) continue;
|
|
610
|
+
let msg;
|
|
611
|
+
try {
|
|
612
|
+
msg = JSON.parse(line);
|
|
613
|
+
} catch {
|
|
614
|
+
continue; // malformed line — keep waiting for the result
|
|
615
|
+
}
|
|
616
|
+
if (msg?.type === "state_command_result" && msg.commandId === commandId) {
|
|
617
|
+
finish({
|
|
618
|
+
status: msg.status === "applied" ? "applied" : "rejected",
|
|
619
|
+
reason: typeof msg.reason === "string" ? msg.reason : null,
|
|
620
|
+
materializedRevision: typeof msg.materializedRevision === "number" ? msg.materializedRevision : 0,
|
|
621
|
+
});
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (msg?.type === "error") {
|
|
625
|
+
// Protocol misuse on our side — a definitive, non-journaled rejection.
|
|
626
|
+
finish({ status: "rejected", reason: `coordinator_error:${msg.message ?? "unknown"}`, materializedRevision: 0 });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
// pong/other lines are ignored — keep waiting for the matching result.
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
}
|