Skip to content

Commit 31ee5ff

Browse files
committed
Merge PR #254: fix: batch fixes for issues #2329-#2352
2 parents f681dfe + ee43119 commit 31ee5ff

File tree

12 files changed

+427
-25
lines changed

12 files changed

+427
-25
lines changed

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cortex-app-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ url = { workspace = true }
6262
bytes = { workspace = true }
6363
base64 = { workspace = true }
6464
dirs = "5"
65+
fs2 = "0.4" # File locking for concurrent access
6566

6667
# Authentication
6768
jsonwebtoken = "9"

cortex-app-server/src/middleware.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,45 @@ pub async fn error_handling_middleware(request: Request, next: Next) -> Response
316316
response
317317
}
318318

319+
/// Content negotiation middleware.
320+
///
321+
/// This middleware validates the Accept header and returns 406 Not Acceptable
322+
/// if the client requests an unsupported content type. The API only supports
323+
/// JSON responses.
324+
///
325+
/// Supported content types:
326+
/// - `application/json`
327+
/// - `*/*` (wildcard)
328+
/// - No Accept header (defaults to JSON)
329+
pub async fn content_negotiation_middleware(
330+
request: Request,
331+
next: Next,
332+
) -> Result<Response, StatusCode> {
333+
// Get Accept header
334+
if let Some(accept) = request.headers().get(header::ACCEPT) {
335+
if let Ok(accept_str) = accept.to_str() {
336+
// Parse Accept header and check for supported types
337+
let supported = accept_str.split(',').any(|media_type| {
338+
let media_type = media_type.split(';').next().unwrap_or("").trim();
339+
media_type == "application/json"
340+
|| media_type == "application/*"
341+
|| media_type == "*/*"
342+
|| media_type.is_empty()
343+
});
344+
345+
if !supported {
346+
warn!(
347+
"Unsupported Accept header: {}. Only application/json is supported.",
348+
accept_str
349+
);
350+
return Err(StatusCode::NOT_ACCEPTABLE);
351+
}
352+
}
353+
}
354+
355+
Ok(next.run(request).await)
356+
}
357+
319358
/// Request context extracted from middleware.
320359
#[derive(Debug, Clone)]
321360
pub struct RequestContext {

cortex-app-server/src/storage.rs

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
//! Session persistence storage for cortex-app-server.
22
//!
33
//! Saves sessions and message history to disk so they survive server restarts.
4+
//! Uses file locking to prevent corruption when multiple server instances share
5+
//! the same storage directory.
46
57
use std::fs;
68
use std::io::{BufRead, BufReader, BufWriter, Write};
79
use std::path::{Path, PathBuf};
810

11+
use fs2::FileExt;
912
use serde::{Deserialize, Serialize};
1013
use tracing::{debug, error, info, warn};
1114

@@ -78,24 +81,45 @@ impl SessionStorage {
7881
Self::new(base_dir)
7982
}
8083

81-
/// Save a session to disk.
84+
/// Save a session to disk with exclusive file locking.
85+
///
86+
/// Uses file locking to prevent concurrent write corruption when multiple
87+
/// server instances share the same storage directory.
8288
pub fn save_session(&self, session: &StoredSession) -> std::io::Result<()> {
8389
let path = self.session_path(&session.id);
8490
let file = fs::File::create(&path)?;
85-
let writer = BufWriter::new(file);
86-
serde_json::to_writer_pretty(writer, session)
87-
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
91+
92+
// Acquire exclusive lock for writing
93+
file.lock_exclusive()?;
94+
95+
let writer = BufWriter::new(&file);
96+
let result = serde_json::to_writer_pretty(writer, session)
97+
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
98+
99+
// Lock is automatically released when file is dropped
100+
file.unlock()?;
101+
102+
result?;
88103
debug!("Saved session {} to {:?}", session.id, path);
89104
Ok(())
90105
}
91106

92-
/// Load a session from disk.
107+
/// Load a session from disk with shared file locking.
108+
///
109+
/// Uses shared locking to allow concurrent reads while preventing
110+
/// reads during writes.
93111
pub fn load_session(&self, id: &str) -> std::io::Result<StoredSession> {
94112
let path = self.session_path(id);
95113
let file = fs::File::open(&path)?;
96-
let reader = BufReader::new(file);
114+
115+
// Acquire shared lock for reading
116+
file.lock_shared()?;
117+
118+
let reader = BufReader::new(&file);
97119
let session: StoredSession = serde_json::from_reader(reader)
98120
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
121+
122+
file.unlock()?;
99123
Ok(session)
100124
}
101125

@@ -142,22 +166,31 @@ impl SessionStorage {
142166

143167
/// Append a message to session history (JSONL format).
144168
///
145-
/// This function ensures data durability by calling sync_all() (fsync)
146-
/// after writing to prevent data loss on crash or forceful termination.
169+
/// This function uses file locking to prevent concurrent write corruption
170+
/// and ensures data durability by calling sync_all() (fsync) after writing.
147171
pub fn append_message(&self, session_id: &str, message: &StoredMessage) -> std::io::Result<()> {
148172
let path = self.history_path(session_id);
149-
let mut file = fs::OpenOptions::new()
173+
let file = fs::OpenOptions::new()
150174
.create(true)
151175
.append(true)
152176
.open(&path)?;
153177

178+
// Acquire exclusive lock for writing
179+
file.lock_exclusive()?;
180+
154181
let json = serde_json::to_string(message)
155182
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
156-
writeln!(file, "{}", json)?;
183+
184+
// Write using a mutable reference to the locked file
185+
use std::io::Write;
186+
let mut writer = &file;
187+
writeln!(writer, "{}", json)?;
157188

158189
// Ensure data is durably written to disk (fsync) to prevent data loss on crash
159190
file.sync_all()?;
160191

192+
file.unlock()?;
193+
161194
debug!("Appended message to session {} history", session_id);
162195
Ok(())
163196
}

cortex-cli/src/agent_cmd.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ pub enum AgentSubcommand {
3131
/// Create a new agent interactively.
3232
Create(CreateArgs),
3333

34+
/// Edit an existing agent in your default editor.
35+
Edit(EditArgs),
36+
3437
/// Remove a user-defined agent.
3538
Remove(RemoveArgs),
3639
}
@@ -113,6 +116,17 @@ pub struct CreateArgs {
113116
pub model: String,
114117
}
115118

119+
/// Arguments for edit command.
120+
#[derive(Debug, Parser)]
121+
pub struct EditArgs {
122+
/// Name of the agent to edit.
123+
pub name: String,
124+
125+
/// Editor to use (defaults to $EDITOR or $VISUAL).
126+
#[arg(short, long)]
127+
pub editor: Option<String>,
128+
}
129+
116130
/// Arguments for remove command.
117131
#[derive(Debug, Parser)]
118132
pub struct RemoveArgs {
@@ -292,6 +306,7 @@ impl AgentCli {
292306
AgentSubcommand::List(args) => run_list(args).await,
293307
AgentSubcommand::Show(args) => run_show(args).await,
294308
AgentSubcommand::Create(args) => run_create(args).await,
309+
AgentSubcommand::Edit(args) => run_edit(args).await,
295310
AgentSubcommand::Remove(args) => run_remove(args).await,
296311
}
297312
}
@@ -1291,6 +1306,121 @@ mode: {mode}
12911306
Ok(())
12921307
}
12931308

1309+
/// Edit agent command.
1310+
///
1311+
/// Opens the agent file in the user's default editor, then validates the file
1312+
/// after editing. If validation fails, offers to re-open the editor to fix issues.
1313+
async fn run_edit(args: EditArgs) -> Result<()> {
1314+
let agents = load_all_agents()?;
1315+
1316+
let agent = agents
1317+
.iter()
1318+
.find(|a| a.name == args.name)
1319+
.ok_or_else(|| anyhow::anyhow!("Agent '{}' not found", args.name))?;
1320+
1321+
if agent.native {
1322+
bail!(
1323+
"Cannot edit built-in agent '{}'.\n\n\
1324+
Built-in agents are part of the Cortex core and cannot be modified.\n\
1325+
To customize this agent, create a copy:\n\
1326+
cortex agent create my-{}",
1327+
args.name,
1328+
args.name
1329+
);
1330+
}
1331+
1332+
let path = agent
1333+
.path
1334+
.as_ref()
1335+
.ok_or_else(|| anyhow::anyhow!("Agent '{}' has no file path", args.name))?;
1336+
1337+
// Determine the editor to use
1338+
let editor = args
1339+
.editor
1340+
.or_else(|| std::env::var("VISUAL").ok())
1341+
.or_else(|| std::env::var("EDITOR").ok())
1342+
.unwrap_or_else(|| {
1343+
if cfg!(windows) {
1344+
"notepad".to_string()
1345+
} else {
1346+
"vi".to_string()
1347+
}
1348+
});
1349+
1350+
// Make a backup of the original file
1351+
let backup_content = std::fs::read_to_string(path)
1352+
.with_context(|| format!("Failed to read agent file: {}", path.display()))?;
1353+
1354+
loop {
1355+
// Open the editor
1356+
println!("Opening {} in {}...", path.display(), editor);
1357+
let status = std::process::Command::new(&editor)
1358+
.arg(path)
1359+
.status()
1360+
.with_context(|| format!("Failed to launch editor: {}", editor))?;
1361+
1362+
if !status.success() {
1363+
bail!("Editor exited with error");
1364+
}
1365+
1366+
// Read and validate the edited file
1367+
let content = std::fs::read_to_string(path)
1368+
.with_context(|| format!("Failed to read edited file: {}", path.display()))?;
1369+
1370+
// Try to parse the frontmatter to validate
1371+
match parse_frontmatter(&content) {
1372+
Ok((frontmatter, _body)) => {
1373+
// Validate required fields
1374+
if frontmatter.name.trim().is_empty() {
1375+
eprintln!("\nError: Agent name cannot be empty.");
1376+
} else if !frontmatter
1377+
.name
1378+
.chars()
1379+
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1380+
{
1381+
eprintln!(
1382+
"\nError: Agent name must contain only alphanumeric characters, hyphens, and underscores."
1383+
);
1384+
} else {
1385+
// Validation passed
1386+
println!("\nAgent '{}' updated successfully!", frontmatter.name);
1387+
return Ok(());
1388+
}
1389+
}
1390+
Err(e) => {
1391+
eprintln!("\nError: Invalid agent configuration: {}", e);
1392+
}
1393+
}
1394+
1395+
// Validation failed - offer to re-edit or rollback
1396+
print!(
1397+
"Would you like to (e)dit again, (r)ollback to original, or (k)eep invalid file? [e/r/k]: "
1398+
);
1399+
io::stdout().flush()?;
1400+
1401+
let mut input = String::new();
1402+
io::stdin().lock().read_line(&mut input)?;
1403+
1404+
match input.trim().to_lowercase().as_str() {
1405+
"r" | "rollback" => {
1406+
// Restore the backup
1407+
std::fs::write(path, &backup_content)
1408+
.with_context(|| format!("Failed to restore backup: {}", path.display()))?;
1409+
println!("Rolled back to original version.");
1410+
return Ok(());
1411+
}
1412+
"k" | "keep" => {
1413+
eprintln!("Warning: Keeping invalid configuration. The agent may fail to load.");
1414+
return Ok(());
1415+
}
1416+
_ => {
1417+
// Default: re-edit
1418+
continue;
1419+
}
1420+
}
1421+
}
1422+
}
1423+
12941424
/// Remove agent command.
12951425
async fn run_remove(args: RemoveArgs) -> Result<()> {
12961426
let agents = load_all_agents()?;

0 commit comments

Comments
 (0)