chore: switch mergemaster to opus and add cargo fmt guidance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+8
-13
@@ -253,35 +253,30 @@ When the auto-resolver fails, you have access to the merge worktree at `.story_k
|
|||||||
6. Call the `run_tests` MCP tool to start tests, then poll `get_test_result` until complete
|
6. Call the `run_tests` MCP tool to start tests, then poll `get_test_result` until complete
|
||||||
7. If it compiles, commit and re-trigger merge_agent_work
|
7. If it compiles, commit and re-trigger merge_agent_work
|
||||||
|
|
||||||
### Common conflict patterns in this project:
|
### Common conflict patterns:
|
||||||
|
|
||||||
**Story file rename/rename conflicts:** Both branches moved the story .md file to different pipeline directories. Resolution: `git rm` both sides — story files in `work/2_current/`, `work/3_qa/`, `work/4_merge/` are gitignored and don't need to be committed.
|
**Story file rename/rename conflicts:** Both branches moved the story .md file to different pipeline directories. Resolution: `git rm` both sides — story files in pipeline directories are gitignored and don't need to be committed.
|
||||||
|
|
||||||
**bot.rs tokio::select! conflicts:** Master has a `tokio::select!` loop in `handle_message()` that handles permission forwarding (story 275). Feature branches created before story 275 have a simpler direct `provider.chat_stream().await` call. Resolution: KEEP master's tokio::select! loop. Integrate only the feature's new logic (e.g. typing indicators, new callbacks) into the existing loop structure. Do NOT replace the loop with the old direct call.
|
|
||||||
|
|
||||||
**Duplicate functions/imports:** The auto-resolver keeps both sides, producing duplicates. Resolution: keep one copy (prefer master's version), delete the duplicate.
|
**Duplicate functions/imports:** The auto-resolver keeps both sides, producing duplicates. Resolution: keep one copy (prefer master's version), delete the duplicate.
|
||||||
|
|
||||||
**Formatting-only conflicts:** Both sides reformatted the same code differently. Resolution: pick either side (prefer master).
|
**Formatting-only conflicts:** Both sides reformatted the same code differently. Resolution: pick either side (prefer master).
|
||||||
|
|
||||||
**IMPORTANT: After resolving ANY conflict or fixing ANY gate failure in the merge workspace, run `script/lint` (if it exists) or the project's formatter before recommitting.** The auto-resolver frequently produces code that compiles but fails formatting checks. Running the formatter after every fix prevents repeated gate failures.
|
**IMPORTANT: After resolving ANY conflict or fixing ANY gate failure in the merge workspace, use the `run_lint` MCP tool to check formatting, then `run_tests` to verify everything passes before recommitting.** The auto-resolver frequently produces code that compiles but fails formatting or linting checks.
|
||||||
|
|
||||||
## Fixing Gate Failures
|
## Fixing Gate Failures
|
||||||
|
|
||||||
If quality gates fail, attempt to fix issues yourself in the merge worktree. Use the run_tests MCP tool (then poll get_test_result) to verify — do not run script/test via Bash.
|
If quality gates fail, attempt to fix issues yourself in the merge workspace. Use the run_tests MCP tool to verify before recommitting.
|
||||||
|
|
||||||
**Fix yourself (up to 3 attempts total):**
|
**Fix yourself (up to 3 attempts total):**
|
||||||
- Syntax errors (missing semicolons, brackets, commas)
|
- Syntax errors
|
||||||
- Duplicate definitions from merge artifacts
|
- Duplicate definitions from merge artifacts
|
||||||
- Simple type annotation errors
|
- Unused import warnings
|
||||||
- Unused import warnings flagged by clippy
|
- Formatting issues that block linting
|
||||||
- Mismatched braces from bad conflict resolution
|
|
||||||
- Trivial formatting issues that block compilation or linting
|
|
||||||
|
|
||||||
**Report to human without attempting a fix:**
|
**Report to human without attempting a fix:**
|
||||||
- Logic errors or incorrect business logic
|
- Logic errors or incorrect business logic
|
||||||
- Missing function implementations
|
- Missing function implementations
|
||||||
- Architectural changes required
|
- Architectural changes required
|
||||||
- Non-trivial refactoring needed
|
|
||||||
|
|
||||||
**Max retry limit:** If gates still fail after 3 fix attempts, call report_merge_failure to record the failure, then stop immediately and report the full gate output to the human.
|
**Max retry limit:** If gates still fail after 3 fix attempts, call report_merge_failure to record the failure, then stop immediately and report the full gate output to the human.
|
||||||
|
|
||||||
@@ -292,4 +287,4 @@ If quality gates fail, attempt to fix issues yourself in the merge worktree. Use
|
|||||||
- Report conflict resolution outcomes clearly
|
- Report conflict resolution outcomes clearly
|
||||||
- Report gate failures with full output so the human can act if needed
|
- Report gate failures with full output so the human can act if needed
|
||||||
- The server automatically runs acceptance gates when your process exits"""
|
- The server automatically runs acceptance gates when your process exits"""
|
||||||
system_prompt = "You are the mergemaster agent. Your primary job is to merge feature branches to master. First try the merge_agent_work MCP tool. If the auto-resolver fails on complex conflicts, resolve them yourself in the merge worktree — you are an opus-class agent capable of understanding both sides of a conflict and producing correct merged code. Common patterns: keep master's tokio::select! permission loop in bot.rs, discard story file rename conflicts (gitignored), remove duplicate definitions. After resolving, verify compilation before re-triggering merge. CRITICAL: Never manually move story files or call accept_story. After 3 failed fix attempts, call report_merge_failure and stop."
|
system_prompt = "You are the mergemaster agent. Your primary job is to merge feature branches to master. First try the merge_agent_work MCP tool. If the auto-resolver fails on complex conflicts, resolve them yourself in the merge workspace. Common patterns: discard story file rename conflicts (gitignored), remove duplicate definitions/imports. After resolving, verify with run_tests MCP tool before re-triggering merge. CRITICAL: Never manually move story files or call accept_story. After 3 failed fix attempts, call report_merge_failure and stop."
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ export interface JoinedAgent {
|
|||||||
label: string;
|
label: string;
|
||||||
address: string;
|
address: string;
|
||||||
registered_at: number;
|
registered_at: number;
|
||||||
|
/// Project this agent is assigned to, if any.
|
||||||
|
assigned_project?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayProject {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayInfo {
|
||||||
|
active: string;
|
||||||
|
projects: GatewayProject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateTokenResponse {
|
export interface GenerateTokenResponse {
|
||||||
@@ -61,4 +73,17 @@ export const gatewayApi = {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Assign an agent to a project, or unassign it by passing null.
|
||||||
|
assignAgent(id: string, project: string | null): Promise<JoinedAgent> {
|
||||||
|
return gatewayRequest<JoinedAgent>(`/gateway/agents/${id}/assign`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ project }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Get the list of registered projects from the gateway.
|
||||||
|
getGatewayInfo(): Promise<GatewayInfo> {
|
||||||
|
return gatewayRequest<GatewayInfo>("/api/gateway");
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
/// Provides:
|
/// Provides:
|
||||||
/// - An "Add Agent" button that generates a one-time join token.
|
/// - An "Add Agent" button that generates a one-time join token.
|
||||||
/// - Instructions for running a build agent with the token.
|
/// - Instructions for running a build agent with the token.
|
||||||
/// - A list of connected agents with per-agent "Remove" buttons.
|
/// - A list of connected agents with per-agent project assignment and "Remove" buttons.
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { gatewayApi, type JoinedAgent } from "../api/gateway";
|
import { gatewayApi, type JoinedAgent, type GatewayProject } from "../api/gateway";
|
||||||
|
|
||||||
const { useCallback, useEffect, useState } = React;
|
const { useCallback, useEffect, useState } = React;
|
||||||
|
|
||||||
@@ -90,12 +90,17 @@ function TokenDisplay({ token }: { token: string }) {
|
|||||||
|
|
||||||
function AgentRow({
|
function AgentRow({
|
||||||
agent,
|
agent,
|
||||||
|
projects,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onAssign,
|
||||||
}: {
|
}: {
|
||||||
agent: JoinedAgent;
|
agent: JoinedAgent;
|
||||||
|
projects: GatewayProject[];
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
onAssign: (id: string, project: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const registeredAt = new Date(agent.registered_at * 1000).toLocaleString();
|
const registeredAt = new Date(agent.registered_at * 1000).toLocaleString();
|
||||||
|
const isAssigned = Boolean(agent.assigned_project);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -116,9 +121,10 @@ function AgentRow({
|
|||||||
width: "8px",
|
width: "8px",
|
||||||
height: "8px",
|
height: "8px",
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
background: "#3fb950",
|
background: isAssigned ? "#3fb950" : "#6e7681",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
|
title={isAssigned ? "Assigned" : "Idle (unassigned)"}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</div>
|
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</div>
|
||||||
@@ -129,6 +135,29 @@ function AgentRow({
|
|||||||
Registered {registeredAt}
|
Registered {registeredAt}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
|
data-testid={`assign-agent-${agent.id}`}
|
||||||
|
value={agent.assigned_project ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onAssign(agent.id, e.target.value === "" ? null : e.target.value)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8em",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #30363d",
|
||||||
|
background: "#0d1117",
|
||||||
|
color: "#e6edf3",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— unassigned —</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.name} value={p.name}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`remove-agent-${agent.id}`}
|
data-testid={`remove-agent-${agent.id}`}
|
||||||
@@ -152,6 +181,7 @@ function AgentRow({
|
|||||||
/// Gateway management panel — rendered when running in `--gateway` mode.
|
/// Gateway management panel — rendered when running in `--gateway` mode.
|
||||||
export function GatewayPanel() {
|
export function GatewayPanel() {
|
||||||
const [agents, setAgents] = useState<JoinedAgent[]>([]);
|
const [agents, setAgents] = useState<JoinedAgent[]>([]);
|
||||||
|
const [projects, setProjects] = useState<GatewayProject[]>([]);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -161,6 +191,10 @@ export function GatewayPanel() {
|
|||||||
.listAgents()
|
.listAgents()
|
||||||
.then(setAgents)
|
.then(setAgents)
|
||||||
.catch(() => setAgents([]));
|
.catch(() => setAgents([]));
|
||||||
|
gatewayApi
|
||||||
|
.getGatewayInfo()
|
||||||
|
.then((info) => setProjects(info.projects))
|
||||||
|
.catch(() => setProjects([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddAgent = useCallback(async () => {
|
const handleAddAgent = useCallback(async () => {
|
||||||
@@ -186,6 +220,20 @@ export function GatewayPanel() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleAssignAgent = useCallback(
|
||||||
|
async (id: string, project: string | null) => {
|
||||||
|
try {
|
||||||
|
const updated = await gatewayApi.assignAgent(id, project);
|
||||||
|
setAgents((prev) =>
|
||||||
|
prev.map((a) => (a.id === updated.id ? updated : a)),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -273,7 +321,9 @@ export function GatewayPanel() {
|
|||||||
<AgentRow
|
<AgentRow
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
|
projects={projects}
|
||||||
onRemove={handleRemoveAgent}
|
onRemove={handleRemoveAgent}
|
||||||
|
onAssign={handleAssignAgent}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+137
-24
@@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -64,6 +64,9 @@ pub struct JoinedAgent {
|
|||||||
pub address: String,
|
pub address: String,
|
||||||
/// Unix timestamp when the agent registered.
|
/// Unix timestamp when the agent registered.
|
||||||
pub registered_at: f64,
|
pub registered_at: f64,
|
||||||
|
/// Project this agent is assigned to, if any.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub assigned_project: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A one-time join token that has been generated but not yet consumed.
|
/// A one-time join token that has been generated but not yet consumed.
|
||||||
@@ -80,6 +83,14 @@ struct RegisterAgentRequest {
|
|||||||
address: String,
|
address: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request body for assigning or reassigning an agent to a project.
|
||||||
|
///
|
||||||
|
/// Send `{"project": "my-project"}` to assign, or `{"project": null}` to unassign.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AssignAgentRequest {
|
||||||
|
project: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// ── Gateway state ────────────────────────────────────────────────────
|
// ── Gateway state ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Shared gateway state threaded through HTTP handlers.
|
/// Shared gateway state threaded through HTTP handlers.
|
||||||
@@ -95,22 +106,51 @@ pub struct GatewayState {
|
|||||||
pub joined_agents: Arc<RwLock<Vec<JoinedAgent>>>,
|
pub joined_agents: Arc<RwLock<Vec<JoinedAgent>>>,
|
||||||
/// One-time join tokens that have been issued but not yet consumed.
|
/// One-time join tokens that have been issued but not yet consumed.
|
||||||
pending_tokens: Arc<RwLock<HashMap<String, PendingToken>>>,
|
pending_tokens: Arc<RwLock<HashMap<String, PendingToken>>>,
|
||||||
|
/// Directory containing `projects.toml`, used for persisting agent data.
|
||||||
|
pub config_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load persisted agents from `<config_dir>/gateway_agents.json`.
|
||||||
|
/// Returns an empty list if the file does not exist or cannot be parsed.
|
||||||
|
fn load_agents(config_dir: &Path) -> Vec<JoinedAgent> {
|
||||||
|
let path = config_dir.join("gateway_agents.json");
|
||||||
|
match std::fs::read(&path) {
|
||||||
|
Ok(data) => serde_json::from_slice(&data).unwrap_or_default(),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the current agent list to `<config_dir>/gateway_agents.json`.
|
||||||
|
/// Silently ignores write errors (e.g. read-only filesystem or empty path).
|
||||||
|
async fn save_agents(agents: &[JoinedAgent], config_dir: &Path) {
|
||||||
|
if config_dir == Path::new("") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = config_dir.join("gateway_agents.json");
|
||||||
|
if let Ok(data) = serde_json::to_vec_pretty(agents) {
|
||||||
|
let _ = tokio::fs::write(&path, data).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayState {
|
impl GatewayState {
|
||||||
/// Create a new gateway state from a config. The first project in the config
|
/// Create a new gateway state from a config and config directory.
|
||||||
/// becomes the active project by default.
|
///
|
||||||
pub fn new(config: GatewayConfig) -> Result<Self, String> {
|
/// The first project in the config becomes the active project by default.
|
||||||
|
/// Previously registered agents are loaded from `gateway_agents.json` in
|
||||||
|
/// `config_dir` if the file exists.
|
||||||
|
pub fn new(config: GatewayConfig, config_dir: PathBuf) -> Result<Self, String> {
|
||||||
if config.projects.is_empty() {
|
if config.projects.is_empty() {
|
||||||
return Err("projects.toml must define at least one project".to_string());
|
return Err("projects.toml must define at least one project".to_string());
|
||||||
}
|
}
|
||||||
let first = config.projects.keys().next().unwrap().clone();
|
let first = config.projects.keys().next().unwrap().clone();
|
||||||
|
let agents = load_agents(&config_dir);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
config,
|
config,
|
||||||
active_project: Arc::new(RwLock::new(first)),
|
active_project: Arc::new(RwLock::new(first)),
|
||||||
client: Client::new(),
|
client: Client::new(),
|
||||||
joined_agents: Arc::new(RwLock::new(Vec::new())),
|
joined_agents: Arc::new(RwLock::new(agents)),
|
||||||
pending_tokens: Arc::new(RwLock::new(HashMap::new())),
|
pending_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
config_dir,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,6 +662,7 @@ pub async fn gateway_register_agent_handler(
|
|||||||
label: req.label,
|
label: req.label,
|
||||||
address: req.address,
|
address: req.address,
|
||||||
registered_at: chrono::Utc::now().timestamp() as f64,
|
registered_at: chrono::Utc::now().timestamp() as f64,
|
||||||
|
assigned_project: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
crate::slog!(
|
crate::slog!(
|
||||||
@@ -630,7 +671,11 @@ pub async fn gateway_register_agent_handler(
|
|||||||
agent.id
|
agent.id
|
||||||
);
|
);
|
||||||
|
|
||||||
state.joined_agents.write().await.push(agent.clone());
|
{
|
||||||
|
let mut agents = state.joined_agents.write().await;
|
||||||
|
agents.push(agent.clone());
|
||||||
|
save_agents(&agents, &state.config_dir).await;
|
||||||
|
}
|
||||||
|
|
||||||
let body = serde_json::to_vec(&agent).unwrap_or_default();
|
let body = serde_json::to_vec(&agent).unwrap_or_default();
|
||||||
Response::builder()
|
Response::builder()
|
||||||
@@ -656,11 +701,16 @@ pub async fn gateway_remove_agent_handler(
|
|||||||
PoemPath(id): PoemPath<String>,
|
PoemPath(id): PoemPath<String>,
|
||||||
state: Data<&Arc<GatewayState>>,
|
state: Data<&Arc<GatewayState>>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let mut agents = state.joined_agents.write().await;
|
let removed = {
|
||||||
let before = agents.len();
|
let mut agents = state.joined_agents.write().await;
|
||||||
agents.retain(|a| a.id != id);
|
let before = agents.len();
|
||||||
let removed = agents.len() < before;
|
agents.retain(|a| a.id != id);
|
||||||
drop(agents);
|
let removed = agents.len() < before;
|
||||||
|
if removed {
|
||||||
|
save_agents(&agents, &state.config_dir).await;
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
};
|
||||||
|
|
||||||
if removed {
|
if removed {
|
||||||
crate::slog!("[gateway] Removed agent id={id}");
|
crate::slog!("[gateway] Removed agent id={id}");
|
||||||
@@ -674,6 +724,63 @@ pub async fn gateway_remove_agent_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /gateway/agents/:id/assign` — assign or unassign an agent to a project.
|
||||||
|
///
|
||||||
|
/// Body: `{ "project": "my-project" }` to assign, or `{ "project": null }` to unassign.
|
||||||
|
/// Returns the updated `JoinedAgent` on success. The assignment is persisted to disk
|
||||||
|
/// so it survives gateway restarts.
|
||||||
|
#[handler]
|
||||||
|
pub async fn gateway_assign_agent_handler(
|
||||||
|
PoemPath(id): PoemPath<String>,
|
||||||
|
body: Json<AssignAgentRequest>,
|
||||||
|
state: Data<&Arc<GatewayState>>,
|
||||||
|
) -> Response {
|
||||||
|
let project = body
|
||||||
|
.0
|
||||||
|
.project
|
||||||
|
.and_then(|p| if p.is_empty() { None } else { Some(p) });
|
||||||
|
|
||||||
|
if let Some(ref p) = project
|
||||||
|
&& !state.config.projects.contains_key(p.as_str())
|
||||||
|
{
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::BAD_REQUEST)
|
||||||
|
.body(Body::from(format!("unknown project '{p}'")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = {
|
||||||
|
let mut agents = state.joined_agents.write().await;
|
||||||
|
match agents.iter_mut().find(|a| a.id == id) {
|
||||||
|
None => None,
|
||||||
|
Some(a) => {
|
||||||
|
a.assigned_project = project;
|
||||||
|
Some(a.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match updated {
|
||||||
|
None => Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::from("agent not found")),
|
||||||
|
Some(agent) => {
|
||||||
|
crate::slog!(
|
||||||
|
"[gateway] Agent '{}' (id={}) assigned to {:?}",
|
||||||
|
agent.label,
|
||||||
|
agent.id,
|
||||||
|
agent.assigned_project
|
||||||
|
);
|
||||||
|
let agents = state.joined_agents.read().await.clone();
|
||||||
|
save_agents(&agents, &state.config_dir).await;
|
||||||
|
let body = serde_json::to_vec(&agent).unwrap_or_default();
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Health aggregation endpoint ──────────────────────────────────────
|
// ── Health aggregation endpoint ──────────────────────────────────────
|
||||||
|
|
||||||
/// HTTP GET `/health` handler for the gateway — aggregates health from all projects.
|
/// HTTP GET `/health` handler for the gateway — aggregates health from all projects.
|
||||||
@@ -948,8 +1055,14 @@ pub async fn gateway_switch_handler(
|
|||||||
|
|
||||||
/// Start the gateway HTTP server. This is the entry point when `--gateway` is used.
|
/// Start the gateway HTTP server. This is the entry point when `--gateway` is used.
|
||||||
pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
||||||
|
// Locate the gateway config directory (parent of `projects.toml`).
|
||||||
|
let config_dir = config_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or(std::path::Path::new("."))
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
let config = GatewayConfig::load(config_path).map_err(std::io::Error::other)?;
|
let config = GatewayConfig::load(config_path).map_err(std::io::Error::other)?;
|
||||||
let state = GatewayState::new(config).map_err(std::io::Error::other)?;
|
let state = GatewayState::new(config, config_dir.clone()).map_err(std::io::Error::other)?;
|
||||||
let state_arc = Arc::new(state);
|
let state_arc = Arc::new(state);
|
||||||
|
|
||||||
let active = state_arc.active_project.read().await.clone();
|
let active = state_arc.active_project.read().await.clone();
|
||||||
@@ -965,12 +1078,6 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Locate the gateway config directory (parent of `projects.toml`).
|
|
||||||
let config_dir = config_path
|
|
||||||
.parent()
|
|
||||||
.unwrap_or(std::path::Path::new("."))
|
|
||||||
.to_path_buf();
|
|
||||||
|
|
||||||
// Write `.mcp.json` so that the gateway's Matrix bot's Claude Code CLI
|
// Write `.mcp.json` so that the gateway's Matrix bot's Claude Code CLI
|
||||||
// connects to this gateway's MCP endpoint (which proxies to the active project).
|
// connects to this gateway's MCP endpoint (which proxies to the active project).
|
||||||
if let Err(e) = write_gateway_mcp_json(&config_dir, port) {
|
if let Err(e) = write_gateway_mcp_json(&config_dir, port) {
|
||||||
@@ -1010,6 +1117,10 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
|||||||
"/gateway/agents/:id",
|
"/gateway/agents/:id",
|
||||||
poem::delete(gateway_remove_agent_handler),
|
poem::delete(gateway_remove_agent_handler),
|
||||||
)
|
)
|
||||||
|
.at(
|
||||||
|
"/gateway/agents/:id/assign",
|
||||||
|
poem::post(gateway_assign_agent_handler),
|
||||||
|
)
|
||||||
// Serve the embedded React frontend so the gateway has a UI.
|
// Serve the embedded React frontend so the gateway has a UI.
|
||||||
.at(
|
.at(
|
||||||
"/assets/*path",
|
"/assets/*path",
|
||||||
@@ -1127,7 +1238,7 @@ url = "http://localhost:3002"
|
|||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
projects: BTreeMap::new(),
|
projects: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
assert!(GatewayState::new(config).is_err());
|
assert!(GatewayState::new(config, PathBuf::new()).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1146,7 +1257,7 @@ url = "http://localhost:3002"
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig { projects };
|
let config = GatewayConfig { projects };
|
||||||
let state = GatewayState::new(config).unwrap();
|
let state = GatewayState::new(config, PathBuf::new()).unwrap();
|
||||||
let active = state.active_project.blocking_read().clone();
|
let active = state.active_project.blocking_read().clone();
|
||||||
assert_eq!(active, "alpha"); // BTreeMap sorts alphabetically.
|
assert_eq!(active, "alpha"); // BTreeMap sorts alphabetically.
|
||||||
}
|
}
|
||||||
@@ -1179,7 +1290,7 @@ url = "http://localhost:3002"
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig { projects };
|
let config = GatewayConfig { projects };
|
||||||
let state = GatewayState::new(config).unwrap();
|
let state = GatewayState::new(config, PathBuf::new()).unwrap();
|
||||||
|
|
||||||
let params = json!({ "arguments": { "project": "beta" } });
|
let params = json!({ "arguments": { "project": "beta" } });
|
||||||
let resp = handle_switch_project(¶ms, &state).await;
|
let resp = handle_switch_project(¶ms, &state).await;
|
||||||
@@ -1199,7 +1310,7 @@ url = "http://localhost:3002"
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig { projects };
|
let config = GatewayConfig { projects };
|
||||||
let state = GatewayState::new(config).unwrap();
|
let state = GatewayState::new(config, PathBuf::new()).unwrap();
|
||||||
|
|
||||||
let params = json!({ "arguments": { "project": "nonexistent" } });
|
let params = json!({ "arguments": { "project": "nonexistent" } });
|
||||||
let resp = handle_switch_project(¶ms, &state).await;
|
let resp = handle_switch_project(¶ms, &state).await;
|
||||||
@@ -1216,7 +1327,7 @@ url = "http://localhost:3002"
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig { projects };
|
let config = GatewayConfig { projects };
|
||||||
let state = GatewayState::new(config).unwrap();
|
let state = GatewayState::new(config, PathBuf::new()).unwrap();
|
||||||
|
|
||||||
let url = state.active_url().await.unwrap();
|
let url = state.active_url().await.unwrap();
|
||||||
assert_eq!(url, "http://my:3001");
|
assert_eq!(url, "http://my:3001");
|
||||||
@@ -1352,7 +1463,7 @@ enabled = false
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig { projects };
|
let config = GatewayConfig { projects };
|
||||||
Arc::new(GatewayState::new(config).unwrap())
|
Arc::new(GatewayState::new(config, PathBuf::new()).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1452,6 +1563,7 @@ enabled = false
|
|||||||
label: "agent-1".into(),
|
label: "agent-1".into(),
|
||||||
address: "ws://a:3001/crdt-sync".into(),
|
address: "ws://a:3001/crdt-sync".into(),
|
||||||
registered_at: 0.0,
|
registered_at: 0.0,
|
||||||
|
assigned_project: None,
|
||||||
});
|
});
|
||||||
let app = poem::Route::new()
|
let app = poem::Route::new()
|
||||||
.at("/gateway/agents", poem::get(gateway_list_agents_handler))
|
.at("/gateway/agents", poem::get(gateway_list_agents_handler))
|
||||||
@@ -1472,6 +1584,7 @@ enabled = false
|
|||||||
label: "to-delete".into(),
|
label: "to-delete".into(),
|
||||||
address: "ws://x:3001/crdt-sync".into(),
|
address: "ws://x:3001/crdt-sync".into(),
|
||||||
registered_at: 0.0,
|
registered_at: 0.0,
|
||||||
|
assigned_project: None,
|
||||||
});
|
});
|
||||||
let app = poem::Route::new()
|
let app = poem::Route::new()
|
||||||
.at(
|
.at(
|
||||||
|
|||||||
Reference in New Issue
Block a user