huskies: merge 643_story_web_ui_consumer_for_the_unified_status_broadcaster
This commit is contained in:
@@ -108,7 +108,50 @@ export type WsResponse =
|
|||||||
/** Final signal that the /btw side question has been fully answered. */
|
/** Final signal that the /btw side question has been fully answered. */
|
||||||
| { type: "side_question_done"; response: string }
|
| { type: "side_question_done"; response: string }
|
||||||
/** A single server log entry (bulk on connect, then live). */
|
/** A single server log entry (bulk on connect, then live). */
|
||||||
| { type: "log_entry"; timestamp: string; level: string; message: string };
|
| { type: "log_entry"; timestamp: string; level: string; message: string }
|
||||||
|
/** A structured pipeline status event from the status broadcaster. */
|
||||||
|
| { type: "status_update"; event: StatusEvent };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structured pipeline status event emitted by the status broadcaster.
|
||||||
|
*
|
||||||
|
* The discriminant `type` field enables per-event-type rendering without
|
||||||
|
* parsing strings. All fields from the original event are preserved so
|
||||||
|
* future UI stories can add dedicated icons, banners, or filters.
|
||||||
|
*/
|
||||||
|
export type StatusEvent =
|
||||||
|
| {
|
||||||
|
type: "stage_transition";
|
||||||
|
story_id: string;
|
||||||
|
story_name: string | null;
|
||||||
|
from_stage: string;
|
||||||
|
to_stage: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "merge_failure";
|
||||||
|
story_id: string;
|
||||||
|
story_name: string | null;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "story_blocked";
|
||||||
|
story_id: string;
|
||||||
|
story_name: string | null;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "rate_limit_warning";
|
||||||
|
story_id: string;
|
||||||
|
story_name: string | null;
|
||||||
|
agent_name: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "rate_limit_hard_block";
|
||||||
|
story_id: string;
|
||||||
|
story_name: string | null;
|
||||||
|
agent_name: string;
|
||||||
|
reset_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -478,6 +521,7 @@ export class ChatWebSocket {
|
|||||||
level: string,
|
level: string,
|
||||||
message: string,
|
message: string,
|
||||||
) => void;
|
) => void;
|
||||||
|
private onStatusUpdate?: (event: StatusEvent) => void;
|
||||||
private onConnected?: () => void;
|
private onConnected?: () => void;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private closeTimer?: number;
|
private closeTimer?: number;
|
||||||
@@ -573,6 +617,7 @@ export class ChatWebSocket {
|
|||||||
this.onSideQuestionDone?.(data.response);
|
this.onSideQuestionDone?.(data.response);
|
||||||
if (data.type === "log_entry")
|
if (data.type === "log_entry")
|
||||||
this.onLogEntry?.(data.timestamp, data.level, data.message);
|
this.onLogEntry?.(data.timestamp, data.level, data.message);
|
||||||
|
if (data.type === "status_update") this.onStatusUpdate?.(data.event);
|
||||||
if (data.type === "pong") {
|
if (data.type === "pong") {
|
||||||
window.clearTimeout(this.heartbeatTimeout);
|
window.clearTimeout(this.heartbeatTimeout);
|
||||||
this.heartbeatTimeout = undefined;
|
this.heartbeatTimeout = undefined;
|
||||||
@@ -630,6 +675,7 @@ export class ChatWebSocket {
|
|||||||
onSideQuestionToken?: (content: string) => void;
|
onSideQuestionToken?: (content: string) => void;
|
||||||
onSideQuestionDone?: (response: string) => void;
|
onSideQuestionDone?: (response: string) => void;
|
||||||
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
||||||
|
onStatusUpdate?: (event: StatusEvent) => void;
|
||||||
onConnected?: () => void;
|
onConnected?: () => void;
|
||||||
},
|
},
|
||||||
wsPath = DEFAULT_WS_PATH,
|
wsPath = DEFAULT_WS_PATH,
|
||||||
@@ -650,6 +696,7 @@ export class ChatWebSocket {
|
|||||||
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
||||||
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
||||||
this.onLogEntry = handlers.onLogEntry;
|
this.onLogEntry = handlers.onLogEntry;
|
||||||
|
this.onStatusUpdate = handlers.onStatusUpdate;
|
||||||
this.onConnected = handlers.onConnected;
|
this.onConnected = handlers.onConnected;
|
||||||
this.wsPath = wsPath;
|
this.wsPath = wsPath;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export function Chat({
|
|||||||
setSideQuestion,
|
setSideQuestion,
|
||||||
serverLogs,
|
serverLogs,
|
||||||
storyTokenCosts,
|
storyTokenCosts,
|
||||||
|
statusEvents,
|
||||||
} = useChatWebSocket({
|
} = useChatWebSocket({
|
||||||
setMessages,
|
setMessages,
|
||||||
setLoading,
|
setLoading,
|
||||||
@@ -384,9 +385,7 @@ export function Chat({
|
|||||||
<BotConfigPage onBack={() => setView("chat")} />
|
<BotConfigPage onBack={() => setView("chat")} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{view === "settings" && (
|
{view === "settings" && <SettingsPage onBack={() => setView("chat")} />}
|
||||||
<SettingsPage onBack={() => setView("chat")} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-testid="chat-content-area"
|
data-testid="chat-content-area"
|
||||||
@@ -449,6 +448,7 @@ export function Chat({
|
|||||||
busyAgentNames={busyAgentNames}
|
busyAgentNames={busyAgentNames}
|
||||||
selectedWorkItemId={selectedWorkItemId}
|
selectedWorkItemId={selectedWorkItemId}
|
||||||
serverLogs={serverLogs}
|
serverLogs={serverLogs}
|
||||||
|
statusEvents={statusEvents}
|
||||||
onSelectWorkItem={setSelectedWorkItemId}
|
onSelectWorkItem={setSelectedWorkItemId}
|
||||||
onCloseWorkItem={() => setSelectedWorkItemId(null)}
|
onCloseWorkItem={() => setSelectedWorkItemId(null)}
|
||||||
onStartAgent={handleStartAgent}
|
onStartAgent={handleStartAgent}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { AgentConfigInfo } from "../api/agents";
|
import type { AgentConfigInfo } from "../api/agents";
|
||||||
import type { PipelineStageItem, PipelineState } from "../api/client";
|
import type {
|
||||||
|
PipelineStageItem,
|
||||||
|
PipelineState,
|
||||||
|
StatusEvent,
|
||||||
|
} from "../api/client";
|
||||||
import { AgentPanel } from "./AgentPanel";
|
import { AgentPanel } from "./AgentPanel";
|
||||||
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
||||||
import type { LogEntry } from "./ServerLogsPanel";
|
import type { LogEntry } from "./ServerLogsPanel";
|
||||||
@@ -7,6 +11,25 @@ import { ServerLogsPanel } from "./ServerLogsPanel";
|
|||||||
import { StagePanel } from "./StagePanel";
|
import { StagePanel } from "./StagePanel";
|
||||||
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||||
|
|
||||||
|
/** Format a structured StatusEvent into a human-readable display string.
|
||||||
|
* This conversion happens at render time, not at the WebSocket boundary,
|
||||||
|
* so the original StatusEvent structure is preserved in state. */
|
||||||
|
function formatStatusEventMessage(event: StatusEvent): string {
|
||||||
|
const name = event.story_name ?? event.story_id;
|
||||||
|
switch (event.type) {
|
||||||
|
case "stage_transition":
|
||||||
|
return `${name} — ${event.from_stage} → ${event.to_stage}`;
|
||||||
|
case "merge_failure":
|
||||||
|
return `✗ ${name} — ${event.reason}`;
|
||||||
|
case "story_blocked":
|
||||||
|
return `⊘ ${name} — BLOCKED: ${event.reason}`;
|
||||||
|
case "rate_limit_warning":
|
||||||
|
return `⚠ ${name} — ${event.agent_name} hit an API rate limit`;
|
||||||
|
case "rate_limit_hard_block":
|
||||||
|
return `⊗ ${name} — ${event.agent_name} hard rate-limited until ${event.reset_at}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatPipelinePanelProps {
|
interface ChatPipelinePanelProps {
|
||||||
isNarrowScreen: boolean;
|
isNarrowScreen: boolean;
|
||||||
pipeline: PipelineState;
|
pipeline: PipelineState;
|
||||||
@@ -18,6 +41,8 @@ interface ChatPipelinePanelProps {
|
|||||||
busyAgentNames: Set<string>;
|
busyAgentNames: Set<string>;
|
||||||
selectedWorkItemId: string | null;
|
selectedWorkItemId: string | null;
|
||||||
serverLogs: LogEntry[];
|
serverLogs: LogEntry[];
|
||||||
|
/** Structured pipeline status events forwarded from the status broadcaster. */
|
||||||
|
statusEvents: Array<{ receivedAt: string; event: StatusEvent }>;
|
||||||
onSelectWorkItem: (id: string) => void;
|
onSelectWorkItem: (id: string) => void;
|
||||||
onCloseWorkItem: () => void;
|
onCloseWorkItem: () => void;
|
||||||
onStartAgent: (storyId: string, agentName?: string) => void;
|
onStartAgent: (storyId: string, agentName?: string) => void;
|
||||||
@@ -36,12 +61,28 @@ export function ChatPipelinePanel({
|
|||||||
busyAgentNames,
|
busyAgentNames,
|
||||||
selectedWorkItemId,
|
selectedWorkItemId,
|
||||||
serverLogs,
|
serverLogs,
|
||||||
|
statusEvents,
|
||||||
onSelectWorkItem,
|
onSelectWorkItem,
|
||||||
onCloseWorkItem,
|
onCloseWorkItem,
|
||||||
onStartAgent,
|
onStartAgent,
|
||||||
onStopAgent,
|
onStopAgent,
|
||||||
onDeleteItem,
|
onDeleteItem,
|
||||||
}: ChatPipelinePanelProps) {
|
}: ChatPipelinePanelProps) {
|
||||||
|
// Convert structured status events to LogEntry format for display in the
|
||||||
|
// existing log area. Structure is preserved in the statusEvents array itself.
|
||||||
|
const statusLogEntries: LogEntry[] = statusEvents.map(
|
||||||
|
({ receivedAt, event }) => ({
|
||||||
|
timestamp: receivedAt,
|
||||||
|
level:
|
||||||
|
event.type === "merge_failure" ||
|
||||||
|
event.type === "story_blocked" ||
|
||||||
|
event.type === "rate_limit_hard_block"
|
||||||
|
? "WARN"
|
||||||
|
: "INFO",
|
||||||
|
message: formatStatusEventMessage(event),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const combinedLogs = [...statusLogEntries, ...serverLogs];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="chat-right-column"
|
data-testid="chat-right-column"
|
||||||
@@ -115,7 +156,7 @@ export function ChatPipelinePanel({
|
|||||||
onStopAgent={onStopAgent}
|
onStopAgent={onStopAgent}
|
||||||
onDeleteItem={onDeleteItem}
|
onDeleteItem={onDeleteItem}
|
||||||
/>
|
/>
|
||||||
<ServerLogsPanel logs={serverLogs} />
|
<ServerLogsPanel logs={combinedLogs} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</LozengeFlyProvider>
|
</LozengeFlyProvider>
|
||||||
|
|||||||
@@ -64,7 +64,13 @@ interface TextFieldProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextField({ label, description, value, onChange, placeholder }: TextFieldProps) {
|
function TextField({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: TextFieldProps) {
|
||||||
return (
|
return (
|
||||||
<div style={fieldStyle}>
|
<div style={fieldStyle}>
|
||||||
<label style={labelStyle}>{label}</label>
|
<label style={labelStyle}>{label}</label>
|
||||||
@@ -90,7 +96,14 @@ interface NumberFieldProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NumberField({ label, description, value, onChange, min, placeholder }: NumberFieldProps) {
|
function NumberField({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min,
|
||||||
|
placeholder,
|
||||||
|
}: NumberFieldProps) {
|
||||||
return (
|
return (
|
||||||
<div style={fieldStyle}>
|
<div style={fieldStyle}>
|
||||||
<label style={labelStyle}>{label}</label>
|
<label style={labelStyle}>{label}</label>
|
||||||
@@ -122,7 +135,12 @@ interface CheckboxFieldProps {
|
|||||||
onChange: (v: boolean) => void;
|
onChange: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckboxField({ label, description, checked, onChange }: CheckboxFieldProps) {
|
function CheckboxField({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: CheckboxFieldProps) {
|
||||||
return (
|
return (
|
||||||
<div style={fieldStyle}>
|
<div style={fieldStyle}>
|
||||||
{description && <span style={descStyle}>{description}</span>}
|
{description && <span style={descStyle}>{description}</span>}
|
||||||
@@ -152,9 +170,13 @@ const QA_MODES = ["server", "agent", "human"] as const;
|
|||||||
/** Settings page — form-based editor for project.toml scalar settings. */
|
/** Settings page — form-based editor for project.toml scalar settings. */
|
||||||
export function SettingsPage({ onBack }: SettingsPageProps) {
|
export function SettingsPage({ onBack }: SettingsPageProps) {
|
||||||
const [settings, setSettings] = useState<ProjectSettings | null>(null);
|
const [settings, setSettings] = useState<ProjectSettings | null>(null);
|
||||||
const [status, setStatus] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
|
const [status, setStatus] = useState<
|
||||||
|
"idle" | "loading" | "saving" | "saved" | "error"
|
||||||
|
>("loading");
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
const [validationErrors, setValidationErrors] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
settingsApi
|
settingsApi
|
||||||
@@ -251,7 +273,9 @@ export function SettingsPage({ onBack }: SettingsPageProps) {
|
|||||||
>
|
>
|
||||||
← Back
|
← Back
|
||||||
</button>
|
</button>
|
||||||
<span style={{ fontWeight: 700, fontSize: "1em" }}>Project Settings</span>
|
<span style={{ fontWeight: 700, fontSize: "1em" }}>
|
||||||
|
Project Settings
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
@@ -284,8 +308,8 @@ export function SettingsPage({ onBack }: SettingsPageProps) {
|
|||||||
<div style={fieldStyle}>
|
<div style={fieldStyle}>
|
||||||
<label style={labelStyle}>Default QA Mode</label>
|
<label style={labelStyle}>Default QA Mode</label>
|
||||||
<span style={descStyle}>
|
<span style={descStyle}>
|
||||||
How stories are QA-reviewed after the coder stage.
|
How stories are QA-reviewed after the coder stage. Default:
|
||||||
Default: server.
|
server.
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
value={s.default_qa}
|
value={s.default_qa}
|
||||||
@@ -346,9 +370,7 @@ export function SettingsPage({ onBack }: SettingsPageProps) {
|
|||||||
label="Base Branch"
|
label="Base Branch"
|
||||||
description="Overrides auto-detection of the merge target branch (e.g. main, master, develop)."
|
description="Overrides auto-detection of the merge target branch (e.g. main, master, develop)."
|
||||||
value={s.base_branch ?? ""}
|
value={s.base_branch ?? ""}
|
||||||
onChange={(v) =>
|
onChange={(v) => patch({ base_branch: v.trim() || null })}
|
||||||
patch({ base_branch: v.trim() || null })
|
|
||||||
}
|
|
||||||
placeholder="e.g. master"
|
placeholder="e.g. master"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -431,11 +453,9 @@ export function SettingsPage({ onBack }: SettingsPageProps) {
|
|||||||
padding: "8px 24px",
|
padding: "8px 24px",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
border: "none",
|
border: "none",
|
||||||
background:
|
background: status === "saved" ? "#1a5c2a" : "#2563eb",
|
||||||
status === "saved" ? "#1a5c2a" : "#2563eb",
|
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
cursor:
|
cursor: status === "saving" ? "not-allowed" : "pointer",
|
||||||
status === "saving" ? "not-allowed" : "pointer",
|
|
||||||
fontSize: "0.9em",
|
fontSize: "0.9em",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
opacity: status === "saving" ? 0.7 : 1,
|
opacity: status === "saving" ? 0.7 : 1,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { PipelineState, WizardStateData } from "../api/client";
|
import type {
|
||||||
|
PipelineState,
|
||||||
|
StatusEvent,
|
||||||
|
WizardStateData,
|
||||||
|
} from "../api/client";
|
||||||
import { api, ChatWebSocket } from "../api/client";
|
import { api, ChatWebSocket } from "../api/client";
|
||||||
import type { LogEntry } from "../components/ServerLogsPanel";
|
import type { LogEntry } from "../components/ServerLogsPanel";
|
||||||
import type { Message } from "../types";
|
import type { Message } from "../types";
|
||||||
@@ -68,6 +72,9 @@ export interface UseChatWebSocketResult {
|
|||||||
} | null>;
|
} | null>;
|
||||||
serverLogs: LogEntry[];
|
serverLogs: LogEntry[];
|
||||||
storyTokenCosts: Map<string, number>;
|
storyTokenCosts: Map<string, number>;
|
||||||
|
/** Structured pipeline status events. Each entry preserves the full
|
||||||
|
* StatusEvent so future UI stories can render per-type icons or filters. */
|
||||||
|
statusEvents: Array<{ receivedAt: string; event: StatusEvent }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChatWebSocket({
|
export function useChatWebSocket({
|
||||||
@@ -116,6 +123,9 @@ export function useChatWebSocket({
|
|||||||
const [storyTokenCosts, setStoryTokenCosts] = useState<Map<string, number>>(
|
const [storyTokenCosts, setStoryTokenCosts] = useState<Map<string, number>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
|
const [statusEvents, setStatusEvents] = useState<
|
||||||
|
Array<{ receivedAt: string; event: StatusEvent }>
|
||||||
|
>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ws = new ChatWebSocket();
|
const ws = new ChatWebSocket();
|
||||||
@@ -240,6 +250,14 @@ export function useChatWebSocket({
|
|||||||
onLogEntry: (timestamp, level, message) => {
|
onLogEntry: (timestamp, level, message) => {
|
||||||
setServerLogs((prev) => [...prev, { timestamp, level, message }]);
|
setServerLogs((prev) => [...prev, { timestamp, level, message }]);
|
||||||
},
|
},
|
||||||
|
onStatusUpdate: (event) => {
|
||||||
|
// Preserve the structured event and receive timestamp so future stories
|
||||||
|
// can render per-type icons, banners, or filters without format changes.
|
||||||
|
setStatusEvents((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ receivedAt: new Date().toISOString(), event },
|
||||||
|
]);
|
||||||
|
},
|
||||||
onConnected: () => {
|
onConnected: () => {
|
||||||
setWsConnected(true);
|
setWsConnected(true);
|
||||||
},
|
},
|
||||||
@@ -276,5 +294,6 @@ export function useChatWebSocket({
|
|||||||
setSideQuestion,
|
setSideQuestion,
|
||||||
serverLogs,
|
serverLogs,
|
||||||
storyTokenCosts,
|
storyTokenCosts,
|
||||||
|
statusEvents,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ pub struct ProjectConfig {
|
|||||||
/// Default: `true`.
|
/// Default: `true`.
|
||||||
#[serde(default = "default_rate_limit_notifications")]
|
#[serde(default = "default_rate_limit_notifications")]
|
||||||
pub rate_limit_notifications: bool,
|
pub rate_limit_notifications: bool,
|
||||||
|
/// Whether the web UI WebSocket consumer subscribes to the status broadcaster.
|
||||||
|
/// Set to `false` to disable status event forwarding to the web UI without
|
||||||
|
/// affecting other consumers (chat transports, agent context).
|
||||||
|
/// Default: `true`.
|
||||||
|
#[serde(default = "default_web_ui_status_consumer")]
|
||||||
|
pub web_ui_status_consumer: bool,
|
||||||
/// IANA timezone name (e.g. `"Europe/London"`, `"America/New_York"`).
|
/// IANA timezone name (e.g. `"Europe/London"`, `"America/New_York"`).
|
||||||
/// When set, timer HH:MM inputs are interpreted in this timezone instead
|
/// When set, timer HH:MM inputs are interpreted in this timezone instead
|
||||||
/// of the container/host local time. Falls back to `chrono::Local` when absent.
|
/// of the container/host local time. Falls back to `chrono::Local` when absent.
|
||||||
@@ -123,6 +129,10 @@ fn default_rate_limit_notifications() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_web_ui_status_consumer() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn default_max_mesh_peers() -> usize {
|
fn default_max_mesh_peers() -> usize {
|
||||||
3
|
3
|
||||||
}
|
}
|
||||||
@@ -252,6 +262,7 @@ impl Default for ProjectConfig {
|
|||||||
max_retries: default_max_retries(),
|
max_retries: default_max_retries(),
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: default_rate_limit_notifications(),
|
rate_limit_notifications: default_rate_limit_notifications(),
|
||||||
|
web_ui_status_consumer: default_web_ui_status_consumer(),
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -333,6 +344,7 @@ impl ProjectConfig {
|
|||||||
max_retries: legacy.max_retries,
|
max_retries: legacy.max_retries,
|
||||||
base_branch: legacy.base_branch,
|
base_branch: legacy.base_branch,
|
||||||
rate_limit_notifications: legacy.rate_limit_notifications,
|
rate_limit_notifications: legacy.rate_limit_notifications,
|
||||||
|
web_ui_status_consumer: default_web_ui_status_consumer(),
|
||||||
timezone: legacy.timezone,
|
timezone: legacy.timezone,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -365,6 +377,7 @@ impl ProjectConfig {
|
|||||||
max_retries: legacy.max_retries,
|
max_retries: legacy.max_retries,
|
||||||
base_branch: legacy.base_branch,
|
base_branch: legacy.base_branch,
|
||||||
rate_limit_notifications: legacy.rate_limit_notifications,
|
rate_limit_notifications: legacy.rate_limit_notifications,
|
||||||
|
web_ui_status_consumer: default_web_ui_status_consumer(),
|
||||||
timezone: legacy.timezone,
|
timezone: legacy.timezone,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -385,6 +398,7 @@ impl ProjectConfig {
|
|||||||
max_retries: legacy.max_retries,
|
max_retries: legacy.max_retries,
|
||||||
base_branch: legacy.base_branch,
|
base_branch: legacy.base_branch,
|
||||||
rate_limit_notifications: legacy.rate_limit_notifications,
|
rate_limit_notifications: legacy.rate_limit_notifications,
|
||||||
|
web_ui_status_consumer: default_web_ui_status_consumer(),
|
||||||
timezone: legacy.timezone,
|
timezone: legacy.timezone,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! WebSocket transport adapter — accept connection, serialise/deserialise frames,
|
//! WebSocket transport adapter — accept connection, serialise/deserialise frames,
|
||||||
//! invoke service methods. No business logic, no inline state transitions.
|
//! invoke service methods. No business logic, no inline state transitions.
|
||||||
|
|
||||||
|
use crate::config::ProjectConfig;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::llm::chat;
|
use crate::llm::chat;
|
||||||
use crate::service::ws::{self, WsResponse};
|
use crate::service::ws::{self, WsResponse};
|
||||||
@@ -56,6 +57,18 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
|||||||
ws::subscribe_watcher(tx.clone(), ctx.clone(), ctx.watcher_tx.subscribe());
|
ws::subscribe_watcher(tx.clone(), ctx.clone(), ctx.watcher_tx.subscribe());
|
||||||
ws::subscribe_reconciliation(tx.clone(), ctx.reconciliation_tx.subscribe());
|
ws::subscribe_reconciliation(tx.clone(), ctx.reconciliation_tx.subscribe());
|
||||||
|
|
||||||
|
// Subscribe to the status broadcaster if web UI consumer is enabled (default: true).
|
||||||
|
let status_enabled = ctx
|
||||||
|
.state
|
||||||
|
.get_project_root()
|
||||||
|
.ok()
|
||||||
|
.and_then(|root| ProjectConfig::load(&root).ok())
|
||||||
|
.map(|c| c.web_ui_status_consumer)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if status_enabled {
|
||||||
|
ws::subscribe_status(tx.clone(), ctx.services.status.subscribe());
|
||||||
|
}
|
||||||
|
|
||||||
// Map of pending permission request_id -> oneshot responder.
|
// Map of pending permission request_id -> oneshot responder.
|
||||||
let mut pending_perms: HashMap<String, oneshot::Sender<PermissionDecision>> =
|
let mut pending_perms: HashMap<String, oneshot::Sender<PermissionDecision>> =
|
||||||
HashMap::new();
|
HashMap::new();
|
||||||
@@ -230,6 +243,7 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
|
use crate::service::status::StatusEvent;
|
||||||
|
|
||||||
// ── ws_handler integration tests (real WebSocket connection) ─────
|
// ── ws_handler integration tests (real WebSocket connection) ─────
|
||||||
|
|
||||||
@@ -534,4 +548,158 @@ mod tests {
|
|||||||
let (_sink2, _stream2, initial2) = connect_ws(&url).await;
|
let (_sink2, _stream2, initial2) = connect_ws(&url).await;
|
||||||
assert_eq!(initial2["type"], "pipeline_state");
|
assert_eq!(initial2["type"], "pipeline_state");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the next `status_update` whose story_id or story_name contains `needle`,
|
||||||
|
/// within a timeout. Skips `log_entry` noise and unrelated status events so
|
||||||
|
/// genuine server log noise cannot cause false positives or negatives.
|
||||||
|
async fn next_status_update_containing(
|
||||||
|
stream: &mut futures::stream::SplitStream<
|
||||||
|
tokio_tungstenite::WebSocketStream<
|
||||||
|
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
|
needle: &str,
|
||||||
|
timeout_ms: u64,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
|
||||||
|
loop {
|
||||||
|
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||||
|
if remaining.is_zero() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let msg = tokio::time::timeout(remaining, stream.next())
|
||||||
|
.await
|
||||||
|
.ok()?
|
||||||
|
.expect("stream ended")
|
||||||
|
.expect("ws error");
|
||||||
|
let val: serde_json::Value = match msg {
|
||||||
|
tungstenite::Message::Text(t) => serde_json::from_str(t.as_ref()).ok()?,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
if val["type"] == "status_update" {
|
||||||
|
let event = &val["event"];
|
||||||
|
let story_id = event["story_id"].as_str().unwrap_or("");
|
||||||
|
let story_name = event["story_name"].as_str().unwrap_or("");
|
||||||
|
if story_id.contains(needle) || story_name.contains(needle) {
|
||||||
|
return Some(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Skip log_entry and other unrelated messages.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status broadcaster integration tests ─────────────────────────
|
||||||
|
|
||||||
|
/// Publishing a status event via `services.status` must result in a
|
||||||
|
/// `status_update` WebSocket message with structured fields delivered to the
|
||||||
|
/// connected client.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ws_handler_forwards_status_events_as_status_update() {
|
||||||
|
let (url, ctx) = start_test_server().await;
|
||||||
|
let (_sink, mut stream, _initial) = connect_ws(&url).await;
|
||||||
|
|
||||||
|
// Use a story ID unique enough that genuine server logs won't match it.
|
||||||
|
ctx.services.status.publish(StatusEvent::StageTransition {
|
||||||
|
story_id: "77_story_status_fwd_test".to_string(),
|
||||||
|
story_name: Some("StatusFwdTest".to_string()),
|
||||||
|
from_stage: "1_backlog".to_string(),
|
||||||
|
to_stage: "2_current".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The handler must forward it as a status_update with structured fields.
|
||||||
|
let msg = next_status_update_containing(&mut stream, "StatusFwdTest", 2000)
|
||||||
|
.await
|
||||||
|
.expect("expected a status_update for the status event");
|
||||||
|
assert_eq!(msg["type"], "status_update");
|
||||||
|
let event = &msg["event"];
|
||||||
|
assert_eq!(event["type"], "stage_transition");
|
||||||
|
assert_eq!(event["story_id"], "77_story_status_fwd_test");
|
||||||
|
assert_eq!(event["story_name"], "StatusFwdTest");
|
||||||
|
assert_eq!(event["from_stage"], "1_backlog");
|
||||||
|
assert_eq!(event["to_stage"], "2_current");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multi-project isolation: a client connected to project A's server must
|
||||||
|
/// NOT receive status events published on project B's broadcaster.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ws_handler_multi_project_status_isolation() {
|
||||||
|
// Start two independent servers (each with its own AppContext / Services).
|
||||||
|
let (url_a, ctx_a) = start_test_server().await;
|
||||||
|
let (url_b, _ctx_b) = start_test_server().await;
|
||||||
|
|
||||||
|
let (_sink_a, mut stream_a, _) = connect_ws(&url_a).await;
|
||||||
|
let (_sink_b, mut stream_b, _) = connect_ws(&url_b).await;
|
||||||
|
|
||||||
|
// Use a needle unique enough that genuine server logs won't match.
|
||||||
|
let needle = "ProjAIsolation7734";
|
||||||
|
ctx_a.services.status.publish(StatusEvent::MergeFailure {
|
||||||
|
story_id: "10_story_proj_a_isolation".to_string(),
|
||||||
|
story_name: Some(needle.to_string()),
|
||||||
|
reason: "conflict".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client A must receive the status_update with structured fields.
|
||||||
|
let msg_a = next_status_update_containing(&mut stream_a, needle, 2000)
|
||||||
|
.await
|
||||||
|
.expect("client A should receive the status event");
|
||||||
|
assert_eq!(msg_a["type"], "status_update");
|
||||||
|
assert_eq!(msg_a["event"]["story_name"], needle);
|
||||||
|
|
||||||
|
// Client B must NOT receive any status_update containing the needle.
|
||||||
|
let msg_b = next_status_update_containing(&mut stream_b, needle, 300).await;
|
||||||
|
assert!(
|
||||||
|
msg_b.is_none(),
|
||||||
|
"client B must not receive project A's status event, got: {msg_b:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When `web_ui_status_consumer = false` in project.toml, the WebSocket
|
||||||
|
/// handler must not forward status events to the connected client.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ws_handler_status_consumer_disabled_via_config() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path().to_path_buf();
|
||||||
|
|
||||||
|
// Write a project.toml that disables the web UI status consumer.
|
||||||
|
let huskies_dir = root.join(".huskies");
|
||||||
|
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
huskies_dir.join("project.toml"),
|
||||||
|
"web_ui_status_consumer = false\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
crate::db::ensure_content_store();
|
||||||
|
let ctx = Arc::new(AppContext::new_test(root));
|
||||||
|
let ctx_data = ctx.clone();
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let app = poem::Route::new()
|
||||||
|
.at("/ws", poem::get(ws_handler))
|
||||||
|
.data(ctx_data);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let acceptor = poem::listener::TcpAcceptor::from_tokio(listener).unwrap();
|
||||||
|
let _ = poem::Server::new_with_acceptor(acceptor).run(app).await;
|
||||||
|
});
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
|
let url = format!("ws://127.0.0.1:{}/ws", addr.port());
|
||||||
|
let (_sink, mut stream, _) = connect_ws(&url).await;
|
||||||
|
|
||||||
|
// Use a unique needle — genuine server logs will never contain this.
|
||||||
|
let needle = "DisabledConsumer9182";
|
||||||
|
ctx.services.status.publish(StatusEvent::StoryBlocked {
|
||||||
|
story_id: "55_story_disabled_consumer".to_string(),
|
||||||
|
story_name: Some(needle.to_string()),
|
||||||
|
reason: "test".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consumer is disabled — no status_update with this needle should arrive.
|
||||||
|
let msg = next_status_update_containing(&mut stream, needle, 500).await;
|
||||||
|
assert!(
|
||||||
|
msg.is_none(),
|
||||||
|
"disabled consumer must not forward status events, got: {msg:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ use crate::service::notifications::format::stage_display_name;
|
|||||||
use crate::service::status::StatusEvent;
|
use crate::service::status::StatusEvent;
|
||||||
|
|
||||||
/// Render a [`StatusEvent`] into a human-readable plain-text string.
|
/// Render a [`StatusEvent`] into a human-readable plain-text string.
|
||||||
#[allow(dead_code)]
|
|
||||||
///
|
///
|
||||||
/// This is the single formatter for all status event types. Every transport
|
/// This is the single formatter for all status event types. Every transport
|
||||||
/// (chat, Web UI, agent context) calls this function rather than duplicating
|
/// (chat, Web UI, agent context) calls this function rather than duplicating
|
||||||
/// formatting logic.
|
/// formatting logic.
|
||||||
|
// Used by chat/agent transports (stories 642/644); the web UI uses StatusUpdate frames instead.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn format_status_event(event: &StatusEvent) -> String {
|
pub fn format_status_event(event: &StatusEvent) -> String {
|
||||||
match event {
|
match event {
|
||||||
StatusEvent::StageTransition {
|
StatusEvent::StageTransition {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
pub mod format;
|
pub mod format;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Serialize;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
@@ -44,7 +45,8 @@ const CHANNEL_CAPACITY: usize = 256;
|
|||||||
///
|
///
|
||||||
/// Each variant carries enough context for [`format_status_event`] to render a
|
/// Each variant carries enough context for [`format_status_event`] to render a
|
||||||
/// human-readable message without additional lookups.
|
/// human-readable message without additional lookups.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum StatusEvent {
|
pub enum StatusEvent {
|
||||||
/// A work item moved between pipeline stages.
|
/// A work item moved between pipeline stages.
|
||||||
StageTransition {
|
StageTransition {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::io::onboarding;
|
|||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
use crate::io::wizard;
|
use crate::io::wizard;
|
||||||
use crate::log_buffer;
|
use crate::log_buffer;
|
||||||
|
use crate::service::status::Subscription;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
@@ -116,6 +117,21 @@ pub fn subscribe_watcher(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn a background task that forwards status broadcaster events to the client.
|
||||||
|
///
|
||||||
|
/// Each [`StatusEvent`](crate::service::status::StatusEvent) is delivered as a
|
||||||
|
/// [`WsResponse::StatusUpdate`] with the structured event fields intact, so the
|
||||||
|
/// frontend can do per-type presentation without parsing strings.
|
||||||
|
pub fn subscribe_status(tx: mpsc::UnboundedSender<WsResponse>, mut subscription: Subscription) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(event) = subscription.recv().await {
|
||||||
|
if tx.send(WsResponse::StatusUpdate { event }).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a background task that forwards reconciliation events to the client.
|
/// Spawn a background task that forwards reconciliation events to the client.
|
||||||
pub fn subscribe_reconciliation(
|
pub fn subscribe_reconciliation(
|
||||||
tx: mpsc::UnboundedSender<WsResponse>,
|
tx: mpsc::UnboundedSender<WsResponse>,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::http::workflow::{PipelineState, UpcomingStory};
|
|||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
use crate::llm::chat;
|
use crate::llm::chat;
|
||||||
use crate::llm::types::Message;
|
use crate::llm::types::Message;
|
||||||
|
use crate::service::status::StatusEvent;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// WebSocket request messages sent by the client.
|
/// WebSocket request messages sent by the client.
|
||||||
@@ -153,6 +154,15 @@ pub enum WsResponse {
|
|||||||
level: String,
|
level: String,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
/// A structured pipeline status event forwarded from the status broadcaster.
|
||||||
|
///
|
||||||
|
/// The structured [`StatusEvent`] fields are preserved on the wire so
|
||||||
|
/// frontend consumers can do per-type presentation without parsing strings.
|
||||||
|
/// This frame intentionally does NOT call `format_status_event` — that
|
||||||
|
/// formatter is reserved for chat transports (story 644).
|
||||||
|
StatusUpdate {
|
||||||
|
event: StatusEvent,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Domain event conversions ────────────────────────────────────────────────
|
// ── Domain event conversions ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -20,6 +20,6 @@ pub use dispatch::{
|
|||||||
};
|
};
|
||||||
pub use io::{
|
pub use io::{
|
||||||
check_onboarding, load_initial_pipeline_state, load_recent_logs, load_wizard_state,
|
check_onboarding, load_initial_pipeline_state, load_recent_logs, load_wizard_state,
|
||||||
subscribe_logs, subscribe_reconciliation, subscribe_watcher,
|
subscribe_logs, subscribe_reconciliation, subscribe_status, subscribe_watcher,
|
||||||
};
|
};
|
||||||
pub use message::{WizardStepInfo, WsResponse};
|
pub use message::{WizardStepInfo, WsResponse};
|
||||||
|
|||||||
@@ -528,6 +528,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -557,6 +558,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -586,6 +588,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -615,6 +618,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -643,6 +647,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -678,6 +683,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -754,6 +760,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -788,6 +795,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -869,6 +877,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -906,6 +915,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -933,6 +943,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
@@ -966,6 +977,7 @@ mod tests {
|
|||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
base_branch: None,
|
base_branch: None,
|
||||||
rate_limit_notifications: true,
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
trusted_keys: Vec::new(),
|
trusted_keys: Vec::new(),
|
||||||
|
|||||||
Reference in New Issue
Block a user