runtime-api: serve the effective instruction-source map at GET /v1/workspace/instructions (#6168)

GPUI's Memory/instructions surface probed GET /v1/instructions, got a missing
route, and fell back to opening local files through the Files module. The
effective instruction set is not a file scan: it is the repository-root →
workspace chain with per-scope precedence, the global fallback layer, rules
directories, opted-in foreign imports, configured `instructions = [...]`
files, and the constitution — assembled under one aggregate byte budget.

project_instruction_sources() in project_context.rs mirrors the loaders'
own enumeration — same candidate order, same existence/readability checks —
and reports each candidate's kind, scope dir, path, exists/status
(loaded|shadowed|skipped|missing), byte size, and loader warning, including
deprecated WHALE.md entries marked ignored. The handler wraps the fs walk
in spawn_blocking and reuses the cached real load pass for assembly-level
warnings and the generated_fallback flag. Read-only by design: edits go
through the workspace file routes (#6163).

Also lands the route-level HTTP test for the #6178 command catalog, which
was written earlier but blocked from running by a peer's in-flight compile
break; all three catalog tests now pass.

Tests: cargo test -p codewhale-tui --lib --locked — 3 passed instruction_sources
unit tests + 1 passed workspace_instructions route test + 3 passed
command_catalog tests (incl. route-level); 0 failed.
This commit is contained in:
CodeWhale Bot committed 2026-09-15 02:40:38 -07:00
1 parent 7c74ecf0f3
commit d5291dfb66
6 files changed
+799 -2

No files matched your search

+514 -1
View File
@@ -180,7 +180,6 @@ impl ForeignInstructionImports {
}
/// Enabled format keys, for provenance and diagnostics.
#[cfg(test)]
#[must_use]
pub fn keys(&self) -> Vec<&'static str> {
self.enabled.iter().copied().collect()
@@ -1296,6 +1295,417 @@ Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore
Ok(agents_path)
}
// === Effective instruction-source listing (#6168) ===
/// Which layer of the instruction stack a source belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstructionSourceKind {
/// Chain candidate (`AGENTS.md`, `.codewhale/instructions.md`, opted-in
/// foreign files) in a repository-root → workspace scope directory.
Project,
/// `.md` file inside a rules directory (`.codewhale/rules`, opted-in
/// foreign rules dirs) at workspace scope.
Rule,
/// User-level fallback (`~/.codewhale/AGENTS.md` and friends).
Global,
/// File selected by the bounded foreign-fragment loader
/// (`codewhale_core::fragments`) for an opted-in format.
Fragment,
/// Configured `instructions = [...]` file (#454).
Configured,
/// `.codewhale/constitution.json` authority policy.
Constitution,
/// Present but deliberately never loaded (deprecated `WHALE.md`).
Ignored,
}
/// Whether the prompt assembly actually consumed the file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstructionSourceStatus {
/// Read and merged into the assembled instructions.
Loaded,
/// Exists, but a higher-priority candidate in the same scope won.
Shadowed,
/// Exists but the loader refused, could not read, or skipped it
/// (empty, oversized, over the per-directory rules cap).
Skipped,
/// Candidate path the loader checks; nothing is there.
Missing,
}
/// One instruction-source candidate or loaded file, for the
/// workspace-instructions listing route.
#[derive(Debug, Clone)]
pub struct InstructionSourceInfo {
pub kind: InstructionSourceKind,
/// Scope directory the candidate belongs to: the chain directory for
/// `Project`, the workspace for `Rule`/`Fragment`/`Configured`, the home
/// directory for `Global`.
pub scope_dir: PathBuf,
/// Candidate or actual file path, as the loader resolves it.
pub path: PathBuf,
/// `symlink_metadata` saw a file or symlink (same check the loader runs).
pub exists: bool,
pub status: InstructionSourceStatus,
/// File size in bytes when it could be stat'd.
pub bytes: Option<u64>,
/// Loader warning produced for this path, when one exists.
pub warning: Option<String>,
}
fn file_len(path: &Path) -> Option<u64> {
fs::metadata(path).ok().map(|m| m.len())
}
fn push_candidate_entry(
sources: &mut Vec<InstructionSourceInfo>,
kind: InstructionSourceKind,
scope_dir: &Path,
path: PathBuf,
scope_loaded: &mut bool,
load: impl FnOnce(&Path) -> Result<String, ProjectContextError>,
) {
let exists = context_candidate_exists(&path);
let (status, warning) = if !exists {
(InstructionSourceStatus::Missing, None)
} else if *scope_loaded {
(InstructionSourceStatus::Shadowed, None)
} else {
match load(&path) {
Ok(_) => {
*scope_loaded = true;
(InstructionSourceStatus::Loaded, None)
}
Err(error) => (InstructionSourceStatus::Skipped, Some(error.to_string())),
}
};
sources.push(InstructionSourceInfo {
kind,
scope_dir: scope_dir.to_path_buf(),
exists,
bytes: file_len(&path),
path,
status,
warning,
});
}
/// Enumerate the effective instruction sources for `workspace` (#6168).
///
/// Mirrors the loaders above — same candidate order, same existence and
/// readability checks — so the listing reports what prompt assembly actually
/// picks up: precedence, shadowing, opt-in imports, and refusal warnings.
/// Read-only; it never creates or modifies files. `configured` is the
/// resolved `instructions = [...]` array (`Config::instructions_paths`);
/// `home_dir` gates the global layer exactly as
/// `load_project_context_with_parents_and_home` does.
///
/// Known limit: statuses describe file selection, not the aggregate byte
/// budget — `enforce_project_instruction_budget` can still trim loaded
/// content at render time without changing a source's `Loaded` status.
#[must_use]
pub fn project_instruction_sources(
workspace: &Path,
home_dir: Option<&Path>,
configured: &[PathBuf],
) -> Vec<InstructionSourceInfo> {
let imports = &foreign_instruction_imports();
let workspace = canonicalize_workspace_or_keep(workspace);
let mut sources = Vec::new();
// Repository-root → workspace instruction chain. Same dir order the
// loader assembles, so wider scopes list first.
for dir in context_chain_dirs(&workspace, home_dir) {
let whale = dir.join(DEPRECATED_WHALE_FILENAME);
if context_candidate_exists(&whale) {
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Ignored,
scope_dir: dir.clone(),
path: whale.clone(),
exists: true,
status: InstructionSourceStatus::Skipped,
bytes: file_len(&whale),
warning: Some(WHALE_IGNORED_WARNING.to_string()),
});
}
let mut scope_loaded = false;
for filename in context_files_for(imports) {
push_candidate_entry(
&mut sources,
InstructionSourceKind::Project,
&dir,
dir.join(filename),
&mut scope_loaded,
load_context_file,
);
}
}
// Workspace-scope rules directories — the chain loader never reads
// ancestor rules, so only the workspace contributes them.
for rules_dir_name in rules_dirs_for(imports) {
let rules_dir = workspace.join(rules_dir_name);
if fs::symlink_metadata(&rules_dir)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
// Same refusal as `load_rules_from_dir`, surfaced rather than
// logged-and-dropped.
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Rule,
scope_dir: workspace.clone(),
path: rules_dir,
exists: true,
status: InstructionSourceStatus::Skipped,
bytes: None,
warning: Some("refusing symlinked rules directory".to_string()),
});
continue;
}
let Ok(dir_entries) = fs::read_dir(&rules_dir) else {
continue;
};
let mut file_paths: Vec<PathBuf> = dir_entries
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.extension().is_some_and(|ext| ext == "md") && context_candidate_exists(path)
})
.collect();
file_paths.sort_by(|a, b| {
a.file_name()
.unwrap_or_default()
.cmp(b.file_name().unwrap_or_default())
});
for (index, path) in file_paths.into_iter().enumerate() {
let (status, warning) = if index >= MAX_RULES_FILES {
(
InstructionSourceStatus::Skipped,
Some(format!(
"beyond the per-directory rules cap of {MAX_RULES_FILES} files"
)),
)
} else {
match load_context_file(&path) {
Ok(_) => (InstructionSourceStatus::Loaded, None),
Err(error) => (InstructionSourceStatus::Skipped, Some(error.to_string())),
}
};
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Rule,
scope_dir: workspace.clone(),
path: path.clone(),
exists: true,
status,
bytes: file_len(&path),
warning,
});
}
}
// User-level fallback layer.
if let Some(home) = home_dir {
for relative in legacy_global_whale_relative_paths() {
let path = join_relative_components(home, relative);
if context_candidate_exists(&path) {
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Ignored,
scope_dir: home.to_path_buf(),
path: path.clone(),
exists: true,
status: InstructionSourceStatus::Skipped,
bytes: file_len(&path),
warning: Some(WHALE_IGNORED_WARNING.to_string()),
});
}
}
let mut scope_loaded = false;
for relative in global_context_relative_paths() {
push_candidate_entry(
&mut sources,
InstructionSourceKind::Global,
home,
join_relative_components(home, relative),
&mut scope_loaded,
load_global_context_file,
);
}
}
// Opted-in foreign fragments — enumerate the declared candidates, then
// mark the files the bounded loader's own selection walk picks.
let fragment_candidates = fragment_candidates_for(imports);
let selected: std::collections::BTreeSet<PathBuf> =
codewhale_core::fragments::selected_project_instruction_candidate_files(
&workspace,
&fragment_candidates,
)
.into_iter()
.collect();
let mut emitted = std::collections::BTreeSet::new();
for candidate in &fragment_candidates {
let path = workspace.join(candidate);
if !path.exists() {
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Fragment,
scope_dir: workspace.clone(),
path,
exists: false,
status: InstructionSourceStatus::Missing,
bytes: None,
warning: None,
});
continue;
}
// A directory candidate's loadable files are the selected entries
// underneath it; report those rather than the directory itself.
let members: Vec<PathBuf> = selected
.iter()
.filter(|file| file.starts_with(&path))
.cloned()
.collect();
if path.is_dir() {
if members.is_empty() {
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Fragment,
scope_dir: workspace.clone(),
path,
exists: true,
status: InstructionSourceStatus::Skipped,
bytes: None,
warning: Some(
"no loadable .md files selected under this directory".to_string(),
),
});
}
for file in members {
emitted.insert(file.clone());
let loaded = fs::read_to_string(&file)
.map(|content| !content.trim().is_empty())
.unwrap_or(false);
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Fragment,
scope_dir: workspace.clone(),
path: file.clone(),
exists: true,
status: if loaded {
InstructionSourceStatus::Loaded
} else {
InstructionSourceStatus::Skipped
},
bytes: file_len(&file),
warning: None,
});
}
} else {
emitted.insert(path.clone());
let selected_file = selected.contains(&path);
let loaded = selected_file
&& fs::read_to_string(&path)
.map(|content| !content.trim().is_empty())
.unwrap_or(false);
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Fragment,
scope_dir: workspace.clone(),
path: path.clone(),
exists: true,
status: if loaded {
InstructionSourceStatus::Loaded
} else if selected_file {
InstructionSourceStatus::Skipped
} else {
// Exists but not selected — e.g. a symlink the walk refuses.
InstructionSourceStatus::Skipped
},
bytes: file_len(&path),
warning: if selected_file && !loaded {
Some("selected but empty or unreadable".to_string())
} else {
None
},
});
}
}
// Selected files not attributed to a listed candidate (nested rules dir
// members) still get reported.
for file in selected.iter().filter(|file| !emitted.contains(*file)) {
let loaded = fs::read_to_string(file)
.map(|content| !content.trim().is_empty())
.unwrap_or(false);
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Fragment,
scope_dir: workspace.clone(),
path: file.clone(),
exists: true,
status: if loaded {
InstructionSourceStatus::Loaded
} else {
InstructionSourceStatus::Skipped
},
bytes: file_len(file),
warning: None,
});
}
// Configured `instructions = [...]` files (#454) — resolved paths, in
// declared order. The renderer skips missing/empty files with a warning.
for path in configured {
let exists = context_candidate_exists(path);
let (status, warning) = match fs::read_to_string(path) {
Ok(content) if !content.trim().is_empty() => (InstructionSourceStatus::Loaded, None),
Ok(_) => (
InstructionSourceStatus::Skipped,
Some("empty instructions file".to_string()),
),
Err(error) => (
InstructionSourceStatus::Skipped,
Some(format!("unreadable instructions file: {error}")),
),
};
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Configured,
scope_dir: workspace.clone(),
path: path.clone(),
exists,
status,
bytes: file_len(path),
warning,
});
}
// `.codewhale/constitution.json`, workspace → repository root. The loader
// stops at the first existing candidate whether it parses or not, so
// later candidates — even ones that exist — are never evaluated.
let (_block, constitution_source, _warnings) = load_repo_constitution_block(&workspace);
let mut seen_existing = false;
for path in repo_constitution_candidate_paths(&workspace) {
let exists = context_candidate_exists(&path);
let status = if !exists {
InstructionSourceStatus::Missing
} else if seen_existing {
InstructionSourceStatus::Shadowed
} else {
seen_existing = true;
if constitution_source.as_deref() == Some(path.as_path()) {
InstructionSourceStatus::Loaded
} else {
InstructionSourceStatus::Skipped
}
};
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Constitution,
scope_dir: workspace.clone(),
path: path.clone(),
exists,
status,
bytes: file_len(&path),
warning: None,
});
}
sources
}
// === Unit Tests ===
#[cfg(test)]
@@ -2852,4 +3262,107 @@ mod tests {
instructions.len()
);
}
#[test]
fn instruction_sources_report_precedence_shadowing_and_ignored_files() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
fs::create_dir_all(root.join(".git")).expect("mkdir git");
fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
fs::write(root.join("AGENTS.md"), "primary\n").expect("agents");
fs::create_dir_all(root.join(".codewhale")).expect("mkdir codewhale");
fs::write(
root.join(".codewhale").join("instructions.md"),
"secondary\n",
)
.expect("workspace instructions");
fs::write(root.join("WHALE.md"), "deprecated\n").expect("whale");
fs::create_dir_all(root.join(".codewhale").join("rules")).expect("mkdir rules");
fs::write(
root.join(".codewhale").join("rules").join("style.md"),
"keep it small\n",
)
.expect("rule");
let home = tmp.path().join("home");
fs::create_dir_all(&home).expect("mkdir home");
let configured = vec![root.join("team").join("extra.md")];
fs::create_dir_all(root.join("team")).expect("mkdir team");
fs::write(root.join("team").join("extra.md"), "configured\n").expect("configured");
let sources = project_instruction_sources(root, Some(&home), &configured);
let find = |path_suffix: &str| {
sources
.iter()
.find(|source| source.path.ends_with(path_suffix))
.unwrap_or_else(|| panic!("missing entry for {path_suffix}: {sources:?}"))
};
let agents = find("AGENTS.md");
assert_eq!(agents.kind, InstructionSourceKind::Project);
assert_eq!(agents.status, InstructionSourceStatus::Loaded);
assert_eq!(agents.bytes, Some(8));
// The lower-priority candidate in the same scope exists but is
// shadowed, not loaded.
let secondary = find(".codewhale/instructions.md");
assert_eq!(secondary.status, InstructionSourceStatus::Shadowed);
let whale = find("WHALE.md");
assert_eq!(whale.kind, InstructionSourceKind::Ignored);
assert_eq!(whale.status, InstructionSourceStatus::Skipped);
assert!(whale.warning.is_some());
let rule = find("style.md");
assert_eq!(rule.kind, InstructionSourceKind::Rule);
assert_eq!(rule.status, InstructionSourceStatus::Loaded);
let configured_entry = find("extra.md");
assert_eq!(configured_entry.kind, InstructionSourceKind::Configured);
assert_eq!(configured_entry.status, InstructionSourceStatus::Loaded);
// Unchecked-but-real candidates are enumerated so clients can render
// the full precedence map.
assert!(
sources
.iter()
.any(|s| s.status == InstructionSourceStatus::Missing),
"missing candidates must appear: {sources:?}"
);
// Global candidates under the (empty) home are reported as missing.
assert!(
sources
.iter()
.any(|s| s.kind == InstructionSourceKind::Global
&& s.status == InstructionSourceStatus::Missing),
"global candidates must appear: {sources:?}"
);
}
#[test]
fn instruction_sources_mark_unreadable_winner_as_skipped_not_loaded() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
fs::create_dir_all(root.join(".git")).expect("mkdir git");
fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
// An empty AGENTS.md fails the load check, so the next candidate wins.
fs::write(root.join("AGENTS.md"), " \n").expect("empty agents");
fs::create_dir_all(root.join(".codewhale")).expect("mkdir codewhale");
fs::write(root.join(".codewhale").join("instructions.md"), "wins\n")
.expect("workspace instructions");
let sources = project_instruction_sources(root, None, &[]);
let agents = sources
.iter()
.find(|s| s.path.ends_with("AGENTS.md"))
.expect("agents entry");
assert_eq!(agents.status, InstructionSourceStatus::Skipped);
assert!(agents.warning.is_some(), "refusal must carry a warning");
let winner = sources
.iter()
.find(|s| s.path.ends_with(".codewhale/instructions.md"))
.expect("instructions entry");
assert_eq!(winner.status, InstructionSourceStatus::Loaded);
}
}
+4 -1
View File
@@ -112,7 +112,9 @@ use self::sessions::{
use self::sessions::{messages_from_thread_detail, session_to_detail};
#[cfg(test)]
use self::workspace::collect_workspace_status;
use self::workspace::{collect_workspace_git_metadata, workspace_file_search, workspace_status};
use self::workspace::{
collect_workspace_git_metadata, workspace_file_search, workspace_instructions, workspace_status,
};
const RUNTIME_TOKEN_ENV: &str = "CODEWHALE_RUNTIME_TOKEN";
const LEGACY_RUNTIME_TOKEN_ENV: &str = "DEEPSEEK_RUNTIME_TOKEN";
@@ -1091,6 +1093,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
)
.route("/v1/workspace/status", get(workspace_status))
.route("/v1/workspace/files/search", get(workspace_file_search))
.route("/v1/workspace/instructions", get(workspace_instructions))
.route("/v1/agent-runs", get(list_agent_runs))
.route("/v1/agent-runs/{run_id}", get(get_agent_run))
.route("/v1/fleet/profiles", get(list_fleet_profiles))
+1
View File
@@ -16,6 +16,7 @@ use uuid::Uuid;
mod command_catalog;
mod headless_catalog;
mod workspace_instructions;
/// Scale a wait budget for shared CI runners.
///
@@ -64,3 +64,60 @@ fn command_catalog_marks_user_shadowing_of_builtin_names_and_aliases() {
subagents.shadowed_aliases
);
}
#[tokio::test]
async fn get_v1_commands_serves_the_catalog_over_http() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let root = temp.path().join("commands-route");
let sessions_dir = root.join("sessions");
let workspace = root.join("workspace");
let commands_dir = workspace.join(".codewhale").join("commands");
fs::create_dir_all(&commands_dir)?;
fs::write(commands_dir.join("model.md"), "Pick the fast route.\n")?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_root_token_mobile_workspace(
root,
sessions_dir,
None,
false,
workspace,
)
.await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let body: serde_json::Value = client
.get(format!("http://{addr}/v1/commands"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let commands = body["commands"].as_array().expect("commands array");
assert!(
commands
.iter()
.any(|command| command["kind"] == "builtin" && command["binding"] == "host"),
"the route must serve builtins: {commands:?}"
);
// The workspace user command named `model` shadows the builtin: the
// builtin reports the shadow and the winning definition is served.
let builtin_model = commands
.iter()
.find(|command| command["name"] == "model" && command["kind"] == "builtin")
.expect("builtin model row");
assert_eq!(builtin_model["shadowed_by"], "model");
let user_model = commands
.iter()
.find(|command| command["name"] == "model" && command["kind"] == "user")
.expect("user model row");
assert_eq!(user_model["binding"], "prompt");
handle.abort();
Ok(())
}
@@ -0,0 +1,97 @@
use super::*;
#[tokio::test]
async fn get_v1_workspace_instructions_lists_effective_sources() -> Result<()> {
// No `lock_test_env`/`EnvVarGuard` here: the handler resolves the global
// layer's home inside `spawn_blocking`, and a blocking-pool thread is not
// enrolled in the test's env scope — sealing the environment would
// deadlock the request against the lock this test would be holding.
let temp = tempfile::tempdir()?;
let root = temp.path().join("instructions-route");
let sessions_dir = root.join("sessions");
let workspace = root.join("workspace");
fs::create_dir_all(workspace.join(".git"))?;
fs::write(
workspace.join(".git").join("HEAD"),
"ref: refs/heads/main\n",
)?;
fs::write(workspace.join("AGENTS.md"), "# Repo rules\n")?;
// Exists but shadowed: AGENTS.md outranks it in the same directory.
fs::create_dir_all(workspace.join(".codewhale"))?;
fs::write(
workspace.join(".codewhale").join("instructions.md"),
"shadowed\n",
)?;
fs::create_dir_all(workspace.join(".codewhale").join("rules"))?;
fs::write(
workspace.join(".codewhale").join("rules").join("style.md"),
"keep it small\n",
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_root_token_mobile_workspace(
root,
sessions_dir,
None,
false,
workspace.clone(),
)
.await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let body: serde_json::Value = client
.get(format!("http://{addr}/v1/workspace/instructions"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let sources = body["sources"].as_array().expect("sources array");
let agents = sources
.iter()
.find(|source| {
source["path"]
.as_str()
.is_some_and(|p| p.ends_with("AGENTS.md"))
})
.expect("AGENTS.md row");
assert_eq!(agents["kind"], "project");
assert_eq!(agents["status"], "loaded");
assert_eq!(agents["relative_path"], "AGENTS.md");
assert_eq!(agents["exists"], true);
assert!(agents["bytes"].as_u64().is_some());
let shadowed = sources
.iter()
.find(|source| {
source["path"]
.as_str()
.is_some_and(|p| p.ends_with(".codewhale/instructions.md"))
})
.expect("shadowed workspace instructions row");
assert_eq!(shadowed["status"], "shadowed");
let rule = sources
.iter()
.find(|source| source["kind"] == "rule")
.expect("rules file row");
assert_eq!(rule["status"], "loaded");
assert_eq!(rule["relative_path"], ".codewhale/rules/style.md");
// Unchecked-but-real candidates are reported too, so clients can render
// the full precedence map instead of a post-hoc file scan.
assert!(
sources.iter().any(|source| source["status"] == "missing"),
"missing candidates must be listed: {sources:?}"
);
assert_eq!(body["generated_fallback"], false);
assert!(body["aggregate_budget_bytes"].as_u64().unwrap() > 0);
handle.abort();
Ok(())
}
+126
View File
@@ -213,6 +213,132 @@ fn current_git_head(workspace: &FsPath) -> Option<String> {
(!head.is_empty()).then(|| head.to_string())
}
// ── Effective instruction sources (#6168) ────────────────────────────────
#[derive(Debug, Serialize)]
pub(super) struct WorkspaceInstructionSource {
/// `project` | `rule` | `global` | `fragment` | `configured` |
/// `constitution` | `ignored`.
kind: &'static str,
/// `loaded` | `shadowed` | `skipped` | `missing`.
status: &'static str,
scope_dir: PathBuf,
path: PathBuf,
/// Workspace-relative spelling when the path sits under the workspace.
relative_path: Option<String>,
exists: bool,
bytes: Option<u64>,
warning: Option<String>,
}
#[derive(Debug, Serialize)]
pub(super) struct WorkspaceInstructionsResponse {
workspace: PathBuf,
sources: Vec<WorkspaceInstructionSource>,
/// Foreign instruction formats the operator opted into.
foreign_imports: Vec<String>,
/// True when no file-based instructions exist anywhere and the
/// prompt carries the ephemeral generated context instead.
generated_fallback: bool,
/// Aggregate byte ceiling applied across instructions and rules at
/// render (`project_context::MAX_PROJECT_INSTRUCTION_BYTES`).
aggregate_budget_bytes: usize,
/// Warnings from the real load pass — includes assembly-level notices
/// (unimported foreign formats, ignored WHALE.md, constitution parse)
/// alongside what per-source `warning` fields already carry.
warnings: Vec<String>,
}
fn instruction_source_kind_name(
kind: crate::project_context::InstructionSourceKind,
) -> &'static str {
use crate::project_context::InstructionSourceKind as Kind;
match kind {
Kind::Project => "project",
Kind::Rule => "rule",
Kind::Global => "global",
Kind::Fragment => "fragment",
Kind::Configured => "configured",
Kind::Constitution => "constitution",
Kind::Ignored => "ignored",
}
}
fn instruction_source_status_name(
status: crate::project_context::InstructionSourceStatus,
) -> &'static str {
use crate::project_context::InstructionSourceStatus as Status;
match status {
Status::Loaded => "loaded",
Status::Shadowed => "shadowed",
Status::Skipped => "skipped",
Status::Missing => "missing",
}
}
/// Read-only listing of the effective instruction sources for this
/// workspace (#6168): repository-root → workspace chain candidates, rules
/// files, the global fallback layer, opted-in foreign fragments, configured
/// `instructions = [...]` files, and the constitution — each with the status
/// the prompt loaders give it. Edits go through the workspace file routes.
pub(super) async fn workspace_instructions(
State(state): State<RuntimeApiState>,
) -> Result<Json<WorkspaceInstructionsResponse>, ApiError> {
let workspace = state.workspace.clone();
let home = crate::config::effective_home_dir();
let configured = state.config.read().instructions_paths();
let (sources, generated_fallback, warnings) = tokio::task::spawn_blocking(move || {
let sources = crate::project_context::project_instruction_sources(
&workspace,
home.as_deref(),
&configured,
);
// The real load pass supplies assembly-level warnings and tells us
// whether the ephemeral generated context is what the prompt
// carries. Cached — this is the same call the engine makes.
let ctx = crate::project_context::load_project_context_with_parents(&workspace);
let generated_fallback = ctx.instructions.is_some() && ctx.source_path.is_none();
(sources, generated_fallback, ctx.warnings)
})
.await
.map_err(|_| ApiError::internal("instruction source listing failed"))?;
// `project_instruction_sources` canonicalizes the workspace (the
// `/var` → `/private/var` class of alias), so relative paths must be
// computed against the canonical spelling or every strip fails.
let workspace_root =
std::fs::canonicalize(&state.workspace).unwrap_or_else(|_| state.workspace.clone());
Ok(Json(WorkspaceInstructionsResponse {
workspace: workspace_root.clone(),
sources: sources
.into_iter()
.map(|source| WorkspaceInstructionSource {
kind: instruction_source_kind_name(source.kind),
status: instruction_source_status_name(source.status),
relative_path: source
.path
.strip_prefix(&workspace_root)
.ok()
.map(|relative| relative.display().to_string()),
scope_dir: source.scope_dir,
path: source.path,
exists: source.exists,
bytes: source.bytes,
warning: source.warning,
})
.collect(),
foreign_imports: crate::project_context::foreign_instruction_imports()
.keys()
.into_iter()
.map(str::to_string)
.collect(),
generated_fallback,
aggregate_budget_bytes: crate::project_context::MAX_PROJECT_INSTRUCTION_BYTES,
warnings,
}))
}
#[cfg(test)]
mod tests {
use super::*;