Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
push:
branches: [main]

permissions:
contents: read

jobs:
gates:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -56,3 +59,12 @@ jobs:
- name: Production quality gate
working-directory: ./frontend
run: npm run check:quality

release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [gates, controller, frontend]
permissions:
contents: write
issues: write
pull-requests: write
uses: ./.github/workflows/release.yml
32 changes: 20 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,47 @@
name: Release

on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_call:

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
queue: max

permissions:
contents: write
issues: write
pull-requests: write
contents: read

jobs:
release:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}

- name: Select tested main revision
run: git checkout -B main "${{ github.event.workflow_run.head_sha }}"
ref: ${{ github.sha }}

- uses: actions/setup-node@v4
with:
node-version: 22.19.0

- name: Verify tested main revision
id: revision
env:
TESTED_SHA: ${{ github.sha }}
run: node scripts/release-revision.mjs

- name: Configure git author
if: steps.revision.outputs.current == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Release
if: steps.revision.outputs.current == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Expand Down
2 changes: 1 addition & 1 deletion controller/src/http/effect-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const controllerRuntimeMiddleware =
return next();
};

const runControllerEffect = <A, E>(
export const runControllerEffect = <A, E>(
runtime: ControllerRuntime,
effect: ControllerEffect<A, E>,
): Promise<A> =>
Expand Down
125 changes: 125 additions & 0 deletions controller/src/modules/engines/recipe-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Effect } from "effect";

import { AppContextService } from "../../app-context";
import { createControllerRuntime, type ControllerRuntime } from "../../core/effect-runtime";
import { createApp } from "../../http/app";
import { runControllerEffect } from "../../http/effect-handler";

const environmentKeys = [
"LOCAL_STUDIO_DATA_DIR",
"LOCAL_STUDIO_DB_PATH",
"LOCAL_STUDIO_MODELS_DIR",
"LOCAL_STUDIO_HOST",
"LOCAL_STUDIO_PORT",
"LOCAL_STUDIO_INFERENCE_PORT",
"LOCAL_STUDIO_MOCK_INFERENCE",
"LOCAL_STUDIO_RUNTIME_SKIP_DOCKER",
"LOCAL_STUDIO_RUNTIME_SKIP_SYSTEM",
"LOCAL_STUDIO_API_KEY",
"PI_CODING_AGENT_DIR",
] as const;

let environmentSnapshot: Map<string, string | undefined>;
let testDirectory: string;
let runtime: ControllerRuntime;

const requestEffect = (
app: ReturnType<typeof createApp>,
path: string,
init?: RequestInit,
): Effect.Effect<Response, unknown> =>
Effect.tryPromise({ try: async () => app.request(path, init), catch: (error) => error });

beforeEach(() => {
environmentSnapshot = new Map(environmentKeys.map((key) => [key, process.env[key]]));
testDirectory = mkdtempSync(join(tmpdir(), "local-studio-recipe-route-"));
Object.assign(process.env, {
LOCAL_STUDIO_DATA_DIR: testDirectory,
LOCAL_STUDIO_DB_PATH: join(testDirectory, "controller.db"),
LOCAL_STUDIO_MODELS_DIR: join(testDirectory, "models"),
LOCAL_STUDIO_HOST: "127.0.0.1",
LOCAL_STUDIO_PORT: "18080",
LOCAL_STUDIO_INFERENCE_PORT: "65534",
LOCAL_STUDIO_MOCK_INFERENCE: "true",
LOCAL_STUDIO_RUNTIME_SKIP_DOCKER: "1",
LOCAL_STUDIO_RUNTIME_SKIP_SYSTEM: "1",
PI_CODING_AGENT_DIR: join(testDirectory, "pi-agent"),
});
delete process.env["LOCAL_STUDIO_API_KEY"];
runtime = createControllerRuntime();
});

afterEach(() => {
for (const key of environmentKeys) {
const value = environmentSnapshot.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
rmSync(testDirectory, { recursive: true, force: true });
});

afterEach(() => runtime.dispose());

describe("recipe routes", () => {
test("reject misleading booleans without persistence and round-trip false", () =>
runControllerEffect(
runtime,
Effect.gen(function* () {
const context = yield* AppContextService;
const app = createApp(context, runtime);
const fields = ["trust_remote_code", "enable_auto_tool_choice"] as const;
const invalidValues: ReadonlyArray<unknown> = [null, "true", "false", 0, 1, [], {}];

for (const field of fields) {
for (const [index, value] of invalidValues.entries()) {
const id = `invalid-${field}-${index}`;
const response = yield* requestEffect(app, "/recipes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id,
name: "Invalid Boolean Recipe",
model_path: join(testDirectory, "models", id),
[field]: value,
}),
});

expect(response.status).toBe(400);
expect(yield* Effect.promise(() => response.json())).toEqual({
detail: `Error: Invalid ${field}`,
});

const persisted = yield* requestEffect(app, `/recipes/${id}`);
expect(persisted.status).toBe(404);
}
}

const createResponse = yield* requestEffect(app, "/recipes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: "explicit-false-recipe",
name: "Explicit False Recipe",
model_path: join(testDirectory, "models", "explicit-false-recipe"),
trust_remote_code: false,
enable_auto_tool_choice: false,
}),
});

expect(createResponse.status).toBe(200);
const getResponse = yield* requestEffect(app, "/recipes/explicit-false-recipe");
expect(getResponse.status).toBe(200);
expect(yield* Effect.promise(() => getResponse.json())).toMatchObject({
trust_remote_code: false,
enable_auto_tool_choice: false,
});
}),
));
});
52 changes: 52 additions & 0 deletions controller/src/modules/models/recipes/recipe-serializer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";

