workerdeck 0.0.0 → 0.6.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tobias Strebitzer
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 CHANGED
@@ -1,16 +1,105 @@
1
- # workerdeck
1
+ # WorkerDeck
2
2
 
3
- **Name placeholder. There is no working release under this name yet — do not depend on `0.0.0`.**
3
+ The turnkey [WorkerDeck](https://github.com/workerdeck/workerdeck) instance: the
4
+ session gateway **and** the full dashboard, on one port, with nothing to clone.
4
5
 
5
- WorkerDeck is a web-controlled runner for coding-agent sessions: embed, watch, and control an
6
- agent session from a host app, over one HTTP + WebSocket gateway.
6
+ ```bash
7
+ npx workerdeck
8
+ ```
7
9
 
8
- This package reserves the name ahead of the first real release. When that ships it will land at the
9
- project's current version — `0.0.0` will never be a usable version, and installing it gets you this
10
- README and nothing else.
10
+ That serves `http://127.0.0.1:8787` — dashboard at the root, API under `/v1` — and persists parked
11
+ sessions under `~/.workerdeck` so a restart doesn't drop them.
11
12
 
12
- The scoped packages live under [`@workerdeck`](https://www.npmjs.com/org/workerdeck).
13
+ The `@workerdeck/*` packages are the libraries you embed in your own app. This one is the
14
+ service you run.
13
15
 
14
- ## License
16
+ ## Protecting it
17
+
18
+ ```bash
19
+ npx workerdeck --auth-key "$SECRET" --host 0.0.0.0
20
+ ```
21
+
22
+ One secret, two transports:
23
+
24
+ - **Browsers** get a login page, trade the secret for an `HttpOnly` session cookie, and use that
25
+ for everything afterwards — including the WebSocket attach.
26
+ - **Services** send the same secret as an `x-workerdeck-key` header (or
27
+ `Authorization: Bearer`).
28
+
29
+ The cookie is not a convenience. A browser cannot set a header on a WebSocket handshake at all, so
30
+ a cookie is the only credential a tab can present when it attaches to a session — and a cookie only
31
+ rides requests to the origin that set it. That is why the dashboard and the API share a port.
32
+
33
+ Neither transport authenticates *who* the person is; the secret is a door key. Put an
34
+ identity-aware proxy in front if you need more than that.
35
+
36
+ **Without `--auth-key`, the instance refuses to bind anything but a loopback address.** An
37
+ unauthenticated gateway on a routable interface is a Claude Code shell for anyone who can reach the
38
+ port. `--insecure` overrides that, for when something in front is doing the authenticating.
39
+
40
+ ## Options
41
+
42
+ | Flag | Env | Default |
43
+ | --- | --- | --- |
44
+ | `--port <n>` | `WORKERDECK_PORT` | `8787` |
45
+ | `--host <addr>` | `WORKERDECK_HOST` | `127.0.0.1` |
46
+ | `--auth-key <secret>` | `WORKERDECK_AUTH_KEY` | none (no auth) |
47
+ | `--cwd-root <path>` (repeatable) | `WORKERDECK_CWD_ROOTS` (`:`-separated) | unrestricted |
48
+ | `--profile <name=dir>` (repeatable) | — | auto-detected from `~/.claude` |
49
+ | `--state-dir <path>` | `WORKERDECK_STATE_DIR` | beside the config file, else `~/.workerdeck` |
50
+ | `--no-parking-store` | — | durable parking on |
51
+ | `--config <path>` | — | `./workerdeck.config.mjs` |
52
+ | `--insecure`, `--open`, `--help`, `--version` | | |
53
+
54
+ Precedence is narrowest-wins: flags > env > config file > defaults.
55
+
56
+ ## The config file
57
+
58
+ `authenticate`, `buildRunnerConfig` and `createEngineRunner` are functions, so they can't come from
59
+ a flag. `workerdeck.config.mjs` default-exports the
60
+ [`createWorkerServer` options](https://workerdeck.github.io/workerdeck/docs/reference/server/)
61
+ (or a function, sync or async, returning them):
62
+
63
+ ```js
64
+ export default {
65
+ allowedCwdRoots: ['/srv/projects'],
66
+ profiles: [{ name: 'me', configDir: '/home/me/.claude' }],
67
+ requireApiKey: true,
68
+ buildRunnerConfig: (req) => ({ ...req, env: { ...process.env, CI: '1' } }),
69
+ }
70
+ ```
71
+
72
+ Supplying your own `authenticate` turns the built-in shared-secret auth **off entirely** — one
73
+ hook, one scheme, rather than two paths where only one got audited. If you take it over, you own
74
+ finding a credential the browser can actually present. There's a fuller example in
75
+ [`examples/workerdeck.config.mjs`](https://github.com/workerdeck/workerdeck/blob/master/examples/workerdeck.config.mjs).
76
+
77
+ ## Restart guard
78
+
79
+ ```bash
80
+ npx workerdeck guard --wait 300 --allow-parked && systemctl restart workerdeck
81
+ ```
82
+
83
+ Exits `0` when a restart is safe, `1` while a session is mid-turn, awaiting an approval, or parked
84
+ without durability behind it, and `2` when it couldn't tell — never treating "couldn't tell" as
85
+ safe. `--allow-parked` and `--allow-queued` are you asserting that the `SessionStore` and the
86
+ `QueueAdapter` respectively are durable; they're separate decisions. Point `--url` at any instance
87
+ and authenticate with `--token` or `--header name=value`.
88
+
89
+ ## Credentials
90
+
91
+ WorkerDeck implements **no Anthropic auth**. The official SDK/CLI resolves credentials from the
92
+ operator's environment, per profile — `--auth-key` protects this gateway and nothing else. See the
93
+ project's
94
+ [Auth & Anthropic's terms](https://github.com/workerdeck/workerdeck#auth--anthropics-terms).
95
+
96
+ ## Mounting
97
+
98
+ The dashboard's assets resolve from an absolute `/assets/...`, so it must be served at a **domain
99
+ root**, not a subpath. A dedicated vhost reverse-proxying to the instance is the intended shape.
100
+
101
+ The dashboard itself is [`@workerdeck/web`](https://www.npmjs.com/package/@workerdeck/web),
102
+ a normal dependency of this package. Point `webRoot` in the config file at your own build to serve
103
+ a fork instead.
15
104
 
16
105
  MIT
@@ -0,0 +1 @@
1
+ export { };
package/build/cli.mjs ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import { a as ConfigError, d as parseArgs, f as resolveInstanceConfig, r as startInstance, u as loadConfigFile } from "./instance-kupxU5UD.mjs";
3
+ import { dirname, join } from "node:path";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawn } from "node:child_process";
7
+ //#region src/cli.ts
8
+ const HELP = `workerdeck — run a workerdeck instance: session gateway + dashboard, one port.
9
+
10
+ Usage
11
+ workerdeck [options]
12
+ workerdeck guard [options] check whether it is safe to restart an instance
13
+
14
+ Options
15
+ -p, --port <n> port to listen on (default 8787, WORKERDECK_PORT)
16
+ --host <addr> interface to bind (default 127.0.0.1, WORKERDECK_HOST)
17
+ --auth-key <secret> shared secret, min 12 chars; browsers log in with it,
18
+ services send it as x-workerdeck-key
19
+ (WORKERDECK_AUTH_KEY). Unset = no auth on loopback;
20
+ on any other interface a key is generated instead,
21
+ printed once, and stored in <state-dir>/auth-key for
22
+ later starts to reuse.
23
+ --trust-proxy trust x-forwarded-proto/-host/-for from one reverse
24
+ proxy. Required behind TLS termination, or the session
25
+ cookie loses its Secure flag and the origin check
26
+ computes http:// where the browser says https://.
27
+ --allowed-origin <o> extra origin accepted on browser requests, for when a
28
+ proxy rewrites Host (repeatable)
29
+ --allowed-host <name> extra Host header accepted when running without auth
30
+ (repeatable; loopback names are always accepted)
31
+ --insecure-host <name>
32
+ bind host that may serve without auth — no key demanded,
33
+ none generated — and, while unauthenticated, also
34
+ accepted as a Host header (repeatable; config:
35
+ insecureHosts). Names the host alone, no port.
36
+ --profile <name=dir> Claude config dir a session may run under (repeatable)
37
+ --cwd-root <path> restrict session cwds to this root (repeatable,
38
+ WORKERDECK_CWD_ROOTS as a ':'-separated list)
39
+ --state-dir <path> where parked sessions are persisted
40
+ (default: beside the config file, else ~/.workerdeck)
41
+ --no-parking-store keep parked sessions in memory only; a restart drops them
42
+ -c, --config <path> config file (default: ./workerdeck.config.mjs)
43
+ --insecure allow no-auth on a non-loopback address. Only when
44
+ something in front is doing the authenticating.
45
+ --open open the dashboard in a browser once it is up
46
+ -h, --help show this
47
+ -v, --version print the version
48
+
49
+ Config file
50
+ Options that cannot fit on a command line — \`authenticate\`, \`buildRunnerConfig\`,
51
+ \`createEngineRunner\` are functions — live in workerdeck.config.mjs, which
52
+ default-exports the createWorkerServer options (or a function returning them).
53
+ Flags and env override it. Supplying your own \`authenticate\` turns the built-in
54
+ shared-secret auth off entirely.
55
+
56
+ Credentials
57
+ workerdeck implements no Anthropic auth of its own: the official SDK/CLI
58
+ resolves credentials from the environment, per profile. --auth-key protects this
59
+ gateway, nothing else.
60
+ `;
61
+ async function readVersion() {
62
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
63
+ try {
64
+ const raw = await readFile(pkgPath, "utf8");
65
+ return JSON.parse(raw).version ?? "unknown";
66
+ } catch {
67
+ return "unknown";
68
+ }
69
+ }
70
+ /** Best-effort: a browser that won't open is a convenience missed, not a failure. */
71
+ function openInBrowser(url) {
72
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
73
+ try {
74
+ const child = spawn(command, [url], {
75
+ stdio: "ignore",
76
+ detached: true,
77
+ shell: process.platform === "win32"
78
+ });
79
+ child.on("error", () => {});
80
+ child.unref();
81
+ } catch {}
82
+ }
83
+ async function main(argv) {
84
+ if (argv[0] === "guard") {
85
+ const { runGuard } = await import("./guard-D3VN855w.mjs").then((n) => n.t);
86
+ return await runGuard(argv.slice(1));
87
+ }
88
+ const flags = parseArgs(argv);
89
+ if (flags.help) {
90
+ process.stdout.write(HELP);
91
+ return 0;
92
+ }
93
+ if (flags.version) {
94
+ process.stdout.write(`${await readVersion()}\n`);
95
+ return 0;
96
+ }
97
+ const config = resolveInstanceConfig(flags, await loadConfigFile(flags.config));
98
+ const instance = await startInstance(config);
99
+ if (config.open) openInBrowser(instance.url);
100
+ const shutdown = (signal) => {
101
+ process.stdout.write(`\n[workerdeck] ${signal} — shutting down\n`);
102
+ instance.close().then(() => process.exit(0)).catch(() => process.exit(1));
103
+ };
104
+ process.on("SIGINT", () => shutdown("SIGINT"));
105
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
106
+ await instance.closed;
107
+ return 0;
108
+ }
109
+ main(process.argv.slice(2)).then((code) => {
110
+ if (code !== 0) process.exit(code);
111
+ }).catch((error) => {
112
+ if (error instanceof ConfigError) {
113
+ process.stderr.write(`[workerdeck] ${error.message}\n`);
114
+ process.exit(2);
115
+ }
116
+ process.stderr.write(`[workerdeck] ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
117
+ process.exit(1);
118
+ });
119
+ //#endregion
120
+ export {};
121
+
122
+ //# sourceMappingURL=cli.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { spawn } from 'node:child_process'\nimport { readFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { ConfigError, loadConfigFile, parseArgs, resolveInstanceConfig } from './config.ts'\nimport { startInstance } from './instance.ts'\n\nconst HELP = `workerdeck — run a workerdeck instance: session gateway + dashboard, one port.\n\nUsage\n workerdeck [options]\n workerdeck guard [options] check whether it is safe to restart an instance\n\nOptions\n -p, --port <n> port to listen on (default 8787, WORKERDECK_PORT)\n --host <addr> interface to bind (default 127.0.0.1, WORKERDECK_HOST)\n --auth-key <secret> shared secret, min 12 chars; browsers log in with it,\n services send it as x-workerdeck-key\n (WORKERDECK_AUTH_KEY). Unset = no auth on loopback;\n on any other interface a key is generated instead,\n printed once, and stored in <state-dir>/auth-key for\n later starts to reuse.\n --trust-proxy trust x-forwarded-proto/-host/-for from one reverse\n proxy. Required behind TLS termination, or the session\n cookie loses its Secure flag and the origin check\n computes http:// where the browser says https://.\n --allowed-origin <o> extra origin accepted on browser requests, for when a\n proxy rewrites Host (repeatable)\n --allowed-host <name> extra Host header accepted when running without auth\n (repeatable; loopback names are always accepted)\n --insecure-host <name>\n bind host that may serve without auth — no key demanded,\n none generated — and, while unauthenticated, also\n accepted as a Host header (repeatable; config:\n insecureHosts). Names the host alone, no port.\n --profile <name=dir> Claude config dir a session may run under (repeatable)\n --cwd-root <path> restrict session cwds to this root (repeatable,\n WORKERDECK_CWD_ROOTS as a ':'-separated list)\n --state-dir <path> where parked sessions are persisted\n (default: beside the config file, else ~/.workerdeck)\n --no-parking-store keep parked sessions in memory only; a restart drops them\n -c, --config <path> config file (default: ./workerdeck.config.mjs)\n --insecure allow no-auth on a non-loopback address. Only when\n something in front is doing the authenticating.\n --open open the dashboard in a browser once it is up\n -h, --help show this\n -v, --version print the version\n\nConfig file\n Options that cannot fit on a command line — \\`authenticate\\`, \\`buildRunnerConfig\\`,\n \\`createEngineRunner\\` are functions — live in workerdeck.config.mjs, which\n default-exports the createWorkerServer options (or a function returning them).\n Flags and env override it. Supplying your own \\`authenticate\\` turns the built-in\n shared-secret auth off entirely.\n\nCredentials\n workerdeck implements no Anthropic auth of its own: the official SDK/CLI\n resolves credentials from the environment, per profile. --auth-key protects this\n gateway, nothing else.\n`\n\nasync function readVersion(): Promise<string> {\n // src/cli.ts and build/cli.mjs are both one level under the package root.\n const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')\n try {\n const raw = await readFile(pkgPath, 'utf8')\n return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'\n } catch {\n return 'unknown'\n }\n}\n\n/** Best-effort: a browser that won't open is a convenience missed, not a failure. */\nfunction openInBrowser(url: string): void {\n const command =\n process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'\n try {\n const child = spawn(command, [url], {\n stdio: 'ignore',\n detached: true,\n shell: process.platform === 'win32',\n })\n child.on('error', () => {})\n child.unref()\n } catch {\n // ignored\n }\n}\n\nasync function main(argv: string[]): Promise<number> {\n if (argv[0] === 'guard') {\n const { runGuard } = await import('./guard.ts')\n return await runGuard(argv.slice(1))\n }\n\n const flags = parseArgs(argv)\n if (flags.help) {\n process.stdout.write(HELP)\n return 0\n }\n if (flags.version) {\n process.stdout.write(`${await readVersion()}\\n`)\n return 0\n }\n\n const loaded = await loadConfigFile(flags.config)\n const config = resolveInstanceConfig(flags, loaded)\n const instance = await startInstance(config)\n\n if (config.open) openInBrowser(instance.url)\n\n const shutdown = (signal: string): void => {\n process.stdout.write(`\\n[workerdeck] ${signal} — shutting down\\n`)\n // Parked sessions are already on disk; this is about letting in-flight\n // requests finish rather than dropping sockets on the floor.\n instance\n .close()\n .then(() => process.exit(0))\n .catch(() => process.exit(1))\n }\n process.on('SIGINT', () => shutdown('SIGINT'))\n process.on('SIGTERM', () => shutdown('SIGTERM'))\n\n // Resolves only on close; the process stays up serving.\n await instance.closed\n return 0\n}\n\nmain(process.argv.slice(2))\n .then((code) => {\n if (code !== 0) process.exit(code)\n })\n .catch((error: unknown) => {\n if (error instanceof ConfigError) {\n process.stderr.write(`[workerdeck] ${error.message}\\n`)\n process.exit(2)\n }\n process.stderr.write(\n `[workerdeck] ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n )\n process.exit(1)\n })\n"],"mappings":";;;;;;;AAQA,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDb,eAAe,cAA+B;CAE5C,MAAM,UAAU,KAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC,EAAE,MAAM,eAAe;AACnF,KAAI;EACF,MAAM,MAAM,MAAM,SAAS,SAAS,OAAO;AAC3C,SAAQ,KAAK,MAAM,IAAI,CAA0B,WAAW;SACtD;AACN,SAAO;;;;AAKX,SAAS,cAAc,KAAmB;CACxC,MAAM,UACJ,QAAQ,aAAa,WAAW,SAAS,QAAQ,aAAa,UAAU,UAAU;AACpF,KAAI;EACF,MAAM,QAAQ,MAAM,SAAS,CAAC,IAAI,EAAE;GAClC,OAAO;GACP,UAAU;GACV,OAAO,QAAQ,aAAa;GAC7B,CAAC;AACF,QAAM,GAAG,eAAe,GAAG;AAC3B,QAAM,OAAO;SACP;;AAKV,eAAe,KAAK,MAAiC;AACnD,KAAI,KAAK,OAAO,SAAS;EACvB,MAAM,EAAE,aAAa,MAAM,OAAO,wBAAA,MAAA,MAAA,EAAA,EAAA;AAClC,SAAO,MAAM,SAAS,KAAK,MAAM,EAAE,CAAC;;CAGtC,MAAM,QAAQ,UAAU,KAAK;AAC7B,KAAI,MAAM,MAAM;AACd,UAAQ,OAAO,MAAM,KAAK;AAC1B,SAAO;;AAET,KAAI,MAAM,SAAS;AACjB,UAAQ,OAAO,MAAM,GAAG,MAAM,aAAa,CAAC,IAAI;AAChD,SAAO;;CAIT,MAAM,SAAS,sBAAsB,OAAO,MADvB,eAAe,MAAM,OAAO,CACE;CACnD,MAAM,WAAW,MAAM,cAAc,OAAO;AAE5C,KAAI,OAAO,KAAM,eAAc,SAAS,IAAI;CAE5C,MAAM,YAAY,WAAyB;AACzC,UAAQ,OAAO,MAAM,kBAAkB,OAAO,oBAAoB;AAGlE,WACG,OAAO,CACP,WAAW,QAAQ,KAAK,EAAE,CAAC,CAC3B,YAAY,QAAQ,KAAK,EAAE,CAAC;;AAEjC,SAAQ,GAAG,gBAAgB,SAAS,SAAS,CAAC;AAC9C,SAAQ,GAAG,iBAAiB,SAAS,UAAU,CAAC;AAGhD,OAAM,SAAS;AACf,QAAO;;AAGT,KAAK,QAAQ,KAAK,MAAM,EAAE,CAAC,CACxB,MAAM,SAAS;AACd,KAAI,SAAS,EAAG,SAAQ,KAAK,KAAK;EAClC,CACD,OAAO,UAAmB;AACzB,KAAI,iBAAiB,aAAa;AAChC,UAAQ,OAAO,MAAM,gBAAgB,MAAM,QAAQ,IAAI;AACvD,UAAQ,KAAK,EAAE;;AAEjB,SAAQ,OAAO,MACb,gBAAgB,iBAAiB,QAAS,MAAM,SAAS,MAAM,UAAW,OAAO,MAAM,CAAC,IACzF;AACD,SAAQ,KAAK,EAAE;EACf"}
@@ -0,0 +1,241 @@
1
+ import { parseArgs } from "node:util";
2
+ //#region \0rolldown/runtime.js
3
+ var __defProp = Object.defineProperty;
4
+ var __exportAll = (all, no_symbols) => {
5
+ let target = {};
6
+ for (var name in all) __defProp(target, name, {
7
+ get: all[name],
8
+ enumerable: true
9
+ });
10
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
11
+ return target;
12
+ };
13
+ //#endregion
14
+ //#region src/guard.ts
15
+ var guard_exports = /* @__PURE__ */ __exportAll({ runGuard: () => runGuard });
16
+ /**
17
+ * Restart guard for a deploy: ask a running instance whether anything would be
18
+ * lost by stopping it, and exit non-zero while the answer is yes.
19
+ *
20
+ * workerdeck guard --wait 300 --allow-parked && launchctl kickstart -k …
21
+ *
22
+ * What a restart costs, and why this is policy and not a server route: an
23
+ * in-flight turn dies with the process (the CLI subprocess and the provider
24
+ * request both go), a pending permission request dies with it, and a running job
25
+ * is left claimed. Two more depend on configuration rather than on state, so each
26
+ * has an opt-out flag that says "I made that durable":
27
+ *
28
+ * --allow-parked parked sessions survive with a durable SessionStore, not
29
+ * with the in-memory default. Note this covers the *session*:
30
+ * a parked job's queue-side record lives in the QueueAdapter,
31
+ * so with the bundled in-memory adapter the woken session
32
+ * completes with no job attached to finish.
33
+ * --allow-queued queued jobs survive only in a durable QueueAdapter; the
34
+ * bundled one loses them with the process.
35
+ *
36
+ * Exit codes: 0 safe to restart, 1 still busy, 2 could not tell (bad URL, auth,
37
+ * or an unexpected response — never treated as safe).
38
+ */
39
+ const BUSY_STATUSES = new Set([
40
+ "starting",
41
+ "running",
42
+ "awaiting_approval"
43
+ ]);
44
+ const HELP = `usage: workerdeck guard [--url URL] [--token TOKEN] [--header name=value]
45
+ [--wait SECONDS] [--interval SECONDS]
46
+ [--allow-parked] [--allow-queued] [--json]
47
+
48
+ exit 0 = safe to restart, 1 = busy, 2 = could not tell
49
+ `;
50
+ var GuardError = class extends Error {};
51
+ async function runGuard(argv) {
52
+ let values;
53
+ try {
54
+ values = parseArgs({
55
+ args: argv,
56
+ options: {
57
+ url: {
58
+ type: "string",
59
+ default: process.env.WORKERDECK_URL ?? "http://127.0.0.1:8787/v1"
60
+ },
61
+ token: {
62
+ type: "string",
63
+ default: process.env.WORKERDECK_TOKEN
64
+ },
65
+ header: {
66
+ type: "string",
67
+ multiple: true,
68
+ default: []
69
+ },
70
+ wait: {
71
+ type: "string",
72
+ default: "0"
73
+ },
74
+ interval: {
75
+ type: "string",
76
+ default: "5"
77
+ },
78
+ "allow-parked": {
79
+ type: "boolean",
80
+ default: false
81
+ },
82
+ "allow-queued": {
83
+ type: "boolean",
84
+ default: false
85
+ },
86
+ json: {
87
+ type: "boolean",
88
+ default: false
89
+ },
90
+ help: {
91
+ type: "boolean",
92
+ default: false
93
+ }
94
+ }
95
+ }).values;
96
+ } catch (error) {
97
+ process.stderr.write(`guard: ${error instanceof Error ? error.message : String(error)}\n`);
98
+ return 2;
99
+ }
100
+ if (values.help) {
101
+ process.stdout.write(HELP);
102
+ return 0;
103
+ }
104
+ const base = values.url.replace(/\/$/, "");
105
+ const headers = { accept: "application/json" };
106
+ if (values.token) headers.authorization = `Bearer ${values.token}`;
107
+ const seconds = (flag, raw) => {
108
+ const value = Number(raw);
109
+ if (!Number.isFinite(value) || value < 0) throw new GuardError(`${flag} must be a non-negative number of seconds`);
110
+ return value;
111
+ };
112
+ let waitMs;
113
+ let intervalMs;
114
+ try {
115
+ for (const entry of values.header) {
116
+ const at = entry.indexOf("=");
117
+ if (at < 1) throw new GuardError(`--header must be name=value, got '${entry}'`);
118
+ headers[entry.slice(0, at).trim()] = entry.slice(at + 1).trim();
119
+ }
120
+ waitMs = seconds("--wait", values.wait) * 1e3;
121
+ intervalMs = Math.max(seconds("--interval", values.interval), 1) * 1e3;
122
+ } catch (error) {
123
+ process.stderr.write(`guard: ${error instanceof Error ? error.message : String(error)}\n`);
124
+ return 2;
125
+ }
126
+ const get = async (path) => {
127
+ let res;
128
+ try {
129
+ res = await fetch(base + path, { headers });
130
+ } catch (error) {
131
+ if (error?.cause?.code === "ECONNREFUSED") return {
132
+ ok: false,
133
+ status: "unreachable"
134
+ };
135
+ return {
136
+ ok: false,
137
+ detail: String(error instanceof Error ? error.message : error)
138
+ };
139
+ }
140
+ if (!res.ok) return {
141
+ ok: false,
142
+ code: res.status,
143
+ detail: `HTTP ${res.status}`
144
+ };
145
+ try {
146
+ return {
147
+ ok: true,
148
+ body: await res.json()
149
+ };
150
+ } catch {
151
+ return {
152
+ ok: false,
153
+ code: res.status,
154
+ detail: "response was not JSON"
155
+ };
156
+ }
157
+ };
158
+ /** One look at the server. Returns the reasons a restart would cost something. */
159
+ const inspect = async () => {
160
+ const sessions = await get("/sessions");
161
+ if (!sessions.ok && "status" in sessions) return {
162
+ unreachable: true,
163
+ reasons: [],
164
+ notes: []
165
+ };
166
+ if (!sessions.ok) return {
167
+ error: `GET ${base}/sessions → ${sessions.detail}`,
168
+ reasons: [],
169
+ notes: []
170
+ };
171
+ const listed = sessions.body.sessions;
172
+ if (!Array.isArray(listed)) return {
173
+ error: `GET ${base}/sessions returned no session list`,
174
+ reasons: [],
175
+ notes: []
176
+ };
177
+ const all = listed;
178
+ const reasons = [];
179
+ /** Worth saying out loud, but not worth blocking a deploy over. */
180
+ const notes = [];
181
+ for (const session of all.filter((s) => BUSY_STATUSES.has(s.status))) reasons.push(`session ${session.id} is ${session.status}`);
182
+ const parked = all.filter((s) => s.status === "parked");
183
+ if (parked.length > 0 && !values["allow-parked"]) reasons.push(`${parked.length} parked session(s) — pass --allow-parked once the server runs a durable SessionStore, or they are lost on restart`);
184
+ const queue = await get("/queue");
185
+ if (!queue.ok && !("status" in queue) && queue.code !== 404) return {
186
+ error: `GET ${base}/queue → ${queue.detail}`,
187
+ reasons: [],
188
+ notes: []
189
+ };
190
+ const stats = queue.ok ? queue.body.stats : void 0;
191
+ if ((stats?.running ?? 0) > 0) reasons.push(`${stats.running} job(s) running`);
192
+ if ((stats?.queued ?? 0) > 0 && !values["allow-queued"]) reasons.push(`${stats.queued} job(s) queued — pass --allow-queued once the server runs a durable QueueAdapter, or they are lost on restart`);
193
+ if ((stats?.parked ?? 0) > 0 && values["allow-parked"]) notes.push(`${stats.parked} parked job(s): their queue-side records are the QueueAdapter's, not the SessionStore's — with the in-memory adapter they never finish`);
194
+ return {
195
+ reasons,
196
+ notes,
197
+ sessions: all.length,
198
+ parked: parked.length
199
+ };
200
+ };
201
+ const report = (verdict, detail) => {
202
+ if (values.json) {
203
+ process.stdout.write(`${JSON.stringify({
204
+ verdict,
205
+ ...detail
206
+ })}\n`);
207
+ return;
208
+ }
209
+ const lines = [...detail.reasons.map((reason) => ` - ${reason}`), ...detail.notes.map((note) => ` note: ${note}`)].join("\n");
210
+ process.stdout.write(`guard: ${verdict}${lines ? `\n${lines}` : ""}\n`);
211
+ };
212
+ const deadline = Date.now() + waitMs;
213
+ for (;;) {
214
+ const result = await inspect();
215
+ if (result.error) {
216
+ report("unknown", {
217
+ ...result,
218
+ reasons: [result.error]
219
+ });
220
+ return 2;
221
+ }
222
+ if (result.unreachable) {
223
+ report("safe to restart (nothing listening on the given URL)", result);
224
+ return 0;
225
+ }
226
+ if (result.reasons.length === 0) {
227
+ report("safe to restart", result);
228
+ return 0;
229
+ }
230
+ if (Date.now() + intervalMs > deadline) {
231
+ report("busy — not safe to restart", result);
232
+ return 1;
233
+ }
234
+ report(`busy, waiting up to ${Math.round((deadline - Date.now()) / 1e3)}s`, result);
235
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
236
+ }
237
+ }
238
+ //#endregion
239
+ export { runGuard as n, guard_exports as t };
240
+
241
+ //# sourceMappingURL=guard-D3VN855w.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard-D3VN855w.mjs","names":[],"sources":["../src/guard.ts"],"sourcesContent":["import { parseArgs } from 'node:util'\n\n/**\n * Restart guard for a deploy: ask a running instance whether anything would be\n * lost by stopping it, and exit non-zero while the answer is yes.\n *\n * workerdeck guard --wait 300 --allow-parked && launchctl kickstart -k …\n *\n * What a restart costs, and why this is policy and not a server route: an\n * in-flight turn dies with the process (the CLI subprocess and the provider\n * request both go), a pending permission request dies with it, and a running job\n * is left claimed. Two more depend on configuration rather than on state, so each\n * has an opt-out flag that says \"I made that durable\":\n *\n * --allow-parked parked sessions survive with a durable SessionStore, not\n * with the in-memory default. Note this covers the *session*:\n * a parked job's queue-side record lives in the QueueAdapter,\n * so with the bundled in-memory adapter the woken session\n * completes with no job attached to finish.\n * --allow-queued queued jobs survive only in a durable QueueAdapter; the\n * bundled one loses them with the process.\n *\n * Exit codes: 0 safe to restart, 1 still busy, 2 could not tell (bad URL, auth,\n * or an unexpected response — never treated as safe).\n */\n\nconst BUSY_STATUSES = new Set(['starting', 'running', 'awaiting_approval'])\n\nconst HELP = `usage: workerdeck guard [--url URL] [--token TOKEN] [--header name=value]\n [--wait SECONDS] [--interval SECONDS]\n [--allow-parked] [--allow-queued] [--json]\n\nexit 0 = safe to restart, 1 = busy, 2 = could not tell\n`\n\ntype GetResult =\n | { ok: true; body: Record<string, unknown> }\n | { ok: false; status: 'unreachable' }\n | { ok: false; code?: number; detail: string }\n\ntype Verdict = {\n reasons: string[]\n notes: string[]\n unreachable?: boolean\n error?: string\n sessions?: number\n parked?: number\n}\n\nclass GuardError extends Error {}\n\nexport async function runGuard(argv: string[]): Promise<number> {\n let values: {\n url: string\n token?: string\n header: string[]\n wait: string\n interval: string\n 'allow-parked': boolean\n 'allow-queued': boolean\n json: boolean\n help: boolean\n }\n try {\n values = parseArgs({\n args: argv,\n options: {\n url: {\n type: 'string',\n default: process.env.WORKERDECK_URL ?? 'http://127.0.0.1:8787/v1',\n },\n token: { type: 'string', default: process.env.WORKERDECK_TOKEN },\n header: { type: 'string', multiple: true, default: [] },\n wait: { type: 'string', default: '0' },\n interval: { type: 'string', default: '5' },\n 'allow-parked': { type: 'boolean', default: false },\n 'allow-queued': { type: 'boolean', default: false },\n json: { type: 'boolean', default: false },\n help: { type: 'boolean', default: false },\n },\n }).values as typeof values\n } catch (error) {\n process.stderr.write(`guard: ${error instanceof Error ? error.message : String(error)}\\n`)\n return 2\n }\n\n if (values.help) {\n process.stdout.write(HELP)\n return 0\n }\n\n const base = values.url.replace(/\\/$/, '')\n const headers: Record<string, string> = { accept: 'application/json' }\n // The token covers the common `Authorization: Bearer` case, including this\n // CLI's own --auth-key; --header covers hosts whose `authenticate` hook reads\n // something else.\n if (values.token) headers.authorization = `Bearer ${values.token}`\n\n const seconds = (flag: string, raw: string): number => {\n const value = Number(raw)\n if (!Number.isFinite(value) || value < 0) {\n throw new GuardError(`${flag} must be a non-negative number of seconds`)\n }\n return value\n }\n\n let waitMs: number\n let intervalMs: number\n try {\n for (const entry of values.header) {\n const at = entry.indexOf('=')\n if (at < 1) throw new GuardError(`--header must be name=value, got '${entry}'`)\n headers[entry.slice(0, at).trim()] = entry.slice(at + 1).trim()\n }\n waitMs = seconds('--wait', values.wait) * 1000\n intervalMs = Math.max(seconds('--interval', values.interval), 1) * 1000\n } catch (error) {\n process.stderr.write(`guard: ${error instanceof Error ? error.message : String(error)}\\n`)\n return 2\n }\n\n const get = async (path: string): Promise<GetResult> => {\n let res: Response\n try {\n res = await fetch(base + path, { headers })\n } catch (error) {\n // Nothing listening on the URL we were pointed at: there is no session to lose.\n const code = (error as { cause?: { code?: string } })?.cause?.code\n if (code === 'ECONNREFUSED') return { ok: false, status: 'unreachable' }\n return { ok: false, detail: String(error instanceof Error ? error.message : error) }\n }\n if (!res.ok) return { ok: false, code: res.status, detail: `HTTP ${res.status}` }\n try {\n return { ok: true, body: (await res.json()) as Record<string, unknown> }\n } catch {\n return { ok: false, code: res.status, detail: 'response was not JSON' }\n }\n }\n\n /** One look at the server. Returns the reasons a restart would cost something. */\n const inspect = async (): Promise<Verdict> => {\n const sessions = await get('/sessions')\n if (!sessions.ok && 'status' in sessions) return { unreachable: true, reasons: [], notes: [] }\n if (!sessions.ok) return { error: `GET ${base}/sessions → ${sessions.detail}`, reasons: [], notes: [] }\n const listed = sessions.body.sessions\n if (!Array.isArray(listed)) {\n return { error: `GET ${base}/sessions returned no session list`, reasons: [], notes: [] }\n }\n const all = listed as { id: string; status: string }[]\n\n const reasons: string[] = []\n /** Worth saying out loud, but not worth blocking a deploy over. */\n const notes: string[] = []\n for (const session of all.filter((s) => BUSY_STATUSES.has(s.status))) {\n reasons.push(`session ${session.id} is ${session.status}`)\n }\n const parked = all.filter((s) => s.status === 'parked')\n if (parked.length > 0 && !values['allow-parked']) {\n reasons.push(\n `${parked.length} parked session(s) — pass --allow-parked once the server ` +\n 'runs a durable SessionStore, or they are lost on restart',\n )\n }\n\n // A queue is optional: 404 here means this server declares none.\n const queue = await get('/queue')\n if (!queue.ok && !('status' in queue) && queue.code !== 404) {\n return { error: `GET ${base}/queue → ${queue.detail}`, reasons: [], notes: [] }\n }\n const stats = queue.ok\n ? (queue.body.stats as { running?: number; queued?: number; parked?: number } | undefined)\n : undefined\n if ((stats?.running ?? 0) > 0) reasons.push(`${stats!.running} job(s) running`)\n if ((stats?.queued ?? 0) > 0 && !values['allow-queued']) {\n reasons.push(\n `${stats!.queued} job(s) queued — pass --allow-queued once the server runs a ` +\n 'durable QueueAdapter, or they are lost on restart',\n )\n }\n if ((stats?.parked ?? 0) > 0 && values['allow-parked']) {\n // Not a blocking reason: the operator has already said parks are durable.\n // But session durability is not job durability, and only one of the two is theirs.\n notes.push(\n `${stats!.parked} parked job(s): their queue-side records are the QueueAdapter's, ` +\n \"not the SessionStore's — with the in-memory adapter they never finish\",\n )\n }\n return { reasons, notes, sessions: all.length, parked: parked.length }\n }\n\n const report = (verdict: string, detail: Verdict): void => {\n if (values.json) {\n process.stdout.write(`${JSON.stringify({ verdict, ...detail })}\\n`)\n return\n }\n const lines = [\n ...detail.reasons.map((reason) => ` - ${reason}`),\n ...detail.notes.map((note) => ` note: ${note}`),\n ].join('\\n')\n process.stdout.write(`guard: ${verdict}${lines ? `\\n${lines}` : ''}\\n`)\n }\n\n const deadline = Date.now() + waitMs\n for (;;) {\n const result = await inspect()\n if (result.error) {\n report('unknown', { ...result, reasons: [result.error] })\n return 2\n }\n if (result.unreachable) {\n report('safe to restart (nothing listening on the given URL)', result)\n return 0\n }\n if (result.reasons.length === 0) {\n report('safe to restart', result)\n return 0\n }\n if (Date.now() + intervalMs > deadline) {\n report('busy — not safe to restart', result)\n return 1\n }\n report(`busy, waiting up to ${Math.round((deadline - Date.now()) / 1000)}s`, result)\n await new Promise((resolve) => setTimeout(resolve, intervalMs))\n }\n}\n"],"mappings":"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"}