@gentbajko/slopify 1.2.1 → 1.4.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 +15 -8
- package/dist/adapter-registry.js +14 -2
- package/dist/adapters/host-cli/index.js +192 -0
- package/dist/adapters/host-cli/transport.js +135 -0
- package/dist/adapters/image/codex.js +239 -0
- package/dist/adapters/llm/claude-code-models.js +1 -0
- package/dist/adapters/llm/claude-code.js +7 -0
- package/dist/adapters/llm/cli-login-error.js +16 -0
- package/dist/adapters/llm/codex.js +10 -0
- package/dist/catalog/validate.js +3 -1
- package/dist/edge/cli.js +36 -0
- package/dist/edge/docker.js +109 -0
- package/dist/edge/host-cli.js +58 -0
- package/dist/edge/http/actions.js +1 -1
- package/dist/edge/http/diagnostics.js +5 -1
- package/dist/edge/http/host-cli.js +247 -0
- package/dist/edge/http/providers.js +5 -1
- package/dist/host-cli/environment.js +25 -0
- package/dist/host-cli/install.js +61 -0
- package/dist/host-cli/paths.js +68 -0
- package/dist/host-cli/runtime.js +78 -0
- package/dist/host-cli/server.js +98 -0
- package/dist/host-cli/service.js +200 -0
- package/dist/host-cli/status.js +134 -0
- package/dist/kernel/db/migrations/0011-narration-prompts.sql +13 -0
- package/dist/kernel/ports/host-cli.js +95 -0
- package/dist/kernel/ports/model.js +1 -0
- package/dist/kernel/runner/attempt.js +10 -1
- package/dist/main.js +7 -1
- package/dist/slices/admission/rules.js +17 -1
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/estimate/index.js +32 -1
- package/dist/slices/estimate/requests.js +8 -0
- package/dist/slices/library/model.js +2 -3
- package/dist/slices/library/slots.js +3 -1
- package/dist/slices/narration/preparation.js +75 -0
- package/dist/slices/narration/steering.js +89 -0
- package/dist/slices/play-drafts/convert.js +3 -0
- package/dist/slices/play-drafts/readiness.js +3 -1
- package/dist/slices/play-drafts/schema.js +1 -0
- package/dist/slices/project-templates/from-project.js +2 -0
- package/dist/slices/project-templates/setup.js +1 -0
- package/dist/slices/rebuild/admission-repo.js +2 -2
- package/dist/slices/rebuild/preview-details.js +12 -9
- package/dist/slices/rebuild/preview-plan.js +7 -1
- package/dist/slices/rebuild/recipe-audio-parts.js +89 -0
- package/dist/slices/rebuild/recipe-audio.js +52 -66
- package/dist/slices/rebuild/recipe-input-schema.js +11 -0
- package/dist/slices/rebuild/recipe-legacy.js +5 -0
- package/dist/slices/rebuild/recipe-model.js +3 -0
- package/dist/slices/rebuild/recipe-narration-text.js +33 -0
- package/dist/slices/rebuild/recipe-preparation.js +66 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +7 -5
- package/dist/slices/rebuild/recipe-validation.js +9 -1
- package/dist/slices/rebuild/recipe-work.js +13 -1
- package/dist/slices/rebuild/runtime-admission.js +11 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +4 -0
- package/dist/slices/rebuild/runtime-local.js +5 -0
- package/dist/slices/rebuild/runtime-materialize.js +2 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +1 -0
- package/dist/slices/rebuild/runtime-narration-text.js +75 -0
- package/dist/slices/rebuild/runtime-provider.js +30 -1
- package/dist/slices/rebuild/runtime-publication.js +13 -1
- package/dist/slices/rebuild/runtime-store.js +13 -2
- package/dist/slices/revisions/rules.js +5 -3
- package/dist/slices/settings/cli-paths.js +15 -6
- package/dist/slices/settings/cli-status.js +3 -1
- package/dist/slices/settings/model.js +9 -0
- package/dist/slices/settings/readiness.js +24 -3
- package/dist/slices/storage/asset-name.js +3 -0
- package/dist/slices/storage/layout.js +4 -0
- package/dist/slices/storage/model.js +2 -0
- package/dist/slices/storage/schema.js +1 -0
- package/dist/web/assets/index-DV1LKwBP.js +137 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/scripts/docker-run.sh +8 -45
- package/dist/web/assets/index-Cq2O1JRf.js +0 -137
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { redact } from "../../kernel/log.js";
|
|
3
3
|
import { providerError } from "../../kernel/ports/model.js";
|
|
4
4
|
import { nodeClaudeCodeModels } from "./claude-code-models.js";
|
|
5
|
+
import { cliLoginError } from "./cli-login-error.js";
|
|
5
6
|
import { cliEvent, cliShaped, endedWithout, promptOf, stopCliRun } from "./run-cli.js";
|
|
6
7
|
import { lines } from "./sse-lines.js";
|
|
7
8
|
// The local-agent adapter for Claude Code: spawned non-interactively, authenticated by the
|
|
@@ -95,6 +96,9 @@ export function claudeCodeLlm(deps) {
|
|
|
95
96
|
}
|
|
96
97
|
const result = cliShaped(binary, resultEvent, event.value);
|
|
97
98
|
if (result.is_error === true || result.subtype !== "success") {
|
|
99
|
+
const login = cliLoginError("claude-code", result.result ?? result.subtype);
|
|
100
|
+
if (login)
|
|
101
|
+
throw login;
|
|
98
102
|
throw providerError({
|
|
99
103
|
kind: kindOf(result.api_error_status ?? null),
|
|
100
104
|
message: redact(result.result ?? result.subtype),
|
|
@@ -124,6 +128,9 @@ export function claudeCodeLlm(deps) {
|
|
|
124
128
|
// be reported as the provider failing.
|
|
125
129
|
req.signal.throwIfAborted();
|
|
126
130
|
// The stream ended with no result event at all.
|
|
131
|
+
const login = cliLoginError("claude-code", run.stderr());
|
|
132
|
+
if (login)
|
|
133
|
+
throw login;
|
|
127
134
|
throw providerError({
|
|
128
135
|
kind: "other",
|
|
129
136
|
message: ended === undefined
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
2
|
+
export function cliLoginError(provider, text) {
|
|
3
|
+
if (/invalid api key/i.test(text))
|
|
4
|
+
return undefined;
|
|
5
|
+
if (!/not logged in|please (?:run|use)\s+\/login|run\s+codex\s+login|authentication required|(?:access|refresh) token.*(?:expired|could not be refreshed|already used)/i.test(text))
|
|
6
|
+
return undefined;
|
|
7
|
+
const command = provider === "claude-code"
|
|
8
|
+
? "claude auth login"
|
|
9
|
+
: provider === "gemini"
|
|
10
|
+
? "gemini"
|
|
11
|
+
: "codex login";
|
|
12
|
+
return providerError({
|
|
13
|
+
kind: "missing_key",
|
|
14
|
+
message: `${provider}: sign in on the machine running the CLI with ${command}, then review the affected rebuild.`,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { redact } from "../../kernel/log.js";
|
|
6
6
|
import { providerError } from "../../kernel/ports/model.js";
|
|
7
|
+
import { cliLoginError } from "./cli-login-error.js";
|
|
7
8
|
import { nodeCodexModels } from "./codex-models.js";
|
|
8
9
|
import { cliEvent, cliShaped, endedWithout, promptOf, stopCliRun } from "./run-cli.js";
|
|
9
10
|
import { lines } from "./sse-lines.js";
|
|
@@ -137,12 +138,18 @@ export function codexLlm(deps) {
|
|
|
137
138
|
return;
|
|
138
139
|
}
|
|
139
140
|
if (event.type === "turn.failed") {
|
|
141
|
+
const login = cliLoginError("codex", cliShaped(binary, turnFailed, event.value).error.message);
|
|
142
|
+
if (login)
|
|
143
|
+
throw login;
|
|
140
144
|
throw providerError({
|
|
141
145
|
kind: "other",
|
|
142
146
|
message: redact(cliShaped(binary, turnFailed, event.value).error.message),
|
|
143
147
|
});
|
|
144
148
|
}
|
|
145
149
|
if (event.type === "error") {
|
|
150
|
+
const login = cliLoginError("codex", cliShaped(binary, errorEvent, event.value).message);
|
|
151
|
+
if (login)
|
|
152
|
+
throw login;
|
|
146
153
|
throw providerError({
|
|
147
154
|
kind: "other",
|
|
148
155
|
message: redact(cliShaped(binary, errorEvent, event.value).message),
|
|
@@ -168,6 +175,9 @@ export function codexLlm(deps) {
|
|
|
168
175
|
// be reported as the provider failing.
|
|
169
176
|
req.signal.throwIfAborted();
|
|
170
177
|
// The stream ended with neither a completed turn nor a failure.
|
|
178
|
+
const login = cliLoginError("codex", run.stderr());
|
|
179
|
+
if (login)
|
|
180
|
+
throw login;
|
|
171
181
|
throw providerError({
|
|
172
182
|
kind: "other",
|
|
173
183
|
message: ended === undefined
|
package/dist/catalog/validate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { usesNarrationPreparation } from "../slices/admission/rules.js";
|
|
1
2
|
import { isLocalCliProvider } from "../slices/settings/model.js";
|
|
2
3
|
export function modelFields(draft, catalogue) {
|
|
3
4
|
if (!catalogue)
|
|
@@ -7,7 +8,8 @@ export function modelFields(draft, catalogue) {
|
|
|
7
8
|
draft.sources.article === "generate" ||
|
|
8
9
|
draft.sources.thumbnail === "prompt_by_llm" ||
|
|
9
10
|
draft.intro?.mode === "llm" ||
|
|
10
|
-
draft.outro?.mode === "llm"
|
|
11
|
+
draft.outro?.mode === "llm" ||
|
|
12
|
+
usesNarrationPreparation(draft);
|
|
11
13
|
const checks = [
|
|
12
14
|
{ field: "llm", family: "llm", choice: draft.llm, needed: needLlm },
|
|
13
15
|
{
|
package/dist/edge/cli.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
3
6
|
import { fileURLToPath } from "node:url";
|
|
4
7
|
import { parseArgs } from "node:util";
|
|
5
8
|
import { configFrom } from "../kernel/config/index.js";
|
|
@@ -15,16 +18,49 @@ const { values } = parseArgs({
|
|
|
15
18
|
"data-dir": { type: "string" },
|
|
16
19
|
"no-open": { type: "boolean" },
|
|
17
20
|
docker: { type: "boolean" },
|
|
21
|
+
"host-cli": { type: "string" },
|
|
22
|
+
"accept-host-cli": { type: "boolean" },
|
|
18
23
|
},
|
|
19
24
|
});
|
|
20
25
|
try {
|
|
26
|
+
if ((values["host-cli"] !== undefined || values["accept-host-cli"] !== undefined) &&
|
|
27
|
+
!values.docker)
|
|
28
|
+
throw new Error("Host CLI options require --docker.");
|
|
29
|
+
if (values["host-cli"] !== undefined && values["host-cli"] !== "off")
|
|
30
|
+
throw new Error("The --host-cli override supports only off (API-only Docker).");
|
|
21
31
|
if (values.docker) {
|
|
22
32
|
if (values.host !== undefined || values["data-dir"] !== undefined)
|
|
23
33
|
throw new Error("Docker stores data in the slopify-data volume and binds to localhost. Use --port to change its port.");
|
|
34
|
+
const { prepareDockerHostCli } = await import("./docker.js");
|
|
35
|
+
const { nodeHostSetupRunner } = await import("../host-cli/install.js");
|
|
36
|
+
const bridge = await prepareDockerHostCli({
|
|
37
|
+
root: join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local/share"), "slopify/host-cli"),
|
|
38
|
+
version: readVersion(),
|
|
39
|
+
image: process.env.SLOPIFY_DOCKER_IMAGE ?? "ghcr.io/gentbajko/slopify:latest",
|
|
40
|
+
disabled: values["host-cli"] === "off",
|
|
41
|
+
accepted: values["accept-host-cli"] === true,
|
|
42
|
+
interactive: process.stdin.isTTY === true,
|
|
43
|
+
env: process.env,
|
|
44
|
+
signal: AbortSignal.timeout(20 * 60_000),
|
|
45
|
+
runner: nodeHostSetupRunner,
|
|
46
|
+
prompt: async (message) => {
|
|
47
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
48
|
+
try {
|
|
49
|
+
return /^(?:y|yes)$/i.test((await terminal.question(message)).trim());
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
terminal.close();
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
console.log(bridge.directory
|
|
57
|
+
? "Host CLI helper ready. Existing CLI logins stay on the host."
|
|
58
|
+
: "Starting API-only Docker. Rerun the launcher after installing host CLIs to enable them.");
|
|
24
59
|
const result = spawnSync("bash", [fileURLToPath(new URL("../../scripts/docker-run.sh", import.meta.url))], {
|
|
25
60
|
stdio: "inherit",
|
|
26
61
|
env: {
|
|
27
62
|
...process.env,
|
|
63
|
+
SLOPIFY_HOST_CLI_DIR: bridge.directory ?? "",
|
|
28
64
|
...(values.port === undefined ? {} : { SLOPIFY_DOCKER_HOST_PORT: values.port }),
|
|
29
65
|
},
|
|
30
66
|
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mkdir, rmdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { installHostPackage } from "../host-cli/install.js";
|
|
6
|
+
import { ensureBridgeToken, hasCode, prepareHostPaths } from "../host-cli/paths.js";
|
|
7
|
+
import { ensureHostService, privateRead, privateWrite } from "../host-cli/service.js";
|
|
8
|
+
import { resolveHostCommand } from "../host-cli/status.js";
|
|
9
|
+
import { hostCliProtocol, hostLlmIds } from "../kernel/ports/host-cli.js";
|
|
10
|
+
export async function prepareDockerHostCli(options) {
|
|
11
|
+
if (options.disabled)
|
|
12
|
+
return {};
|
|
13
|
+
let installed = false;
|
|
14
|
+
for (const id of hostLlmIds) {
|
|
15
|
+
try {
|
|
16
|
+
await resolveHostCommand(id, options.env);
|
|
17
|
+
installed = true;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (!hasCode(error, "ENOENT"))
|
|
21
|
+
throw new Error("Host CLI detection failed. No container was changed.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (!installed)
|
|
25
|
+
return {};
|
|
26
|
+
if (process.platform !== "linux")
|
|
27
|
+
throw new Error("Managed host CLI setup requires Linux/systemd. Use --host-cli=off for API-only Docker.");
|
|
28
|
+
const uid = process.getuid?.();
|
|
29
|
+
if (uid === undefined || uid === 0)
|
|
30
|
+
throw new Error("Run the Docker launcher as your logged-in user, not root; or use --host-cli=off.");
|
|
31
|
+
const receiptPath = join(options.root, "consent.json");
|
|
32
|
+
const existing = await privateRead(receiptPath, uid);
|
|
33
|
+
const receipt = z.object({ version: z.literal(1), automaticStartup: z.literal(true) }).strict();
|
|
34
|
+
let approved = options.accepted;
|
|
35
|
+
if (existing !== undefined) {
|
|
36
|
+
try {
|
|
37
|
+
approved ||= receipt.safeParse(JSON.parse(existing)).success;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error("Invalid host helper consent receipt.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!approved) {
|
|
44
|
+
if (!options.interactive)
|
|
45
|
+
throw new Error("Host CLI setup needs confirmation. Use --accept-host-cli to approve, or --host-cli=off for API-only Docker.");
|
|
46
|
+
approved = await options.prompt("Allow Slopify to run your host Claude Code, Codex and Gemini CLIs using their existing logins? This installs a private helper and enables automatic startup. User lingering keeps your user services running after logout and at boot. Credentials stay on the host. [y/N] ");
|
|
47
|
+
if (!approved)
|
|
48
|
+
throw new Error("Host CLI setup declined. The container is unchanged. Use --host-cli=off for API-only Docker.");
|
|
49
|
+
}
|
|
50
|
+
const inspect = () => options.runner.exec("docker", [
|
|
51
|
+
"image",
|
|
52
|
+
"inspect",
|
|
53
|
+
"--format",
|
|
54
|
+
'{{index .Config.Labels "io.slopify.host-cli-protocol"}}',
|
|
55
|
+
options.image,
|
|
56
|
+
], options.signal);
|
|
57
|
+
let image = await inspect();
|
|
58
|
+
if (image.code !== 0 || image.stdout.trim() !== String(hostCliProtocol)) {
|
|
59
|
+
const pulled = await options.runner.exec("docker", ["pull", options.image], options.signal);
|
|
60
|
+
if (pulled.code !== 0)
|
|
61
|
+
throw new Error("Could not pull a compatible Docker image. No helper or container was changed.");
|
|
62
|
+
image = await inspect();
|
|
63
|
+
if (image.code !== 0 || image.stdout.trim() !== String(hostCliProtocol))
|
|
64
|
+
throw new Error("This Docker image does not support the host helper. Update the image before retrying.");
|
|
65
|
+
}
|
|
66
|
+
const supported = await options.runner.exec("systemctl", ["--user", "list-units", "--no-legend", "--no-pager", "slopify-cli-bridge.service"], options.signal);
|
|
67
|
+
if (supported.code !== 0)
|
|
68
|
+
throw new Error("The systemd user service is unavailable. Use a normal Slopify install or --host-cli=off for API-only Docker.");
|
|
69
|
+
const paths = await prepareHostPaths(options.root);
|
|
70
|
+
const lock = join(paths.root, "setup.lock");
|
|
71
|
+
const deadline = Date.now() + 30_000;
|
|
72
|
+
for (;;) {
|
|
73
|
+
try {
|
|
74
|
+
await mkdir(lock, { mode: 0o700 });
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (!hasCode(error, "EEXIST"))
|
|
79
|
+
throw error;
|
|
80
|
+
if (Date.now() >= deadline)
|
|
81
|
+
throw new Error("Another host helper setup holds setup.lock. Wait for it to finish; the container is unchanged.");
|
|
82
|
+
await delay(100, undefined, { signal: options.signal });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
await ensureBridgeToken(paths.tokenFile);
|
|
87
|
+
const installed = await installHostPackage({
|
|
88
|
+
root: paths.root,
|
|
89
|
+
version: options.version,
|
|
90
|
+
runner: options.runner,
|
|
91
|
+
signal: options.signal,
|
|
92
|
+
});
|
|
93
|
+
await ensureHostService({
|
|
94
|
+
root: paths.root,
|
|
95
|
+
entry: installed.entry,
|
|
96
|
+
node: process.execPath,
|
|
97
|
+
uid,
|
|
98
|
+
version: options.version,
|
|
99
|
+
runner: options.runner,
|
|
100
|
+
signal: options.signal,
|
|
101
|
+
env: options.env,
|
|
102
|
+
});
|
|
103
|
+
await privateWrite(receiptPath, JSON.stringify({ version: 1, automaticStartup: true }));
|
|
104
|
+
return { directory: paths.share };
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await rmdir(lock);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { nodeRunCli } from "../adapters/llm/run-cli.js";
|
|
6
|
+
import { hostEnvironmentSchema } from "../host-cli/environment.js";
|
|
7
|
+
import { ensureBridgeToken, prepareHostPaths } from "../host-cli/paths.js";
|
|
8
|
+
import { createHostRuntime } from "../host-cli/runtime.js";
|
|
9
|
+
import { startHostServer } from "../host-cli/server.js";
|
|
10
|
+
import { resolveHostCommand } from "../host-cli/status.js";
|
|
11
|
+
import { readVersion } from "../kernel/version.js";
|
|
12
|
+
import { nodeCliProbe } from "../slices/settings/cli-status.js";
|
|
13
|
+
const { values } = parseArgs({ options: { "state-dir": { type: "string" } }, strict: true });
|
|
14
|
+
if (!values["state-dir"])
|
|
15
|
+
throw new Error("A host helper state directory is required.");
|
|
16
|
+
const paths = await prepareHostPaths(values["state-dir"]);
|
|
17
|
+
const file = await open(join(paths.root, "host-environment.json"), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
18
|
+
try {
|
|
19
|
+
const stat = await file.stat();
|
|
20
|
+
if (!stat.isFile() ||
|
|
21
|
+
stat.size > 64 * 1024 ||
|
|
22
|
+
(stat.mode & 0o077) !== 0 ||
|
|
23
|
+
(process.getuid && stat.uid !== process.getuid()))
|
|
24
|
+
throw new Error("Unsafe host environment file.");
|
|
25
|
+
const buffer = Buffer.alloc(64 * 1024 + 1);
|
|
26
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
27
|
+
if (bytesRead > 64 * 1024)
|
|
28
|
+
throw new Error("Host environment file is too large.");
|
|
29
|
+
const environment = hostEnvironmentSchema.parse(JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")));
|
|
30
|
+
Object.assign(process.env, environment);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await file.close();
|
|
34
|
+
}
|
|
35
|
+
const server = await startHostServer({
|
|
36
|
+
directory: paths.share,
|
|
37
|
+
token: await ensureBridgeToken(paths.tokenFile),
|
|
38
|
+
version: readVersion(),
|
|
39
|
+
ports: createHostRuntime({
|
|
40
|
+
run: nodeRunCli,
|
|
41
|
+
probe: nodeCliProbe,
|
|
42
|
+
env: process.env,
|
|
43
|
+
now: Date.now,
|
|
44
|
+
resolve: (id) => resolveHostCommand(id, process.env),
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
process.on("SIGHUP", server.pauseAdmissions);
|
|
48
|
+
process.on("SIGUSR2", server.resumeAdmissions);
|
|
49
|
+
const stop = () => {
|
|
50
|
+
void server.stop().then(() => {
|
|
51
|
+
process.exitCode = 0;
|
|
52
|
+
}, () => {
|
|
53
|
+
console.error("Host helper shutdown failed.");
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
process.once("SIGINT", stop);
|
|
58
|
+
process.once("SIGTERM", stop);
|
|
@@ -56,7 +56,7 @@ export function actionRoutes(deps) {
|
|
|
56
56
|
...reruns,
|
|
57
57
|
runner: deps.runner,
|
|
58
58
|
emit: (projectId, event) => deps.hub.emit(projectId, event),
|
|
59
|
-
providers: () => providerStatuses({ db: deps.db, probe: deps.probe }),
|
|
59
|
+
providers: () => providerStatuses({ db: deps.db, probe: deps.probe, hostCliStatus: deps.hostCliStatus }),
|
|
60
60
|
allowsCustomModels: deps.catalogue ? () => false : allowsCustomModel,
|
|
61
61
|
modelsFor: deps.modelsFor ?? (() => Promise.reject(new Error("Model catalog unavailable"))),
|
|
62
62
|
};
|
|
@@ -7,7 +7,11 @@ import { providerStatuses } from "../../slices/settings/readiness.js";
|
|
|
7
7
|
*/
|
|
8
8
|
export function diagnosticsRoutes(deps) {
|
|
9
9
|
return new Hono().get("/", async (c) => {
|
|
10
|
-
const providers = await providerStatuses({
|
|
10
|
+
const providers = await providerStatuses({
|
|
11
|
+
db: deps.db,
|
|
12
|
+
probe: deps.probe,
|
|
13
|
+
hostCliStatus: deps.hostCliStatus,
|
|
14
|
+
});
|
|
11
15
|
const projectCount = deps.db.prepare("SELECT count(*) AS count FROM projects").get();
|
|
12
16
|
const schema = deps.db.prepare("SELECT max(version) AS version FROM schema_migrations").get();
|
|
13
17
|
c.header("Cache-Control", "no-store");
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { bodyLimit } from "hono/body-limit";
|
|
4
|
+
import { stream } from "hono/streaming";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { sniffImage } from "../../adapters/image/bytes.js";
|
|
7
|
+
import { redact } from "../../kernel/log.js";
|
|
8
|
+
import { bridgeLimits, hostCliIds, hostCliProtocol, hostFrameSchema, hostImageSchema, hostLlmIds, hostLlmSchema, hostModelsSchema, hostStatusSchema, } from "../../kernel/ports/host-cli.js";
|
|
9
|
+
import { isProviderError, providerError } from "../../kernel/ports/model.js";
|
|
10
|
+
export function hostGate() {
|
|
11
|
+
let accepting = true;
|
|
12
|
+
let stopped = false;
|
|
13
|
+
const generation = new Set();
|
|
14
|
+
const metadata = new Set();
|
|
15
|
+
return {
|
|
16
|
+
get active() {
|
|
17
|
+
return generation.size;
|
|
18
|
+
},
|
|
19
|
+
get accepting() {
|
|
20
|
+
return accepting;
|
|
21
|
+
},
|
|
22
|
+
acquire: (kind) => {
|
|
23
|
+
const set = kind === "generation" ? generation : metadata;
|
|
24
|
+
if (!accepting || set.size >= bridgeLimits[kind])
|
|
25
|
+
return undefined;
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
set.add(controller);
|
|
28
|
+
return {
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
abort: () => controller.abort(),
|
|
31
|
+
release: () => {
|
|
32
|
+
set.delete(controller);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
pause: () => {
|
|
37
|
+
accepting = false;
|
|
38
|
+
},
|
|
39
|
+
resume: () => {
|
|
40
|
+
if (!stopped)
|
|
41
|
+
accepting = true;
|
|
42
|
+
},
|
|
43
|
+
stop: () => {
|
|
44
|
+
stopped = true;
|
|
45
|
+
accepting = false;
|
|
46
|
+
for (const controller of [...generation, ...metadata])
|
|
47
|
+
controller.abort();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const unavailable = "Host CLI operation did not finish reliably; its result may be uncertain. Review the affected rebuild before retrying.";
|
|
52
|
+
function fault(error, token) {
|
|
53
|
+
return {
|
|
54
|
+
type: "error",
|
|
55
|
+
kind: isProviderError(error) ? error.fault.kind : "unavailable",
|
|
56
|
+
message: redact(isProviderError(error)
|
|
57
|
+
? error.message
|
|
58
|
+
: "Host helper operation failed. Check the host CLI and rerun the Docker launcher.")
|
|
59
|
+
.replaceAll(token, "[redacted]")
|
|
60
|
+
.slice(0, 4096),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function hostCliRoutes(options) {
|
|
64
|
+
const app = new Hono();
|
|
65
|
+
const { gate, ports, token } = options;
|
|
66
|
+
app.use("*", async (c, next) => {
|
|
67
|
+
const supplied = Buffer.from(c.req.header("authorization") ?? "");
|
|
68
|
+
const expected = Buffer.from(`Bearer ${token}`);
|
|
69
|
+
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected))
|
|
70
|
+
return c.json({ type: "error", kind: "unavailable", message: "Host helper authorization failed." }, 401);
|
|
71
|
+
c.header("Cache-Control", "no-store");
|
|
72
|
+
await next();
|
|
73
|
+
});
|
|
74
|
+
app.get("/v1/health", (c) => c.json({
|
|
75
|
+
protocol: hostCliProtocol,
|
|
76
|
+
version: options.version,
|
|
77
|
+
active: gate.active,
|
|
78
|
+
accepting: gate.accepting,
|
|
79
|
+
}));
|
|
80
|
+
app.use("/v1/*", async (c, next) => {
|
|
81
|
+
const kind = c.req.method === "POST" ? "generation" : "metadata";
|
|
82
|
+
const job = gate.acquire(kind);
|
|
83
|
+
if (!job)
|
|
84
|
+
return c.json({
|
|
85
|
+
type: "error",
|
|
86
|
+
kind: "unavailable",
|
|
87
|
+
message: "The host helper is busy or restarting. Review the rebuild after it is ready.",
|
|
88
|
+
}, 503);
|
|
89
|
+
const outgoing = c.env?.outgoing;
|
|
90
|
+
let responseEnded = !outgoing;
|
|
91
|
+
let handlerEnded = false;
|
|
92
|
+
let streaming = false;
|
|
93
|
+
const releaseIfFinished = () => {
|
|
94
|
+
if (responseEnded && handlerEnded && !streaming)
|
|
95
|
+
job.release();
|
|
96
|
+
};
|
|
97
|
+
c.set("job", {
|
|
98
|
+
...job,
|
|
99
|
+
holdStream: () => {
|
|
100
|
+
streaming = true;
|
|
101
|
+
},
|
|
102
|
+
release: () => {
|
|
103
|
+
streaming = false;
|
|
104
|
+
releaseIfFinished();
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const finish = () => {
|
|
108
|
+
responseEnded = true;
|
|
109
|
+
releaseIfFinished();
|
|
110
|
+
};
|
|
111
|
+
const close = () => {
|
|
112
|
+
if (!outgoing?.writableFinished)
|
|
113
|
+
job.abort();
|
|
114
|
+
finish();
|
|
115
|
+
};
|
|
116
|
+
outgoing?.once("close", close);
|
|
117
|
+
outgoing?.once("finish", finish);
|
|
118
|
+
const abort = () => job.abort();
|
|
119
|
+
c.req.raw.signal.addEventListener("abort", abort, { once: true });
|
|
120
|
+
try {
|
|
121
|
+
await next();
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
c.req.raw.signal.removeEventListener("abort", abort);
|
|
125
|
+
handlerEnded = true;
|
|
126
|
+
releaseIfFinished();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
app.use("/v1/*", bodyLimit({
|
|
130
|
+
maxSize: bridgeLimits.request,
|
|
131
|
+
onError: (c) => c.json({ type: "error", kind: "unavailable", message: "Host request is too large." }, 413),
|
|
132
|
+
}));
|
|
133
|
+
app.get("/v1/status/:provider", async (c) => {
|
|
134
|
+
const id = z.enum(hostCliIds).safeParse(c.req.param("provider"));
|
|
135
|
+
if (!id.success)
|
|
136
|
+
return c.notFound();
|
|
137
|
+
const status = hostStatusSchema.parse(await ports.status(id.data));
|
|
138
|
+
const text = JSON.stringify(status);
|
|
139
|
+
if (Buffer.byteLength(text) > bridgeLimits.status)
|
|
140
|
+
throw providerError({ kind: "unavailable", message: "Host status exceeded its limit." });
|
|
141
|
+
return c.json(status);
|
|
142
|
+
});
|
|
143
|
+
app.get("/v1/models/:provider", async (c) => {
|
|
144
|
+
const id = z.enum(hostCliIds).safeParse(c.req.param("provider"));
|
|
145
|
+
if (!id.success)
|
|
146
|
+
return c.notFound();
|
|
147
|
+
const result = hostModelsSchema.parse({
|
|
148
|
+
models: await (id.data === "codex-image" ? ports.image : ports.llm(id.data)).models(),
|
|
149
|
+
});
|
|
150
|
+
if (Buffer.byteLength(JSON.stringify(result)) > bridgeLimits.models)
|
|
151
|
+
throw providerError({ kind: "unavailable", message: "Host models exceeded their limit." });
|
|
152
|
+
return c.json(result);
|
|
153
|
+
});
|
|
154
|
+
app.post("/v1/llm/:provider", async (c) => {
|
|
155
|
+
const id = z.enum(hostLlmIds).safeParse(c.req.param("provider"));
|
|
156
|
+
if (!id.success)
|
|
157
|
+
return c.notFound();
|
|
158
|
+
const body = hostLlmSchema.safeParse(await json(c.req.raw));
|
|
159
|
+
if (!body.success)
|
|
160
|
+
return invalid(c);
|
|
161
|
+
const job = c.get("job");
|
|
162
|
+
job.holdStream();
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
const signal = AbortSignal.any([job.signal, controller.signal]);
|
|
165
|
+
let timer = setTimeout(() => controller.abort(), 120_000);
|
|
166
|
+
let total = 0;
|
|
167
|
+
let done = false;
|
|
168
|
+
c.header("Content-Type", "application/x-ndjson");
|
|
169
|
+
return stream(c, async (output) => {
|
|
170
|
+
output.onAbort(() => controller.abort());
|
|
171
|
+
const abort = () => output.abort();
|
|
172
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
173
|
+
try {
|
|
174
|
+
for await (const event of ports.llm(id.data).complete({ ...body.data, signal })) {
|
|
175
|
+
signal.throwIfAborted();
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
timer = setTimeout(() => controller.abort(), 120_000);
|
|
178
|
+
const parsed = hostFrameSchema.parse(event);
|
|
179
|
+
if (done || parsed.type === "error")
|
|
180
|
+
throw new Error("Invalid provider stream.");
|
|
181
|
+
const frame = `${JSON.stringify(parsed)}\n`;
|
|
182
|
+
total += Buffer.byteLength(frame);
|
|
183
|
+
if (Buffer.byteLength(frame) > bridgeLimits.frame || total > bridgeLimits.stream)
|
|
184
|
+
throw new Error("Host stream exceeded its limit.");
|
|
185
|
+
await output.write(frame);
|
|
186
|
+
signal.throwIfAborted();
|
|
187
|
+
done = parsed.type === "done";
|
|
188
|
+
}
|
|
189
|
+
if (!done)
|
|
190
|
+
throw providerError({ kind: "unavailable", message: unavailable });
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (!output.aborted)
|
|
194
|
+
await output.write(`${JSON.stringify(fault(signal.aborted ? providerError({ kind: "unavailable", message: unavailable }) : error, token))}\n`);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
signal.removeEventListener("abort", abort);
|
|
199
|
+
controller.abort();
|
|
200
|
+
job.release();
|
|
201
|
+
}
|
|
202
|
+
}, async () => {
|
|
203
|
+
controller.abort();
|
|
204
|
+
job.release();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
app.post("/v1/image", async (c) => {
|
|
208
|
+
const body = hostImageSchema.safeParse(await json(c.req.raw));
|
|
209
|
+
if (!body.success)
|
|
210
|
+
return invalid(c);
|
|
211
|
+
const job = c.get("job");
|
|
212
|
+
const controller = new AbortController();
|
|
213
|
+
const signal = AbortSignal.any([job.signal, controller.signal]);
|
|
214
|
+
const timer = setTimeout(() => controller.abort(), 300_000);
|
|
215
|
+
try {
|
|
216
|
+
const result = await ports.image.generate({ ...body.data, signal });
|
|
217
|
+
if (signal.aborted)
|
|
218
|
+
throw providerError({ kind: "unavailable", message: unavailable });
|
|
219
|
+
if (result.bytes.byteLength > bridgeLimits.image || sniffImage(result.bytes) !== result.mime)
|
|
220
|
+
throw new Error("Invalid host image.");
|
|
221
|
+
c.header("Content-Type", result.mime);
|
|
222
|
+
c.header("Content-Length", String(result.bytes.byteLength));
|
|
223
|
+
return c.body(new Uint8Array(result.bytes));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
throw signal.aborted ? providerError({ kind: "unavailable", message: unavailable }) : error;
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
controller.abort();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
app.onError((error, c) => c.json(fault(error, token), 503));
|
|
234
|
+
app.notFound((c) => c.json({ type: "error", kind: "unavailable", message: "Unknown host helper operation or protocol." }, 404));
|
|
235
|
+
return app;
|
|
236
|
+
}
|
|
237
|
+
async function json(request) {
|
|
238
|
+
try {
|
|
239
|
+
return await request.json();
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function invalid(c) {
|
|
246
|
+
return c.json({ type: "error", kind: "unavailable", message: "Invalid host provider request." }, 400);
|
|
247
|
+
}
|
|
@@ -17,7 +17,11 @@ const keyBody = z.object({ key: z.string().min(1).max(4096) });
|
|
|
17
17
|
// generated from; see stagingRoutes.
|
|
18
18
|
export function providerRoutes(deps) {
|
|
19
19
|
const keys = { db: deps.db, clock: deps.clock };
|
|
20
|
-
const readiness = {
|
|
20
|
+
const readiness = {
|
|
21
|
+
db: deps.db,
|
|
22
|
+
probe: deps.probe,
|
|
23
|
+
hostCliStatus: deps.hostCliStatus,
|
|
24
|
+
};
|
|
21
25
|
const catalog = createModelCatalog({
|
|
22
26
|
load: deps.modelsFor ?? (() => Promise.reject(new Error("Model catalog unavailable"))),
|
|
23
27
|
fallback: deps.fallbackModelsFor ?? (() => []),
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const path = z
|
|
3
|
+
.string()
|
|
4
|
+
.max(16384)
|
|
5
|
+
.refine((value) => !/[\p{Cc}]/u.test(value));
|
|
6
|
+
export const hostEnvironmentSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
HOME: path,
|
|
9
|
+
PATH: path,
|
|
10
|
+
CODEX_HOME: path.optional(),
|
|
11
|
+
CLAUDE_CONFIG_DIR: path.optional(),
|
|
12
|
+
XDG_CONFIG_HOME: path.optional(),
|
|
13
|
+
XDG_DATA_HOME: path.optional(),
|
|
14
|
+
XDG_CACHE_HOME: path.optional(),
|
|
15
|
+
SSL_CERT_FILE: path.optional(),
|
|
16
|
+
NODE_EXTRA_CA_CERTS: path.optional(),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
export function hostEnvironment(env) {
|
|
20
|
+
const selected = {};
|
|
21
|
+
for (const key of Object.keys(hostEnvironmentSchema.shape))
|
|
22
|
+
if (env[key] !== undefined)
|
|
23
|
+
selected[key] = env[key];
|
|
24
|
+
return hostEnvironmentSchema.parse(selected);
|
|
25
|
+
}
|