import { parseRecipe } from "./recipe-serializer";

const minimalRecipe = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
id: "strict-boolean-recipe",
name: "Strict Boolean Recipe",
model_path: "/models/strict-boolean-recipe",
...overrides,
});

describe("parseRecipe boolean settings", () => {
const fields = ["trust_remote_code", "enable_auto_tool_choice"] as const;
const invalidValues: ReadonlyArray<unknown> = [null, "true", "false", 0, 1, [], {}];

for (const field of fields) {
test(`preserves JSON booleans for ${field}`, () => {
expect(parseRecipe(minimalRecipe({ [field]: true }))[field]).toBe(true);
expect(parseRecipe(minimalRecipe({ [field]: false }))[field]).toBe(false);
});

test(`rejects non-boolean values for ${field}`, () => {
for (const value of invalidValues) {
expect(() => parseRecipe(minimalRecipe({ [field]: value }))).toThrow(
`Invalid ${field}`,
);
}
});
}

test("preserves omitted-field defaults", () => {
const recipe = parseRecipe(minimalRecipe());
expect(recipe.trust_remote_code).toBe(
process.env["LOCAL_STUDIO_DEFAULT_TRUST_REMOTE_CODE"] !== "false",
);
expect(recipe.enable_auto_tool_choice).toBe(false);
});

test("preserves the disabled trust default", () => {
const previous = process.env["LOCAL_STUDIO_DEFAULT_TRUST_REMOTE_CODE"];
process.env["LOCAL_STUDIO_DEFAULT_TRUST_REMOTE_CODE"] = "false";
try {
expect(parseRecipe(minimalRecipe()).trust_remote_code).toBe(false);
} finally {
if (previous === undefined) {
delete process.env["LOCAL_STUDIO_DEFAULT_TRUST_REMOTE_CODE"];
} else {
process.env["LOCAL_STUDIO_DEFAULT_TRUST_REMOTE_CODE"] = previous;
}
}
});
});
24 changes: 20 additions & 4 deletions controller/src/modules/models/recipes/recipe-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,19 @@ const clampFraction = (value: unknown, fallback: number): number => {
const coerceNullableNumber = (value: unknown): number | null =>
value === undefined || value === null ? null : Number(value);

const coerceBoolean = (value: unknown, fallback: boolean): boolean =>
value === undefined ? fallback : Boolean(value);
const decodeOptionalFlag = Schema.decodeUnknownSync(Schema.optional(Schema.Boolean));

const booleanSetting = (
field: "trust_remote_code" | "enable_auto_tool_choice",
value: unknown,
fallback: boolean,
): boolean => {
try {
return decodeOptionalFlag(value) ?? fallback;
} catch {
throw new Error(`Invalid ${field}`);
}
};

/**
* Normalize raw recipe input before validation.
Expand Down Expand Up @@ -231,13 +242,18 @@ export const parseRecipe = (raw: unknown): Recipe => {
gpu_memory_utilization: clampFraction(normalized["gpu_memory_utilization"], 0.9),
kv_cache_dtype: normalized["kv_cache_dtype"] ?? "auto",
max_num_seqs: coercePositiveInt(normalized["max_num_seqs"], 256),
trust_remote_code: coerceBoolean(
trust_remote_code: booleanSetting(
"trust_remote_code",
normalized["trust_remote_code"],
process.env["LOCAL_STUDIO_DEFAULT_TRUST_REMOTE_CODE"] !== "false",
),
tool_call_parser: normalized["tool_call_parser"] ?? null,
reasoning_parser: normalized["reasoning_parser"] ?? null,
enable_auto_tool_choice: coerceBoolean(normalized["enable_auto_tool_choice"], false),
enable_auto_tool_choice: booleanSetting(
"enable_auto_tool_choice",
normalized["enable_auto_tool_choice"],
false,
),
quantization: normalized["quantization"] ?? null,
dtype: normalized["dtype"] ?? null,
host: normalized["host"] ?? "0.0.0.0",
Expand Down
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"check:deadcode": "knip",
"check:dupes": "jscpd src",
"check:cleanup": "npm run check:deadcode && npm run check:dupes && npm run depcheck",
"check:release-workflow": "node --test scripts/release-workflow.test.mjs",
"check:ui-structure": "node scripts/validate-ui-structure.mjs",
"check:fix": "knip --fix",
"desktop:build:main": "tsc -p desktop/tsconfig.json",
Expand All @@ -38,7 +39,7 @@
"typecheck": "tsc --noEmit",
"typecheck:desktop": "tsc -p desktop/tsconfig.json",
"check:cycles": "madge --extensions ts,tsx --circular src",
"check:static": "npm run lint && npm run typecheck && npm run typecheck:desktop && npm run check:cycles && npm run check:ui-structure",
"check:static": "npm run lint && npm run typecheck && npm run typecheck:desktop && npm run check:cycles && npm run check:release-workflow && npm run check:ui-structure",
"check:quality": "node scripts/validate-package-json.mjs && npm run check:static && npm run check:cleanup && npm run build",
"precommit": "lint-staged --config .lintstagedrc.json && npm run typecheck"
},
Expand Down
Loading