45 Commits
Author SHA1 Message Date
Hare 4e935c6203 test: refresh clean-build fixtures and assertions 2026-08-26 10:16:18 +09:00
Hare eac07cf5a8 fix: provide subworker control service 2026-08-26 10:16:10 +09:00
Hare 260259d461 fix: return ticket queue outcomes to web clients 2026-08-26 10:16:03 +09:00
Hare 9d572d18bc fix: accept reserved workers during initial flow resolution 2026-08-26 07:52:04 +09:00
Hare f7852e8034 fix: remove misleading runtime capability projections 2026-08-26 06:19:33 +09:00
Hare 8c075de147 fix: allow remote runtime workdir creation 2026-08-26 05:13:21 +09:00
Hare 864367f4f5 fix: remove repository-local server configuration paths 2026-08-26 03:56:35 +09:00
Hare 33db2ea7f4 fix: unify browser origin configuration 2026-08-26 02:37:14 +09:00
Hare 8396d09891 fix: limit coder ticket comments to handoffs 2026-08-25 13:21:47 +09:00
Hare bf7171924d feat: integrate dependency queue planning 2026-08-25 13:08:09 +09:00
Hare 097c363fbc fix: block internal dependency cycles in projections 2026-08-25 12:54:47 +09:00
Hare 7dd8809e38 fix: fail closed without queue target authority 2026-08-25 12:44:03 +09:00
Hare 1749757036 fix: align queue eligibility with target authority 2026-08-25 12:24:31 +09:00
Hare 5857e6121c fix: preserve dependency queue atomicity 2026-08-25 12:05:43 +09:00
Hare a41147916b fix: confirm queue closures across clients 2026-08-25 11:51:40 +09:00
Hare cabe38db1d fix: align queue projections with dependency closure 2026-08-25 11:19:27 +09:00
Hare f079479160 feat: queue ready dependency closures atomically 2026-08-25 10:53:31 +09:00
Hare 87e160a01a test: track current migration version in dry run 2026-08-25 10:29:48 +09:00
Hare f94d829bf8 feat: integrate hare/develop 2026-08-25 10:29:41 +09:00
Hare 9a05bfa0c3 feat: allow ticket implementation cancellation 2026-08-25 09:52:21 +09:00
Hare c1d46859a3 fix: block cleanup for assigned workers 2026-08-25 09:39:33 +09:00
Hare 87ecbcb113 fix: validate non-worker ticket assignments 2026-08-25 08:09:31 +09:00
Hare 1fb2949561 fix: give reviewer write-scoped command tools 2026-08-25 04:45:17 +09:00
Hare 11be777fc0 fix: verify signed Workspace query targets 2026-08-25 01:12:24 +09:00
Hare ff94161fc0 test: authenticate workdir route fixtures 2026-08-25 00:54:28 +09:00
Hare c2ab9a950f feat: integrate Workspace API authorization 2026-08-25 00:54:13 +09:00
Hare 8e26a0f5a8 chore: merge latest develop into Ticket source
# Conflicts:
#	crates/workspace-server/src/store.rs
2026-08-24 23:24:12 +09:00
Hare d1c15ee295 fix: protect direct auth mutation routes 2026-08-24 14:58:34 +09:00
Hare 00a96234c6 fix: enforce CSRF on server mutations 2026-08-24 14:50:15 +09:00
Hare fc3b663510 fix: authorize Runtime profile archive fetches 2026-08-24 14:39:16 +09:00
Hare e4e045d059 fix: preserve Workdir provider rejection codes 2026-08-24 14:30:34 +09:00
Hare 436feaf33d fix: close remaining Workspace auth gaps 2026-08-24 14:28:44 +09:00
Hare 29f450b962 fix: map missing default Runtime to bad request 2026-08-24 14:16:38 +09:00
Hare db343893c8 fix: secure HTTPS browser session cookies 2026-08-24 14:11:51 +09:00
Hare 554906ec02 fix: treat legacy Runtime config as unconfigured 2026-08-24 14:07:42 +09:00
Hare 18c37f4842 fix: persist Workdir Runtime failure classifications 2026-08-24 13:59:52 +09:00
Hare 83382b824a fix: protect legacy Workspace API routes 2026-08-24 13:56:46 +09:00
Hare 6203316aa1 fix: defer local repository validation to runtimes 2026-08-24 13:46:02 +09:00
Hare d57b4d1d5e fix: align ticket queue projection test 2026-08-24 13:42:39 +09:00
Hare 379ae214fc feat: queue tickets with dependency context 2026-08-24 13:40:33 +09:00
Hare 163a403636 feat: support typed remote repository sources 2026-08-24 13:35:19 +09:00
Hare 53edaadc3a feat: authenticate Workspace API requests 2026-08-24 13:32:55 +09:00
Hare 3c2664c3ce feat: sign Runtime workspace requests 2026-08-24 13:32:49 +09:00
Hare d9048954a5 fix: reject unsupported Workdir runtimes before delegation 2026-08-24 13:24:59 +09:00
Hare 4f84dfd73f feat: resolve default Runtime for Workdir creation 2026-08-24 13:15:18 +09:00
73 changed files with 7361 additions and 2258 deletions
Generated
+2
View File
@@ -6131,9 +6131,11 @@ dependencies = [
"tokio-tungstenite 0.29.0",
"toml",
"tower",
"url",
"uuid",
"workdir",
"worker",
"workspace-api",
]
[[package]]
+1 -1
View File
@@ -21,7 +21,7 @@ services:
- "8787"
volumes:
- server-data:/server-data
- ./docker/workspace:/workspace:ro
- /etc/yoi/server.toml:/server-config/server.toml:ro
webui:
image: yoi-webui:latest
+7 -1
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use workspace_api::{RepositoryObservedStatus, RepositorySource};
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
@@ -44,8 +45,13 @@ pub struct CreateBackendWorkspaceRepositoryRecord {
pub repository_id: String,
pub name: String,
pub kind: String,
pub uri: String,
pub provider: Option<String>,
pub source: RepositorySource,
pub default_ref: Option<String>,
pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
pub observed_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
+9 -5
View File
@@ -26,7 +26,7 @@ struct BackendWorkerLaunchOptions {
#[derive(Debug, Deserialize)]
struct BackendWorkerLaunchRuntime {
runtime_id: String,
can_spawn_worker: bool,
worker_creation_available: bool,
working_directory_required: bool,
}
@@ -261,7 +261,7 @@ impl BackendWorkspaceProductClient {
let runtime = options
.runtimes
.iter()
.find(|runtime| runtime.can_spawn_worker && !runtime.working_directory_required)
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
.ok_or_else(|| {
BackendWorkspaceClientError::InvalidTarget(
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
@@ -473,8 +473,12 @@ impl TicketBackend for BackendWorkspaceProductClient {
.map_err(ticket_client_error)
}
fn queue_ready(&self, id: TicketIdOrSlug, _queued_by: &str) -> ticket::Result<()> {
self.send_unit::<()>(
fn queue_ready(
&self,
id: TicketIdOrSlug,
_queued_by: &str,
) -> ticket::Result<ticket::TicketQueueOutcome> {
self.send_json::<(), _>(
Method::POST,
&format!(
"/tickets/{}/workflow/queue",
@@ -773,7 +777,7 @@ mod tests {
let (base_url, requests, handle) = response_sequence_server(vec![
(
"200 OK",
r#"{"runtimes":[{"runtime_id":"embedded","can_spawn_worker":true,"working_directory_required":false}]}"#,
r#"{"runtimes":[{"runtime_id":"embedded","worker_creation_available":true,"working_directory_required":false}]}"#,
),
(
"200 OK",
+1 -1
View File
@@ -1377,7 +1377,7 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "completions");
assert_eq!(parsed["data"]["kind"], "file");
assert_eq!(parsed["data"]["entries"][0]["value"], "clear");
assert_eq!(parsed["data"]["entries"][0]["value"], "src/main.rs");
// is_dir defaults to false on inbound payloads that omit it.
let inbound =
+1103 -198
View File
File diff suppressed because it is too large Load Diff
+20 -6
View File
@@ -142,8 +142,8 @@ const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark
The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \
state_changed event, effective target, and planning -> ready transition atomically.";
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
and rejects unresolved blocking relations.";
Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \
requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \
@@ -316,7 +316,11 @@ impl TicketBackend for TicketToolBackend {
self.backend.mark_ready(id, request)
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
fn queue_ready(
&self,
id: TicketIdOrSlug,
queued_by: &str,
) -> TicketResult<crate::TicketQueueOutcome> {
self.backend.queue_ready(id, queued_by)
}
@@ -1219,12 +1223,22 @@ impl Tool for TicketQueueTool {
) -> Result<ToolOutput, ToolError> {
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
let queued_by = default_author();
self.backend
let outcome = self
.backend
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
.map_err(|error| backend_error("TicketQueue", error))?;
Ok(json_output(
format!("Queued ticket {} for Orchestrator", params.ticket),
json!({ "ticket": params.ticket, "state": "queued", "queued_by": queued_by, "ok": true }),
format!(
"Queued {} ticket(s) for Orchestrator",
outcome.queued_tickets.len()
),
json!({
"ticket": outcome.requested_ticket,
"queued_tickets": outcome.queued_tickets,
"state": "queued",
"queued_by": queued_by,
"ok": true
}),
))
}
}
+8 -3
View File
@@ -131,10 +131,11 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
assert!(
msg.contains("outside allowed read scope")
|| msg.contains("outside allowed write scope")
|| msg.contains("outside allowed scope")
|| msg.contains("has not been read"),
"symlink escape not rejected: {msg}"
);
if !msg.contains("has not been read") {
if msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope") {
assert!(
msg.contains("add the symlink target"),
"symlink escape diagnostic should include remediation: {msg}"
@@ -233,12 +234,16 @@ async fn absolute_path_is_rejected() {
)
.await
.unwrap_err();
assert!(format!("{err}").contains("invalid Workdir path"));
let msg = format!("{err}");
assert!(
msg.contains("invalid logical filesystem path"),
"absolute path was not rejected as invalid: {msg}"
);
}
#[tokio::test]
async fn directory_target_is_rejected_for_read() {
let (dir, _spill, reg) = setup();
let (_dir, _spill, reg) = setup();
let read = reg.get("Read");
let err = read
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
+6 -3
View File
@@ -191,7 +191,7 @@ async fn write_then_grep_finds_content() {
#[tokio::test]
async fn glob_finds_written_files() {
let (dir, _spill, reg) = setup();
let (_dir, _spill, reg) = setup();
let write = reg.get("Write");
let glob = reg.get("Glob");
@@ -229,7 +229,10 @@ async fn absolute_path_is_rejected() {
.await;
// Absolute paths are rejected at the logical WorkdirSession boundary.
let msg = format!("{err}");
assert!(msg.contains("invalid Workdir path"), "unexpected: {msg}");
assert!(
msg.contains("invalid logical filesystem path"),
"unexpected: {msg}"
);
}
#[tokio::test]
@@ -340,7 +343,7 @@ async fn tracker_recent_files_tracks_read_write_edit() {
));
let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt");
let _b = dir.path().join("b.txt");
std::fs::write(&a, "one\n").unwrap();
// Read `a` — should appear in recency.
+127 -31
View File
@@ -4,6 +4,7 @@ use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use client::ticket_role::{
@@ -4125,7 +4126,10 @@ async fn dispatch_ticket_action(
let config = TicketConfig::load_workspace(&request.workspace_root)
.map_err(|error| TicketActionError::BackendConfig(error.to_string()))?;
let backend = LocalTicketBackend::new(config.backend_root())
.with_record_language(config.ticket_record_language());
.with_record_language(config.ticket_record_language())
.with_target_authority(Arc::new(DashboardTicketTargetAuthority {
workspace_root: request.workspace_root.clone(),
}));
if request.action == NextUserAction::Close {
return dispatch_panel_close(&backend, &request.ticket_id);
}
@@ -4201,17 +4205,32 @@ async fn dispatch_panel_queue(
"root-ticket-state-after-orchestration-merge",
&preflight.root_top_level,
)?;
backend
let queue_outcome = backend
.queue_ready(TicketIdOrSlug::Id(ticket_id.to_owned()), "workspace-panel")
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
let expected_queue_tickets = preflight
.queue_tickets
.iter()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
let actual_queue_tickets = queue_outcome
.queued_tickets
.iter()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
if actual_queue_tickets != expected_queue_tickets {
return Err(TicketActionError::Stale(format!(
"Queue dependency plan changed after confirmation for Ticket {ticket_id}; reload and retry"
)));
}
let commit = commit_panel_queue_ticket_record(&preflight)?;
let sync = sync_panel_queue_to_orchestration(&preflight, &commit)?;
verify_panel_queue_synced(&preflight, &commit)?;
let notification = notify_workspace_orchestrator(orchestrator, current_ticket).await;
Ok(TicketActionOutcome {
notice: format!(
"Queued Ticket {}; root Queue commit {}; {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
ticket_id,
"Queued Ticket closure [{}]; root Queue commit {}; {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
queue_outcome.queued_tickets.join(", "),
commit.sha,
root_merge.sentence(),
sync.sentence(),
@@ -4220,12 +4239,52 @@ async fn dispatch_panel_queue(
})
}
struct DashboardTicketTargetAuthority {
workspace_root: PathBuf,
}
impl ticket::TicketTargetAuthority for DashboardTicketTargetAuthority {
fn resolve_target(
&self,
_workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> ticket::Result<ticket::ResolvedTicketTarget> {
let repository_id = repository_id.unwrap_or("main");
if repository_id != "main" {
return Err(ticket::TicketError::UnknownTargetRepository(
repository_id.to_string(),
));
}
let ref_selector = ref_selector.unwrap_or("HEAD");
git_capture(
&self.workspace_root,
&[
"rev-parse",
"--verify",
&format!("{ref_selector}^{{commit}}"),
],
"resolve Queue Ticket target",
)
.map_err(|reason| ticket::TicketError::InvalidTargetSelector {
repository_id: repository_id.to_string(),
selector: ref_selector.to_string(),
reason,
})?;
Ok(ticket::ResolvedTicketTarget {
repository_id: repository_id.to_string(),
ref_selector: ref_selector.to_string(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PanelQueueHandoffPreflight {
ticket_id: String,
root_top_level: PathBuf,
orchestration: OrchestrationWorktreeLayout,
ticket_record_dir: PathBuf,
queue_tickets: Vec<String>,
ticket_record_dirs: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -4393,27 +4452,53 @@ fn prepare_panel_queue_handoff(
&root_top_level,
)?;
let ticket_record_dir = backend.root().join(ticket_id);
let dependency_check = backend
.dependency_check(TicketIdOrSlug::Id(ticket_id.to_owned()))
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
if !dependency_check.queue_guard.can_queue_for_orchestrator {
return Err(queue_check_failed(
"dependency-queue-plan",
ticket_id,
&root_top_level,
dependency_check
.queue_guard
.blocked_reason
.or(dependency_check.queue_guard.reason)
.unwrap_or_else(|| "Queue dependency validation failed".to_string()),
));
}
let queue_tickets = dependency_check.queue_tickets;
let mut ticket_record_dirs = Vec::with_capacity(queue_tickets.len());
for queue_ticket in &queue_tickets {
let ticket_record_dir = backend.root().join(queue_ticket);
if !ticket_record_dir.join("item.md").is_file() {
return Err(queue_check_failed(
"target-ticket-record",
ticket_id,
&queue_ticket,
&ticket_record_dir,
"target Ticket item.md is missing".to_string(),
"Queue Ticket item.md is missing".to_string(),
));
}
let clean_stage = if queue_ticket == ticket_id {
"root-ticket-clean"
} else {
"queue-dependency-clean"
};
ensure_git_path_clean(
"root-ticket-clean",
ticket_id,
clean_stage,
&queue_ticket,
&root_top_level,
&ticket_record_dir,
)?;
ticket_record_dirs.push(ticket_record_dir);
}
Ok(PanelQueueHandoffPreflight {
ticket_id: ticket_id.to_string(),
root_top_level,
orchestration,
ticket_record_dir,
queue_tickets,
ticket_record_dirs,
})
}
@@ -4504,37 +4589,36 @@ fn sync_orchestration_to_root_before_queue(
fn commit_panel_queue_ticket_record(
preflight: &PanelQueueHandoffPreflight,
) -> Result<PanelQueueCommit, TicketActionError> {
let ticket_rel = path_relative_to_root(
let ticket_rels = preflight
.ticket_record_dirs
.iter()
.map(|ticket_record_dir| {
path_relative_to_root(
&preflight.root_top_level,
&preflight.ticket_record_dir,
ticket_record_dir,
"target-ticket-record",
&preflight.ticket_id,
)?;
)
})
.collect::<Result<Vec<_>, _>>()?;
let mut add = Command::new("git");
add.arg("-C")
.arg(&preflight.root_top_level)
.arg("add")
.arg("--")
.arg(&ticket_rel);
run_git_command(add, "stage Queue Ticket record").map_err(|message| {
.args(&ticket_rels);
run_git_command(add, "stage Queue Ticket records").map_err(|message| {
queue_check_failed(
"queue-commit-stage",
&preflight.ticket_id,
&preflight.ticket_record_dir,
&preflight.root_top_level,
message,
)
})?;
let ticket_rel_string = git_path_string(&ticket_rel);
let staged = git_capture(
&preflight.root_top_level,
&[
"diff",
"--cached",
"--name-only",
"--",
ticket_rel_string.as_str(),
],
&["diff", "--cached", "--name-only"],
"list staged Queue Ticket files",
)
.map_err(|message| {
@@ -4545,19 +4629,31 @@ fn commit_panel_queue_ticket_record(
message,
)
})?;
let allowed = ticket_rels
.iter()
.map(|path| format!("{}/", git_path_string(path).trim_end_matches('/')))
.collect::<Vec<_>>();
let staged_paths = staged
.lines()
.filter(|line| !line.trim().is_empty())
.collect::<Vec<_>>();
if staged_paths.is_empty() {
if staged_paths.is_empty()
|| staged_paths
.iter()
.any(|path| !allowed.iter().any(|root| path.starts_with(root)))
{
return Err(queue_check_failed(
"queue-commit-pathscope",
&preflight.ticket_id,
&preflight.ticket_record_dir,
"Queue mutation produced no staged Ticket record changes".to_string(),
&preflight.root_top_level,
"Queue mutation staged no Ticket records or included files outside the confirmed dependency closure"
.to_string(),
));
}
let message = format!("ticket: queue {}", preflight.ticket_id);
let message = format!(
"chore: queue Ticket dependency closure {}",
preflight.ticket_id
);
let mut commit = Command::new("git");
commit
.arg("-C")
@@ -4567,8 +4663,8 @@ fn commit_panel_queue_ticket_record(
.arg("-m")
.arg(message)
.arg("--")
.arg(&ticket_rel);
run_git_command(commit, "commit Queue Ticket record").map_err(|message| {
.args(&ticket_rels);
run_git_command(commit, "commit Queue Ticket records").map_err(|message| {
queue_check_failed(
"queue-commit-create",
&preflight.ticket_id,
+1 -1
View File
@@ -462,7 +462,7 @@ pub(super) fn panel_ticket_detail(row: &PanelRow) -> String {
.as_ref()
.and_then(|ticket| ticket.blocked_reason.as_deref())
{
parts.push(format!("Gate: waiting for {blocked_reason}"));
parts.push(format!("Dependencies: {blocked_reason}"));
} else {
parts.push("Gate: clear".to_string());
}
+7 -8
View File
@@ -1846,24 +1846,23 @@ fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() {
}
#[test]
fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() {
fn ready_ticket_with_dependency_context_keeps_queue_action_available() {
let mut row = panel_test_ticket_row(
"00001WAITING",
"Ready but gated",
ActionPriority::Background,
NextUserAction::Wait,
"Ready with dependency context",
ActionPriority::ReadyForQueue,
NextUserAction::Queue,
"ready",
);
row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string());
row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string());
let lines = panel_row_lines(&row, true, 160);
let detail = &lines[1];
let detail_line = plain_line(&detail);
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on"));
assert!(detail_line.contains("Action: queue disabled"));
assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1"));
assert!(detail_line.contains("Dependencies: BLOCKER-1 via depends_on"));
assert!(detail_line.contains("Action: Queue"));
assert!(!detail_line.contains("Queue disabled"));
}
#[test]
+10 -12
View File
@@ -2203,7 +2203,7 @@ mod tests {
}
#[test]
fn workspace_panel_marks_ready_ticket_with_unresolved_relation_waiting_gate() {
fn workspace_panel_blocks_ready_ticket_with_planning_relation() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
@@ -2235,12 +2235,7 @@ mod tests {
assert_eq!(row.kind, PanelRowKind::Ticket);
assert_eq!(row.next_action, Some(NextUserAction::Wait));
assert_eq!(row.priority, ActionPriority::Background);
assert!(
row.disabled_reason
.as_deref()
.unwrap()
.contains("Queue disabled: waiting for")
);
assert!(row.disabled_reason.is_some());
assert!(
row.ticket
.as_ref()
@@ -2253,7 +2248,7 @@ mod tests {
}
#[test]
fn workspace_panel_allows_ready_ticket_when_relation_prerequisite_is_queued() {
fn workspace_panel_queues_ready_ticket_when_relation_prerequisite_is_queued() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
@@ -2286,13 +2281,16 @@ mod tests {
assert_eq!(row.next_action, Some(NextUserAction::Queue));
assert_eq!(row.priority, ActionPriority::ReadyForQueue);
assert!(row.disabled_reason.is_none());
assert!(row.ticket.as_ref().unwrap().blocked_reason.is_none());
assert!(
row.key_hint
.as_deref()
row.ticket
.as_ref()
.unwrap()
.contains("Queue allowed: prerequisites are already queued/in progress")
.blocked_reason
.as_deref()
.unwrap_or_default()
.contains(&dependency.id)
);
assert!(row.key_hint.as_deref().unwrap().contains("Queue targets:"));
assert!(row.key_hint.as_deref().unwrap().contains(&dependency.id));
}
+42 -1
View File
@@ -311,7 +311,10 @@ impl DelegatingWorkdirSession {
if !self.capabilities.supports(WorkdirSessionCapability::Read)
|| (writable
&& (!self.capabilities.supports(WorkdirSessionCapability::Write)
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)))
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)
|| !self
.capabilities
.supports(WorkdirSessionCapability::Command)))
{
return Err(WorkdirError::Denied(
"parent workdir session cannot delegate the requested capabilities".into(),
@@ -342,6 +345,7 @@ impl DelegatingWorkdirSession {
if writable {
delegated.push(WorkdirSessionCapability::Write);
delegated.push(WorkdirSessionCapability::Edit);
delegated.push(WorkdirSessionCapability::Command);
}
Ok(WorkdirSessionCapabilities::from_capabilities(delegated))
}
@@ -929,6 +933,43 @@ mod tests {
.delegate(request("leased", WorkdirDelegationPermission::Write))
.await
.unwrap();
assert!(
child
.capabilities
.supports(WorkdirSessionCapability::Command)
);
let command = child
.scoped_session
.start_command(CommandRequest {
command: "printf child-command".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("delegated-child-command".into()),
})
.await
.unwrap();
let command_output = child
.scoped_session
.command_output(CommandOutputRequest {
handle: command,
cursor: 0,
limit: 1024,
wait: true,
})
.await
.unwrap();
assert_eq!(command_output.content, "child-command");
assert!(
parent
.start_command(CommandRequest {
command: "printf parent-command".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("blocked-parent-command".into()),
})
.await
.is_err()
);
assert!(matches!(
parent.write(write("leased/file", "parent")).await,
+2
View File
@@ -211,6 +211,7 @@ pub struct WorkingDirectoryListResponse {
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryDetailResponse {
pub workspace_id: String,
pub runtime_id: String,
pub item: WorkingDirectorySummary,
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
}
@@ -306,6 +307,7 @@ mod tests {
let detail = WorkingDirectoryDetailResponse {
workspace_id: decoded.workspace_id.clone(),
runtime_id: "arcadia".to_string(),
item: decoded.items[0].clone(),
diagnostics: decoded.diagnostics.clone(),
};
+2
View File
@@ -41,9 +41,11 @@ tar.workspace = true
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
toml.workspace = true
url.workspace = true
uuid = { workspace = true, features = ["v7"] }
tower = { workspace = true, features = ["util"], optional = true }
worker.workspace = true
workspace-api = { path = "../workspace-api" }
workdir.workspace = true
[dev-dependencies]
+259
View File
@@ -3,6 +3,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::{SecureRandom, SystemRandom};
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -14,6 +15,11 @@ pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-pro
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
pub const WORKER_REMOVE_PERMISSION: &str = "workspace:worker-remove";
pub const RUNTIME_REQUEST_SOURCE_PROOF_HEADER: &str = "x-yoi-runtime-request-proof";
pub const WORKSPACE_REQUEST_PERMISSION: &str = "workspace:request";
pub const BACKEND_RESOURCE_FETCH_PERMISSION: &str = "workspace:resource-fetch";
const RUNTIME_REQUEST_SOURCE_PROOF_PREFIX: &str = "yoi-runtime-request-v1";
const RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-runtime-request-v1.";
#[derive(Debug, thiserror::Error)]
pub enum RuntimeAuthError {
@@ -33,6 +39,10 @@ pub enum RuntimeAuthError {
InvalidTokenFormat,
#[error("malformed capability token claims: {0}")]
MalformedClaims(#[from] serde_json::Error),
#[error("runtime request proof contains an invalid `{0}` claim")]
InvalidClaim(&'static str),
#[error("runtime request proof does not match the HTTP request")]
ClaimMismatch,
#[error("unknown token issuer `{0}`")]
UnknownIssuer(String),
#[error("invalid token signature")]
@@ -224,6 +234,162 @@ pub fn verify_capability_token(
})
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeRequestSourceClaims {
pub iss: String,
pub aud: String,
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker_id: Option<String>,
pub permission: String,
pub method: String,
pub path: String,
pub body_digest: String,
pub iat: i64,
pub exp: i64,
pub jti: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeRequestSourceSigner {
identity_id: String,
private_key: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeRequestSourceExpectation<'a> {
pub identity_id: &'a str,
pub audience: &'a str,
pub workspace_id: &'a str,
pub worker_id: Option<&'a str>,
pub permission: &'a str,
pub method: &'a str,
pub path: &'a str,
pub body_digest: &'a str,
pub now_unix: i64,
}
pub fn request_body_digest(body: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(body))
}
impl RuntimeRequestSourceSigner {
pub fn from_identity(identity: &RuntimeIdentityMaterial) -> Self {
Self {
identity_id: identity.identity_id.clone(),
private_key: identity.private_key.clone(),
}
}
#[allow(clippy::too_many_arguments)]
pub fn issue(
&self,
audience: &str,
workspace_id: &str,
worker_id: Option<&str>,
permission: &str,
method: &str,
path: &str,
body: &[u8],
now_unix: i64,
ttl_seconds: u64,
) -> Result<String, RuntimeAuthError> {
for (name, value) in [
("audience", audience),
("workspace_id", workspace_id),
("permission", permission),
("method", method),
("path", path),
] {
if value.trim().is_empty() {
return Err(RuntimeAuthError::InvalidClaim(name));
}
}
if worker_id.is_some_and(str::is_empty) {
return Err(RuntimeAuthError::InvalidClaim("worker_id"));
}
let ttl_seconds = i64::try_from(ttl_seconds).unwrap_or(i64::MAX);
let claims = RuntimeRequestSourceClaims {
iss: self.identity_id.clone(),
aud: audience.to_owned(),
workspace_id: workspace_id.to_owned(),
worker_id: worker_id.map(str::to_owned),
permission: permission.to_owned(),
method: method.to_owned(),
path: path.to_owned(),
body_digest: request_body_digest(body),
iat: now_unix,
exp: now_unix.saturating_add(ttl_seconds),
jti: new_token_id()?,
};
let payload = serde_json::to_vec(&claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let private = decode_private_key(&self.private_key)?;
let key_pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref());
Ok(format!(
"{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}"
))
}
}
pub fn decode_runtime_request_source_claims(
proof: &str,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?;
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let payload = URL_SAFE_NO_PAD.decode(payload)?;
serde_json::from_slice(&payload).map_err(RuntimeAuthError::from)
}
pub fn verify_runtime_request_source(
proof: &str,
public_key: &str,
expected: &RuntimeRequestSourceExpectation<'_>,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?;
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let signature = URL_SAFE_NO_PAD.decode(signature)?;
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let public_key = decode_public_key(public_key)?;
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
let claims = decode_runtime_request_source_claims(proof)?;
if claims.iss != expected.identity_id
|| claims.aud != expected.audience
|| claims.workspace_id != expected.workspace_id
|| claims.worker_id.as_deref() != expected.worker_id
|| claims.permission != expected.permission
|| claims.method != expected.method
|| claims.path != expected.path
|| claims.body_digest != expected.body_digest
{
return Err(RuntimeAuthError::ClaimMismatch);
}
if claims.iat > expected.now_unix || claims.exp < expected.now_unix {
return Err(RuntimeAuthError::Expired);
}
Ok(claims)
}
fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = proof.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerMutationSourceClaims {
pub iss: String,
@@ -592,6 +758,99 @@ mod tests {
));
}
#[test]
fn runtime_request_source_proof_binds_request_and_rejects_spoofed_signature() {
let trusted = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
let signer = RuntimeRequestSourceSigner::from_identity(&trusted);
let body = br#"{"ticket":"T-1"}"#;
let proof = signer
.issue(
"server-main",
"workspace-a",
Some("worker-7"),
WORKSPACE_REQUEST_PERMISSION,
"POST",
"/api/w/workspace-a/tickets/comment",
body,
90,
10,
)
.unwrap();
let expected = RuntimeRequestSourceExpectation {
identity_id: "runtime-main",
audience: "server-main",
workspace_id: "workspace-a",
worker_id: Some("worker-7"),
permission: WORKSPACE_REQUEST_PERMISSION,
method: "POST",
path: "/api/w/workspace-a/tickets/comment",
body_digest: &request_body_digest(body),
now_unix: 99,
};
let claims = verify_runtime_request_source(&proof, &trusted.public_key, &expected).unwrap();
assert_eq!(claims.iss, "runtime-main");
let changed_body = RuntimeRequestSourceExpectation {
body_digest: &request_body_digest(br#"{"ticket":"T-2"}"#),
..expected.clone()
};
assert!(matches!(
verify_runtime_request_source(&proof, &trusted.public_key, &changed_body),
Err(RuntimeAuthError::ClaimMismatch)
));
let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
assert!(matches!(
verify_runtime_request_source(&proof, &spoofed.public_key, &expected),
Err(RuntimeAuthError::InvalidSignature)
));
}
#[test]
fn runtime_request_source_proof_rejects_wrong_scope_and_expiry() {
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
let proof = RuntimeRequestSourceSigner::from_identity(&runtime)
.issue(
"server-main",
"workspace-a",
None,
BACKEND_RESOURCE_FETCH_PERMISSION,
"POST",
"/api/runtime/v1/workspaces/workspace-a/resources/fetch",
b"{}",
90,
10,
)
.unwrap();
let digest = request_body_digest(b"{}");
let expected = RuntimeRequestSourceExpectation {
identity_id: "runtime-main",
audience: "server-main",
workspace_id: "workspace-a",
worker_id: None,
permission: BACKEND_RESOURCE_FETCH_PERMISSION,
method: "POST",
path: "/api/runtime/v1/workspaces/workspace-a/resources/fetch",
body_digest: &digest,
now_unix: 99,
};
assert!(verify_runtime_request_source(&proof, &runtime.public_key, &expected).is_ok());
let wrong_workspace = RuntimeRequestSourceExpectation {
workspace_id: "workspace-b",
..expected.clone()
};
assert!(matches!(
verify_runtime_request_source(&proof, &runtime.public_key, &wrong_workspace),
Err(RuntimeAuthError::ClaimMismatch)
));
let expired = RuntimeRequestSourceExpectation {
now_unix: 101,
..expected
};
assert!(matches!(
verify_runtime_request_source(&proof, &runtime.public_key, &expired),
Err(RuntimeAuthError::Expired)
));
}
#[test]
fn capability_token_verifies_signature_audience_expiry_and_permission() {
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
+3 -4
View File
@@ -2,7 +2,6 @@ use crate::identity::{RuntimeWorkerRef, WorkerId, WorkerRef};
use crate::interaction::WorkerInput;
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
fn is_false(value: &bool) -> bool {
!*value
@@ -85,9 +84,9 @@ impl std::ops::Deref for RepositorySelector {
pub struct WorkingDirectoryRepository {
pub id: String,
pub provider: String,
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_path: Option<PathBuf>,
pub source: workspace_api::RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<RepositorySelector>,
}
+20 -2
View File
@@ -160,15 +160,33 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
};
let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root"))
.with_runtime_store_dir(runtime_store_dir);
if let Some(identity) = read_runtime_auth_file(&runtime_auth_path(config))?.identity {
let runtime_auth = read_runtime_auth_file(&runtime_auth_path(config))?;
if let Some(identity) = runtime_auth.identity.clone() {
if let [trusted_server] = runtime_auth.trusted_servers.as_slice() {
factory =
factory.with_runtime_request_identity(identity, trusted_server.server_id.clone());
} else {
factory = factory.with_remote_worker_mutation_identity(identity);
}
}
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
let identity = runtime_auth.identity.as_ref().ok_or_else(|| {
ProcessError::Auth(
"--backend-resource-endpoint requires a configured Runtime identity".to_owned(),
)
})?;
let [trusted_server] = runtime_auth.trusted_servers.as_slice() else {
return Err(ProcessError::Auth(
"--backend-resource-endpoint requires exactly one trusted Server identity"
.to_owned(),
));
};
factory = factory.with_resource_client(Arc::new(
worker_runtime::resource::HttpBackendResourceClient::new(
endpoint,
config.backend_resource_token.clone(),
),
)
.with_runtime_request_source(identity, trusted_server.server_id.clone()),
));
}
let backend = Arc::new(
+56 -1
View File
@@ -1,3 +1,7 @@
use crate::auth::{
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
};
use crate::identity::WorkerId;
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
use async_trait::async_trait;
@@ -108,6 +112,8 @@ pub trait BackendResourceClient: Send + Sync + 'static {
pub struct HttpBackendResourceClient {
endpoint: String,
bearer_token: Option<String>,
request_source_signer: Option<RuntimeRequestSourceSigner>,
request_source_audience: Option<String>,
client: reqwest::Client,
}
@@ -117,9 +123,21 @@ impl HttpBackendResourceClient {
Self {
endpoint: endpoint.into(),
bearer_token,
request_source_signer: None,
request_source_audience: None,
client: reqwest::Client::new(),
}
}
pub fn with_runtime_request_source(
mut self,
identity: &RuntimeIdentityMaterial,
audience: impl Into<String>,
) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
self.request_source_audience = Some(audience.into());
self
}
}
#[cfg(feature = "http-server")]
@@ -129,7 +147,44 @@ impl BackendResourceClient for HttpBackendResourceClient {
&self,
request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError> {
let builder = self.client.post(&self.endpoint).json(&request);
let body = serde_json::to_vec(&request).map_err(|error| {
BackendResourceError::InvalidResponse {
message: error.to_string(),
}
})?;
let endpoint = reqwest::Url::parse(&self.endpoint).map_err(|error| {
BackendResourceError::Transport {
message: error.to_string(),
}
})?;
let mut builder = self
.client
.post(endpoint.clone())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.clone());
if let Some(signer) = self.request_source_signer.as_ref() {
let audience = self.request_source_audience.as_deref().ok_or_else(|| {
BackendResourceError::Unauthorized {
message: "Runtime request proof audience is unavailable".to_owned(),
}
})?;
let proof = signer
.issue(
audience,
&request.handle.workspace_id,
None,
BACKEND_RESOURCE_FETCH_PERMISSION,
"POST",
endpoint.path(),
&body,
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| BackendResourceError::Unauthorized {
message: error.to_string(),
})?;
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
let builder = if let Some(token) = self.bearer_token.as_deref() {
builder.bearer_auth(token)
} else {
+106 -13
View File
@@ -14,7 +14,10 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
use crate::auth::RuntimeIdentityMaterial;
use crate::auth::{
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
};
use crate::catalog::{
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
WorkingDirectoryRequest, WorkingDirectoryStatus,
@@ -295,6 +298,7 @@ pub struct ProfileRuntimeWorkerFactory {
prompt_projection_cache: Arc<WorkspacePromptProjectionCache>,
runtime_id: Option<String>,
worker_mutation_identity: Option<RuntimeIdentityMaterial>,
runtime_request_audience: Option<String>,
embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>,
controller_transport: WorkerControllerTransport,
}
@@ -311,6 +315,7 @@ impl ProfileRuntimeWorkerFactory {
prompt_projection_cache: Arc::new(WorkspacePromptProjectionCache::default()),
runtime_id: None,
worker_mutation_identity: None,
runtime_request_audience: None,
embedded_worker_mutation_dispatcher: None,
controller_transport: WorkerControllerTransport::UnixSocket,
}
@@ -331,6 +336,17 @@ impl ProfileRuntimeWorkerFactory {
self
}
pub fn with_runtime_request_identity(
mut self,
identity: RuntimeIdentityMaterial,
audience: impl Into<String>,
) -> Self {
self.runtime_id = Some(identity.identity_id.clone());
self.worker_mutation_identity = Some(identity);
self.runtime_request_audience = Some(audience.into());
self
}
pub fn with_embedded_worker_mutation_dispatcher(
mut self,
runtime_id: impl Into<String>,
@@ -457,13 +473,15 @@ impl ProfileRuntimeWorkerFactory {
async fn resolve_profile_source_archive(
&self,
source: &ProfileSourceArchiveSource,
request_audience: Option<&str>,
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
match source {
ProfileSourceArchiveSource::Embedded { archive } => archive
.verify()
.map_err(|err| format!("failed to verify embedded profile source archive: {err}")),
ProfileSourceArchiveSource::Http { location } => {
self.fetch_profile_source_archive(location).await
self.fetch_profile_source_archive(location, request_audience)
.await
}
}
}
@@ -471,10 +489,18 @@ impl ProfileRuntimeWorkerFactory {
async fn fetch_profile_source_archive(
&self,
location: &ProfileSourceArchiveHttpRef,
request_audience: Option<&str>,
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
if let Some(cached) = self.profile_archive_cache.get(&location.archive.digest) {
let response =
fetch_profile_source_archive_http(location, Some(&location.archive.digest)).await?;
let response = fetch_profile_source_archive_http(
location,
Some(&location.archive.digest),
self.worker_mutation_identity.as_ref(),
self.runtime_request_audience
.as_deref()
.or(request_audience),
)
.await?;
if let Some(fetched) = response {
self.profile_archive_cache.insert(fetched.clone());
fetched.verify().map_err(|err| {
@@ -486,7 +512,14 @@ impl ProfileRuntimeWorkerFactory {
.map_err(|err| format!("failed to verify cached profile source archive: {err}"))
}
} else {
let archive = fetch_profile_source_archive_http(location, None)
let archive = fetch_profile_source_archive_http(
location,
None,
self.worker_mutation_identity.as_ref(),
self.runtime_request_audience
.as_deref()
.or(request_audience),
)
.await?
.ok_or_else(|| {
"profile source archive HTTP revalidation returned 304 without a cached archive"
@@ -527,6 +560,7 @@ impl RuntimeWorkspaceBackendRef {
worker_ref: &WorkerRef,
workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>,
mutation_identity: Option<&RuntimeIdentityMaterial>,
runtime_request_audience: Option<&str>,
embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>,
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
) -> WorkerWorkspaceContext {
@@ -546,6 +580,13 @@ impl RuntimeWorkspaceBackendRef {
if let Some(cache) = prompt_projection_cache {
client = client.with_prompt_projection_cache(cache);
}
if let Some(identity) = mutation_identity {
let audience = runtime_request_audience
.or_else(|| workspace_scope.map(|scope| scope.server_id.as_str()));
if let Some(audience) = audience {
client = client.with_runtime_request_source(identity, audience.to_owned());
}
}
if let (Some(scope), Some(identity)) = (workspace_scope, mutation_identity) {
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote(
identity,
@@ -576,9 +617,40 @@ impl RuntimeWorkspaceBackendRef {
async fn fetch_profile_source_archive_http(
location: &ProfileSourceArchiveHttpRef,
cached_digest: Option<&str>,
identity: Option<&RuntimeIdentityMaterial>,
audience: Option<&str>,
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
let client = reqwest::Client::new();
let mut request = client.get(&location.url);
let url = reqwest::Url::parse(&location.url)
.map_err(|error| format!("profile source archive URL is invalid: {error}"))?;
let path = url.path().to_owned();
let workspace_id = path
.split('/')
.collect::<Vec<_>>()
.windows(2)
.find_map(|parts| (parts[0] == "w").then_some(parts[1]))
.filter(|value| !value.is_empty())
.ok_or_else(|| "profile source archive URL is not workspace-scoped".to_owned())?;
let mut request = client.get(url);
if let Some(identity) = identity {
let audience = audience.ok_or_else(|| {
"profile source archive request proof audience is unavailable".to_owned()
})?;
let proof = RuntimeRequestSourceSigner::from_identity(identity)
.issue(
audience,
workspace_id,
None,
BACKEND_RESOURCE_FETCH_PERMISSION,
"GET",
&path,
b"",
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| error.to_string())?;
request = request.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if cached_digest == Some(location.archive.digest.as_str()) {
if let Some(etag) = location.etag.as_deref() {
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
@@ -620,6 +692,8 @@ async fn fetch_profile_source_archive_http(
async fn fetch_profile_source_archive_http(
_location: &ProfileSourceArchiveHttpRef,
_cached_digest: Option<&str>,
_identity: Option<&RuntimeIdentityMaterial>,
_audience: Option<&str>,
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
Err(
"HTTP profile source archive fetch requires the worker-runtime http-server feature"
@@ -743,12 +817,19 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
&request.worker_ref,
request.workspace_scope.as_ref(),
self.worker_mutation_identity.as_ref(),
self.runtime_request_audience.as_deref(),
self.embedded_worker_mutation_dispatcher.as_ref(),
Some(self.prompt_projection_cache.clone()),
);
let selector = profile.as_ref();
let archive = self
.resolve_profile_source_archive(&request.request.profile_source)
.resolve_profile_source_archive(
&request.request.profile_source,
request
.workspace_scope
.as_ref()
.map(|scope| scope.server_id.as_str()),
)
.await?;
let (mut manifest, mut loader) = {
let manifest = archive
@@ -909,6 +990,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
&request.worker_ref,
request.workspace_scope.as_ref(),
self.worker_mutation_identity.as_ref(),
self.runtime_request_audience.as_deref(),
self.embedded_worker_mutation_dispatcher.as_ref(),
Some(self.prompt_projection_cache.clone()),
);
@@ -2187,12 +2269,18 @@ mod tests {
let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main");
let before_restart =
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None);
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None, None);
let adapter = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap();
let (after_restore_kind, after_restore_workspace_id) = adapter
.run_on_adapter_runtime(async move {
let after_restore =
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None);
let after_restore = backend.worker_context(
&worker_ref,
Some(&scope),
Some(&identity),
None,
None,
None,
);
let client = after_restore.client_handle();
Ok((
client.kind().to_string(),
@@ -2383,6 +2471,7 @@ mod tests {
None,
None,
None,
None,
);
let workspace_client = workspace_context.client_handle();
self.observed_workspace_clients.lock().unwrap().push((
@@ -2631,8 +2720,12 @@ mod tests {
repository: WorkingDirectoryRepository {
id: "repo-main".to_string(),
provider: "git".to_string(),
uri: ".".to_string(),
local_path: Some(repo.to_path_buf()),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: repo.display().to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")),
},
materializer: MaterializerKind::LocalGitWorktree,
@@ -2781,7 +2874,7 @@ mod tests {
archive: bundle.profile_source_archive.clone().unwrap(),
};
factory
.resolve_profile_source_archive(&source)
.resolve_profile_source_archive(&source, None)
.await
.expect("embedded archive should resolve without Backend resource client");
}
+82 -13
View File
@@ -7,8 +7,9 @@ use worker::{
};
use crate::auth::{
RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner,
WORKER_REMOVE_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation,
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION,
WORKSPACE_REQUEST_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation,
WorkerMutationSourceClaims, new_token_id,
};
use crate::runtime::RuntimeWorkspaceScope;
@@ -289,6 +290,8 @@ pub struct RuntimeOwnedWorkspaceClient {
worker_id: String,
request_timeout: Option<Duration>,
worker_remove: Option<RuntimeWorkerMutationForwarder>,
request_source_signer: Option<RuntimeRequestSourceSigner>,
request_source_audience: Option<String>,
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
}
@@ -306,6 +309,8 @@ impl RuntimeOwnedWorkspaceClient {
worker_id: worker_id.into(),
request_timeout: None,
worker_remove: None,
request_source_signer: None,
request_source_audience: None,
prompt_projection_cache: None,
}
}
@@ -315,6 +320,16 @@ impl RuntimeOwnedWorkspaceClient {
self
}
pub fn with_runtime_request_source(
mut self,
identity: &RuntimeIdentityMaterial,
audience: impl Into<String>,
) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
self.request_source_audience = Some(audience.into());
self
}
pub(crate) fn with_prompt_projection_cache(
mut self,
cache: Arc<WorkspacePromptProjectionCache>,
@@ -363,15 +378,21 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let base_url = self.base_url.clone();
let workspace_id = self.workspace_id.clone();
let runtime_id = self.runtime_id.clone();
let worker_id = self.worker_id.clone();
let request_source_signer = self.request_source_signer.clone();
let request_source_audience = self.request_source_audience.clone();
let request_timeout = self.request_timeout;
if tokio::runtime::Handle::try_current().is_ok() {
std::thread::spawn(move || {
execute_runtime_owned_workspace_http(
&base_url,
&workspace_id,
&runtime_id,
&worker_id,
request_source_signer.as_ref(),
request_source_audience.as_deref(),
request_timeout,
request,
)
@@ -383,8 +404,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
} else {
execute_runtime_owned_workspace_http(
&self.base_url,
&self.workspace_id,
&self.runtime_id,
&self.worker_id,
self.request_source_signer.as_ref(),
self.request_source_audience.as_deref(),
self.request_timeout,
request,
)
@@ -484,8 +508,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
fn execute_runtime_owned_workspace_http(
base_url: &str,
workspace_id: &str,
runtime_id: &str,
worker_id: &str,
request_source_signer: Option<&RuntimeRequestSourceSigner>,
request_source_audience: Option<&str>,
request_timeout: Option<Duration>,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
@@ -510,11 +537,33 @@ fn execute_runtime_owned_workspace_http(
))
})?;
let request_label = format!("{method} {}", request.path);
let body = request.body.unwrap_or_default();
let mut request_builder = client
.request(method, url)
.request(method.clone(), url)
.header("x-yoi-runtime-id", runtime_id)
.header("x-yoi-worker-id", worker_id);
if let Some(body) = request.body {
if let Some(signer) = request_source_signer {
let audience = request_source_audience.ok_or_else(|| {
WorkspaceClientError::Request(
"runtime request proof audience is unavailable".to_owned(),
)
})?;
let proof = signer
.issue(
audience,
workspace_id,
Some(worker_id),
WORKSPACE_REQUEST_PERMISSION,
method.as_str(),
&request.path,
body.as_bytes(),
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
request_builder = request_builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if !body.is_empty() {
request_builder = request_builder
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
@@ -590,8 +639,8 @@ fn unix_now_seconds() -> u64 {
mod tests {
use super::*;
use crate::auth::{
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims,
verify_worker_mutation_source_proof,
WorkerMutationSourceExpectation, decode_runtime_request_source_claims,
decode_worker_mutation_source_claims, verify_worker_mutation_source_proof,
};
#[test]
@@ -797,7 +846,7 @@ mod tests {
}
#[test]
fn ordinary_workspace_forwarding_stamps_legacy_source_only_inside_runtime() {
fn ordinary_workspace_forwarding_stamps_runtime_identity_and_signs_path_and_query() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::Mutex;
@@ -817,21 +866,41 @@ mod tests {
.unwrap();
});
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let client = RuntimeOwnedWorkspaceClient::new(
"workspace-a",
format!("http://{address}"),
"runtime-a",
"worker-a",
);
)
.with_runtime_request_source(&identity, "server-a");
let response = client
.execute(WorkspaceRequest::get("/api/w/workspace-a/tickets/search"))
.execute(WorkspaceRequest::get(
"/api/w/workspace-a/tickets/search?state=planning&limit=20",
))
.unwrap();
assert_eq!(response.status, 200);
server.join().unwrap();
let request = received.lock().unwrap().to_ascii_lowercase();
assert!(request.contains("x-yoi-runtime-id: runtime-a"));
assert!(request.contains("x-yoi-worker-id: worker-a"));
assert!(!request.contains("authorization:"));
let request = received.lock().unwrap().clone();
let lowercase_request = request.to_ascii_lowercase();
assert!(lowercase_request.contains("x-yoi-runtime-id: runtime-a"));
assert!(lowercase_request.contains("x-yoi-worker-id: worker-a"));
assert!(lowercase_request.contains("x-yoi-runtime-request-proof: yoi-runtime-request-v1."));
assert!(!lowercase_request.contains("authorization:"));
let token = request
.lines()
.find_map(|line| {
line.split_once(':').and_then(|(name, value)| {
name.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
.then(|| value.trim())
})
})
.expect("runtime proof header");
let claims = decode_runtime_request_source_claims(token).unwrap();
assert_eq!(
claims.path,
"/api/w/workspace-a/tickets/search?state=planning&limit=20"
);
}
#[test]
+39 -19
View File
@@ -318,18 +318,36 @@ impl LocalGitWorktreeMaterializer {
),
));
}
if is_remote_uri(&request.repository.uri) {
let source_path = match request.repository.source.kind {
workspace_api::RepositorySourceKind::LocalPath => {
PathBuf::from(&request.repository.source.uri)
}
workspace_api::RepositorySourceKind::File => {
url::Url::parse(&request.repository.source.uri)
.ok()
.and_then(|uri| uri.to_file_path().ok())
.ok_or_else(|| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_source_invalid",
"configured file Repository source is invalid",
)
})?
}
workspace_api::RepositorySourceKind::Ssh
| workspace_api::RepositorySourceKind::Http
| workspace_api::RepositorySourceKind::Https => {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_remote_repository_unsupported",
"remote repository URI materialization is not implemented in v0",
"working_directory_remote_repository_access_required",
"remote Repository materialization requires an explicit authenticated access and trust handle",
));
}
let source_path = request
.repository
.local_path
.clone()
.unwrap_or_else(|| PathBuf::from(&request.repository.uri));
workspace_api::RepositorySourceKind::Invalid => {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_repository_source_invalid",
"configured Repository source is invalid and cannot be materialized",
));
}
};
let source_root = git_stdout(&source_path, ["rev-parse", "--show-toplevel"])
.map(|value| PathBuf::from(value.trim()))
.map_err(|_| {
@@ -661,10 +679,6 @@ fn path_str(path: &Path) -> Result<String, WorkingDirectoryDiagnostic> {
})
}
fn is_remote_uri(uri: &str) -> bool {
uri.contains("://") || uri.starts_with("git@") || uri.starts_with("ssh:")
}
fn sanitize_path_component(value: &str) -> String {
let sanitized = value
.chars()
@@ -793,8 +807,12 @@ mod tests {
repository: WorkingDirectoryRepository {
id: "repo-main".to_string(),
provider: "git".to_string(),
uri: ".".to_string(),
local_path: Some(repo.to_path_buf()),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: repo.display().to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")),
},
materializer: MaterializerKind::LocalGitWorktree,
@@ -908,19 +926,21 @@ mod tests {
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let mut remote = request(Path::new("."));
remote.repository.local_path = None;
remote.repository.uri = "https://example.invalid/repo.git".to_string();
remote.repository.source = workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Https,
uri: "https://example.invalid/repo.git".to_string(),
};
let error = materializer
.materialize(&worker_ref(1), &remote)
.unwrap_err();
assert_eq!(
error.code,
"working_directory_remote_repository_unsupported"
"working_directory_remote_repository_access_required"
);
let mut non_git = remote;
non_git.repository.provider = "archive".to_string();
non_git.repository.uri = ".".to_string();
non_git.repository.source.uri = ".".to_string();
let error = materializer
.materialize(&worker_ref(2), &non_git)
.unwrap_err();
+8
View File
@@ -975,6 +975,14 @@ where
if feature_config.sub_worker.enabled {
worker.register_worker_orchestration_instruction();
if !feature_config.worker.enabled {
feature_registry.add_module(
crate::feature::builtin::manage_worker::sub_worker_control_feature(
worker.workspace_client_handle(),
spawned_registry.clone(),
),
);
}
}
let host_worker_observation_provider = worker.worker_observation_provider();
@@ -398,24 +398,35 @@ impl WorkspaceHttpWorkdirBackend {
workdir_output(format!("Listed {count} Workdir(s)"), &response)
}
fn create(&self, input: WorkdirCreateInput) -> Result<ToolOutput, ToolError> {
let runtime_id = validate_identity(&input.runtime_id, CREATE_TOOL, "runtime_id")?;
fn create(
&self,
input: WorkdirCreateInput,
operation_id: String,
) -> Result<ToolOutput, ToolError> {
let runtime_id = input
.runtime_id
.as_deref()
.map(|value| validate_identity(value, CREATE_TOOL, "runtime_id"))
.transpose()?;
let repository_id = validate_identity(&input.repository_id, CREATE_TOOL, "repository_id")?;
let selector = validate_optional_selector(input.selector)?;
let workspace_id = encode_path_segment(self.workspace_id()?);
let runtime_path = encode_path_segment(runtime_id);
let request = WorkdirCreateRequest {
runtime_id: runtime_id.to_string(),
runtime_id: runtime_id.map(str::to_string),
repository_id: repository_id.to_string(),
selector,
operation_id,
};
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/runtimes/{runtime_path}/working-directories"),
format!("/api/w/{workspace_id}/working-directories"),
serde_json::to_string(&request).map_err(decode_error)?,
))?;
workdir_output(
format!("Created Workdir {}", response.item.working_directory_id),
format!(
"Created Workdir {} on Runtime {}",
response.item.working_directory_id, response.runtime_id
),
&response,
)
}
@@ -518,16 +529,17 @@ impl Tool for WorkspaceHttpWorkdirTool {
async fn execute(
&self,
input_json: &str,
_ctx: ToolExecutionContext,
ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
match self.operation {
WorkdirOperation::List => {
let _input = parse_input::<WorkdirListInput>(input_json)?;
self.backend.list()
}
WorkdirOperation::Create => self
.backend
.create(parse_input::<WorkdirCreateInput>(input_json)?),
WorkdirOperation::Create => self.backend.create(
parse_input::<WorkdirCreateInput>(input_json)?,
ctx.call_id.to_string(),
),
WorkdirOperation::Attach => self
.backend
.attach(parse_input::<WorkdirAttachInput>(input_json)?),
@@ -635,9 +647,9 @@ fn create_schema() -> serde_json::Value {
json!({
"type": "object",
"additionalProperties": false,
"required": ["runtime_id", "repository_id"],
"required": ["repository_id"],
"properties": {
"runtime_id": {"type": "string", "minLength": 1},
"runtime_id": {"type": ["string", "null"], "minLength": 1},
"repository_id": {"type": "string", "minLength": 1},
"selector": {"type": ["string", "null"], "minLength": 1}
}
@@ -677,7 +689,8 @@ struct WorkdirListInput {}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkdirCreateInput {
runtime_id: String,
#[serde(default)]
runtime_id: Option<String>,
repository_id: String,
#[serde(default)]
selector: Option<String>,
@@ -685,10 +698,12 @@ struct WorkdirCreateInput {
#[derive(Debug, Serialize)]
struct WorkdirCreateRequest {
runtime_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
runtime_id: Option<String>,
repository_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
selector: Option<String>,
operation_id: String,
}
#[derive(Debug, Deserialize)]
@@ -916,7 +931,11 @@ mod tests {
#[test]
fn schemas_expose_identities_without_paths_or_session_handles() {
let create = create_schema();
assert_eq!(create["required"], json!(["runtime_id", "repository_id"]));
assert_eq!(create["required"], json!(["repository_id"]));
assert_eq!(
create["properties"]["runtime_id"]["type"],
json!(["string", "null"])
);
assert!(create["properties"].get("path").is_none());
assert!(create["properties"].get("session_id").is_none());
assert_eq!(attach_schema()["required"], json!(["workdir_id"]));
@@ -946,6 +965,7 @@ mod tests {
})),
response(json!({
"workspace_id": "workspace/test",
"runtime_id": "runtime/one",
"item": workdir_json("wd-created"),
"diagnostics": []
})),
@@ -961,6 +981,7 @@ mod tests {
})),
response(json!({
"workspace_id": "workspace/test",
"runtime_id": "runtime/one",
"item": {
"working_directory_id": "wd-created",
"repository_id": "main",
@@ -985,13 +1006,19 @@ mod tests {
.is_none()
);
let created = backend
.create(WorkdirCreateInput {
runtime_id: "runtime/one".to_string(),
.create(
WorkdirCreateInput {
runtime_id: Some("runtime/one".to_string()),
repository_id: "main".to_string(),
selector: Some("refs/heads/topic".to_string()),
})
},
"call-create-1".to_string(),
)
.unwrap();
assert_eq!(created.summary, "Created Workdir wd-created");
assert_eq!(
created.summary,
"Created Workdir wd-created on Runtime runtime/one"
);
let created: serde_json::Value =
serde_json::from_str(created.content.as_deref().unwrap()).unwrap();
assert_eq!(created["item"]["working_directory_id"], "wd-created");
@@ -1017,12 +1044,14 @@ mod tests {
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
assert_eq!(
requests[1].path,
"/api/w/workspace%2Ftest/runtimes/runtime%2Fone/working-directories"
"/api/w/workspace%2Ftest/working-directories"
);
assert_eq!(requests[1].method, WorkspaceRequestMethod::Post);
let body: serde_json::Value =
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
assert_eq!(body["repository_id"], "main");
assert_eq!(body["runtime_id"], "runtime/one");
assert_eq!(body["operation_id"], "call-create-1");
assert_eq!(body["selector"], "refs/heads/topic");
assert_eq!(
requests[2].path,
@@ -1216,16 +1245,50 @@ mod tests {
assert_eq!(body["operation"]["request"]["path"], "file");
}
#[test]
fn create_omits_runtime_for_backend_default_resolution() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
"workspace_id": "workspace/test",
"runtime_id": "arcadia",
"item": workdir_json("wd-default"),
"diagnostics": []
}))]));
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
let created = backend
.create(
WorkdirCreateInput {
runtime_id: None,
repository_id: "main".to_string(),
selector: None,
},
"call-default".to_string(),
)
.unwrap();
assert_eq!(
created.summary,
"Created Workdir wd-default on Runtime arcadia"
);
let requests = client.requests();
let body: serde_json::Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert!(body.get("runtime_id").is_none());
assert_eq!(body["operation_id"], "call-default");
}
#[test]
fn invalid_or_extra_inputs_are_rejected_before_workspace_request() {
let client = Arc::new(RecordingWorkspaceClient::new(Vec::new()));
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
let error = backend
.create(WorkdirCreateInput {
runtime_id: " ".to_string(),
.create(
WorkdirCreateInput {
runtime_id: Some(" ".to_string()),
repository_id: "main".to_string(),
selector: None,
})
},
"call-invalid".to_string(),
)
.unwrap_err();
assert!(matches!(error, ToolError::InvalidArgument(_)));
assert!(client.requests().is_empty());
@@ -356,6 +356,57 @@ pub fn manage_worker_feature(
}
}
#[derive(Clone)]
pub struct SubWorkerControlFeature {
control: Arc<dyn WorkerControlService>,
}
impl std::fmt::Debug for SubWorkerControlFeature {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("SubWorkerControlFeature")
.finish_non_exhaustive()
}
}
pub fn sub_worker_control_feature(
client: Arc<dyn WorkspaceClient>,
registry: Arc<SpawnedWorkerRegistry>,
) -> SubWorkerControlFeature {
let workspace_id = client.workspace_id().unwrap_or_default().to_string();
SubWorkerControlFeature {
control: Arc::new(WorkspaceWorkerControlService {
client,
workspace_id,
registry: Some(registry),
}),
}
}
impl FeatureModule for SubWorkerControlFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("sub_worker", "SubWorker")
.with_description("Parent-owned SubWorker control authority.")
.with_provided_service(ServiceDeclaration::new(
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Known-SubWorker discovery and permission-fenced control operations",
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Known-SubWorker discovery and permission-fenced control operations",
),
self.control.clone(),
)?;
Ok(())
}
}
impl FeatureModule for ManageWorkerFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
+13 -10
View File
@@ -873,12 +873,13 @@ impl WorkspaceHttpTicketBackend {
})?),
)
.map(TicketBackendOperationResult::Ticket),
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
TicketBackendOperation::QueueReady { id, .. } => Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
None,
),
)
.map(TicketBackendOperationResult::QueueOutcome),
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
@@ -1099,16 +1100,18 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
)
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
match self.invoke(TicketBackendOperation::QueueReady {
fn queue_ready(
&self,
id: TicketIdOrSlug,
queued_by: &str,
) -> TicketResult<ticket::TicketQueueOutcome> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::QueueReady {
id,
queued_by: queued_by.to_string(),
})? {
TicketBackendOperationResult::Unit => Ok(()),
other => Err(TicketError::Conflict(format!(
"unexpected ticket backend response: {other:?}"
))),
}
}),
TicketBackendOperationResult::QueueOutcome
)
}
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
+17 -18
View File
@@ -329,13 +329,13 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro
"reviewer handoff requires the explicit effective profile builtin:reviewer".to_string(),
));
}
if input
if !input
.scope
.iter()
.any(|rule| matches!(rule.permission, PermissionInput::Write))
{
return Err(ToolError::InvalidArgument(
"Merge Request Reviewer SubWorkers must have read-only delegated scope".to_string(),
"Merge Request Reviewer SubWorkers must include writable delegated scope".to_string(),
));
}
Ok(())
@@ -1008,28 +1008,28 @@ mod tests {
}
#[test]
fn reviewer_handoff_requires_explicit_builtin_profile_and_read_only_scope() {
fn reviewer_handoff_requires_explicit_builtin_profile_and_writable_scope() {
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"work","permission":"read"}],
"scope":[{"target":"work","permission":"write"}],
"review":{"ticket_id":"T1"}
}))
.unwrap();
assert!(validate_reviewer_handoff(&valid).is_ok());
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:coder",
"scope":[{"target":"work","permission":"read"}],
"review":{"ticket_id":"T1"}
}))
.unwrap();
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"work","permission":"write"}],
"review":{"ticket_id":"T1"}
}))
.unwrap();
assert!(validate_reviewer_handoff(&writable).is_err());
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
let read_only: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"work","permission":"read"}],
"review":{"ticket_id":"T1"}
}))
.unwrap();
assert!(validate_reviewer_handoff(&read_only).is_err());
}
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
@@ -1079,7 +1079,7 @@ extract_threshold = 4000
}
#[tokio::test]
async fn reviewer_profile_spawns_and_notifies_parent_controller() {
async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() {
let runtime = TempDir::new().unwrap();
let workspace_root = runtime.path().join("project");
let available_profiles = write_project_profile_registry(
@@ -1140,7 +1140,7 @@ extract_threshold = 4000
"task": "review immutable commit",
"scope": [{
"target": ".",
"permission": "read",
"permission": "write",
"recursive": true
}]
});
@@ -1171,11 +1171,10 @@ extract_threshold = 4000
let record = registry
.get_internal("reviewer-child")
.expect("Internal reviewer registry record");
assert!(record.installed_tools.iter().any(|name| name == "Read"));
for denied in ["Write", "Edit", "Bash"] {
for required in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
assert!(
!record.installed_tools.iter().any(|name| name == denied),
"read-only child unexpectedly received {denied}: {:?}",
record.installed_tools.iter().any(|name| name == required),
"write-scoped child is missing {required}: {:?}",
record.installed_tools
);
}
+4 -1
View File
@@ -2460,7 +2460,10 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| "Workspace rejected Flow source resolution".to_string());
return Err(WorkerError::FlowInput(message));
return Err(WorkerError::FlowInput(format!(
"{message} (HTTP {})",
response.status
)));
}
let source: flow::ResolvedFlowSource = serde_json::from_str(&response.body)
.map_err(|error| WorkerError::FlowInput(format!("decode Flow source: {error}")))?;
@@ -533,6 +533,9 @@ model_id = "test-model"
max_tokens = 100
[memory]
workspace_id = "test-workspace"
settings_revision = 1
language = "English"
extract_threshold = 1
[compaction]
@@ -695,6 +698,9 @@ model_id = "test-model"
max_tokens = 100
[memory]
workspace_id = "test-workspace"
settings_revision = 1
language = "English"
extract_threshold = 1
[[scope.allow]]
@@ -87,6 +87,7 @@ impl Tool for BigContentTool {
Ok(ToolOutput {
summary: self.summary.into(),
content: Some(self.content.clone()),
attachments: Vec::new(),
})
}
}
+85 -19
View File
@@ -7,6 +7,88 @@
use serde::{Deserialize, Serialize};
use workdir::workspace::WorkingDirectorySummary;
/// Provider-neutral classification of an authoritative Repository source.
///
/// Local paths remain distinct from network Git transports so callers cannot
/// accidentally treat an unmaterialized remote as a server-local filesystem path.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RepositorySourceKind {
LocalPath,
File,
Ssh,
Http,
Https,
/// A legacy value that could not be classified during migration. It remains
/// inspectable but every provider operation must fail closed.
Invalid,
}
impl RepositorySourceKind {
pub const fn is_remote(self) -> bool {
matches!(self, Self::Ssh | Self::Http | Self::Https)
}
pub const fn as_str(self) -> &'static str {
match self {
Self::LocalPath => "local_path",
Self::File => "file",
Self::Ssh => "ssh",
Self::Http => "http",
Self::Https => "https",
Self::Invalid => "invalid",
}
}
pub fn parse(value: &str) -> Option<Self> {
Some(match value {
"local_path" => Self::LocalPath,
"file" => Self::File,
"ssh" => Self::Ssh,
"http" => Self::Http,
"https" => Self::Https,
"invalid" => Self::Invalid,
_ => return None,
})
}
}
/// Stable Repository source identity stored by Workspace authority.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySource {
pub kind: RepositorySourceKind,
/// Canonical source representation. This is an absolute local path for
/// `local_path`, and a normalized URI/remote specification otherwise.
pub uri: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RepositoryObservedStatus {
Unverified,
Ready,
Invalid,
}
impl RepositoryObservedStatus {
pub const fn as_str(self) -> &'static str {
match self {
Self::Unverified => "unverified",
Self::Ready => "ready",
Self::Invalid => "invalid",
}
}
pub fn parse(value: &str) -> Option<Self> {
Some(match value {
"unverified" => Self::Unverified,
"ready" => Self::Ready,
"invalid" => Self::Invalid,
_ => return None,
})
}
}
pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search";
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
@@ -165,24 +247,6 @@ pub struct RuntimeSourceSummary {
pub note: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeCapabilitySummary {
pub can_list_hosts: bool,
pub can_list_workers: bool,
pub can_get_worker: bool,
pub can_spawn_worker: bool,
pub can_stop_worker: bool,
pub has_workspace_fs: bool,
pub has_shell: bool,
pub has_git: bool,
pub supports_worktrees: bool,
pub supports_backend_internal_tools: bool,
pub workspace_scope: String,
pub max_workers: usize,
pub os: String,
pub arch: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeSummary {
pub runtime_id: String,
@@ -192,7 +256,9 @@ pub struct RuntimeSummary {
pub source: RuntimeSourceSummary,
#[serde(default)]
pub host_ids: Vec<String>,
pub capabilities: RuntimeCapabilitySummary,
pub worker_creation_available: bool,
pub os: String,
pub arch: String,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
+22 -2
View File
@@ -176,8 +176,28 @@ fn actor_for_user<S: ControlPlaneStore + ?Sized>(
}))
}
pub fn session_set_cookie(cookie_name: &str, token: &str, max_age_seconds: i64) -> String {
format!("{cookie_name}={token}; Max-Age={max_age_seconds}; Path=/; HttpOnly; SameSite=Lax")
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionCookiePolicy<'a> {
pub cookie_name: &'a str,
pub path: &'a str,
pub domain: Option<&'a str>,
pub secure: bool,
}
pub fn session_set_cookie(
policy: SessionCookiePolicy<'_>,
token: &str,
max_age_seconds: i64,
) -> String {
let domain = policy
.domain
.map(|domain| format!("; Domain={domain}"))
.unwrap_or_default();
let secure = if policy.secure { "; Secure" } else { "" };
format!(
"{}={token}; Max-Age={max_age_seconds}; Path={}; HttpOnly; SameSite=Lax{domain}{secure}",
policy.cookie_name, policy.path
)
}
pub fn auth_error(code: &str, message: &str) -> Error {
+77 -14
View File
@@ -704,6 +704,15 @@ impl SqliteWorkspaceAuthority {
&self,
reference: &str,
request: TicketShowRequest,
) -> Result<TicketDetail> {
self.read_ticket_detail_with_backend(reference, request, &self.ticket_backend)
}
pub(crate) fn read_ticket_detail_with_backend(
&self,
reference: &str,
request: TicketShowRequest,
backend: &SqliteTicketBackend,
) -> Result<TicketDetail> {
let id = self
.store
@@ -713,16 +722,19 @@ impl SqliteWorkspaceAuthority {
reference,
)?
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())))?;
let ticket = self.ticket_backend.show(TicketIdOrSlug::Id(id))?;
self.ticket_detail_from_ticket(ticket, request)
let ticket = backend.show(TicketIdOrSlug::Id(id))?;
self.ticket_detail_from_ticket(ticket, request, backend)
}
fn ticket_detail_from_ticket(
&self,
ticket: ticket::Ticket,
request: TicketShowRequest,
dependency_backend: &SqliteTicketBackend,
) -> Result<TicketDetail> {
let id = ticket.meta.id.as_str();
let dependency_check =
dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?;
let (body, body_truncated) =
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
let event_limit = request
@@ -822,6 +834,27 @@ impl SqliteWorkspaceAuthority {
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some();
let has_blockers = !ticket.relations.blockers.is_empty();
let mut queue_assignment_blockers = Vec::new();
for ticket_id in &dependency_check.queue_tickets {
let assignments = self
.store
.list_current_ticket_role_assignments(&self.workspace_id, ticket_id)?;
if !assignments
.iter()
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator)
{
queue_assignment_blockers.push(format!(
"Ticket {ticket_id} requires an active Orchestrator assignment"
));
}
if assignments
.iter()
.any(|assignment| assignment.role == TicketAssignmentRole::Coder)
{
queue_assignment_blockers
.push(format!("Ticket {ticket_id} has an active Coder assignment"));
}
}
let mut assignment_diagnostics = Vec::new();
if let Some(legacy_assignee) = ticket
.meta
@@ -833,6 +866,19 @@ impl SqliteWorkspaceAuthority {
"legacy Ticket assignee `{legacy_assignee}` is not assignment authority"
));
}
let mut action_blockers = Vec::new();
if !has_target {
action_blockers.push("Ticket target is required".to_string());
}
if !dependency_check.queue_guard.can_queue_for_orchestrator {
if let Some(reason) = dependency_check.queue_guard.blocked_reason.clone() {
action_blockers.push(reason);
} else if let Some(reason) = dependency_check.queue_guard.reason.clone() {
action_blockers.push(reason);
}
}
let queue_assignments_valid = queue_assignment_blockers.is_empty();
action_blockers.extend(queue_assignment_blockers);
let action_eligibility = TicketActionEligibility {
can_assign_orchestrator: matches!(
ticket.meta.workflow_state,
@@ -848,19 +894,15 @@ impl SqliteWorkspaceAuthority {
&& has_orchestrator
&& !has_coder
&& has_target
&& !has_blockers,
&& dependency_check.queue_guard.can_queue_for_orchestrator
&& queue_assignments_valid,
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
&& !has_orchestrator
&& !has_coder
&& has_target
&& !has_blockers,
blockers: [
(!has_target).then_some("Ticket target is required".to_string()),
has_blockers.then_some("unresolved blocking relations remain".to_string()),
]
.into_iter()
.flatten()
.collect(),
queue_tickets: dependency_check.queue_tickets.clone(),
blockers: action_blockers,
};
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
Ok(request) => {
@@ -1084,6 +1126,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
event_limit: Some(TICKET_EVENT_LIMIT),
event_cursor: None,
},
&self.ticket_backend,
)?;
if ticket_matches_query(
&summary,
@@ -2927,7 +2970,7 @@ mod tests {
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
let dir = tempfile::tempdir().unwrap();
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning");
write_ticket(dir.path(), "00000000001J5", "Second ticket", "queued");
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
let db_path = dir.path().join("workspace.db");
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
@@ -3038,10 +3081,22 @@ VALUES ('workspace-test', 'ticket', 4);
.ticket_backend
.add_ticket_relation(
TicketIdOrSlug::Id("00000000001J2".to_string()),
ticket::NewTicketRelation {
kind: ticket::TicketRelationKind::DependsOn,
target: "00000000001J5".to_string(),
note: Some("queued dependency with a transitive blocker".to_string()),
author: Some("tester".to_string()),
},
)
.unwrap();
authority
.ticket_backend
.add_ticket_relation(
TicketIdOrSlug::Id("00000000001J5".to_string()),
ticket::NewTicketRelation {
kind: ticket::TicketRelationKind::DependsOn,
target: "00000000001J6".to_string(),
note: Some("separate dependency relation".to_string()),
note: Some("transitive planning dependency".to_string()),
author: Some("tester".to_string()),
},
)
@@ -3056,6 +3111,14 @@ VALUES ('workspace-test', 'ticket', 4);
assert_eq!(ticket_by_key.id, tickets.items[0].id);
let ticket = authority.ticket("00000000001J2").unwrap();
assert!(!ticket.action_eligibility.can_queue);
assert!(
ticket
.action_eligibility
.blockers
.iter()
.any(|reason| reason.contains("00000000001J6"))
);
assert!(ticket.body.contains("Ticket body"));
assert!(ticket.body_truncated);
assert!(!ticket.body.contains("Deep Ticket marker"));
@@ -3139,8 +3202,8 @@ VALUES ('workspace-test', 'ticket', 4);
assert!(note_only_kind.items.is_empty());
let crossed_relation_filters = authority
.query_tickets(TicketQueryRequest {
related_ticket_id: Some("00000000001J5".to_string()),
relation_kind: Some("depends_on".to_string()),
related_ticket_id: Some("00000000001J6".to_string()),
relation_kind: Some("related".to_string()),
..TicketQueryRequest::default()
})
.unwrap();
+213 -563
View File
@@ -3,53 +3,52 @@ use std::path::{Path, PathBuf};
use std::{fs, io};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::hosts::RemoteRuntimeConfig;
use crate::identity::WorkspaceIdentity;
use crate::repositories::ConfiguredRepository;
use crate::server::{AuthConfig, ServerConfig};
use crate::{Error, Result};
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
include_str!("../../../resources/workspace-backend.default.toml");
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787";
const DEFAULT_AUTH_RP_ID: &str = "localhost";
const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
const DEFAULT_MAX_RECORDS: usize = 200;
fn default_auth_rp_id() -> String {
DEFAULT_AUTH_RP_ID.to_string()
}
fn default_auth_origin() -> String {
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
}
fn default_auth_public_base_url() -> String {
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
}
fn default_auth_cookie_name() -> String {
DEFAULT_AUTH_COOKIE_NAME.to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendConfigFile {
pub struct ServerHostConfigFile {
#[serde(default)]
pub server: WorkspaceBackendServerConfig,
#[serde(default)]
pub data: WorkspaceBackendDataConfig,
#[serde(default)]
pub limits: WorkspaceBackendLimitsConfig,
#[serde(default)]
pub auth: WorkspaceBackendAuthConfig,
#[serde(default)]
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
pub browser: ServerBrowserConfig,
}
impl Default for ServerHostConfigFile {
fn default() -> Self {
Self {
browser: ServerBrowserConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ServerBrowserConfig {
#[serde(default = "default_browser_public_url")]
pub public_url: String,
}
impl Default for ServerBrowserConfig {
fn default() -> Self {
Self {
public_url: default_browser_public_url(),
}
}
}
fn default_browser_public_url() -> String {
DEFAULT_BROWSER_PUBLIC_URL.to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
@@ -59,71 +58,6 @@ pub struct BackendRuntimesConfigFile {
pub runtimes: WorkspaceBackendRuntimesConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendServerConfig {
#[serde(default)]
pub listen: Option<String>,
#[serde(default)]
pub frontend_url: Option<String>,
#[serde(default)]
pub static_assets_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendDataConfig {
#[serde(default)]
pub root: Option<PathBuf>,
#[serde(default)]
pub workspace_database_path: Option<PathBuf>,
#[serde(default)]
pub embedded_runtime_store_root: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendLimitsConfig {
#[serde(default)]
pub max_records: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendAuthConfig {
#[serde(default = "default_auth_rp_id")]
pub rp_id: String,
#[serde(default = "default_auth_origin")]
pub origin: String,
#[serde(default = "default_auth_public_base_url")]
pub public_base_url: String,
#[serde(default = "default_auth_cookie_name")]
pub cookie_name: String,
}
impl Default for WorkspaceBackendAuthConfig {
fn default() -> Self {
Self {
rp_id: default_auth_rp_id(),
origin: default_auth_origin(),
public_base_url: default_auth_public_base_url(),
cookie_name: default_auth_cookie_name(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRepositoryConfigFile {
pub id: String,
pub provider: String,
pub uri: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub default_selector: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendRuntimesConfig {
@@ -142,61 +76,6 @@ pub struct RemoteRuntimeConfigFile {
pub token_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigDiff {
pub differs: bool,
pub text: String,
}
impl ConfigDiff {
fn new(default: &str, local: &str) -> Self {
if default == local {
return Self {
differs: false,
text: "workspace backend local config matches the packaged default\n".to_string(),
};
}
let mut text = String::from("--- packaged default\n+++ workspace local\n");
let default_lines = default.lines().collect::<Vec<_>>();
let local_lines = local.lines().collect::<Vec<_>>();
let max = default_lines.len().max(local_lines.len());
for index in 0..max {
match (default_lines.get(index), local_lines.get(index)) {
(Some(left), Some(right)) if left == right => {
text.push(' ');
text.push_str(left);
text.push('\n');
}
(Some(left), Some(right)) => {
text.push('-');
text.push_str(left);
text.push('\n');
text.push('+');
text.push_str(right);
text.push('\n');
}
(Some(left), None) => {
text.push('-');
text.push_str(left);
text.push('\n');
}
(None, Some(right)) => {
text.push('+');
text.push_str(right);
text.push('\n');
}
(None, None) => {}
}
}
Self {
differs: true,
text,
}
}
}
#[derive(Clone)]
pub struct ResolvedWorkspaceBackendConfig {
pub server: ServerConfig,
@@ -204,6 +83,47 @@ pub struct ResolvedWorkspaceBackendConfig {
pub database_path: PathBuf,
}
impl ServerHostConfigFile {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(SERVER_HOST_CONFIG_FILE_NAME)
}
pub fn default_path() -> Option<PathBuf> {
manifest::paths::config_dir().map(Self::path_for_config_dir)
}
pub fn load_default() -> Result<Self> {
let Some(path) = Self::default_path() else {
return Ok(Self::default());
};
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let raw = fs::read_to_string(path).map_err(|error| {
Error::Config(format!(
"failed to read Server host config `{}`: {error}",
path.display()
))
})?;
Self::parse_str(&raw, path)
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| {
Error::Config(format!(
"failed to parse Server host config `{}`: {error}",
path.as_ref().display()
))
})
}
}
impl BackendRuntimesConfigFile {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
@@ -272,151 +192,22 @@ impl BackendRuntimesConfigFile {
}
}
impl WorkspaceBackendConfigFile {
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
workspace_root
.as_ref()
.join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH)
}
pub fn ensure_local_config_for_workspace(workspace_root: impl AsRef<Path>) -> Result<()> {
let path = Self::path_for_workspace(workspace_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write;
file.write_all(WORKSPACE_BACKEND_CONFIG_TEMPLATE.as_bytes())?;
file.sync_all()?;
Ok(())
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn local_config_diff_for_workspace(workspace_root: impl AsRef<Path>) -> Result<ConfigDiff> {
let workspace_root = workspace_root.as_ref();
let path = Self::path_for_workspace(workspace_root);
match fs::read_to_string(&path) {
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
"workspace backend local config `{}` does not exist; run `yoi-server init --workspace {}` first",
path.display(),
workspace_root.display()
))),
Err(error) => Err(Error::Io(error)),
}
}
pub fn load_for_workspace(workspace_root: impl AsRef<Path>) -> Result<Self> {
let path = Self::path_for_workspace(workspace_root);
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn write_for_workspace(&self, workspace_root: impl AsRef<Path>) -> Result<()> {
let path = Self::path_for_workspace(workspace_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let raw = toml::to_string_pretty(self).map_err(|error| {
Error::Config(format!(
"failed to serialize workspace backend config: {error}"
))
})?;
fs::write(path, raw)?;
Ok(())
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| {
Error::Config(format!(
"failed to parse workspace backend config `{}`: {error}",
path.as_ref().display()
))
})
}
pub fn resolve(
&self,
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
) -> Result<ResolvedWorkspaceBackendConfig> {
self.resolve_with_runtime_config(
workspace_root,
identity,
&BackendRuntimesConfigFile::default(),
)
}
pub fn resolve_with_runtime_config(
&self,
impl ResolvedWorkspaceBackendConfig {
pub fn local_dev(
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
host_config: &ServerHostConfigFile,
runtime_config: &BackendRuntimesConfigFile,
) -> Result<ResolvedWorkspaceBackendConfig> {
) -> Result<Self> {
let workspace_root = workspace_root.as_ref();
let data_root = self
.data
.root
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| {
ServerConfig::default_workspace_backend_data_root(&identity.workspace_id)
});
let database_path = self
.data
.workspace_database_path
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(ServerConfig::default_server_database_path);
let embedded_runtime_store_root = self
.data
.embedded_runtime_store_root
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| data_root.join("embedded-runtime"));
let listen = self
.server
.listen
.as_deref()
.unwrap_or(DEFAULT_LISTEN)
.parse::<SocketAddr>()
.map_err(|_| {
Error::Config(format!(
"invalid workspace backend server.listen `{}`",
self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN)
))
})?;
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
let database_path = ServerConfig::default_server_database_path();
let (browser_public_url, browser_rp_id) =
resolve_browser_public_url(&host_config.browser.public_url)?;
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
server.database_path = database_path.clone();
server.frontend_url = self
.server
.frontend_url
.clone()
.unwrap_or_else(|| DEFAULT_FRONTEND_URL.to_string());
server.static_assets_dir = self
.server
.static_assets_dir
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path));
server.embedded_runtime_store_root = embedded_runtime_store_root;
server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
server.repositories = self
.repositories
.iter()
.map(|repository| resolve_repository(workspace_root, repository))
.collect::<Result<Vec<_>>>()?;
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
server.max_records = DEFAULT_MAX_RECORDS;
server.remote_runtime_sources = runtime_config
.runtimes
.remote
@@ -424,13 +215,16 @@ impl WorkspaceBackendConfigFile {
.map(resolve_remote_runtime)
.collect::<Result<Vec<_>>>()?;
server.auth = AuthConfig::Passkey {
rp_id: self.auth.rp_id.trim().to_string(),
origin: self.auth.origin.trim().to_string(),
public_base_url: self.auth.public_base_url.trim().to_string(),
cookie_name: self.auth.cookie_name.trim().to_string(),
rp_id: browser_rp_id,
origin: browser_public_url.clone(),
public_base_url: browser_public_url,
cookie_name: DEFAULT_AUTH_COOKIE_NAME.to_string(),
};
let listen = DEFAULT_LISTEN.parse::<SocketAddr>().map_err(|error| {
Error::Config(format!("invalid built-in Server listen address: {error}"))
})?;
Ok(ResolvedWorkspaceBackendConfig {
Ok(Self {
server,
listen,
database_path,
@@ -439,18 +233,6 @@ impl WorkspaceBackendConfigFile {
}
impl ResolvedWorkspaceBackendConfig {
pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self {
let path = path.into();
self.database_path = path.clone();
self.server.database_path = path;
self
}
pub fn with_static_assets_dir(mut self, path: Option<PathBuf>) -> Self {
self.server.static_assets_dir = path;
self
}
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
self
@@ -462,29 +244,6 @@ impl ResolvedWorkspaceBackendConfig {
}
}
fn resolve_repository(
workspace_root: &Path,
config: &WorkspaceRepositoryConfigFile,
) -> Result<ConfiguredRepository> {
let id = normalize_required_string("repository id", &config.id)?;
validate_repository_id(&id)?;
let provider =
normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase();
let uri = normalize_required_string("repository uri", &config.uri)?;
let path = resolve_repository_uri(workspace_root, &id, &uri)?;
let display_name = normalize_optional_string(config.display_name.as_deref());
let default_selector = normalize_optional_string(config.default_selector.as_deref());
Ok(ConfiguredRepository {
id,
provider,
uri,
path,
display_name,
default_selector,
})
}
fn normalize_required_string(field: &str, value: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -493,45 +252,12 @@ fn normalize_required_string(field: &str, value: &str) -> Result<String> {
Ok(trimmed.to_string())
}
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn validate_repository_id(id: &str) -> Result<()> {
if id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
Ok(())
} else {
Err(Error::Config(format!(
"repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`"
)))
}
}
fn resolve_repository_uri(workspace_root: &Path, id: &str, uri: &str) -> Result<PathBuf> {
if uri.contains("://") {
return Err(Error::Config(format!(
"repository `{id}` uses a remote URI, but remote repository materialization is not implemented"
)));
}
Ok(resolve_workspace_path(workspace_root, Path::new(uri)))
}
pub(crate) fn resolve_remote_runtime(
config: &RemoteRuntimeConfigFile,
) -> Result<RemoteRuntimeConfig> {
if let Some(token_ref) = config.token_ref.as_deref() {
return Err(Error::Config(format!(
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet",
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet",
config.id
)));
}
@@ -546,12 +272,34 @@ pub(crate) fn resolve_remote_runtime(
))
}
fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
workspace_root.join(path)
fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
let value = normalize_required_string("browser.public_url", value)?;
let url = Url::parse(&value).map_err(|error| {
Error::Config(format!(
"browser.public_url must be an absolute http(s) URL: {error}"
))
})?;
if !matches!(url.scheme(), "http" | "https") {
return Err(Error::Config(
"browser.public_url must use the http or https scheme".to_string(),
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(Error::Config(
"browser.public_url must not contain user information".to_string(),
));
}
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
return Err(Error::Config(
"browser.public_url must contain only an origin without a path, query, or fragment"
.to_string(),
));
}
let rp_id = url
.host_str()
.ok_or_else(|| Error::Config("browser.public_url must contain a host".to_string()))?
.to_string();
Ok((url.origin().ascii_serialization(), rp_id))
}
#[cfg(test)]
@@ -566,14 +314,33 @@ mod tests {
}
}
#[test]
fn missing_config_path_uses_defaults() {
fn resolved_with_runtimes(
runtimes: &BackendRuntimesConfigFile,
) -> ResolvedWorkspaceBackendConfig {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
ResolvedWorkspaceBackendConfig::local_dev(
dir.path(),
identity(),
&ServerHostConfigFile::default(),
runtimes,
)
.unwrap()
}
#[test]
fn default_settings_resolve_without_a_repository_file() {
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default());
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL);
let AuthConfig::Passkey {
rp_id,
origin,
public_base_url,
..
} = &resolved.server.auth;
assert_eq!(rp_id, "localhost");
assert_eq!(origin, DEFAULT_BROWSER_PUBLIC_URL);
assert_eq!(public_base_url, DEFAULT_BROWSER_PUBLIC_URL);
assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS);
assert!(resolved.database_path.ends_with("server.db"));
assert!(
@@ -586,12 +353,8 @@ mod tests {
#[test]
fn backend_base_url_is_explicit_and_normalized() {
let dir = tempfile::tempdir().unwrap();
let listen = "127.0.0.1:48787".parse().unwrap();
let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path())
.unwrap()
.resolve(dir.path(), identity())
.unwrap()
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default())
.with_listen(listen)
.with_backend_base_url("http://127.0.0.1:48787/");
@@ -603,172 +366,82 @@ mod tests {
}
#[test]
fn rejects_unknown_fields() {
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test")
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error: {error}"
);
}
#[test]
fn resolves_relative_paths_against_workspace_root() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[server]
static_assets_dir = "web/build"
[data]
root = ".yoi/backend-data"
workspace_database_path = ".yoi/custom.db"
embedded_runtime_store_root = ".yoi/runtime-store"
"#,
"test",
fn browser_public_url_from_host_config_drives_all_browser_auth_settings() {
let host_config = ServerHostConfigFile::parse_str(
"[browser]\npublic_url = \"https://Yoi.Example:443/\"\n",
"server.toml",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(
resolved.server.static_assets_dir,
Some(dir.path().join("web/build"))
);
assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db"));
assert_eq!(
resolved.server.embedded_runtime_store_root,
dir.path().join(".yoi/runtime-store")
);
}
#[test]
fn absolute_paths_are_preserved() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[data]
workspace_database_path = "/tmp/yoi-workspace.db"
embedded_runtime_store_root = "/tmp/yoi-runtime"
"#,
"test",
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&host_config,
&BackendRuntimesConfigFile::default(),
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(
resolved.database_path,
PathBuf::from("/tmp/yoi-workspace.db")
let AuthConfig::Passkey {
rp_id,
origin,
public_base_url,
..
} = &resolved.server.auth;
assert_eq!(rp_id, "yoi.example");
assert_eq!(origin, "https://yoi.example");
assert_eq!(public_base_url, "https://yoi.example");
}
#[test]
fn browser_public_url_rejects_non_origin_urls() {
for value in [
"https://example.test/path",
"https://example.test?query=true",
"file:///tmp/web",
] {
let host_config = ServerHostConfigFile {
browser: ServerBrowserConfig {
public_url: value.to_string(),
},
};
let result = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&host_config,
&BackendRuntimesConfigFile::default(),
);
assert_eq!(
resolved.server.embedded_runtime_store_root,
PathBuf::from("/tmp/yoi-runtime")
);
}
#[test]
fn data_root_derives_runtime_store_path_only() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[data]
root = ".local-data"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert!(resolved.database_path.ends_with("server.db"));
assert_eq!(
resolved.server.embedded_runtime_store_root,
dir.path().join(".local-data/embedded-runtime")
);
}
#[test]
fn copies_local_config_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
let path = WorkspaceBackendConfigFile::path_for_workspace(dir.path());
let raw = fs::read_to_string(&path).unwrap();
assert_eq!(raw, WORKSPACE_BACKEND_CONFIG_TEMPLATE);
WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap();
fs::write(&path, "# custom local config\n").unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"# custom local config\n"
);
}
#[test]
fn local_config_diff_reports_match_and_difference() {
let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
let matched =
WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(!matched.differs);
fs::write(
WorkspaceBackendConfigFile::path_for_workspace(dir.path()),
"[server]\nlisten = \"127.0.0.1:9999\"\n",
)
.unwrap();
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(diff.differs);
assert!(diff.text.contains("+++ workspace local"));
assert!(diff.text.contains("127.0.0.1:9999"));
}
#[test]
fn resolves_repository_uri_relative_to_workspace_root() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[[repositories]]
id = "main"
provider = "git"
uri = "."
display_name = "Main"
default_selector = "HEAD"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
let repository = resolved.server.repositories.first().unwrap();
assert_eq!(repository.id, "main");
assert_eq!(repository.provider, "git");
assert_eq!(repository.path, dir.path());
assert_eq!(repository.display_name.as_deref(), Some("Main"));
assert_eq!(repository.default_selector.as_deref(), Some("HEAD"));
}
#[test]
fn remote_repository_uri_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[[repositories]]
id = "main"
provider = "git"
uri = "https://example.com/org/repo.git"
"#,
"test",
)
.unwrap();
let error = match config.resolve(dir.path(), identity()) {
Ok(_) => panic!("remote repository URI should fail closed"),
let error = match result {
Ok(_) => panic!("expected {value} to be rejected"),
Err(error) => error,
};
assert!(
error.to_string().contains("browser.public_url"),
"unexpected error for {value}: {error}"
);
}
}
#[test]
fn server_host_config_loads_only_from_the_explicit_host_path() {
let dir = tempfile::tempdir().unwrap();
let path = ServerHostConfigFile::path_for_config_dir(dir.path());
fs::write(
&path,
"[browser]\npublic_url = \"https://deploy.example.test\"\n",
)
.unwrap();
let loaded = ServerHostConfigFile::load_from_path(&path).unwrap();
assert_eq!(loaded.browser.public_url, "https://deploy.example.test");
assert_eq!(path, dir.path().join("server.toml"));
}
#[test]
fn explicit_missing_server_host_config_fails_closed() {
let error = ServerHostConfigFile::load_from_path("/missing/yoi/server.toml").unwrap_err();
assert!(
error
.to_string()
.contains("remote repository materialization is not implemented"),
"unexpected error: {error}"
.contains("failed to read Server host config")
);
}
@@ -794,28 +467,8 @@ uri = "https://example.com/org/repo.git"
);
}
#[test]
fn workspace_backend_config_rejects_runtime_entries() {
let error = WorkspaceBackendConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "arc"
endpoint = "http://legacy.example.test"
display_name = "legacy arc"
"#,
"test",
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field `runtimes`"),
"unexpected error: {error}"
);
}
#[test]
fn backend_runtimes_config_is_the_only_runtime_source() {
let dir = tempfile::tempdir().unwrap();
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
@@ -826,9 +479,7 @@ display_name = "xdg arc"
"runtimes.toml",
)
.unwrap();
let resolved = workspace_config
.resolve_with_runtime_config(dir.path(), identity(), &runtime_config)
.unwrap();
let resolved = resolved_with_runtimes(&runtime_config);
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
assert_eq!(
@@ -857,8 +508,6 @@ token = "secret"
#[test]
fn token_ref_fails_closed_until_secret_resolution_exists() {
let dir = tempfile::tempdir().unwrap();
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
@@ -869,9 +518,10 @@ token_ref = "local:remote-token"
"runtimes.toml",
)
.unwrap();
let error = match workspace_config.resolve_with_runtime_config(
dir.path(),
let error = match ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&ServerHostConfigFile::default(),
&runtime_config,
) {
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
+46 -132
View File
@@ -177,26 +177,6 @@ impl RuntimeSourceSummary {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeCapabilitySummary {
pub can_list_hosts: bool,
pub can_list_workers: bool,
pub can_get_worker: bool,
pub can_spawn_worker: bool,
pub can_stop_worker: bool,
pub has_workspace_fs: bool,
pub has_shell: bool,
pub has_git: bool,
pub supports_worktrees: bool,
pub supports_backend_internal_tools: bool,
pub workspace_scope: String,
pub max_workers: usize,
pub os: String,
pub arch: String,
}
pub type HostCapabilitySummary = RuntimeCapabilitySummary;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeSummary {
pub runtime_id: String,
@@ -205,7 +185,9 @@ pub struct RuntimeSummary {
pub status: String,
pub source: RuntimeSourceSummary,
pub host_ids: Vec<String>,
pub capabilities: RuntimeCapabilitySummary,
pub worker_creation_available: bool,
pub os: String,
pub arch: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
@@ -218,7 +200,8 @@ pub struct HostSummary {
pub status: String,
pub observed_at: String,
pub last_seen_at: Option<String>,
pub capabilities: HostCapabilitySummary,
pub os: String,
pub arch: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
@@ -310,27 +293,6 @@ impl From<RuntimeSourceSummary> for workspace_api::RuntimeSourceSummary {
}
}
impl From<RuntimeCapabilitySummary> for workspace_api::RuntimeCapabilitySummary {
fn from(capabilities: RuntimeCapabilitySummary) -> Self {
Self {
can_list_hosts: capabilities.can_list_hosts,
can_list_workers: capabilities.can_list_workers,
can_get_worker: capabilities.can_get_worker,
can_spawn_worker: capabilities.can_spawn_worker,
can_stop_worker: capabilities.can_stop_worker,
has_workspace_fs: capabilities.has_workspace_fs,
has_shell: capabilities.has_shell,
has_git: capabilities.has_git,
supports_worktrees: capabilities.supports_worktrees,
supports_backend_internal_tools: capabilities.supports_backend_internal_tools,
workspace_scope: capabilities.workspace_scope,
max_workers: capabilities.max_workers,
os: capabilities.os,
arch: capabilities.arch,
}
}
}
impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
fn from(runtime: RuntimeSummary) -> Self {
Self {
@@ -340,7 +302,9 @@ impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
status: runtime.status,
source: runtime.source.into(),
host_ids: runtime.host_ids,
capabilities: runtime.capabilities.into(),
worker_creation_available: runtime.worker_creation_available,
os: runtime.os,
arch: runtime.arch,
diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(),
}
}
@@ -1890,7 +1854,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
status: "unavailable".to_string(),
source: RuntimeSourceSummary::embedded_worker_runtime(),
host_ids: Vec::new(),
capabilities: embedded_runtime_capabilities(limit, false, false),
worker_creation_available: false,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
diagnostics,
};
}
@@ -1910,7 +1876,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
} else {
vec![self.host_id.clone()]
},
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled),
worker_creation_available: true,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
diagnostics,
}
}
@@ -1928,7 +1896,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
status: "available".to_string(),
observed_at: Utc::now().to_rfc3339(),
last_seen_at: None,
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
diagnostics: vec![diagnostic(
"embedded_runtime_host_boundary",
DiagnosticSeverity::Info,
@@ -2576,7 +2545,9 @@ pub struct RemoteRuntimeConfig {
pub base_url: String,
pub bearer_token: Option<String>,
pub auth: Option<RemoteRuntimeAuthConfig>,
pub cached_capabilities: RuntimeCapabilitySummary,
pub cached_worker_creation_available: bool,
pub cached_os: String,
pub cached_arch: String,
pub cached_status: String,
pub timeout: Duration,
}
@@ -2598,7 +2569,12 @@ impl std::fmt::Debug for RemoteRuntimeConfig {
&self.bearer_token.as_ref().map(|_| "<redacted>"),
)
.field("auth", &self.auth.as_ref().map(|_| "<capability-signer>"))
.field("cached_capabilities", &self.cached_capabilities)
.field(
"cached_worker_creation_available",
&self.cached_worker_creation_available,
)
.field("cached_os", &self.cached_os)
.field("cached_arch", &self.cached_arch)
.field("cached_status", &self.cached_status)
.field("timeout", &self.timeout)
.finish()
@@ -2619,9 +2595,9 @@ impl RemoteRuntimeConfig {
base_url: base_url.into(),
bearer_token,
auth: None,
cached_capabilities: remote_runtime_capabilities(
200, false, false, "unknown", "unknown",
),
cached_worker_creation_available: false,
cached_os: "unknown".to_string(),
cached_arch: "unknown".to_string(),
cached_status: "configured".to_string(),
timeout: Duration::from_secs(10),
}
@@ -2632,11 +2608,6 @@ impl RemoteRuntimeConfig {
self
}
pub fn with_cached_capabilities(mut self, capabilities: RuntimeCapabilitySummary) -> Self {
self.cached_capabilities = capabilities;
self
}
pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self {
self.auth = Some(auth);
self
@@ -2708,7 +2679,9 @@ pub struct RemoteWorkerRuntime {
workspace_id: String,
bearer_token: Option<String>,
auth: Option<RemoteRuntimeAuthConfig>,
cached_capabilities: RuntimeCapabilitySummary,
cached_worker_creation_available: bool,
cached_os: String,
cached_arch: String,
cached_status: String,
host_id: String,
resource_broker: BackendResourceBroker,
@@ -2768,7 +2741,9 @@ impl RemoteWorkerRuntime {
workspace_id,
bearer_token: config.bearer_token,
auth: config.auth,
cached_capabilities: config.cached_capabilities,
cached_worker_creation_available: config.cached_worker_creation_available,
cached_os: config.cached_os,
cached_arch: config.cached_arch,
cached_status: config.cached_status,
resource_broker: BackendResourceBroker::default(),
http,
@@ -3045,13 +3020,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} else {
vec![self.host_id.clone()]
},
capabilities: remote_runtime_capabilities(
limit,
true,
response.runtime.worker_creation_available,
response.runtime.os,
response.runtime.arch,
),
worker_creation_available: response.runtime.worker_creation_available,
os: response.runtime.os,
arch: response.runtime.arch,
diagnostics: Vec::new(),
},
Err(diagnostic) => RuntimeSummary {
@@ -3065,7 +3036,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} else {
vec![self.host_id.clone()]
},
capabilities: self.cached_capabilities.clone(),
worker_creation_available: self.cached_worker_creation_available,
os: self.cached_os.clone(),
arch: self.cached_arch.clone(),
diagnostics: vec![diagnostic],
},
}
@@ -3084,7 +3057,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
status: "configured".to_string(),
observed_at: Utc::now().to_rfc3339(),
last_seen_at: None,
capabilities: remote_runtime_capabilities(limit, true, false, "unknown", "unknown"),
os: self.cached_os.clone(),
arch: self.cached_arch.clone(),
diagnostics: Vec::new(),
}],
Vec::new(),
@@ -3553,29 +3527,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
}
}
fn embedded_runtime_capabilities(
limit: usize,
available: bool,
execution_enabled: bool,
) -> RuntimeCapabilitySummary {
RuntimeCapabilitySummary {
can_list_hosts: true,
can_list_workers: available,
can_get_worker: available,
can_spawn_worker: available,
can_stop_worker: available && execution_enabled,
has_workspace_fs: false,
has_shell: false,
has_git: false,
supports_worktrees: false,
supports_backend_internal_tools: true,
workspace_scope: "backend_internal".to_string(),
max_workers: limit,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
}
}
fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str {
match status {
RuntimeStatus::Running => "running",
@@ -4122,31 +4073,6 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
encoded
}
fn remote_runtime_capabilities(
limit: usize,
available: bool,
worker_creation_available: bool,
os: impl Into<String>,
arch: impl Into<String>,
) -> RuntimeCapabilitySummary {
RuntimeCapabilitySummary {
can_list_hosts: true,
can_list_workers: available,
can_get_worker: available,
can_spawn_worker: available && worker_creation_available,
can_stop_worker: available,
has_workspace_fs: false,
has_shell: false,
has_git: false,
supports_worktrees: false,
supports_backend_internal_tools: false,
workspace_scope: "remote_runtime_backend_private".to_string(),
max_workers: limit,
os: os.into(),
arch: arch.into(),
}
}
fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDiagnostic {
if err.is_timeout() {
diagnostic(
@@ -4800,22 +4726,9 @@ mod tests {
status: "available".to_string(),
source: RuntimeSourceSummary::embedded_worker_runtime_reserved(),
host_ids: vec![self.host_id.clone()],
capabilities: RuntimeCapabilitySummary {
can_list_hosts: true,
can_list_workers: true,
can_get_worker: true,
can_spawn_worker: false,
can_stop_worker: false,
has_workspace_fs: false,
has_shell: false,
has_git: false,
supports_worktrees: false,
supports_backend_internal_tools: false,
workspace_scope: "none".to_string(),
max_workers: self.workers.len(),
worker_creation_available: false,
os: "test".to_string(),
arch: "test".to_string(),
},
diagnostics: Vec::new(),
}
}
@@ -4830,7 +4743,8 @@ mod tests {
status: "available".to_string(),
observed_at: "unknown".to_string(),
last_seen_at: None,
capabilities: self.runtime_summary(1).capabilities,
os: "test".to_string(),
arch: "test".to_string(),
diagnostics: Vec::new(),
}],
Vec::new(),
@@ -5234,7 +5148,7 @@ mod tests {
RuntimeSourceKind::EmbeddedWorkerRuntime
);
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
assert!(embedded_summary.capabilities.can_spawn_worker);
assert!(embedded_summary.worker_creation_available);
let spawned = registry
.spawn_worker(
+2 -3
View File
@@ -46,8 +46,7 @@ impl WorkspaceIdentity {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => {
Err(Error::WorkspaceIdentity(format!(
"workspace is not initialized at {}; run `yoi-server init --workspace {}` first",
workspace_root.as_ref().display(),
"workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
workspace_root.as_ref().display()
)))
}
@@ -219,7 +218,7 @@ mod tests {
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
assert!(
error.to_string().contains("workspace is not initialized"),
error.to_string().contains("workspace identity is missing"),
"unexpected error: {error}"
);
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
+4 -5
View File
@@ -20,12 +20,15 @@ pub mod records;
#[cfg(feature = "typescript")]
pub use records::ticket_api_typescript;
pub mod repositories;
pub mod repository_source;
pub mod resource_broker;
pub mod retention;
pub mod runtime_settings;
pub mod runtime_subscription;
pub mod server;
pub mod skills;
pub mod store;
pub mod workdir_create_operations;
pub mod worker_source;
pub mod workspace_catalog;
mod workspace_subscription;
@@ -35,11 +38,7 @@ pub use authority::{
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
WorkspaceAuthority,
};
pub use config::{
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WorkspaceBackendConfigFile,
};
pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
pub use repositories::{
+48 -240
View File
@@ -11,17 +11,13 @@ use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
use yoi_workspace_server::{
BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig,
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService,
WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig,
ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
};
#[derive(Debug)]
enum Command {
Serve(ServeOptions),
Init(InitOptions),
ConfigDefault,
ConfigDiff(WorkspacePathOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
MigrateDryRun { database: Option<PathBuf> },
@@ -32,16 +28,7 @@ enum Command {
#[derive(Debug)]
struct ServeOptions {
listen: Option<SocketAddr>,
}
#[derive(Debug)]
struct InitOptions {
workspace: PathBuf,
}
#[derive(Debug)]
struct WorkspacePathOptions {
workspace: PathBuf,
config: Option<PathBuf>,
}
#[derive(Debug)]
@@ -82,9 +69,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await,
Command::Init(options) => run_init(options).await,
Command::ConfigDefault => run_config_default(),
Command::ConfigDiff(options) => run_config_diff(options),
Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::MigrateDryRun { database } => {
@@ -110,14 +94,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
};
match command.as_str() {
"init" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_init_help();
return Ok(Command::Help);
}
Ok(Command::Init(parse_init_options(rest)?))
}
"config" => parse_config_command(rest),
"identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_command(rest),
@@ -134,61 +110,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help)
}
other => Err(CliError(format!(
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
"unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
))),
}
}
async fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
run_init_with_database_path(options, ServerConfig::default_server_database_path()).await
}
async fn run_init_with_database_path(
options: InitOptions,
database_path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?;
if let Some(parent) = database_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
let service = WorkspaceCatalogService::new(store);
service.create_with_workspace_id(
WorkspaceCreateRequest {
operation_key: format!("cli-init:{}", identity.workspace_id),
display_name: identity.display_name.clone(),
repository: InitialRepositoryIntent {
uri: options.workspace.display().to_string(),
display_name: Some("Main repository".to_string()),
default_ref: Some("HEAD".to_string()),
},
},
None,
Some(identity.workspace_id.clone()),
)?;
eprintln!(
"yoi-server: initialized workspace `{}` ({}) in server DB `{}`",
options.workspace.display(),
identity.workspace_id,
database_path.display()
);
Ok(())
}
fn run_config_default() -> Result<(), Box<dyn std::error::Error>> {
print!("{WORKSPACE_BACKEND_CONFIG_TEMPLATE}");
Ok(())
}
fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::error::Error>> {
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(&options.workspace)?;
print!("{}", diff.text);
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct ServerIdentityFile {
identity: RuntimeIdentityMaterial,
@@ -626,10 +552,15 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
.to_path_buf(),
)
};
let host_config = match options.config.as_ref() {
Some(path) => ServerHostConfigFile::load_from_path(path)?,
None => ServerHostConfigFile::load_default()?,
};
let runtime_config = BackendRuntimesConfigFile::load_default()?;
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config(
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
&workspace_root,
identity,
&host_config,
&runtime_config,
)?;
resolved.database_path = database_path.clone();
@@ -638,7 +569,6 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
if let Some(listen) = options.listen {
resolved = resolved.with_listen(listen);
}
resolved.server.allow_local_workspace_bootstrap = resolved.listen.ip().is_loopback();
let listener = TcpListener::bind(resolved.listen).await?;
let local_addr = listener.local_addr()?;
@@ -711,49 +641,15 @@ fn infer_workspace_root_from_repositories(
)));
};
let repository_path = PathBuf::from(&repository.uri);
if !repository_path.is_absolute() {
if repository.source.kind == workspace_api::RepositorySourceKind::Invalid {
return Err(CliError(format!(
"repository `{}` has relative URI `{}`; repository records used by serve must be absolute paths",
repository.repository_id, repository.uri
"repository `{}` has an invalid migrated source and cannot be used by serve",
repository.repository_id
)));
}
Ok(repository_path)
}
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
let Some((subcommand, rest)) = args.split_first() else {
print_config_help();
return Ok(Command::Help);
};
match subcommand.as_str() {
"default" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_config_help();
return Ok(Command::Help);
}
if !rest.is_empty() {
return Err(CliError(
"config default does not accept options".to_string(),
));
}
Ok(Command::ConfigDefault)
}
"diff" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_config_help();
return Ok(Command::Help);
}
Ok(Command::ConfigDiff(parse_workspace_path_options(rest)?))
}
"--help" | "-h" => {
print_config_help();
Ok(Command::Help)
}
other => Err(CliError(format!(
"unknown config subcommand `{other}`; expected `default` or `diff`"
))),
}
Ok(ServerConfig::default_workspace_backend_data_root(
&workspace.workspace_id,
))
}
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
@@ -839,57 +735,9 @@ fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOption
Ok(SkillWorkspaceOptions { workspace_id })
}
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--workspace" => {
let value = iter
.next()
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
workspace = PathBuf::from(value);
}
value if value.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
other => return Err(CliError(format!("unknown workspace option `{other}`"))),
}
}
let workspace = workspace
.canonicalize()
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
Ok(WorkspacePathOptions { workspace })
}
fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--workspace" => {
let value = iter
.next()
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
workspace = PathBuf::from(value);
}
value if value.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
other => return Err(CliError(format!("unknown init option `{other}`"))),
}
}
let workspace = workspace
.canonicalize()
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
Ok(InitOptions { workspace })
}
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut listen = None;
let mut config = None;
let mut index = 0;
while index < args.len() {
@@ -905,6 +753,16 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
_ if arg.starts_with("--listen=") => {
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
}
"--config" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--config requires a path".to_string()))?;
config = Some(PathBuf::from(value));
}
_ if arg.starts_with("--config=") => {
config = Some(PathBuf::from(value_after_equals(arg, "--config")?));
}
_ if arg.starts_with('-') => {
return Err(CliError(format!("unknown serve option `{arg}`")));
}
@@ -917,7 +775,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
index += 1;
}
Ok(ServeOptions { listen })
Ok(ServeOptions { listen, config })
}
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
@@ -939,23 +797,11 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
fn print_init_help() {
println!(
"yoi-server init\n\nUsage:\n yoi-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
);
}
fn print_config_help() {
println!(
"yoi-server config\n\nUsage:\n yoi-server config default\n yoi-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
);
}
fn print_skills_help() {
println!(
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
@@ -965,24 +811,23 @@ fn print_skills_help() {
fn print_serve_help() {
println!(
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help"
yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
);
}
#[cfg(test)]
mod tests {
use super::*;
use yoi_workspace_server::{
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WORKSPACE_IDENTITY_RELATIVE_PATH,
};
#[test]
fn parse_init_defaults_workspace_to_cwd_or_flag() {
let temp = tempfile::tempdir().unwrap();
let args = vec!["--workspace".to_string(), temp.path().display().to_string()];
let options = parse_init_options(&args).unwrap();
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
fn removed_repository_local_commands_are_rejected() {
for command in ["init", "config"] {
let error = parse_command(&[command.to_string()]).unwrap_err();
assert!(
error.to_string().contains("unknown command"),
"unexpected error for {command}: {error}"
);
}
}
#[test]
@@ -1022,10 +867,18 @@ mod tests {
}
#[test]
fn parse_serve_accepts_listen_only() {
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
fn parse_serve_accepts_listen_and_host_config() {
let args = vec![
"--listen".to_string(),
"127.0.0.1:0".to_string(),
"--config=/etc/yoi/server.toml".to_string(),
];
let options = parse_serve_options(&args).unwrap();
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
assert_eq!(
options.config.unwrap(),
PathBuf::from("/etc/yoi/server.toml")
);
}
#[test]
@@ -1080,49 +933,4 @@ mod tests {
);
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
}
#[tokio::test]
async fn init_creates_identity_local_config_and_server_records() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("data").join("server").join("server.db");
std::fs::create_dir(temp.path().join(".git")).unwrap();
run_init_with_database_path(
InitOptions {
workspace: temp.path().canonicalize().unwrap(),
},
database_path.clone(),
)
.await
.unwrap();
assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists());
let local_config_path = temp.path().join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH);
assert!(local_config_path.exists());
assert_eq!(
std::fs::read_to_string(local_config_path).unwrap(),
WORKSPACE_BACKEND_CONFIG_TEMPLATE
);
assert!(
!temp
.path()
.join(".yoi/workspace-backend.default.toml")
.exists()
);
assert!(!temp.path().join(".yoi/workspace.db").exists());
assert!(!temp.path().join(".yoi/embedded-runtime").exists());
assert!(database_path.exists());
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
let workspaces = store.list_workspaces().unwrap();
assert_eq!(workspaces.len(), 1);
let repositories = store
.list_repositories(&workspaces[0].workspace_id)
.unwrap();
assert_eq!(repositories.len(), 1);
assert_eq!(repositories[0].repository_id, "main");
assert_eq!(
repositories[0].uri,
temp.path().canonicalize().unwrap().display().to_string()
);
}
}
+1
View File
@@ -296,6 +296,7 @@ pub struct TicketActionEligibility {
pub can_unassign_orchestrator: bool,
pub can_queue: bool,
pub can_start_manual_coder: bool,
pub queue_tickets: Vec<String>,
pub blockers: Vec<String>,
}
+116 -13
View File
@@ -5,6 +5,7 @@ use std::{
};
use serde::{Deserialize, Serialize};
use workspace_api::{RepositoryObservedStatus, RepositorySource};
pub type RepositoryId = String;
pub type RepositorySelector = String;
@@ -13,8 +14,12 @@ pub type RepositorySelector = String;
pub struct ConfiguredRepository {
pub id: RepositoryId,
pub provider: String,
pub uri: String,
pub path: PathBuf,
pub source: RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
pub observed_at: Option<String>,
pub path: Option<PathBuf>,
pub display_name: Option<String>,
pub default_selector: Option<RepositorySelector>,
}
@@ -25,6 +30,12 @@ pub struct RepositorySummary {
pub display_name: String,
pub kind: String,
pub provider: String,
pub source: RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_selector: Option<RepositorySelector>,
pub record_authority: String,
@@ -262,9 +273,17 @@ impl RepositoryRegistryReader {
descendant: &str,
) -> Result<(), RepositoryLookupError> {
let repository = self.merge_repository(id)?;
let repository_path =
repository
.path
.as_ref()
.ok_or_else(|| RepositoryLookupError::ProviderFailure {
id: id.into(),
operation: "repository source is not materialized for local Git access".into(),
})?;
let status = Command::new("git")
.arg("-C")
.arg(&repository.path)
.arg(repository_path)
.args(["merge-base", "--is-ancestor", ancestor, descendant])
.status()
.map_err(|_| RepositoryLookupError::ProviderFailure {
@@ -306,7 +325,24 @@ impl RepositoryRegistryReader {
.clone()
.unwrap_or_else(|| repository.id.clone());
let mut diagnostics = Vec::new();
if repository.source.kind == workspace_api::RepositorySourceKind::Http {
diagnostics.push(RepositoryDiagnostic {
severity: "warning".to_string(),
code: "repository_source_insecure_http".to_string(),
message:
"HTTP Repository source is unencrypted; prefer HTTPS or SSH when available."
.to_string(),
});
}
let git = match repository.provider.as_str() {
"git" if repository.path.is_none() => {
diagnostics.push(RepositoryDiagnostic {
severity: "info".to_string(),
code: "repository_source_unverified".to_string(),
message: "Remote Repository source is registered but is not materialized for server-local inspection.".to_string(),
});
None
}
"git" => match self.inspect_git(repository) {
Ok(git) => Some(git),
Err(message) => {
@@ -335,6 +371,11 @@ impl RepositoryRegistryReader {
display_name,
kind: repository.provider.clone(),
provider: repository.provider.clone(),
source: repository.source.clone(),
source_revision: repository.source_revision,
source_fingerprint: repository.source_fingerprint.clone(),
observed_status: repository.observed_status,
observed_at: repository.observed_at.clone(),
default_selector: repository.default_selector.clone(),
record_authority: "workspace-control-plane".to_string(),
git,
@@ -346,12 +387,15 @@ impl RepositoryRegistryReader {
&self,
repository: &ConfiguredRepository,
) -> Result<GitRepositorySummary, String> {
let head = git_stdout(&repository.path, ["rev-parse", "HEAD"])?;
let branch = git_stdout(&repository.path, ["branch", "--show-current"])
let path = repository.path.as_ref().ok_or_else(|| {
"Repository source is not materialized for local Git inspection.".to_string()
})?;
let head = git_stdout(path, ["rev-parse", "HEAD"])?;
let branch = git_stdout(path, ["branch", "--show-current"])
.ok()
.and_then(|value| non_empty_string(value.trim()));
let status = git_stdout(&repository.path, ["status", "--porcelain"])?;
let remotes = git_stdout(&repository.path, ["remote", "-v"])
let status = git_stdout(path, ["status", "--porcelain"])?;
let remotes = git_stdout(path, ["remote", "-v"])
.map(|raw| parse_remotes(&raw))
.unwrap_or_default();
Ok(GitRepositorySummary {
@@ -369,8 +413,11 @@ impl RepositoryRegistryReader {
limit: usize,
) -> Result<Vec<GitCommitSummary>, String> {
let limit_arg = format!("-{limit}");
let path = repository.path.as_ref().ok_or_else(|| {
"Repository source is not materialized for local Git log access.".to_string()
})?;
let output = git_stdout(
&repository.path,
path,
[
"log",
"--date=iso-strict",
@@ -419,11 +466,16 @@ fn merge_git_stdout(
operation: &str,
args: &[&str],
) -> Result<String, RepositoryLookupError> {
git_stdout(&repository.path, args.iter().copied()).map_err(|_| {
RepositoryLookupError::ProviderFailure {
let path = repository
.path
.as_ref()
.ok_or_else(|| RepositoryLookupError::ProviderFailure {
id: repository.id.clone(),
operation: "repository source is not materialized for local Git access".into(),
})?;
git_stdout(path, args.iter().copied()).map_err(|_| RepositoryLookupError::ProviderFailure {
id: repository.id.clone(),
operation: operation.into(),
}
})
}
@@ -593,6 +645,47 @@ mod tests {
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
}
#[test]
fn remote_source_is_visible_but_local_provider_operations_fail_closed() {
let source = RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
uri: "git@example.test:org/repository.git".to_string(),
};
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
id: "remote".into(),
display_name: Some("Remote".into()),
provider: "git".into(),
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
source,
source_revision: 1,
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
path: None,
default_selector: Some("main".into()),
}]);
let projection = reader.list();
let summary = &projection.items[0];
assert_eq!(
summary.source.kind,
workspace_api::RepositorySourceKind::Ssh
);
assert_eq!(
summary.observed_status,
RepositoryObservedStatus::Unverified
);
assert!(summary.git.is_none());
assert_eq!(summary.diagnostics[0].code, "repository_source_unverified");
let repository = reader.merge_repository("remote").unwrap();
let error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err();
assert!(matches!(
error,
RepositoryLookupError::ProviderFailure { operation, .. }
if operation.contains("not materialized")
));
}
#[test]
fn merge_evidence_is_resolved_by_repository_identity() {
let temp = tempfile::tempdir().unwrap();
@@ -675,12 +768,22 @@ mod tests {
.success()
);
let source_descriptor = RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: path.display().to_string(),
};
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
id: "main".into(),
display_name: Some("Main".into()),
provider: "git".into(),
path: path.to_path_buf(),
uri: path.display().to_string(),
source_fingerprint: crate::repository_source::repository_source_fingerprint(
&source_descriptor,
),
source: source_descriptor,
source_revision: 1,
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
path: Some(path.to_path_buf()),
default_selector: Some("main".into()),
}]);
let target = reader.observe_merge_target("main", Some("main")).unwrap();
@@ -0,0 +1,230 @@
use std::path::Path;
use sha2::{Digest, Sha256};
use url::Url;
use workspace_api::{RepositorySource, RepositorySourceKind};
use crate::{Error, Result};
const MAX_REPOSITORY_SOURCE_BYTES: usize = 4096;
/// Parse and canonicalize a user-authored Git source without accessing the
/// filesystem or network.
pub fn parse_repository_source(value: &str) -> Result<RepositorySource> {
let value = value.trim();
if value.is_empty() || value.len() > MAX_REPOSITORY_SOURCE_BYTES {
return Err(Error::InvalidInput(format!(
"initial repository source must be between 1 and {MAX_REPOSITORY_SOURCE_BYTES} bytes"
)));
}
if value.chars().any(char::is_control) {
return Err(Error::InvalidInput(
"initial repository source must not contain control characters".to_string(),
));
}
if Path::new(value).is_absolute() {
return Ok(RepositorySource {
kind: RepositorySourceKind::LocalPath,
uri: value.to_string(),
});
}
if is_scp_like_ssh(value) {
validate_scp_like_ssh(value)?;
return Ok(RepositorySource {
kind: RepositorySourceKind::Ssh,
uri: value.to_string(),
});
}
let parsed = Url::parse(value).map_err(|_| {
Error::InvalidInput(
"initial repository source must be an absolute local path or a supported Git URI"
.to_string(),
)
})?;
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err(Error::InvalidInput(
"initial repository source must not contain query parameters or fragments".to_string(),
));
}
if parsed.password().is_some() {
return Err(Error::InvalidInput(
"initial repository source must not embed a password or token".to_string(),
));
}
let kind = match parsed.scheme() {
"file" => {
if !parsed.username().is_empty() {
return Err(Error::InvalidInput(
"file repository URI must not contain user information".to_string(),
));
}
if parsed.host_str().is_some_and(|host| host != "localhost") {
return Err(Error::InvalidInput(
"file repository URI host must be empty or localhost".to_string(),
));
}
parsed.to_file_path().map_err(|_| {
Error::InvalidInput("file repository URI must contain an absolute path".to_string())
})?;
RepositorySourceKind::File
}
"ssh" => {
require_remote_host_and_path(&parsed)?;
RepositorySourceKind::Ssh
}
"http" | "https" => {
if !parsed.username().is_empty() {
return Err(Error::InvalidInput(
"HTTP repository URI must not contain user information".to_string(),
));
}
require_remote_host_and_path(&parsed)?;
if parsed.scheme() == "http" {
RepositorySourceKind::Http
} else {
RepositorySourceKind::Https
}
}
scheme => {
return Err(Error::InvalidInput(format!(
"unsupported initial repository source scheme `{scheme}`"
)));
}
};
Ok(RepositorySource {
kind,
uri: parsed.to_string(),
})
}
/// Classify persisted pre-source-contract rows without guessing a usable remote
/// when the legacy value is malformed. No filesystem or network access occurs.
pub fn classify_legacy_repository_source(value: &str) -> RepositorySource {
parse_repository_source(value).unwrap_or_else(|_| RepositorySource {
kind: RepositorySourceKind::Invalid,
uri: value.trim().to_string(),
})
}
pub fn repository_source_fingerprint(source: &RepositorySource) -> String {
let payload = serde_json::to_vec(source).expect("Repository source serializes");
let mut hasher = Sha256::new();
hasher.update(b"yoi.repository-source.v1\0");
hasher.update(payload);
let digest = hasher.finalize();
let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
}
format!("sha256:{encoded}")
}
fn require_remote_host_and_path(parsed: &Url) -> Result<()> {
if parsed.host_str().is_none() || parsed.path().is_empty() || parsed.path() == "/" {
return Err(Error::InvalidInput(
"remote repository URI must contain a host and repository path".to_string(),
));
}
Ok(())
}
fn is_scp_like_ssh(value: &str) -> bool {
!value.contains("://")
&& value
.split_once(':')
.is_some_and(|(identity, _)| identity.contains('@'))
}
fn validate_scp_like_ssh(value: &str) -> Result<()> {
let (identity, path) = value.split_once(':').ok_or_else(|| {
Error::InvalidInput("scp-like SSH source must contain `host:path`".to_string())
})?;
let (username, host) = identity.split_once('@').ok_or_else(|| {
Error::InvalidInput("scp-like SSH source must contain `user@host:path`".to_string())
})?;
if username.is_empty()
|| host.is_empty()
|| path.is_empty()
|| username.contains('@')
|| username.contains(':')
|| host.contains('@')
|| path.starts_with('-')
|| value.contains('?')
|| value.contains('#')
|| value.chars().any(char::is_whitespace)
{
return Err(Error::InvalidInput(
"scp-like SSH source must use `user@host:path` without credentials or parameters"
.to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_local_file_ssh_http_and_https_sources_without_io() {
let cases = [
("/runtime/repos/project", RepositorySourceKind::LocalPath),
("file:///runtime/repos/project", RepositorySourceKind::File),
(
"ssh://git@example.test/org/project.git",
RepositorySourceKind::Ssh,
),
(
"git@example.test:org/project.git",
RepositorySourceKind::Ssh,
),
(
"http://git.test/org/project.git",
RepositorySourceKind::Http,
),
(
"https://git.test/org/project.git",
RepositorySourceKind::Https,
),
];
for (source, expected_kind) in cases {
assert_eq!(parse_repository_source(source).unwrap().kind, expected_kind);
}
}
#[test]
fn rejects_relative_unsupported_and_credential_bearing_sources() {
for source in [
"relative/project",
"ftp://git.test/project.git",
"https://user@git.test/project.git",
"https://git.test/project.git?token=secret",
"ssh://git:secret@git.test/project.git",
"git@example.test:",
"git:secret@example.test:org/project.git",
"https://git.test/project.git\nother",
] {
assert!(
parse_repository_source(source).is_err(),
"accepted {source:?}"
);
}
}
#[test]
fn fingerprint_uses_canonical_source_identity() {
let first = parse_repository_source(" https://EXAMPLE.test/a/../project.git ").unwrap();
let second = parse_repository_source("https://example.test/project.git").unwrap();
assert_eq!(first, second);
assert_eq!(
repository_source_fingerprint(&first),
repository_source_fingerprint(&second)
);
}
}
@@ -0,0 +1,229 @@
use config_source::ConfigSchemaContribution;
use serde::Deserialize;
use crate::config_source::{
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
};
use crate::{Error, Result};
const RUNTIME_SCHEMA_SOURCE: &str = r#"{
runtime = {
default_runtime_id = String default "";
};
}"#;
#[derive(Debug, Default)]
pub struct RuntimeConfigSchemaProvider;
impl WorkspaceConfigSchemaProvider for RuntimeConfigSchemaProvider {
fn contribution(&self) -> Result<ConfigSchemaContribution> {
ConfigSchemaContribution::new("builtin:runtime", "runtime", "1", RUNTIME_SCHEMA_SOURCE)
.map_err(|error| Error::Config(error.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeConfigProjection {
pub config_revision: u64,
pub projection_digest: String,
pub default_runtime_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct VirtualRuntimeConfig {
runtime: VirtualRuntimeSection,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct VirtualRuntimeSection {
default_runtime_id: String,
}
pub fn project_runtime_from_workspace_config(
workspace_id: &str,
state: &WorkspaceConfigState,
) -> Result<RuntimeConfigProjection> {
let has_runtime_schema = state
.contract
.schema_bundle
.contributions
.iter()
.any(|entry| entry.provider_id == "builtin:runtime");
if !has_runtime_schema {
return Ok(RuntimeConfigProjection {
config_revision: state.snapshot.revision,
projection_digest: state.projection_digest.clone(),
default_runtime_id: None,
});
}
let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone())?;
if evaluation.projection_digest != state.projection_digest {
return Err(Error::RegistryInconsistency(format!(
"Runtime projection digest mismatch for Workspace {workspace_id}"
)));
}
let projected = evaluation.projections.first().ok_or_else(|| {
Error::RegistryInconsistency("Workspace config has no active projection".to_string())
})?;
let config: VirtualRuntimeConfig = serde_json::from_value(projected.data_json.clone())
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
let default_runtime_id = normalize_runtime_id(&config.runtime.default_runtime_id)?;
Ok(RuntimeConfigProjection {
config_revision: state.snapshot.revision,
projection_digest: evaluation.projection_digest,
default_runtime_id,
})
}
fn normalize_runtime_id(value: &str) -> Result<Option<String>> {
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
if value.chars().any(char::is_control) {
return Err(Error::InvalidRuntimeIdentifier {
kind: "runtime_id".to_string(),
value: "[redacted invalid value]".to_string(),
});
}
Ok(Some(value.to_string()))
}
#[cfg(test)]
mod tests {
use config_source::{ConfigContentType, ConfigEntry, ConfigTreeSnapshot, VirtualPath};
use super::*;
fn state(source: &str) -> WorkspaceConfigState {
let bundle =
config_source::WorkspaceConfigSchemaBundle::compose([RuntimeConfigSchemaProvider
.contribution()
.unwrap()])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(
7,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
source,
)
.unwrap()],
)
.unwrap();
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![VirtualPath::parse("main.dcdl").unwrap()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
);
let projection_digest = config_source::SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract)
.unwrap()
.projection_digest;
WorkspaceConfigState {
snapshot,
contract,
projection_digest,
}
}
#[test]
fn runtime_projection_reads_default_and_preserves_revision_evidence() {
let projection = project_runtime_from_workspace_config(
"workspace",
&state(
r#"{ runtime = { default_runtime_id = "arcadia"; }; } as WorkspaceConfigSchema"#,
),
)
.unwrap();
assert_eq!(projection.default_runtime_id.as_deref(), Some("arcadia"));
assert_eq!(projection.config_revision, 7);
assert!(!projection.projection_digest.is_empty());
}
#[test]
fn runtime_projection_treats_missing_default_as_unconfigured() {
let projection = project_runtime_from_workspace_config(
"workspace",
&state("{} as WorkspaceConfigSchema"),
)
.unwrap();
assert_eq!(projection.default_runtime_id, None);
}
#[test]
fn runtime_projection_treats_pre_runtime_schema_bundle_as_unconfigured() {
let bundle =
config_source::WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:legacy",
"legacy",
"1",
"{ legacy = { enabled = Bool default false; }; }",
)
.unwrap()])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(
6,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ legacy = { enabled = true; }; } as WorkspaceConfigSchema",
)
.unwrap()],
)
.unwrap();
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![VirtualPath::parse("main.dcdl").unwrap()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
);
let projection_digest = config_source::SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract)
.unwrap()
.projection_digest;
let state = WorkspaceConfigState {
snapshot,
contract,
projection_digest: projection_digest.clone(),
};
let projection = project_runtime_from_workspace_config("workspace", &state).unwrap();
assert_eq!(projection.default_runtime_id, None);
assert_eq!(projection.config_revision, 6);
assert_eq!(projection.projection_digest, projection_digest);
}
#[test]
fn runtime_schema_rejects_non_string_default() {
let bundle =
config_source::WorkspaceConfigSchemaBundle::compose([RuntimeConfigSchemaProvider
.contribution()
.unwrap()])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ runtime = { default_runtime_id = 42; }; } as WorkspaceConfigSchema",
)
.unwrap()],
)
.unwrap();
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![VirtualPath::parse("main.dcdl").unwrap()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
);
assert!(
config_source::SnapshotEnvironment::new(snapshot)
.evaluate_contract(&contract)
.is_err()
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,246 @@
use rusqlite::{OptionalExtension, params};
use sha2::{Digest, Sha256};
use crate::store::WorkdirCreateOperationRecord;
use crate::{Error, Result, SqliteWorkspaceStore};
pub fn request_fingerprint(
repository_id: &str,
selector: Option<&str>,
requested_runtime_id: Option<&str>,
) -> String {
let mut hasher = Sha256::new();
for value in [Some(repository_id), selector, requested_runtime_id] {
match value {
Some(value) => {
hasher.update([1]);
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(value.as_bytes());
}
None => hasher.update([0]),
}
}
let digest = hasher.finalize();
let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
}
format!("sha256:{encoded}")
}
impl SqliteWorkspaceStore {
pub fn reserve_workdir_create_operation(
&self,
record: &WorkdirCreateOperationRecord,
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
let tx = conn.transaction()?;
tx.execute(
r#"INSERT OR IGNORE INTO workdir_create_operations (
workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, working_directory_id, state, failure,
created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
params![
record.workspace_id,
record.operation_id,
record.request_fingerprint,
record.repository_id,
record.selector,
record.requested_runtime_id,
record.resolved_runtime_id,
record.config_revision as i64,
record.config_projection_digest,
record.working_directory_id,
record.state,
record.failure,
record.created_at,
record.updated_at,
],
)?;
let persisted =
read_workdir_create_operation(&tx, &record.workspace_id, &record.operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{}` was not persisted",
record.operation_id
))
})?;
if persisted.request_fingerprint != record.request_fingerprint {
return Err(Error::InvalidInput(format!(
"Workdir create operation `{}` was reused with different input",
record.operation_id
)));
}
tx.commit()?;
Ok(persisted)
})
}
pub fn finish_workdir_create_operation(
&self,
workspace_id: &str,
operation_id: &str,
request_fingerprint: &str,
succeeded: bool,
failure: Option<&str>,
updated_at: &str,
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
let changed = conn.execute(
r#"UPDATE workdir_create_operations
SET state = ?1, failure = ?2, updated_at = ?3
WHERE workspace_id = ?4 AND operation_id = ?5
AND request_fingerprint = ?6"#,
params![
if succeeded { "succeeded" } else { "failed" },
failure,
updated_at,
workspace_id,
operation_id,
request_fingerprint,
],
)?;
if changed != 1 {
return Err(Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` could not be finalized"
)));
}
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared"
))
})
})
}
pub fn load_workdir_create_operation(
&self,
workspace_id: &str,
operation_id: &str,
) -> Result<Option<WorkdirCreateOperationRecord>> {
self.with_conn(|conn| read_workdir_create_operation(conn, workspace_id, operation_id))
}
}
fn read_workdir_create_operation(
conn: &rusqlite::Connection,
workspace_id: &str,
operation_id: &str,
) -> Result<Option<WorkdirCreateOperationRecord>> {
conn.query_row(
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, working_directory_id, state, failure,
created_at, updated_at
FROM workdir_create_operations
WHERE workspace_id = ?1 AND operation_id = ?2"#,
params![workspace_id, operation_id],
|row| {
Ok(WorkdirCreateOperationRecord {
workspace_id: row.get(0)?,
operation_id: row.get(1)?,
request_fingerprint: row.get(2)?,
repository_id: row.get(3)?,
selector: row.get(4)?,
requested_runtime_id: row.get(5)?,
resolved_runtime_id: row.get(6)?,
config_revision: row.get::<_, i64>(7)? as u64,
config_projection_digest: row.get(8)?,
working_directory_id: row.get(9)?,
state: row.get(10)?,
failure: row.get(11)?,
created_at: row.get(12)?,
updated_at: row.get(13)?,
})
},
)
.optional()
.map_err(Error::from)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{ControlPlaneStore, RepositoryRecord, WorkspaceRecord};
#[test]
fn retry_keeps_resolved_config_evidence_and_rejects_changed_input() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace".to_string(),
owner_account_id: None,
display_name: "Workspace".to_string(),
state: "active".to_string(),
created_at: "2026-08-24T00:00:00Z".to_string(),
updated_at: "2026-08-24T00:00:00Z".to_string(),
}))
.unwrap();
store
.upsert_repository(&RepositoryRecord {
workspace_id: "workspace".to_string(),
repository_id: "main".to_string(),
name: "main".to_string(),
kind: "git".to_string(),
provider: Some("git".to_string()),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: "/tmp/main".to_string(),
},
default_ref: Some("develop".to_string()),
source_revision: 1,
source_fingerprint: "sha256:test".to_string(),
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
observed_at: None,
created_at: "2026-08-24T00:00:00Z".to_string(),
updated_at: "2026-08-24T00:00:00Z".to_string(),
})
.unwrap();
let record = WorkdirCreateOperationRecord {
workspace_id: "workspace".to_string(),
operation_id: "call-1".to_string(),
request_fingerprint: request_fingerprint("main", Some("develop"), None),
repository_id: "main".to_string(),
selector: Some("develop".to_string()),
requested_runtime_id: None,
resolved_runtime_id: "arcadia".to_string(),
config_revision: 7,
config_projection_digest: "sha256:projection".to_string(),
working_directory_id: "wd-1".to_string(),
state: "pending".to_string(),
failure: None,
created_at: "2026-08-24T00:00:00Z".to_string(),
updated_at: "2026-08-24T00:00:00Z".to_string(),
};
assert_eq!(
store.reserve_workdir_create_operation(&record).unwrap(),
record
);
let mut changed_resolution = record.clone();
changed_resolution.resolved_runtime_id = "other".to_string();
changed_resolution.config_revision = 8;
assert_eq!(
store
.reserve_workdir_create_operation(&changed_resolution)
.unwrap(),
record
);
assert_eq!(
store
.load_workdir_create_operation("workspace", "call-1")
.unwrap(),
Some(record.clone())
);
let mut changed_input = record.clone();
changed_input.request_fingerprint = request_fingerprint("main", Some("main"), None);
assert!(
store
.reserve_workdir_create_operation(&changed_input)
.unwrap_err()
.to_string()
.contains("reused with different input")
);
}
}
+122 -8
View File
@@ -3,14 +3,121 @@ use std::time::{SystemTime, UNIX_EPOCH};
use axum::http::HeaderMap;
use worker_runtime::auth::{
WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims,
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims,
verify_worker_mutation_source_proof,
RuntimeRequestSourceExpectation, WorkerMutationActorKind, WorkerMutationOperation,
WorkerMutationSourceClaims, WorkerMutationSourceExpectation,
decode_runtime_request_source_claims, decode_worker_mutation_source_claims,
verify_runtime_request_source, verify_worker_mutation_source_proof,
};
use worker_runtime::worker_source::InProcessWorkerMutationProof;
use crate::hosts::RemoteRuntimeConfig;
use crate::server::WorkspaceApi;
use crate::server::{ServerConfig, WorkspaceApi};
use crate::store::ControlPlaneStore;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifiedRuntimeRequestSource {
pub runtime_id: String,
pub worker_id: Option<String>,
}
pub async fn verify_runtime_request_source_proof(
api: &WorkspaceApi,
proof: &str,
workspace_id: &str,
permission: &str,
method: &str,
path: &str,
body_digest: &str,
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
verify_runtime_request_source_proof_with_store(
api.store.as_ref(),
&api.config,
proof,
workspace_id,
permission,
method,
path,
body_digest,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn verify_runtime_request_source_proof_with_store(
store: &dyn ControlPlaneStore,
config: &ServerConfig,
proof: &str,
workspace_id: &str,
permission: &str,
method: &str,
path: &str,
body_digest: &str,
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
let unverified = decode_runtime_request_source_claims(proof)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, workspace_id)?;
let trusted = store
.get_trusted_runtime(&unverified.iss)
.await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
.filter(|record| record.revoked_at.is_none())
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
let trusted_for_workspace = trusted.workspace_id.as_deref() == Some(workspace_id)
|| (unverified.iss == crate::hosts::EMBEDDED_RUNTIME_ID && trusted.workspace_id.is_none());
if !trusted_for_workspace {
return Err(WorkerMutationSourceProofError::WrongWorkspace);
}
let expected = RuntimeRequestSourceExpectation {
identity_id: &unverified.iss,
audience: audience.as_ref(),
workspace_id,
worker_id: unverified.worker_id.as_deref(),
permission,
method,
path,
body_digest,
now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
};
let claims = verify_runtime_request_source(proof, &trusted.public_key, &expected)
.map_err(map_auth_error)?;
let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX);
let expires_at = u64::try_from(claims.exp).unwrap_or(0);
let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0)
.ok_or(WorkerMutationSourceProofError::Expired)?
.to_rfc3339();
if !store
.consume_worker_mutation_source_jti(
&claims.iss,
&claims.jti,
expires_at,
now_seconds,
&consumed_at,
)
.await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
{
return Err(WorkerMutationSourceProofError::Replay);
}
if let Some(worker_id) = claims.worker_id.as_deref() {
let worker = worker_runtime::identity::RuntimeWorkerRef {
runtime_id: claims.iss.clone(),
worker_id: worker_id.to_owned(),
};
let member = store
.get_worker_registry(workspace_id, &worker)
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
let reserved = store
.has_active_worker_create_reservation(workspace_id, &worker)
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
if member.is_none() && !reserved {
return Err(WorkerMutationSourceProofError::WorkerCatalogMembership);
}
}
Ok(VerifiedRuntimeRequestSource {
runtime_id: claims.iss,
worker_id: claims.worker_id,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PresentedWorkerMutationSourceProof<'a> {
@@ -97,16 +204,19 @@ async fn verify_worker_remove_source_with(
PresentedWorkerMutationSourceProof::Remote(token) => {
let unverified = decode_worker_mutation_source_claims(token)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss)?;
let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?;
let trusted = store
.get_trusted_runtime(&unverified.iss)
.await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
.filter(|record| record.revoked_at.is_none())
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
if trusted.workspace_id.as_deref() != Some(config.workspace_id.as_str()) {
return Err(WorkerMutationSourceProofError::WrongWorkspace);
}
let expected = WorkerMutationSourceExpectation {
runtime_id: &unverified.iss,
audience,
audience: audience.as_ref(),
workspace_id: &config.workspace_id,
worker_id: None,
actor_kind: WorkerMutationActorKind::Worker,
@@ -247,13 +357,17 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
fn remote_audience<'a>(
config: &'a crate::server::ServerConfig,
runtime_id: &str,
) -> Result<&'a str, WorkerMutationSourceProofError> {
workspace_id: &str,
) -> Result<std::borrow::Cow<'a, str>, WorkerMutationSourceProofError> {
if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID {
return Ok(std::borrow::Cow::Owned(format!("embedded:{workspace_id}")));
}
config
.remote_runtime_sources
.iter()
.find(|runtime| runtime.runtime_id == runtime_id)
.and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref())
.map(|auth| auth.server_id.as_str())
.map(|auth| std::borrow::Cow::Borrowed(auth.server_id.as_str()))
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)
}
+104 -124
View File
@@ -1,4 +1,3 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use chrono::{SecondsFormat, Utc};
@@ -6,6 +5,9 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use workspace_api::{RepositoryObservedStatus, RepositorySource};
use crate::repository_source::{parse_repository_source, repository_source_fingerprint};
use crate::store::{
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
};
@@ -52,6 +54,10 @@ impl WorkspaceCatalogService {
Self { store }
}
pub fn is_empty(&self) -> Result<bool> {
Ok(self.store.list_workspaces()?.is_empty())
}
pub fn list(
&self,
owner_account_id: Option<&str>,
@@ -74,33 +80,16 @@ impl WorkspaceCatalogService {
pub fn create(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
owner_account_id: String,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, owner_account_id, None, false)
}
pub fn create_first_ownerless(
&self,
request: WorkspaceCreateRequest,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, None, None, true)
}
pub fn create_with_workspace_id(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
requested_workspace_id: Option<String>,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, owner_account_id, requested_workspace_id, false)
self.create_internal(request, owner_account_id, None)
}
fn create_internal(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
owner_account_id: String,
requested_workspace_id: Option<String>,
require_empty_catalog: bool,
) -> Result<WorkspaceCreateResponse> {
let operation_key = normalize_required(
"operation_key",
@@ -109,8 +98,8 @@ impl WorkspaceCatalogService {
)?;
let display_name =
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
let repository_path = validate_repository_uri(&request.repository.uri)?;
let repository_uri = repository_path.to_string_lossy().into_owned();
let repository_source = validate_repository_source(&request.repository.uri)?;
let repository_uri = repository_source.uri.clone();
let repository_name = request
.repository
.display_name
@@ -140,7 +129,7 @@ impl WorkspaceCatalogService {
let fingerprint = workspace_create_fingerprint(
requested_workspace_id.as_deref(),
&display_name,
owner_account_id.as_deref(),
Some(&owner_account_id),
&repository_uri,
&repository_name,
&default_ref,
@@ -151,10 +140,10 @@ impl WorkspaceCatalogService {
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
operation_key,
request_fingerprint: fingerprint.clone(),
require_empty_catalog,
require_empty_catalog: false,
workspace: WorkspaceRecord {
workspace_id: workspace_id.clone(),
owner_account_id,
owner_account_id: Some(owner_account_id),
display_name,
state: "active".to_string(),
created_at: now.clone(),
@@ -166,10 +155,12 @@ impl WorkspaceCatalogService {
name: repository_name,
kind: "git".to_string(),
provider: Some("git".to_string()),
uri: repository_uri,
source: repository_source.clone(),
default_ref: Some(default_ref),
auth_ref_kind: None,
auth_ref_key: None,
source_revision: 1,
source_fingerprint: repository_source_fingerprint(&repository_source),
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
created_at: now.clone(),
updated_at: now,
},
@@ -194,35 +185,8 @@ fn normalize_required(field: &str, value: String, max_bytes: usize) -> Result<St
Ok(value.to_string())
}
fn validate_repository_uri(uri: &str) -> Result<PathBuf> {
let uri = uri.trim();
if uri.is_empty() || uri.contains("://") {
return Err(Error::InvalidInput(
"initial repository uri must be an absolute server-local path".to_string(),
));
}
let path = Path::new(uri);
if !path.is_absolute() {
return Err(Error::InvalidInput(
"initial repository uri must be an absolute server-local path".to_string(),
));
}
let path = path.canonicalize().map_err(|error| {
Error::InvalidInput(format!("initial repository path is unavailable: {error}"))
})?;
if !path.is_dir() {
return Err(Error::InvalidInput(
"initial repository path must be a directory".to_string(),
));
}
let normal_git = path.join(".git").exists();
let bare_git = path.join("HEAD").is_file() && path.join("objects").is_dir();
if !normal_git && !bare_git {
return Err(Error::InvalidInput(
"initial repository path is not a Git repository".to_string(),
));
}
Ok(path)
fn validate_repository_source(uri: &str) -> Result<RepositorySource> {
parse_repository_source(uri)
}
fn workspace_create_fingerprint(
@@ -258,7 +222,8 @@ fn workspace_create_fingerprint(
#[cfg(test)]
mod tests {
use super::*;
use crate::store::SqliteWorkspaceStore;
use crate::store::{AccountRecord, SqliteWorkspaceStore};
use workspace_api::RepositorySourceKind;
fn git_repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
@@ -266,6 +231,22 @@ mod tests {
dir
}
fn owner_account(store: &SqliteWorkspaceStore) -> String {
let account_id = Uuid::now_v7().to_string();
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
store
.upsert_account(&AccountRecord {
account_id: account_id.clone(),
kind: "user".to_string(),
handle: format!("owner-{}", &account_id[..8]),
display_name: "Workspace Owner".to_string(),
created_at: now.clone(),
updated_at: now,
})
.unwrap();
account_id
}
#[tokio::test]
async fn create_is_atomic_and_exact_retries_converge() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
@@ -281,8 +262,11 @@ mod tests {
},
};
let created = service.create(request.clone(), None).unwrap();
let replayed = service.create(request, None).unwrap();
let owner_account_id = owner_account(store.as_ref());
let created = service
.create(request.clone(), owner_account_id.clone())
.unwrap();
let replayed = service.create(request, owner_account_id).unwrap();
assert!(!created.replayed);
assert!(replayed.replayed);
@@ -306,64 +290,10 @@ mod tests {
);
}
#[test]
fn concurrent_ownerless_bootstrap_commits_exactly_one_workspace() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let service = WorkspaceCatalogService::new(store.clone());
let repository_a = git_repository();
let repository_b = git_repository();
let requests = [
WorkspaceCreateRequest {
operation_key: "bootstrap-a".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository_a.path().display().to_string(),
display_name: None,
default_ref: None,
},
},
WorkspaceCreateRequest {
operation_key: "bootstrap-b".to_string(),
display_name: "Workspace B".to_string(),
repository: InitialRepositoryIntent {
uri: repository_b.path().display().to_string(),
display_name: None,
default_ref: None,
},
},
];
let barrier = Arc::new(std::sync::Barrier::new(2));
let results = std::thread::scope(|scope| {
requests
.into_iter()
.map(|request| {
let service = service.clone();
let barrier = barrier.clone();
scope.spawn(move || {
barrier.wait();
service.create_first_ownerless(request)
})
})
.collect::<Vec<_>>()
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>()
});
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
let error = results
.into_iter()
.find_map(Result::err)
.unwrap()
.to_string();
assert!(error.contains("catalog is empty"), "{error}");
}
#[tokio::test]
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store);
let repository = git_repository();
let mut request = WorkspaceCreateRequest {
@@ -375,20 +305,70 @@ mod tests {
default_ref: None,
},
};
service.create(request.clone(), None).unwrap();
service
.create(request.clone(), owner_account_id.clone())
.unwrap();
request.display_name = "Workspace B".to_string();
let error = service.create(request, None).unwrap_err().to_string();
let error = service
.create(request, owner_account_id)
.unwrap_err()
.to_string();
assert!(error.contains("different input"), "{error}");
}
#[test]
fn repository_intent_rejects_remote_and_non_git_paths() {
let remote = validate_repository_uri("https://example.test/repo.git").unwrap_err();
assert!(remote.to_string().contains("server-local path"));
fn repository_intent_accepts_unavailable_local_sources_without_server_io() {
let remote = validate_repository_source("https://example.test/repo.git").unwrap();
assert_eq!(remote.kind, RepositorySourceKind::Https);
let dir = tempfile::tempdir().unwrap();
let non_git = validate_repository_uri(&dir.path().display().to_string()).unwrap_err();
assert!(non_git.to_string().contains("not a Git repository"));
let local = validate_repository_source("/runtime-only/missing/repository").unwrap();
assert_eq!(local.kind, RepositorySourceKind::LocalPath);
assert_eq!(local.uri, "/runtime-only/missing/repository");
let file = validate_repository_source("file:///runtime-only/missing/repository").unwrap();
assert_eq!(file.kind, RepositorySourceKind::File);
assert!(validate_repository_source("relative/repository").is_err());
}
#[test]
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store.clone());
let result = service
.create(
WorkspaceCreateRequest {
operation_key: "remote-create".to_string(),
display_name: "Remote Workspace".to_string(),
repository: InitialRepositoryIntent {
uri: "ssh://git@example.test/org/repository.git".to_string(),
display_name: Some("Remote Repository".to_string()),
default_ref: Some("main".to_string()),
},
},
owner_account_id,
)
.unwrap();
let persisted = store
.get_repository(
&result.workspace.workspace_id,
&result.repository.repository_id,
)
.unwrap()
.unwrap();
assert_eq!(persisted.source.kind, RepositorySourceKind::Ssh);
assert_eq!(persisted.source_revision, 1);
assert!(persisted.source_fingerprint.starts_with("sha256:"));
assert_eq!(
persisted.observed_status,
RepositoryObservedStatus::Unverified
);
let json = serde_json::to_value(&persisted).unwrap();
assert!(json.get("source").is_some());
assert!(json.get("auth_ref_kind").is_none());
assert!(json.get("auth_ref_key").is_none());
}
}
+3 -1
View File
@@ -264,6 +264,8 @@ in
"serve"
"--listen"
"0.0.0.0:8787"
"--config"
"/server-config/server.toml"
];
Env = [
"PATH=/bin"
@@ -274,7 +276,7 @@ in
};
Volumes = {
"/server-data" = { };
"/workspace" = { };
"/server-config" = { };
};
WorkingDir = "/server-data";
};
+9 -2
View File
@@ -58,12 +58,19 @@ The Compose files live at:
```text
compose.yaml
docker/workspace/.yoi/workspace.toml
docker/workspace/.yoi/workspace-backend.local.toml
```
The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `yoi-runtime` and owns Worker spawning/materialization for that runtime.
The operator-owned `/etc/yoi/server.toml` is mounted read-only at `/server-config/server.toml`. Its `browser.public_url` is the single browser-facing setting used for WebAuthn, device-login URLs, cookie policy, and cookie-authenticated mutation origin checks:
```toml
[browser]
public_url = "https://yoi.example.com"
```
Create this host file before starting Compose and set it to the exact Nginx-facing origin. It is not an API/backend URL and must not include a path, query, or fragment. It is deployment topology outside the source and Workspace repositories, not Workspace DB state or repository-local configuration.
Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly.
## Worker launch path
+1 -5
View File
@@ -153,11 +153,7 @@ For repository builds:
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
```
If the Server DB has no workspace record yet, initialize it first:
```bash
yoi-server init --workspace <WORKSPACE_ROOT>
```
An empty Server DB is valid. Open the Web UI, create or authenticate the Account, and register the first Workspace through the normal Workspace creation flow. Server startup does not create a Workspace from its current working directory or repository-local configuration.
## Smoke checks
+1 -1
View File
@@ -242,7 +242,7 @@ Implementation normally happens in a child git worktree created by the Orchestra
### 5. Review
The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with read-only scope and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval.
The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with write scope, so it can use the Workdir command tools required for inspection and validation, and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval.
The Reviewer records the structured result with `MergeRequestReview`. Request changes requires a new immutable revision and a fresh child attempt. The Orchestrator uses `MergeRequestReadinessCheck` and then `MergeRequestComplete` for guarded integration with operation-id dedupe/CAS semantics; Flow transitions are not completion authority.
+2 -2
View File
@@ -15,7 +15,7 @@
};
review = {
instructions = "Use the current Ticket Merge Request as review authority. Confirm its immutable source selector resolves to the exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, read-only scope, and a structured review handoff bound to the current immutable Merge Request revision. The trusted spawn layer records `ReviewRequested`; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit MergeRequestReview; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition.";
instructions = "Use the current Ticket Merge Request as review authority. Confirm its immutable source selector resolves to the exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope for Workdir inspection and command validation, and a structured review handoff bound to the current immutable Merge Request revision. The trusted spawn layer records `ReviewRequested`; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit MergeRequestReview; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition.";
transitions = {
approved = {
target = "complete";
@@ -29,7 +29,7 @@
};
fix = {
instructions = "Resolve every open Reviewer finding on the same Ticket work branch, rerun the validation affected by the fixes, commit the corrected implementation as a new revision, and preserve concrete evidence. Publish only the updated Ticket work branch with a normal non-force push, verify that the configured repository provider resolves the published source ref to the exact new HEAD, and update the linked Merge Request so its current revision records that same subject. Request review from a fresh read-only Reviewer child so the trusted spawn layer captures the new immutable subject. Do not rewrite the previously reviewed commit, claim approval from the prior request_changes review, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected committed revision is published and ready for a new independent review.";
instructions = "Resolve every open Reviewer finding on the same Ticket work branch, rerun the validation affected by the fixes, commit the corrected implementation as a new revision, and preserve concrete evidence. Publish only the updated Ticket work branch with a normal non-force push, verify that the configured repository provider resolves the published source ref to the exact new HEAD, and update the linked Merge Request so its current revision records that same subject. Request review from a fresh Reviewer child with write scope so it can use the Workdir command tools required for inspection and validation while the trusted spawn layer captures the new immutable subject. Do not rewrite the previously reviewed commit, claim approval from the prior request_changes review, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected committed revision is published and ready for a new independent review.";
transitions = {
review = {
target = "review";
+1 -1
View File
@@ -4,6 +4,6 @@ Use the available typed Ticket tools as the authority for Ticket reads and mutat
Read the relevant Ticket before making implementation, routing, review, state, or closure decisions. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work.
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements.
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. For implementation and review workflows, keep routine revision, verdict, fix, and rereview evidence on the Merge Request; use Ticket comments only for blockers or decisions requiring orchestration attention and the final approved handoff. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements.
Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish implementation completion from review and closure, and perform only lifecycle actions supported by the tools and authority available to the current Worker.
+3 -1
View File
@@ -1,11 +1,13 @@
You are the assigned Coder. Implement the requested scope in the provided Workdir and keep durable evidence on the Ticket and its Merge Request.
Use the Merge Request as the routine authority for review requests, verdicts, fixes, and rereview cycles. Do not add a Ticket comment for each review or fix iteration. Add a Ticket comment only when a blocker or decision requires Orchestrator attention, or once after approval to hand off the final implementation and validation evidence.
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose.
Before opening a Merge Request, publish only the committed Ticket work branch with a normal non-force push and verify that the Ticket repository remote resolves it to the exact local `HEAD`; a local branch name or dirty Workdir is not immutable review evidence. Do not push the target branch, tags, or unrelated refs, and never force-push.
{% include "common.git" %}
Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReview` through its injected capability authority.
Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate write scope so it can use the Workdir command tools required for inspection and validation, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReview` through its injected capability authority.
A request-changes result requires a freshly published immutable subject and a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. After the exact current Merge Request subject has authoritative approval, keep that source ref immutable, leave concise implementation evidence on the Ticket when useful, and hand off integration to the Orchestrator. Do not update the target selector. Do not call `MergeRequestComplete`.
+1 -1
View File
@@ -1,6 +1,6 @@
You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder.
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, update a repository ref, or take over implementation.
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation.
Your prose response is not review authority. Before finishing, call `MergeRequestReview` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work.
-65
View File
@@ -1,65 +0,0 @@
# Workspace Backend local config template.
#
# `yoi-server init` copies this packaged template to
# `.yoi/workspace-backend.local.toml` without overwriting an existing file.
# The `.local` file is intentionally git-ignored.
#
# Print the latest packaged template with:
# yoi-server config default
#
# Compare the local config with the latest packaged template with:
# yoi-server config diff
#
# Omit a key to use the built-in fallback. TOML has no `null`, so optional
# settings are represented by leaving the key commented out.
[server]
# Backend HTTP/WebSocket listen address.
listen = "127.0.0.1:8787"
# Browser-facing frontend URL used by local tooling/display.
frontend_url = "http://127.0.0.1:5173"
# Static SPA build directory override. Leave commented for dev/API-only mode.
# Relative paths are resolved from the workspace root.
# static_assets_dir = "web/workspace/dist"
[data]
# Workspace-scoped runtime/data root override. Leave commented to use the user-data fallback:
# <data_dir>/server/workspaces/<workspace_id>/
# Relative paths are resolved from the workspace root.
# root = ".yoi/workspace-backend.data"
# Explicit control-plane SQLite DB path override. Normal `serve` uses the Yoi
# server DB at `<data_dir>/server/server.db`; keep this commented unless a
# local test needs a custom DB path.
# workspace_database_path = ".yoi/workspace-backend.data/server.db"
# Explicit embedded Runtime fs-store root override.
# If omitted, this falls back to `<data.root>/embedded-runtime`.
# embedded_runtime_store_root = ".yoi/workspace-backend.data/embedded-runtime"
[limits]
max_records = 200
[auth]
# WebAuthn / Passkey relying-party settings. For local development keep rp_id
# aligned with the browser host in public_base_url/origin.
rp_id = "localhost"
origin = "http://localhost:8787"
public_base_url = "http://localhost:8787"
cookie_name = "yoi_workspace_session"
# Repository registry. Browser/API repository projection reads only configured
# entries and never falls back to the backend process cwd. Relative URI values
# are resolved from this workspace config root. Git is the v0 supported provider.
#
# [[repositories]]
# id = "main"
# provider = "git"
# uri = "."
# display_name = "Main repository"
# default_selector = "HEAD"
# Runtime registrations live in `$XDG_CONFIG_HOME/yoi/runtimes.toml`
# or `YOI_CONFIG_DIR/runtimes.toml`, not in workspace backend config.
@@ -21,7 +21,7 @@ export type TicketRoleAssignmentSummary = { assignment_id: string, role: string,
export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, };
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, blockers: Array<string>, };
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, queue_tickets: Array<string>, blockers: Array<string>, };
export type TicketMergeRequestSummary = { merge_request_id: string, repository_id: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
@@ -7,13 +7,29 @@ export type WorkspaceCatalogRecord = {
updated_at: string;
};
export type RepositorySourceKind =
| "local_path"
| "file"
| "ssh"
| "http"
| "https"
| "invalid";
export type WorkspaceRepositoryRecord = {
workspace_id: string;
repository_id: string;
name: string;
kind: string;
provider: string | null;
source: {
kind: RepositorySourceKind;
uri: string;
};
default_ref: string | null;
source_revision: number;
source_fingerprint: string;
observed_status: "unverified" | "ready" | "invalid";
observed_at: string | null;
};
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
@@ -115,14 +115,12 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
"top workspace page should not own the Worker list",
);
assert(
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
workersPage.includes('<table class="workers-table">') &&
workersPage.includes(
"workerDisplayName = worker.display_name || worker.label",
) &&
workersPage.includes("worker <code>{worker.worker_id}</code>") &&
workersPage.includes("workerHref") &&
workersPage.includes("workers-table") &&
workersPage.includes("workerDisplayName") &&
workersPage.includes("worker.resource_key") &&
workersPage.includes("Delete ${workerDisplayName}"),
"dedicated Workers page should expose a table, console link target, and icon actions per Worker",
"dedicated Workers page should expose a table, canonical Worker link target, and icon actions per Worker",
);
assert(
workersNav.includes("href={`/w/${workspaceId}/workers`}") &&
@@ -218,14 +216,16 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
"Tickets and Objectives should each be a single sidebar link",
);
assert(
ticketsLoad.includes("?limit=1000") &&
ticketsLoad.includes("Object.entries(LANE_STATES)") &&
ticketsLoad.includes('limit: "30"') &&
ticketsLoad.includes('states: states.join(",")') &&
ticketsLoad.includes("/tickets?${search}") &&
!ticketsLoad.includes("/tickets/query") &&
ticketsPage.includes('class="ticket-kanban"') &&
ticketsPage.includes('class="ticket-lane-cards"') &&
ticketsPage.includes("lane.tickets.slice(0, lane.visibleCount)") &&
ticketsPage.includes("handleLaneScroll") &&
ticketsPage.includes("revealNextTickets"),
"Tickets list should fetch lightweight summaries once and incrementally reveal each Kanban lane",
ticketsPage.includes("laneState") &&
ticketsPage.includes("loadMore(lane.id)") &&
ticketsPage.includes("handleLaneScroll(event, lane.id)"),
"Tickets list should fetch lightweight paginated summaries for each Kanban lane",
);
assert(
ticketPanelModel.includes('label: "Ready + Planning"') &&
@@ -250,16 +250,17 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
assert(
ticketDetailLoad.includes("/repositories") &&
ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes('mutate("queue", "/queue"') &&
ticketDetailPage.includes("async function queueTicket") &&
ticketDetailPage.includes("`${ticketPath}/queue`") &&
!ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("mergeRequest.selector_from") &&
ticketDetailPage.includes("currentReview?.kind") &&
ticketDetailPage.includes("mergeEvent?.kind") &&
!ticketDetailPage.includes('mutate("review", "/review"') &&
ticketDetailPage.includes("mergeRequest.review_status") &&
ticketDetailPage.includes("mergeRequestPagePath") &&
ticketDetailPage.includes('mutate("close", "/close"') &&
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
ticketDetailPage.includes("mutateAssignment") &&
ticketDetailPage.includes("can_start_manual_coder") &&
ticketDetailPage.includes("ticket.relations.outgoing"),
"Ticket detail should expose typed lifecycle actions, relations, target selection, and role Worker launch",
"Ticket detail should expose typed lifecycle actions, relations, target selection, assignments, and Merge Request navigation",
);
});
@@ -319,17 +320,19 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
);
});
Deno.test("root layout does not keep legacy unscoped route compatibility", async () => {
Deno.test("root layout keeps Workspace selection explicit", async () => {
const layoutLoad = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url),
);
assert(
layoutLoad.includes("export const load") &&
layoutLoad.includes("() => ({})") &&
!layoutLoad.includes("scopedCompatibilityRoute") &&
!layoutLoad.includes('pathname === "/runtimes"') &&
!layoutLoad.includes("return workspaceRoute(workspaceId, pathname)") &&
layoutLoad.includes("workspaceRoute(workspace.data.workspace_id)"),
"root layout should bootstrap the workspace entry only, not preserve legacy unscoped routes",
!layoutLoad.includes("/api/workspace") &&
!layoutLoad.includes("workspaceRoute") &&
!layoutLoad.includes("redirect("),
"root layout should not infer, bootstrap, or redirect through a singleton Workspace",
);
});
@@ -811,9 +814,11 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"SidebarOverride should register and clean up the child-provided sidebar snippet",
);
assert(
rootLayoutLoad.includes('"/account"') &&
rootLayoutLoad.includes('"/login/device"'),
"Root layout should not redirect account and device-login public routes to a workspace",
rootLayoutLoad.includes("export const load") &&
rootLayoutLoad.includes("() => ({})") &&
!rootLayoutLoad.includes("workspaceRoute") &&
!rootLayoutLoad.includes("redirect("),
"Root layout should leave account and device-login routes public by avoiding Workspace redirects entirely",
);
});
@@ -32,7 +32,7 @@ export type RuntimeConnectionSummary = {
built_in: boolean;
config_managed: boolean;
active: boolean;
can_spawn_worker: boolean;
worker_creation_available: boolean;
restart_required: boolean;
status: string;
diagnostics: Diagnostic[];
@@ -29,30 +29,15 @@ export type Diagnostic = {
message: string;
};
export type RuntimeCapabilities = {
can_list_hosts: boolean;
can_list_workers: boolean;
can_get_worker: boolean;
can_spawn_worker: boolean;
can_stop_worker: boolean;
has_workspace_fs: boolean;
has_shell: boolean;
has_git: boolean;
supports_worktrees: boolean;
supports_backend_internal_tools: boolean;
workspace_scope: string;
os: string;
arch: string;
max_workers: number;
};
export type Runtime = {
runtime_id: string;
label: string;
kind: string;
status: string;
host_ids: string[];
capabilities: RuntimeCapabilities;
worker_creation_available: boolean;
os: string;
arch: string;
diagnostics: Diagnostic[];
};
@@ -64,7 +49,8 @@ export type Host = {
status: string;
observed_at: string;
last_seen_at: string | null;
capabilities: RuntimeCapabilities;
os: string;
arch: string;
diagnostics: Diagnostic[];
};
@@ -100,7 +86,7 @@ export type WorkerLaunchRuntimeOption = {
runtime_id: string;
display_name: string;
built_in: boolean;
can_spawn_worker: boolean;
worker_creation_available: boolean;
working_directory_required: boolean;
status: string;
diagnostics: Diagnostic[];
@@ -269,6 +255,14 @@ export type RepositorySummary = {
display_name: string;
kind: string;
provider: string;
source: {
kind: "local_path" | "file" | "ssh" | "http" | "https" | "invalid";
uri: string;
};
source_revision: number;
source_fingerprint: string;
observed_status: "unverified" | "ready" | "invalid";
observed_at?: string | null;
default_selector?: string | null;
record_authority: string;
git?: GitRepositorySummary | null;
@@ -23,7 +23,7 @@ const options: WorkerLaunchOptionsResponse = {
runtime_id: "remote",
display_name: "Remote",
status: "active",
can_spawn_worker: true,
worker_creation_available: true,
built_in: false,
working_directory_required: true,
diagnostics: [],
@@ -32,7 +32,7 @@ const options: WorkerLaunchOptionsResponse = {
runtime_id: "embedded",
display_name: "Embedded",
status: "active",
can_spawn_worker: true,
worker_creation_available: true,
built_in: true,
working_directory_required: false,
diagnostics: [],
@@ -30,9 +30,9 @@ export function defaultWorkerLaunchForm(
): WorkerLaunchFormState {
const preferredRuntime =
options?.runtimes.find((runtime) =>
runtime.can_spawn_worker && runtime.status === "active"
runtime.worker_creation_available && runtime.status === "active"
) ??
options?.runtimes.find((runtime) => runtime.can_spawn_worker) ??
options?.runtimes.find((runtime) => runtime.worker_creation_available) ??
options?.runtimes[0];
const preferredProfile = options?.profiles.find((candidate) =>
candidate.id === options.default_profile
@@ -119,6 +119,14 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
);
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
assertEquals(source.includes("ticket.relations.blockers.length > 0"), true);
assertEquals(source.includes("ticket.action_eligibility.queue_tickets"), true);
assertEquals(source.includes("This operation queues:"), true);
assertEquals(source.includes("outcome.queued_tickets.join"), true);
assertEquals(
source.includes("resolve the listed blockers before Queue"),
false,
);
assertEquals(
source.includes("ticket.action_eligibility.can_assign_orchestrator"),
true,
+1 -2
View File
@@ -155,8 +155,7 @@
<p class="workspace-catalog-eyebrow">New team space</p>
<h2 id="workspace-create-title">Create Workspace</h2>
<p>
Repository paths and URIs are interpreted by the Backend. Browser-local paths are
not authority.
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
</p>
</div>
<form onsubmit={submitCreation}>
@@ -88,7 +88,7 @@
</div>
<div>
<dt>Platform</dt>
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
<dd>{host.os} / {host.arch}</dd>
</div>
</dl>
</article>
@@ -16,7 +16,7 @@
<div>
<h3>{data.repository.item.display_name}</h3>
</div>
<span class="status-pill" class:warn={data.repository.item.git?.status !== 'clean'}>{data.repository.item.git?.status ?? 'not observed'}</span>
<span class="status-pill" class:warn={data.repository.item.observed_status !== 'ready'}>{data.repository.item.observed_status}</span>
</div>
<dl>
<div>
@@ -27,6 +27,18 @@
<dt>Provider</dt>
<dd>{data.repository.item.provider}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{data.repository.item.source.kind} · {data.repository.item.source.uri}</dd>
</div>
<div>
<dt>Source revision</dt>
<dd>{data.repository.item.source_revision} · {data.repository.item.source_fingerprint}</dd>
</div>
<div>
<dt>Observed</dt>
<dd>{data.repository.item.observed_at ?? 'not observed'}</dd>
</div>
<div>
<dt>Record authority</dt>
<dd>{data.repository.item.record_authority}</dd>
@@ -183,7 +183,7 @@
built_in: true,
config_managed: false,
active: false,
can_spawn_worker: false,
worker_creation_available: false,
restart_required: false,
status: 'unknown',
diagnostics: []
@@ -5,7 +5,7 @@
let { data }: PageProps = $props();
function runtimePlatform(runtime: Runtime): string {
return `${runtime.capabilities.os} / ${runtime.capabilities.arch}`;
return `${runtime.os} / ${runtime.arch}`;
}
</script>
@@ -37,7 +37,6 @@
<th>Kind</th>
<th>Status</th>
<th>Platform</th>
<th>Capacity</th>
<th>Workdirs</th>
</tr>
</thead>
@@ -51,7 +50,6 @@
<td>{runtime.kind}</td>
<td>{runtime.status}</td>
<td>{runtimePlatform(runtime)}</td>
<td>{runtime.capabilities.max_workers} workers</td>
<td>
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
Open workdirs
@@ -38,6 +38,11 @@
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
const loadedRepositories = initialData.repositories.data;
type QueueOutcome = {
requested_ticket: string;
queued_tickets: string[];
};
let ticket = $state<TicketDetail>(loadedTicket);
const mergeRequest = $derived(ticket.merge_request);
let editing = $state(false);
@@ -52,9 +57,14 @@
let resolution = $state("");
let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let queueMessage = $state<string | null>(null);
let readyOperationKey = $state<string | null>(null);
let manualRuntimeId = $state("");
let manualWorkerId = $state("");
let cancellationReason = $state("");
const coderAssignment = $derived(
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
);
const selectedRepository = $derived(
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
);
@@ -117,6 +127,25 @@
}
}
async function queueTicket(): Promise<void> {
if (busy) return;
busy = "queue";
errorMessage = null;
queueMessage = null;
try {
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
`${ticketPath}/queue`,
{ method: "POST", body: JSON.stringify({}) },
);
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
} finally {
busy = null;
}
}
async function mutateAssignment(
action: string,
role: "orchestrator" | "coder",
@@ -162,6 +191,18 @@
});
}
async function cancelImplementation(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (!coderAssignment || !cancellationReason.trim()) return;
if (
await mutate("cancel-implementation", "/implementation-cancellations", {
operation_id: crypto.randomUUID(),
assignment_id: coderAssignment.assignment_id,
reason: cancellationReason.trim(),
})
) cancellationReason = "";
}
async function saveEdit(event: SubmitEvent) {
event.preventDefault();
if (
@@ -272,6 +313,10 @@
<div class="workspace-callout is-error" role="alert">{errorMessage}</div>
{/if}
{#if queueMessage}
<div class="workspace-callout" role="status">{queueMessage}</div>
{/if}
{#if editing}
<form class="ticket-editor" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required /></label>
@@ -416,6 +461,24 @@
</button>
</form>
{/if}
{#if ticket.state === "inprogress" && coderAssignment}
<details class="ticket-cancel-implementation">
<summary>Cancel implementation</summary>
<form class="ticket-control-form" onsubmit={cancelImplementation}>
<p class="workspace-empty-copy">
Cancel the assigned Coder, remove its assignment, and return this Ticket to ready.
</p>
<label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label>
<button
class="workspace-danger-button"
type="submit"
disabled={busy !== null || !cancellationReason.trim()}
>
{busy === "cancel-implementation" ? "Cancelling…" : "Cancel and return to ready"}
</button>
</form>
</details>
{/if}
{#if ticket.assignment_diagnostics.length > 0}
{#each ticket.assignment_diagnostics as diagnostic}
<p class="workspace-callout">{diagnostic}</p>
@@ -462,11 +525,16 @@
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
{/if}
{:else if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => mutate("queue", "/queue", {})}>
{busy === "queue" ? "Queueing…" : "Queue ticket"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>
{busy === "queue" ? "Queueing…" : `Queue ${ticket.action_eligibility.queue_tickets.length} Ticket(s)`}
</button>
{#if !ticket.action_eligibility.can_queue}
<p class="workspace-empty-copy">Assign the Orchestrator role and resolve the listed blockers before Queue.</p>
<p class="workspace-empty-copy">Queue requires a valid target, an active Orchestrator assignment, no active Coder assignment, and no dependency still in planning.</p>
{:else if ticket.action_eligibility.queue_tickets.length > 0}
<p class="workspace-empty-copy">This operation queues: {ticket.action_eligibility.queue_tickets.join(", ")}.</p>
{#if ticket.relations.blockers.length > 0}
<p class="workspace-empty-copy">Ready dependencies are queued atomically. Queued or in-progress dependencies remain unchanged for the Orchestrator to schedule.</p>
{/if}
{/if}
{/if}
</section>
@@ -313,7 +313,7 @@
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
{#if options?.runtimes.length}
{#each options.runtimes as runtime}
<option value={runtime.runtime_id} disabled={!runtime.can_spawn_worker}>
<option value={runtime.runtime_id} disabled={!runtime.worker_creation_available}>
{runtime.display_name}
</option>
{/each}
+3
View File
@@ -5,6 +5,9 @@ export default defineConfig({
plugins: [sveltekit()],
server: {
host: "localhost",
port: 5173,
strictPort: true,
allowedHosts: ["develop.hareworks.net"],
watch: {
ignored: [