onepipeline-cli 0.95.3 → 0.95.4

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 CHANGED
@@ -8,7 +8,13 @@ onepipeline --help
8
8
  ```
9
9
 
10
10
  The binary ships inside a per-platform package that npm selects by `os`/`cpu`, so
11
- there is no compile step and no Rust toolchain to install. The same binary is on
11
+ there is no compile step and no Rust toolchain to install.
12
+
13
+ On Linux and macOS the `onepipeline` command *becomes* the binary's process
14
+ (`process.execve`), so it needs Node.js 22.15+ or 23.11+ and refuses an older
15
+ one. On Windows, which has no exec, it runs the binary under a small keeper
16
+ process that passes its exit status and console interrupts through and never
17
+ leaves it running once the command has ended. The same binary is on
12
18
  [PyPI](https://pypi.org/project/onepipeline-cli/) (`pip install
13
19
  onepipeline-cli`) and [crates.io](https://crates.io/crates/onepipeline)
14
20
  (`cargo install onepipeline`).
@@ -7,15 +7,30 @@
7
7
  // compile, no download at install time. The platform-specific binary ships
8
8
  // inside a per-platform package (`onepipeline-cli-<platform>-<arch>`) declared
9
9
  // in this package's `optionalDependencies`; npm installs only the one whose
10
- // `os`/`cpu` match the host, and this shim resolves it and execs it with the
11
- // caller's argv.
10
+ // `os`/`cpu` match the host, and this shim resolves it and starts it with the
11
+ // caller's argv, stdin, stdout and stderr.
12
+ //
13
+ // How it starts it is the point of this file. On Linux and macOS it *replaces
14
+ // itself* with the binary (`process.execve`), so the process the caller started
15
+ // is the engine: the PID the caller holds is the engine's, a signal sent to it
16
+ // reaches the engine, and a process the engine starts sees the engine as its
17
+ // parent — the process tree a PyPI or cargo install gives. A caller that marks
18
+ // itself by the PID that execs the engine depends on exactly that, so a Node.js
19
+ // without `process.execve` (before 22.15 / 23.11) is refused there rather than
20
+ // given a child process in the engine's place.
21
+ //
22
+ // Windows has no exec. There the engine runs under a keeper process this one
23
+ // starts (`supervise` below): this process exits with the engine's status, the
24
+ // Ctrl+C and Ctrl+Break a console delivers reach the engine as they are, and the
25
+ // engine is ended rather than orphaned when this process is ended — abruptly too.
12
26
  //
13
27
  // This file is committed source; the version and the optionalDependency versions
14
28
  // are stamped from Cargo.toml at publish time by scripts/npm-build.mjs, which
15
29
  // also generates the per-platform packages from the release binaries.
16
30
 
31
+ const fs = require("node:fs");
17
32
  const path = require("node:path");
18
- const { spawnSync } = require("node:child_process");
33
+ const { spawn } = require("node:child_process");
19
34
 
20
35
  // process.platform-process.arch -> the platform package that carries the binary.
21
36
  // The keys mirror the targets release/release.py builds, the TARGETS table in
@@ -82,16 +97,120 @@ function binaryPath() {
82
97
  }
83
98
  }
84
99
 
85
- const result = spawnSync(binaryPath(), process.argv.slice(2), { stdio: "inherit" });
100
+ // The interrupts a Windows console delivers to every process attached to it,
101
+ // the engine included. The engine already has each one, as itself, so this
102
+ // process and the keeper hold on through them and report what the engine does.
103
+ const CONSOLE_EVENTS = ["SIGINT", "SIGBREAK"];
104
+
105
+ // The keeper, a Node process this one starts with an IPC channel to it. It
106
+ // starts the engine as its own child, so it knows the engine the moment the
107
+ // engine exists, and tells this process the engine's status. The channel breaks
108
+ // however this process ends — TerminateProcess included, where no handler of
109
+ // this process runs — and the keeper then ends the engine: there is no moment
110
+ // at which the engine runs and nothing that would end it knows it, however
111
+ // early in its start this process is ended. A message the keeper cannot
112
+ // deliver is the same break, seen first: this process ended before the keeper
113
+ // ran, and the failed send ends the engine rather than the keeper alone.
114
+ const KEEPER = `
115
+ const { spawn } = require("node:child_process");
116
+ const [binary, ...args] = JSON.parse(process.argv[1]);
117
+ for (const signal of ${JSON.stringify(CONSOLE_EVENTS)}) process.on(signal, () => {});
118
+ const engine = spawn(binary, args, { stdio: "inherit" });
119
+ const end = () => {
120
+ engine.kill("SIGKILL");
121
+ process.exit(0);
122
+ };
123
+ process.on("disconnect", end);
124
+ const send = (message, then = () => {}) =>
125
+ process.send(message, (error) => (error ? end() : then()));
126
+ if (engine.pid !== undefined) send({ engine: engine.pid });
127
+ engine.on("error", (error) => send({ error: error.message }, () => process.exit(0)));
128
+ engine.on("exit", (code) => send({ status: code ?? 1 }, () => process.exit(0)));
129
+ `;
86
130
 
87
- if (result.error) {
88
- fail(`failed to launch the onepipeline binary: ${result.error.message}`);
131
+ function supervise(binary, args) {
132
+ for (const signal of CONSOLE_EVENTS) process.on(signal, () => {});
133
+ const keeper = spawn(process.execPath, ["-e", KEEPER, JSON.stringify([binary, ...args])], {
134
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
135
+ });
136
+ // Without a keeper nothing would end the engine if this process were ended,
137
+ // so no engine is started: the command refuses instead.
138
+ keeper.on("error", (error) =>
139
+ fail(
140
+ `could not start the process that keeps the onepipeline binary from outliving ` +
141
+ `this command, so the binary was not started: ${error.message}`,
142
+ ),
143
+ );
144
+ let engine = null;
145
+ const endEngine = () => {
146
+ if (engine !== null) {
147
+ try {
148
+ process.kill(engine, "SIGKILL");
149
+ } catch {}
150
+ }
151
+ };
152
+ // The keeper's messages cross a process boundary, so each is read as exactly
153
+ // one of its three envelopes; anything else ends the engine and the command.
154
+ const isCount = (value) => Number.isInteger(value) && value >= 0;
155
+ // A Windows exit code is 32 bits; process.exit would wrap a larger one, 2^32 to 0.
156
+ const isStatus = (value) => isCount(value) && value <= 0xffffffff;
157
+ keeper.on("message", (message) => {
158
+ const keys = message !== null && typeof message === "object" ? Object.keys(message) : [];
159
+ const [key] = keys;
160
+ if (keys.length === 1 && key === "engine" && isCount(message.engine) && message.engine > 0) {
161
+ engine = message.engine;
162
+ return;
163
+ }
164
+ if (keys.length === 1 && key === "error" && typeof message.error === "string") {
165
+ fail(`failed to launch the onepipeline binary: ${message.error}`);
166
+ }
167
+ // The engine's exit code verbatim — the contract assigns meaning to 1 and 2,
168
+ // and a caller reads them.
169
+ if (keys.length === 1 && key === "status" && isStatus(message.status)) {
170
+ process.exit(message.status);
171
+ }
172
+ endEngine();
173
+ fail(
174
+ `the process keeping the onepipeline binary sent an unreadable message: ` +
175
+ JSON.stringify(message),
176
+ );
177
+ });
178
+ // A keeper gone without the engine's status has left the engine with
179
+ // nothing to end it, so this process ends it before saying so.
180
+ keeper.on("disconnect", () => {
181
+ endEngine();
182
+ fail("the process keeping the onepipeline binary ended without reporting its status");
183
+ });
89
184
  }
90
185
 
91
- // Re-raise a terminating signal so callers observe the true cause; otherwise
92
- // propagate the child's exit code verbatim — the contract assigns meaning to 1
93
- // and 2, and a caller reads them.
94
- if (result.signal) {
95
- process.kill(process.pid, result.signal);
186
+ const binary = binaryPath();
187
+ const args = process.argv.slice(2);
188
+
189
+ if (process.platform === "win32") {
190
+ supervise(binary, args);
191
+ } else {
192
+ if (typeof process.execve !== "function") {
193
+ fail(
194
+ `Node.js ${process.version} cannot run onepipeline from npm on ${process.platform}: ` +
195
+ "the command has to become the onepipeline binary's own process, which needs " +
196
+ `process.execve (Node.js ${require("../package.json").engines.node}). Upgrade Node.js, or ` +
197
+ `${OTHER_INSTALLS.charAt(0).toLowerCase()}${OTHER_INSTALLS.slice(1)}`,
198
+ );
199
+ }
200
+ // Node ignores SIGXFSZ, and an ignored signal survives exec while a handled
201
+ // one returns to its default: handle it so the engine, and every program it
202
+ // starts, begins with the disposition a shell would give it. (Node ignores
203
+ // SIGPIPE too, but the engine's runtime ignores that itself and restores it
204
+ // for what it starts.)
205
+ process.on("SIGXFSZ", () => {});
206
+ // On Node.js 22 an execve that fails aborts this process rather than
207
+ // throwing, so the binary is checked first: a missing or non-executable one
208
+ // is refused here with the same message a throwing execve would get.
209
+ try {
210
+ if (!fs.statSync(binary).isFile()) throw new Error(`${binary} is not a file`);
211
+ fs.accessSync(binary, fs.constants.X_OK);
212
+ process.execve(binary, [binary, ...args], process.env);
213
+ } catch (error) {
214
+ fail(`failed to launch the onepipeline binary: ${error.message}`);
215
+ }
96
216
  }
97
- process.exit(result.status === null ? 1 : result.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onepipeline-cli",
3
- "version": "0.95.3",
3
+ "version": "0.95.4",
4
4
  "description": "Execute a task DAG over oneagentgraph and onevcs, merging their event streams into one.",
5
5
  "keywords": [
6
6
  "orchestration",
@@ -24,12 +24,12 @@
24
24
  "bin/onepipeline.js"
25
25
  ],
26
26
  "engines": {
27
- "node": ">=18"
27
+ "node": "^22.15.0 || >=23.11.0"
28
28
  },
29
29
  "optionalDependencies": {
30
- "onepipeline-cli-linux-x64": "0.95.3",
31
- "onepipeline-cli-linux-arm64": "0.95.3",
32
- "onepipeline-cli-darwin-arm64": "0.95.3",
33
- "onepipeline-cli-win32-x64": "0.95.3"
30
+ "onepipeline-cli-linux-x64": "0.95.4",
31
+ "onepipeline-cli-linux-arm64": "0.95.4",
32
+ "onepipeline-cli-darwin-arm64": "0.95.4",
33
+ "onepipeline-cli-win32-x64": "0.95.4"
34
34
  }
35
35
  }