1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
//! Routine flags: a lightweight, typed note an agent (or a human, via MCP/HTTP) attaches to a
//! routine mid-run when something is unclear — a gap, a bug, an edge case, a question — with no
//! other channel back to a human (the agent runs unattended inside tmux).
//!
//! Each flag is one file under `{routine_dir}/flags/`, named `{slugify(type)}-{timestamp}.md`
//! (general, committed) or `...timestamp.local.md` (local, gitignored — matches the `*.local.*`
//! pattern already seeded into every routine's `.gitignore`). The file's first line is the exact
//! (unslugified) `type`, then a blank line, then the free-text `description`.
//!
//! There is no status field: an "open" flag is simply a file that exists. Resolving a flag means
//! deleting it ([`resolve_flag`]).
use std::sync::{Mutex, OnceLock};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::paths::routine_flags_dir;
use crate::utils::atomic::atomic_write;
use crate::utils::lock::LockRecover;
use crate::utils::time::now_secs;
use super::command::slugify;
/// Whether a flag file is committed to version control or kept machine-local.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum FlagScope {
/// Committed to git (`{type}-{timestamp}.md`).
General,
/// Gitignored, machine-local (`{type}-{timestamp}.local.md`).
Local,
}
impl FlagScope {
/// The filename suffix (including the leading `.`) this scope's flag files carry.
const fn suffix(self) -> &'static str {
match self {
Self::General => ".md",
Self::Local => ".local.md",
}
}
}
/// A single flag raised against a routine.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, utoipa::ToSchema)]
pub struct Flag {
/// Filename on disk under the routine's `flags/` dir; the handle used to resolve it.
pub filename: String,
/// Free-text category, e.g. `"bug"`, `"gap"`, `"edge_case"`, `"question"`.
#[serde(rename = "type")]
pub category: String,
/// Free-text body describing what's unclear.
pub description: String,
/// Whether this flag is committed (general) or machine-local.
pub scope: FlagScope,
/// Unix timestamp (seconds) the flag was created.
pub created_at: u64,
}
/// `true` when `filename` is safe to join onto [`routine_flags_dir`] — rejects path separators,
/// `..` traversal, and anything not ending in `.md` (every flag file, local or general, does).
/// Guards [`resolve_flag`] against a caller-supplied filename escaping the flags directory.
fn is_safe_flag_filename(filename: &str) -> bool {
!filename.is_empty()
&& !filename.contains(['/', '\\'])
&& !filename.contains("..")
&& std::path::Path::new(filename)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
}
/// Split a flag filename into its `(created_at, scope)`, or `None` if it doesn't match the
/// `{anything}-{timestamp}(.local)?.md` shape.
///
/// Only the *last* `-`-delimited token before the extension is read (the timestamp); whatever
/// precedes it is the type slug and is not parsed back — the authoritative type lives in the file
/// body, read separately by [`list_flags`].
fn parse_filename(filename: &str) -> Option<(u64, FlagScope)> {
let (stem, scope) = if let Some(stem) = filename.strip_suffix(".local.md") {
(stem, FlagScope::Local)
} else {
let stem = filename.strip_suffix(".md")?;
(stem, FlagScope::General)
};
let (_, ts) = stem.rsplit_once('-')?;
let created_at = ts.parse().ok()?;
Some((created_at, scope))
}
/// Process-wide lock serializing the collision-check-then-write span in [`create_flag`].
///
/// The free-filename loop reads (`dir.join(&candidate).exists()`) and the subsequent
/// `atomic_write` are not otherwise synchronized, so two concurrent calls for the same routine
/// (reachable via the HTTP and MCP flag-creation handlers, both async on the multi-thread Tokio
/// runtime) can both observe the same candidate as free before either writes, and the second
/// write silently clobbers the first — the same read-modify-write hazard fixed the same way for
/// `machine.local.toml` (see `machine_toml_lock` in `src/machine/mod.rs`).
fn flags_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// Create a new flag under the routine identified by `slug`, returning the persisted record.
///
/// `flag_type` and `description` are trimmed; callers are expected to have already rejected blank
/// values (mirroring the other `validate_*`/`reject_*` boundary checks in `service.rs`). On a
/// same-second collision with an existing flag the timestamp is bumped by one second at a time
/// until the filename is free, so a flag never silently overwrites another.
pub fn create_flag(
slug: &str,
flag_type: &str,
description: &str,
scope: FlagScope,
) -> std::io::Result<Flag> {
let flag_type = flag_type.trim();
let description = description.trim();
let dir = routine_flags_dir(slug);
crate::utils::fs_perms::create_private_dir_all(&dir)?;
let _guard = flags_lock().lock_recover();
let type_slug = slugify(flag_type);
let mut created_at = now_secs();
let filename = loop {
let candidate = format!("{type_slug}-{created_at}{}", scope.suffix());
if !dir.join(&candidate).exists() {
break candidate;
}
created_at += 1;
};
atomic_write(
&dir.join(&filename),
format!("{flag_type}\n\n{description}\n").as_bytes(),
)?;
Ok(Flag {
filename,
category: flag_type.to_string(),
description: description.to_string(),
scope,
created_at,
})
}
/// List every open flag for the routine identified by `slug`, oldest first.
///
/// Returns an empty list when the routine has no `flags/` dir yet (nothing has ever been raised)
/// rather than erroring, mirroring how other routine sidecars are read.
pub fn list_flags(slug: &str) -> Vec<Flag> {
let dir = routine_flags_dir(slug);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut flags: Vec<Flag> = entries
.flatten()
.filter_map(|entry| {
let filename = entry.file_name().to_string_lossy().into_owned();
let (created_at, scope) = parse_filename(&filename)?;
let text = std::fs::read_to_string(entry.path()).ok()?;
let mut parts = text.splitn(2, "\n\n");
let flag_type = parts.next().unwrap_or_default().trim().to_string();
let description = parts.next().unwrap_or_default().trim().to_string();
Some(Flag {
filename,
category: flag_type,
description,
scope,
created_at,
})
})
.collect();
flags.sort_by_key(|flag| flag.created_at);
flags
}
/// Resolve (delete) the flag named `filename` under the routine identified by `slug`.
///
/// Returns `Ok(true)` if a flag was removed, `Ok(false)` if `filename` was unsafe (see
/// [`is_safe_flag_filename`]) or named no existing flag — both read as "nothing to resolve" to the
/// caller, since neither leaves anything on disk to clean up.
pub fn resolve_flag(slug: &str, filename: &str) -> std::io::Result<bool> {
if !is_safe_flag_filename(filename) {
return Ok(false);
}
let path = routine_flags_dir(slug).join(filename);
if !path.exists() {
return Ok(false);
}
std::fs::remove_file(&path)?;
Ok(true)
}
#[cfg(test)]
#[path = "flags_tests.rs"]
mod flags_tests;