huskies: merge 595_story_web_ui_settings_page_with_form_based_project_toml_editor
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ProjectSettings } from "./settings";
|
||||||
import { settingsApi } from "./settings";
|
import { settingsApi } from "./settings";
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
@@ -22,7 +23,77 @@ function errorResponse(status: number, text: string) {
|
|||||||
return new Response(text, { status });
|
return new Response(text, { status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultProjectSettings: ProjectSettings = {
|
||||||
|
default_qa: "server",
|
||||||
|
default_coder_model: null,
|
||||||
|
max_coders: null,
|
||||||
|
max_retries: 2,
|
||||||
|
base_branch: null,
|
||||||
|
rate_limit_notifications: true,
|
||||||
|
timezone: null,
|
||||||
|
rendezvous: null,
|
||||||
|
watcher_sweep_interval_secs: 60,
|
||||||
|
watcher_done_retention_secs: 14400,
|
||||||
|
};
|
||||||
|
|
||||||
describe("settingsApi", () => {
|
describe("settingsApi", () => {
|
||||||
|
describe("getProjectSettings", () => {
|
||||||
|
it("sends GET to /settings and returns project settings", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(okResponse(defaultProjectSettings));
|
||||||
|
|
||||||
|
const result = await settingsApi.getProjectSettings();
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/settings",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(defaultProjectSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom baseUrl when provided", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(okResponse(defaultProjectSettings));
|
||||||
|
await settingsApi.getProjectSettings("http://localhost:4000/api");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"http://localhost:4000/api/settings",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("putProjectSettings", () => {
|
||||||
|
it("sends PUT to /settings with settings body", async () => {
|
||||||
|
const updated = { ...defaultProjectSettings, default_qa: "agent" };
|
||||||
|
mockFetch.mockResolvedValueOnce(okResponse(updated));
|
||||||
|
|
||||||
|
const result = await settingsApi.putProjectSettings(updated);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/settings",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(updated),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.default_qa).toBe("agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on validation error", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
errorResponse(400, "Invalid default_qa value"),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
settingsApi.putProjectSettings({
|
||||||
|
...defaultProjectSettings,
|
||||||
|
default_qa: "invalid",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Invalid default_qa value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getEditorCommand", () => {
|
describe("getEditorCommand", () => {
|
||||||
it("sends GET to /settings/editor and returns editor settings", async () => {
|
it("sends GET to /settings/editor and returns editor settings", async () => {
|
||||||
const expected = { editor_command: "zed" };
|
const expected = { editor_command: "zed" };
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ export interface EditorSettings {
|
|||||||
editor_command: string | null;
|
editor_command: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectSettings {
|
||||||
|
default_qa: string;
|
||||||
|
default_coder_model: string | null;
|
||||||
|
max_coders: number | null;
|
||||||
|
max_retries: number;
|
||||||
|
base_branch: string | null;
|
||||||
|
rate_limit_notifications: boolean;
|
||||||
|
timezone: string | null;
|
||||||
|
rendezvous: string | null;
|
||||||
|
watcher_sweep_interval_secs: number;
|
||||||
|
watcher_done_retention_secs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenFileResult {
|
export interface OpenFileResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
}
|
}
|
||||||
@@ -34,6 +47,21 @@ async function requestJson<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
|
getProjectSettings(baseUrl?: string): Promise<ProjectSettings> {
|
||||||
|
return requestJson<ProjectSettings>("/settings", {}, baseUrl);
|
||||||
|
},
|
||||||
|
|
||||||
|
putProjectSettings(
|
||||||
|
settings: ProjectSettings,
|
||||||
|
baseUrl?: string,
|
||||||
|
): Promise<ProjectSettings> {
|
||||||
|
return requestJson<ProjectSettings>(
|
||||||
|
"/settings",
|
||||||
|
{ method: "PUT", body: JSON.stringify(settings) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
getEditorCommand(baseUrl?: string): Promise<EditorSettings> {
|
getEditorCommand(baseUrl?: string): Promise<EditorSettings> {
|
||||||
return requestJson<EditorSettings>("/settings/editor", {}, baseUrl);
|
return requestJson<EditorSettings>("/settings/editor", {}, baseUrl);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useChatWebSocket } from "../hooks/useChatWebSocket";
|
|||||||
import { estimateTokens, getContextWindowSize } from "../utils/chatUtils";
|
import { estimateTokens, getContextWindowSize } from "../utils/chatUtils";
|
||||||
import { ApiKeyDialog } from "./ApiKeyDialog";
|
import { ApiKeyDialog } from "./ApiKeyDialog";
|
||||||
import { BotConfigPage } from "./BotConfigPage";
|
import { BotConfigPage } from "./BotConfigPage";
|
||||||
|
import { SettingsPage } from "./SettingsPage";
|
||||||
import { ChatHeader } from "./ChatHeader";
|
import { ChatHeader } from "./ChatHeader";
|
||||||
import type { ChatInputHandle } from "./ChatInput";
|
import type { ChatInputHandle } from "./ChatInput";
|
||||||
import { ChatInput } from "./ChatInput";
|
import { ChatInput } from "./ChatInput";
|
||||||
@@ -62,7 +63,7 @@ export function Chat({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [view, setView] = useState<"chat" | "bot-config">("chat");
|
const [view, setView] = useState<"chat" | "bot-config" | "settings">("chat");
|
||||||
const [queuedMessages, setQueuedMessages] = useState<
|
const [queuedMessages, setQueuedMessages] = useState<
|
||||||
{ id: string; text: string }[]
|
{ id: string; text: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -376,16 +377,21 @@ export function Chat({
|
|||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
oauthStatus={oauthStatus}
|
oauthStatus={oauthStatus}
|
||||||
onShowBotConfig={() => setView("bot-config")}
|
onShowBotConfig={() => setView("bot-config")}
|
||||||
|
onShowSettings={() => setView("settings")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{view === "bot-config" && (
|
{view === "bot-config" && (
|
||||||
<BotConfigPage onBack={() => setView("chat")} />
|
<BotConfigPage onBack={() => setView("chat")} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view === "settings" && (
|
||||||
|
<SettingsPage onBack={() => setView("chat")} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-testid="chat-content-area"
|
data-testid="chat-content-area"
|
||||||
style={{
|
style={{
|
||||||
display: view === "bot-config" ? "none" : "flex",
|
display: view === "chat" ? "flex" : "none",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
flexDirection: isNarrowScreen ? "column" : "row",
|
flexDirection: isNarrowScreen ? "column" : "row",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface ChatHeaderProps {
|
|||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
oauthStatus?: OAuthStatus | null;
|
oauthStatus?: OAuthStatus | null;
|
||||||
onShowBotConfig?: () => void;
|
onShowBotConfig?: () => void;
|
||||||
|
onShowSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getContextEmoji = (percentage: number): string => {
|
const getContextEmoji = (percentage: number): string => {
|
||||||
@@ -60,6 +61,7 @@ export function ChatHeader({
|
|||||||
wsConnected,
|
wsConnected,
|
||||||
oauthStatus = null,
|
oauthStatus = null,
|
||||||
onShowBotConfig,
|
onShowBotConfig,
|
||||||
|
onShowSettings,
|
||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
|
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
@@ -552,6 +554,43 @@ export function ChatHeader({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onShowSettings && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onShowSettings}
|
||||||
|
title="Edit project.toml settings"
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "99px",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
backgroundColor: "#2f2f2f",
|
||||||
|
color: "#888",
|
||||||
|
cursor: "pointer",
|
||||||
|
outline: "none",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#3f3f3f";
|
||||||
|
e.currentTarget.style.color = "#ccc";
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2f2f2f";
|
||||||
|
e.currentTarget.style.color = "#888";
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#3f3f3f";
|
||||||
|
e.currentTarget.style.color = "#ccc";
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2f2f2f";
|
||||||
|
e.currentTarget.style.color = "#888";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚙ Settings
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasModelOptions ? (
|
{hasModelOptions ? (
|
||||||
<select
|
<select
|
||||||
value={model}
|
value={model}
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import type { ProjectSettings } from "../api/settings";
|
||||||
|
import { settingsApi } from "../api/settings";
|
||||||
|
|
||||||
|
const { useState, useEffect } = React;
|
||||||
|
|
||||||
|
interface SettingsPageProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "4px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
fontSize: "0.8em",
|
||||||
|
color: "#aaa",
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const descStyle: React.CSSProperties = {
|
||||||
|
fontSize: "0.75em",
|
||||||
|
color: "#666",
|
||||||
|
marginTop: "2px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
background: "#1e1e1e",
|
||||||
|
color: "#ececec",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionStyle: React.CSSProperties = {
|
||||||
|
background: "#1e1e1e",
|
||||||
|
border: "1px solid #333",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "20px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "16px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionTitleStyle: React.CSSProperties = {
|
||||||
|
fontSize: "0.85em",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#aaa",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
marginBottom: "2px",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TextFieldProps {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextField({ label, description, value, onChange, placeholder }: TextFieldProps) {
|
||||||
|
return (
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label style={labelStyle}>{label}</label>
|
||||||
|
{description && <span style={descStyle}>{description}</span>}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder ?? ""}
|
||||||
|
style={inputStyle}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NumberFieldProps {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: number | null;
|
||||||
|
onChange: (v: number | null) => void;
|
||||||
|
min?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberField({ label, description, value, onChange, min, placeholder }: NumberFieldProps) {
|
||||||
|
return (
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label style={labelStyle}>{label}</label>
|
||||||
|
{description && <span style={descStyle}>{description}</span>}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value === null ? "" : value}
|
||||||
|
min={min}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.trim();
|
||||||
|
if (raw === "") {
|
||||||
|
onChange(null);
|
||||||
|
} else {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isNaN(n)) onChange(n);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder ?? ""}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckboxFieldProps {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckboxField({ label, description, checked, onChange }: CheckboxFieldProps) {
|
||||||
|
return (
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
{description && <span style={descStyle}>{description}</span>}
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
color: "#ccc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QA_MODES = ["server", "agent", "human"] as const;
|
||||||
|
|
||||||
|
/** Settings page — form-based editor for project.toml scalar settings. */
|
||||||
|
export function SettingsPage({ onBack }: SettingsPageProps) {
|
||||||
|
const [settings, setSettings] = useState<ProjectSettings | null>(null);
|
||||||
|
const [status, setStatus] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settingsApi
|
||||||
|
.getProjectSettings()
|
||||||
|
.then((s) => {
|
||||||
|
setSettings(s);
|
||||||
|
setStatus("idle");
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : "Failed to load settings");
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function patch(partial: Partial<ProjectSettings>) {
|
||||||
|
setSettings((prev) => (prev ? { ...prev, ...partial } : prev));
|
||||||
|
setValidationErrors({});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(s: ProjectSettings): Record<string, string> {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
if (!QA_MODES.includes(s.default_qa as (typeof QA_MODES)[number])) {
|
||||||
|
errors.default_qa = `Must be one of: ${QA_MODES.join(", ")}`;
|
||||||
|
}
|
||||||
|
if (s.max_retries < 0) {
|
||||||
|
errors.max_retries = "Must be 0 or greater";
|
||||||
|
}
|
||||||
|
if (s.watcher_sweep_interval_secs < 1) {
|
||||||
|
errors.watcher_sweep_interval_secs = "Must be at least 1 second";
|
||||||
|
}
|
||||||
|
if (s.watcher_done_retention_secs < 1) {
|
||||||
|
errors.watcher_done_retention_secs = "Must be at least 1 second";
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!settings) return;
|
||||||
|
const errors = validate(settings);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setValidationErrors(errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("saving");
|
||||||
|
setErrorMsg(null);
|
||||||
|
try {
|
||||||
|
const saved = await settingsApi.putProjectSettings(settings);
|
||||||
|
setSettings(saved);
|
||||||
|
setStatus("saved");
|
||||||
|
setTimeout(() => setStatus("idle"), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : "Save failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = settings;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
color: "#ececec",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 24px",
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "16px",
|
||||||
|
background: "#171717",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#888",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: "1em" }}>Project Settings</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "24px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "20px",
|
||||||
|
maxWidth: "640px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === "loading" && (
|
||||||
|
<p style={{ color: "#888", fontSize: "0.9em" }}>Loading settings…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "error" && !s && (
|
||||||
|
<p style={{ color: "#f08080", fontSize: "0.9em" }}>
|
||||||
|
Error: {errorMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s && (
|
||||||
|
<>
|
||||||
|
{/* Pipeline */}
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Pipeline</div>
|
||||||
|
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label style={labelStyle}>Default QA Mode</label>
|
||||||
|
<span style={descStyle}>
|
||||||
|
How stories are QA-reviewed after the coder stage.
|
||||||
|
Default: server.
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={s.default_qa}
|
||||||
|
onChange={(e) => patch({ default_qa: e.target.value })}
|
||||||
|
style={{ ...inputStyle, cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{QA_MODES.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{validationErrors.default_qa && (
|
||||||
|
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||||||
|
{validationErrors.default_qa}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NumberField
|
||||||
|
label="Max Retries"
|
||||||
|
description="Maximum retries per story per pipeline stage before blocking. Default: 2. Set 0 to disable."
|
||||||
|
value={s.max_retries}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => patch({ max_retries: v ?? 0 })}
|
||||||
|
/>
|
||||||
|
{validationErrors.max_retries && (
|
||||||
|
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||||||
|
{validationErrors.max_retries}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NumberField
|
||||||
|
label="Max Concurrent Coders"
|
||||||
|
description="Maximum number of coder-stage agents running at once. Leave blank for unlimited."
|
||||||
|
value={s.max_coders}
|
||||||
|
min={1}
|
||||||
|
placeholder="unlimited"
|
||||||
|
onChange={(v) => patch({ max_coders: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Default Coder Model"
|
||||||
|
description="When set, only coder agents matching this model are auto-assigned (e.g. sonnet, opus)."
|
||||||
|
value={s.default_coder_model ?? ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
patch({ default_coder_model: v.trim() || null })
|
||||||
|
}
|
||||||
|
placeholder="e.g. sonnet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Git */}
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Git</div>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Base Branch"
|
||||||
|
description="Overrides auto-detection of the merge target branch (e.g. main, master, develop)."
|
||||||
|
value={s.base_branch ?? ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
patch({ base_branch: v.trim() || null })
|
||||||
|
}
|
||||||
|
placeholder="e.g. master"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Notifications</div>
|
||||||
|
|
||||||
|
<CheckboxField
|
||||||
|
label="Rate Limit Notifications"
|
||||||
|
description="Send chat notifications on soft API rate-limit warnings. Disable to reduce noise."
|
||||||
|
checked={s.rate_limit_notifications}
|
||||||
|
onChange={(v) => patch({ rate_limit_notifications: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced */}
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Advanced</div>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Timezone"
|
||||||
|
description="IANA timezone for timer inputs (e.g. Europe/London, America/New_York). Leave blank for system default."
|
||||||
|
value={s.timezone ?? ""}
|
||||||
|
onChange={(v) => patch({ timezone: v.trim() || null })}
|
||||||
|
placeholder="e.g. Europe/London"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Rendezvous URL"
|
||||||
|
description="WebSocket URL of a remote huskies node for CRDT state sync (e.g. ws://host:3001/crdt-sync)."
|
||||||
|
value={s.rendezvous ?? ""}
|
||||||
|
onChange={(v) => patch({ rendezvous: v.trim() || null })}
|
||||||
|
placeholder="e.g. ws://host:3001/crdt-sync"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Watcher */}
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Archiver</div>
|
||||||
|
|
||||||
|
<NumberField
|
||||||
|
label="Sweep Interval (seconds)"
|
||||||
|
description="How often to check the done stage for items ready to archive. Default: 60."
|
||||||
|
value={s.watcher_sweep_interval_secs}
|
||||||
|
min={1}
|
||||||
|
onChange={(v) =>
|
||||||
|
patch({ watcher_sweep_interval_secs: v ?? 60 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{validationErrors.watcher_sweep_interval_secs && (
|
||||||
|
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||||||
|
{validationErrors.watcher_sweep_interval_secs}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NumberField
|
||||||
|
label="Done Retention (seconds)"
|
||||||
|
description="How long an item must stay in the done stage before archiving. Default: 14400 (4 hours)."
|
||||||
|
value={s.watcher_done_retention_secs}
|
||||||
|
min={1}
|
||||||
|
onChange={(v) =>
|
||||||
|
patch({ watcher_done_retention_secs: v ?? 14400 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{validationErrors.watcher_done_retention_secs && (
|
||||||
|
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||||||
|
{validationErrors.watcher_done_retention_secs}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={status === "saving"}
|
||||||
|
style={{
|
||||||
|
padding: "8px 24px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "none",
|
||||||
|
background:
|
||||||
|
status === "saved" ? "#1a5c2a" : "#2563eb",
|
||||||
|
color: "#fff",
|
||||||
|
cursor:
|
||||||
|
status === "saving" ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: status === "saving" ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === "saving"
|
||||||
|
? "Saving…"
|
||||||
|
: status === "saved"
|
||||||
|
? "Saved!"
|
||||||
|
: "Save"}
|
||||||
|
</button>
|
||||||
|
{status === "error" && errorMsg && (
|
||||||
|
<span style={{ color: "#f08080", fontSize: "0.85em" }}>
|
||||||
|
{errorMsg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+411
-1
@@ -1,13 +1,181 @@
|
|||||||
//! HTTP settings endpoints — REST API for user preferences and editor configuration.
|
//! HTTP settings endpoints — REST API for user preferences and editor configuration.
|
||||||
|
use crate::config::ProjectConfig;
|
||||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||||
use crate::store::StoreOps;
|
use crate::store::StoreOps;
|
||||||
use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json};
|
use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
const EDITOR_COMMAND_KEY: &str = "editor_command";
|
const EDITOR_COMMAND_KEY: &str = "editor_command";
|
||||||
|
|
||||||
|
/// Project-level settings exposed via `GET /api/settings` and `PUT /api/settings`.
|
||||||
|
///
|
||||||
|
/// Only contains the scalar fields of `ProjectConfig` — array sections
|
||||||
|
/// (`[[component]]`, `[[agent]]`, `[watcher]`) are preserved in the TOML file
|
||||||
|
/// and are not editable through this API.
|
||||||
|
#[derive(Debug, Object, Serialize, Deserialize)]
|
||||||
|
struct ProjectSettings {
|
||||||
|
/// Project-wide default QA mode: "server", "agent", or "human". Default: "server".
|
||||||
|
default_qa: String,
|
||||||
|
/// Default model for coder-stage agents (e.g. "sonnet"). When set, only agents whose
|
||||||
|
/// model matches this value are used for auto-assignment.
|
||||||
|
default_coder_model: Option<String>,
|
||||||
|
/// Maximum number of concurrent coder-stage agents. When set, stories wait in
|
||||||
|
/// 2_current/ until a slot is free.
|
||||||
|
max_coders: Option<u32>,
|
||||||
|
/// Maximum retries per story per pipeline stage before marking as blocked. Default: 2.
|
||||||
|
max_retries: u32,
|
||||||
|
/// Optional base branch name (e.g. "main", "master"). Overrides auto-detection.
|
||||||
|
base_branch: Option<String>,
|
||||||
|
/// Whether to send RateLimitWarning chat notifications. Default: true.
|
||||||
|
rate_limit_notifications: bool,
|
||||||
|
/// IANA timezone name (e.g. "Europe/London"). Timer inputs are interpreted in this tz.
|
||||||
|
timezone: Option<String>,
|
||||||
|
/// WebSocket URL of a remote huskies node to sync CRDT state with.
|
||||||
|
rendezvous: Option<String>,
|
||||||
|
/// How often (seconds) to check 5_done/ for items to archive. Default: 60.
|
||||||
|
watcher_sweep_interval_secs: u64,
|
||||||
|
/// How long (seconds) an item must remain in 5_done/ before archiving. Default: 14400.
|
||||||
|
watcher_done_retention_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load `ProjectSettings` from `ProjectConfig`.
|
||||||
|
fn settings_from_config(cfg: &ProjectConfig) -> ProjectSettings {
|
||||||
|
ProjectSettings {
|
||||||
|
default_qa: cfg.default_qa.clone(),
|
||||||
|
default_coder_model: cfg.default_coder_model.clone(),
|
||||||
|
max_coders: cfg.max_coders.map(|v| v as u32),
|
||||||
|
max_retries: cfg.max_retries,
|
||||||
|
base_branch: cfg.base_branch.clone(),
|
||||||
|
rate_limit_notifications: cfg.rate_limit_notifications,
|
||||||
|
timezone: cfg.timezone.clone(),
|
||||||
|
rendezvous: cfg.rendezvous.clone(),
|
||||||
|
watcher_sweep_interval_secs: cfg.watcher.sweep_interval_secs,
|
||||||
|
watcher_done_retention_secs: cfg.watcher.done_retention_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the incoming `ProjectSettings` before writing.
|
||||||
|
fn validate_project_settings(s: &ProjectSettings) -> Result<(), String> {
|
||||||
|
match s.default_qa.as_str() {
|
||||||
|
"server" | "agent" | "human" => {}
|
||||||
|
other => {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid default_qa value '{other}'. Must be one of: server, agent, human"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write only the scalar settings from `s` into the project.toml at the given root.
|
||||||
|
/// Array sections (`[[component]]`, `[[agent]]`) are preserved unchanged.
|
||||||
|
fn write_project_settings(project_root: &Path, s: &ProjectSettings) -> Result<(), String> {
|
||||||
|
let config_path = project_root.join(".huskies/project.toml");
|
||||||
|
|
||||||
|
let content = if config_path.exists() {
|
||||||
|
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut val: toml::Value = if content.trim().is_empty() {
|
||||||
|
toml::Value::Table(toml::map::Map::new())
|
||||||
|
} else {
|
||||||
|
toml::from_str(&content).map_err(|e| format!("Parse config: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let table = val
|
||||||
|
.as_table_mut()
|
||||||
|
.ok_or_else(|| "Config is not a TOML table".to_string())?;
|
||||||
|
|
||||||
|
// Scalar root fields
|
||||||
|
table.insert(
|
||||||
|
"default_qa".to_string(),
|
||||||
|
toml::Value::String(s.default_qa.clone()),
|
||||||
|
);
|
||||||
|
table.insert(
|
||||||
|
"max_retries".to_string(),
|
||||||
|
toml::Value::Integer(s.max_retries as i64),
|
||||||
|
);
|
||||||
|
table.insert(
|
||||||
|
"rate_limit_notifications".to_string(),
|
||||||
|
toml::Value::Boolean(s.rate_limit_notifications),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional scalar fields
|
||||||
|
match &s.default_coder_model {
|
||||||
|
Some(v) => {
|
||||||
|
table.insert(
|
||||||
|
"default_coder_model".to_string(),
|
||||||
|
toml::Value::String(v.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
table.remove("default_coder_model");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match s.max_coders {
|
||||||
|
Some(v) => {
|
||||||
|
table.insert("max_coders".to_string(), toml::Value::Integer(v as i64));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
table.remove("max_coders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match &s.base_branch {
|
||||||
|
Some(v) => {
|
||||||
|
table.insert("base_branch".to_string(), toml::Value::String(v.clone()));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
table.remove("base_branch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match &s.timezone {
|
||||||
|
Some(v) => {
|
||||||
|
table.insert("timezone".to_string(), toml::Value::String(v.clone()));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
table.remove("timezone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match &s.rendezvous {
|
||||||
|
Some(v) => {
|
||||||
|
table.insert("rendezvous".to_string(), toml::Value::String(v.clone()));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
table.remove("rendezvous");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [watcher] sub-table
|
||||||
|
let watcher_entry = table
|
||||||
|
.entry("watcher".to_string())
|
||||||
|
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
|
||||||
|
if let toml::Value::Table(wt) = watcher_entry {
|
||||||
|
wt.insert(
|
||||||
|
"sweep_interval_secs".to_string(),
|
||||||
|
toml::Value::Integer(s.watcher_sweep_interval_secs as i64),
|
||||||
|
);
|
||||||
|
wt.insert(
|
||||||
|
"done_retention_secs".to_string(),
|
||||||
|
toml::Value::Integer(s.watcher_done_retention_secs as i64),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure .huskies/ directory exists
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| format!("Create .huskies dir: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_content = toml::to_string_pretty(&val).map_err(|e| format!("Serialize config: {e}"))?;
|
||||||
|
std::fs::write(&config_path, new_content).map_err(|e| format!("Write config: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Tags)]
|
#[derive(Tags)]
|
||||||
enum SettingsTags {
|
enum SettingsTags {
|
||||||
Settings,
|
Settings,
|
||||||
@@ -71,6 +239,30 @@ impl SettingsApi {
|
|||||||
Ok(Json(OpenFileResponse { success: true }))
|
Ok(Json(OpenFileResponse { success: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get current project.toml scalar settings as JSON.
|
||||||
|
#[oai(path = "/settings", method = "get")]
|
||||||
|
async fn get_settings(&self) -> OpenApiResult<Json<ProjectSettings>> {
|
||||||
|
let project_root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let config = ProjectConfig::load(&project_root).map_err(bad_request)?;
|
||||||
|
Ok(Json(settings_from_config(&config)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update project.toml scalar settings. Array sections (component, agent) are preserved.
|
||||||
|
///
|
||||||
|
/// Returns 400 if the input fails validation (e.g. unknown qa mode, negative max_retries).
|
||||||
|
#[oai(path = "/settings", method = "put")]
|
||||||
|
async fn put_settings(
|
||||||
|
&self,
|
||||||
|
payload: Json<ProjectSettings>,
|
||||||
|
) -> OpenApiResult<Json<ProjectSettings>> {
|
||||||
|
validate_project_settings(&payload.0).map_err(bad_request)?;
|
||||||
|
let project_root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
write_project_settings(&project_root, &payload.0).map_err(bad_request)?;
|
||||||
|
// Re-read to confirm what was written
|
||||||
|
let config = ProjectConfig::load(&project_root).map_err(bad_request)?;
|
||||||
|
Ok(Json(settings_from_config(&config)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the preferred editor command (e.g. "zed", "code", "cursor").
|
/// Set the preferred editor command (e.g. "zed", "code", "cursor").
|
||||||
/// Pass null or empty string to clear the preference.
|
/// Pass null or empty string to clear the preference.
|
||||||
#[oai(path = "/settings/editor", method = "put")]
|
#[oai(path = "/settings/editor", method = "put")]
|
||||||
@@ -360,4 +552,222 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── /api/settings GET/PUT ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn default_project_settings() -> ProjectSettings {
|
||||||
|
let cfg = ProjectConfig::default();
|
||||||
|
settings_from_config(&cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_settings_returns_defaults_when_no_project_toml() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
// Create .huskies dir so project root detection works but no project.toml
|
||||||
|
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||||
|
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||||
|
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||||
|
let result = api.get_settings().await.unwrap().0;
|
||||||
|
assert_eq!(result.default_qa, "server");
|
||||||
|
assert_eq!(result.max_retries, 2);
|
||||||
|
assert!(result.rate_limit_notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn put_settings_writes_and_returns_settings() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||||
|
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||||
|
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||||
|
|
||||||
|
let mut s = default_project_settings();
|
||||||
|
s.default_qa = "agent".to_string();
|
||||||
|
s.max_retries = 5;
|
||||||
|
s.rate_limit_notifications = false;
|
||||||
|
|
||||||
|
let result = api.put_settings(Json(s)).await.unwrap().0;
|
||||||
|
assert_eq!(result.default_qa, "agent");
|
||||||
|
assert_eq!(result.max_retries, 5);
|
||||||
|
assert!(!result.rate_limit_notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn put_settings_preserves_agent_sections() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let huskies_dir = dir.path().join(".huskies");
|
||||||
|
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||||
|
|
||||||
|
// Write a project.toml with agent sections
|
||||||
|
std::fs::write(
|
||||||
|
huskies_dir.join("project.toml"),
|
||||||
|
r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-1"
|
||||||
|
model = "sonnet"
|
||||||
|
stage = "coder"
|
||||||
|
|
||||||
|
[[component]]
|
||||||
|
name = "server"
|
||||||
|
path = "."
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||||
|
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||||
|
|
||||||
|
let mut s = default_project_settings();
|
||||||
|
s.default_qa = "human".to_string();
|
||||||
|
api.put_settings(Json(s)).await.unwrap();
|
||||||
|
|
||||||
|
// Re-read the file and verify agent/component sections are still there
|
||||||
|
let written = std::fs::read_to_string(huskies_dir.join("project.toml")).unwrap();
|
||||||
|
assert!(
|
||||||
|
written.contains("coder-1"),
|
||||||
|
"agent section should be preserved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
written.contains("server"),
|
||||||
|
"component section should be preserved"
|
||||||
|
);
|
||||||
|
assert!(written.contains("human"), "new setting should be written");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn put_settings_rejects_invalid_qa_mode() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||||
|
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||||
|
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||||
|
|
||||||
|
let mut s = default_project_settings();
|
||||||
|
s.default_qa = "invalid_mode".to_string();
|
||||||
|
|
||||||
|
let result = api.put_settings(Json(s)).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.status(), poem::http::StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_project_settings_accepts_valid_qa_modes() {
|
||||||
|
for mode in &["server", "agent", "human"] {
|
||||||
|
let s = ProjectSettings {
|
||||||
|
default_qa: mode.to_string(),
|
||||||
|
default_coder_model: None,
|
||||||
|
max_coders: None,
|
||||||
|
max_retries: 2,
|
||||||
|
base_branch: None,
|
||||||
|
rate_limit_notifications: true,
|
||||||
|
timezone: None,
|
||||||
|
rendezvous: None,
|
||||||
|
watcher_sweep_interval_secs: 60,
|
||||||
|
watcher_done_retention_secs: 14400,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
validate_project_settings(&s).is_ok(),
|
||||||
|
"qa mode '{mode}' should be valid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_project_settings_rejects_unknown_qa_mode() {
|
||||||
|
let s = ProjectSettings {
|
||||||
|
default_qa: "robot".to_string(),
|
||||||
|
default_coder_model: None,
|
||||||
|
max_coders: None,
|
||||||
|
max_retries: 2,
|
||||||
|
base_branch: None,
|
||||||
|
rate_limit_notifications: true,
|
||||||
|
timezone: None,
|
||||||
|
rendezvous: None,
|
||||||
|
watcher_sweep_interval_secs: 60,
|
||||||
|
watcher_done_retention_secs: 14400,
|
||||||
|
};
|
||||||
|
let err = validate_project_settings(&s).unwrap_err();
|
||||||
|
assert!(err.contains("robot"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_and_read_project_settings_roundtrip() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||||
|
|
||||||
|
let s = ProjectSettings {
|
||||||
|
default_qa: "agent".to_string(),
|
||||||
|
default_coder_model: Some("opus".to_string()),
|
||||||
|
max_coders: Some(2),
|
||||||
|
max_retries: 3,
|
||||||
|
base_branch: Some("main".to_string()),
|
||||||
|
rate_limit_notifications: false,
|
||||||
|
timezone: Some("America/New_York".to_string()),
|
||||||
|
rendezvous: Some("ws://host:3001/crdt-sync".to_string()),
|
||||||
|
watcher_sweep_interval_secs: 30,
|
||||||
|
watcher_done_retention_secs: 7200,
|
||||||
|
};
|
||||||
|
|
||||||
|
write_project_settings(dir.path(), &s).unwrap();
|
||||||
|
|
||||||
|
let config = ProjectConfig::load(dir.path()).unwrap();
|
||||||
|
let loaded = settings_from_config(&config);
|
||||||
|
|
||||||
|
assert_eq!(loaded.default_qa, "agent");
|
||||||
|
assert_eq!(loaded.default_coder_model, Some("opus".to_string()));
|
||||||
|
assert_eq!(loaded.max_coders, Some(2));
|
||||||
|
assert_eq!(loaded.max_retries, 3);
|
||||||
|
assert_eq!(loaded.base_branch, Some("main".to_string()));
|
||||||
|
assert!(!loaded.rate_limit_notifications);
|
||||||
|
assert_eq!(loaded.timezone, Some("America/New_York".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
loaded.rendezvous,
|
||||||
|
Some("ws://host:3001/crdt-sync".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(loaded.watcher_sweep_interval_secs, 30);
|
||||||
|
assert_eq!(loaded.watcher_done_retention_secs, 7200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_project_settings_clears_optional_fields_when_none() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let huskies_dir = dir.path().join(".huskies");
|
||||||
|
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||||
|
|
||||||
|
// First write with optional fields set
|
||||||
|
let s_with = ProjectSettings {
|
||||||
|
default_qa: "server".to_string(),
|
||||||
|
default_coder_model: Some("sonnet".to_string()),
|
||||||
|
max_coders: Some(3),
|
||||||
|
max_retries: 2,
|
||||||
|
base_branch: Some("master".to_string()),
|
||||||
|
rate_limit_notifications: true,
|
||||||
|
timezone: Some("UTC".to_string()),
|
||||||
|
rendezvous: None,
|
||||||
|
watcher_sweep_interval_secs: 60,
|
||||||
|
watcher_done_retention_secs: 14400,
|
||||||
|
};
|
||||||
|
write_project_settings(dir.path(), &s_with).unwrap();
|
||||||
|
|
||||||
|
// Then write with optional fields cleared
|
||||||
|
let s_clear = ProjectSettings {
|
||||||
|
default_qa: "server".to_string(),
|
||||||
|
default_coder_model: None,
|
||||||
|
max_coders: None,
|
||||||
|
max_retries: 2,
|
||||||
|
base_branch: None,
|
||||||
|
rate_limit_notifications: true,
|
||||||
|
timezone: None,
|
||||||
|
rendezvous: None,
|
||||||
|
watcher_sweep_interval_secs: 60,
|
||||||
|
watcher_done_retention_secs: 14400,
|
||||||
|
};
|
||||||
|
write_project_settings(dir.path(), &s_clear).unwrap();
|
||||||
|
|
||||||
|
let config = ProjectConfig::load(dir.path()).unwrap();
|
||||||
|
let loaded = settings_from_config(&config);
|
||||||
|
assert!(loaded.default_coder_model.is_none());
|
||||||
|
assert!(loaded.max_coders.is_none());
|
||||||
|
assert!(loaded.base_branch.is_none());
|
||||||
|
assert!(loaded.timezone.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user