@fusionkit/cli 0.1.5 → 0.1.7
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 +32 -8
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1 -0
- package/dist/commands/doctor.js +7 -3
- package/dist/commands/ensemble-gateway.js +0 -2
- package/dist/commands/ensemble-records.d.ts +2 -1
- package/dist/commands/ensemble-records.js +3 -1
- package/dist/commands/ensemble.js +3 -4
- package/dist/commands/fusion.js +16 -13
- package/dist/commands/local.js +3 -3
- package/dist/cursor-acp.d.ts +3 -5
- package/dist/cursor-acp.js +12 -11
- package/dist/dashboard.d.ts +65 -0
- package/dist/dashboard.js +587 -0
- package/dist/fusion/env.d.ts +111 -0
- package/dist/fusion/env.js +98 -0
- package/dist/fusion/observability.d.ts +39 -0
- package/dist/fusion/observability.js +227 -0
- package/dist/fusion/preflight.d.ts +12 -0
- package/dist/fusion/preflight.js +42 -0
- package/dist/fusion/stack.d.ts +66 -0
- package/dist/fusion/stack.js +315 -0
- package/dist/fusion-config.d.ts +58 -7
- package/dist/fusion-config.js +152 -28
- package/dist/fusion-init.d.ts +1 -0
- package/dist/fusion-init.js +50 -15
- package/dist/fusion-quickstart.d.ts +11 -222
- package/dist/fusion-quickstart.js +58 -759
- package/dist/gateway.d.ts +0 -2
- package/dist/gateway.js +0 -2
- package/dist/local.d.ts +10 -17
- package/dist/local.js +50 -116
- package/dist/shared/options.d.ts +2 -1
- package/dist/shared/options.js +13 -19
- package/dist/shared/proc.d.ts +4 -70
- package/dist/shared/proc.js +3 -228
- package/dist/test/cli.test.js +11 -6
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +214 -0
- package/dist/test/fusion-config.test.js +64 -4
- package/dist/test/gateway-e2e.test.js +13 -10
- package/dist/test/local.test.js +4 -4
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +25 -0
- package/package.json +14 -9
- package/scope/.next/BUILD_ID +1 -1
- package/scope/.next/app-build-manifest.json +10 -10
- package/scope/.next/app-path-routes-manifest.json +2 -2
- package/scope/.next/build-manifest.json +2 -2
- package/scope/.next/prerender-manifest.json +13 -13
- package/scope/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/_not-found.html +1 -1
- package/scope/.next/server/app/_not-found.rsc +1 -1
- package/scope/.next/server/app/api/environments/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/ingest/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/models/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/replay/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/sessions/[traceId]/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/sessions/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/stream/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/environments/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/environments.html +1 -1
- package/scope/.next/server/app/environments.rsc +1 -1
- package/scope/.next/server/app/index.html +1 -1
- package/scope/.next/server/app/index.rsc +1 -1
- package/scope/.next/server/app/models/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/models.html +1 -1
- package/scope/.next/server/app/models.rsc +1 -1
- package/scope/.next/server/app/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/sessions/[traceId]/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app-paths-manifest.json +2 -2
- package/scope/.next/server/functions-config-manifest.json +2 -2
- package/scope/.next/server/pages/404.html +1 -1
- package/scope/.next/server/pages/500.html +1 -1
- package/scope/.next/server/server-reference-manifest.json +1 -1
- /package/scope/.next/static/{vxqImMqlOwssVTua5Facf → BrrtQvnEIgv-OVkaeanKI}/_buildManifest.js +0 -0
- /package/scope/.next/static/{vxqImMqlOwssVTua5Facf → BrrtQvnEIgv-OVkaeanKI}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types, panel defaults, and env helpers for the `fusionkit <tool>`
|
|
3
|
+
* launcher. Kept dependency-light (node builtins only) so every other fusion
|
|
4
|
+
* module and the orchestrator can import from here without cycles.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
/**
|
|
9
|
+
* The PyPI version of the `fusionkit` Python distribution that provides the
|
|
10
|
+
* synthesizer (`fusionkit serve`) and the single-model OpenAI shim
|
|
11
|
+
* (`fusionkit serve-endpoint`). Pinned so `uvx` resolves a reproducible build.
|
|
12
|
+
*/
|
|
13
|
+
export const FUSIONKIT_PYPI_VERSION = "0.3.0";
|
|
14
|
+
/**
|
|
15
|
+
* Default cloud panel — works cross-platform with only `OPENAI_API_KEY` and
|
|
16
|
+
* `ANTHROPIC_API_KEY` set. The judge defaults to the first entry.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_CLOUD_PANEL = [
|
|
19
|
+
{ id: "gpt", model: "gpt-5.5", provider: "openai" },
|
|
20
|
+
{ id: "sonnet", model: "claude-sonnet-4-6", provider: "anthropic" }
|
|
21
|
+
];
|
|
22
|
+
/** The locally cached MLX trio (Apple Silicon only) used behind `--local`. */
|
|
23
|
+
export const DEFAULT_TRIO = [
|
|
24
|
+
{ id: "qwen", model: "mlx-community/Qwen3-1.7B-4bit", provider: "mlx" },
|
|
25
|
+
{ id: "gemma", model: "mlx-community/gemma-3-1b-it-4bit", provider: "mlx" },
|
|
26
|
+
{ id: "llama", model: "mlx-community/Llama-3.2-1B-Instruct-4bit", provider: "mlx" }
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* How to invoke the `fusionkit` Python CLI: from PyPI via `uvx` by default
|
|
30
|
+
* (no checkout), or from a local checkout via `uv run` when `fusionkitDir` is
|
|
31
|
+
* given (a dev override). Returns the command plus the argv prefix that
|
|
32
|
+
* precedes the subcommand (`serve`, `serve-endpoint`, ...).
|
|
33
|
+
*/
|
|
34
|
+
export function fusionkitPyCommand(fusionkitDir) {
|
|
35
|
+
if (fusionkitDir !== undefined) {
|
|
36
|
+
return { command: "uv", prefix: ["run", "fusionkit"], cwd: fusionkitDir };
|
|
37
|
+
}
|
|
38
|
+
return { command: "uvx", prefix: [`fusionkit@${FUSIONKIT_PYPI_VERSION}`] };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse a `.env` file (KEY=VALUE lines, `#` comments, optional `export`,
|
|
42
|
+
* single/double quotes) and fill any keys not already present in `env`.
|
|
43
|
+
* Existing env values win, so an explicitly exported key is never overridden.
|
|
44
|
+
*/
|
|
45
|
+
export function loadEnvFileInto(path, env) {
|
|
46
|
+
if (!existsSync(path))
|
|
47
|
+
return;
|
|
48
|
+
for (const rawLine of readFileSync(path, "utf8").split("\n")) {
|
|
49
|
+
const line = rawLine.trim();
|
|
50
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
51
|
+
continue;
|
|
52
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length) : line;
|
|
53
|
+
const eq = withoutExport.indexOf("=");
|
|
54
|
+
if (eq <= 0)
|
|
55
|
+
continue;
|
|
56
|
+
const key = withoutExport.slice(0, eq).trim();
|
|
57
|
+
let value = withoutExport.slice(eq + 1).trim();
|
|
58
|
+
if (value.length >= 2 &&
|
|
59
|
+
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
60
|
+
value = value.slice(1, -1);
|
|
61
|
+
}
|
|
62
|
+
if (env[key] === undefined)
|
|
63
|
+
env[key] = value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Default env var holding the API key for each cloud provider. */
|
|
67
|
+
export function defaultKeyEnv(provider) {
|
|
68
|
+
switch (provider) {
|
|
69
|
+
case "openai":
|
|
70
|
+
return "OPENAI_API_KEY";
|
|
71
|
+
case "anthropic":
|
|
72
|
+
return "ANTHROPIC_API_KEY";
|
|
73
|
+
case "google":
|
|
74
|
+
return "GEMINI_API_KEY";
|
|
75
|
+
case "openai-compatible":
|
|
76
|
+
case "mlx":
|
|
77
|
+
return undefined;
|
|
78
|
+
default: {
|
|
79
|
+
const exhaustive = provider;
|
|
80
|
+
throw new Error(`unknown provider ${String(exhaustive)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** The git repository root containing `dir`, or undefined if it is not in a repo. */
|
|
85
|
+
export function gitToplevel(dir) {
|
|
86
|
+
try {
|
|
87
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
88
|
+
cwd: dir,
|
|
89
|
+
encoding: "utf8",
|
|
90
|
+
// Don't leak git's "fatal: not a git repository" to our stderr; we surface
|
|
91
|
+
// a clearer message ourselves.
|
|
92
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
93
|
+
}).trim();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PortlessSession } from "../shared/portless.js";
|
|
2
|
+
import type { StackReporter } from "./env.js";
|
|
3
|
+
/** Fixed port for the local observability dashboard (the scope app). */
|
|
4
|
+
export declare const SCOPE_DASHBOARD_PORT = 4317;
|
|
5
|
+
/**
|
|
6
|
+
* Locate the isolated scope dashboard app (handoffkit/apps/scope) by walking up
|
|
7
|
+
* from this module. Works from both the compiled dist and src layouts. Only the
|
|
8
|
+
* monorepo dev fallback uses this — published installs ship a prebuilt bundle.
|
|
9
|
+
*/
|
|
10
|
+
export declare function findScopeAppDir(): string;
|
|
11
|
+
/**
|
|
12
|
+
* Path to the prebuilt, self-contained dashboard server staged into the CLI
|
|
13
|
+
* package (`scope/server.js`, a sibling of `dist/`), or undefined when it is
|
|
14
|
+
* absent — i.e. a monorepo dev checkout where the bundle was never staged. This
|
|
15
|
+
* module compiles to `<cli-package>/dist/fusion/observability.js`, so the staged
|
|
16
|
+
* bundle is two levels up at `<cli-package>/scope/server.js`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function bundledScopeServer(): string | undefined;
|
|
19
|
+
/** Best-effort: open a URL in the default browser (no-op on failure). */
|
|
20
|
+
export declare function openUrl(url: string): void;
|
|
21
|
+
export type Observability = {
|
|
22
|
+
url: string;
|
|
23
|
+
ingestUrl: string;
|
|
24
|
+
traceDir: string;
|
|
25
|
+
close: () => Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Start the scope dashboard on the fixed port, backed by a fresh per-run SQLite
|
|
29
|
+
* file and trace dir, and return the URLs the caller injects (as
|
|
30
|
+
* FUSION_TRACE_URL / FUSION_TRACE_DIR) into every spawned process. Prefers the
|
|
31
|
+
* prebuilt bundle shipped inside the npm package; falls back to building the
|
|
32
|
+
* app from source in a monorepo dev checkout.
|
|
33
|
+
*/
|
|
34
|
+
export declare function startObservability(input: {
|
|
35
|
+
log: (line: string) => void;
|
|
36
|
+
logFile?: string;
|
|
37
|
+
report?: StackReporter;
|
|
38
|
+
portless: PortlessSession;
|
|
39
|
+
}): Promise<Observability>;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local scope observability dashboard: locating the prebuilt bundle (or
|
|
3
|
+
* building it from source in a monorepo checkout), starting it on the fixed
|
|
4
|
+
* port, and exposing the trace URLs the orchestrator injects into spawned
|
|
5
|
+
* children.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
8
|
+
import { appendFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { spawnLogged, terminate, waitForHttp } from "../shared/proc.js";
|
|
13
|
+
/** Fixed port for the local observability dashboard (the scope app). */
|
|
14
|
+
export const SCOPE_DASHBOARD_PORT = 4317;
|
|
15
|
+
/**
|
|
16
|
+
* Locate the isolated scope dashboard app (handoffkit/apps/scope) by walking up
|
|
17
|
+
* from this module. Works from both the compiled dist and src layouts. Only the
|
|
18
|
+
* monorepo dev fallback uses this — published installs ship a prebuilt bundle.
|
|
19
|
+
*/
|
|
20
|
+
export function findScopeAppDir() {
|
|
21
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
23
|
+
const candidate = join(dir, "apps", "scope");
|
|
24
|
+
if (existsSync(join(candidate, "package.json")))
|
|
25
|
+
return candidate;
|
|
26
|
+
const parent = dirname(dir);
|
|
27
|
+
if (parent === dir)
|
|
28
|
+
break;
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
throw new Error("could not locate apps/scope relative to the handoffkit CLI");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Path to the prebuilt, self-contained dashboard server staged into the CLI
|
|
35
|
+
* package (`scope/server.js`, a sibling of `dist/`), or undefined when it is
|
|
36
|
+
* absent — i.e. a monorepo dev checkout where the bundle was never staged. This
|
|
37
|
+
* module compiles to `<cli-package>/dist/fusion/observability.js`, so the staged
|
|
38
|
+
* bundle is two levels up at `<cli-package>/scope/server.js`.
|
|
39
|
+
*/
|
|
40
|
+
export function bundledScopeServer() {
|
|
41
|
+
const serverJs = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scope", "server.js");
|
|
42
|
+
return existsSync(serverJs) ? serverJs : undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Inject the portless CA so spawned Node children (dashboard, cursor bridge,
|
|
46
|
+
* launched agents) trust the proxy's HTTPS routes. Only `NODE_EXTRA_CA_CERTS` is
|
|
47
|
+
* set: it *extends* Node's trust store. We deliberately do NOT set Python's
|
|
48
|
+
* `SSL_CERT_FILE`/`REQUESTS_CA_BUNDLE`, because those *replace* the bundle — and
|
|
49
|
+
* pointing them at the portless CA alone breaks the router's outbound HTTPS to
|
|
50
|
+
* real providers (api.openai.com, etc.). The router never calls a portless HTTPS
|
|
51
|
+
* URL (providers go direct; MLX is loopback), so it needs no portless CA. If a
|
|
52
|
+
* Python process ever must reach a portless HTTPS URL, build a combined
|
|
53
|
+
* certifi+portless bundle rather than replacing the bundle here. A no-op when
|
|
54
|
+
* portless is off (no CA path).
|
|
55
|
+
*/
|
|
56
|
+
function withCaEnv(env, caCertPath) {
|
|
57
|
+
if (caCertPath === undefined)
|
|
58
|
+
return env;
|
|
59
|
+
return {
|
|
60
|
+
...env,
|
|
61
|
+
NODE_EXTRA_CA_CERTS: env.NODE_EXTRA_CA_CERTS ?? caCertPath
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Best-effort: open a URL in the default browser (no-op on failure). */
|
|
65
|
+
export function openUrl(url) {
|
|
66
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
67
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
68
|
+
try {
|
|
69
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
70
|
+
child.on("error", () => { });
|
|
71
|
+
child.unref();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// opening a browser is a convenience, never required
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Spawn the prebuilt standalone dashboard server (`node scope/server.js`) on the
|
|
79
|
+
* fixed port. The Next standalone entrypoint reads PORT/HOSTNAME from the env
|
|
80
|
+
* (there is no `-p` flag). This is the path every npm-installed user takes.
|
|
81
|
+
*/
|
|
82
|
+
function startBundledDashboard(input) {
|
|
83
|
+
return spawnLogged(process.execPath, [input.serverJs], {
|
|
84
|
+
cwd: dirname(input.serverJs),
|
|
85
|
+
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
86
|
+
env: { ...input.env, PORT: String(SCOPE_DASHBOARD_PORT), HOSTNAME: "127.0.0.1" }
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Monorepo dev fallback: build the scope app once (reusing a prior build) and
|
|
91
|
+
* `next start` it on the fixed port. Only reached in a handoffkit checkout where
|
|
92
|
+
* no bundle was staged; published installs always use {@link startBundledDashboard}.
|
|
93
|
+
*/
|
|
94
|
+
function startDevDashboard(input) {
|
|
95
|
+
const scopeDir = findScopeAppDir();
|
|
96
|
+
const nextBin = join(scopeDir, "node_modules", ".bin", "next");
|
|
97
|
+
if (!existsSync(nextBin)) {
|
|
98
|
+
throw new Error("the observability dashboard is not available in this checkout.\n" +
|
|
99
|
+
` Install its dependencies once: cd ${scopeDir} && pnpm install`);
|
|
100
|
+
}
|
|
101
|
+
// Rebuilding every run is slow; reuse a prior build when present. The build
|
|
102
|
+
// output is captured (never inherited) so it can't corrupt a live checklist.
|
|
103
|
+
const alreadyBuilt = existsSync(join(scopeDir, ".next", "BUILD_ID"));
|
|
104
|
+
if (!alreadyBuilt) {
|
|
105
|
+
try {
|
|
106
|
+
const buildOut = execFileSync(nextBin, ["build"], {
|
|
107
|
+
cwd: scopeDir,
|
|
108
|
+
env: input.env,
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
111
|
+
});
|
|
112
|
+
if (input.logFile !== undefined)
|
|
113
|
+
appendFileSync(input.logFile, buildOut);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (input.logFile !== undefined) {
|
|
117
|
+
appendFileSync(input.logFile, String(error.stdout ?? "") + String(error.stderr ?? ""));
|
|
118
|
+
}
|
|
119
|
+
throw new Error("the observability dashboard failed to build. See the log for details" +
|
|
120
|
+
(input.logFile ? `: ${input.logFile}` : ""));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return spawnLogged(nextBin, ["start", "-p", String(SCOPE_DASHBOARD_PORT)], {
|
|
124
|
+
cwd: scopeDir,
|
|
125
|
+
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
126
|
+
env: input.env
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Identity token of a reusable scope dashboard (any healthy instance qualifies). */
|
|
130
|
+
const SCOPE_IDENTITY = "scope-dashboard";
|
|
131
|
+
/**
|
|
132
|
+
* Start the scope dashboard on the fixed port, backed by a fresh per-run SQLite
|
|
133
|
+
* file and trace dir, and return the URLs the caller injects (as
|
|
134
|
+
* FUSION_TRACE_URL / FUSION_TRACE_DIR) into every spawned process. Prefers the
|
|
135
|
+
* prebuilt bundle shipped inside the npm package; falls back to building the
|
|
136
|
+
* app from source in a monorepo dev checkout.
|
|
137
|
+
*/
|
|
138
|
+
export async function startObservability(input) {
|
|
139
|
+
const traceDir = mkdtempSync(join(tmpdir(), "fusion-trace-"));
|
|
140
|
+
const dbPath = join(traceDir, "scope.db");
|
|
141
|
+
// The dashboard server loads node:sqlite; keep its experimental warnings out
|
|
142
|
+
// of the log just like the parent CLI. The per-run db/trace dir isolate state.
|
|
143
|
+
const childEnv = withCaEnv({
|
|
144
|
+
...process.env,
|
|
145
|
+
NODE_OPTIONS: [process.env.NODE_OPTIONS, "--disable-warning=ExperimentalWarning"].filter(Boolean).join(" "),
|
|
146
|
+
SCOPEKIT_DB: dbPath,
|
|
147
|
+
FUSION_TRACE_DIR: traceDir
|
|
148
|
+
}, input.portless.caCertPath);
|
|
149
|
+
const spawnDashboard = async () => {
|
|
150
|
+
const bundled = bundledScopeServer();
|
|
151
|
+
if (input.report)
|
|
152
|
+
input.report({ kind: "dashboard.start" });
|
|
153
|
+
else if (bundled !== undefined)
|
|
154
|
+
input.log("fusion: starting observability dashboard...");
|
|
155
|
+
else
|
|
156
|
+
input.log("fusion: building observability dashboard (one-time)...");
|
|
157
|
+
let proc;
|
|
158
|
+
try {
|
|
159
|
+
proc =
|
|
160
|
+
bundled !== undefined
|
|
161
|
+
? startBundledDashboard({
|
|
162
|
+
serverJs: bundled,
|
|
163
|
+
env: childEnv,
|
|
164
|
+
...(input.logFile !== undefined ? { logFile: input.logFile } : {})
|
|
165
|
+
})
|
|
166
|
+
: startDevDashboard({
|
|
167
|
+
env: childEnv,
|
|
168
|
+
traceDir,
|
|
169
|
+
...(input.logFile !== undefined ? { logFile: input.logFile } : {})
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
await waitForHttp(`http://127.0.0.1:${SCOPE_DASHBOARD_PORT}`, proc, {
|
|
177
|
+
timeoutMs: 60_000,
|
|
178
|
+
label: "dashboard"
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
terminate(proc.child);
|
|
183
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
port: SCOPE_DASHBOARD_PORT,
|
|
187
|
+
...(proc.child.pid !== undefined ? { pid: proc.child.pid } : {}),
|
|
188
|
+
close: () => terminate(proc.child)
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
let resolved;
|
|
192
|
+
try {
|
|
193
|
+
resolved = await input.portless.discoverOrSpawn({
|
|
194
|
+
name: "scope",
|
|
195
|
+
identity: SCOPE_IDENTITY,
|
|
196
|
+
healthCheck: async (loopbackUrl) => {
|
|
197
|
+
try {
|
|
198
|
+
const response = await fetch(loopbackUrl, { signal: AbortSignal.timeout(2000) });
|
|
199
|
+
return response.ok ? SCOPE_IDENTITY : undefined;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
spawn: spawnDashboard
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
rmSync(traceDir, { recursive: true, force: true });
|
|
210
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
211
|
+
}
|
|
212
|
+
if (input.report)
|
|
213
|
+
input.report({ kind: "dashboard.ready", detail: resolved.url });
|
|
214
|
+
else
|
|
215
|
+
input.log(`fusion: observability dashboard ready on ${resolved.url}`);
|
|
216
|
+
return {
|
|
217
|
+
url: resolved.url,
|
|
218
|
+
// Trace events post over loopback (the in-process emitters do not carry the
|
|
219
|
+
// portless CA), so ingest uses the raw port; the named URL is for humans.
|
|
220
|
+
ingestUrl: `${resolved.loopbackUrl}/api/ingest`,
|
|
221
|
+
traceDir,
|
|
222
|
+
close: async () => {
|
|
223
|
+
await resolved.close();
|
|
224
|
+
rmSync(traceDir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FusionTool, PanelModelSpec, RunFusionOptions } from "./env.js";
|
|
2
|
+
/** The PATH binary each coding agent launches as. `serve` launches nothing. */
|
|
3
|
+
export declare function agentBinary(tool: FusionTool): string | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* Compute the binaries and API keys the run requires given the tool, panel, and
|
|
6
|
+
* options. Pre-running endpoints (`--model-endpoint`) and a pre-running
|
|
7
|
+
* `--synthesis-url` drop the corresponding requirements.
|
|
8
|
+
*/
|
|
9
|
+
export declare function preflightRequirements(tool: FusionTool, models: PanelModelSpec[], options: RunFusionOptions): {
|
|
10
|
+
requiredBins: string[];
|
|
11
|
+
requiredEnv: string[];
|
|
12
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight: the binaries and API keys a `fusionkit <tool>` run requires, given
|
|
3
|
+
* the tool, panel, and options. The actual prerequisite check lives in
|
|
4
|
+
* `../shared/preflight.ts`; this computes what to check.
|
|
5
|
+
*/
|
|
6
|
+
import { toolRegistry } from "../tools.js";
|
|
7
|
+
import { defaultKeyEnv } from "./env.js";
|
|
8
|
+
/** The PATH binary each coding agent launches as. `serve` launches nothing. */
|
|
9
|
+
export function agentBinary(tool) {
|
|
10
|
+
return toolRegistry.get(tool)?.binary;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Compute the binaries and API keys the run requires given the tool, panel, and
|
|
14
|
+
* options. Pre-running endpoints (`--model-endpoint`) and a pre-running
|
|
15
|
+
* `--synthesis-url` drop the corresponding requirements.
|
|
16
|
+
*/
|
|
17
|
+
export function preflightRequirements(tool, models, options) {
|
|
18
|
+
const requiredBins = [];
|
|
19
|
+
const requiredEnv = [];
|
|
20
|
+
const endpointsProvided = options.endpoints !== undefined;
|
|
21
|
+
const spawnsServers = !endpointsProvided;
|
|
22
|
+
const spawnsSynthesizer = options.synthesisUrl === undefined;
|
|
23
|
+
// The FusionKit Python CLI is fetched via uvx (or run from a local checkout).
|
|
24
|
+
if (spawnsServers || spawnsSynthesizer) {
|
|
25
|
+
requiredBins.push(options.fusionkitDir !== undefined ? "uv" : "uvx");
|
|
26
|
+
}
|
|
27
|
+
const agent = agentBinary(tool);
|
|
28
|
+
if (agent !== undefined)
|
|
29
|
+
requiredBins.push(agent);
|
|
30
|
+
// Cloud panel members need their provider key when we front them ourselves.
|
|
31
|
+
if (spawnsServers) {
|
|
32
|
+
for (const spec of models) {
|
|
33
|
+
const provider = spec.provider ?? "mlx";
|
|
34
|
+
if (provider === "mlx")
|
|
35
|
+
continue;
|
|
36
|
+
const keyEnv = spec.keyEnv ?? defaultKeyEnv(provider);
|
|
37
|
+
if (keyEnv !== undefined)
|
|
38
|
+
requiredEnv.push(keyEnv);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { requiredBins, requiredEnv };
|
|
42
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { EnsembleModel } from "@fusionkit/ensemble";
|
|
2
|
+
import type { PortlessSession } from "../shared/portless.js";
|
|
3
|
+
import type { PromptOverrides } from "../fusion-config.js";
|
|
4
|
+
import type { PanelModelSpec, StackReporter } from "./env.js";
|
|
5
|
+
/**
|
|
6
|
+
* The single `fusionkit serve` router: one process that fronts every panel
|
|
7
|
+
* model (passthrough, routed by the endpoint id in the request `model` field)
|
|
8
|
+
* and also performs trajectory synthesis. `endpoints` maps each panel id to the
|
|
9
|
+
* router URL so the harness reaches its model through the one base URL.
|
|
10
|
+
*/
|
|
11
|
+
export type Router = {
|
|
12
|
+
url: string;
|
|
13
|
+
port: number;
|
|
14
|
+
/** The router process pid (owns its portless route across runs). */
|
|
15
|
+
pid?: number;
|
|
16
|
+
endpoints: Record<string, string>;
|
|
17
|
+
models: EnsembleModel[];
|
|
18
|
+
/** The endpoint id used as the judge/synthesizer. */
|
|
19
|
+
judgeModel: string;
|
|
20
|
+
/** Sorted endpoint ids — the router's discover-or-spawn identity token. */
|
|
21
|
+
identity: string;
|
|
22
|
+
close: () => Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Spawn the single `fusionkit serve` router fronting every panel model + the
|
|
26
|
+
* synthesizer. MLX specs first get an in-process OpenAI-compatible gateway
|
|
27
|
+
* (loopback) that the router proxies to; cloud specs call their provider
|
|
28
|
+
* directly. Returns the router URL, an id->routerUrl endpoint map, and a close
|
|
29
|
+
* that tears down the router process and any MLX gateways it fronts.
|
|
30
|
+
*/
|
|
31
|
+
export declare function startRouter(options: {
|
|
32
|
+
specs: PanelModelSpec[];
|
|
33
|
+
judgeModel?: string;
|
|
34
|
+
fusionkitDir?: string;
|
|
35
|
+
prompts?: PromptOverrides;
|
|
36
|
+
logsDir?: string;
|
|
37
|
+
report?: StackReporter;
|
|
38
|
+
log: (line: string) => void;
|
|
39
|
+
}): Promise<Router>;
|
|
40
|
+
export type FusionStack = {
|
|
41
|
+
fusionUrl: string;
|
|
42
|
+
endpoints: Record<string, string>;
|
|
43
|
+
close: () => Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
export type StartFusionStackOptions = {
|
|
46
|
+
repo: string;
|
|
47
|
+
outputRoot: string;
|
|
48
|
+
models: PanelModelSpec[];
|
|
49
|
+
endpoints?: Record<string, string>;
|
|
50
|
+
fusionkitDir?: string;
|
|
51
|
+
/** System-prompt overrides emitted into the router's synthesizer config. */
|
|
52
|
+
prompts?: PromptOverrides;
|
|
53
|
+
judgeModel?: string;
|
|
54
|
+
/** Pre-running fusionkit serve URL for trajectory synthesis (skips spawn). */
|
|
55
|
+
synthesisUrl?: string;
|
|
56
|
+
host?: string;
|
|
57
|
+
port?: number;
|
|
58
|
+
authToken?: string;
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
logsDir?: string;
|
|
61
|
+
report?: StackReporter;
|
|
62
|
+
/** Active portless session; defaults to a disabled (loopback) session. */
|
|
63
|
+
portless?: PortlessSession;
|
|
64
|
+
log: (line: string) => void;
|
|
65
|
+
};
|
|
66
|
+
export declare function startFusionStack(options: StartFusionStackOptions): Promise<FusionStack>;
|