merge: integrate orchestration merge request lifecycle
# Conflicts: # resources/flows/coder-review.dcdl
This commit is contained in:
@@ -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
@@ -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)
|
||||
));
|
||||
}
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
))
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
+1923
-133
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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user