orcha-dev 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/LICENSE +21 -0
- package/README.md +71 -0
- package/bin/orcha.js +180 -0
- package/lib/downloader.js +201 -0
- package/lib/errors.js +25 -0
- package/lib/index.d.ts +68 -0
- package/lib/index.js +158 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 alaeddine_gd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# orcha-dev
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/orcha-dev)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
Unix pipes for AI workflows. Define reusable tasks and linear pipelines in
|
|
7
|
+
one `orcha.yaml` file; execute them from Node, the shell, or both.
|
|
8
|
+
|
|
9
|
+
`orcha-dev` is a thin Node wrapper around the same single Go binary used by
|
|
10
|
+
the Python SDK. The binary is downloaded into `~/.orcha/bin/` on first run
|
|
11
|
+
and verified by sha256; later runs are zero-network.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install orcha-dev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Node 18+ is required. The first invocation downloads the engine binary that
|
|
20
|
+
matches the package version; pin a specific tag with `ORCHA_BINARY_VERSION`
|
|
21
|
+
or point at a local build with `ORCHA_BINARY_PATH=/abs/path/to/orcha`.
|
|
22
|
+
|
|
23
|
+
## CLI
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Run a pipeline; pretty progress on stderr, final output on stdout.
|
|
27
|
+
npx orcha run <pipeline> [-y orcha.yaml] [-i STR | -f FILE] [--json]
|
|
28
|
+
|
|
29
|
+
# Print version.
|
|
30
|
+
npx orcha version
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Input precedence: `-i` (inline string) → `-f` (file content) → stdin.
|
|
34
|
+
`--json` swaps pretty progress for a JSON-line event stream.
|
|
35
|
+
|
|
36
|
+
## Programmatic API
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
const { Orcha } = require('orcha-dev');
|
|
40
|
+
|
|
41
|
+
const orcha = await Orcha.create('./orcha.yaml');
|
|
42
|
+
|
|
43
|
+
// Stream events as the pipeline runs.
|
|
44
|
+
for await (const event of orcha.run('summarize-article', './article.txt')) {
|
|
45
|
+
console.log(event.type, event.task, event.elapsed_ms);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Or get just the final output.
|
|
49
|
+
const result = await orcha.runToCompletion('summarize-article', './article.txt');
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
TypeScript types ship with the package — `import { Orcha, OrchaEvent } from 'orcha-dev'` works out of the box.
|
|
53
|
+
|
|
54
|
+
## Providers
|
|
55
|
+
|
|
56
|
+
Set the matching environment variable for whichever provider your YAML uses:
|
|
57
|
+
|
|
58
|
+
| Provider | Variable |
|
|
59
|
+
|------------|------------------------|
|
|
60
|
+
| `openai` | `OPENAI_API_KEY` |
|
|
61
|
+
| `anthropic`| `ANTHROPIC_API_KEY` |
|
|
62
|
+
| `deepseek` | `DEEPSEEK_API_KEY` |
|
|
63
|
+
| custom | `ORCHA_<NAME>_API_KEY` |
|
|
64
|
+
|
|
65
|
+
## Links
|
|
66
|
+
|
|
67
|
+
- Source: <https://github.com/ryfoo/orcha>
|
|
68
|
+
- Issues: <https://github.com/ryfoo/orcha/issues>
|
|
69
|
+
- Python SDK: <https://pypi.org/project/orcha-dev/>
|
|
70
|
+
|
|
71
|
+
MIT licensed.
|
package/bin/orcha.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// The `orcha` shell command shipped via the `orcha-dev` npm package. Mirrors
|
|
5
|
+
// the UX of the Go orcha-run binary and the Python `orcha` script so users
|
|
6
|
+
// who pick npm get the same subcommands and flags as everyone else.
|
|
7
|
+
//
|
|
8
|
+
// orcha run <pipeline> [-y YAML] [-i STR | -f FILE] [--json]
|
|
9
|
+
// orcha version
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
const { Orcha } = require('../lib/index');
|
|
15
|
+
const { OrchaError } = require('../lib/errors');
|
|
16
|
+
const pkg = require('../package.json');
|
|
17
|
+
|
|
18
|
+
function printUsage(stream = process.stderr) {
|
|
19
|
+
stream.write(
|
|
20
|
+
'orcha — run an orcha.yaml pipeline from the command line.\n' +
|
|
21
|
+
'\n' +
|
|
22
|
+
'Usage:\n' +
|
|
23
|
+
' orcha run <pipeline> [-y YAML] [-i STR | -f FILE] [--json]\n' +
|
|
24
|
+
' orcha version\n' +
|
|
25
|
+
'\n' +
|
|
26
|
+
'Flags:\n' +
|
|
27
|
+
' -y, --yaml PATH path to orcha.yaml (default: ./orcha.yaml)\n' +
|
|
28
|
+
' -i, --input STR inline input string passed to the first task\n' +
|
|
29
|
+
' -f, --input-file PATH read input from this file\n' +
|
|
30
|
+
' --json emit JSON-line events to stdout instead of pretty progress\n',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseRunArgs(argv) {
|
|
35
|
+
const out = { pipeline: null, yaml: null, input: null, inputFile: null, json: false };
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
const a = argv[i];
|
|
38
|
+
const next = () => {
|
|
39
|
+
const v = argv[++i];
|
|
40
|
+
if (v === undefined) {
|
|
41
|
+
process.stderr.write(`orcha: ${a} requires a value\n`);
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
return v;
|
|
45
|
+
};
|
|
46
|
+
if (a === '-y' || a === '--yaml') out.yaml = next();
|
|
47
|
+
else if (a === '-i' || a === '--input') out.input = next();
|
|
48
|
+
else if (a === '-f' || a === '--input-file') out.inputFile = next();
|
|
49
|
+
else if (a === '--json') out.json = true;
|
|
50
|
+
else if (a === '-h' || a === '--help') { printUsage(process.stdout); process.exit(0); }
|
|
51
|
+
else if (a.startsWith('-')) { process.stderr.write(`orcha: unknown flag ${a}\n`); process.exit(2); }
|
|
52
|
+
else if (out.pipeline === null) out.pipeline = a;
|
|
53
|
+
else { process.stderr.write(`orcha: unexpected argument ${a}\n`); process.exit(2); }
|
|
54
|
+
}
|
|
55
|
+
if (out.pipeline === null) { printUsage(); process.exit(2); }
|
|
56
|
+
if (out.input !== null && out.inputFile !== null) {
|
|
57
|
+
process.stderr.write('orcha: pass either -i/--input or -f/--input-file, not both\n');
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveYaml(arg) {
|
|
64
|
+
if (arg) return arg;
|
|
65
|
+
const candidate = path.join(process.cwd(), 'orcha.yaml');
|
|
66
|
+
if (!fs.existsSync(candidate)) {
|
|
67
|
+
process.stderr.write(
|
|
68
|
+
`orcha: no -y/--yaml flag and ./orcha.yaml not found in ${process.cwd()}\n`,
|
|
69
|
+
);
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readStdinAll() {
|
|
76
|
+
const chunks = [];
|
|
77
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
78
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function resolveInput(args) {
|
|
82
|
+
if (args.input !== null) return args.input;
|
|
83
|
+
if (args.inputFile) return fs.readFileSync(args.inputFile, 'utf8');
|
|
84
|
+
if (!process.stdin.isTTY) return readStdinAll();
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function runJson(orcha, pipeline, input) {
|
|
89
|
+
let exitCode = 0;
|
|
90
|
+
try {
|
|
91
|
+
for await (const ev of orcha.run(pipeline, input)) {
|
|
92
|
+
if (ev.type === 'task_fail') exitCode = 1;
|
|
93
|
+
process.stdout.write(JSON.stringify(ev) + '\n');
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e instanceof OrchaError) {
|
|
97
|
+
process.stderr.write(`orcha: ${e.message}\n`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
throw e;
|
|
101
|
+
}
|
|
102
|
+
return exitCode;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function runPretty(orcha, pipeline, input) {
|
|
106
|
+
let final = null;
|
|
107
|
+
let finalType = '';
|
|
108
|
+
try {
|
|
109
|
+
for await (const ev of orcha.run(pipeline, input)) {
|
|
110
|
+
if (ev.type === 'task_start') {
|
|
111
|
+
process.stderr.write(`> ${ev.task} ...\n`);
|
|
112
|
+
} else if (ev.type === 'task_complete') {
|
|
113
|
+
process.stderr.write(`+ ${ev.task} (${ev.elapsed_ms}ms)\n`);
|
|
114
|
+
} else if (ev.type === 'task_fail') {
|
|
115
|
+
process.stderr.write(`x ${ev.task} -- ${ev.error}\n`);
|
|
116
|
+
return 1;
|
|
117
|
+
} else if (ev.type === 'pipeline_complete') {
|
|
118
|
+
final = ev.output;
|
|
119
|
+
finalType = ev.output_type;
|
|
120
|
+
process.stderr.write(`-- done in ${ev.elapsed_ms}ms\n`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (e instanceof OrchaError) {
|
|
125
|
+
process.stderr.write(`orcha: ${e.message}\n`);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
throw e;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (final !== null && final !== undefined) {
|
|
132
|
+
if ((finalType === 'text' || finalType === 'filepath') && typeof final === 'string') {
|
|
133
|
+
process.stdout.write(final + '\n');
|
|
134
|
+
} else {
|
|
135
|
+
process.stdout.write(JSON.stringify(final, null, 2) + '\n');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function main(argv) {
|
|
142
|
+
const sub = argv[0];
|
|
143
|
+
if (!sub || sub === '-h' || sub === '--help') {
|
|
144
|
+
printUsage(sub ? process.stdout : process.stderr);
|
|
145
|
+
return sub ? 0 : 2;
|
|
146
|
+
}
|
|
147
|
+
if (sub === 'version' || sub === '--version' || sub === '-v') {
|
|
148
|
+
process.stdout.write(pkg.version + '\n');
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
if (sub !== 'run') {
|
|
152
|
+
process.stderr.write(`orcha: unknown command: ${sub}\n`);
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const args = parseRunArgs(argv.slice(1));
|
|
157
|
+
const yamlPath = resolveYaml(args.yaml);
|
|
158
|
+
const input = await resolveInput(args);
|
|
159
|
+
|
|
160
|
+
let orcha;
|
|
161
|
+
try {
|
|
162
|
+
orcha = await Orcha.create(yamlPath);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (e instanceof OrchaError) {
|
|
165
|
+
process.stderr.write(`orcha: ${e.message}\n`);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
throw e;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return args.json ? runJson(orcha, args.pipeline, input) : runPretty(orcha, args.pipeline, input);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
main(process.argv.slice(2)).then(
|
|
175
|
+
(code) => process.exit(code || 0),
|
|
176
|
+
(err) => {
|
|
177
|
+
process.stderr.write(`orcha: ${err && err.message ? err.message : err}\n`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
},
|
|
180
|
+
);
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Resolve and (lazily) download the orcha Go binary for the current platform.
|
|
4
|
+
//
|
|
5
|
+
// The binary lives at ~/.orcha/bin/orcha-<os>-<arch>[.exe]. On first call we:
|
|
6
|
+
//
|
|
7
|
+
// 1. Fetch a manifest.json from the GitHub release that matches this npm
|
|
8
|
+
// package's version.
|
|
9
|
+
// 2. Look up the entry for our platform — it gives us a binary URL and the
|
|
10
|
+
// expected sha256.
|
|
11
|
+
// 3. Download the binary, verify the hash, mark it executable, cache it.
|
|
12
|
+
//
|
|
13
|
+
// Subsequent runs find the cached file and skip the network entirely. The
|
|
14
|
+
// behavior matches the Python SDK's downloader.py byte-for-byte so a single
|
|
15
|
+
// GitHub release serves both ecosystems.
|
|
16
|
+
//
|
|
17
|
+
// Override hooks (env):
|
|
18
|
+
// ORCHA_BINARY_PATH — absolute path to a pre-built binary (no download
|
|
19
|
+
// or hash check is run).
|
|
20
|
+
// ORCHA_BINARY_VERSION — pin a specific release tag instead of using the
|
|
21
|
+
// npm package version.
|
|
22
|
+
|
|
23
|
+
const crypto = require('node:crypto');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const fsp = require('node:fs/promises');
|
|
26
|
+
const https = require('node:https');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const { URL } = require('node:url');
|
|
30
|
+
|
|
31
|
+
const { OrchaError } = require('./errors');
|
|
32
|
+
const pkg = require('../package.json');
|
|
33
|
+
|
|
34
|
+
const MANIFEST_URL_TEMPLATE = 'https://github.com/ryfoo/orcha/releases/download/v{version}/manifest.json';
|
|
35
|
+
|
|
36
|
+
function detectPlatform() {
|
|
37
|
+
const osMap = { linux: 'linux', darwin: 'darwin', win32: 'windows' };
|
|
38
|
+
const archMap = { x64: 'amd64', arm64: 'arm64' };
|
|
39
|
+
|
|
40
|
+
const sys = os.platform();
|
|
41
|
+
const arch = os.arch();
|
|
42
|
+
if (!(sys in osMap)) throw new OrchaError(`unsupported OS: ${sys}`);
|
|
43
|
+
if (!(arch in archMap)) throw new OrchaError(`unsupported architecture: ${arch}`);
|
|
44
|
+
return { osName: osMap[sys], arch: archMap[arch] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function binaryFilename(osName, arch) {
|
|
48
|
+
let name = `orcha-${osName}-${arch}`;
|
|
49
|
+
if (osName === 'windows') name += '.exe';
|
|
50
|
+
return name;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function installDir() {
|
|
54
|
+
return path.join(os.homedir(), '.orcha', 'bin');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Wrap https.get so we can transparently follow GitHub release redirects
|
|
58
|
+
// (objects.githubusercontent.com) without pulling in a dep.
|
|
59
|
+
function httpsGet(url, opts = {}, redirectsLeft = 5) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const req = https.get(url, opts, (res) => {
|
|
62
|
+
const status = res.statusCode || 0;
|
|
63
|
+
if (status >= 300 && status < 400 && res.headers.location) {
|
|
64
|
+
if (redirectsLeft <= 0) {
|
|
65
|
+
res.resume();
|
|
66
|
+
reject(new OrchaError(`too many redirects fetching ${url}`));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const next = new URL(res.headers.location, url).toString();
|
|
70
|
+
res.resume();
|
|
71
|
+
httpsGet(next, opts, redirectsLeft - 1).then(resolve, reject);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
resolve(res);
|
|
75
|
+
});
|
|
76
|
+
req.on('error', reject);
|
|
77
|
+
req.setTimeout(120_000, () => {
|
|
78
|
+
req.destroy(new OrchaError(`timeout fetching ${url}`));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function fetchManifest(version) {
|
|
84
|
+
const url = MANIFEST_URL_TEMPLATE.replace('{version}', version);
|
|
85
|
+
let res;
|
|
86
|
+
try {
|
|
87
|
+
res = await httpsGet(url);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
throw new OrchaError(`failed to fetch manifest at ${url}: ${e.message}`);
|
|
90
|
+
}
|
|
91
|
+
const status = res.statusCode || 0;
|
|
92
|
+
if (status === 404) {
|
|
93
|
+
res.resume();
|
|
94
|
+
throw new OrchaError(
|
|
95
|
+
`orcha release v${version} not found at ${url}. Either the release ` +
|
|
96
|
+
`hasn't been published yet, or ORCHA_BINARY_VERSION points at a tag ` +
|
|
97
|
+
`that doesn't exist.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (status < 200 || status >= 300) {
|
|
101
|
+
res.resume();
|
|
102
|
+
throw new OrchaError(`failed to fetch manifest at ${url}: HTTP ${status}`);
|
|
103
|
+
}
|
|
104
|
+
const chunks = [];
|
|
105
|
+
for await (const chunk of res) chunks.push(chunk);
|
|
106
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(body);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
throw new OrchaError(`manifest at ${url} was not valid JSON: ${e.message}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function downloadTo(url, dest) {
|
|
115
|
+
const tmpPath = `${dest}.tmp-${process.pid}-${Date.now()}`;
|
|
116
|
+
let res;
|
|
117
|
+
try {
|
|
118
|
+
res = await httpsGet(url);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
throw new OrchaError(`download failed (${url}): ${e.message}`);
|
|
121
|
+
}
|
|
122
|
+
const status = res.statusCode || 0;
|
|
123
|
+
if (status < 200 || status >= 300) {
|
|
124
|
+
res.resume();
|
|
125
|
+
throw new OrchaError(`download failed (${url}): HTTP ${status}`);
|
|
126
|
+
}
|
|
127
|
+
await new Promise((resolve, reject) => {
|
|
128
|
+
const out = fs.createWriteStream(tmpPath);
|
|
129
|
+
res.pipe(out);
|
|
130
|
+
out.on('finish', () => out.close(resolve));
|
|
131
|
+
out.on('error', reject);
|
|
132
|
+
res.on('error', reject);
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
await fsp.rename(tmpPath, dest);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
await fsp.unlink(tmpPath).catch(() => {});
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function sha256(filePath) {
|
|
143
|
+
const hash = crypto.createHash('sha256');
|
|
144
|
+
const stream = fs.createReadStream(filePath);
|
|
145
|
+
for await (const chunk of stream) hash.update(chunk);
|
|
146
|
+
return hash.digest('hex');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// resolveBinary returns the cached binary path, downloading + verifying it
|
|
150
|
+
// the first time. Safe to call from multiple processes — concurrent downloads
|
|
151
|
+
// race only on the final rename, which is atomic on the platforms we target.
|
|
152
|
+
async function resolveBinary(version) {
|
|
153
|
+
const override = process.env.ORCHA_BINARY_PATH;
|
|
154
|
+
if (override) {
|
|
155
|
+
if (!fs.existsSync(override)) {
|
|
156
|
+
throw new OrchaError(`ORCHA_BINARY_PATH points to missing file: ${override}`);
|
|
157
|
+
}
|
|
158
|
+
return override;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const v = version || process.env.ORCHA_BINARY_VERSION || pkg.version;
|
|
162
|
+
const { osName, arch } = detectPlatform();
|
|
163
|
+
const name = binaryFilename(osName, arch);
|
|
164
|
+
const target = path.join(installDir(), name);
|
|
165
|
+
if (fs.existsSync(target)) return target;
|
|
166
|
+
|
|
167
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
168
|
+
|
|
169
|
+
const manifest = await fetchManifest(v);
|
|
170
|
+
const platformKey = `${osName}-${arch}`;
|
|
171
|
+
const entry = manifest?.binaries?.[platformKey];
|
|
172
|
+
if (!entry) {
|
|
173
|
+
const known = Object.keys(manifest?.binaries || {}).sort();
|
|
174
|
+
throw new OrchaError(
|
|
175
|
+
`release v${v} has no binary for ${platformKey} (available: [${known.join(', ')}])`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await downloadTo(entry.url, target);
|
|
180
|
+
const actual = await sha256(target);
|
|
181
|
+
if (actual !== entry.sha256) {
|
|
182
|
+
await fsp.unlink(target).catch(() => {});
|
|
183
|
+
throw new OrchaError(
|
|
184
|
+
`sha256 mismatch for ${name}: expected ${entry.sha256}, got ${actual}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
// chmod +x for owner/group/other; no-op on Windows.
|
|
188
|
+
if (osName !== 'windows') {
|
|
189
|
+
await fsp.chmod(target, 0o755);
|
|
190
|
+
}
|
|
191
|
+
return target;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
resolveBinary,
|
|
196
|
+
// Exported for tests / advanced callers.
|
|
197
|
+
detectPlatform,
|
|
198
|
+
binaryFilename,
|
|
199
|
+
installDir,
|
|
200
|
+
sha256,
|
|
201
|
+
};
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// OrchaError covers anything that goes wrong outside a task — binary
|
|
4
|
+
// download/verification, IPC plumbing, missing yaml, etc. Task-level failures
|
|
5
|
+
// surface as TaskFailed.
|
|
6
|
+
class OrchaError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'OrchaError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// TaskFailed is thrown by runToCompletion() when the engine emits a
|
|
14
|
+
// task_fail event, so callers can use plain try/catch.
|
|
15
|
+
class TaskFailed extends OrchaError {
|
|
16
|
+
constructor(task, index, error) {
|
|
17
|
+
super(`task ${task} (index ${index}) failed: ${error}`);
|
|
18
|
+
this.name = 'TaskFailed';
|
|
19
|
+
this.task = task;
|
|
20
|
+
this.index = index;
|
|
21
|
+
this.taskError = error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { OrchaError, TaskFailed };
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Type declarations for orcha-dev. Hand-written rather than generated so the
|
|
2
|
+
// runtime stays plain JS with no build step.
|
|
3
|
+
|
|
4
|
+
export type OutputType = 'text' | 'json' | 'filepath' | 'list';
|
|
5
|
+
|
|
6
|
+
export type EventType =
|
|
7
|
+
| 'task_start'
|
|
8
|
+
| 'task_complete'
|
|
9
|
+
| 'task_fail'
|
|
10
|
+
| 'pipeline_complete';
|
|
11
|
+
|
|
12
|
+
/** One streaming event from a pipeline run. Mirrors the Python OrchaEvent. */
|
|
13
|
+
export interface OrchaEvent {
|
|
14
|
+
type: EventType;
|
|
15
|
+
/** Name of the task; empty string on `pipeline_complete` in v1. */
|
|
16
|
+
task: string;
|
|
17
|
+
/** 0-based position in the pipeline; -1 for parse-time errors. */
|
|
18
|
+
index: number;
|
|
19
|
+
/** Final/intermediate output. `null` for `task_start` and `task_fail`. */
|
|
20
|
+
output: unknown;
|
|
21
|
+
/** Output type tag from the YAML; empty for `task_start`/`task_fail`. */
|
|
22
|
+
output_type: OutputType | '';
|
|
23
|
+
/** Error message; only set when `type === 'task_fail'`. */
|
|
24
|
+
error?: string | null;
|
|
25
|
+
/** Wall-clock duration of this step in ms. 0 for `task_start`. */
|
|
26
|
+
elapsed_ms: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CreateOptions {
|
|
30
|
+
/** Absolute path to a pre-built engine binary (skips download). */
|
|
31
|
+
binaryPath?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type RunInput = string | Buffer | Uint8Array | object | unknown[] | null | undefined;
|
|
35
|
+
|
|
36
|
+
export class Orcha {
|
|
37
|
+
/** Use {@link Orcha.create} instead unless you already have a binary path. */
|
|
38
|
+
constructor(yamlPath: string, binaryPath: string);
|
|
39
|
+
|
|
40
|
+
/** Resolve (and lazily download + verify) the engine binary, then return an Orcha bound to a YAML file. */
|
|
41
|
+
static create(yamlPath: string, options?: CreateOptions): Promise<Orcha>;
|
|
42
|
+
|
|
43
|
+
/** Absolute path to the resolved orcha.yaml. */
|
|
44
|
+
readonly yamlPath: string;
|
|
45
|
+
/** Absolute path to the engine binary in use. */
|
|
46
|
+
readonly binary: string;
|
|
47
|
+
|
|
48
|
+
/** Run a pipeline and stream events as they arrive. */
|
|
49
|
+
run(target: string, input?: RunInput): AsyncIterable<OrchaEvent>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run a pipeline to completion and return its final output. Throws
|
|
53
|
+
* {@link TaskFailed} on the first failing task. Equivalent to Python's
|
|
54
|
+
* `run_sync`.
|
|
55
|
+
*/
|
|
56
|
+
runToCompletion(target: string, input?: RunInput): Promise<unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class OrchaError extends Error {
|
|
60
|
+
readonly name: 'OrchaError' | 'TaskFailed';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class TaskFailed extends OrchaError {
|
|
64
|
+
readonly name: 'TaskFailed';
|
|
65
|
+
readonly task: string;
|
|
66
|
+
readonly index: number;
|
|
67
|
+
readonly taskError: string;
|
|
68
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Programmatic JS/TS API for the orcha-dev npm package.
|
|
4
|
+
//
|
|
5
|
+
// Spawns the Go binary as a subprocess, sends one JSON command on stdin, and
|
|
6
|
+
// streams JSON-line events back from stdout. The protocol is one-shot per
|
|
7
|
+
// invocation: every run() call is a fresh process. The wire format is
|
|
8
|
+
// shared with the Python SDK so the binary stays single-source-of-truth.
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { spawn } = require('node:child_process');
|
|
13
|
+
|
|
14
|
+
const { resolveBinary } = require('./downloader');
|
|
15
|
+
const { OrchaError, TaskFailed } = require('./errors');
|
|
16
|
+
|
|
17
|
+
class Orcha {
|
|
18
|
+
// Use Orcha.create() instead — resolving the binary involves I/O, so the
|
|
19
|
+
// factory is async. The bare constructor is kept for callers that already
|
|
20
|
+
// have an absolute binary path (tests, monorepo dev loops).
|
|
21
|
+
constructor(yamlPath, binaryPath) {
|
|
22
|
+
if (!yamlPath) throw new OrchaError('yamlPath is required');
|
|
23
|
+
this.yamlPath = path.resolve(yamlPath);
|
|
24
|
+
if (!fs.existsSync(this.yamlPath)) {
|
|
25
|
+
throw new OrchaError(`yaml file not found: ${this.yamlPath}`);
|
|
26
|
+
}
|
|
27
|
+
if (!binaryPath) {
|
|
28
|
+
throw new OrchaError('binaryPath is required (use Orcha.create() to auto-resolve)');
|
|
29
|
+
}
|
|
30
|
+
this.binary = path.resolve(binaryPath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static async create(yamlPath, options = {}) {
|
|
34
|
+
const binaryPath = options.binaryPath
|
|
35
|
+
? path.resolve(options.binaryPath)
|
|
36
|
+
: await resolveBinary();
|
|
37
|
+
return new Orcha(yamlPath, binaryPath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// run() returns an async iterable that yields one event per JSON line the
|
|
41
|
+
// binary emits. The returned iterator is consumable with `for await`. If
|
|
42
|
+
// the engine exits non-zero, the iterator throws OrchaError after the last
|
|
43
|
+
// event; per-task failures arrive as `task_fail` events first.
|
|
44
|
+
run(target, input) {
|
|
45
|
+
if (input instanceof Buffer || input instanceof Uint8Array) {
|
|
46
|
+
input = Buffer.from(input).toString('utf8');
|
|
47
|
+
}
|
|
48
|
+
const cmd = {
|
|
49
|
+
command: 'run',
|
|
50
|
+
pipeline: target,
|
|
51
|
+
yaml_path: this.yamlPath,
|
|
52
|
+
input: input === undefined ? null : input,
|
|
53
|
+
};
|
|
54
|
+
return this._spawn(cmd);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// runToCompletion() drains the event stream and returns the final pipeline
|
|
58
|
+
// output. Throws TaskFailed if any task fails so callers can use plain
|
|
59
|
+
// try/catch. Equivalent to Python's run_sync().
|
|
60
|
+
async runToCompletion(target, input) {
|
|
61
|
+
let final;
|
|
62
|
+
for await (const ev of this.run(target, input)) {
|
|
63
|
+
if (ev.type === 'task_fail') {
|
|
64
|
+
throw new TaskFailed(ev.task, ev.index, ev.error || 'unknown error');
|
|
65
|
+
}
|
|
66
|
+
if (ev.type === 'pipeline_complete') {
|
|
67
|
+
final = ev.output;
|
|
68
|
+
} else if (ev.type === 'task_complete') {
|
|
69
|
+
// Track latest in case the binary doesn't emit pipeline_complete
|
|
70
|
+
// (it always does in v1, but defensiveness here is cheap).
|
|
71
|
+
final = ev.output;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return final;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_spawn(cmd) {
|
|
78
|
+
const proc = spawn(this.binary, [], {
|
|
79
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Drain stderr eagerly so a full pipe buffer never blocks the engine
|
|
83
|
+
// mid-run; surface the captured text only if the process exits non-zero.
|
|
84
|
+
let stderrBuf = '';
|
|
85
|
+
proc.stderr.setEncoding('utf8');
|
|
86
|
+
proc.stderr.on('data', (chunk) => { stderrBuf += chunk; });
|
|
87
|
+
|
|
88
|
+
let writeError = null;
|
|
89
|
+
try {
|
|
90
|
+
proc.stdin.write(JSON.stringify(cmd) + '\n');
|
|
91
|
+
proc.stdin.end();
|
|
92
|
+
} catch (e) {
|
|
93
|
+
writeError = e;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return iterateLines(proc.stdout, () => stderrBuf, proc, writeError);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// iterateLines consumes a Readable byte stream and yields one parsed JSON
|
|
101
|
+
// object per newline. Trailing partial lines are buffered until the next
|
|
102
|
+
// chunk arrives. When the stream ends, we await the child's exit code and
|
|
103
|
+
// throw if it's non-zero.
|
|
104
|
+
function iterateLines(stdout, getStderr, proc, initialError) {
|
|
105
|
+
return {
|
|
106
|
+
async *[Symbol.asyncIterator]() {
|
|
107
|
+
if (initialError) {
|
|
108
|
+
proc.kill();
|
|
109
|
+
await new Promise((resolve) => proc.once('exit', resolve));
|
|
110
|
+
throw new OrchaError(
|
|
111
|
+
`failed to send command to engine: ${initialError.message}; stderr=${getStderr()}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
stdout.setEncoding('utf8');
|
|
116
|
+
let buf = '';
|
|
117
|
+
try {
|
|
118
|
+
for await (const chunk of stdout) {
|
|
119
|
+
buf += chunk;
|
|
120
|
+
let nl;
|
|
121
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
122
|
+
const line = buf.slice(0, nl).trim();
|
|
123
|
+
buf = buf.slice(nl + 1);
|
|
124
|
+
if (!line) continue;
|
|
125
|
+
let raw;
|
|
126
|
+
try {
|
|
127
|
+
raw = JSON.parse(line);
|
|
128
|
+
} catch (_) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
yield raw;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const tail = buf.trim();
|
|
135
|
+
if (tail) {
|
|
136
|
+
try {
|
|
137
|
+
yield JSON.parse(tail);
|
|
138
|
+
} catch (_) {
|
|
139
|
+
// Ignore; matches Python which silently drops malformed lines.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
const code = await new Promise((resolve) => {
|
|
144
|
+
if (proc.exitCode !== null) resolve(proc.exitCode);
|
|
145
|
+
else proc.once('exit', (c) => resolve(c));
|
|
146
|
+
});
|
|
147
|
+
if (code !== 0) {
|
|
148
|
+
const stderr = (getStderr() || '').trim();
|
|
149
|
+
throw new OrchaError(
|
|
150
|
+
`orcha engine exited ${code}` + (stderr ? `: ${stderr}` : ''),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { Orcha, OrchaError, TaskFailed };
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "orcha-dev",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Unix pipes for AI workflows. Define tasks and pipelines in YAML; execute with a single Go binary auto-downloaded on first run.",
|
|
5
|
+
"main": "./lib/index.js",
|
|
6
|
+
"types": "./lib/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"orcha": "./bin/orcha.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"lib",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"homepage": "https://github.com/ryfoo/orcha",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/ryfoo/orcha.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/ryfoo/orcha/issues"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"ai",
|
|
30
|
+
"workflow",
|
|
31
|
+
"pipeline",
|
|
32
|
+
"yaml",
|
|
33
|
+
"openai",
|
|
34
|
+
"anthropic",
|
|
35
|
+
"deepseek",
|
|
36
|
+
"llm",
|
|
37
|
+
"automation"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node --test test/*.test.js"
|
|
41
|
+
}
|
|
42
|
+
}
|