orkestrator 0.0.0-stage → 2.23.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/README.md CHANGED
@@ -1,3 +1,155 @@
1
- # Temporary Holding Version
1
+ # Orkestrator backend and client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The `orkestrator` executable has two modes:
4
+
5
+ - **Service** — `orkestrator`, `orkestrator serve …`, or the historical
6
+ `orkestrator --host … --port …` forms run the backend in the foreground.
7
+ - **Client** — `orkestrator <group> <command> …` talks to a backend that is
8
+ already running. Client commands never start a backend.
9
+
10
+ Run the Orkestrator backend service without installing the desktop application:
11
+
12
+ ```bash
13
+ bunx orkestrator
14
+ ```
15
+
16
+ The service stays in the foreground and stops cleanly when it receives Ctrl-C,
17
+ SIGINT, or SIGTERM. It stores persistent state in the normal Orkestrator data
18
+ directory.
19
+
20
+ For the hosted web client, publish the backend through Tailscale Serve and allow
21
+ the Orkestrator origins:
22
+
23
+ ```bash
24
+ bunx orkestrator \
25
+ --tailscale-serve \
26
+ --allowed-origins https://orkestrator.dev,https://www.orkestrator.dev
27
+ ```
28
+
29
+ For a loopback-only service:
30
+
31
+ ```bash
32
+ bunx orkestrator \
33
+ --host 127.0.0.1 \
34
+ --port 34121 \
35
+ --allow-non-tailscale-bind
36
+ ```
37
+
38
+ Or install Bun when needed and start the service in one command:
39
+
40
+ ```bash
41
+ curl -fsSL https://orkestrator.dev/install.sh | bash
42
+ ```
43
+
44
+ Pass backend arguments through the installer with `bash -s --`:
45
+
46
+ ```bash
47
+ curl -fsSL https://orkestrator.dev/install.sh | \
48
+ bash -s -- --tailscale-serve \
49
+ --allowed-origins https://orkestrator.dev,https://www.orkestrator.dev
50
+ ```
51
+
52
+ macOS and Linux are supported. Docker is required for container environments;
53
+ Tailscale is required only for tailnet access and Tailscale Serve.
54
+
55
+ ## Agent binaries
56
+
57
+ The desktop app downloads the pinned Claude, Codex, OpenCode, Grok and Pi
58
+ binaries when it starts. A backend run this way does not, and without them it
59
+ falls back to whatever is on `PATH`. Install the exact pinned versions once, and
60
+ again after each upgrade:
61
+
62
+ ```bash
63
+ orkestrator toolchain install # what this data directory enables
64
+ orkestrator toolchain install --dry-run # show what would be fetched
65
+ ```
66
+
67
+ Every download is checked against its pinned size and SHA-256 before use, into
68
+ `<data-dir>/toolchains`. Which tools are installed follows the data directory's
69
+ enabled agent platforms (all of them when nothing is configured yet); `--tool`
70
+ installs exactly the tools you name instead. On success `toolchains/bin/current`
71
+ points at the installed set and the service uses it by default for that data
72
+ directory — restart a running service to pick up a new one. Pass `--data-dir`
73
+ when the service does, and see `orkestrator toolchain install --help`.
74
+
75
+ ## Client commands
76
+
77
+ Select the backend explicitly. A running backend publishes a private
78
+ `backend-instance.json` in its data directory; save it once as the default:
79
+
80
+ ```bash
81
+ orkestrator connection add local --data-dir "$HOME/.config/orkestrator-v2" --default
82
+ orkestrator connection check
83
+ ```
84
+
85
+ Remote backends use a private token file or stdin — never a token argument:
86
+
87
+ ```bash
88
+ orkestrator connection add laptop --url https://laptop.example.ts.net --credential-file ~/.secrets/token
89
+ ```
90
+
91
+ A complete, owned workflow (IDs and request keys are explicit so a script can
92
+ recover after a lost response or restart):
93
+
94
+ ```bash
95
+ set -euo pipefail
96
+ PROJECT=$(orkestrator project add --path /srv/repos/app --request-id "$RUN:add" --output id)
97
+ ENV=$(orkestrator environment create --project "$PROJECT" --type local \
98
+ --name "$RUN" --request-id "$RUN:create" --output id)
99
+ trap 'orkestrator environment delete "$ENV" --wait deleted --timeout 5m --json >/dev/null' EXIT
100
+ orkestrator environment start "$ENV" --wait ready --timeout 10m --json
101
+ orkestrator environment config set "$ENV" --set agent.claude.model=opus
102
+ orkestrator session start --environment "$ENV" --agent claude \
103
+ --prompt-file task.txt --request-id "$RUN:prompt" --json > launch.json
104
+ RUN_ID=$(jq -er '.result.runId' launch.json)
105
+ SESSION=$(jq -er '.result.sessionId' launch.json)
106
+ orkestrator run wait "$RUN_ID" --timeout 30m --json
107
+ orkestrator session transcript "$SESSION" --limit 20 --json > transcript.json
108
+ orkestrator environment exec "$ENV" --wait -- bun test
109
+ ```
110
+
111
+ - `--json` prints exactly one envelope on stdout; `--output id` prints only
112
+ the documented ID. Exit codes: 0 ok, 1 failed, 2 invalid input, 3 not
113
+ found/ambiguous/expired, 4 connection/auth, 5 observer deadline,
114
+ 6 interaction required, 7 unknown dispatch/outcome, 8 conflict/unsupported,
115
+ 130/143 observation interrupted.
116
+ - Paths given to `project` commands are on the backend host; prompt, patch and
117
+ credential files are read locally.
118
+ - `project create` creates a **private GitHub repository** and pushes to it;
119
+ it requires `--github-private`.
120
+ - A timeout or Ctrl+C only stops observing. Resume with `orkestrator run wait
121
+ OP`; recover a lost response with `orkestrator run get --request-id KEY
122
+ --action ACTION`. The client never resubmits under a new key.
123
+
124
+ Run `orkestrator help` and `orkestrator help <group> <command>` for the full
125
+ command reference. The contract, retention rules and provider completion
126
+ matrix are documented in the repository's `docs/architecture/public-cli.md`.
127
+
128
+ ## Publishing
129
+
130
+ `dependencies` here lists only the modules `scripts/build.ts` leaves unbundled;
131
+ everything else is inlined into `dist/` and `resources/`. `tests/cli.test.ts`
132
+ derives that list from the built artifacts, so adding a dependency the bundles do
133
+ not resolve — or bumping one in `apps/backend` or `bridges/` without mirroring it
134
+ here — fails the suite.
135
+
136
+ Publish from the repository root:
137
+
138
+ ```bash
139
+ mise run publish:cli
140
+ ```
141
+
142
+ That runs `smoke:cli` first, which packs the tarball, installs it into a scratch
143
+ project, checks the unbundled dependencies resolve from the installed layout,
144
+ runs client help/version/errors with no backend, starts the backend, drives a
145
+ fixture project/environment lifecycle through the installed client, confirms
146
+ only one backend ran, and stops it. It needs network access, which is why it is
147
+ a publish gate rather than part of `mise run test`.
148
+
149
+ The npm dist-tag follows the version's update channel: `2.23.0` publishes as
150
+ `latest`, `2.23.0-beta` as `beta` and `2.23.0-alpha` as `alpha`, so
151
+ `bunx orkestrator` only ever gets a stable release. Try a prerelease with
152
+ `bunx orkestrator@beta` or `bunx orkestrator@alpha`.
153
+
154
+ `dist/client.js` is the client bundle the launcher imports first; it must stay
155
+ free of backend imports so client commands never initialize a service.
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The process launchd or systemd runs for a standalone backend installed with
4
+ * `orkestrator service install`.
5
+ *
6
+ * It starts `<dataDir>/backend/current`, with the Bun and serve arguments in
7
+ * `service.json`, and supervises it:
8
+ *
9
+ * - A release that has not reached readiness (a backend-instance.json naming
10
+ * its process and version) gets two boots. After that the launcher points
11
+ * `current` back at `previous` and records the rollback, which the restored
12
+ * backend reports as the outcome of its update.
13
+ * - When the backend exits after writing `restart-requested` (its self-update
14
+ * just switched `current`), the launcher starts the new release at once.
15
+ * - SIGTERM and SIGINT are forwarded; the launcher then exits with the
16
+ * backend's status, and the service manager decides what happens next.
17
+ *
18
+ * This file is copied out of the release into `<dataDir>/backend/launcher.js`,
19
+ * so it must not import anything outside Node's and Bun's built-ins. It
20
+ * repeats the layout in `packages/protocol/src/node/standalone-service.ts`.
21
+ */
22
+ import { spawn, spawnSync } from "node:child_process";
23
+ import {
24
+ existsSync,
25
+ readFileSync,
26
+ readlinkSync,
27
+ renameSync,
28
+ rmSync,
29
+ symlinkSync,
30
+ writeFileSync,
31
+ } from "node:fs";
32
+ import path from "node:path";
33
+
34
+ const MAX_BOOT_ATTEMPTS = 2;
35
+ const READY_TIMEOUT_MS = Number(process.env.ORKESTRATOR_LAUNCHER_READY_TIMEOUT_MS ?? 120_000);
36
+ const RETRY_DELAY_MS = Number(process.env.ORKESTRATOR_LAUNCHER_RETRY_DELAY_MS ?? 2_000);
37
+ const CRASH_DELAY_MS = Number(process.env.ORKESTRATOR_LAUNCHER_CRASH_DELAY_MS ?? 5_000);
38
+ const STOP_TIMEOUT_MS = Number(process.env.ORKESTRATOR_LAUNCHER_STOP_TIMEOUT_MS ?? 10_000);
39
+ const TOOLCHAIN_TIMEOUT_MS = 20 * 60 * 1000;
40
+
41
+ const dataDir = path.resolve(process.argv[2] ?? "");
42
+ if (!process.argv[2]) {
43
+ console.error("usage: launcher.js <data-dir>");
44
+ process.exit(2);
45
+ }
46
+ const root = path.join(dataDir, "backend");
47
+ const layout = {
48
+ current: path.join(root, "current"),
49
+ previous: path.join(root, "previous"),
50
+ serviceFile: path.join(root, "service.json"),
51
+ stateFile: path.join(root, "launcher-state.json"),
52
+ restartMarker: path.join(root, "restart-requested"),
53
+ instance: path.join(dataDir, "backend-instance.json"),
54
+ };
55
+
56
+ function log(message) {
57
+ console.error(`[launcher] ${new Date().toISOString()} ${message}`);
58
+ }
59
+
60
+ function readJson(file) {
61
+ try {
62
+ return JSON.parse(readFileSync(file, "utf8"));
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function readState() {
69
+ const value = readJson(layout.stateFile) ?? {};
70
+ return {
71
+ version: 1,
72
+ lastReadyVersion: typeof value.lastReadyVersion === "string" ? value.lastReadyVersion : null,
73
+ bootAttempts:
74
+ value.bootAttempts && typeof value.bootAttempts === "object" ? value.bootAttempts : {},
75
+ rollback: value.rollback ?? null,
76
+ };
77
+ }
78
+
79
+ function writeState(state) {
80
+ const temporary = `${layout.stateFile}.${process.pid}.tmp`;
81
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
82
+ renameSync(temporary, layout.stateFile);
83
+ }
84
+
85
+ function linked(link) {
86
+ try {
87
+ return readlinkSync(link);
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function pointLink(link, target) {
94
+ const temporary = `${link}.${process.pid}.tmp`;
95
+ rmSync(temporary, { force: true });
96
+ symlinkSync(target, temporary);
97
+ renameSync(temporary, link);
98
+ }
99
+
100
+ function entryOf(release) {
101
+ return path.join(release, "node_modules", "orkestrator", "bin", "orkestrator.js");
102
+ }
103
+
104
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
105
+
106
+ const service = readJson(layout.serviceFile);
107
+ if (!service || typeof service.bun !== "string" || !Array.isArray(service.args)) {
108
+ log(`${layout.serviceFile} is missing or invalid; run \`orkestrator service install\``);
109
+ process.exit(1);
110
+ }
111
+
112
+ let child = null;
113
+ let stopping = false;
114
+ for (const signal of ["SIGTERM", "SIGINT"]) {
115
+ process.on(signal, () => {
116
+ stopping = true;
117
+ if (child && child.exitCode === null) child.kill(signal);
118
+ else process.exit(0);
119
+ });
120
+ }
121
+
122
+ async function waitForReady(pid, version, exited) {
123
+ const deadline = Date.now() + READY_TIMEOUT_MS;
124
+ while (Date.now() < deadline && !exited()) {
125
+ const instance = readJson(layout.instance);
126
+ if (instance && instance.pid === pid && instance.appVersion === version) return true;
127
+ await sleep(500);
128
+ }
129
+ return false;
130
+ }
131
+
132
+ while (!stopping) {
133
+ const target = linked(layout.current);
134
+ if (!target) {
135
+ log(`${layout.current} does not point at a release`);
136
+ process.exit(1);
137
+ }
138
+ const release = path.resolve(root, target);
139
+ const version = path.basename(release);
140
+ const state = readState();
141
+
142
+ if (version !== state.lastReadyVersion) {
143
+ const attempts = (state.bootAttempts[version] ?? 0) + 1;
144
+ const previousTarget = linked(layout.previous);
145
+ const previousVersion = previousTarget ? path.basename(previousTarget) : null;
146
+ if (attempts > MAX_BOOT_ATTEMPTS && previousTarget && previousVersion !== version) {
147
+ log(
148
+ `${version} did not start after ${MAX_BOOT_ATTEMPTS} attempts; rolling back to ${previousVersion}`,
149
+ );
150
+ pointLink(layout.current, previousTarget);
151
+ state.bootAttempts[version] = 0;
152
+ state.rollback = {
153
+ failedVersion: version,
154
+ restoredVersion: previousVersion,
155
+ at: new Date().toISOString(),
156
+ };
157
+ writeState(state);
158
+ continue;
159
+ }
160
+ state.bootAttempts[version] = attempts;
161
+ writeState(state);
162
+ }
163
+
164
+ const entry = entryOf(release);
165
+ // Idempotent and quick when nothing changed; a new release installs its pins.
166
+ const toolchain = spawnSync(service.bun, [entry, "toolchain", "install", "--data-dir", dataDir], {
167
+ stdio: ["ignore", "ignore", "inherit"],
168
+ timeout: TOOLCHAIN_TIMEOUT_MS,
169
+ });
170
+ if (toolchain.status !== 0) log(`toolchain install for ${version} failed; starting anyway`);
171
+
172
+ rmSync(layout.restartMarker, { force: true });
173
+ log(`starting ${version}`);
174
+ let exited = false;
175
+ child = spawn(
176
+ service.bun,
177
+ [entry, "serve", ...service.args, "--data-dir", dataDir, "--standalone-updater", root],
178
+ { stdio: "inherit", env: process.env },
179
+ );
180
+ const exit = new Promise((resolve) =>
181
+ child.once("exit", (code, signal) => {
182
+ exited = true;
183
+ resolve({ code, signal });
184
+ }),
185
+ );
186
+ const ready = await waitForReady(child.pid, version, () => exited);
187
+ if (ready) {
188
+ const next = readState();
189
+ next.lastReadyVersion = version;
190
+ next.bootAttempts[version] = 0;
191
+ writeState(next);
192
+ log(`${version} is ready`);
193
+ }
194
+ if (!ready && !exited) {
195
+ log(`${version} missed its readiness deadline; stopping it`);
196
+ child.kill("SIGTERM");
197
+ let timer;
198
+ await Promise.race([
199
+ exit,
200
+ new Promise((resolve) => {
201
+ timer = setTimeout(resolve, STOP_TIMEOUT_MS);
202
+ }),
203
+ ]);
204
+ clearTimeout(timer);
205
+ if (!exited) child.kill("SIGKILL");
206
+ }
207
+ const { code, signal } = await exit;
208
+ child = null;
209
+ if (stopping) process.exit(code ?? 0);
210
+ if (existsSync(layout.restartMarker)) {
211
+ log(`${version} exited to restart into ${path.basename(linked(layout.current) ?? "")}`);
212
+ continue;
213
+ }
214
+ log(`${version} exited (code ${code ?? "none"}, signal ${signal ?? "none"})`);
215
+ await sleep(ready ? CRASH_DELAY_MS : RETRY_DELAY_MS);
216
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+ const manifest = await Bun.file(path.join(packageRoot, "package.json")).json();
8
+ const version = typeof manifest.version === "string" ? manifest.version : "0.0.0";
9
+
10
+ // Decide service versus client before the backend is imported: help, version
11
+ // and client commands must never initialize storage, listeners, or bridges.
12
+ const client = await import("../dist/client.js");
13
+ const argv = process.argv.slice(2);
14
+ const invocation = client.classifyInvocation(argv);
15
+
16
+ if (invocation.mode === "serve") {
17
+ // `serve` is an explicit spelling of the historical no-subcommand form; the
18
+ // backend's own parser reads the remaining service options from argv.
19
+ process.argv.splice(2, process.argv.length - 2, ...invocation.args);
20
+ // The standalone bundle is deliberately laid out like the desktop resources,
21
+ // but it lives in Bun's package cache instead of an application bundle. Make
22
+ // those roots authoritative before main.ts calculates any path defaults.
23
+ process.env.NODE_ENV ??= "production";
24
+ process.env.ORKESTRATOR_APP_ROOT ??= packageRoot;
25
+ process.env.ORKESTRATOR_RESOURCE_ROOT ??= path.join(packageRoot, "resources");
26
+ process.env.ORKESTRATOR_VERSION ??= version;
27
+ await import("../dist/main.js");
28
+ } else if (invocation.mode === "client") {
29
+ process.exitCode = await client.runClientProcess(invocation.argv, version);
30
+ } else {
31
+ process.exitCode = client.renderInvocationError(argv, invocation.message);
32
+ }