merge: integrate orchestration merge request lifecycle

# Conflicts:
#	resources/flows/coder-review.dcdl
This commit is contained in:
2026-08-12 18:30:08 +09:00
61 changed files with 13864 additions and 1248 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ Intake は以下を行う。
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
- `TicketDoctor`: 必要に応じた整合性確認。
Intake は `TicketReview`, `TicketWorkflowState`, `TicketClose` を通常使わない。review / state transition / close は Orchestrator または reviewer / maintainer workflow の責務である。
Intake は `MergeRequest*`, `TicketWorkflowState`, `TicketClose` を通常使わない。review authority は assigned Coder が起動した read-only direct-child Reviewer の immutable Merge Request attempt に属し、completion / merge / close は各guarded workflowの責務である。
Ticket tools が利用できない環境では、勝手に file write で代替しない。ユーザーまたは Orchestrator に「Ticket tools がないため materialize できない」と報告し、必要なら `yoi ticket` を使える人間/親 workflow に戻す。
Generated
+12
View File
@@ -2481,6 +2481,17 @@ dependencies = [
"uuid",
]
[[package]]
name = "merge-request"
version = "0.1.0"
dependencies = [
"rusqlite",
"serde",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -6164,6 +6175,7 @@ dependencies = [
"futures",
"manifest",
"memory",
"merge-request",
"project-record",
"protocol",
"reqwest",
+3
View File
@@ -23,6 +23,7 @@ members = [
"crates/tui",
"crates/memory",
"crates/ticket",
"crates/merge-request",
"crates/project-record",
"crates/workspace-server",
"tests/e2e",
@@ -50,6 +51,7 @@ default-members = [
"crates/tui",
"crates/memory",
"crates/ticket",
"crates/merge-request",
"crates/project-record",
"crates/workspace-server",
]
@@ -67,6 +69,7 @@ manifest = { path = "crates/manifest" }
mcp = { path = "crates/mcp" }
lint-common = { path = "crates/lint-common" }
memory = { path = "crates/memory" }
merge-request = { path = "crates/merge-request" }
ticket = { path = "crates/ticket" }
project-record = { path = "crates/project-record" }
worker = { path = "crates/worker" }
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "merge-request"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] }
sha2.workspace = true
thiserror.workspace = true
[dev-dependencies]
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+404
View File
@@ -0,0 +1,404 @@
use merge_request::*;
use rusqlite::{Connection, params};
use tempfile::TempDir;
fn setup() -> (TempDir, SqliteMergeRequestStore) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
let conn = Connection::open(&path).unwrap();
conn.execute_batch(r#"
PRAGMA foreign_keys=ON;
CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id));
CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));
CREATE TABLE typed_ticket_events(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,event_index INTEGER NOT NULL,kind TEXT NOT NULL,author TEXT,at TEXT,status TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));
CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,event_index INTEGER NOT NULL,key TEXT NOT NULL,value TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,event_index,key));
CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,assignment_id));
CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));
"#).unwrap();
for ws in ["ws-a", "ws-b"] {
conn.execute("INSERT INTO repositories VALUES(?1,'repo')", params![ws])
.unwrap();
conn.execute(
"INSERT INTO typed_tickets VALUES(?1,'T1','inprogress',1,'t0')",
params![ws],
)
.unwrap();
conn.execute(
"INSERT INTO ticket_worker_assignments VALUES(?1,'T1','A1','R1','W1')",
params![ws],
)
.unwrap();
conn.execute(
"INSERT INTO ticket_current_worker_assignments VALUES(?1,'T1','A1','R1','W1')",
params![ws],
)
.unwrap();
}
drop(conn);
let store = SqliteMergeRequestStore::open(&path, "ws-a").unwrap();
(dir, store)
}
fn revision(id: &str, ordinal: u64, head: &str) -> MergeRequestRevision {
MergeRequestRevision {
revision_id: id.into(),
ordinal,
base_commit: "base".into(),
head_commit: head.into(),
head_tree: format!("tree-{head}"),
diff_digest: format!("sha256:diff-{head}"),
changed_paths: vec!["src/lib.rs".into()],
summary: format!("revision {id}"),
assignment_id: "A1".into(),
created_at: format!("t{ordinal}"),
}
}
fn open(store: &SqliteMergeRequestStore) {
store
.open_merge_request(OpenMergeRequest {
merge_request_id: "MR1".into(),
ticket_id: "T1".into(),
repository_id: "repo".into(),
revision: revision("V1", 1, "h1"),
authenticated_runtime_id: "R1".into(),
authenticated_worker_id: "W1".into(),
now: "t1".into(),
})
.unwrap();
}
fn attempt(store: &SqliteMergeRequestStore, id: &str, revision: &str, token: &str, child: &str) {
store
.register_reviewer_child_session(RegisterReviewerChildSession {
parent_runtime_id: "R1".into(),
parent_worker_id: "W1".into(),
child_session_id: child.into(),
now: "t".into(),
})
.unwrap();
store
.register_review_attempt(RegisterReviewAttempt {
attempt_id: id.into(),
ticket_id: "T1".into(),
revision_id: revision.into(),
parent_assignment_id: "A1".into(),
parent_runtime_id: "R1".into(),
parent_worker_id: "W1".into(),
child_session_id: child.into(),
capability_token: token.into(),
now: "t".into(),
})
.unwrap();
}
fn review(
store: &SqliteMergeRequestStore,
revision: &str,
token: &str,
decision: ReviewDecision,
) -> Result<MergeRequestReview> {
store.submit_review(SubmitReview {
ticket_id: "T1".into(),
revision_id: revision.into(),
capability_token: token.into(),
decision,
body: "evidence".into(),
findings: vec![],
now: "tr".into(),
})
}
#[test]
fn storage_allows_multiple_merge_requests_for_one_ticket() {
let (_dir, store) = setup();
open(&store);
store
.open_merge_request(OpenMergeRequest {
merge_request_id: "MR2".into(),
ticket_id: "T1".into(),
repository_id: "repo".into(),
revision: revision("V2", 1, "h2"),
authenticated_runtime_id: "R1".into(),
authenticated_worker_id: "W1".into(),
now: "t2".into(),
})
.unwrap();
let conn = Connection::open(store.db_path()).unwrap();
let count:i64=conn.query_row("SELECT COUNT(*) FROM merge_request_ticket_relations WHERE workspace_id='ws-a' AND ticket_id='T1'",[],|row|row.get(0)).unwrap();
assert_eq!(count, 2);
assert_eq!(
store
.show_for_ticket("T1")
.unwrap()
.unwrap()
.merge_request_id,
"MR2"
);
}
#[test]
fn bounded_context_rejects_oversized_revision_evidence() {
let (_dir, store) = setup();
let mut oversized = revision("V1", 1, "h1");
oversized.changed_paths = (0..=1_000).map(|i| format!("src/{i}.rs")).collect();
let result = store.open_merge_request(OpenMergeRequest {
merge_request_id: "MR1".into(),
ticket_id: "T1".into(),
repository_id: "repo".into(),
revision: oversized,
authenticated_runtime_id: "R1".into(),
authenticated_worker_id: "W1".into(),
now: "t".into(),
});
assert!(matches!(
result,
Err(MergeRequestError::TooLarge {
field: "revision.changed_paths",
..
})
));
}
#[test]
fn rejected_v6_schema_missing_diff_digest_is_archived_before_fresh_v7() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("legacy.db");
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\
INSERT INTO merge_request_schema_migrations(version,name) VALUES(6,'rejected_merge_request_v6');\
CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id));\
CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));\
CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,assignment_id));\
CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL,lifecycle_generation INTEGER NOT NULL,current_revision_id TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id));\
CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id));\
CREATE TABLE merge_request_revisions(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,revision_id TEXT NOT NULL,ordinal INTEGER NOT NULL,base_commit TEXT NOT NULL,head_commit TEXT NOT NULL,head_tree TEXT NOT NULL,assignment_id TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,revision_id));",
).unwrap();
drop(conn);
let store = SqliteMergeRequestStore::open(&path, "ws-a").unwrap();
assert!(store.show_for_ticket("missing").unwrap().is_none());
let conn = Connection::open(&path).unwrap();
let archived: i64 = conn.query_row("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='legacy_v6_merge_requests'",[],|row|row.get(0)).unwrap();
assert_eq!(archived, 1);
for table in [
"merge_request_review_attempts",
"merge_request_completion_operations",
] {
let present: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
params![table],
|row| row.get(0),
)
.unwrap();
assert_eq!(present, 1);
}
}
#[test]
fn request_changes_new_revision_resets_and_exact_completion_replay_converges() {
let (_dir, store) = setup();
open(&store);
attempt(&store, "AT1", "V1", "tok1", "child1");
review(&store, "V1", "tok1", ReviewDecision::RequestChanges).unwrap();
assert_eq!(
store.show_for_ticket("T1").unwrap().unwrap().review_status,
ReviewStatus::ChangesRequested
);
store
.add_revision(AddRevision {
ticket_id: "T1".into(),
expected_current_revision_id: "V1".into(),
revision: revision("V2", 2, "h2"),
authenticated_runtime_id: "R1".into(),
authenticated_worker_id: "W1".into(),
now: "t2".into(),
})
.unwrap();
assert_eq!(
store.show_for_ticket("T1").unwrap().unwrap().review_status,
ReviewStatus::Pending
);
assert!(review(&store, "V1", "tok1", ReviewDecision::Approve).is_err());
attempt(&store, "AT2", "V2", "tok2", "child2");
review(&store, "V2", "tok2", ReviewDecision::Approve).unwrap();
let input = CompleteMergeRequest {
operation_id: "OP1".into(),
ticket_id: "T1".into(),
expected_revision_id: "V2".into(),
assignment_id: "A1".into(),
authenticated_runtime_id: "R1".into(),
authenticated_worker_id: "W1".into(),
now: "tc".into(),
};
let first = store.complete(input.clone()).unwrap();
assert!(!first.replayed);
let replay = store.complete(input).unwrap();
assert!(replay.replayed);
assert!(matches!(
store.confirm_merge(MergeConfirmation {
ticket_id: "T1".into(),
expected_revision_id: "V2".into(),
authenticated_account_id: "runtime".into(),
actor_kind: "worker".into(),
explicit_confirmation: true,
now: "tm".into()
}),
Err(MergeRequestError::MergeConfirmationRequired)
));
let merged = store
.confirm_merge(MergeConfirmation {
ticket_id: "T1".into(),
expected_revision_id: "V2".into(),
authenticated_account_id: "account-1".into(),
actor_kind: "user".into(),
explicit_confirmation: true,
now: "tm".into(),
})
.unwrap();
assert_eq!(merged.state, MergeRequestState::Merged);
let conn = Connection::open(store.db_path()).unwrap();
assert_eq!(
conn.query_row(
"SELECT workflow_state FROM typed_tickets WHERE workspace_id='ws-a' AND ticket_id='T1'",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
"done"
);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id='ws-a' AND ticket_id='T1'",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
}
#[test]
fn spoof_self_approval_replay_and_cross_workspace_are_rejected() {
let (_dir, store) = setup();
open(&store);
let mut bad = RegisterReviewAttempt {
attempt_id: "bad".into(),
ticket_id: "T1".into(),
revision_id: "V1".into(),
parent_assignment_id: "A1".into(),
parent_runtime_id: "R1".into(),
parent_worker_id: "W1".into(),
child_session_id: "W1".into(),
capability_token: "bad".into(),
now: "t".into(),
};
assert!(matches!(
store.register_review_attempt(bad.clone()),
Err(MergeRequestError::SelfApproval)
));
bad.child_session_id = "child".into();
assert!(matches!(
store.register_review_attempt(bad),
Err(MergeRequestError::InvalidReviewer)
));
attempt(&store, "AT", "V1", "secret", "child");
assert!(review(&store, "V1", "spoof", ReviewDecision::Approve).is_err());
review(&store, "V1", "secret", ReviewDecision::Approve).unwrap();
assert!(review(&store, "V1", "secret", ReviewDecision::Approve).is_err());
let other = SqliteMergeRequestStore::open_verified(store.db_path(), "ws-b").unwrap();
assert!(other.show_for_ticket("T1").unwrap().is_none());
}
#[test]
fn reopen_resets_approval_and_merge_requires_authenticated_explicit_user() {
let (_dir, store) = setup();
open(&store);
attempt(&store, "AT", "V1", "token", "child");
review(&store, "V1", "token", ReviewDecision::Approve).unwrap();
store.close("T1", "V1", "tc").unwrap();
let reopened = store.reopen("T1", "V1", "tr").unwrap();
assert_eq!(reopened.review_status, ReviewStatus::Pending);
let denied = store.confirm_merge(MergeConfirmation {
ticket_id: "T1".into(),
expected_revision_id: "V1".into(),
authenticated_account_id: "user".into(),
actor_kind: "user".into(),
explicit_confirmation: false,
now: "tm".into(),
});
assert!(matches!(
denied,
Err(MergeRequestError::MergeConfirmationRequired)
));
}
#[test]
fn concurrent_exact_completion_replays_commit_one_ticket_side_effect() {
let (_dir, store) = setup();
open(&store);
attempt(&store, "AT", "V1", "token", "child");
review(&store, "V1", "token", ReviewDecision::Approve).unwrap();
let input = CompleteMergeRequest {
operation_id: "OP-concurrent".into(),
ticket_id: "T1".into(),
expected_revision_id: "V1".into(),
assignment_id: "A1".into(),
authenticated_runtime_id: "R1".into(),
authenticated_worker_id: "W1".into(),
now: "t".into(),
};
let left_store = store.clone();
let left_input = input.clone();
let left = std::thread::spawn(move || left_store.complete(left_input));
let right_store = store.clone();
let right = std::thread::spawn(move || right_store.complete(input));
let outcomes = [
left.join().unwrap().unwrap(),
right.join().unwrap().unwrap(),
];
assert_eq!(
outcomes.iter().filter(|outcome| !outcome.replayed).count(),
1
);
assert_eq!(
outcomes.iter().filter(|outcome| outcome.replayed).count(),
1
);
let conn = Connection::open(store.db_path()).unwrap();
let events: i64 = conn
.query_row(
"SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id='ws-a' AND ticket_id='T1'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(events, 1);
}
#[test]
fn operation_key_mismatch_and_assignment_takeover_are_fenced() {
let (_dir, store) = setup();
open(&store);
attempt(&store, "AT", "V1", "token", "child");
review(&store, "V1", "token", ReviewDecision::Approve).unwrap();
let mut input = CompleteMergeRequest {
operation_id: "OP".into(),
ticket_id: "T1".into(),
expected_revision_id: "V1".into(),
assignment_id: "A1".into(),
authenticated_runtime_id: "R1".into(),
authenticated_worker_id: "W1".into(),
now: "t".into(),
};
let conn = Connection::open(store.db_path()).unwrap();
conn.execute("UPDATE ticket_current_worker_assignments SET assignment_id='A2',runtime_id='R2',worker_id='W2' WHERE workspace_id='ws-a' AND ticket_id='T1'",[]).unwrap();
assert!(matches!(
store.complete(input.clone()),
Err(MergeRequestError::AssignmentMismatch)
));
conn.execute("UPDATE ticket_current_worker_assignments SET assignment_id='A1',runtime_id='R1',worker_id='W1' WHERE workspace_id='ws-a' AND ticket_id='T1'",[]).unwrap();
store.complete(input.clone()).unwrap();
input.expected_revision_id = "other".into();
assert!(matches!(
store.complete(input),
Err(MergeRequestError::OperationConflict)
));
}
+5 -3
View File
@@ -38,6 +38,7 @@ pub mod segment_log;
pub mod store;
pub mod system_item;
pub mod worker_metadata;
pub mod worker_session_store;
pub use event_trace::{TraceEntry, TracePayload};
pub use fs_store::FsStore;
@@ -54,10 +55,11 @@ pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension,
pub use store::{Store, StoreError};
pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_worker_event};
pub use worker_metadata::{
CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore,
WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild, WorkerSpawnedScopeRule, WorkerStoreError,
validate_worker_name,
CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerAggregateStore, WorkerMetadata,
WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild,
WorkerSpawnedScopeRule, WorkerStoreError, validate_worker_name,
};
pub use worker_session_store::WorkerSessionStore;
/// Session identifier — the fork-tree root. UUID v7 (time-ordered).
///
+130
View File
@@ -319,6 +319,115 @@ pub trait WorkerMetadataStore: Send + Sync {
}
}
/// Metadata store for one canonical Worker aggregate.
///
/// The Worker key is fixed by Runtime authority and metadata lives directly at
/// `<worker-aggregate>/metadata.json`. This store cannot address another
/// Worker's global metadata root.
#[derive(Clone)]
pub struct WorkerAggregateStore {
root: PathBuf,
worker_name: String,
}
impl WorkerAggregateStore {
pub fn new(
root: impl Into<PathBuf>,
worker_name: impl Into<String>,
) -> Result<Self, WorkerStoreError> {
let root = root.into();
let worker_name = worker_name.into();
validate_worker_name(&worker_name)?;
fs::create_dir_all(&root)?;
Ok(Self { root, worker_name })
}
fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
validate_worker_name(worker_name)?;
if worker_name == self.worker_name {
Ok(())
} else {
Err(WorkerStoreError::InvalidWorkerName(format!(
"aggregate owns `{}`; requested `{worker_name}`",
self.worker_name
)))
}
}
fn metadata_path(&self) -> PathBuf {
self.root.join("metadata.json")
}
}
impl WorkerMetadataStore for WorkerAggregateStore {
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
self.validate_name(&metadata.worker_name)?;
let mut content = serde_json::to_vec_pretty(metadata)?;
content.push(b'\n');
let path = self.metadata_path();
let temp = self.root.join(format!(
".metadata.json.tmp-{}-{}",
std::process::id(),
uuid::Uuid::now_v7()
));
let result = (|| -> Result<(), WorkerStoreError> {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)?;
file.write_all(&content)?;
file.sync_all()?;
drop(file);
fs::rename(&temp, &path)?;
std::fs::File::open(&self.root)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(temp);
}
result
}
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
self.validate_name(worker_name)?;
let content = match fs::read(self.metadata_path()) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let metadata: WorkerMetadata = serde_json::from_slice(&content)?;
if metadata.worker_name != self.worker_name {
return Err(WorkerStoreError::InvalidWorkerName(format!(
"aggregate identity mismatch: expected `{}`, found `{}`",
self.worker_name, metadata.worker_name
)));
}
Ok(Some(metadata))
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
Ok(if self.metadata_path().is_file() {
vec![self.worker_name.clone()]
} else {
Vec::new()
})
}
fn root_dir(&self) -> Option<PathBuf> {
Some(self.root.clone())
}
fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
self.validate_name(worker_name)?;
match fs::remove_file(self.metadata_path()) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
}
/// Filesystem-backed Worker metadata store.
#[derive(Clone)]
pub struct FsWorkerStore {
@@ -555,6 +664,27 @@ mod tests {
assert_eq!(restored, metadata);
}
#[test]
fn worker_aggregate_store_writes_one_fixed_metadata_identity() {
let tmp = tempfile::tempdir().unwrap();
let store = WorkerAggregateStore::new(tmp.path(), "worker-runtime-7").unwrap();
let metadata = WorkerMetadata::new("worker-runtime-7", None);
store.write(&metadata).unwrap();
assert!(tmp.path().join("metadata.json").is_file());
assert_eq!(store.list_names().unwrap(), vec!["worker-runtime-7"]);
assert_eq!(
store
.read_by_name("worker-runtime-7")
.unwrap()
.unwrap()
.worker_name,
"worker-runtime-7"
);
let error = store.read_by_name("worker-runtime-8").unwrap_err();
assert!(error.to_string().contains("aggregate owns"));
}
#[test]
fn fs_store_writes_under_worker_state_root_only() {
let tmp = tempfile::TempDir::new().unwrap();
@@ -0,0 +1,422 @@
//! Filesystem store for the canonical `1 Worker = 1 Session` aggregate.
//!
//! Layout under one Worker aggregate:
//! - `session/session.json` — immutable Session identity
//! - `session/segments/<segment_id>.jsonl`
//! - `session/segments/<segment_id>.trace.jsonl`
//!
//! Unlike [`crate::FsStore`], this store cannot enumerate or switch between
//! arbitrary Sessions. The first segment materializes the sole Session identity;
//! every later operation must use that same ID.
use crate::event_trace::TraceEntry;
use crate::segment_log::LogEntry;
use crate::store::{Store, StoreError};
use crate::{SegmentId, SessionId};
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
const SESSION_SCHEMA_VERSION: u32 = 1;
const SESSION_FILE: &str = "session.json";
const SEGMENTS_DIR: &str = "segments";
#[derive(Clone)]
pub struct WorkerSessionStore {
root: PathBuf,
session_id: Arc<Mutex<Option<SessionId>>>,
append_lock: Arc<Mutex<()>>,
}
#[derive(Debug, Serialize, Deserialize)]
struct SessionManifest {
schema_version: u32,
session_id: SessionId,
}
impl WorkerSessionStore {
/// Open the Session store rooted at `<worker-aggregate>/session`.
pub fn new(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
let root = root.into();
fs::create_dir_all(root.join(SEGMENTS_DIR))?;
let session_id = match fs::read(root.join(SESSION_FILE)) {
Ok(bytes) => {
let manifest: SessionManifest = serde_json::from_slice(&bytes)?;
if manifest.schema_version != SESSION_SCHEMA_VERSION {
return Err(StoreError::Corrupt {
line: 0,
message: format!(
"unsupported Worker Session schema version {}, expected {}",
manifest.schema_version, SESSION_SCHEMA_VERSION
),
});
}
Some(manifest.session_id)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
Ok(Self {
root,
session_id: Arc::new(Mutex::new(session_id)),
append_lock: Arc::new(Mutex::new(())),
})
}
pub fn root_dir(&self) -> &Path {
&self.root
}
pub fn session_id(&self) -> Result<Option<SessionId>, StoreError> {
self.session_id
.lock()
.map(|session_id| *session_id)
.map_err(|_| std::io::Error::other("Worker Session identity lock was poisoned").into())
}
pub fn session_modified_at(&self) -> Result<Option<SystemTime>, StoreError> {
let metadata = match fs::metadata(&self.root) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let mut latest = Some(metadata.modified()?);
for entry in fs::read_dir(self.root.join(SEGMENTS_DIR))? {
let modified = entry?.metadata()?.modified()?;
if latest.map(|current| modified > current).unwrap_or(true) {
latest = Some(modified);
}
}
Ok(latest)
}
fn ensure_session(&self, requested: SessionId, materialize: bool) -> Result<(), StoreError> {
let mut session_id = self
.session_id
.lock()
.map_err(|_| std::io::Error::other("Worker Session identity lock was poisoned"))?;
match *session_id {
Some(existing) if existing == requested => Ok(()),
Some(existing) => Err(StoreError::Corrupt {
line: 0,
message: format!(
"Worker aggregate owns Session {existing}; cannot attach or switch to Session {requested}"
),
}),
None if !materialize => Err(StoreError::Corrupt {
line: 0,
message: format!(
"Worker aggregate has no materialized Session; requested Session {requested}"
),
}),
None => {
let manifest = SessionManifest {
schema_version: SESSION_SCHEMA_VERSION,
session_id: requested,
};
atomic_write_json(&self.root.join(SESSION_FILE), &manifest)?;
*session_id = Some(requested);
Ok(())
}
}
}
fn log_path(&self, segment_id: SegmentId) -> PathBuf {
self.root
.join(SEGMENTS_DIR)
.join(format!("{segment_id}.jsonl"))
}
fn trace_path(&self, segment_id: SegmentId) -> PathBuf {
self.root
.join(SEGMENTS_DIR)
.join(format!("{segment_id}.trace.jsonl"))
}
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.append(true)
.open(path)?;
let committed_len = truncate_uncommitted_tail(&mut file)?;
let mut record = Vec::with_capacity(line.len() + 1);
record.extend_from_slice(line.as_bytes());
record.push(b'\n');
if let Err(write_error) = file.write_all(&record) {
return match file.set_len(committed_len) {
Ok(()) => Err(write_error.into()),
Err(rollback_error) => Err(std::io::Error::new(
rollback_error.kind(),
format!(
"session append failed ({write_error}) and rollback failed: {rollback_error}"
),
)
.into()),
};
}
Ok(())
}
}
impl Store for WorkerSessionStore {
fn append(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &LogEntry,
) -> Result<(), StoreError> {
self.ensure_session(session_id, true)?;
self.append_line(&self.log_path(segment_id), &serde_json::to_string(entry)?)
}
fn read_all(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<LogEntry>, StoreError> {
self.ensure_session(session_id, false)?;
let path = self.log_path(segment_id);
if !path.exists() {
return Err(StoreError::NotFound(segment_id));
}
parse_jsonl(&fs::read(path)?)
}
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
Ok(self.session_id()?.into_iter().collect())
}
fn list_segments(&self, session_id: SessionId) -> Result<Vec<SegmentId>, StoreError> {
self.ensure_session(session_id, false)?;
let mut segments: Vec<SegmentId> = Vec::new();
for entry in fs::read_dir(self.root.join(SEGMENTS_DIR))? {
let path = entry?.path();
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if name.ends_with(".jsonl")
&& !name.ends_with(".trace.jsonl")
&& let Ok(segment_id) = name.trim_end_matches(".jsonl").parse()
{
segments.push(segment_id);
}
}
segments.sort_by(|left, right| right.cmp(left));
Ok(segments)
}
fn lookup_session_of(&self, segment_id: SegmentId) -> Result<Option<SessionId>, StoreError> {
let session_id = self.session_id()?;
Ok(session_id.filter(|_| self.log_path(segment_id).exists()))
}
fn create_segment(
&self,
session_id: SessionId,
segment_id: SegmentId,
entries: &[LogEntry],
) -> Result<(), StoreError> {
self.ensure_session(session_id, true)?;
let mut content = Vec::new();
for entry in entries {
serde_json::to_writer(&mut content, entry)?;
content.push(b'\n');
}
atomic_write_bytes(&self.log_path(segment_id), &content)?;
Ok(())
}
fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result<bool, StoreError> {
self.ensure_session(session_id, false)?;
Ok(self.log_path(segment_id).exists())
}
fn read_entry_count(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<usize, StoreError> {
self.ensure_session(session_id, false)?;
let path = self.log_path(segment_id);
if !path.exists() {
return Err(StoreError::NotFound(segment_id));
}
let content = fs::read(path)?;
let complete = complete_jsonl_prefix(&content);
let complete = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
line: complete[..error.valid_up_to()]
.iter()
.filter(|byte| **byte == b'\n')
.count()
+ 1,
message: error.to_string(),
})?;
Ok(complete
.lines()
.filter(|line| !line.trim().is_empty())
.count())
}
fn append_trace(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &TraceEntry,
) -> Result<(), StoreError> {
self.ensure_session(session_id, true)?;
self.append_line(&self.trace_path(segment_id), &serde_json::to_string(entry)?)
}
}
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
let mut bytes = serde_json::to_vec_pretty(value)?;
bytes.push(b'\n');
atomic_write_bytes(path, &bytes)
}
fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
let parent = path
.parent()
.ok_or_else(|| std::io::Error::other("Worker Session path has no parent"))?;
fs::create_dir_all(parent)?;
let tmp = path.with_file_name(format!(
".{}.tmp-{}-{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("session"),
std::process::id(),
uuid::Uuid::now_v7()
));
let result = (|| -> Result<(), StoreError> {
let mut file = OpenOptions::new().create_new(true).write(true).open(&tmp)?;
file.write_all(bytes)?;
file.sync_all()?;
drop(file);
fs::rename(&tmp, path)?;
File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&tmp);
}
result
}
fn complete_jsonl_prefix(content: &[u8]) -> &[u8] {
if content.last() == Some(&b'\n') {
return content;
}
content
.iter()
.rposition(|byte| *byte == b'\n')
.map(|index| &content[..=index])
.unwrap_or(&[])
}
fn parse_jsonl<T: serde::de::DeserializeOwned>(content: &[u8]) -> Result<Vec<T>, StoreError> {
let complete = complete_jsonl_prefix(content);
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
line: complete[..error.valid_up_to()]
.iter()
.filter(|byte| **byte == b'\n')
.count()
+ 1,
message: error.to_string(),
})?;
content
.lines()
.enumerate()
.filter(|(_, line)| !line.trim().is_empty())
.map(|(index, line)| {
serde_json::from_str(line).map_err(|error| StoreError::Corrupt {
line: index + 1,
message: error.to_string(),
})
})
.collect()
}
fn truncate_uncommitted_tail(file: &mut File) -> std::io::Result<u64> {
const SCAN_BYTES: usize = 8 * 1024;
let len = file.metadata()?.len();
if len == 0 {
return Ok(0);
}
file.seek(SeekFrom::End(-1))?;
let mut last = [0_u8; 1];
file.read_exact(&mut last)?;
if last[0] == b'\n' {
return Ok(len);
}
let mut end = len;
let mut buffer = [0_u8; SCAN_BYTES];
while end > 0 {
let start = end.saturating_sub(SCAN_BYTES as u64);
let chunk_len = (end - start) as usize;
file.seek(SeekFrom::Start(start))?;
file.read_exact(&mut buffer[..chunk_len])?;
if let Some(index) = buffer[..chunk_len].iter().rposition(|byte| *byte == b'\n') {
let committed_len = start + index as u64 + 1;
file.set_len(committed_len)?;
return Ok(committed_len);
}
end = start;
}
file.set_len(0)?;
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Store, new_segment_id, new_session_id};
#[test]
fn canonical_layout_and_single_session_invariant() {
let root = tempfile::tempdir().unwrap();
let store = WorkerSessionStore::new(root.path().join("session")).unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
store.create_segment(session_id, segment_id, &[]).unwrap();
assert!(root.path().join("session/session.json").is_file());
assert!(
root.path()
.join(format!("session/segments/{segment_id}.jsonl"))
.is_file()
);
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
let other = new_session_id();
let error = store
.create_segment(other, new_segment_id(), &[])
.unwrap_err();
assert!(error.to_string().contains("cannot attach or switch"));
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
}
#[test]
fn reopen_preserves_session_and_segment_ids() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
WorkerSessionStore::new(root.path())
.unwrap()
.create_segment(session_id, segment_id, &[])
.unwrap();
let reopened = WorkerSessionStore::new(root.path()).unwrap();
assert_eq!(reopened.session_id().unwrap(), Some(session_id));
assert!(reopened.exists(session_id, segment_id).unwrap());
}
}
+60 -299
View File
@@ -20,8 +20,13 @@ use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use thiserror::Error;
pub mod config;
mod sqlite_schema;
pub mod tool;
pub use sqlite_schema::{
LATEST_SQLITE_TICKET_SCHEMA_VERSION, migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema,
};
const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"];
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
@@ -290,7 +295,6 @@ pub enum TicketEventKind {
Plan,
Decision,
ImplementationReport,
Review,
StateChanged,
IntakeSummary,
StatusChanged,
@@ -306,7 +310,6 @@ impl TicketEventKind {
Self::Plan => "plan",
Self::Decision => "decision",
Self::ImplementationReport => "implementation_report",
Self::Review => "review",
Self::StateChanged => "state_changed",
Self::IntakeSummary => "intake_summary",
Self::StatusChanged => "status_changed",
@@ -322,7 +325,6 @@ impl TicketEventKind {
Self::Plan => "Plan".to_string(),
Self::Decision => "Decision".to_string(),
Self::ImplementationReport => "Implementation report".to_string(),
Self::Review => "Review".to_string(),
Self::StateChanged => "State changed".to_string(),
Self::IntakeSummary => "Intake summary".to_string(),
Self::StatusChanged => "Status changed".to_string(),
@@ -340,7 +342,7 @@ impl From<&str> for TicketEventKind {
"plan" => Self::Plan,
"decision" => Self::Decision,
"implementation_report" => Self::ImplementationReport,
"review" => Self::Review,
"review" => Self::Comment,
"state_changed" => Self::StateChanged,
"intake_summary" => Self::IntakeSummary,
"status_changed" => Self::StatusChanged,
@@ -350,42 +352,6 @@ impl From<&str> for TicketEventKind {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketReviewResult {
Approve,
RequestChanges,
Other(String),
}
impl TicketReviewResult {
pub fn as_str(&self) -> &str {
match self {
Self::Approve => "approve",
Self::RequestChanges => "request_changes",
Self::Other(value) => value.as_str(),
}
}
fn heading(&self) -> String {
match self {
Self::Approve => "Review: approve".to_string(),
Self::RequestChanges => "Review: request changes".to_string(),
Self::Other(value) => format!("Review: {value}"),
}
}
}
impl From<&str> for TicketReviewResult {
fn from(value: &str) -> Self {
match value {
"approve" => Self::Approve,
"request_changes" => Self::RequestChanges,
other => Self::Other(other.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketReference {
pub kind: String,
@@ -456,31 +422,6 @@ impl TicketIntakeSummary {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketReview {
pub result: TicketReviewResult,
pub author: Option<String>,
pub body: MarkdownText,
}
impl TicketReview {
pub fn approve(body: impl Into<MarkdownText>) -> Self {
Self {
result: TicketReviewResult::Approve,
author: None,
body: body.into(),
}
}
pub fn request_changes(body: impl Into<MarkdownText>) -> Self {
Self {
result: TicketReviewResult::RequestChanges,
author: None,
body: body.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NewTicket {
pub title: String,
@@ -1573,7 +1514,6 @@ pub trait TicketBackend {
change: TicketStateChange,
) -> Result<()>;
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
fn add_ticket_relation(
&self,
@@ -1651,10 +1591,6 @@ pub enum TicketBackendOperation {
id: TicketIdOrSlug,
queued_by: String,
},
Review {
id: TicketIdOrSlug,
review: TicketReview,
},
Close {
id: TicketIdOrSlug,
resolution: MarkdownText,
@@ -1758,10 +1694,6 @@ where
backend.queue_ready(id, &queued_by)?;
TicketBackendOperationResult::Unit
}
TicketBackendOperation::Review { id, review } => {
backend.review(id, review)?;
TicketBackendOperationResult::Unit
}
TicketBackendOperation::Close { id, resolution } => {
backend.close(id, resolution)?;
TicketBackendOperationResult::Unit
@@ -2301,7 +2233,7 @@ impl fmt::Debug for SqliteTicketBackend {
}
impl SqliteTicketBackend {
pub fn new(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
fn configured(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
Self {
db_path: db_path.into(),
workspace_id: workspace_id.into(),
@@ -2311,6 +2243,27 @@ impl SqliteTicketBackend {
}
}
/// Opens a standalone Ticket backend, applying all Ticket-owned migrations once.
pub fn open(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Result<Self> {
let backend = Self::configured(db_path, workspace_id);
let connection = backend.connect()?;
migrate_sqlite_ticket_schema(&connection)?;
Ok(backend)
}
/// Connects to a database whose Ticket schema was composed by its startup owner.
///
/// This performs verification only and never creates or alters schema objects.
pub fn open_verified(
db_path: impl Into<PathBuf>,
workspace_id: impl Into<String>,
) -> Result<Self> {
let backend = Self::configured(db_path, workspace_id);
let connection = backend.connect()?;
verify_sqlite_ticket_schema(&connection)?;
Ok(backend)
}
pub fn with_event_attributes(mut self, attributes: BTreeMap<String, String>) -> Self {
self.event_attributes = attributes;
self
@@ -2338,7 +2291,6 @@ impl SqliteTicketBackend {
pub fn import_from_local_backend(&self, local: &LocalTicketBackend) -> Result<()> {
let conn = self.open_connection()?;
self.ensure_schema(&conn)?;
conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?;
let result = (|| {
for summary in local.list(TicketListQuery::all())? {
@@ -2351,7 +2303,7 @@ impl SqliteTicketBackend {
finish_sqlite_transaction(&conn, result)
}
fn open_connection(&self) -> Result<Connection> {
fn connect(&self) -> Result<Connection> {
if let Some(parent) = self.db_path.parent() {
fs::create_dir_all(parent).map_err(|error| io_err(parent, error))?;
}
@@ -2361,115 +2313,20 @@ impl SqliteTicketBackend {
Ok(conn)
}
fn ensure_schema(&self, conn: &Connection) -> Result<()> {
conn.execute_batch(r#"
CREATE TABLE IF NOT EXISTS typed_tickets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
kind TEXT NOT NULL,
priority TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT,
updated_at TEXT,
assignee TEXT,
readiness TEXT,
workflow_state TEXT NOT NULL,
workflow_state_explicit INTEGER NOT NULL,
queued_by TEXT,
queued_at TEXT,
resolution TEXT,
repository_id TEXT,
ref_selector TEXT,
PRIMARY KEY (workspace_id, ticket_id)
);
CREATE TABLE IF NOT EXISTS typed_ticket_labels (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_risk_flags (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_raw_frontmatter (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_events (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
event_index INTEGER NOT NULL,
kind TEXT NOT NULL,
author TEXT,
at TEXT,
status TEXT,
from_state TEXT,
to_state TEXT,
reason TEXT,
state_field TEXT,
heading TEXT,
body TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_event_references (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_event_attributes (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_relations (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, kind, target),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_orchestration_plans (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
record_id TEXT NOT NULL,
kind TEXT NOT NULL,
related_ticket TEXT,
note TEXT,
accepted_summary TEXT,
accepted_branch TEXT,
accepted_worktree TEXT,
accepted_role_plan TEXT,
author TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, record_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_artifacts (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, relative_path),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
"#)
.map_err(sqlite_err)?;
ensure_sqlite_ticket_column(conn, "repository_id", "TEXT")?;
ensure_sqlite_ticket_column(conn, "ref_selector", "TEXT")?;
Ok(())
fn open_connection(&self) -> Result<Connection> {
let connection = self.connect()?;
verify_sqlite_ticket_schema(&connection)?;
Ok(connection)
}
fn with_write<R>(&self, op: impl FnOnce(&Connection) -> Result<R>) -> Result<R> {
let conn = self.open_connection()?;
self.ensure_schema(&conn)?;
conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?;
finish_sqlite_transaction(&conn, op(&conn))
}
fn with_read<R>(&self, op: impl FnOnce(&Connection) -> Result<R>) -> Result<R> {
let conn = self.open_connection()?;
self.ensure_schema(&conn)?;
op(&conn)
}
@@ -2915,30 +2772,6 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts (
}
}
fn ensure_sqlite_ticket_column(
conn: &rusqlite::Connection,
name: &str,
sql_type: &str,
) -> Result<()> {
let mut statement = conn
.prepare("PRAGMA table_info(typed_tickets)")
.map_err(sqlite_err)?;
let columns = statement
.query_map([], |row| row.get::<_, String>(1))
.map_err(sqlite_err)?;
for column in columns {
if column.map_err(sqlite_err)? == name {
return Ok(());
}
}
conn.execute(
format!("ALTER TABLE typed_tickets ADD COLUMN {name} {sql_type}").as_str(),
[],
)
.map_err(sqlite_err)?;
Ok(())
}
fn finish_sqlite_transaction<R>(conn: &Connection, result: Result<R>) -> Result<R> {
match result {
Ok(output) => {
@@ -3295,34 +3128,6 @@ impl TicketBackend for SqliteTicketBackend {
})
}
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> {
self.with_write(|conn| {
let ticket_id = self.resolve_ticket_id(conn, id)?;
let at = now_utc();
let mut attributes = BTreeMap::new();
attributes.insert("result".to_string(), review.result.as_str().to_string());
self.insert_event(
conn,
&ticket_id,
&TicketEvent {
kind: TicketEventKind::Review,
author: Some(review.author.unwrap_or_else(default_author)),
at: Some(at.clone()),
status: Some(review.result.as_str().to_string()),
from: None,
to: None,
reason: None,
state_field: None,
heading: Some(review.result.heading()),
body: review.body,
references: Vec::new(),
attributes,
},
)?;
self.touch_ticket(conn, &ticket_id, &at)
})
}
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
self.with_write(|conn| {
let ticket_id = self.resolve_ticket_id(conn, id)?;
@@ -3898,21 +3703,6 @@ impl TicketBackend for LocalTicketBackend {
)
}
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> {
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
let author = review.author.unwrap_or_else(default_author);
self.append_thread_event(
&dir,
"review",
&review.result.heading(),
&author,
Some(review.result.as_str()),
&[],
&review.body,
)
}
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
let _lock = self.acquire_lock()?;
self.ensure_backend_dirs()?;
@@ -5431,7 +5221,8 @@ fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
.strip_prefix("<!-- ")
.and_then(|v| v.strip_suffix(" -->"))
{
let attrs = parse_event_comment(comment);
let mut attrs = parse_event_comment(comment);
let legacy_review = attrs.get("event").is_some_and(|value| value == "review");
let kind = attrs
.get("event")
.map(|value| TicketEventKind::from(value.as_str()))
@@ -5463,11 +5254,22 @@ fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
while body.ends_with('\n') {
body.pop();
}
if legacy_review {
heading = Some("Legacy review (non-authoritative)".to_string());
attrs.remove("status");
attrs.remove("result");
attrs.insert("event".to_string(), "comment".to_string());
attrs.insert("legacy_event_kind".to_string(), "review".to_string());
}
events.push(TicketEvent {
kind,
author: attrs.get("author").cloned(),
at: attrs.get("at").cloned(),
status: attrs.get("status").cloned(),
status: if legacy_review {
None
} else {
attrs.get("status").cloned()
},
from: attrs.get("from").cloned(),
to: attrs.get("to").cloned(),
reason: attrs.get("reason").cloned(),
@@ -6406,28 +6208,11 @@ state: planning
#[test]
fn sqlite_backend_persists_and_edits_ticket_target() {
let tmp = TempDir::new().unwrap();
let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
let backend =
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
assert_ticket_target_edit_semantics(&backend);
}
#[test]
fn sqlite_ticket_target_columns_are_added_to_existing_table() {
let tmp = TempDir::new().unwrap();
let conn = rusqlite::Connection::open(tmp.path().join("workspace.db")).unwrap();
conn.execute_batch("CREATE TABLE typed_tickets (ticket_id TEXT PRIMARY KEY);")
.unwrap();
ensure_sqlite_ticket_column(&conn, "repository_id", "TEXT").unwrap();
ensure_sqlite_ticket_column(&conn, "ref_selector", "TEXT").unwrap();
let mut statement = conn.prepare("PRAGMA table_info(typed_tickets)").unwrap();
let columns = statement
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
assert!(columns.iter().any(|column| column == "repository_id"));
assert!(columns.iter().any(|column| column == "ref_selector"));
}
#[test]
fn local_backend_edit_item_supports_partial_body_replacement() {
let tmp = TempDir::new().unwrap();
@@ -6438,7 +6223,8 @@ state: planning
#[test]
fn sqlite_backend_edit_item_supports_partial_body_replacement() {
let tmp = TempDir::new().unwrap();
let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
let backend =
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
assert_partial_body_replacement_semantics(&backend);
}
@@ -6446,7 +6232,7 @@ state: planning
fn sqlite_mutation_hook_failure_rolls_back_ticket_event() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("workspace.db");
let backend = SqliteTicketBackend::new(&db_path, "workspace-test");
let backend = SqliteTicketBackend::open(&db_path, "workspace-test").unwrap();
let created = backend.create(NewTicket::new("Atomic mutation")).unwrap();
let before = backend
.show(TicketIdOrSlug::Id(created.id.clone()))
@@ -6480,7 +6266,8 @@ state: planning
#[test]
fn sqlite_backend_persists_core_ticket_operations() {
let tmp = TempDir::new().unwrap();
let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
let backend =
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
let created = backend.create(NewTicket::new("SQLite Ticket")).unwrap();
backend
.add_event(
@@ -6488,12 +6275,6 @@ state: planning
NewTicketEvent::new(TicketEventKind::Comment, "Imported into SQLite."),
)
.unwrap();
backend
.review(
TicketIdOrSlug::Id(created.id.clone()),
TicketReview::approve("Looks good."),
)
.unwrap();
backend
.close(
TicketIdOrSlug::Id(created.id.clone()),
@@ -6501,7 +6282,9 @@ state: planning
)
.unwrap();
let reopened = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
let reopened =
SqliteTicketBackend::open_verified(tmp.path().join("workspace.db"), "workspace-test")
.unwrap();
let list = reopened.list(TicketListQuery::all()).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, created.id);
@@ -6511,13 +6294,6 @@ state: planning
assert!(ticket.events.iter().any(|event| {
event.kind == TicketEventKind::Comment && event.body.0.contains("Imported into SQLite")
}));
assert!(
ticket
.events
.iter()
.any(|event| event.kind == TicketEventKind::Review
&& event.body.0.contains("Looks good"))
);
assert!(
ticket
.resolution
@@ -6531,7 +6307,8 @@ state: planning
let tmp = TempDir::new().unwrap();
let local = backend(&tmp);
let created = local.create(NewTicket::new("Legacy Ticket")).unwrap();
let db = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
let db =
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
db.import_from_local_backend(&local).unwrap();
let ticket = db.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
@@ -6630,7 +6407,7 @@ state: planning
}
#[test]
fn add_event_review_status_and_close_preserve_local_layout() {
fn add_event_status_and_close_preserve_local_layout() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap();
@@ -6640,12 +6417,6 @@ state: planning
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
)
.unwrap();
backend
.review(
TicketIdOrSlug::Id(ticket.id.clone()),
TicketReview::approve("Looks good."),
)
.unwrap();
let mut summary = TicketIntakeSummary::new("Ready for queue.");
summary.author = Some("test".to_string());
let mut change = TicketStateChange::new(
@@ -6669,8 +6440,6 @@ state: planning
let closed_dir = tmp.path().join("tickets").join(&ticket.id);
assert!(closed_dir.join("resolution.md").exists());
let thread = fs::read_to_string(closed_dir.join("thread.md")).unwrap();
assert!(thread.contains("<!-- event: review"));
assert!(thread.contains("status: approve"));
assert!(thread.contains("<!-- event: close"));
let report = backend.doctor().unwrap();
assert!(report.is_ok(), "{:?}", report.diagnostics);
@@ -6698,14 +6467,6 @@ state: planning
));
assert_eq!(fs::read_to_string(&thread_path).unwrap(), original);
let mut review = TicketReview::approve("This must not append either.");
review.author = Some("bad-->author".into());
assert!(matches!(
backend.review(TicketIdOrSlug::Id(ticket.id.clone()), review),
Err(TicketError::Conflict(_))
));
assert_eq!(fs::read_to_string(&thread_path).unwrap(), original);
let invalid_kind = NewTicketEvent::new(
TicketEventKind::Other("bad\nevent".into()),
"Invalid event kind.",
File diff suppressed because it is too large Load Diff
+5 -90
View File
@@ -17,8 +17,7 @@ use crate::{
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
TicketIntakeSummary, TicketListState, TicketRef, TicketRelation, TicketRelationKind,
TicketRelationView, TicketReview, TicketReviewResult, TicketStateChange, TicketSummary,
TicketWorkflowState, default_author,
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author,
};
const DEFAULT_LIST_LIMIT: usize = 50;
@@ -34,7 +33,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_BASE_TOOL_NAMES: [&str; 15] = [
pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
"TicketCreate",
"TicketEditItem",
"TicketList",
@@ -43,7 +42,6 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 15] = [
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
@@ -69,7 +67,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
pub const TICKET_TOOL_NAMES: [&str; 19] = [
pub const TICKET_TOOL_NAMES: [&str; 18] = [
"TicketCreate",
"TicketEditItem",
"TicketList",
@@ -78,7 +76,6 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
@@ -100,14 +97,13 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketOrchestrationPlanQuery",
];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 12] = [
"TicketCreate",
"TicketEditItem",
"TicketComment",
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
@@ -134,8 +130,6 @@ const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Mark
const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
"Append a typed Ticket implementation_report event. `body` is Markdown.";
const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be `approve` or \
`request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root.";
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
for `state`, and transitions state to `ready`.";
@@ -175,7 +169,6 @@ fn base_tool_description(name: &str) -> &'static str {
"TicketPlan" => PLAN_DESCRIPTION,
"TicketDecision" => DECISION_DESCRIPTION,
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
"TicketReview" => REVIEW_DESCRIPTION,
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
"TicketQueue" => QUEUE_DESCRIPTION,
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
@@ -319,10 +312,6 @@ impl TicketBackend for TicketToolBackend {
self.backend.queue_ready(id, queued_by)
}
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> TicketResult<()> {
self.backend.review(id, review)
}
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
self.backend.close(id, resolution)
}
@@ -554,23 +543,6 @@ struct TicketThreadEventParams {
body: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketReviewResultParam {
Approve,
RequestChanges,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketReviewParams {
/// Ticket id.
ticket: String,
/// Review result: `approve` or `request_changes`.
result: TicketReviewResultParam,
/// Markdown review body.
body: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams {
/// Ticket id.
@@ -839,11 +811,6 @@ struct TicketImplementationReportTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketReviewTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketIntakeReadyTool {
backend: TicketToolBackend,
@@ -1117,34 +1084,6 @@ impl_ticket_thread_event_tool!(
TicketEventKind::ImplementationReport
);
#[async_trait]
impl Tool for TicketReviewTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketReviewParams = parse_input("TicketReview", input_json)?;
let result = match params.result {
TicketReviewResultParam::Approve => TicketReviewResult::Approve,
TicketReviewResultParam::RequestChanges => TicketReviewResult::RequestChanges,
};
let result_str = result.as_str().to_string();
let review = TicketReview {
result,
author: None,
body: MarkdownText::new(params.body),
};
self.backend
.review(TicketIdOrSlug::Query(params.ticket.clone()), review)
.map_err(|error| backend_error("TicketReview", error))?;
Ok(json_output(
format!("Appended {result_str} review to ticket {}", params.ticket),
json!({ "ticket": params.ticket, "review": result_str, "ok": true }),
))
}
}
#[async_trait]
impl Tool for TicketIntakeReadyTool {
async fn execute(
@@ -1731,7 +1670,6 @@ fn input_schema(name: &str) -> Value {
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
}
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
"TicketWorkflowState" => {
@@ -1777,7 +1715,6 @@ impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketPlanTool);
impl_from_backend!(TicketDecisionTool);
impl_from_backend!(TicketImplementationReportTool);
impl_from_backend!(TicketReviewTool);
impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketQueueTool);
impl_from_backend!(TicketWorkflowStateTool);
@@ -1804,7 +1741,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
"TicketImplementationReport",
backend.clone(),
),
tool_definition::<TicketReviewTool>("TicketReview", backend.clone()),
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
@@ -1880,7 +1816,6 @@ mod tests {
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
@@ -2373,12 +2308,11 @@ mod tests {
}
#[tokio::test]
async fn ticket_tools_comment_review_state_and_close_are_doctor_clean() {
async fn ticket_tools_report_state_and_close_are_doctor_clean() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let created = backend.create(NewTicket::new("Flow Tool")).unwrap();
let report = tool_by_name(backend.clone(), "TicketImplementationReport");
let review = tool_by_name(backend.clone(), "TicketReview");
let close = tool_by_name(backend.clone(), "TicketClose");
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
@@ -2393,18 +2327,6 @@ mod tests {
)
.await
.unwrap();
review
.execute(
&json!({
"ticket": created.id.clone(),
"result": "approve",
"body": "Looks good."
})
.to_string(),
Default::default(),
)
.await
.unwrap();
close
.execute(
&json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" })
@@ -2427,12 +2349,6 @@ mod tests {
.iter()
.any(|event| event.kind == TicketEventKind::ImplementationReport)
);
assert!(
closed
.events
.iter()
.any(|event| event.kind == TicketEventKind::Review)
);
assert!(
closed
.events
@@ -2852,7 +2768,6 @@ mod tests {
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketRelationRecord",
+1 -7
View File
@@ -795,13 +795,7 @@ async fn ticket_review_action_does_not_silently_approve() {
.unwrap_err();
assert!(error.to_string().contains("current action is Queue"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert!(
!ticket
.events
.iter()
.any(|event| event.kind == TicketEventKind::Review)
);
let _ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
}
#[test]
+1 -1
View File
@@ -1524,7 +1524,7 @@ fn render_compact(lines: &mut Vec<Line<'static>>, evt: &CompactEvent, width: u16
.collect::<String>();
let elapsed = elapsed_suffix(*elapsed_secs);
(
format!("[compact] done (new session {short}){elapsed}"),
format!("[compact] done (new segment {short}){elapsed}"),
MessageKind::NoticeWarn,
)
}
+315
View File
@@ -10,6 +10,10 @@ const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
const TOKEN_PREFIX: &str = "yoi-cap-v1";
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
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";
#[derive(Debug, thiserror::Error)]
pub enum RuntimeAuthError {
@@ -41,6 +45,16 @@ pub enum RuntimeAuthError {
MissingWorkspaceScope,
#[error("capability token is missing required permission `{0}`")]
MissingPermission(String),
#[error("source proof workspace `{actual}` does not match `{expected}`")]
WrongWorkspace { expected: String, actual: String },
#[error("source proof Worker `{actual}` does not match `{expected}`")]
WrongWorker { expected: String, actual: String },
#[error("source proof actor kind is not allowed")]
WrongActorKind,
#[error("source proof operation is not allowed")]
WrongOperation,
#[error("source proof mutation target does not match the request")]
WrongMutationTarget,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -210,6 +224,201 @@ pub fn verify_capability_token(
})
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerMutationSourceClaims {
pub iss: String,
pub aud: String,
pub workspace_id: String,
pub worker_id: String,
pub actor_kind: WorkerMutationActorKind,
pub operation: WorkerMutationOperation,
pub target_runtime_id: String,
pub target_worker_id: String,
pub permission: String,
pub iat: u64,
pub exp: u64,
pub jti: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerMutationActorKind {
Worker,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerMutationOperation {
WorkerRemove,
}
#[derive(Clone)]
pub struct RuntimeWorkerMutationSourceSigner {
runtime_id: String,
private_key: String,
}
impl fmt::Debug for RuntimeWorkerMutationSourceSigner {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RuntimeWorkerMutationSourceSigner")
.field("runtime_id", &self.runtime_id)
.field("private_key", &"[redacted]")
.finish()
}
}
impl RuntimeWorkerMutationSourceSigner {
pub fn from_identity(identity: &RuntimeIdentityMaterial) -> Self {
Self {
runtime_id: identity.identity_id.clone(),
private_key: identity.private_key.clone(),
}
}
pub fn runtime_id(&self) -> &str {
&self.runtime_id
}
pub fn issue_worker_remove(
&self,
audience: impl Into<String>,
workspace_id: impl Into<String>,
source_worker_id: impl Into<String>,
target_runtime_id: impl Into<String>,
target_worker_id: impl Into<String>,
ttl_seconds: u64,
) -> Result<String, RuntimeAuthError> {
let issued_at = unix_now_seconds();
let claims = WorkerMutationSourceClaims {
iss: self.runtime_id.clone(),
aud: audience.into(),
workspace_id: workspace_id.into(),
worker_id: source_worker_id.into(),
actor_kind: WorkerMutationActorKind::Worker,
operation: WorkerMutationOperation::WorkerRemove,
target_runtime_id: target_runtime_id.into(),
target_worker_id: target_worker_id.into(),
permission: WORKER_REMOVE_PERMISSION.to_string(),
iat: issued_at,
exp: issued_at.saturating_add(ttl_seconds),
jti: new_token_id()?,
};
self.sign(&claims)
}
pub fn sign(&self, claims: &WorkerMutationSourceClaims) -> Result<String, RuntimeAuthError> {
if claims.iss != self.runtime_id {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
}
let private = decode_private_key(&self.private_key)?;
let pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?);
let signing_input = format!("{WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let signature = pair.sign(signing_input.as_bytes());
Ok(format!(
"{WORKER_MUTATION_SOURCE_PROOF_PREFIX}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkerMutationSourceExpectation<'a> {
pub runtime_id: &'a str,
pub audience: &'a str,
pub workspace_id: &'a str,
pub worker_id: Option<&'a str>,
pub actor_kind: WorkerMutationActorKind,
pub operation: WorkerMutationOperation,
pub target_runtime_id: &'a str,
pub target_worker_id: &'a str,
pub permission: &'a str,
}
pub fn decode_worker_mutation_source_claims(
token: &str,
) -> Result<WorkerMutationSourceClaims, RuntimeAuthError> {
let (payload, _) = split_worker_mutation_source_proof(token)?;
Ok(serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?)
}
pub fn verify_worker_mutation_source_proof(
trusted_runtime_public_key: &str,
token: &str,
expected: &WorkerMutationSourceExpectation<'_>,
now_seconds: u64,
) -> Result<WorkerMutationSourceClaims, RuntimeAuthError> {
let (payload, signature) = split_worker_mutation_source_proof(token)?;
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
let claims: WorkerMutationSourceClaims = serde_json::from_slice(&claims_json)?;
let public_key = decode_public_key(trusted_runtime_public_key)?;
let signing_input = format!("{WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
if claims.iss != expected.runtime_id {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
}
if claims.aud != expected.audience {
return Err(RuntimeAuthError::WrongAudience {
expected: expected.audience.to_string(),
actual: claims.aud,
});
}
if claims.exp <= now_seconds || claims.iat > now_seconds.saturating_add(60) {
return Err(RuntimeAuthError::Expired);
}
if claims.workspace_id != expected.workspace_id {
return Err(RuntimeAuthError::WrongWorkspace {
expected: expected.workspace_id.to_string(),
actual: claims.workspace_id,
});
}
if let Some(worker_id) = expected.worker_id {
if claims.worker_id != worker_id {
return Err(RuntimeAuthError::WrongWorker {
expected: worker_id.to_string(),
actual: claims.worker_id,
});
}
}
if claims.actor_kind != expected.actor_kind {
return Err(RuntimeAuthError::WrongActorKind);
}
if claims.operation != expected.operation {
return Err(RuntimeAuthError::WrongOperation);
}
if claims.target_runtime_id != expected.target_runtime_id
|| claims.target_worker_id != expected.target_worker_id
{
return Err(RuntimeAuthError::WrongMutationTarget);
}
if claims.permission != expected.permission {
return Err(RuntimeAuthError::MissingPermission(
expected.permission.to_string(),
));
}
if claims.jti.trim().is_empty() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok(claims)
}
fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
let mut parts = token.split('.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(prefix), Some(payload), Some(signature), None)
if prefix == WORKER_MUTATION_SOURCE_PROOF_PREFIX =>
{
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
}
_ => Err(RuntimeAuthError::InvalidTokenFormat),
}
}
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
let mut parts = token.split('.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
@@ -277,6 +486,112 @@ impl fmt::Display for RuntimeAuthContext {
mod tests {
use super::*;
#[test]
fn worker_mutation_source_proof_binds_all_source_authority_claims() {
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
let signer = RuntimeWorkerMutationSourceSigner::from_identity(&runtime);
let claims = WorkerMutationSourceClaims {
iss: "runtime-main".to_string(),
aud: "server-main".to_string(),
workspace_id: "workspace-a".to_string(),
worker_id: "worker-7".to_string(),
actor_kind: WorkerMutationActorKind::Worker,
operation: WorkerMutationOperation::WorkerRemove,
target_runtime_id: "runtime-target".to_string(),
target_worker_id: "worker-target".to_string(),
permission: WORKER_REMOVE_PERMISSION.to_string(),
iat: 90,
exp: 100,
jti: "source-proof-1".to_string(),
};
let token = signer.sign(&claims).unwrap();
let expected = WorkerMutationSourceExpectation {
runtime_id: "runtime-main",
audience: "server-main",
workspace_id: "workspace-a",
worker_id: Some("worker-7"),
actor_kind: WorkerMutationActorKind::Worker,
operation: WorkerMutationOperation::WorkerRemove,
target_runtime_id: "runtime-target",
target_worker_id: "worker-target",
permission: WORKER_REMOVE_PERMISSION,
};
assert_eq!(
verify_worker_mutation_source_proof(&runtime.public_key, &token, &expected, 99)
.unwrap(),
claims
);
let wrong_worker = WorkerMutationSourceExpectation {
worker_id: Some("worker-8"),
..expected.clone()
};
assert!(matches!(
verify_worker_mutation_source_proof(&runtime.public_key, &token, &wrong_worker, 99),
Err(RuntimeAuthError::WrongWorker { .. })
));
let wrong_scope = WorkerMutationSourceExpectation {
workspace_id: "workspace-b",
..expected.clone()
};
assert!(matches!(
verify_worker_mutation_source_proof(&runtime.public_key, &token, &wrong_scope, 99),
Err(RuntimeAuthError::WrongWorkspace { .. })
));
let wrong_audience = WorkerMutationSourceExpectation {
audience: "server-other",
..expected.clone()
};
assert!(matches!(
verify_worker_mutation_source_proof(&runtime.public_key, &token, &wrong_audience, 99),
Err(RuntimeAuthError::WrongAudience { .. })
));
assert!(matches!(
verify_worker_mutation_source_proof(&runtime.public_key, &token, &expected, 101),
Err(RuntimeAuthError::Expired)
));
}
#[test]
fn worker_mutation_source_proof_rejects_spoofed_runtime_signature() {
let trusted = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
let claims = WorkerMutationSourceClaims {
iss: "runtime-main".to_string(),
aud: "server-main".to_string(),
workspace_id: "workspace-a".to_string(),
worker_id: "worker-7".to_string(),
actor_kind: WorkerMutationActorKind::Worker,
operation: WorkerMutationOperation::WorkerRemove,
target_runtime_id: "runtime-target".to_string(),
target_worker_id: "worker-target".to_string(),
permission: WORKER_REMOVE_PERMISSION.to_string(),
iat: 90,
exp: 100,
jti: "source-proof-2".to_string(),
};
let token = RuntimeWorkerMutationSourceSigner::from_identity(&spoofed)
.sign(&claims)
.unwrap();
let expected = WorkerMutationSourceExpectation {
runtime_id: "runtime-main",
audience: "server-main",
workspace_id: "workspace-a",
worker_id: Some("worker-7"),
actor_kind: WorkerMutationActorKind::Worker,
operation: WorkerMutationOperation::WorkerRemove,
target_runtime_id: "runtime-target",
target_worker_id: "worker-target",
permission: WORKER_REMOVE_PERMISSION,
};
assert!(matches!(
verify_worker_mutation_source_proof(&trusted.public_key, &token, &expected, 99),
Err(RuntimeAuthError::InvalidSignature)
));
}
#[test]
fn capability_token_verifies_signature_audience_expiry_and_permission() {
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
+28 -3
View File
@@ -179,8 +179,6 @@ pub struct WorkingDirectoryStatus {
pub struct WorkspaceApiRef {
pub workspace_id: String,
pub base_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
}
impl std::fmt::Debug for WorkspaceApiRef {
@@ -189,7 +187,6 @@ impl std::fmt::Debug for WorkspaceApiRef {
.debug_struct("WorkspaceApiRef")
.field("workspace_id", &self.workspace_id)
.field("base_url", &self.base_url)
.field("runtime_id", &self.runtime_id)
.finish()
}
}
@@ -292,3 +289,31 @@ pub struct WorkerLifecycleAck {
pub worker_ref: WorkerRef,
pub status: WorkerStatus,
}
#[cfg(test)]
mod tests {
use super::WorkspaceApiRef;
#[test]
fn workspace_api_ref_public_schema_contains_no_source_credentials_or_claim_choices() {
let value = serde_json::to_value(WorkspaceApiRef {
workspace_id: "workspace-a".to_string(),
base_url: "https://server.invalid".to_string(),
})
.unwrap();
let object = value.as_object().unwrap();
assert_eq!(object.len(), 2);
assert!(object.contains_key("workspace_id"));
assert!(object.contains_key("base_url"));
for forbidden in [
"runtime_id",
"worker_id",
"permission",
"private_key",
"bearer_token",
"signing_handle",
] {
assert!(!object.contains_key(forbidden), "unexpected {forbidden}");
}
}
}
+6
View File
@@ -245,7 +245,10 @@ impl fmt::Debug for WorkerExecutionContext {
#[derive(Clone, Debug)]
pub struct WorkerExecutionSpawnRequest {
pub worker_ref: WorkerRef,
/// Monotonic execution generation reserved durably before launch.
pub run_generation: u64,
pub request: crate::catalog::CreateWorkerRequest,
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
pub context: WorkerExecutionContext,
pub working_directory: Option<WorkingDirectoryBinding>,
pub config_bundle: Option<ConfigBundle>,
@@ -255,7 +258,10 @@ pub struct WorkerExecutionSpawnRequest {
#[derive(Clone, Debug)]
pub struct WorkerExecutionRestoreRequest {
pub worker_ref: WorkerRef,
/// Monotonic execution generation reserved durably before restore.
pub run_generation: u64,
pub request: crate::catalog::CreateWorkerRequest,
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
pub context: WorkerExecutionContext,
pub previous_working_directory: Option<WorkingDirectoryStatus>,
pub working_directory: Option<WorkingDirectoryBinding>,
File diff suppressed because it is too large Load Diff
+81 -2
View File
@@ -21,6 +21,9 @@ use crate::interaction::{WorkerInput, WorkerInteractionAck};
use crate::management::{RuntimeSummary, WorkerDeleteResult};
#[cfg(feature = "ws-server")]
use crate::observation::WorkerObservationCursor;
use crate::retention::{
WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory,
};
#[cfg(feature = "ws-server")]
use crate::runtime::RuntimeSubscriptionRecvError;
use crate::{Runtime, RuntimeWorkspaceScope};
@@ -217,6 +220,14 @@ fn runtime_http_router_with_optional_auth(
"/v1/workers/{worker_id}",
get(get_worker).delete(delete_worker),
)
.route(
"/v1/workers/{worker_id}/retention/inventory",
get(worker_retention_inventory),
)
.route(
"/v1/workers/{worker_id}/retention/execute",
post(execute_worker_retention),
)
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
.route("/v1/workers/{worker_id}/restore", post(restore_worker))
.route(
@@ -1220,6 +1231,61 @@ fn protocol_error_event(message: impl Into<String>) -> protocol::Event {
}
}
async fn worker_retention_inventory(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>,
) -> RestResult<WorkerRetentionInventory> {
let scope = auth_workspace_scope(&state, auth.as_ref())?.ok_or_else(|| {
RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_scope_required",
"Worker retention inventory requires workspace-scoped authorization",
)
})?;
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
state
.runtime
.worker_retention_inventory(&scope.workspace_id, &worker_ref)
.map(Json)
.map_err(RuntimeHttpRestError::runtime)
}
async fn execute_worker_retention(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>,
body: Result<Json<WorkerRetentionExecutionRequest>, JsonRejection>,
) -> RestResult<WorkerRetentionExecutionResult> {
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
if request.worker_id.to_string() != worker_id {
return Err(RuntimeHttpRestError::new(
StatusCode::BAD_REQUEST,
"worker_id_mismatch",
"Retention request worker_id does not match the route",
));
}
let scope = auth_workspace_scope(&state, auth.as_ref())?.ok_or_else(|| {
RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_scope_required",
"Worker retention execution requires workspace-scoped authorization",
)
})?;
if request.workspace_id != scope.workspace_id {
return Err(RuntimeHttpRestError::new(
StatusCode::NOT_FOUND,
"worker_not_found",
"Worker was not found in the authenticated Workspace",
));
}
state
.runtime
.execute_worker_retention(&request)
.map(Json)
.map_err(RuntimeHttpRestError::runtime)
}
async fn send_worker_input(
State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
@@ -1473,6 +1539,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
if path.ends_with("/completions") {
return Some("workers:read");
}
if path.contains("/retention/") {
return Some("workers:delete");
}
if path.starts_with("/v1/workers/") && *method == Method::DELETE {
return Some("workers:delete");
}
@@ -1753,7 +1822,6 @@ mod tests {
request.workspace_api = Some(WorkspaceApiRef {
workspace_id: workspace_id.to_string(),
base_url: format!("https://workspace.example/{workspace_id}"),
runtime_id: None,
});
request
}
@@ -2067,6 +2135,18 @@ mod tests {
}
}
#[test]
fn retention_routes_require_worker_delete_permission() {
assert_eq!(
required_runtime_permission(&Method::GET, "/v1/workers/worker-1/retention/inventory",),
Some("workers:delete")
);
assert_eq!(
required_runtime_permission(&Method::POST, "/v1/workers/worker-1/retention/execute",),
Some("workers:delete")
);
}
#[test]
fn workdir_routes_require_dedicated_operation_permission() {
assert_eq!(
@@ -2324,7 +2404,6 @@ mod tests {
workspace_api: WorkspaceApiRef {
workspace_id: "local".to_string(),
base_url: "http://127.0.0.1:8787".to_string(),
runtime_id: None,
},
},
)
+3
View File
@@ -22,8 +22,11 @@ pub mod management;
pub mod observation;
pub mod profile_archive;
pub mod resource;
#[cfg(feature = "fs-store")]
pub mod retention;
mod runtime;
pub mod worker_backend;
pub mod worker_source;
pub mod working_directory;
#[cfg(feature = "fs-store")]
+16 -3
View File
@@ -18,7 +18,7 @@ use worker_runtime::auth::{
RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, decode_public_key,
};
use worker_runtime::error::RuntimeError;
use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
use worker_runtime::http_server::{
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
};
@@ -80,9 +80,16 @@ fn run() -> Result<(), ProcessError> {
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
let fs_paths = config.resolved_fs_paths();
let runtime_store_dir = match &config.http.store {
RuntimeHttpStoreSelection::Memory => fs_paths.runtime_dir.clone(),
RuntimeHttpStoreSelection::Fs { root } => root.clone(),
_ => fs_paths.runtime_dir.clone(),
};
let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root"))
.with_store_dir(fs_paths.worker_dir.join("sessions"))
.with_worker_metadata_dir(fs_paths.worker_dir.join("metadata"));
.with_runtime_store_dir(runtime_store_dir);
if let Some(identity) = read_runtime_auth_file(&runtime_auth_path(config))?.identity {
factory = factory.with_remote_worker_mutation_identity(identity);
}
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
factory = factory.with_resource_client(Arc::new(
worker_runtime::resource::HttpBackendResourceClient::new(
@@ -105,6 +112,12 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
.map_err(ProcessError::Runtime)
}
RuntimeHttpStoreSelection::Fs { root } => {
FsRuntimeStore::migrate_legacy_worker_aggregates(
root,
fs_paths.worker_dir.join("sessions"),
fs_paths.worker_dir.join("metadata"),
)
.map_err(ProcessError::Runtime)?;
let mut options = FsRuntimeStoreOptions::new(root.clone());
options.display_name = config.http.display_name.clone();
Runtime::with_fs_store_and_execution_backend(options, backend)
File diff suppressed because it is too large Load Diff
+263 -38
View File
@@ -26,6 +26,11 @@ use crate::management::{
};
#[cfg(feature = "ws-server")]
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
#[cfg(feature = "fs-store")]
use crate::retention::{
FsWorkerRetentionProvider, WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult,
WorkerRetentionInventory, WorkerRetentionInventorySnapshot, WorkerRetentionProvider,
};
use protocol::subscription::{
EventSubscriptionSelector, SubscriptionEventPayload, SubscriptionSnapshot,
SubscriptionValidationError, SubscriptionWorkdirId, SubscriptionWorker, SubscriptionWorkerId,
@@ -532,13 +537,18 @@ impl Runtime {
status: WorkerStatus::Stopped,
workspace_id: scope.map(|scope| scope.workspace_id.clone()),
request: request.clone(),
run_generation: 1,
working_directory: None,
execution_handle: None,
};
state.workers.insert(worker_id, record);
state.persist_runtime_snapshot()?;
state.persist_worker(&worker_ref.worker_id)?;
let spawn_request = WorkerExecutionSpawnRequest {
worker_ref: worker_ref.clone(),
run_generation: 1,
request,
workspace_scope: scope.cloned(),
context: self.execution_context(worker_ref.clone()),
working_directory: None,
config_bundle: None,
@@ -817,13 +827,10 @@ impl Runtime {
if let Some(existing) = worker.request.workspace_api.as_ref()
&& (existing.workspace_id != workspace_api.workspace_id
|| existing.base_url.trim_end_matches('/')
!= workspace_api.base_url.trim_end_matches('/')
|| existing.runtime_id.as_ref().is_some_and(|runtime_id| {
workspace_api.runtime_id.as_ref() != Some(runtime_id)
}))
!= workspace_api.base_url.trim_end_matches('/'))
{
return Err(RuntimeError::InvalidRequest(
"Workspace API replacement cannot change Worker Workspace identity, Runtime identity, or base URL"
"Workspace API replacement cannot change Worker Workspace identity or base URL"
.to_string(),
));
}
@@ -859,35 +866,53 @@ impl Runtime {
/// present this is idempotent; otherwise the configured backend is tried.
pub fn restore_worker(&self, worker_ref: &WorkerRef) -> Result<WorkerDetail, RuntimeError> {
let (backend, request) = {
let state = self.lock()?;
let mut state = self.lock()?;
state.ensure_running()?;
let worker = state.worker(worker_ref)?;
if worker.execution_handle.is_some() {
return Ok(worker.detail());
}
if worker.status == WorkerStatus::Cancelled {
return Err(RuntimeError::InvalidRequest(format!(
"worker {} is cancelled",
worker_ref.worker_id
)));
}
let (worker_request, previous_working_directory, config_bundle, run_generation) = {
let worker = state.worker(worker_ref)?;
if worker.execution_handle.is_some() {
return Ok(worker.detail());
}
if worker.status == WorkerStatus::Cancelled {
return Err(RuntimeError::InvalidRequest(format!(
"worker {} is cancelled",
worker_ref.worker_id
)));
}
let config_bundle = worker
.request
.config_bundle
.as_ref()
.and_then(|bundle_ref| state.config_bundles.get(&bundle_ref.id))
.cloned();
(
worker.request.clone(),
worker.working_directory.clone(),
config_bundle,
worker.run_generation.saturating_add(1).max(1),
)
};
let backend = state.execution_backend.clone().ok_or_else(|| {
RuntimeError::WorkerExecutionUnavailable {
worker_id: worker_ref.worker_id.clone(),
message: "runtime has no execution backend".to_string(),
}
})?;
let config_bundle = worker
.request
.config_bundle
.as_ref()
.and_then(|bundle_ref| state.config_bundles.get(&bundle_ref.id))
.cloned();
state.worker_mut(worker_ref)?.run_generation = run_generation;
state.persist_worker(&worker_ref.worker_id)?;
let workspace_scope = worker_request.workspace_api.as_ref().and_then(|api| {
state
.workspace_owners
.get(&api.workspace_id)
.map(|server_id| RuntimeWorkspaceScope::new(&api.workspace_id, server_id))
});
let request = WorkerExecutionRestoreRequest {
worker_ref: worker_ref.clone(),
request: worker.request.clone(),
run_generation,
request: worker_request,
workspace_scope,
context: self.execution_context(worker_ref.clone()),
previous_working_directory: worker.working_directory.clone(),
previous_working_directory,
working_directory: None,
config_bundle,
};
@@ -1514,47 +1539,85 @@ impl Runtime {
struct RestoreCandidate {
worker_ref: WorkerRef,
request: CreateWorkerRequest,
run_generation: u64,
previous_working_directory: Option<CatalogWorkingDirectoryStatus>,
config_bundle: Option<ConfigBundle>,
}
let candidates = {
let state = self.lock()?;
let mut state = self.lock()?;
if state.execution_backend.is_none() {
return Ok(());
}
state
let worker_ids = state
.workers
.values()
.filter(|worker| worker.execution_handle.is_none())
.map(|worker| {
.map(|worker| worker.worker_id)
.collect::<Vec<_>>();
let mut candidates = Vec::with_capacity(worker_ids.len());
for worker_id in worker_ids {
let (
worker_ref,
request,
previous_working_directory,
config_bundle,
run_generation,
) = {
let worker = state
.workers
.get(&worker_id)
.expect("collected Worker exists");
let config_bundle = worker
.request
.config_bundle
.as_ref()
.and_then(|bundle_ref| state.config_bundles.get(&bundle_ref.id))
.cloned();
RestoreCandidate {
worker_ref: worker.worker_ref.clone(),
request: worker.request.clone(),
previous_working_directory: worker.working_directory.clone(),
(
worker.worker_ref.clone(),
worker.request.clone(),
worker.working_directory.clone(),
config_bundle,
}
})
.collect::<Vec<_>>()
worker.run_generation.saturating_add(1).max(1),
)
};
state
.workers
.get_mut(&worker_id)
.expect("collected Worker exists")
.run_generation = run_generation;
state.persist_worker(&worker_id)?;
candidates.push(RestoreCandidate {
worker_ref,
request,
run_generation,
previous_working_directory,
config_bundle,
});
}
candidates
};
for candidate in candidates {
let backend = {
let (backend, workspace_scope) = {
let state = self.lock()?;
state.execution_backend.clone()
let workspace_scope = candidate.request.workspace_api.as_ref().and_then(|api| {
state
.workspace_owners
.get(&api.workspace_id)
.map(|server_id| RuntimeWorkspaceScope::new(&api.workspace_id, server_id))
});
(state.execution_backend.clone(), workspace_scope)
};
let Some(backend) = backend else {
return Ok(());
};
let request = WorkerExecutionRestoreRequest {
worker_ref: candidate.worker_ref.clone(),
run_generation: candidate.run_generation,
request: candidate.request,
workspace_scope,
context: self.execution_context(candidate.worker_ref.clone()),
previous_working_directory: candidate.previous_working_directory,
working_directory: None,
@@ -1602,6 +1665,139 @@ impl Runtime {
Ok(())
}
/// Bind the Backend registry identity once. Retention evidence fails closed
/// until the Runtime host supplies this trusted configuration.
pub fn bind_runtime_identity(&self, runtime_id: &str) -> Result<(), RuntimeError> {
if runtime_id.trim().is_empty() || runtime_id.len() > 160 {
return Err(RuntimeError::InvalidRequest(
"Runtime identity must be non-empty and bounded".to_string(),
));
}
let mut state = self.lock()?;
match state.runtime_identity.as_deref() {
Some(current) if current == runtime_id => Ok(()),
Some(_) => Err(RuntimeError::InvalidRequest(
"Runtime identity is already bound".to_string(),
)),
None => {
state.runtime_identity = Some(runtime_id.to_string());
Ok(())
}
}
}
/// Read canonical aggregate facts needed by a Backend removal plan.
#[cfg(feature = "fs-store")]
pub fn worker_retention_inventory(
&self,
workspace_id: &str,
worker_ref: &WorkerRef,
) -> Result<WorkerRetentionInventory, RuntimeError> {
let state = self.lock()?;
let runtime_id = state.runtime_identity.as_deref().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Runtime identity is not bound for Worker retention".to_string(),
)
})?;
let worker = state.worker(worker_ref)?;
if worker.workspace_id.as_deref() != Some(workspace_id) {
return Err(RuntimeError::WorkerNotFound {
worker_id: worker_ref.worker_id,
});
}
let store = state.fs_store().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Worker retention archive authority requires an fs-backed Runtime".to_string(),
)
})?;
FsWorkerRetentionProvider::new(store.runtime_dir()).inventory(
workspace_id,
runtime_id,
worker.worker_id,
worker.run_generation,
)
}
/// Enumerate host-authoritative Runtime inventory for Backend orphan
/// reconciliation. Runtime identity and Workspace scope are derived here,
/// not accepted in a diagnostic payload.
#[cfg(feature = "fs-store")]
pub fn list_worker_retention_inventory(
&self,
workspace_id: &str,
) -> Result<WorkerRetentionInventorySnapshot, RuntimeError> {
let state = self.lock()?;
let runtime_id = state.runtime_identity.as_deref().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Runtime identity is not bound for Worker retention".to_string(),
)
})?;
let store = state.fs_store().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Worker retention inventory requires an fs-backed Runtime".to_string(),
)
})?;
let provider = FsWorkerRetentionProvider::new(store.runtime_dir());
provider.snapshot(workspace_id, runtime_id)
}
/// Execute a Backend-resolved retention plan. Only stopped Workers are
/// eligible. Provider receipt lookup happens before live lookup so exact
/// retries converge after aggregate removal.
#[cfg(feature = "fs-store")]
pub fn execute_worker_retention(
&self,
request: &WorkerRetentionExecutionRequest,
) -> Result<WorkerRetentionExecutionResult, RuntimeError> {
let mut state = self.lock()?;
let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Runtime identity is not bound for Worker retention".to_string(),
)
})?;
if request.source_runtime_id != runtime_id {
return Err(RuntimeError::InvalidRequest(
"Worker retention Runtime identity mismatch".to_string(),
));
}
let store = state.fs_store().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Worker retention execution requires an fs-backed Runtime".to_string(),
)
})?;
let provider = FsWorkerRetentionProvider::new(store.runtime_dir());
if let Some(completed) = provider.completed_for(request)? {
state.workers.remove(&request.worker_id);
state.persist_runtime_snapshot()?;
return Ok(completed);
}
let Some(worker) = state.workers.get(&request.worker_id) else {
// Recover a pending receipt after a crash between aggregate removal
// and final receipt/Runtime catalog commit.
return provider.recover_after_source_removal(request);
};
if worker.workspace_id.as_deref() != Some(request.workspace_id.as_str()) {
return Err(RuntimeError::WorkerNotFound {
worker_id: request.worker_id,
});
}
if worker.status != WorkerStatus::Stopped {
return Err(RuntimeError::InvalidRequest(
"Worker retention requires a stopped Worker".to_string(),
));
}
if worker.run_generation != request.expected_run_generation {
return Err(RuntimeError::InvalidRequest(format!(
"Worker retention plan expected generation {}, current generation is {}",
request.expected_run_generation, worker.run_generation
)));
}
let result = provider.execute(request)?;
state.workers.remove(&request.worker_id);
state.persist_runtime_snapshot()?;
Ok(result)
}
fn lock(&self) -> Result<MutexGuard<'_, RuntimeState>, RuntimeError> {
self.inner.lock().map_err(|_| RuntimeError::StatePoisoned)
}
@@ -1627,6 +1823,9 @@ struct SubscriptionSink {
struct RuntimeState {
display_name: Option<String>,
backend: RuntimeBackendKind,
/// Backend-bound stable identity used for cross-boundary retention evidence.
/// It is configured once by the Runtime host and never model input.
runtime_identity: Option<String>,
#[cfg_attr(not(feature = "fs-store"), allow(dead_code))]
persistence: RuntimePersistence,
status: RuntimeStatus,
@@ -1655,6 +1854,7 @@ impl RuntimeState {
Self {
display_name,
backend: RuntimeBackendKind::Memory,
runtime_identity: None,
persistence: RuntimePersistence::Memory,
status: RuntimeStatus::Running,
execution_backend: None,
@@ -1683,6 +1883,7 @@ impl RuntimeState {
Self {
display_name,
backend: RuntimeBackendKind::FsStore,
runtime_identity: None,
persistence: RuntimePersistence::Fs(store),
status: RuntimeStatus::Running,
execution_backend: None,
@@ -1723,6 +1924,7 @@ impl RuntimeState {
status: WorkerStatus::Stopped,
workspace_id: worker.workspace_id,
request: worker.request,
run_generation: worker.run_generation,
working_directory: worker.working_directory,
execution_handle: None,
},
@@ -1732,6 +1934,7 @@ impl RuntimeState {
Ok(Self {
display_name: persisted.display_name,
backend: RuntimeBackendKind::FsStore,
runtime_identity: None,
persistence: RuntimePersistence::Fs(store),
status: persisted.status,
execution_backend: None,
@@ -2281,6 +2484,7 @@ struct WorkerRecord {
status: WorkerStatus,
workspace_id: Option<String>,
request: CreateWorkerRequest,
run_generation: u64,
working_directory: Option<CatalogWorkingDirectoryStatus>,
execution_handle: Option<WorkerExecutionHandle>,
}
@@ -2324,6 +2528,7 @@ impl WorkerRecord {
worker_ref: self.worker_ref.clone(),
worker_id: self.worker_id.clone(),
request: self.request.clone(),
run_generation: self.run_generation,
workspace_id: self.workspace_id.clone(),
working_directory: self.working_directory.clone(),
}
@@ -2501,6 +2706,18 @@ mod tests {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
#[test]
fn runtime_identity_binding_is_immutable_and_host_owned() {
let runtime = Runtime::new_memory();
runtime.bind_runtime_identity("runtime-a").unwrap();
runtime.bind_runtime_identity("runtime-a").unwrap();
assert!(runtime.bind_runtime_identity("runtime-b").is_err());
assert_eq!(
runtime.lock().unwrap().runtime_identity.as_deref(),
Some("runtime-a")
);
}
#[test]
fn typed_segments_allow_empty_flat_content() {
let input = WorkerInput {
@@ -2585,7 +2802,6 @@ mod tests {
request.workspace_api = Some(WorkspaceApiRef {
workspace_id: workspace_id.to_string(),
base_url: format!("https://workspace.example/{workspace_id}"),
runtime_id: None,
});
request
}
@@ -2627,6 +2843,7 @@ mod tests {
dispatch_result: Mutex<Option<WorkerExecutionResult>>,
restore_result: Mutex<Option<WorkerExecutionSpawnResult>>,
restore_count: Mutex<u64>,
run_generations: Mutex<Vec<u64>>,
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
dispatched_inputs: Mutex<Vec<WorkerInput>>,
preserve_commit_ack_submission_id: AtomicBool,
@@ -2670,6 +2887,10 @@ mod tests {
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
self.run_generations
.lock()
.unwrap()
.push(request.run_generation);
self.contexts
.lock()
.unwrap()
@@ -2689,6 +2910,10 @@ mod tests {
request: WorkerExecutionRestoreRequest,
) -> WorkerExecutionSpawnResult {
*self.restore_count.lock().unwrap() += 1;
self.run_generations
.lock()
.unwrap()
.push(request.run_generation);
if let Some(result) = self.restore_result.lock().unwrap().clone() {
return result;
}
@@ -3025,7 +3250,6 @@ mod tests {
let replacement = WorkspaceApiRef {
workspace_id: "workspace-a".to_string(),
base_url: "https://workspace.example/workspace-a/".to_string(),
runtime_id: Some("runtime-a".to_string()),
};
runtime
@@ -3551,6 +3775,7 @@ mod tests {
.unwrap();
assert_eq!(*backend.restore_count.lock().unwrap(), 1);
assert_eq!(*backend.run_generations.lock().unwrap(), vec![1, 2]);
assert_eq!(
runtime.worker_detail(&detail.worker_ref).unwrap().status,
WorkerStatus::Idle
+253 -160
View File
@@ -10,10 +10,11 @@
use std::collections::HashMap;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
use crate::auth::RuntimeIdentityMaterial;
use crate::catalog::{
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
WorkingDirectoryRequest, WorkingDirectoryStatus,
@@ -26,13 +27,19 @@ use crate::execution::{
use crate::identity::WorkerRef;
use crate::interaction::{WorkerInput, WorkerInputKind};
use crate::resource::{BackendResourceClient, ProfileSourceArchiveCache};
use crate::worker_source::{
EmbeddedWorkerMutationDispatcher, RuntimeOwnedWorkspaceClient, RuntimeWorkerMutationForwarder,
};
use crate::working_directory::{
WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer,
};
use async_trait::async_trait;
use manifest::paths;
use protocol::{Event, Method, Segment, WorkerStatus};
use session_store::{CombinedStore, FsStore, FsWorkerStore, LogEntry, collect_state};
use session_store::{
CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore, collect_state,
};
#[cfg(test)]
use session_store::{FsStore, FsWorkerStore};
use tokio::runtime::Runtime;
#[cfg(feature = "ws-server")]
use tokio::sync::broadcast;
@@ -46,10 +53,9 @@ use worker::feature::builtin::{
#[cfg(feature = "ws-server")]
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
use worker::{
PromptLoader, RuntimeWorkspaceHttpClient, SegmentLogSink,
WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerController, WorkerError,
WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState, WorkerWorkspaceContext,
WorkspaceClient, WorkspaceId,
PromptLoader, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker,
WorkerController, WorkerError, WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState,
WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
};
const DEFAULT_BACKEND_ID: &str = "worker-crate";
@@ -57,7 +63,6 @@ const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
// Keep this below the adapter task timeout so a failed acknowledgement task
// returns a typed execution error instead of leaving the outer waiter to time out.
const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
static NEXT_RUNTIME_ARTIFACT_ROOT: AtomicU64 = AtomicU64::new(1);
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
let LogEntry::UserInput { extensions, .. } = entry else {
@@ -69,43 +74,9 @@ fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
})
}
#[derive(Clone)]
enum RuntimeArtifactRoot {
Owned(Arc<OwnedRuntimeArtifactRoot>),
External(PathBuf),
}
impl RuntimeArtifactRoot {
fn owned() -> Self {
let sequence = NEXT_RUNTIME_ARTIFACT_ROOT.fetch_add(1, Ordering::Relaxed);
Self::Owned(Arc::new(OwnedRuntimeArtifactRoot {
path: std::env::temp_dir().join(format!(
"yoi-worker-runtime-artifacts-{}-{sequence}",
std::process::id()
)),
}))
}
fn path(&self) -> &std::path::Path {
match self {
Self::Owned(root) => &root.path,
Self::External(path) => path,
}
}
}
struct OwnedRuntimeArtifactRoot {
path: PathBuf,
}
impl Drop for OwnedRuntimeArtifactRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
pub struct RuntimeWorkerController {
pub handle: WorkerHandle,
pub shutdown: Arc<tokio::sync::Mutex<Option<worker::ShutdownReceiver>>>,
pub workspace_client: Arc<dyn WorkspaceClient>,
}
@@ -244,11 +215,12 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider {
pub struct ProfileRuntimeWorkerFactory {
observation_hub: Arc<RuntimeWorkerObservationHub>,
profile_base_dir: PathBuf,
store_dir: Option<PathBuf>,
worker_metadata_dir: Option<PathBuf>,
runtime_base_dir: RuntimeArtifactRoot,
worker_aggregate_root: Option<PathBuf>,
resource_client: Option<Arc<dyn BackendResourceClient>>,
profile_archive_cache: Arc<ProfileSourceArchiveCache>,
runtime_id: Option<String>,
worker_mutation_identity: Option<RuntimeIdentityMaterial>,
embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>,
}
impl ProfileRuntimeWorkerFactory {
@@ -257,26 +229,43 @@ impl ProfileRuntimeWorkerFactory {
Self {
observation_hub: Arc::new(RuntimeWorkerObservationHub::default()),
profile_base_dir,
store_dir: None,
worker_metadata_dir: None,
runtime_base_dir: RuntimeArtifactRoot::owned(),
worker_aggregate_root: None,
resource_client: None,
profile_archive_cache: Arc::new(ProfileSourceArchiveCache::default()),
runtime_id: None,
worker_mutation_identity: None,
embedded_worker_mutation_dispatcher: None,
}
}
pub fn with_store_dir(mut self, store_dir: impl Into<PathBuf>) -> Self {
self.store_dir = Some(store_dir.into());
pub fn with_runtime_id(mut self, runtime_id: impl Into<String>) -> Self {
self.runtime_id = Some(runtime_id.into());
self
}
pub fn with_worker_metadata_dir(mut self, worker_metadata_dir: impl Into<PathBuf>) -> Self {
self.worker_metadata_dir = Some(worker_metadata_dir.into());
pub fn with_remote_worker_mutation_identity(
mut self,
identity: RuntimeIdentityMaterial,
) -> Self {
self.runtime_id = Some(identity.identity_id.clone());
self.worker_mutation_identity = Some(identity);
self.embedded_worker_mutation_dispatcher = None;
self
}
pub fn with_runtime_base_dir(mut self, runtime_base_dir: impl Into<PathBuf>) -> Self {
self.runtime_base_dir = RuntimeArtifactRoot::External(runtime_base_dir.into());
pub fn with_embedded_worker_mutation_dispatcher(
mut self,
runtime_id: impl Into<String>,
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
) -> Self {
self.runtime_id = Some(runtime_id.into());
self.worker_mutation_identity = None;
self.embedded_worker_mutation_dispatcher = Some(dispatcher);
self
}
pub fn with_runtime_store_dir(mut self, runtime_store_dir: impl Into<PathBuf>) -> Self {
self.worker_aggregate_root = Some(runtime_store_dir.into().join("workers"));
self
}
@@ -285,28 +274,16 @@ impl ProfileRuntimeWorkerFactory {
self
}
fn store_dir(&self) -> Result<PathBuf, String> {
self.store_dir
.clone()
.or_else(paths::sessions_dir)
fn worker_aggregate_dir(&self, worker_ref: &WorkerRef) -> Result<PathBuf, String> {
self.worker_aggregate_root
.as_ref()
.map(|root| root.join(worker_ref.worker_id.to_string()))
.ok_or_else(|| {
"could not resolve sessions directory (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)"
"Runtime Worker aggregate root is not configured; global Session/metadata roots are migration-only"
.to_string()
})
}
fn worker_metadata_dir(&self, store_dir: &std::path::Path) -> PathBuf {
self.worker_metadata_dir
.clone()
.or_else(|| paths::data_dir().map(|data_dir| data_dir.join("workers")))
.or_else(|| store_dir.parent().map(|parent| parent.join("workers")))
.unwrap_or_else(|| PathBuf::from("workers"))
}
fn runtime_base_dir(&self) -> Result<PathBuf, String> {
Ok(self.runtime_base_dir.path().to_path_buf())
}
fn runtime_worker_name_for_ref(worker_ref: &crate::identity::WorkerRef) -> String {
format!("worker-runtime-{}", worker_ref.worker_id)
}
@@ -406,38 +383,59 @@ enum RuntimeWorkspaceBackendRef {
}
impl RuntimeWorkspaceBackendRef {
fn from_worker_request(request: &CreateWorkerRequest) -> Self {
if let Some(api) = request.workspace_api.as_ref()
&& let Some(runtime_id) = api
.runtime_id
.as_ref()
.filter(|runtime_id| !runtime_id.trim().is_empty())
{
fn from_worker_request(request: &CreateWorkerRequest, runtime_id: Option<&str>) -> Self {
if let (Some(api), Some(runtime_id)) = (request.workspace_api.as_ref(), runtime_id) {
return Self::Http {
workspace_id: api.workspace_id.clone(),
base_url: api.base_url.clone(),
runtime_id: runtime_id.clone(),
runtime_id: runtime_id.to_string(),
};
}
Self::None
}
fn worker_context(&self, worker_ref: &WorkerRef) -> WorkerWorkspaceContext {
fn worker_context(
&self,
worker_ref: &WorkerRef,
workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>,
mutation_identity: Option<&RuntimeIdentityMaterial>,
embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>,
) -> WorkerWorkspaceContext {
match self {
Self::None => WorkerWorkspaceContext::no_workspace(),
Self::Http {
workspace_id,
base_url,
runtime_id,
} => WorkerWorkspaceContext::with_client(
WorkspaceId::new(workspace_id.clone()).ok(),
Arc::new(RuntimeWorkspaceHttpClient::new(
} => {
let mut client = RuntimeOwnedWorkspaceClient::new(
workspace_id.clone(),
base_url.clone(),
runtime_id.clone(),
worker_ref.worker_id.to_string(),
)),
),
);
if let (Some(scope), Some(identity)) = (workspace_scope, mutation_identity) {
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote(
identity,
scope.clone(),
worker_ref.worker_id.to_string(),
base_url.clone(),
));
} else if let (Some(scope), Some(dispatcher)) =
(workspace_scope, embedded_dispatcher)
{
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::embedded(
runtime_id,
scope.clone(),
worker_ref.worker_id.to_string(),
(*dispatcher).clone(),
));
}
WorkerWorkspaceContext::with_client(
WorkspaceId::new(workspace_id.clone()).ok(),
Arc::new(client),
)
}
}
}
}
@@ -536,13 +534,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
)
})
.unwrap_or(WorkerFilesystemAuthority::None);
let workspace_backend_ref =
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
let observation_runtime_id = request
.request
.workspace_api
.as_ref()
.and_then(|api| api.runtime_id.clone());
let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request(
&request.request,
self.runtime_id.as_deref(),
);
let observation_runtime_id = self.runtime_id.clone();
let observation_workspace_id = request
.request
.workspace_api
@@ -550,7 +546,12 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
.map(|api| api.workspace_id.clone());
let observation_grants = request.request.worker_observation_grants.clone();
let observation_enabled = request.request.worker_observation_enabled;
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
let workspace_context = workspace_backend_ref.worker_context(
&request.worker_ref,
request.workspace_scope.as_ref(),
self.worker_mutation_identity.as_ref(),
self.embedded_worker_mutation_dispatcher.as_ref(),
);
let selector = profile.as_ref();
let archive = self
.resolve_profile_source_archive(&request.request.profile_source)
@@ -575,20 +576,23 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
};
let flow_transition_enabled = manifest.feature.flow.enabled;
let store_dir = self.store_dir()?;
let session_store = FsStore::new(&store_dir).map_err(|err| {
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
let session_dir = worker_aggregate_dir.join("session");
let session_store = WorkerSessionStore::new(&session_dir).map_err(|err| {
format!(
"failed to initialize session store at {}: {err}",
store_dir.display()
)
})?;
let worker_metadata_dir = self.worker_metadata_dir(&store_dir);
let worker_metadata_store = FsWorkerStore::new(&worker_metadata_dir).map_err(|err| {
format!(
"failed to initialize worker metadata store at {}: {err}",
worker_metadata_dir.display()
"failed to initialize canonical Worker Session store at {}: {err}",
session_dir.display()
)
})?;
let worker_metadata_store =
WorkerAggregateStore::new(&worker_aggregate_dir, worker_name.clone()).map_err(
|err| {
format!(
"failed to initialize canonical Worker metadata store at {}: {err}",
worker_aggregate_dir.display()
)
},
)?;
let store = CombinedStore::new(session_store, worker_metadata_store);
let mut worker = Worker::from_manifest_with_context(
@@ -642,10 +646,17 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
}
let workspace_client = worker.workspace_client_handle();
let runtime_base = self.runtime_base_dir()?;
let (handle, _shutdown_rx) = WorkerController::spawn_runtime_managed(worker, &runtime_base)
let run_dir = worker_aggregate_dir
.join("runs")
.join(request.run_generation.to_string());
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed_run(worker, &run_dir)
.await
.map_err(|err| format!("failed to spawn Worker controller: {err}"))?;
.map_err(|err| {
format!(
"failed to spawn Worker controller in {}: {err}",
run_dir.display()
)
})?;
if flow_transition_enabled {
handle.shared_state.enable_flow_transition();
}
@@ -656,6 +667,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
);
Ok(RuntimeWorkerController {
handle,
shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))),
workspace_client,
})
}
@@ -675,13 +687,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
)
})
.unwrap_or(WorkerFilesystemAuthority::None);
let workspace_backend_ref =
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
let observation_runtime_id = request
.request
.workspace_api
.as_ref()
.and_then(|api| api.runtime_id.clone());
let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request(
&request.request,
self.runtime_id.as_deref(),
);
let observation_runtime_id = self.runtime_id.clone();
let observation_workspace_id = request
.request
.workspace_api
@@ -689,29 +699,37 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
.map(|api| api.workspace_id.clone());
let observation_grants = request.request.worker_observation_grants.clone();
let observation_enabled = request.request.worker_observation_enabled;
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
let workspace_context = workspace_backend_ref.worker_context(
&request.worker_ref,
request.workspace_scope.as_ref(),
self.worker_mutation_identity.as_ref(),
self.embedded_worker_mutation_dispatcher.as_ref(),
);
let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?;
let store_dir = self.store_dir()?;
let session_store = FsStore::new(&store_dir).map_err(|err| {
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
let session_dir = worker_aggregate_dir.join("session");
let session_store = WorkerSessionStore::new(&session_dir).map_err(|err| {
format!(
"failed to initialize session store at {}: {err}",
store_dir.display()
)
})?;
let worker_metadata_dir = self.worker_metadata_dir(&store_dir);
let worker_metadata_store = FsWorkerStore::new(&worker_metadata_dir).map_err(|err| {
format!(
"failed to initialize worker metadata store at {}: {err}",
worker_metadata_dir.display()
"failed to initialize canonical Worker Session store at {}: {err}",
session_dir.display()
)
})?;
let worker_metadata_store =
WorkerAggregateStore::new(&worker_aggregate_dir, worker_name.clone()).map_err(
|err| {
format!(
"failed to initialize canonical Worker metadata store at {}: {err}",
worker_aggregate_dir.display()
)
},
)?;
let store = CombinedStore::new(session_store, worker_metadata_store);
let mut worker = match Worker::restore_from_worker_metadata_with_context(
&worker_name,
manifest.clone(),
store,
store.clone(),
loader.clone(),
workspace_context.clone(),
filesystem_authority.clone(),
@@ -722,20 +740,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
Err(WorkerError::WorkerMetadataPending { .. })
if request.request.initial_input.is_none() =>
{
let session_store = FsStore::new(&store_dir).map_err(|err| {
format!(
"failed to initialize session store at {}: {err}",
store_dir.display()
)
})?;
let worker_metadata_store =
FsWorkerStore::new(&worker_metadata_dir).map_err(|err| {
format!(
"failed to initialize worker metadata store at {}: {err}",
worker_metadata_dir.display()
)
})?;
let store = CombinedStore::new(session_store, worker_metadata_store);
Worker::restore_pending_from_worker_metadata_with_context(
&worker_name,
manifest.clone(),
@@ -792,10 +796,17 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
}
let workspace_client = worker.workspace_client_handle();
let runtime_base = self.runtime_base_dir()?;
let (handle, _shutdown_rx) = WorkerController::spawn_runtime_managed(worker, &runtime_base)
let run_dir = worker_aggregate_dir
.join("runs")
.join(request.run_generation.to_string());
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed_run(worker, &run_dir)
.await
.map_err(|err| format!("failed to spawn restored Worker controller: {err}"))?;
.map_err(|err| {
format!(
"failed to spawn restored Worker controller in {}: {err}",
run_dir.display()
)
})?;
if flow_transition_enabled {
handle.shared_state.enable_flow_transition();
}
@@ -806,6 +817,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
);
Ok(RuntimeWorkerController {
handle,
shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))),
workspace_client,
})
}
@@ -813,6 +825,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
struct RuntimeWorkerExecution {
handle: WorkerHandle,
shutdown: Arc<tokio::sync::Mutex<Option<worker::ShutdownReceiver>>>,
busy: Arc<AtomicBool>,
workspace_client: Option<Arc<dyn WorkspaceClient>>,
}
@@ -828,7 +841,10 @@ pub struct WorkerRuntimeExecutionBackend<F = ProfileRuntimeWorkerFactory> {
impl WorkerRuntimeExecutionBackend<ProfileRuntimeWorkerFactory> {
pub fn from_workspace(workspace_root: impl Into<PathBuf>) -> Result<Self, String> {
Self::new(ProfileRuntimeWorkerFactory::new(workspace_root))
let workspace_root = workspace_root.into();
let factory = ProfileRuntimeWorkerFactory::new(&workspace_root)
.with_runtime_store_dir(workspace_root.join(".yoi/runtime-store"));
Self::new(factory)
}
}
@@ -1094,6 +1110,7 @@ where
worker_ref: crate::identity::WorkerRef,
bridge_context: crate::execution::WorkerExecutionContext,
handle: WorkerHandle,
shutdown: Arc<tokio::sync::Mutex<Option<worker::ShutdownReceiver>>>,
working_directory: Option<WorkingDirectoryBinding>,
workspace_client: Option<Arc<dyn WorkspaceClient>>,
) -> WorkerExecutionSpawnResult {
@@ -1157,6 +1174,7 @@ where
worker_ref.clone(),
RuntimeWorkerExecution {
handle,
shutdown,
busy,
workspace_client,
},
@@ -1393,6 +1411,7 @@ where
worker_ref,
bridge_context,
controller.handle,
controller.shutdown,
working_directory,
Some(controller.workspace_client),
)
@@ -1489,6 +1508,7 @@ where
worker_ref,
bridge_context,
controller.handle,
controller.shutdown,
working_directory,
Some(controller.workspace_client),
)
@@ -1698,12 +1718,28 @@ where
"execution handle does not reference a live Worker",
);
};
self.send_method(
let shutdown = execution.shutdown.clone();
let result = self.send_method(
WorkerExecutionOperation::Stop,
execution.handle,
Method::Shutdown,
WorkerExecutionRunState::Stopped,
)
);
if result.outcome != crate::execution::WorkerExecutionOutcome::Accepted {
return result;
}
match self.run_on_adapter_runtime(async move {
let receiver = shutdown.lock().await.take();
if let Some(receiver) = receiver {
receiver
.await
.map_err(|_| "Worker shutdown completion channel closed".to_string())?;
}
Ok(())
}) {
Ok(()) => result,
Err(message) => WorkerExecutionResult::errored(WorkerExecutionOperation::Stop, message),
}
}
fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
@@ -1782,6 +1818,36 @@ mod tests {
use manifest::{Scope, WorkerManifest};
use session_store::{LogEntry, WorkerMetadataStore};
#[test]
fn restart_restore_reconstructs_runtime_owned_worker_mutation_client() {
let identity = RuntimeIdentityMaterial::generate("runtime-source").unwrap();
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(17));
let backend = RuntimeWorkspaceBackendRef::Http {
workspace_id: "workspace-a".to_string(),
base_url: "https://server.invalid".to_string(),
runtime_id: "runtime-source".to_string(),
};
let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main");
let before_restart =
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None);
let after_restore =
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None);
assert_eq!(
before_restart.client_handle().kind(),
"runtime-owned-workspace-client"
);
assert_eq!(
after_restore.client_handle().kind(),
"runtime-owned-workspace-client"
);
assert_eq!(
after_restore.client_handle().workspace_id(),
Some("workspace-a")
);
}
#[test]
fn notify_run_state_allows_running_worker_inbox_delivery() {
assert_eq!(
@@ -1904,9 +1970,16 @@ mod tests {
.as_ref()
.map(|binding| binding.root().to_path_buf())
.unwrap_or_else(|| self.cwd.clone());
let workspace_backend_ref =
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request(
&request.request,
Some("runtime-test"),
);
let workspace_context = workspace_backend_ref.worker_context(
&request.worker_ref,
request.workspace_scope.as_ref(),
None,
None,
);
let workspace_client = workspace_context.client_handle();
self.observed_workspace_clients.lock().unwrap().push((
workspace_client.kind().to_string(),
@@ -1924,12 +1997,13 @@ mod tests {
)
.await
.map_err(|err| err.to_string())?;
let (handle, _shutdown_rx) =
let (handle, shutdown_rx) =
WorkerController::spawn_runtime_managed(worker, &self.runtime_base)
.await
.map_err(|err| err.to_string())?;
Ok(RuntimeWorkerController {
handle,
shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))),
workspace_client,
})
}
@@ -1939,7 +2013,9 @@ mod tests {
) -> Result<RuntimeWorkerController, String> {
let request = WorkerExecutionSpawnRequest {
worker_ref: request.worker_ref,
run_generation: request.run_generation,
request: request.request,
workspace_scope: request.workspace_scope,
context: request.context,
working_directory: request.working_directory,
config_bundle: request.config_bundle,
@@ -2210,7 +2286,9 @@ mod tests {
let worker_ref = crate::identity::WorkerRef::new(crate::identity::WorkerId::new(1));
let request = WorkerExecutionSpawnRequest {
worker_ref: worker_ref.clone(),
run_generation: 1,
request: create_request("1"),
workspace_scope: None,
context: test_execution_context(worker_ref),
working_directory: None,
config_bundle: None,
@@ -2263,9 +2341,9 @@ mod tests {
#[tokio::test]
async fn restore_pending_worker_uses_saved_manifest_snapshot() {
let root = tempfile::tempdir().unwrap();
let store_dir = root.path().join("sessions");
let worker_metadata_dir = root.path().join("workers");
let runtime_store_dir = root.path().join("runtime");
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(1));
let worker_aggregate_dir = runtime_store_dir.join("workers/1");
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
let session_id = session_store::new_session_id();
let manifest = manifest::WorkerManifest::from_toml(&format!(
@@ -2294,7 +2372,7 @@ mod tests {
root.path().display(),
))
.unwrap();
FsWorkerStore::new(&worker_metadata_dir)
WorkerAggregateStore::new(&worker_aggregate_dir, &worker_name)
.unwrap()
.set_active(
&worker_name,
@@ -2309,14 +2387,15 @@ mod tests {
request.workspace_api = Some(crate::catalog::WorkspaceApiRef {
workspace_id: "workspace-restore".to_string(),
base_url: "http://workspace.invalid".to_string(),
runtime_id: Some("runtime-restore".to_string()),
});
let controller = ProfileRuntimeWorkerFactory::new(root.path())
.with_store_dir(&store_dir)
.with_worker_metadata_dir(&worker_metadata_dir)
.with_runtime_id("runtime-restore")
.with_runtime_store_dir(&runtime_store_dir)
.restore_controller(WorkerExecutionRestoreRequest {
worker_ref: worker_ref.clone(),
run_generation: 1,
request,
workspace_scope: None,
context: test_execution_context(worker_ref),
previous_working_directory: None,
working_directory: None,
@@ -2325,8 +2404,23 @@ mod tests {
.await
.expect("pending restore should use the saved manifest snapshot");
assert!(controller.handle.shared_state.flow_transition_enabled());
let run_dir = runtime_store_dir.join("workers/1/runs/1");
assert!(run_dir.join("worker.sock").exists());
assert!(run_dir.join("worker.out.log").is_file());
assert!(run_dir.join("worker.err.log").is_file());
assert!(run_dir.join("artifacts").is_dir());
assert!(run_dir.join("spawned").is_dir());
let shutdown = controller.shutdown.clone();
controller.handle.send(Method::Shutdown).await.unwrap();
if let Some(receiver) = shutdown.lock().await.take() {
receiver.await.unwrap();
}
assert!(
run_dir.is_dir(),
"run evidence remains until a separate retention policy disposes it"
);
assert!(!run_dir.join("worker.sock").exists());
}
#[test]
@@ -2428,7 +2522,6 @@ mod tests {
request.workspace_api = Some(crate::catalog::WorkspaceApiRef {
workspace_id: "ws-test".to_string(),
base_url: "http://127.0.0.1:3999".to_string(),
runtime_id: Some("runtime-test".to_string()),
});
let detail = runtime.create_worker(request).unwrap();
@@ -2461,7 +2554,7 @@ mod tests {
assert_eq!(
observed_workspace_clients.lock().unwrap().as_slice(),
&[(
"runtime-http-proxy".to_string(),
"runtime-owned-workspace-client".to_string(),
Some("ws-test".to_string()),
true,
)]
+649
View File
@@ -0,0 +1,649 @@
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
WorkspaceResponse,
};
use crate::auth::{
RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner,
WORKER_REMOVE_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation,
WorkerMutationSourceClaims, new_token_id,
};
use crate::runtime::RuntimeWorkspaceScope;
pub const DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS: u64 = 60;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeOwnedWorkerMutationProof {
Remote(String),
InProcess(InProcessWorkerMutationProof),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InProcessWorkerMutationProof {
claims: WorkerMutationSourceClaims,
}
impl InProcessWorkerMutationProof {
pub fn claims(&self) -> &WorkerMutationSourceClaims {
&self.claims
}
pub fn into_claims(self) -> WorkerMutationSourceClaims {
self.claims
}
}
#[derive(Clone)]
pub struct RuntimeWorkerMutationSourceAuthority {
mode: RuntimeWorkerMutationSourceMode,
}
#[derive(Clone)]
enum RuntimeWorkerMutationSourceMode {
Remote {
signer: RuntimeWorkerMutationSourceSigner,
},
Embedded {
runtime_id: String,
audience: String,
},
}
impl RuntimeWorkerMutationSourceAuthority {
pub fn remote(identity: &RuntimeIdentityMaterial) -> Self {
Self {
mode: RuntimeWorkerMutationSourceMode::Remote {
signer: RuntimeWorkerMutationSourceSigner::from_identity(identity),
},
}
}
pub fn embedded(runtime_id: impl Into<String>, workspace_id: impl AsRef<str>) -> Self {
Self {
mode: RuntimeWorkerMutationSourceMode::Embedded {
runtime_id: runtime_id.into(),
audience: format!("embedded:{}", workspace_id.as_ref()),
},
}
}
pub fn issue_worker_remove(
&self,
scope: &RuntimeWorkspaceScope,
source_worker_id: &str,
target_runtime_id: &str,
target_worker_id: &str,
) -> Result<RuntimeOwnedWorkerMutationProof, RuntimeAuthError> {
match &self.mode {
RuntimeWorkerMutationSourceMode::Remote { signer } => {
let token = signer.issue_worker_remove(
&scope.server_id,
&scope.workspace_id,
source_worker_id,
target_runtime_id,
target_worker_id,
DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS,
)?;
Ok(RuntimeOwnedWorkerMutationProof::Remote(token))
}
RuntimeWorkerMutationSourceMode::Embedded {
runtime_id,
audience,
} => {
let issued_at = unix_now_seconds();
Ok(RuntimeOwnedWorkerMutationProof::InProcess(
InProcessWorkerMutationProof {
claims: WorkerMutationSourceClaims {
iss: runtime_id.clone(),
aud: audience.clone(),
workspace_id: scope.workspace_id.clone(),
worker_id: source_worker_id.to_string(),
actor_kind: WorkerMutationActorKind::Worker,
operation: WorkerMutationOperation::WorkerRemove,
target_runtime_id: target_runtime_id.to_string(),
target_worker_id: target_worker_id.to_string(),
permission: WORKER_REMOVE_PERMISSION.to_string(),
iat: issued_at,
exp: issued_at
.saturating_add(DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS),
jti: new_token_id()?,
},
},
))
}
}
}
}
pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
fn execute_worker_remove(
&self,
proof: InProcessWorkerMutationProof,
target_runtime_id: &str,
target_worker_id: &str,
expected_worker_revision: &str,
reason: &str,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError>;
}
#[derive(Clone)]
enum RuntimeWorkerMutationTransport {
Remote {
base_url: String,
client: reqwest::blocking::Client,
},
Embedded {
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
},
}
#[derive(Clone)]
pub struct RuntimeWorkerMutationForwarder {
authority: RuntimeWorkerMutationSourceAuthority,
scope: RuntimeWorkspaceScope,
source_worker_id: String,
transport: RuntimeWorkerMutationTransport,
}
impl RuntimeWorkerMutationForwarder {
pub fn remote(
identity: &RuntimeIdentityMaterial,
scope: RuntimeWorkspaceScope,
source_worker_id: impl Into<String>,
base_url: impl Into<String>,
) -> Self {
Self {
authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
scope,
source_worker_id: source_worker_id.into(),
transport: RuntimeWorkerMutationTransport::Remote {
base_url: base_url.into().trim_end_matches('/').to_string(),
client: reqwest::blocking::Client::new(),
},
}
}
pub fn embedded(
runtime_id: impl Into<String>,
scope: RuntimeWorkspaceScope,
source_worker_id: impl Into<String>,
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
) -> Self {
let runtime_id = runtime_id.into();
Self {
authority: RuntimeWorkerMutationSourceAuthority::embedded(
&runtime_id,
&scope.workspace_id,
),
scope,
source_worker_id: source_worker_id.into(),
transport: RuntimeWorkerMutationTransport::Embedded { dispatcher },
}
}
pub fn execute_worker_remove(
&self,
target_runtime_id: &str,
target_worker_id: &str,
expected_worker_revision: &str,
reason: &str,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
let proof = self.authority.issue_worker_remove(
&self.scope,
&self.source_worker_id,
target_runtime_id,
target_worker_id,
)?;
match (&self.transport, proof) {
(
RuntimeWorkerMutationTransport::Remote { base_url, client },
RuntimeOwnedWorkerMutationProof::Remote(token),
) => {
let url = format!(
"{base_url}/api/w/{}/workers/remove",
self.scope.workspace_id
);
let body = serde_json::json!({
"target_runtime_id": target_runtime_id,
"target_worker_id": target_worker_id,
"expected_worker_revision": expected_worker_revision,
"reason": reason,
});
let response = client
.post(url)
.header(crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER, token)
.json(&body)
.send()
.map_err(|error| {
RuntimeWorkerMutationForwardError::Transport(error.to_string())
})?;
let status = response.status().as_u16();
let body = response.text().map_err(|error| {
RuntimeWorkerMutationForwardError::Transport(error.to_string())
})?;
Ok(WorkspaceResponse { status, body })
}
(
RuntimeWorkerMutationTransport::Embedded { dispatcher },
RuntimeOwnedWorkerMutationProof::InProcess(claims),
) => dispatcher.execute_worker_remove(
claims,
target_runtime_id,
target_worker_id,
expected_worker_revision,
reason,
),
_ => Err(RuntimeWorkerMutationForwardError::AuthorityTransportMismatch),
}
}
}
pub struct RuntimeOwnedWorkspaceClient {
workspace_id: String,
base_url: String,
runtime_id: String,
worker_id: String,
worker_remove: Option<RuntimeWorkerMutationForwarder>,
}
impl RuntimeOwnedWorkspaceClient {
pub fn new(
workspace_id: impl Into<String>,
base_url: impl Into<String>,
runtime_id: impl Into<String>,
worker_id: impl Into<String>,
) -> Self {
Self {
workspace_id: workspace_id.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
runtime_id: runtime_id.into(),
worker_id: worker_id.into(),
worker_remove: None,
}
}
pub fn with_worker_remove(mut self, worker_remove: RuntimeWorkerMutationForwarder) -> Self {
self.worker_remove = Some(worker_remove);
self
}
}
impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RuntimeOwnedWorkspaceClient")
.field("workspace_id", &self.workspace_id)
.field("base_url", &self.base_url)
.field("source", &"Runtime-owned")
.field(
"worker_remove",
&self.worker_remove.as_ref().map(|_| "enabled"),
)
.finish()
}
}
impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
Some(&self.workspace_id)
}
fn kind(&self) -> &str {
"runtime-owned-workspace-client"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let base_url = self.base_url.clone();
let runtime_id = self.runtime_id.clone();
let worker_id = self.worker_id.clone();
if tokio::runtime::Handle::try_current().is_ok() {
std::thread::spawn(move || {
execute_runtime_owned_workspace_http(&base_url, &runtime_id, &worker_id, request)
})
.join()
.map_err(|_| {
WorkspaceClientError::Request("workspace request thread panicked".to_string())
})?
} else {
execute_runtime_owned_workspace_http(
&self.base_url,
&self.runtime_id,
&self.worker_id,
request,
)
}
}
fn execute_worker_remove(
&self,
target_runtime_id: &str,
target_worker_id: &str,
expected_worker_revision: &str,
reason: &str,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.worker_remove
.as_ref()
.ok_or_else(|| {
WorkspaceClientError::Unavailable(
"Runtime-owned WorkerRemove forwarding is unavailable".to_string(),
)
})?
.execute_worker_remove(
target_runtime_id,
target_worker_id,
expected_worker_revision,
reason,
)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))
}
}
fn execute_runtime_owned_workspace_http(
base_url: &str,
runtime_id: &str,
worker_id: &str,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if !request.path.starts_with('/') || request.path.starts_with("//") {
return Err(WorkspaceClientError::InvalidPath(request.path));
}
let url = format!("{base_url}{}", request.path);
let method = match request.method {
WorkspaceRequestMethod::Get => reqwest::Method::GET,
WorkspaceRequestMethod::Post => reqwest::Method::POST,
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
};
let client = reqwest::blocking::Client::new();
let mut request_builder = client
.request(method, url)
.header("x-yoi-runtime-id", runtime_id)
.header("x-yoi-worker-id", worker_id);
if let Some(body) = request.body {
request_builder = request_builder
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
}
let response = request_builder
.send()
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
let status = response.status().as_u16();
let body = response
.text()
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
Ok(WorkspaceResponse { status, body })
}
#[derive(Debug, thiserror::Error)]
pub enum RuntimeWorkerMutationForwardError {
#[error(transparent)]
Auth(#[from] RuntimeAuthError),
#[error("Worker mutation forwarding transport failed: {0}")]
Transport(String),
#[error("Worker mutation source authority does not match its forwarding transport")]
AuthorityTransportMismatch,
#[error("embedded Worker mutation dispatcher failed: {0}")]
Embedded(String),
}
fn unix_now_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims,
verify_worker_mutation_source_proof,
};
#[test]
fn ordinary_workspace_forwarding_stamps_legacy_source_only_inside_runtime() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::Mutex;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let received = Arc::new(Mutex::new(String::new()));
let received_for_server = received.clone();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut bytes = [0_u8; 4096];
let count = stream.read(&mut bytes).unwrap();
*received_for_server.lock().unwrap() =
String::from_utf8_lossy(&bytes[..count]).into_owned();
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}")
.unwrap();
});
let client = RuntimeOwnedWorkspaceClient::new(
"workspace-a",
format!("http://{address}"),
"runtime-a",
"worker-a",
);
let response = client
.execute(WorkspaceRequest::get("/api/w/workspace-a/tickets/search"))
.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:"));
}
#[test]
fn remote_authority_stamps_and_signs_worker_remove_without_caller_claim_choices() {
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let authority = RuntimeWorkerMutationSourceAuthority::remote(&identity);
let scope = RuntimeWorkspaceScope {
workspace_id: "workspace-a".to_string(),
server_id: "server-a".to_string(),
};
let RuntimeOwnedWorkerMutationProof::Remote(token) = authority
.issue_worker_remove(&scope, "worker-source", "runtime-b", "worker-target")
.unwrap()
else {
panic!("remote authority must produce a signed proof");
};
let claims = decode_worker_mutation_source_claims(&token).unwrap();
let expected = WorkerMutationSourceExpectation {
runtime_id: "runtime-a",
audience: "server-a",
workspace_id: "workspace-a",
worker_id: Some("worker-source"),
actor_kind: WorkerMutationActorKind::Worker,
operation: WorkerMutationOperation::WorkerRemove,
target_runtime_id: "runtime-b",
target_worker_id: "worker-target",
permission: WORKER_REMOVE_PERMISSION,
};
assert_eq!(
verify_worker_mutation_source_proof(
&identity.public_key,
&token,
&expected,
claims.iat
)
.unwrap(),
claims
);
}
#[test]
fn remote_forwarder_stamps_signed_proof_inside_runtime_before_http_delivery() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::Mutex;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let received = Arc::new(Mutex::new(String::new()));
let received_for_server = received.clone();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut bytes = [0_u8; 8192];
let count = stream.read(&mut bytes).unwrap();
*received_for_server.lock().unwrap() =
String::from_utf8_lossy(&bytes[..count]).into_owned();
stream
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
.unwrap();
});
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let scope = RuntimeWorkspaceScope::new("workspace-a", "server-a");
let forwarder = RuntimeWorkerMutationForwarder::remote(
&identity,
scope,
"worker-source",
format!("http://{address}"),
);
let response = forwarder
.execute_worker_remove(
"runtime-target",
"worker-target",
"revision-7",
"retire obsolete Worker",
)
.unwrap();
assert_eq!(response.status, 204);
server.join().unwrap();
let request = received.lock().unwrap().clone();
assert!(request.starts_with("POST /api/w/workspace-a/workers/remove HTTP/1.1"));
assert!(request.contains("\"target_runtime_id\":\"runtime-target\""));
assert!(request.contains("\"target_worker_id\":\"worker-target\""));
assert!(request.contains("\"expected_worker_revision\":\"revision-7\""));
assert!(request.contains("\"reason\":\"retire obsolete Worker\""));
let token = request
.lines()
.find_map(|line| {
line.split_once(':').and_then(|(name, value)| {
name.eq_ignore_ascii_case(crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER)
.then(|| value.trim())
})
})
.expect("proof header");
let claims = decode_worker_mutation_source_claims(token).unwrap();
let expected = WorkerMutationSourceExpectation {
runtime_id: "runtime-a",
audience: "server-a",
workspace_id: "workspace-a",
worker_id: Some("worker-source"),
actor_kind: WorkerMutationActorKind::Worker,
operation: WorkerMutationOperation::WorkerRemove,
target_runtime_id: "runtime-target",
target_worker_id: "worker-target",
permission: WORKER_REMOVE_PERMISSION,
};
verify_worker_mutation_source_proof(&identity.public_key, token, &expected, claims.iat)
.unwrap();
}
#[test]
fn embedded_forwarder_delivers_in_process_proof_with_the_request() {
use std::sync::Mutex;
#[derive(Default)]
struct RecordingDispatcher {
seen: Mutex<Option<(WorkerMutationSourceClaims, String, String, String, String)>>,
}
impl EmbeddedWorkerMutationDispatcher for RecordingDispatcher {
fn execute_worker_remove(
&self,
proof: InProcessWorkerMutationProof,
target_runtime_id: &str,
target_worker_id: &str,
expected_worker_revision: &str,
reason: &str,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
*self.seen.lock().unwrap() = Some((
proof.into_claims(),
target_runtime_id.to_string(),
target_worker_id.to_string(),
expected_worker_revision.to_string(),
reason.to_string(),
));
Ok(WorkspaceResponse {
status: 202,
body: "accepted".to_string(),
})
}
}
let dispatcher = Arc::new(RecordingDispatcher::default());
let scope = RuntimeWorkspaceScope::new("workspace-a", "server-a");
let forwarder = RuntimeWorkerMutationForwarder::embedded(
"runtime-embedded",
scope,
"worker-source",
dispatcher.clone(),
);
let response = forwarder
.execute_worker_remove(
"runtime-target",
"worker-target",
"revision-7",
"retire obsolete Worker",
)
.unwrap();
assert_eq!(response.status, 202);
let (claims, target_runtime_id, target_worker_id, expected_revision, reason) =
dispatcher.seen.lock().unwrap().take().unwrap();
assert_eq!(claims.iss, "runtime-embedded");
assert_eq!(claims.worker_id, "worker-source");
assert_eq!(claims.target_runtime_id, "runtime-target");
assert_eq!(claims.target_worker_id, "worker-target");
assert_eq!(target_runtime_id, "runtime-target");
assert_eq!(target_worker_id, "worker-target");
assert_eq!(expected_revision, "revision-7");
assert_eq!(reason, "retire obsolete Worker");
}
#[test]
fn embedded_authority_uses_the_same_claim_contract_without_a_credential() {
let authority =
RuntimeWorkerMutationSourceAuthority::embedded("runtime-embedded", "workspace-a");
let scope = RuntimeWorkspaceScope {
workspace_id: "workspace-a".to_string(),
server_id: "server-unused-for-embedded".to_string(),
};
let RuntimeOwnedWorkerMutationProof::InProcess(proof) = authority
.issue_worker_remove(&scope, "worker-source", "runtime-b", "worker-target")
.unwrap()
else {
panic!("embedded authority must produce an in-process proof");
};
let claims = proof.claims();
assert_eq!(claims.iss, "runtime-embedded");
assert_eq!(claims.aud, "embedded:workspace-a");
assert_eq!(claims.workspace_id, "workspace-a");
assert_eq!(claims.worker_id, "worker-source");
assert_eq!(claims.operation, WorkerMutationOperation::WorkerRemove);
assert_eq!(claims.target_runtime_id, "runtime-b");
assert_eq!(claims.target_worker_id, "worker-target");
assert_eq!(claims.permission, WORKER_REMOVE_PERMISSION);
assert!(!claims.jti.is_empty());
}
}
+29 -4
View File
@@ -211,7 +211,7 @@ impl WorkerController {
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
Self::spawn_inner(worker, runtime_base, false).await
Self::spawn_inner(worker, runtime_base, false, None).await
}
/// Spawn a Worker owned by `worker-runtime`.
@@ -227,20 +227,37 @@ impl WorkerController {
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
Self::spawn_inner(worker, runtime_base, true).await
Self::spawn_inner(worker, runtime_base, true, None).await
}
/// Spawn into an exact persistent `runs/<generation>` directory.
pub async fn spawn_runtime_managed_run<C, St>(
worker: Worker<C, St>,
run_dir: &Path,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let parent = run_dir
.parent()
.ok_or_else(|| std::io::Error::other("run path has no parent"))?;
Self::spawn_inner(worker, parent, true, Some(run_dir)).await
}
async fn spawn_inner<C, St>(
worker: Worker<C, St>,
runtime_base: &Path,
runtime_managed: bool,
runtime_run: Option<&Path>,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let session = worker.workdir_session().cloned();
let result = Self::spawn_initialized(worker, runtime_base, runtime_managed).await;
let result =
Self::spawn_initialized(worker, runtime_base, runtime_managed, runtime_run).await;
if result.is_err()
&& let Some(session) = session
&& let Err(error) = session.close().await
@@ -254,6 +271,7 @@ impl WorkerController {
mut worker: Worker<C, St>,
runtime_base: &Path,
runtime_managed: bool,
runtime_run: Option<&Path>,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + Clone + 'static,
@@ -273,7 +291,9 @@ impl WorkerController {
// the spawn-tool factories need its socket path, and before the
// initial status/history writes consume the greeting we build
// after registration is complete.
let runtime_dir = Arc::new(if runtime_managed {
let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run {
RuntimeDir::create_worker_run(run_dir).await?
} else if runtime_managed {
RuntimeDir::create_transient(runtime_base, &worker.manifest().worker.name).await?
} else {
RuntimeDir::create(runtime_base, &worker.manifest().worker.name).await?
@@ -1298,6 +1318,11 @@ async fn controller_loop<C, St>(
}
}
drop(_socket_server);
if let Err(error) = runtime_dir.close_socket().await {
tracing::warn!(%error, "Worker runtime socket cleanup failed");
}
// Background memory jobs own extract/consolidate workers after a
// turn completes. Join them before closing the Workdir session so no
// Worker-owned task can outlive its operation attachment.
+1
View File
@@ -9,6 +9,7 @@ pub mod manage_workdir;
pub mod manage_worker;
pub mod memory;
pub mod memory_extract;
pub mod merge_request;
pub mod objective;
pub mod session_explore;
pub mod task;
@@ -77,6 +77,11 @@ impl FeatureModule for ManageWorkerFeature {
self.client.clone(),
workspace_id.clone(),
),
WorkerOperation::Remove => definition::<WorkerRemoveInput>(
operation,
self.client.clone(),
workspace_id.clone(),
),
};
context
.tools()
@@ -149,6 +154,15 @@ struct WorkerStopInput {
reason: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct WorkerRemoveInput {
runtime_id: String,
worker_id: String,
expected_worker_revision: String,
reason: String,
}
struct WorkspaceWorkerTool {
operation: WorkerOperation,
client: Arc<dyn WorkspaceClient>,
@@ -161,10 +175,17 @@ enum WorkerOperation {
Spawn,
Stop,
Restore,
Remove,
}
impl WorkerOperation {
const ALL: [Self; 4] = [Self::List, Self::Spawn, Self::Stop, Self::Restore];
const ALL: [Self; 5] = [
Self::List,
Self::Spawn,
Self::Stop,
Self::Restore,
Self::Remove,
];
fn tool_name(self) -> &'static str {
match self {
@@ -172,6 +193,7 @@ impl WorkerOperation {
Self::Spawn => "WorkerSpawn",
Self::Stop => "WorkerStop",
Self::Restore => "WorkerRestore",
Self::Remove => "WorkerRemove",
}
}
@@ -187,6 +209,9 @@ impl WorkerOperation {
Self::Restore => {
"Restore a stopped Backend/Runtime Worker session in the current Workspace."
}
Self::Remove => {
"Remove an eligible stopped, unassigned, non-internal Worker. Supply the current Worker revision and a bounded reason; Backend validation and retention are authoritative."
}
}
}
}
@@ -198,82 +223,107 @@ impl Tool for WorkspaceWorkerTool {
input_json: &str,
ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let request = match self.operation {
WorkerOperation::List => {
parse::<WorkerListInput>(input_json, "WorkerList")?;
WorkspaceRequest::get(format!("/api/w/{}/workers", self.workspace_id))
let response = match self.operation {
WorkerOperation::Remove => {
let input = parse::<WorkerRemoveInput>(input_json, "WorkerRemove")?;
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
let worker_id = authority_id(&input.worker_id, "worker_id")?;
let expected_worker_revision =
non_empty(input.expected_worker_revision, "expected_worker_revision")?;
let reason = non_empty(input.reason, "reason")?;
if reason.len() > 512 {
return Err(ToolError::ExecutionFailed(
"reason must contain at most 512 bytes".to_string(),
));
}
self.client
.execute_worker_remove(
&runtime_id,
&worker_id,
&expected_worker_revision,
&reason,
)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
}
WorkerOperation::Spawn => {
let input = parse::<WorkerSpawnInput>(input_json, "WorkerSpawn")?;
let ticket_assignment = input
.ticket_id
.map(|ticket_id| {
let ticket_id = authority_id(&ticket_id, "ticket_id")?;
let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?;
Ok::<_, ToolError>(WorkerSpawnTicketAssignmentRequest {
operation_id: format!("worker-spawn:{ticket_id}:{call_id}"),
ticket_id,
})
})
.transpose()?;
let request = WorkerSpawnRequest {
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
display_name: input
.display_name
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "Workspace Worker".to_string()),
profile: non_empty(input.profile, "profile")?,
ticket_assignment,
initial_submit: input.initial_submit,
working_directory: WorkerWorkingDirectorySelection {
working_directory_id: authority_id(
&input.working_directory_id,
"working_directory_id",
)?,
relative_cwd: input
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
},
operation => {
let request = match operation {
WorkerOperation::List => {
parse::<WorkerListInput>(input_json, "WorkerList")?;
WorkspaceRequest::get(format!("/api/w/{}/workers", self.workspace_id))
}
WorkerOperation::Spawn => {
let input = parse::<WorkerSpawnInput>(input_json, "WorkerSpawn")?;
let ticket_assignment = input
.ticket_id
.map(|ticket_id| {
let ticket_id = authority_id(&ticket_id, "ticket_id")?;
let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?;
Ok::<_, ToolError>(WorkerSpawnTicketAssignmentRequest {
operation_id: format!("worker-spawn:{ticket_id}:{call_id}"),
ticket_id,
})
})
.transpose()?;
let request = WorkerSpawnRequest {
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
display_name: input
.display_name
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "Workspace Worker".to_string()),
profile: non_empty(input.profile, "profile")?,
ticket_assignment,
initial_submit: input.initial_submit,
working_directory: WorkerWorkingDirectorySelection {
working_directory_id: authority_id(
&input.working_directory_id,
"working_directory_id",
)?,
relative_cwd: input
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
},
};
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/workers", self.workspace_id),
serde_json::to_string(&request)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
)
}
WorkerOperation::Stop => {
let input = parse::<WorkerStopInput>(input_json, "WorkerStop")?;
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
let worker_id = authority_id(&input.worker_id, "worker_id")?;
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/runtimes/{runtime_id}/workers/{worker_id}/stop",
self.workspace_id
),
serde_json::json!({ "reason": input.reason }).to_string(),
)
}
WorkerOperation::Restore => {
let input = parse::<WorkerTargetInput>(input_json, "WorkerRestore")?;
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
let worker_id = authority_id(&input.worker_id, "worker_id")?;
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/runtimes/{runtime_id}/workers/{worker_id}/restore",
self.workspace_id
),
"{}",
)
}
WorkerOperation::Remove => unreachable!("handled above"),
};
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/workers", self.workspace_id),
serde_json::to_string(&request)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
)
}
WorkerOperation::Stop => {
let input = parse::<WorkerStopInput>(input_json, "WorkerStop")?;
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
let worker_id = authority_id(&input.worker_id, "worker_id")?;
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/runtimes/{runtime_id}/workers/{worker_id}/stop",
self.workspace_id
),
serde_json::json!({ "reason": input.reason }).to_string(),
)
}
WorkerOperation::Restore => {
let input = parse::<WorkerTargetInput>(input_json, "WorkerRestore")?;
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
let worker_id = authority_id(&input.worker_id, "worker_id")?;
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/runtimes/{runtime_id}/workers/{worker_id}/restore",
self.workspace_id
),
"{}",
)
self.client
.execute(request)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
}
};
let response = self
.client
.execute(request)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Worker operation returned HTTP {}: {}",
@@ -356,6 +406,7 @@ mod tests {
#[derive(Debug, Default)]
struct RecordingWorkspaceClient {
requests: Mutex<Vec<WorkspaceRequest>>,
removals: Mutex<Vec<(String, String, String, String)>>,
}
impl WorkspaceClient for RecordingWorkspaceClient {
@@ -381,6 +432,25 @@ mod tests {
body: "{}".to_string(),
})
}
fn execute_worker_remove(
&self,
target_runtime_id: &str,
target_worker_id: &str,
expected_worker_revision: &str,
reason: &str,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.removals.lock().unwrap().push((
target_runtime_id.to_string(),
target_worker_id.to_string(),
expected_worker_revision.to_string(),
reason.to_string(),
));
Ok(WorkspaceResponse {
status: 200,
body: r#"{"removed":true}"#.to_string(),
})
}
}
#[tokio::test]
@@ -433,7 +503,13 @@ mod tests {
fn worker_tool_family_is_distinct_from_sub_worker_tools() {
assert_eq!(
WorkerOperation::ALL.map(WorkerOperation::tool_name),
["WorkerList", "WorkerSpawn", "WorkerStop", "WorkerRestore"]
[
"WorkerList",
"WorkerSpawn",
"WorkerStop",
"WorkerRestore",
"WorkerRemove",
]
);
}
@@ -481,6 +557,92 @@ mod tests {
assert!(value.get("initial_text").is_none());
}
#[tokio::test]
async fn worker_remove_forwards_only_target_revision_and_bounded_reason() {
let client = Arc::new(RecordingWorkspaceClient::default());
let tool = WorkspaceWorkerTool {
operation: WorkerOperation::Remove,
client: client.clone(),
workspace_id: "workspace%2Ftest".to_string(),
};
tool.execute(
&serde_json::json!({
"runtime_id": "runtime-1",
"worker_id": "worker-7",
"expected_worker_revision": "2026-08-11T20:00:00Z",
"reason": " retire completed Worker "
})
.to_string(),
ToolExecutionContext::new("call-remove", "batch-remove", 0),
)
.await
.unwrap();
assert_eq!(
client.removals.lock().unwrap().as_slice(),
[(
"runtime-1".to_string(),
"worker-7".to_string(),
"2026-08-11T20:00:00Z".to_string(),
"retire completed Worker".to_string(),
)]
);
let schema = serde_json::to_value(schemars::schema_for!(WorkerRemoveInput))
.unwrap()
.to_string();
for field in [
"runtime_id",
"worker_id",
"expected_worker_revision",
"reason",
] {
assert!(schema.contains(field));
}
for forbidden in ["proof", "actor", "workspace_id", "policy", "plan", "stage"] {
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
}
}
#[tokio::test]
async fn worker_remove_rejects_empty_oversized_and_unknown_authority_input() {
let client = Arc::new(RecordingWorkspaceClient::default());
let tool = WorkspaceWorkerTool {
operation: WorkerOperation::Remove,
client: client.clone(),
workspace_id: "workspace%2Ftest".to_string(),
};
for reason in [" ".to_string(), "x".repeat(513)] {
let _error = tool
.execute(
&serde_json::json!({
"runtime_id": "runtime-1",
"worker_id": "worker-7",
"expected_worker_revision": "revision-1",
"reason": reason,
})
.to_string(),
ToolExecutionContext::new("call-invalid", "batch-remove", 0),
)
.await
.unwrap_err();
}
let _error = tool
.execute(
&serde_json::json!({
"runtime_id": "runtime-1",
"worker_id": "worker-7",
"expected_worker_revision": "revision-1",
"reason": "retire",
"source_proof": "caller-controlled"
})
.to_string(),
ToolExecutionContext::new("call-spoof", "batch-remove", 0),
)
.await
.unwrap_err();
assert!(client.removals.lock().unwrap().is_empty());
}
#[test]
fn worker_inputs_reject_paths_and_parent_traversal() {
assert!(authority_id("https://runtime.example", "runtime_id").is_err());
+1 -3
View File
@@ -346,11 +346,9 @@ mod tests {
use llm_engine::tool::ToolDefinition;
fn test_client() -> Arc<dyn WorkspaceClient> {
Arc::new(crate::worker::RuntimeWorkspaceHttpClient::new(
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
"http://backend",
"test-runtime",
"test-worker",
))
}
@@ -0,0 +1,299 @@
use crate::feature::ToolDefinition;
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use async_trait::async_trait;
use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[
"MergeRequestShow",
"MergeRequestReadinessCheck",
"MergeRequestOpen",
"MergeRequestAddRevision",
"MergeRequestComplete",
];
pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit";
#[derive(Clone, Copy)]
enum Kind {
Show,
Readiness,
Open,
AddRevision,
Complete,
Review,
}
#[derive(Clone)]
struct MergeRequestTool {
client: Arc<dyn WorkspaceClient>,
kind: Kind,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ShowInput {
ticket: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct OpenInput {
ticket: String,
repository_id: String,
revision_id: String,
base_commit: String,
head_commit: String,
head_tree: String,
diff_digest: String,
#[serde(default)]
changed_paths: Vec<String>,
#[serde(default)]
summary: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct AddRevisionInput {
ticket: String,
expected_current_revision_id: String,
revision_id: String,
base_commit: String,
head_commit: String,
head_tree: String,
diff_digest: String,
#[serde(default)]
changed_paths: Vec<String>,
#[serde(default)]
summary: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct CompleteInput {
ticket: String,
operation_id: String,
expected_revision_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ReviewInput {
decision: ReviewDecisionInput,
#[serde(default)]
body: String,
#[serde(default)]
findings: Vec<ReviewFindingInput>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum ReviewDecisionInput {
Approve,
RequestChanges,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ReviewFindingInput {
severity: String,
#[serde(default)]
code: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
line: Option<u64>,
body: String,
}
impl Kind {
fn name(self) -> &'static str {
match self {
Self::Show => "MergeRequestShow",
Self::Readiness => "MergeRequestReadinessCheck",
Self::Open => "MergeRequestOpen",
Self::AddRevision => "MergeRequestAddRevision",
Self::Complete => "MergeRequestComplete",
Self::Review => "MergeRequestReviewSubmit",
}
}
fn description(self) -> &'static str {
description(self.name()).unwrap_or("Merge Request operation.")
}
fn schema(self) -> serde_json::Value {
match self {
Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)),
Self::Open => json!(schemars::schema_for!(OpenInput)),
Self::AddRevision => json!(schemars::schema_for!(AddRevisionInput)),
Self::Complete => json!(schemars::schema_for!(CompleteInput)),
Self::Review => json!(schemars::schema_for!(ReviewInput)),
}
}
}
#[async_trait]
impl Tool for MergeRequestTool {
async fn execute(
&self,
input: &str,
_context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let workspace_id = self.client.workspace_id().ok_or_else(|| {
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
})?;
let (method, path, body) = match self.kind {
Kind::Show => {
let v: ShowInput = parse(input)?;
nonempty(&v.ticket)?;
(
WorkspaceRequestMethod::Get,
format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket),
None,
)
}
Kind::Readiness => {
let v: ShowInput = parse(input)?;
nonempty(&v.ticket)?;
(
WorkspaceRequestMethod::Get,
format!(
"/api/w/{workspace_id}/tickets/{}/merge-request/readiness",
v.ticket
),
None,
)
}
Kind::Open => {
let v: OpenInput = parse(input)?;
nonempty(&v.ticket)?;
(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket),
Some(
json!({"repository_id":v.repository_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"head_tree":v.head_tree,"diff_digest":v.diff_digest,"changed_paths":v.changed_paths,"summary":v.summary}),
),
)
}
Kind::AddRevision => {
let v: AddRevisionInput = parse(input)?;
nonempty(&v.ticket)?;
(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{workspace_id}/tickets/{}/merge-request/revisions",
v.ticket
),
Some(
json!({"expected_current_revision_id":v.expected_current_revision_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"head_tree":v.head_tree,"diff_digest":v.diff_digest,"changed_paths":v.changed_paths,"summary":v.summary}),
),
)
}
Kind::Complete => {
let v: CompleteInput = parse(input)?;
nonempty(&v.ticket)?;
(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{workspace_id}/tickets/{}/merge-request/complete",
v.ticket
),
Some(
json!({"operation_id":v.operation_id,"expected_revision_id":v.expected_revision_id}),
),
)
}
Kind::Review => {
let v: ReviewInput = parse(input)?;
let context = self.client.reviewer_attempt_context().ok_or_else(|| {
ToolError::ExecutionFailed(
"MergeRequestReviewSubmit is available only to an attested Reviewer child"
.into(),
)
})?;
(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{workspace_id}/tickets/{}/merge-request/reviews",
context.ticket_id
),
Some(
json!({"decision":match v.decision{ReviewDecisionInput::Approve=>"approve",ReviewDecisionInput::RequestChanges=>"request_changes"},"body":v.body,"findings":v.findings.into_iter().map(|f|json!({"severity":f.severity,"code":f.code,"path":f.path,"line":f.line,"body":f.body})).collect::<Vec<_>>() }),
),
)
}
};
let request = match body {
Some(body) => WorkspaceRequest::json(method, path, body.to_string()),
None => WorkspaceRequest::get(path),
};
let response = self
.client
.execute(request)
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Merge Request API returned HTTP {}: {}",
response.status, response.body
)));
}
Ok(ToolOutput {
summary: self.kind.name().to_string(),
content: Some(response.body),
attachments: Vec::new(),
})
}
}
fn parse<T: serde::de::DeserializeOwned>(value: &str) -> Result<T, ToolError> {
serde_json::from_str(value).map_err(|e| ToolError::InvalidArgument(e.to_string()))
}
fn nonempty(value: &str) -> Result<(), ToolError> {
if value.trim().is_empty() {
Err(ToolError::InvalidArgument(
"ticket must not be empty".into(),
))
} else {
Ok(())
}
}
fn definition(client: Arc<dyn WorkspaceClient>, kind: Kind) -> ToolDefinition {
Arc::new(move || {
let meta = ToolMeta::new(kind.name())
.description(kind.description())
.input_schema(kind.schema());
let tool: Arc<dyn Tool> = Arc::new(MergeRequestTool {
client: client.clone(),
kind,
});
(meta, tool)
})
}
pub fn common_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
vec![
definition(client.clone(), Kind::Show),
definition(client.clone(), Kind::Readiness),
definition(client.clone(), Kind::Open),
definition(client.clone(), Kind::AddRevision),
definition(client, Kind::Complete),
]
}
pub fn reviewer_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
if client.reviewer_attempt_context().is_some() {
vec![
definition(client.clone(), Kind::Show),
definition(client, Kind::Review),
]
} else {
Vec::new()
}
}
pub fn description(name: &str) -> Option<&'static str> {
match name {
"MergeRequestShow" => Some(
"Read the authoritative Merge Request, immutable current revision, and structured review status.",
),
"MergeRequestReadinessCheck" => {
Some("Check derived merge readiness for the current immutable revision.")
}
"MergeRequestOpen" => {
Some("Open an immutable Merge Request revision for the current assigned Coder.")
}
"MergeRequestAddRevision" => {
Some("Append an immutable revision; prior approval cannot carry to the new revision.")
}
"MergeRequestComplete" => {
Some("CAS-complete an approved revision with operation-id replay and crash fencing.")
}
"MergeRequestReviewSubmit" => Some(
"Submit the attested direct-child Reviewer result bound to its immutable revision.",
),
_ => None,
}
}
@@ -628,12 +628,7 @@ mod tests {
#[test]
fn workspace_http_objective_tools_include_objective_crud_tools() {
let names = tool_names(workspace_http_objective_tools(Arc::new(
crate::worker::RuntimeWorkspaceHttpClient::new(
"workspace",
"http://backend",
"test-runtime",
"test-worker",
),
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
)));
assert_eq!(
+35 -40
View File
@@ -14,12 +14,13 @@ use ticket::{
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview,
TicketStateChange, TicketSummary,
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketStateChange,
TicketSummary,
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
};
use super::merge_request;
use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
@@ -100,7 +101,7 @@ impl TicketFeatureAccess {
pub const fn review() -> Self {
Self {
authoring: false,
thread: true,
thread: false,
intake: false,
orchestration_control: false,
}
@@ -141,7 +142,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketRelationRecord",
];
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment", "TicketReview"];
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
@@ -152,7 +153,6 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketList",
"TicketShow",
"TicketComment",
"TicketReview",
"TicketQueue",
"TicketClose",
"TicketDependencyCheck",
@@ -167,7 +167,6 @@ const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
"TicketList",
"TicketShow",
"TicketComment",
"TicketReview",
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
@@ -340,6 +339,22 @@ impl FeatureModule for TicketFeature {
ticket_tool_description(name, self.record_language.as_deref()),
));
}
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
let names: Vec<&str> = if client.reviewer_attempt_context().is_some() {
vec![
"MergeRequestShow",
merge_request::MERGE_REQUEST_REVIEW_TOOL_NAME,
]
} else {
merge_request::MERGE_REQUEST_COMMON_TOOL_NAMES.to_vec()
};
for name in names {
descriptor = descriptor.with_tool(ToolDeclaration::new(
name,
merge_request::description(name).unwrap_or("Merge Request operation."),
));
}
}
descriptor
}
@@ -373,6 +388,17 @@ impl FeatureModule for TicketFeature {
}
tools.register(ToolContribution::new(name, definition))?;
}
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
let definitions = if client.reviewer_attempt_context().is_some() {
merge_request::reviewer_tools(client.clone())
} else {
merge_request::common_tools(client.clone())
};
for definition in definitions {
let (meta, _) = definition();
tools.register(ToolContribution::new(meta.name.clone(), definition))?;
}
}
Ok(())
}
}
@@ -611,14 +637,6 @@ impl WorkspaceHttpTicketBackend {
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
None,
),
TicketBackendOperation::Review { id, review } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/workflow/review", Self::ticket_path(&id)),
Some(serde_json::to_value(review).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket review: {error}"))
})?),
),
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
@@ -844,15 +862,6 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
}
}
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> TicketResult<()> {
match self.invoke(TicketBackendOperation::Review { id, review })? {
TicketBackendOperationResult::Unit => Ok(()),
other => Err(TicketError::Conflict(format!(
"unexpected ticket backend response: {other:?}"
))),
}
}
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
match self.invoke(TicketBackendOperation::Close { id, resolution })? {
TicketBackendOperationResult::Unit => Ok(()),
@@ -1075,7 +1084,6 @@ mod tests {
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(work_report_tools.contains(&"TicketComment"));
assert!(work_report_tools.contains(&"TicketReview"));
assert!(!work_report_tools.contains(&"TicketWorkflowState"));
let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::review());
@@ -1085,7 +1093,6 @@ mod tests {
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(review_tools.contains(&"TicketReview"));
assert!(!review_tools.contains(&"TicketWorkflowState"));
}
@@ -1369,12 +1376,7 @@ provider = "github"
#[tokio::test(flavor = "multi_thread")]
async fn workspace_http_backend_invoke_is_safe_inside_async_context() {
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::RuntimeWorkspaceHttpClient::new(
"workspace-a",
"not-a-url",
"test-runtime",
"test-worker",
),
crate::worker::TestWorkspaceHttpClient::new("workspace-a", "not-a-url"),
));
let error = backend
@@ -1405,11 +1407,9 @@ provider = "github"
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
.unwrap();
});
let client = Arc::new(crate::worker::RuntimeWorkspaceHttpClient::new(
let client = Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace-a",
format!("http://{address}"),
"test-runtime",
"worker-a",
));
let backend = WorkspaceHttpTicketBackend::new(client);
@@ -1450,12 +1450,7 @@ provider = "github"
});
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::RuntimeWorkspaceHttpClient::new(
"workspace-a",
base_url,
"test-runtime",
"test-worker",
),
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
));
let created = backend.create(NewTicket::new("HTTP ticket")).unwrap();
+4
View File
@@ -259,6 +259,10 @@ pub(crate) struct InternalWorkerSessionHandle {
}
impl InternalWorkerSessionHandle {
pub(crate) fn session_id_string(&self) -> String {
self.session_id.to_string()
}
pub(crate) fn status(&self) -> InternalWorkerSessionStatus {
InternalWorkerSessionStatus::decode(self.status.load(std::sync::atomic::Ordering::Acquire))
}
+4 -4
View File
@@ -40,9 +40,9 @@ pub use runtime::dir::RuntimeDir;
pub use segment_log_sink::SegmentLogSink;
pub use shared_state::WorkerSharedState;
pub use worker::{
LocalWorkingDirectory, RuntimeWorkspaceHttpClient, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN,
Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext,
WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspaceRequest,
WorkspaceRequestMethod, WorkspaceResponse, apply_worker_manifest, marker_workspace_client,
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspaceRequest, WorkspaceRequestMethod,
WorkspaceResponse, apply_worker_manifest, marker_workspace_client,
unavailable_workspace_client,
};
+15
View File
@@ -746,6 +746,21 @@ compact_system = "PREFIX\n{% include \"$yoi/internal/compact_system\" %}"
assert!(rendered.contains("bypass user/Ticket authorization"));
}
#[test]
fn orchestrator_role_prompt_fences_worker_remove_authority() {
let source = include_str!("../../../../resources/prompts/role/orchestrator.md");
assert!(source.contains("Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder"));
assert!(source.contains("exact current `updated_at` value"));
assert!(source.contains("must have no current Ticket assignment"));
assert!(source.contains("pending notification, Reviewer handoff, legal hold, or pin"));
assert!(source.contains("After removal, reread the Worker catalog and attachment state"));
assert!(source.contains("attachment-close, and attachment-release conflicts"));
assert!(source.contains("preserves the Workdir materialization"));
assert!(!source.contains("source proof"));
assert!(!source.contains("provider handle"));
assert!(!source.contains("retention plan"));
}
#[test]
fn sub_worker_spawn_tool_description_renders_profile_block() {
let cat = PromptCatalog::builtins_only().unwrap();
+47 -2
View File
@@ -42,6 +42,8 @@ pub struct SpawnedWorkerRecord {
pub struct RuntimeDir {
path: PathBuf,
write_legacy_snapshots: bool,
preserve_on_drop: bool,
socket_file_name: &'static str,
}
impl RuntimeDir {
@@ -56,6 +58,8 @@ impl RuntimeDir {
Ok(Self {
path,
write_legacy_snapshots: true,
preserve_on_drop: false,
socket_file_name: "sock",
})
}
@@ -69,6 +73,36 @@ impl RuntimeDir {
Ok(Self {
path,
write_legacy_snapshots: false,
preserve_on_drop: false,
socket_file_name: "sock",
})
}
/// Create an exact, persistent generation-scoped Worker run directory.
/// Existing directories are rejected so stale artifacts cannot be reused.
pub async fn create_worker_run(path: &Path) -> Result<Self, io::Error> {
let parent = path
.parent()
.ok_or_else(|| io::Error::other("run path has no parent"))?;
fs::create_dir_all(parent).await?;
fs::create_dir(path).await?;
fs::create_dir(path.join("artifacts")).await?;
fs::create_dir(path.join("spawned")).await?;
for log in ["worker.out.log", "worker.err.log"] {
let file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path.join(log))
.await?;
file.sync_all().await?;
}
std::fs::File::open(path)?.sync_all()?;
std::fs::File::open(parent)?.sync_all()?;
Ok(Self {
path: path.to_path_buf(),
write_legacy_snapshots: false,
preserve_on_drop: true,
socket_file_name: "worker.sock",
})
}
@@ -116,13 +150,24 @@ impl RuntimeDir {
/// that only know the worker name (e.g. the TUI's attach flow)
/// predict the same path via [`manifest::paths::worker_socket_path`].
pub fn socket_path(&self) -> PathBuf {
self.path.join("sock")
self.path.join(self.socket_file_name)
}
pub async fn close_socket(&self) -> Result<(), io::Error> {
match fs::remove_file(self.socket_path()).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
}
impl Drop for RuntimeDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
let _ = std::fs::remove_file(self.socket_path());
if !self.preserve_on_drop {
let _ = std::fs::remove_dir_all(&self.path);
}
}
}
@@ -120,15 +120,15 @@ pub fn adopt_allocation(
/// The Worker's in-memory `segment_id` can change underneath the
/// allocation in two normal places:
///
/// - `Worker::compact` mints a fresh session and swaps it in.
/// - `session_store::ensure_head_or_fork` auto-forks when another
/// - `Worker::compact` mints a fresh Segment in the same Session.
/// - `session_store::ensure_head_or_fork` auto-forks within that Session when another
/// writer has advanced the store head behind our back.
///
/// Both paths must call this so subsequent [`lookup_segment`] queries
/// find the live session id, not the old one. Without this update a
/// find the live Segment id, not the old one. Without this update a
/// concurrent `restore_from_manifest(new_id)` would see "no live
/// writer" and proceed to register a competing allocation on the
/// session this Worker just moved into.
/// Segment lineage this Worker just moved into.
///
/// The lock is opened once and the allocation is rewritten inside the
/// guard, so the segment_id collision check is atomic with the
+3 -8
View File
@@ -212,8 +212,8 @@ mod tests {
break;
}
}
assert_eq!(runtime_header.as_deref(), Some("runtime-test"));
assert_eq!(worker_header.as_deref(), Some("test-worker"));
assert_eq!(runtime_header, None);
assert_eq!(worker_header, None);
assert_eq!(authorization, None);
let body = serde_json::json!({
"authority": "workspace-backend-skills-v0",
@@ -236,12 +236,7 @@ mod tests {
.unwrap();
});
let client = crate::worker::RuntimeWorkspaceHttpClient::new(
"ws-1",
format!("http://{addr}"),
"runtime-test",
"test-worker",
);
let client = crate::worker::TestWorkspaceHttpClient::new("ws-1", format!("http://{addr}"));
let catalog = (&client as &dyn WorkspaceClient).list_skills().unwrap();
assert_eq!(catalog.entries[0].name, "triage-errors");
assert_eq!(catalog.entries[0].provenance.id, "workspace:triage-errors");
+169 -2
View File
@@ -27,7 +27,10 @@ use crate::internal_worker::{
};
use crate::prompt::catalog::PromptCatalog;
use crate::spawn::registry::SpawnedWorkerRegistry;
use crate::worker::{Worker, WorkerFilesystemAuthority};
use crate::worker::{
ReviewerAttemptContext, ReviewerChildWorkspaceClient, Worker, WorkerFilesystemAuthority,
WorkspaceRequest, WorkspaceRequestMethod,
};
use protocol::Method;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -55,6 +58,16 @@ struct SubWorkerSpawnInput {
/// spawner's explicit delegation authority; direct tool scope alone is not
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
scope: Vec<ScopeRuleInput>,
/// Binds an actual read-only builtin Reviewer child to an immutable Merge Request revision.
/// Review attempt identity and capability material are generated by the trusted spawn layer.
#[serde(default)]
review: Option<ReviewerHandoffInput>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ReviewerHandoffInput {
ticket_id: String,
revision_id: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -320,6 +333,32 @@ impl SubWorkerSpawnTool {
}
}
fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolError> {
let Some(review) = &input.review else {
return Ok(());
};
if review.ticket_id.trim().is_empty() || review.revision_id.trim().is_empty() {
return Err(ToolError::InvalidArgument(
"reviewer handoff requires non-empty ticket_id and revision_id".to_string(),
));
}
if input.profile.as_deref() != Some("builtin:reviewer") {
return Err(ToolError::InvalidArgument(
"reviewer handoff requires the explicit effective profile builtin:reviewer".to_string(),
));
}
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(),
));
}
Ok(())
}
#[async_trait]
impl Tool for SubWorkerSpawnTool {
async fn execute(
@@ -340,6 +379,7 @@ impl Tool for SubWorkerSpawnTool {
input.name
)));
}
validate_reviewer_handoff(&input)?;
let name_reservation = self
.registry
.reserve_internal_name(input.name.clone())
@@ -378,6 +418,48 @@ impl Tool for SubWorkerSpawnTool {
.map_err(|error| {
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
})?;
let reviewer_attempt = input.review.as_ref().map(|review| {
(
review.ticket_id.clone(),
review.revision_id.clone(),
uuid::Uuid::now_v7().to_string(),
format!(
"{}{}",
uuid::Uuid::now_v7().simple(),
uuid::Uuid::now_v7().simple()
),
)
});
let child_workspace_context =
if let Some((ticket_id, revision_id, _, capability_token)) = &reviewer_attempt {
let workspace_id =
self.workspace_context
.workspace_id()
.cloned()
.ok_or_else(|| {
ToolError::InvalidArgument(
"reviewer handoff requires Workspace identity".to_string(),
)
})?;
let parent_client = self.workspace_context.client_handle();
if !parent_client.is_available() {
return Err(ToolError::InvalidArgument(
"reviewer handoff requires Workspace API authority".to_string(),
));
}
let child_client: Arc<dyn crate::worker::WorkspaceClient> =
Arc::new(ReviewerChildWorkspaceClient::new(
parent_client.clone(),
ReviewerAttemptContext {
ticket_id: ticket_id.clone(),
revision_id: revision_id.clone(),
},
capability_token.clone(),
));
crate::worker::WorkerWorkspaceContext::with_client(Some(workspace_id), child_client)
} else {
self.workspace_context.clone()
};
let store = EphemeralSessionStore::default();
let filesystem_authority =
WorkerFilesystemAuthority::local(self.workspace_root.clone(), child_cwd.clone());
@@ -385,7 +467,7 @@ impl Tool for SubWorkerSpawnTool {
child_manifest,
store.clone(),
self.prompt_loader.clone(),
self.workspace_context.clone(),
child_workspace_context,
filesystem_authority,
self.internal_client_override
.as_ref()
@@ -465,6 +547,66 @@ impl Tool for SubWorkerSpawnTool {
}
};
if let Some((ticket_id, revision_id, attempt_id, capability_token)) = &reviewer_attempt {
let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| {
ToolError::ExecutionFailed("reviewer attempt lost Workspace identity".to_string())
})?;
let child_session_id = session.session_id_string();
let child_registration = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/internal/reviewer-child-sessions",
workspace_id.as_str()
),
serde_json::json!({"child_session_id": child_session_id}).to_string(),
);
let child_response = self
.workspace_context
.client()
.execute(child_registration)
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"register Runtime-owned Reviewer child session: {error}"
))
})?;
if !child_response.is_success() {
let _ = session.stop().await;
return Err(ToolError::ExecutionFailed(format!(
"register Runtime-owned Reviewer child session failed with status {}: {}",
child_response.status, child_response.body
)));
}
let body = serde_json::json!({
"attempt_id": attempt_id,
"revision_id": revision_id,
"child_session_id": child_session_id,
"capability_token": capability_token,
});
let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/tickets/{}/merge-request/review-attempts",
workspace_id.as_str(),
ticket_id
),
body.to_string(),
);
let response = self
.workspace_context
.client()
.execute(request)
.map_err(|error| {
ToolError::ExecutionFailed(format!("register reviewer attempt: {error}"))
})?;
if !response.is_success() {
let _ = session.stop().await;
return Err(ToolError::ExecutionFailed(format!(
"register reviewer attempt failed with status {}: {}",
response.status, response.body
)));
}
}
let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new(
input.name.clone(),
scope_allow,
@@ -899,6 +1041,31 @@ mod tests {
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
};
#[test]
fn reviewer_handoff_requires_explicit_builtin_profile_and_read_only_scope() {
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"/tmp/work","permission":"read"}],
"review":{"ticket_id":"T1","revision_id":"V1"}
}))
.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":"/tmp/work","permission":"read"}],
"review":{"ticket_id":"T1","revision_id":"V1"}
}))
.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":"/tmp/work","permission":"write"}],
"review":{"ticket_id":"T1","revision_id":"V1"}
}))
.unwrap();
assert!(validate_reviewer_handoff(&writable).is_err());
}
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
ScopeRule {
target: path.to_path_buf(),
+159 -114
View File
@@ -223,55 +223,135 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
fn is_available(&self) -> bool;
fn execute(&self, request: WorkspaceRequest)
-> Result<WorkspaceResponse, WorkspaceClientError>;
}
/// HTTP forwarding client created by Runtime for one concrete Worker execution.
///
/// The upstream endpoint and source headers are private implementation details;
/// model-visible tools can only submit [`WorkspaceRequest`] values through the
/// [`WorkspaceClient`] trait.
pub struct RuntimeWorkspaceHttpClient {
workspace_id: String,
base_url: String,
runtime_id: String,
worker_id: String,
}
/// Executes the destructive WorkerRemove operation through Runtime-owned source proof.
/// Target identity is operation data; source identity and permission are never caller inputs.
fn execute_worker_remove(
&self,
_target_runtime_id: &str,
_target_worker_id: &str,
_expected_worker_revision: &str,
_reason: &str,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
Err(WorkspaceClientError::Unavailable(
"Runtime-owned WorkerRemove forwarding is unavailable".to_string(),
))
}
impl std::fmt::Debug for RuntimeWorkspaceHttpClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RuntimeWorkspaceHttpClient")
.field("workspace_id", &self.workspace_id)
.field("base_url", &self.base_url)
.field("runtime_id", &self.runtime_id)
.field("worker_id", &self.worker_id)
.finish()
/// Trusted review-attempt context is injected by the Internal SubWorker spawn layer.
/// It is never accepted from a model-visible tool argument.
fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> {
None
}
}
impl RuntimeWorkspaceHttpClient {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewerAttemptContext {
pub ticket_id: String,
pub revision_id: String,
}
#[derive(Debug)]
pub struct ReviewerChildWorkspaceClient {
inner: Arc<dyn WorkspaceClient>,
context: ReviewerAttemptContext,
capability_token: String,
}
impl ReviewerChildWorkspaceClient {
pub fn new(
workspace_id: impl Into<String>,
base_url: impl Into<String>,
runtime_id: impl Into<String>,
worker_id: impl Into<String>,
inner: Arc<dyn WorkspaceClient>,
context: ReviewerAttemptContext,
capability_token: String,
) -> Self {
Self {
workspace_id: workspace_id.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
runtime_id: runtime_id.into(),
worker_id: worker_id.into(),
inner,
context,
capability_token,
}
}
}
impl WorkspaceClient for RuntimeWorkspaceHttpClient {
impl WorkspaceClient for ReviewerChildWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
self.inner.workspace_id()
}
fn kind(&self) -> &str {
"runtime-reviewer-child"
}
fn is_available(&self) -> bool {
self.inner.is_available()
}
fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> {
Some(&self.context)
}
fn execute(
&self,
mut request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let expected_path = format!(
"/api/w/{}/tickets/{}/merge-request/reviews",
self.workspace_id().unwrap_or_default(),
self.context.ticket_id
);
if request.method == WorkspaceRequestMethod::Post && request.path == expected_path {
let body = request.body.take().ok_or_else(|| {
WorkspaceClientError::Request("review submission requires a JSON body".to_string())
})?;
let mut value: serde_json::Value = serde_json::from_str(&body)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
let object = value.as_object_mut().ok_or_else(|| {
WorkspaceClientError::Request(
"review submission body must be an object".to_string(),
)
})?;
object.insert(
"revision_id".to_string(),
serde_json::Value::String(self.context.revision_id.clone()),
);
object.insert(
"capability_token".to_string(),
serde_json::Value::String(self.capability_token.clone()),
);
request.body = Some(
serde_json::to_string(&value)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
);
} else if request.method != WorkspaceRequestMethod::Get {
return Err(WorkspaceClientError::Unavailable(
"Reviewer child Workspace authority is read-only except for its one attested Merge Request review submission".to_string(),
));
}
self.inner.execute(request)
}
}
#[cfg(test)]
#[derive(Clone, Debug)]
pub(crate) struct TestWorkspaceHttpClient {
workspace_id: String,
base_url: String,
}
#[cfg(test)]
impl TestWorkspaceHttpClient {
pub(crate) fn new(workspace_id: impl Into<String>, base_url: impl Into<String>) -> Self {
Self {
workspace_id: workspace_id.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
}
}
}
#[cfg(test)]
impl WorkspaceClient for TestWorkspaceHttpClient {
fn workspace_id(&self) -> Option<&str> {
Some(&self.workspace_id)
}
fn kind(&self) -> &str {
"runtime-http-proxy"
"test-http"
}
fn is_available(&self) -> bool {
@@ -283,32 +363,28 @@ impl WorkspaceClient for RuntimeWorkspaceHttpClient {
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let base_url = self.base_url.clone();
let runtime_id = self.runtime_id.clone();
let worker_id = self.worker_id.clone();
if tokio::runtime::Handle::try_current().is_ok() {
std::thread::spawn(move || {
execute_runtime_workspace_http(&base_url, &runtime_id, &worker_id, request)
})
.join()
.map_err(|_| {
WorkspaceClientError::Request("workspace request thread panicked".to_string())
})?
std::thread::spawn(move || execute_test_workspace_http(&base_url, request))
.join()
.map_err(|_| {
WorkspaceClientError::Request(
"test workspace request thread panicked".to_string(),
)
})?
} else {
execute_runtime_workspace_http(&base_url, &runtime_id, &worker_id, request)
execute_test_workspace_http(&base_url, request)
}
}
}
fn execute_runtime_workspace_http(
#[cfg(test)]
fn execute_test_workspace_http(
base_url: &str,
runtime_id: &str,
worker_id: &str,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if !request.path.starts_with('/') || request.path.starts_with("//") {
return Err(WorkspaceClientError::InvalidPath(request.path));
}
let url = format!("{base_url}{}", request.path);
let method = match request.method {
WorkspaceRequestMethod::Get => reqwest::Method::GET,
WorkspaceRequestMethod::Post => reqwest::Method::POST,
@@ -317,16 +393,11 @@ fn execute_runtime_workspace_http(
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
};
let client = reqwest::blocking::Client::new();
let mut request_builder = client
.request(method, url)
.header("x-yoi-runtime-id", runtime_id)
.header("x-yoi-worker-id", worker_id);
let mut builder = client.request(method, format!("{base_url}{}", request.path));
if let Some(body) = request.body {
request_builder = request_builder
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
builder = builder.body(body);
}
let response = request_builder
let response = builder
.send()
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
let status = response.status().as_u16();
@@ -365,6 +436,36 @@ impl WorkspaceClient for MarkerWorkspaceClient {
}
}
#[cfg(test)]
mod reviewer_client_tests {
use super::*;
#[test]
fn reviewer_child_client_denies_non_review_workspace_mutations() {
let inner: Arc<dyn WorkspaceClient> = Arc::new(MarkerWorkspaceClient {
workspace_id: Some("ws".to_string()),
kind: "marker".to_string(),
available: true,
reason: "forwarded".to_string(),
});
let client = ReviewerChildWorkspaceClient::new(
inner,
ReviewerAttemptContext {
ticket_id: "T1".into(),
revision_id: "V1".into(),
},
"secret".into(),
);
let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
"/api/w/ws/tickets/T1/comments",
"{}".to_string(),
);
let error = client.execute(request).unwrap_err();
assert!(error.to_string().contains("read-only"));
}
}
pub fn unavailable_workspace_client(
workspace_id: Option<&WorkspaceId>,
reason: impl Into<String>,
@@ -2949,13 +3050,13 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// Compact the current session by summarising history via a
/// disposable Engine, then replacing history with
/// `[summary, ...recent_turns]` and creating a new session.
/// `[summary, ...recent_turns]` in a new Segment of the same Session.
///
/// The summary Engine uses:
/// - `compaction.model` from the manifest if configured, or
/// - a clone of the main LlmClient via `clone_boxed()`.
///
/// Returns the new session ID.
/// Returns the new Segment ID. The Worker keeps its Session ID.
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SegmentId, WorkerError> {
use crate::compact::worker::{
CompactWorkerContext, CompactWorkerInterceptor, add_reference_tool,
@@ -6844,11 +6945,9 @@ mod build_summary_prompt_tests {
});
WorkerWorkspaceContext::with_client(
Some(WorkspaceId::new("test-memory").unwrap()),
Arc::new(RuntimeWorkspaceHttpClient::new(
Arc::new(TestWorkspaceHttpClient::new(
"test-memory",
format!("http://{addr}"),
"test-runtime",
"test-worker",
)),
)
}
@@ -6981,11 +7080,9 @@ mod build_summary_prompt_tests {
store,
WorkerWorkspaceContext::with_client(
Some(WorkspaceId::new("ws-skill").unwrap()),
Arc::new(RuntimeWorkspaceHttpClient::new(
Arc::new(TestWorkspaceHttpClient::new(
"ws-skill",
format!("http://{addr}"),
"test-runtime",
"test-worker",
)),
),
authority,
@@ -7027,58 +7124,6 @@ mod build_summary_prompt_tests {
}));
}
#[test]
fn runtime_workspace_client_sends_runtime_worker_identity_without_bearer() {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut first_line = String::new();
reader.read_line(&mut first_line).unwrap();
assert!(first_line.contains("/api/w/workspace-a/tickets/search"));
let mut runtime_id = String::new();
let mut worker_id = String::new();
let mut authorization = String::new();
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if let Some(value) = line.strip_prefix("x-yoi-runtime-id: ") {
runtime_id = value.trim().to_string();
}
if let Some(value) = line.strip_prefix("x-yoi-worker-id: ") {
worker_id = value.trim().to_string();
}
if let Some(value) = line.strip_prefix("authorization: ") {
authorization = value.trim().to_string();
}
if line == "\r\n" || line.is_empty() {
break;
}
}
assert_eq!(runtime_id, "runtime-a");
assert_eq!(worker_id, "worker-a");
assert!(authorization.is_empty());
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}")
.unwrap();
});
let client = RuntimeWorkspaceHttpClient::new(
"workspace-a",
format!("http://{address}"),
"runtime-a",
"worker-a",
);
let response = client
.execute(WorkspaceRequest::get("/api/w/workspace-a/tickets/search"))
.unwrap();
assert_eq!(response.status, 200);
server.join().unwrap();
}
#[tokio::test]
async fn cancelled_internal_extract_does_not_commit_pointer_or_completed_audit() {
let dir = tempfile::tempdir().unwrap();
+7 -3
View File
@@ -421,7 +421,11 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
// Drain run events so only compact events remain in `rx`.
let _ = drain(&mut rx);
let session_before = worker.session_id();
let segment_before = worker.segment_id();
worker.try_pre_run_compact().await;
assert_eq!(worker.session_id(), session_before);
assert_ne!(worker.segment_id(), segment_before);
let events = drain(&mut rx);
let kinds: Vec<&str> = events
@@ -442,7 +446,7 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
"unexpected CompactFailed in {kinds:?}"
);
// CompactDone carries the new session id.
// CompactDone carries the new Segment ID; the Session ID is unchanged.
let new_id_in_event = events.iter().find_map(|e| match e {
Event::CompactDone { new_segment_id } => Some(*new_segment_id),
_ => None,
@@ -583,11 +587,11 @@ async fn compact_resets_extract_pointer_so_extract_can_fire_again() {
);
// Compact runs. Without the fix the in-memory pointer would still
// reference the old session's history_len.
// reference the old Segment's history_len.
worker.try_pre_run_compact().await;
assert!(
worker.extract_pointer().is_none(),
"extract_pointer must be reset to None after compact (matches cold-restore on the new session)"
"extract_pointer must be reset to None after compact (matches cold-restore on the new Segment)"
);
}
+1
View File
@@ -32,6 +32,7 @@ sha2.workspace = true
thiserror.workspace = true
ticket.workspace = true
memory.workspace = true
merge-request.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
tokio-tungstenite.workspace = true
worker.workspace = true
+3 -2
View File
@@ -131,7 +131,7 @@ impl SqliteWorkspaceAuthority {
Ok(Self {
workspace_id: workspace_id.clone(),
store: SqliteWorkspaceStore::open(&database_path)?,
ticket_backend: SqliteTicketBackend::new(database_path, workspace_id),
ticket_backend: SqliteTicketBackend::open_verified(database_path, workspace_id)?,
})
}
@@ -725,7 +725,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
let db_path = dir.path().join("workspace.db");
SqliteTicketBackend::new(&db_path, "workspace-test")
SqliteTicketBackend::open(&db_path, "workspace-test")
.unwrap()
.import_from_local_backend(&ticket::LocalTicketBackend::new(
dir.path().join(".yoi/tickets"),
))
+113 -3
View File
@@ -54,8 +54,11 @@ use worker_runtime::interaction::{
};
use worker_runtime::management::{RuntimeOptions as EmbeddedRuntimeOptions, RuntimeStatus};
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
use worker_runtime::retention::{
WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory,
};
const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
pub(crate) const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host";
const MAX_DIAGNOSTICS: usize = 16;
@@ -856,6 +859,25 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
}
}
fn worker_retention_inventory(
&self,
worker_id: &str,
) -> Result<WorkerRetentionInventory, String> {
Err(format!(
"runtime does not implement retention inventory for '{worker_id}'"
))
}
fn execute_worker_retention(
&self,
request: WorkerRetentionExecutionRequest,
) -> Result<WorkerRetentionExecutionResult, String> {
Err(format!(
"runtime does not implement retention execution for '{}'",
request.worker_id
))
}
fn observation_source(
&self,
_worker_id: &str,
@@ -1399,6 +1421,44 @@ impl RuntimeRegistry {
Ok(runtime.delete_worker(worker_id))
}
pub fn worker_retention_inventory(
&self,
worker: &RuntimeWorkerRef,
) -> Result<WorkerRetentionInventory, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", &worker.runtime_id)?;
validate_backend_identifier("worker_id", &worker.worker_id)?;
self.runtime(&worker.runtime_id)?
.worker_retention_inventory(&worker.worker_id)
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: worker.runtime_id.clone(),
code: "worker_retention_inventory_failed".to_string(),
message,
})
}
pub fn execute_worker_retention(
&self,
worker: &RuntimeWorkerRef,
request: WorkerRetentionExecutionRequest,
) -> Result<WorkerRetentionExecutionResult, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", &worker.runtime_id)?;
validate_backend_identifier("worker_id", &worker.worker_id)?;
if request.worker_id.to_string() != worker.worker_id {
return Err(RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: worker.runtime_id.clone(),
code: "worker_id_mismatch".to_string(),
message: "retention request worker_id does not match target".to_string(),
});
}
self.runtime(&worker.runtime_id)?
.execute_worker_retention(request)
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: worker.runtime_id.clone(),
code: "worker_retention_execution_failed".to_string(),
message,
})
}
pub fn observation_source(
&self,
worker: &RuntimeWorkerRef,
@@ -1438,6 +1498,7 @@ impl RuntimeRegistry {
#[derive(Clone)]
pub struct EmbeddedWorkerRuntime {
workspace_id: String,
runtime_id: String,
host_id: String,
runtime: worker_runtime::Runtime,
@@ -1497,9 +1558,13 @@ impl EmbeddedWorkerRuntime {
pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self {
let workspace_id = workspace_id.as_ref().to_string();
runtime
.bind_runtime_identity(EMBEDDED_RUNTIME_ID)
.expect("fresh embedded Runtime must accept its Backend-owned identity");
Self {
runtime_id: EMBEDDED_RUNTIME_ID.to_string(),
host_id: host_id_for_embedded_workspace(&workspace_id),
workspace_id,
runtime_id: EMBEDDED_RUNTIME_ID.to_string(),
runtime,
execution_enabled: false,
resource_broker: BackendResourceBroker::default(),
@@ -2103,6 +2168,30 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
}
}
fn worker_retention_inventory(
&self,
worker_id: &str,
) -> Result<WorkerRetentionInventory, String> {
let worker_ref = self
.worker_ref(worker_id)
.ok_or_else(|| format!("invalid embedded Worker id '{worker_id}'"))?;
self.runtime
.worker_retention_inventory(&self.workspace_id, &worker_ref)
.map_err(|error| error.to_string())
}
fn execute_worker_retention(
&self,
request: WorkerRetentionExecutionRequest,
) -> Result<WorkerRetentionExecutionResult, String> {
if request.workspace_id != self.workspace_id {
return Err("retention request Workspace does not match embedded Runtime".to_string());
}
self.runtime
.execute_worker_retention(&request)
.map_err(|error| error.to_string())
}
fn observation_source(
&self,
worker_id: &str,
@@ -3125,6 +3214,28 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
}
}
fn worker_retention_inventory(
&self,
worker_id: &str,
) -> Result<WorkerRetentionInventory, String> {
self.get_json::<WorkerRetentionInventory>(&format!(
"/v1/workers/{worker_id}/retention/inventory"
))
.map_err(|diagnostic| diagnostic.message)
}
fn execute_worker_retention(
&self,
request: WorkerRetentionExecutionRequest,
) -> Result<WorkerRetentionExecutionResult, String> {
let worker_id = request.worker_id.to_string();
self.post_json::<_, WorkerRetentionExecutionResult>(
&format!("/v1/workers/{worker_id}/retention/execute"),
&request,
)
.map_err(|diagnostic| diagnostic.message)
}
fn observation_source(
&self,
worker_id: &str,
@@ -4036,7 +4147,6 @@ mod tests {
WorkspaceApiRef {
workspace_id: "workspace-test".to_string(),
base_url: "http://127.0.0.1:8787".to_string(),
runtime_id: Some("runtime-test".to_string()),
}
}
+12
View File
@@ -19,10 +19,12 @@ pub mod records;
pub use records::ticket_api_typescript;
pub mod repositories;
pub mod resource_broker;
pub mod retention;
pub mod runtime_subscription;
pub mod server;
pub mod skills;
pub mod store;
pub mod worker_source;
mod workspace_subscription;
pub use authority::{
@@ -55,6 +57,8 @@ pub enum Error {
Sqlite(#[from] rusqlite::Error),
#[error("ticket error: {0}")]
Ticket(#[from] ticket::TicketError),
#[error("merge request error: {0}")]
MergeRequest(#[from] merge_request::MergeRequestError),
#[error("yaml error: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("invalid input: {0}")]
@@ -88,6 +92,14 @@ pub enum Error {
},
#[error("unknown local repository `{0}`")]
UnknownRepository(String),
#[error(
"merge confirmation requires an authenticated Browser session; API tokens and Worker actors are not accepted"
)]
BrowserMergeConfirmationRequired,
#[error(
"Merge Request reopen requires an authenticated Browser session and explicit confirmation"
)]
BrowserReopenConfirmationRequired,
#[error("workspace id does not match this Workspace backend")]
WorkspaceIdMismatch,
#[error("Ticket assignment conflict: {0}")]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,325 @@
use std::sync::{Arc, OnceLock};
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,
};
use worker_runtime::worker_source::InProcessWorkerMutationProof;
use crate::hosts::RemoteRuntimeConfig;
use crate::server::WorkspaceApi;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PresentedWorkerMutationSourceProof<'a> {
Remote(&'a str),
InProcess(InProcessWorkerMutationProof),
}
pub fn presented_worker_remove_source<'a>(
headers: &'a HeaderMap,
in_process: Option<InProcessWorkerMutationProof>,
) -> Result<PresentedWorkerMutationSourceProof<'a>, WorkerMutationSourceProofError> {
if let Some(claims) = in_process {
return Ok(PresentedWorkerMutationSourceProof::InProcess(claims));
}
headers
.get(worker_runtime::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.trim().is_empty())
.map(PresentedWorkerMutationSourceProof::Remote)
.ok_or(WorkerMutationSourceProofError::Missing)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifiedWorkerMutationSource {
pub runtime_id: String,
pub worker_id: String,
pub actor_kind: WorkerMutationActorKind,
pub permission: String,
pub jti: String,
}
#[derive(Debug, thiserror::Error)]
pub enum WorkerMutationSourceProofError {
#[error("Worker mutation source proof is required")]
Missing,
#[error("Worker mutation source proof is invalid")]
Invalid,
#[error("Worker mutation source proof is not authorized for this Server")]
WrongAudience,
#[error("Worker mutation source proof is not authorized for this Workspace")]
WrongWorkspace,
#[error("Worker mutation source proof actor is not allowed")]
WrongActor,
#[error("Worker mutation source proof lacks `{0}` permission")]
MissingPermission(String),
#[error("Worker mutation source proof is expired")]
Expired,
#[error("Runtime trust is missing or revoked")]
RevokedRuntimeTrust,
#[error("Worker mutation source proof was already consumed")]
Replay,
#[error("source Worker is not a current member of this Workspace Runtime catalog")]
WorkerCatalogMembership,
#[error("source proof authority failed: {0}")]
Authority(String),
}
pub async fn verify_worker_remove_source(
api: &WorkspaceApi,
proof: PresentedWorkerMutationSourceProof<'_>,
target_runtime_id: &str,
target_worker_id: &str,
) -> Result<VerifiedWorkerMutationSource, WorkerMutationSourceProofError> {
verify_worker_remove_source_with(
&api.config,
&api.store,
proof,
target_runtime_id,
target_worker_id,
)
.await
}
async fn verify_worker_remove_source_with(
config: &crate::server::ServerConfig,
store: &std::sync::Arc<dyn crate::store::ControlPlaneStore>,
proof: PresentedWorkerMutationSourceProof<'_>,
target_runtime_id: &str,
target_worker_id: &str,
) -> Result<VerifiedWorkerMutationSource, WorkerMutationSourceProofError> {
let required_permission = worker_runtime::auth::WORKER_REMOVE_PERMISSION;
let now = unix_now_seconds();
let claims = match proof {
PresentedWorkerMutationSourceProof::Remote(token) => {
let unverified = decode_worker_mutation_source_claims(token)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss)?;
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 expected = WorkerMutationSourceExpectation {
runtime_id: &unverified.iss,
audience,
workspace_id: &config.workspace_id,
worker_id: None,
actor_kind: WorkerMutationActorKind::Worker,
operation: WorkerMutationOperation::WorkerRemove,
target_runtime_id,
target_worker_id,
permission: required_permission,
};
verify_worker_mutation_source_proof(&trusted.public_key, token, &expected, now)
.map_err(map_auth_error)?
}
PresentedWorkerMutationSourceProof::InProcess(proof) => {
let claims = proof.into_claims();
if config
.remote_runtime_sources
.iter()
.any(|runtime| runtime.runtime_id == claims.iss)
{
return Err(WorkerMutationSourceProofError::Invalid);
}
validate_in_process_claims(
&claims,
&format!("embedded:{}", config.workspace_id),
&config.workspace_id,
target_runtime_id,
target_worker_id,
required_permission,
now,
)?;
claims
}
};
let worker = worker_runtime::identity::RuntimeWorkerRef {
runtime_id: claims.iss.clone(),
worker_id: claims.worker_id.clone(),
};
let member = store
.get_worker_registry(&config.workspace_id, &worker)
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
if member.is_none() {
return Err(WorkerMutationSourceProofError::WorkerCatalogMembership);
}
let consumed_at = chrono::Utc::now().to_rfc3339();
let consumed = store
.consume_worker_mutation_source_jti(&claims.iss, &claims.jti, claims.exp, now, &consumed_at)
.await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
if !consumed {
return Err(WorkerMutationSourceProofError::Replay);
}
Ok(VerifiedWorkerMutationSource {
runtime_id: claims.iss,
worker_id: claims.worker_id,
actor_kind: claims.actor_kind,
permission: claims.permission,
jti: claims.jti,
})
}
pub(crate) trait VerifiedWorkerRemoveExecutor: Send + Sync {
fn execute(
&self,
source: VerifiedWorkerMutationSource,
target_runtime_id: &str,
target_worker_id: &str,
expected_worker_revision: &str,
reason: &str,
) -> Result<worker::WorkspaceResponse, String>;
}
#[derive(Clone)]
pub(crate) struct EmbeddedServerWorkerMutationDispatcher {
config: crate::server::ServerConfig,
store: Arc<dyn crate::store::ControlPlaneStore>,
executor: Arc<OnceLock<Arc<dyn VerifiedWorkerRemoveExecutor>>>,
}
impl EmbeddedServerWorkerMutationDispatcher {
pub(crate) fn new(
config: crate::server::ServerConfig,
store: Arc<dyn crate::store::ControlPlaneStore>,
) -> Self {
Self {
config,
store,
executor: Arc::new(OnceLock::new()),
}
}
pub(crate) fn install_executor(
&self,
executor: Arc<dyn VerifiedWorkerRemoveExecutor>,
) -> Result<(), &'static str> {
self.executor
.set(executor)
.map_err(|_| "WorkerRemove executor is already installed")
}
}
impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
for EmbeddedServerWorkerMutationDispatcher
{
fn execute_worker_remove(
&self,
proof: InProcessWorkerMutationProof,
target_runtime_id: &str,
target_worker_id: &str,
expected_worker_revision: &str,
reason: &str,
) -> Result<
worker::WorkspaceResponse,
worker_runtime::worker_source::RuntimeWorkerMutationForwardError,
> {
let source = futures::executor::block_on(verify_worker_remove_source_with(
&self.config,
&self.store,
PresentedWorkerMutationSourceProof::InProcess(proof),
target_runtime_id,
target_worker_id,
))
.map_err(|error| {
worker_runtime::worker_source::RuntimeWorkerMutationForwardError::Embedded(
error.to_string(),
)
})?;
let executor = self.executor.get().ok_or_else(|| {
worker_runtime::worker_source::RuntimeWorkerMutationForwardError::Embedded(
"WorkerRemove executor is unavailable".to_string(),
)
})?;
executor
.execute(
source,
target_runtime_id,
target_worker_id,
expected_worker_revision,
reason,
)
.map_err(worker_runtime::worker_source::RuntimeWorkerMutationForwardError::Embedded)
}
}
fn remote_audience<'a>(
config: &'a crate::server::ServerConfig,
runtime_id: &str,
) -> Result<&'a str, WorkerMutationSourceProofError> {
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())
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)
}
fn validate_in_process_claims(
claims: &WorkerMutationSourceClaims,
audience: &str,
workspace_id: &str,
target_runtime_id: &str,
target_worker_id: &str,
permission: &str,
now: u64,
) -> Result<(), WorkerMutationSourceProofError> {
if claims.aud != audience {
return Err(WorkerMutationSourceProofError::WrongAudience);
}
if claims.workspace_id != workspace_id {
return Err(WorkerMutationSourceProofError::WrongWorkspace);
}
if claims.actor_kind != WorkerMutationActorKind::Worker {
return Err(WorkerMutationSourceProofError::WrongActor);
}
if claims.operation != WorkerMutationOperation::WorkerRemove
|| claims.target_runtime_id != target_runtime_id
|| claims.target_worker_id != target_worker_id
{
return Err(WorkerMutationSourceProofError::Invalid);
}
if claims.permission != permission {
return Err(WorkerMutationSourceProofError::MissingPermission(
permission.to_string(),
));
}
if claims.exp <= now || claims.iat > now.saturating_add(60) || claims.jti.trim().is_empty() {
return Err(WorkerMutationSourceProofError::Expired);
}
Ok(())
}
fn map_auth_error(error: worker_runtime::auth::RuntimeAuthError) -> WorkerMutationSourceProofError {
use worker_runtime::auth::RuntimeAuthError;
match error {
RuntimeAuthError::WrongAudience { .. } => WorkerMutationSourceProofError::WrongAudience,
RuntimeAuthError::WrongWorkspace { .. } => WorkerMutationSourceProofError::WrongWorkspace,
RuntimeAuthError::WrongActorKind => WorkerMutationSourceProofError::WrongActor,
RuntimeAuthError::WrongOperation | RuntimeAuthError::WrongMutationTarget => {
WorkerMutationSourceProofError::Invalid
}
RuntimeAuthError::MissingPermission(permission) => {
WorkerMutationSourceProofError::MissingPermission(permission)
}
RuntimeAuthError::Expired => WorkerMutationSourceProofError::Expired,
_ => WorkerMutationSourceProofError::Invalid,
}
}
fn unix_now_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
+15 -127
View File
@@ -12,8 +12,8 @@ use ticket::config::{
use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketReview,
TicketReviewResult, TicketSummary, TicketWorkflowState,
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketSummary,
TicketWorkflowState,
};
const DEFAULT_LIST_LIMIT: usize = 50;
@@ -35,7 +35,6 @@ pub enum TicketCommand {
List(ListOptions),
Show { query: String },
Comment(CommentOptions),
Review(ReviewOptions),
State(StateOptions),
Close(CloseOptions),
Relation(RelationOptions),
@@ -67,13 +66,6 @@ pub struct CommentOptions {
pub body: BodySource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewOptions {
pub query: String,
pub result: TicketReviewResult,
pub body: BodySource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StateTarget {
Planning,
@@ -194,7 +186,6 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
query: parse_one_positional("show", &args[1..])?,
},
"comment" => TicketCommand::Comment(parse_comment(&args[1..])?),
"review" => TicketCommand::Review(parse_review(&args[1..])?),
"state" => TicketCommand::State(parse_state(&args[1..])?),
"close" => TicketCommand::Close(parse_close(&args[1..])?),
"relation" => TicketCommand::Relation(parse_relation(&args[1..])?),
@@ -249,7 +240,6 @@ fn run_command(
TicketCommand::List(options) => list(backend.as_ref(), options),
TicketCommand::Show { query } => show(backend.as_ref(), query),
TicketCommand::Comment(options) => comment(backend.as_ref(), options),
TicketCommand::Review(options) => review(backend.as_ref(), options),
TicketCommand::State(options) => state(backend.as_ref(), options),
TicketCommand::Close(options) => close(backend.as_ref(), options),
TicketCommand::Relation(options) => relation(backend.as_ref(), options),
@@ -370,7 +360,7 @@ fn backend_for_workspace(workspace: &Path) -> Result<Box<dyn TicketBackend>, Tic
let workspace_id = workspace_id_for_workspace(workspace)?;
let db_path = server_database_path(workspace)?;
Ok(Box::new(
SqliteTicketBackend::new(db_path, workspace_id)
SqliteTicketBackend::open(db_path, workspace_id)?
.with_record_language(config.ticket_record_language()),
))
}
@@ -381,7 +371,7 @@ fn import_local(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
.with_record_language(config.ticket_record_language());
let workspace_id = workspace_id_for_workspace(workspace)?;
let db_path = server_database_path(workspace)?;
let sqlite = SqliteTicketBackend::new(db_path.clone(), workspace_id)
let sqlite = SqliteTicketBackend::open(db_path.clone(), workspace_id)?
.with_record_language(config.ticket_record_language());
sqlite.import_from_local_backend(&local)?;
Ok(success(format!(
@@ -633,23 +623,6 @@ fn comment(
Ok(success(format!("appended\t{}\t{}\n", options.query, role)))
}
fn review(
backend: &dyn TicketBackend,
options: ReviewOptions,
) -> Result<TicketCliOutput, TicketCliError> {
let result = options.result.as_str().to_string();
let review = TicketReview {
result: options.result,
author: Some(default_author()),
body: MarkdownText::new(read_body_source(&options.body)?),
};
backend.review(TicketIdOrSlug::Query(options.query.clone()), review)?;
Ok(success(format!(
"reviewed\t{}\t{}\n",
options.query, result
)))
}
fn state(
backend: &dyn TicketBackend,
options: StateOptions,
@@ -660,7 +633,11 @@ fn state(
StateTarget::Ready => TicketWorkflowState::Ready,
StateTarget::Queued => TicketWorkflowState::Queued,
StateTarget::InProgress => TicketWorkflowState::InProgress,
StateTarget::Done => TicketWorkflowState::Done,
StateTarget::Done => {
return Err(TicketCliError::new(
"done is guarded by MergeRequestComplete with an approved immutable revision and operation_id",
));
}
StateTarget::Closed => {
return Err(TicketCliError::new(
"yoi ticket state <ticket> closed cannot write resolution.md; use `yoi ticket close <ticket> --resolution <text>` instead",
@@ -965,64 +942,6 @@ fn parse_comment(args: &[String]) -> Result<CommentOptions, TicketCliError> {
})
}
fn parse_review(args: &[String]) -> Result<ReviewOptions, TicketCliError> {
if args.is_empty() || args[0].starts_with('-') {
return Err(TicketCliError::new("review requires <id>"));
}
let query = args[0].clone();
let mut approve = false;
let mut request_changes = false;
let mut file = None;
let mut message = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--approve" => {
approve = true;
i += 1;
}
"--request-changes" => {
request_changes = true;
i += 1;
}
_ => match option_with_value(args, &mut i)? {
Some(("--file", value)) => file = Some(PathBuf::from(value)),
Some(("--message", value)) => message = Some(value),
Some((name, _)) => {
return Err(TicketCliError::new(format!(
"unknown review argument: {name}"
)));
}
None => {
return Err(TicketCliError::new(format!(
"unknown review argument: {}",
args[i]
)));
}
},
}
}
let result = match (approve, request_changes) {
(true, false) => TicketReviewResult::Approve,
(false, true) => TicketReviewResult::RequestChanges,
(false, false) => {
return Err(TicketCliError::new(
"review requires exactly one of --approve or --request-changes",
));
}
(true, true) => {
return Err(TicketCliError::new(
"review accepts exactly one of --approve or --request-changes",
));
}
};
Ok(ReviewOptions {
query,
result,
body: exactly_one_body("review", file, message)?,
})
}
fn parse_state(args: &[String]) -> Result<StateOptions, TicketCliError> {
if args.len() != 2 {
return Err(TicketCliError::new(
@@ -1244,7 +1163,7 @@ fn default_author() -> String {
}
fn help_text() -> &'static str {
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
}
#[cfg(test)]
@@ -1375,7 +1294,7 @@ mod tests {
}
#[test]
fn ticket_cli_create_list_show_comment_review_state_close_and_doctor() {
fn ticket_cli_create_list_show_comment_state_close_and_doctor() {
let temp = TempDir::new().unwrap();
let created = run(&temp, &["create", "--title", "CLI Created"]);
@@ -1416,22 +1335,6 @@ mod tests {
.contains(&format!("appended\t{}\timplementation_report", ticket_id))
);
let reviewed = run(
&temp,
&[
"review",
&ticket_id,
"--approve",
"--message",
"Looks good.",
],
);
assert!(
reviewed
.stdout
.contains(&format!("reviewed\t{}\tapprove", ticket_id))
);
let ready = run(&temp, &["state", &ticket_id, "ready"]);
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id));
let ready_listed = run(&temp, &["list", "--state", "ready"]);
@@ -1450,10 +1353,10 @@ mod tests {
let inprogress_listed = run(&temp, &["list", "--state", "inprogress"]);
assert!(inprogress_listed.stdout.contains(&ticket_id));
let done = run(&temp, &["state", &ticket_id, "done"]);
assert_eq!(done.stdout, format!("state\t{}\tdone\n", ticket_id));
let done_listed = run(&temp, &["list", "--state", "done"]);
assert!(done_listed.stdout.contains(&ticket_id));
let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"]))
.and_then(|cli| run_in_workspace(cli, temp.path()))
.unwrap_err();
assert!(done_error.to_string().contains("MergeRequestComplete"));
let closed = run(
&temp,
@@ -1469,7 +1372,6 @@ mod tests {
assert!(final_show.stdout.contains("State: closed"));
assert!(final_show.stdout.contains("Done via yoi ticket."));
assert!(final_show.stdout.contains("implementation_report"));
assert!(final_show.stdout.contains("review"));
}
#[test]
@@ -1594,20 +1496,6 @@ mod tests {
assert!(err.to_string().contains("exactly one"));
}
#[test]
fn ticket_cli_rejects_ambiguous_review_result() {
let err = parse_ticket_args(&args(&[
"review",
"ticket",
"--approve",
"--request-changes",
"--message",
"body",
]))
.unwrap_err();
assert!(err.to_string().contains("exactly one"));
}
#[test]
fn ticket_cli_state_closed_requires_close_command() {
let temp = TempDir::new().unwrap();
+63 -23
View File
@@ -1,45 +1,85 @@
# Worker, session, and state authority
# Worker aggregate, Session, and run authority
Yoi separates replayable history from current Worker identity because they answer different questions.
A Runtime-managed Worker is the canonical durable aggregate. One Worker owns exactly one Session for its lifetime; compaction and forks create Segments inside that Session rather than replacing the Session identity.
A session log answers: "what happened and what can be replayed?" Worker metadata answers: "what does this Worker name currently refer to?" Live sockets and registries answer only: "what seems reachable right now?"
This identity rule separates durable conversation history from execution attempts:
## Session logs
- **Worker ID** identifies the Runtime catalog aggregate and remains stable across stop/restore.
- **Session ID** identifies that Worker's sole replayable history and remains stable across compaction.
- **Segment ID** identifies a branch or compacted history projection inside the Session.
- **run generation** identifies one process/controller execution attempt and increases before every spawn or restore.
Session JSONL is the durable replay record. It contains committed user inputs, assistant items, tool results, system/runtime events that must explain later behavior, segment boundaries, and persisted effective snapshots needed to understand a run.
## Canonical Runtime layout
The session log should be append-oriented and schema drift should be compile-visible. Compatibility shims that silently reinterpret old plural/current entries make future readers less safe.
Filesystem Runtime stores materialize one aggregate under `workers/<worker_id>/`:
Session logs do not own current Worker-name state. A historical session can be replayable without being the active session for a Worker name.
```text
workers/<worker_id>/
worker.json
metadata.json
session/
session.json
segments/
<segment_id>.jsonl
<segment_id>.trace.jsonl
runs/
<generation>/
worker.sock
worker.out.log
worker.err.log
artifacts/
spawned/
```
## Worker metadata
`worker.json` is Runtime catalog authority: Workspace attribution, create/restore request, execution binding, and the last durably reserved run generation. `metadata.json` is the current Worker projection used to restore active/pending Segment pointers, resolved manifest state, delegation metadata, and child/peer visibility. It is not a second transcript.
Worker metadata is the current-state layer keyed by Worker name. It records active/pending session pointers, resolved manifest snapshots, current delegation metadata, spawned-child visibility, and restoration information.
`session/session.json` fixes the single Session ID for the aggregate. The Worker-specific Session store rejects attempts to address another Session ID. Session JSONL is the append-oriented replay authority for committed user inputs, assistant items, tool results, system/runtime events, Segment lineage, and effective snapshots required to explain later behavior.
This avoids reconstructing current Worker state by scanning every session log. It also gives `--worker <name>`, TUI resume, `ListWorkers`, and `RestoreWorker` a single current authority.
The normal execution path resolves all three stores from the trusted `WorkerRef`. It does not fall back to process-global Session or Worker-metadata roots. Those roots are legacy migration inputs only.
Worker metadata should stay thin. It is not a second transcript, and it should not duplicate model conversation content.
## Segment lifecycle
## Live runtime hints
A new Worker materializes its Session when the initial Segment is created. The Session ID then remains stable.
Sockets, process registries, and runtime files are liveness hints. They are useful for attach, status probing, and fast discovery, but they are not final proof that work completed or that a Worker's state changed durably.
Compaction writes a new Segment in the same Session with `compacted_from` lineage. Forking likewise writes a sibling Segment in the same Session with `forked_from` lineage. Allocation, UI, and event surfaces therefore report the new **Segment ID**, never a "new Session". A different Session requires a different Worker aggregate.
A reachable pending Worker should be visible even if durable logs have not materialized yet. Missing restore labels should degrade labels and diagnostics, not hide a live attachable Worker.
This keeps existing Worker IDs, Session IDs, Segment references, observation entry references, and UI routes meaningful across compaction and restore.
## Spawned children and delegation
## Run lifecycle
Parent-visible children are sourced from Worker metadata, not from a transient runtime mirror. Restoring a parent should reconstruct reachable children where possible and keep stopped-but-restorable children visible when metadata supports it.
Run generations are monotonic per Worker. Runtime durably reserves and persists the next generation before invoking the execution backend:
Delegated write scope is a capability loan. Stopping, shutting down, or pruning a child must reclaim the parent's effective write permissions while preserving explicit base denies.
- initial spawn reserves generation `1`;
- explicit restore reserves the next generation;
- startup restoration after a process crash reserves the next generation before reconnecting providers or observation state.
## Peer Workers
A generation directory is created with `create_new` semantics. An existing directory is a collision and is never reused as a new execution. This makes a crash between reservation and controller startup recoverable: startup consumes another generation instead of treating stale socket/log state as live authority.
Peer visibility is also Worker metadata, but it is distinct from spawned-child delegation. A TUI user can run `:peer <worker-name>` while attached to an idle Worker to register reciprocal peer metadata with another existing Worker. This is a metadata-level registration, not live target-controller consent.
Stopping a Worker waits for controller shutdown completion and removes `worker.sock`. The generation directory and diagnostic files remain evidence. How old generations are retained, archived, or purged is a separate policy; aggregate creation and migration do not invent that disposition.
A peer relationship only makes the Workers mutually visible through `ListWorkers` with visibility source `peer`. It does not grant filesystem scope, create a child output cursor, make either Worker the other's parent, or imply child completion notifications. Peer messages use `SendToPeerWorker`, which delivers a labeled notification into the target Worker's normal durable notification/history path. `SendToPeerWorker` requires the peer to be live and fails clearly for non-live peers rather than auto-restoring them.
Live sockets and provider sessions are execution hints, not durable identity. Restore reconstructs Workspace client attribution, observation registration, and Workdir/provider bindings from Runtime/Backend authority while keeping the Worker and Session identities unchanged.
## Notifications are not authority
## Legacy migration
Worker completion notifications are UX hints. Before treating delegated work as complete, inspect queryable evidence: child output, session/log state, worktree status, diffs, and validation output.
Startup migration recognizes the versioned `worker-aggregate-v1` format and treats legacy process-global metadata/Session directories as read-only sources.
This is why orchestration code should expose state-aware operations such as `ListWorkers` and `RestoreWorker`, rather than letting a background alert decide workflow state by itself.
The migration is serialized by an OS file lock and writes a versioned manifest with per-Worker checkpoints and diagnostics. For each unambiguous catalog Worker it:
1. validates Worker metadata identity and the referenced Session;
2. parses Segment and trace JSONL, tolerating only the existing crash-truncated final-line rule;
3. stages `session.json` plus all Segment files under the target Worker aggregate;
4. fsyncs files and directories, atomically renames the staged Session directory, and fsyncs its parent;
5. atomically writes and fsyncs `metadata.json`;
6. atomically updates the migration checkpoint.
Reruns validate exact Session identity, the complete source/target filename set, and file bytes before accepting an existing target. Mixed old/new stores and a crash between Session rename, metadata copy, and checkpoint update therefore converge without replacing divergent data.
Migration fails closed on target collisions, corrupt complete JSONL records, or ambiguous ownership. If multiple legacy metadata sources reference one Session—including metadata with no catalog Worker—the Session is not assigned to either aggregate. The manifest records the shared reference. Legacy metadata or Session directories with no catalog-backed owner are also recorded as orphans and left untouched; they do not become normal authority and are not silently deleted.
Legacy sources remain available for audit and recovery after a successful copy. Archive/retention disposition and Orchestrator-driven Worker removal are intentionally outside this migration contract.
## Child, peer, and notification state
Parent-visible children and peer registrations are current Worker metadata, distinct from Session history and run liveness. Restoring a parent reconstructs reachable children where possible and retains stopped-but-restorable visibility when metadata supports it. Delegated write scope is a capability loan; stopping or pruning a child must reclaim the parent's effective permissions.
Peer registration does not grant filesystem authority, imply parent ownership, or make notifications completion proof. Notifications remain UX hints committed through the normal Worker history path. Completion decisions must reread durable Ticket, repository, review, and test evidence rather than relying on a socket event or final assistant message.
+6 -6
View File
@@ -24,7 +24,7 @@ Use the highest-level interface that matches the work:
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions.
- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links.
- Inside Workers, use typed Ticket tools to create, inspect, comment, review, and close Tickets.
- Inside Workers, use typed Ticket tools for Ticket records and typed Merge Request tools for immutable implementation/review/completion evidence.
- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates.
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through `yoi panel`, Ticket tools, or `yoi ticket ...`.
@@ -37,8 +37,8 @@ Workers with the Ticket built-in feature can use typed Ticket tools:
- `TicketList` — lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority.
- `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds.
- `TicketComment`
- `TicketReview`
- `TicketWorkflowState`
- `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete`
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input.
- `TicketClose`
- `TicketRelationRecord`
- `TicketRelationQuery`
@@ -52,7 +52,7 @@ Use them when a Worker needs to materialize or update project records:
- Intake creates a new Ticket after user agreement.
- Orchestrator records routing decisions and intent packets.
- Reviewer records approve/request-changes review results.
- Reviewer commits an approve/request-changes result against one immutable Merge Request revision.
- Maintainer closes a Ticket with a resolution when merge/validation/cleanup evidence is complete.
Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable.
@@ -241,9 +241,9 @@ Implementation normally happens in a child git worktree created by the Orchestra
### 5. Review
Reviewer Workers should be sibling Workers, not children of coder Workers. They should read the Ticket, intent packet, diff, implementation report, and validation evidence.
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.
Review results should be recorded with the `TicketReview` tool. Maintainers working directly with the local backend can use the `yoi ticket` CLI documented later.
The Reviewer records the structured result with `MergeRequestReviewSubmit`. Request changes requires a new immutable revision and a fresh child attempt. `MergeRequestComplete` performs guarded Ticket completion with operation-id dedupe/CAS semantics; Flow transitions are not completion authority.
Blockers must be fixed or explicitly escalated before merge-ready submission.
@@ -0,0 +1,52 @@
# Flow verifier uses a stale committed-session capture
Date: 2026-08-11
Ticket: `00001KZPQW4GJ`
Flow instance: `019ff243-d4df-71f2-beb3-cbc360f58c34`
## Symptom
`RequestFlowTransition` repeatedly evaluated the `implement -> review` condition against a parent-session capture that ended immediately after branch creation and Ticket/plan reads. It did not observe later committed session entries containing implementation work, post-commit validation, clean-tree checks, commits, or independent Reviewer approval.
The verifier therefore returned `indeterminate` even though the Workdir contained the implementation and the current Worker session had already recorded the required evidence.
## Repository-visible evidence
Named branch and commits:
- `work/00001KZPQW4GJ-worker-remove-v3`
- `8ae930c5fc81acb2c60de15add07e016a1552edd``worker: add guarded WorkerRemove lifecycle`
- `f60c2d583485572697f7ec42d8cf3c8015e7c179``worker: resume failed removal operation`
Post-commit validation:
- `cargo test -p worker --lib`: 520 passed.
- `cargo test -p worker-runtime --lib`: 127 passed.
- `cargo test -p yoi-workspace-server --lib retention::tests`: 12 passed.
- `cargo test -p yoi-workspace-server --lib worker_remove`: 5 passed.
- `cargo test -p yoi-workspace-server --lib stale_policy_and_failed_retry_restore_fence`: passed.
- `cargo check -p yoi-workspace-server -p worker-runtime -p worker`: passed.
- `cargo fmt --all -- --check`: passed.
- `git diff --check`: passed.
Independent read-only Reviewer verdict:
> APPROVE — I found no blocker/high security or correctness issue in commits `8ae930c5` + `f60c2d58`.
The Reviewer explicitly confirmed the constrained four-field tool input, proof-only destructive boundary, exact Runtime-result Worker revision binding, successful recovery after registry purge, and failed-operation re-entry through the authoritative prepare/executing fence.
## Impact
A correct, tested, independently approved implementation cannot advance from `implement` to `review` because the Flow verifier does not see newly committed Worker history. Repeating validation or review inside the same live session does not repair the verifier input.
## Suggested fix
Before evaluating a transition, refresh the verifier's session capture from the latest committed Worker history revision and include stable references to:
- the current branch and commit,
- bounded validation command results,
- current clean-tree evidence,
- independent Reviewer verdicts,
- current Ticket review/evidence events.
The refreshed evidence must be committed to Worker history before verifier context construction, following the project context-injection invariant. Do not use an unrecorded transient reminder or mutate earlier history.
+15 -5
View File
@@ -5,7 +5,7 @@
states = {
implement = {
instructions = "Before editing, inspect the assigned Workdir's Git state. A newly delegated Git Workdir normally starts at a detached HEAD. If HEAD is detached, create and switch to a local branch named `work/<ticket-id>-<slug>`, using the canonical Ticket id and a short lowercase kebab-case slug derived from the Ticket title or implementation scope. If the Workdir is already on a suitable work branch after restore, keep it. Never delete, reset, or overwrite an existing branch to resolve a name collision; choose a concise collision-free suffix and report the actual branch. For this assigned Ticket Workdir, you are explicitly authorized to create or switch the local work branch and to use `git add` and `git commit`. Commit coherent, validated implementation slices while working; do not push, merge, force-rewrite a submitted revision, delete branches, or discard pre-existing changes. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record the concrete repository/test evidence. Before requesting review, commit all intended changes and confirm that the Workdir is clean.";
instructions = "Before editing, inspect the assigned Workdir's Git state. A newly delegated Git Workdir normally starts at a detached HEAD. If HEAD is detached, create and switch to a local branch named `work/<ticket-id>-<slug>`, using the canonical Ticket id and a short lowercase kebab-case slug derived from the Ticket title or implementation scope. If the Workdir is already on a suitable work branch after restore, keep it. Never delete, reset, or overwrite an existing branch to resolve a name collision; choose a concise collision-free suffix and report the actual branch. For this assigned Ticket Workdir, you are explicitly authorized to create or switch the local work branch and to use `git add` and `git commit`. Commit coherent, validated implementation slices while working; do not push, merge, force-rewrite a submitted revision, delete branches, or discard pre-existing changes. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence. Open or update the Ticket Merge Request with immutable repository revision evidence. Before requesting independent review, commit all intended changes, confirm that the Workdir is clean, and make the current MR revision authoritative. A Flow transition is never Ticket completion authority.";
transitions = {
review = {
target = "review";
@@ -15,11 +15,11 @@
};
review = {
instructions = "Spawn one independent Reviewer SubWorker with bounded Ticket, repository, diff, and validation context. Read its committed review through worker observation. Do not review your own implementation or treat a prose status as approval. After the Reviewer returns a typed approval or concrete requested changes, request a Flow transition.";
instructions = "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 child must commit MergeRequestReviewSubmit; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition.";
transitions = {
approved = {
target = "done";
condition = "The latest independent Reviewer attempt for the current implementation completed and approved it, with no later unresolved request_changes finding.";
target = "complete";
condition = "The authoritative Merge Request current revision has a structured approve result from its registered direct-child builtin:reviewer attempt, with no later unresolved request_changes finding. The Flow transition itself does not complete the Ticket.";
};
changes_requested = {
target = "fix";
@@ -38,8 +38,18 @@
};
};
complete = {
instructions = "Call MergeRequestComplete with a fresh operation_id and the approved current revision. The Server must revalidate current assignment, immutable revision, registered Reviewer attempt, and Ticket inprogress CAS. Only after the authoritative operation returns Ticket state done, request a Flow transition.";
transitions = {
completed = {
target = "done";
condition = "MergeRequestComplete durably returned done for this exact operation_id and current approved revision. A Flow state or prose report alone is never sufficient.";
};
};
};
done = {
instructions = "The Coder implementation and independent review loop is complete.";
instructions = "The guarded Merge Request completion operation committed Ticket state done. Flow terminal state only reflects that durable authority.";
terminal = true;
};
};
+1 -1
View File
@@ -9,6 +9,6 @@ import "./base.dcdl" // {
web = { enabled = true; };
sub_worker = { enabled = false; };
worker = { enabled = false; };
ticket = { enabled = true; thread = true; };
ticket = { enabled = true; thread = false; };
};
}
+4 -4
View File
@@ -1,7 +1,7 @@
You are the Ticket Coder role.
You are the assigned Coder. Implement the requested scope in the provided Workdir and keep durable evidence on the Ticket and its Merge Request.
Keep role behavior here and treat the first committed user message as concrete Ticket/action context only. Implement only within the delegated worktree/branch and authority scope. Treat the Ticket, intent packet, binding decisions/invariants, implementation latitude, validation expectations, and report expectations as the contract.
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose.
Choose local implementation tactics within that contract. Escalate to the Orchestrator instead of expanding scope when design, permission, dependency, prompt-boundary, or Ticket-boundary questions appear. Do not merge, push, close Tickets, delete worktrees, or create generated memory/local/runtime/log/lock/cache/socket/secret-like `.yoi` state.
Before review, open or append an immutable Merge Request revision containing the exact base/head/tree and changed-path evidence. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and include the structured `review` handoff with the Ticket id and current MR revision id. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected attempt authority.
Keep the repository operational throughout the work unless the Ticket explicitly permits a bounded incomplete state. Report the implementation and proportionate validation through the available typed Ticket tools; do not edit Ticket storage directly.
A request-changes result requires a new immutable revision and a fresh Reviewer child attempt. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id and the currently approved revision; the Server revalidates assignment and fences Ticket state side effects.
+2
View File
@@ -7,3 +7,5 @@ The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers its
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker with `WorkerShow`; pass the exact current `updated_at` value as `expected_worker_revision` with a concise reason. After removal, reread the Worker catalog and attachment state. Treat revision, assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures: do not guess policy or retry with stale input. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
+4 -4
View File
@@ -1,7 +1,7 @@
You are the Ticket Reviewer role.
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 concrete Ticket/action context only. Review the implementation against the Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Prefer read-only inspection and focused validation; do not merge, close, clean up worktrees, or take over implementation unless explicitly asked.
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the immutable current Merge Request revision 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, or take over implementation.
Report clear approve/request-changes evidence with risks, validation performed, and any unresolved requirement or design-boundary concern. When a workflow is invoked, follow that workflow as the procedural authority for reviewer handoff and report shape.
Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Attempt identity and revision identity are injected by your child Workspace client and are not model inputs. If the authoritative revision changed, submission must fail rather than approving stale work.
Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary behavior or compatibility.
Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary compatibility.
@@ -238,7 +238,9 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
ticketDetailLoad.includes("/repositories") &&
ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes('mutate("queue", "/queue"') &&
ticketDetailPage.includes('mutate("review", "/review"') &&
ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("explicit_confirmation: true") &&
!ticketDetailPage.includes('mutate("review", "/review"') &&
ticketDetailPage.includes('mutate("close", "/close"') &&
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
ticketDetailPage.includes("ticket.relations.outgoing"),
@@ -17,6 +17,16 @@
TicketDetail,
} from "$lib/workspace/sidebar/types";
type MergeRequestDetail = {
state: "draft" | "open" | "closed" | "merged";
review_status: "pending" | "approved" | "changes_requested";
current_revision: { revision_id: string; head_commit: string; head_tree: string; diff_digest: string; changed_paths: string[]; summary: string };
current_review?: { decision: string; body: string; reviewer_effective_profile: string } | null;
merged_at?: string | null;
};
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done");
const { data } = $props<{
data: {
workspaceId: string;
@@ -24,6 +34,7 @@
ticket: ApiResult<TicketDetail>;
repositories: ApiResult<RepositoryListResponse>;
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
mergeRequest: ApiResult<MergeRequestDetail | null>;
};
}>();
@@ -34,6 +45,7 @@
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
let ticket = $state<TicketDetail>(loadedTicket);
let mergeRequest = $state<MergeRequestDetail | null>(initialData.mergeRequest.data ?? null);
let editing = $state(false);
let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body);
@@ -43,8 +55,7 @@
let transitionReason = $state("");
let threadRole = $state("comment");
let threadBody = $state("");
let reviewResult = $state("approve");
let reviewBody = $state("");
let confirmMerge = $state(false);
let resolution = $state("");
let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
@@ -134,15 +145,27 @@
) threadBody = "";
}
async function review(event: SubmitEvent) {
event.preventDefault();
if (!reviewBody.trim()) return;
if (
await mutate("review", "/review", {
result: reviewResult,
body: reviewBody.trim(),
})
) reviewBody = "";
async function mergeConfirmedRevision() {
if (!mergeRequest || !confirmMerge || busy) return;
busy = "merge";
errorMessage = null;
try {
mergeRequest = await workspaceApiJsonWithBody<MergeRequestDetail>(
`${ticketPath}/merge-request/merge`,
{
method: "POST",
body: JSON.stringify({
expected_revision_id: mergeRequest.current_revision.revision_id,
explicit_confirmation: true,
}),
},
);
confirmMerge = false;
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
} finally {
busy = null;
}
}
async function closeTicket(event: SubmitEvent) {
@@ -277,7 +300,6 @@
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p>
<div class="ticket-role-actions">
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "reviewer")}>Reviewer</a>
</div>
{:else}
<p class="workspace-callout">Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers.</p>
@@ -311,7 +333,7 @@
<form class="ticket-control-form" onsubmit={transition}>
<label>State
<select bind:value={nextState}>
{#each TICKET_STATES as state}<option value={state}>{state}</option>{/each}
{#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each}
</select>
</label>
<label>Reason<input bind:value={transitionReason} placeholder="Optional decision context" /></label>
@@ -340,17 +362,27 @@
</form>
</details>
<details class="ticket-control-card">
<summary>Record review</summary>
<form class="ticket-control-form" onsubmit={review}>
<label>Result<select bind:value={reviewResult}>
<option value="approve">Approve</option>
<option value="request_changes">Request changes</option>
</select></label>
<label>Review body<textarea bind:value={reviewBody} rows="5" required></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "review" || !reviewBody.trim()}>Record review</button>
</form>
</details>
<section class="ticket-control-card">
<header><h2>Merge Request</h2></header>
{#if data.mergeRequest.error}
<p class="workspace-callout is-error">{data.mergeRequest.error}</p>
{:else if mergeRequest}
<p><strong>{mergeRequest.state}</strong> · {mergeRequest.review_status}</p>
<p><code>{mergeRequest.current_revision.revision_id}</code></p>
<p>Head <code>{mergeRequest.current_revision.head_commit}</code></p>
{#if mergeRequest.current_revision.summary}<p>{mergeRequest.current_revision.summary}</p>{/if}
{#if mergeRequest.current_review}
<p><strong>{mergeRequest.current_review.decision}</strong> by {mergeRequest.current_review.reviewer_effective_profile}</p>
{#if mergeRequest.current_review.body}<RichMarkdown text={mergeRequest.current_review.body} />{/if}
{/if}
{#if mergeRequest.state === "open" && mergeRequest.review_status === "approved"}
<label><input type="checkbox" bind:checked={confirmMerge} /> Explicitly confirm merge of this revision</label>
<button class="workspace-primary-button" type="button" disabled={!confirmMerge || busy !== null} onclick={mergeConfirmedRevision}>Confirm merge</button>
{/if}
{:else}
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
{/if}
</section>
{#if ticket.state !== "closed"}
<details class="ticket-control-card ticket-close-card">
@@ -1,35 +1,26 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type {
RepositoryListResponse,
TicketDetail,
} from "$lib/workspace/sidebar/types";
import type { RepositoryListResponse, TicketDetail } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const [ticket, repositories, orchestrator] = await Promise.all([
loadJson<TicketDetail>(
fetch,
workspaceApiPath(
params.workspaceId,
`/tickets/${encodeURIComponent(params.ticketId)}`,
),
),
loadJson<RepositoryListResponse>(
fetch,
workspaceApiPath(params.workspaceId, "/repositories"),
),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
),
]);
async function loadOptionalJson<T>(fetcher: typeof fetch, path: string): Promise<{ data: T | null; error: string | null }> {
try {
const response = await fetcher(path);
if (response.status === 404) return { data: null, error: null };
if (!response.ok) return { data: null, error: await response.text() || `HTTP ${response.status}` };
return { data: await response.json() as T, error: null };
} catch (error) {
return { data: null, error: error instanceof Error ? error.message : String(error) };
}
}
return {
workspaceId: params.workspaceId,
ticketId: params.ticketId,
ticket,
repositories,
orchestrator,
};
export const load = (async ({ fetch, params }) => {
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(params.ticketId)}`);
const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath),
loadJson<RepositoryListResponse>(fetch, workspaceApiPath(params.workspaceId, "/repositories")),
loadJson<WorkspaceOrchestratorStatus>(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")),
loadOptionalJson<Record<string, unknown>>(fetch, `${ticketPath}/merge-request`),
]);
return { workspaceId: params.workspaceId, ticketId: params.ticketId, ticket, repositories, orchestrator, mergeRequest };
}) satisfies PageLoad;