Compare commits
47
Commits
4cf34375a8
...
8c60921c99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c60921c99 | ||
|
|
956b453e6a | ||
|
|
063f203efe | ||
|
|
5f44b10ff9 | ||
|
|
e3dc8ee327 | ||
|
|
645458498a | ||
|
|
6c4119e2c6 | ||
|
|
7e9cae5f39 | ||
|
|
1fe1b7463f | ||
|
|
9e48cae759 | ||
|
|
aeb2727bea | ||
|
|
298c20012a | ||
|
|
7507412f1c | ||
|
|
45b7d0764d | ||
|
|
7508d428b0 | ||
|
|
977c8e7b21 | ||
|
|
4964583868 | ||
|
|
14aa1aabea | ||
|
|
76c427c887 | ||
|
|
ff7d874138 | ||
|
|
e881c8fad8 | ||
|
|
06cc6056e5 | ||
|
|
c15999b7a6 | ||
|
|
0b954c1ab6 | ||
|
|
eb0dd67d16 | ||
|
|
97828f8bd5 | ||
|
|
8be2cfd2a3 | ||
|
|
f41ab0e277 | ||
|
|
46a44b232b | ||
|
|
2cf4c57813 | ||
|
|
41534b215a | ||
|
|
5ea2792df7 | ||
|
|
1479148f84 | ||
|
|
7256d80514 | ||
|
|
8e73d755d3 | ||
|
|
027f60d262 | ||
|
|
4f042cae84 | ||
|
|
0b8924eda7 | ||
|
|
eaaf2f6dcc | ||
|
|
f5a0e14991 | ||
|
|
11e4d536c5 | ||
|
|
0fd2486baf | ||
|
|
8294a476d2 | ||
|
|
9922b654d7 | ||
|
|
b910efd945 | ||
|
|
20854edca2 | ||
|
|
2a23a5d574 |
@@ -70,11 +70,10 @@ Intake は以下を行う。
|
||||
|
||||
利用可能なら、以下の typed Ticket tools を使う。
|
||||
|
||||
- `TicketList`: 既存 Ticket の一覧・重複確認。
|
||||
- `TicketShow`: 関連 Ticket の詳細確認。
|
||||
- `QueryTicket`: 既存 Ticket の一覧・重複確認。
|
||||
- `ShowTicket`: 関連 Ticket の詳細確認。
|
||||
- `TicketCreate`: 合意済み Ticket の作成。
|
||||
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
|
||||
- `TicketDoctor`: 必要に応じた整合性確認。
|
||||
|
||||
Intake は `MergeRequest*`, `TicketWorkflowState`, `TicketClose` を通常使わない。review authority は assigned Coder が起動した read-only direct-child Reviewer の immutable Merge Request attempt に属し、completion / merge / close は各guarded workflowの責務である。
|
||||
|
||||
@@ -96,7 +95,7 @@ Ticket tools が利用できない環境では、勝手に file write で代替
|
||||
|
||||
### 2. 既存 Ticket を確認する
|
||||
|
||||
`TicketList` / `TicketShow` で duplicate / related work を探す。
|
||||
`QueryTicket` / `ShowTicket` で duplicate / related work を探す。
|
||||
|
||||
確認観点:
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ TicketCreate / TicketComment
|
||||
|
||||
Orchestrator は以下を行う。
|
||||
|
||||
- Ticket を `TicketShow` で読む。
|
||||
- 必要に応じて関連 Ticket を `TicketList` / `TicketShow` で確認する。
|
||||
- Ticket を `ShowTicket` で読む。
|
||||
- 必要に応じて関連 Ticket を `QueryTicket` / `ShowTicket` で確認する。
|
||||
- Ticket body / thread / artifacts / resolution / review / implementation report を読む。
|
||||
- Ticket が Objective context と結びついている場合は、Objective を medium-term goal / motivation / strategy / success criteria / decision context として読む。ただし Objective context は判断背景であり、Ticket body/thread/artifacts や explicit Ticket relations / OrchestrationPlan records を読む代替ではない。
|
||||
- repository 状態、関連 docs/code、既存 worktree、visible Pods を必要に応じて明示的に確認する。
|
||||
@@ -73,21 +73,21 @@ Orchestrator は以下を行う。
|
||||
|
||||
利用可能なら、以下を使う。
|
||||
|
||||
- `TicketList`: routing 候補や関連 Ticket の確認。
|
||||
- `TicketShow`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata と derived inverse/blocker view を確認。
|
||||
- `QueryTicket`: routing 候補、関連 Ticket、project-level forward relation (`depends_on` / `blocks` / `related` / `supersedes` / `duplicate_of`) と derived blocker summary を bounded filter/projection で確認する。`depends_on` と incoming unresolved `blocks` は queue/acceptance blocker であり、`related` は blocker ではない。`supersedes` / `duplicate_of` は visible diagnostic として扱い、自動的な lifecycle 変更や scheduler 判断にはしない。
|
||||
- `ShowTicket`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata、linked Objective、assignment、implementation/review evidence を確認する。
|
||||
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
|
||||
- `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。
|
||||
- `TicketRelationQuery`: project-level の forward relation (`depends_on` / `blocks` / `related` / `supersedes` / `duplicate_of`) を読む。`depends_on` と incoming unresolved `blocks` は queue/acceptance blocker であり、`related` は blocker ではない。`supersedes` / `duplicate_of` は visible diagnostic として扱い、自動的な lifecycle 変更や scheduler 判断にはしない。
|
||||
- `TicketDependencyCheck`: queue/acceptance 直前の typed dependency readiness guard に使う。
|
||||
- `TicketRelationRecord` / `TicketRelationRemove`: ユーザー合意済みの durable project relation を明示的に更新する場合だけ使う。
|
||||
- `TicketOrchestrationPlanQuery`: 対象 Ticket や関連 Ticket の ordering / blocker / conflict / waiting-capacity / accepted-plan 記録を読む。queued acceptance 前に必ず確認する。
|
||||
- `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、state 変更ではない。
|
||||
- `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
|
||||
- `TicketDoctor`: routing 前後の整合性確認。
|
||||
|
||||
`TicketCreate` は通常 Intake の責務だが、routing 中に follow-up Ticket が必要だと判断した場合は、ユーザー/上位 Orchestrator の合意後にだけ使う。
|
||||
|
||||
## Queued acceptance contract
|
||||
|
||||
- `queued -> inprogress` acceptance の直前に `TicketShow` / `TicketRelationQuery` の relation blockers を再確認する。unresolved `depends_on` や incoming unresolved `blocks` が残る場合は implementation side effect を始めず、理由を thread に残して `planning` へ戻すか blocked diagnostic として停止する。
|
||||
- `queued -> inprogress` acceptance の直前に `ShowTicket` / `QueryTicket` の relation blocker projection を再確認する。unresolved `depends_on` や incoming unresolved `blocks` が残る場合は implementation side effect を始めず、理由を thread に残して `planning` へ戻すか blocked diagnostic として停止する。
|
||||
- Relation metadata は project-level constraint であり、OrchestrationPlan は runtime ordering/capacity decision である。relation を OrchestrationPlan で代替しないし、OrchestrationPlan を durable dependency authority として扱わない。
|
||||
|
||||
`state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket、workspace state、対象 Ticket の `TicketOrchestrationPlanQuery` 記録、risk domain に応じた bounded project context を読んで、次のどちらかを行う。
|
||||
@@ -100,7 +100,7 @@ Orchestrator は以下を行う。
|
||||
|
||||
Parallel acceptance pass:
|
||||
|
||||
- 明示的な queue review 中に複数の queued Ticket が見える場合、Orchestrator は最初の1件の完了待ちを default にしない。各 Ticket について Ticket body/thread/artifacts、TicketRelationQuery、TicketOrchestrationPlanQuery、workspace/worktree dirty state、visible Pods、既存 branches、conflict/dependency notes を確認する。
|
||||
- 明示的な queue review 中に複数の queued Ticket が見える場合、Orchestrator は最初の1件の完了待ちを default にしない。各 Ticket について Ticket body/thread/artifacts、QueryTicket の relation/blocker projection、TicketOrchestrationPlanQuery、workspace/worktree dirty state、visible Pods、既存 branches、conflict/dependency notes を確認する。
|
||||
- 追加で開始してよいのは、blocking relation/dependency がなく、`do_not_parallelize` または applicable conflict record がなく、source/write surfaces が disjoint または conflict risk が小さく機械的で、coder/reviewer follow-up capacity があり、acceptance basis となる Ticket thread/plan/workspace records を side effect 前に記録・commit でき、別 worktree/branch/scope を切れる Ticket だけである。
|
||||
- capacity が見えるのに queued Ticket を idle にする場合は、dependency / conflict / capacity / missing planning decision / dirty workspace / reviewer-coder bottleneck / migration boundary / human gate のいずれかの bounded reason を記録する。
|
||||
- この pass は scheduler、background runner、resource graph solver、automatic queue drain loop ではない。unqueued Ticket を開始せず、各 Ticket の `queued -> inprogress` acceptance を個別に記録する。
|
||||
@@ -269,8 +269,8 @@ Action:
|
||||
### 1. 状態確認
|
||||
|
||||
- `git state --short --branch`
|
||||
- `TicketShow <target>`
|
||||
- 関連 Ticket の `TicketList` / `TicketShow`
|
||||
- `ShowTicket <target>`
|
||||
- 関連 Ticket の `QueryTicket` / `ShowTicket`
|
||||
- 必要に応じて docs/code/workflow/history
|
||||
- 必要に応じて visible Pods / worktrees / branches
|
||||
|
||||
|
||||
@@ -26,17 +26,26 @@ Workerの状態から純粋に再現可能で、且つ揮発性の無い操作
|
||||
```sh
|
||||
cargo test -p <crate> --lib <test-or-module-filter>
|
||||
cargo test -p <crate> --test <test-target> <test-filter>
|
||||
cargo check -p <crate> -p <dependent-crate>
|
||||
```
|
||||
|
||||
完了前には変更内容に応じて、対象crate全体のtest、影響するfeature構成、依存crateのcheckを追加する。通常の差分検証には以下を使う。
|
||||
完了前には、workspace rootで必ず`cargo check`を実行する。rootの`cargo check`は
|
||||
`default-members`に含まれるTUIやServerを含む通常のcompile closureを確認するため、公開型の
|
||||
変更ごとにLLMがreverse dependencyを推測して`-p`を列挙する運用にはしない。
|
||||
|
||||
```sh
|
||||
cargo check
|
||||
cargo test -p <changed-crate>
|
||||
cargo fmt --all -- --check
|
||||
git diff --check HEAD
|
||||
```
|
||||
|
||||
workspace全体、E2E、Nix/Docker buildなどの重い検証は、変更した境界を狭い検証では証明できない場合や明示的に要求された場合に選ぶ。実行した検証が何を証明するのかを意識し、広い検証を形式的に回すだけにしない。
|
||||
変更したcrate全体のtestに加え、影響するfeature構成やtest-only targetがある場合は、その検証を
|
||||
追加する。`cargo check`はtestを実行せず、通常有効でないfeatureまでは確認しないため、semanticな
|
||||
証明とfeature境界の検証はtargeted test/checkで補う。
|
||||
|
||||
workspace全体のtest、`--all-targets`、E2E、Nix/Docker buildなどの重い検証は、変更した境界を
|
||||
通常のroot checkと狭い検証では証明できない場合や、明示的に要求された場合に選ぶ。実行した検証が
|
||||
何を証明するのかを意識し、広い検証を形式的に回すだけにしない。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -539,6 +539,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"uuid",
|
||||
"workdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2532,11 +2533,13 @@ dependencies = [
|
||||
name = "merge-request"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"sha2 0.11.0",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -16,6 +16,7 @@ thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
workdir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::fmt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendRuntimeTarget {
|
||||
@@ -98,46 +99,6 @@ pub struct BackendWorkerCapabilitySummary {
|
||||
pub can_spawn_followup: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendWorkingDirectoryCleanupTarget {
|
||||
pub kind: String,
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendWorkingDirectoryOccupancy {
|
||||
pub runtime_id: String,
|
||||
pub runtime_worker_id: u64,
|
||||
pub worker_id: String,
|
||||
pub display_name: String,
|
||||
pub linked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendWorkingDirectorySummary {
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
#[serde(default)]
|
||||
pub creation_selector: Option<String>,
|
||||
#[serde(default)]
|
||||
pub creation_ref: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_selector: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_ref: Option<String>,
|
||||
pub materializer_kind: String,
|
||||
#[serde(default)]
|
||||
pub cleanup_target: Option<BackendWorkingDirectoryCleanupTarget>,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub cleanliness: Option<String>,
|
||||
#[serde(default)]
|
||||
pub primary_worker_id: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub occupied_by: Option<BackendWorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendWorkerSummary {
|
||||
pub runtime_id: String,
|
||||
@@ -618,6 +579,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
|
||||
let payload = serde_json::json!({
|
||||
"runtime_id": "arcadia",
|
||||
"worker_id": "worker-opaque-64",
|
||||
"host_id": "host",
|
||||
"display_name": "Coder",
|
||||
"label": "Coder",
|
||||
"workspace": {"visibility": "workspace", "identity": "workspace"},
|
||||
"state": "idle",
|
||||
"implementation": {"kind": "worker", "display_hint": "Coder"},
|
||||
"capabilities": {"can_stop": true, "can_spawn_followup": false},
|
||||
"working_directory": {
|
||||
"working_directory_id": "wd-1",
|
||||
"repository_id": "main",
|
||||
"materializer_kind": "local_git_worktree",
|
||||
"status": "active",
|
||||
"occupied_by": {
|
||||
"runtime_id": "arcadia",
|
||||
"worker_id": "worker-opaque-64",
|
||||
"display_name": "Coder",
|
||||
"linked_at": "2026-08-12T00:00:00Z"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let worker: BackendWorkerSummary = serde_json::from_value(payload.clone()).unwrap();
|
||||
let occupied_by = worker
|
||||
.working_directory
|
||||
.unwrap()
|
||||
.occupied_by
|
||||
.expect("occupied Workdir");
|
||||
assert_eq!(occupied_by.worker.runtime_id, "arcadia");
|
||||
assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64");
|
||||
|
||||
let mut stale = payload;
|
||||
stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
|
||||
assert!(serde_json::from_value::<BackendWorkerSummary>(stale).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workers_path_can_be_workspace_scoped_for_status_queries() {
|
||||
let path = backend_runtime_workers_path(Some("team main"), "runtime/one");
|
||||
|
||||
@@ -5,10 +5,12 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
rusqlite.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
sha2.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
+1299
-1290
File diff suppressed because it is too large
Load Diff
+352
-461
@@ -1,495 +1,386 @@
|
||||
use chrono::{TimeZone, Utc};
|
||||
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}"),
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
#[derive(Clone)]
|
||||
struct Assignments(Arc<Mutex<CurrentAssignment>>);
|
||||
impl AssignmentSource for Assignments {
|
||||
fn current_assignment(&self, _: &str, _: &str) -> Result<Option<CurrentAssignment>, String> {
|
||||
Ok(Some(self.0.lock().unwrap().clone()))
|
||||
}
|
||||
}
|
||||
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();
|
||||
struct Repositories;
|
||||
impl RepositorySource for Repositories {
|
||||
fn repository_belongs_to_workspace(&self, w: &str, r: &str) -> Result<bool, String> {
|
||||
Ok(w == "W" && r == "R")
|
||||
}
|
||||
}
|
||||
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 at(s: u32) -> chrono::DateTime<Utc> {
|
||||
Utc.with_ymd_and_hms(2026, 7, 26, 12, 0, s)
|
||||
.single()
|
||||
.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(),
|
||||
fn auth() -> MergeRequestAuth {
|
||||
MergeRequestAuth {
|
||||
workspace_id: "W".into(),
|
||||
repository_id: "R".into(),
|
||||
runtime_id: "runtime".into(),
|
||||
worker_id: "coder".into(),
|
||||
assignment_id: "A".into(),
|
||||
}
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("db");
|
||||
let c = Connection::open(&p).unwrap();
|
||||
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at 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,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap();
|
||||
drop(c);
|
||||
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
|
||||
assignment_id: "A".into(),
|
||||
ticket_id: "T".into(),
|
||||
runtime_id: "runtime".into(),
|
||||
worker_id: "coder".into(),
|
||||
})));
|
||||
let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap();
|
||||
(d, s)
|
||||
}
|
||||
fn open(s: &MergeRequestStore) {
|
||||
s.open_merge_request(OpenMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
repository_id: "R".into(),
|
||||
selector_from: "work/t".into(),
|
||||
selector_to: "develop".into(),
|
||||
summary: "summary".into(),
|
||||
auth: auth(),
|
||||
now: at(1),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequestedEvent {
|
||||
s.register_reviewer_child_session(RegisterReviewerChildSession {
|
||||
workspace_id: "W".into(),
|
||||
parent_runtime_id: "runtime".into(),
|
||||
parent_worker_id: "coder".into(),
|
||||
child_session_id: format!("child-{token}"),
|
||||
reviewer_profile: "builtin:reviewer".into(),
|
||||
now: at(2),
|
||||
})
|
||||
.unwrap();
|
||||
s.request_review(RequestMergeRequestReview {
|
||||
ticket_id: "T".into(),
|
||||
subject_ref: subject.into(),
|
||||
child_session_id: format!("child-{token}"),
|
||||
capability_token: token.into(),
|
||||
auth: auth(),
|
||||
now: at(3),
|
||||
})
|
||||
.unwrap()
|
||||
.request_event
|
||||
}
|
||||
fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
|
||||
request(s, subject, token);
|
||||
s.submit_review(SubmitMergeRequestReview {
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: subject.into(),
|
||||
capability_token: token.into(),
|
||||
decision: ReviewDecision::Approve,
|
||||
body: "approved".into(),
|
||||
findings: vec![],
|
||||
now: at(4),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[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(),
|
||||
fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||
let (_d, s) = fixture();
|
||||
open(&s);
|
||||
let review = approve(&s, "opaque-source-ref", "token");
|
||||
let ready = s
|
||||
.readiness(ReadinessCheck {
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: Some("opaque-source-ref".into()),
|
||||
auth: auth(),
|
||||
})
|
||||
.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",
|
||||
..
|
||||
assert!(ready.ready);
|
||||
let merged = s
|
||||
.complete(CompleteMergeRequest {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "op".into(),
|
||||
approval_event_id: review.event_id,
|
||||
current_subject_ref: "opaque-source-ref".into(),
|
||||
target_ref_before: "old-target-ref".into(),
|
||||
target_ref_after: "new-target-ref".into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
now: at(5),
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[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",
|
||||
.unwrap();
|
||||
assert_eq!(merged.approved_source_ref, "opaque-source-ref");
|
||||
let mr = s.get("W", "T").unwrap();
|
||||
assert_eq!(mr.selector_from.as_deref(), Some("work/t"));
|
||||
assert_eq!(mr.state, MergeRequestState::Merged);
|
||||
let json = serde_json::to_string(&mr).unwrap();
|
||||
for banned in [
|
||||
"revision_id",
|
||||
"attempt_id",
|
||||
"base_commit",
|
||||
"head_commit",
|
||||
"source_commit",
|
||||
"result_commit",
|
||||
"current_revision",
|
||||
] {
|
||||
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);
|
||||
assert!(!json.contains(banned), "{banned} in {json}")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v7_completion_operations_are_preserved_as_legacy_assigned_coder_authority() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("v7.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(7,'fresh_bounded_context_authority');\
|
||||
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));\
|
||||
INSERT INTO typed_tickets VALUES('ws-a','T1','done',1,'t');\
|
||||
CREATE TABLE merge_request_completion_operations(workspace_id TEXT NOT NULL,operation_id TEXT NOT NULL,ticket_id TEXT NOT NULL,revision_id TEXT NOT NULL,assignment_id TEXT NOT NULL,fingerprint TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN ('pending','completed')),result_ticket_state TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,operation_id),FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id));\
|
||||
INSERT INTO merge_request_completion_operations VALUES('ws-a','legacy-op','T1','V1','A1','legacy-fingerprint','completed','done','t','t');",
|
||||
).unwrap();
|
||||
drop(conn);
|
||||
|
||||
SqliteMergeRequestStore::open(&path, "ws-a").unwrap();
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
let row: (String, String, Option<String>, Option<String>, String) = conn
|
||||
.query_row(
|
||||
"SELECT authority_kind,implementation_assignment_id,completion_actor_runtime_id,completion_actor_worker_id,fingerprint FROM merge_request_completion_operations WHERE operation_id='legacy-op'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
row,
|
||||
(
|
||||
"legacy_assigned_coder".into(),
|
||||
"A1".into(),
|
||||
None,
|
||||
None,
|
||||
"legacy-fingerprint".into()
|
||||
)
|
||||
fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_returns() {
|
||||
let (_d, s) = fixture();
|
||||
open(&s);
|
||||
let approved = approve(&s, "source-a", "one");
|
||||
request(&s, "source-b", "two");
|
||||
assert!(
|
||||
s.submit_review(SubmitMergeRequestReview {
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "source-c".into(),
|
||||
capability_token: "two".into(),
|
||||
decision: ReviewDecision::Approve,
|
||||
body: "stale".into(),
|
||||
findings: vec![],
|
||||
now: at(6)
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
let version: i64 = conn
|
||||
.query_row(
|
||||
"SELECT MAX(version) FROM merge_request_schema_migrations",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
let mr = s.get("W", "T").unwrap();
|
||||
assert!(
|
||||
mr.thread
|
||||
.iter()
|
||||
.any(|e| matches!(e, MergeRequestThreadEvent::ReviewCancelled(_)))
|
||||
);
|
||||
assert_eq!(
|
||||
mr.effective_review("source-a").map(|r| &r.event_id),
|
||||
Some(&approved.event_id)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn review_revocation_invalidates_readiness() {
|
||||
let (_d, s) = fixture();
|
||||
open(&s);
|
||||
let review = approve(&s, "source", "one");
|
||||
s.revoke_review(RevokeMergeRequestReview {
|
||||
ticket_id: "T".into(),
|
||||
review_event_id: review.event_id,
|
||||
reason: "bad evidence".into(),
|
||||
auth: auth(),
|
||||
now: at(7),
|
||||
})
|
||||
.unwrap();
|
||||
let r = s
|
||||
.readiness(ReadinessCheck {
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: Some("source".into()),
|
||||
auth: auth(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(version, 8);
|
||||
assert!(!r.ready);
|
||||
}
|
||||
|
||||
#[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(),
|
||||
})
|
||||
fn v11_migration_preserves_review_events_and_requires_selector_repair() {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
c.execute_batch("CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T');CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY,version INTEGER);INSERT INTO merge_request_schema VALUES(1,11);CREATE TABLE merge_requests(workspace_id TEXT,merge_request_id TEXT,repository_id TEXT,state TEXT,target_ref_selector TEXT,current_revision_ordinal INTEGER,current_revision_id TEXT,created_at TEXT,updated_at TEXT,merged_revision_id TEXT,merged_at TEXT);CREATE TABLE merge_request_ticket_relations(workspace_id TEXT,merge_request_id TEXT,ticket_id TEXT,relation_kind TEXT,created_at TEXT);CREATE TABLE merge_request_revisions(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,base_commit TEXT,head_commit TEXT,diff_digest TEXT,summary TEXT,assignment_id TEXT,created_at TEXT);CREATE TABLE merge_request_revision_paths(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,path TEXT);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT,child_session_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,reviewer_profile TEXT,registered_at TEXT);CREATE TABLE merge_request_review_attempts(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,ticket_id TEXT,revision_id TEXT,revision_ordinal INTEGER,parent_assignment_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,child_session_id TEXT,reviewer_effective_profile TEXT,capability_token TEXT,status TEXT,created_at TEXT,consumed_at TEXT);CREATE TABLE merge_request_reviews(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,revision_id TEXT,decision TEXT,body TEXT,submitted_at TEXT);CREATE TABLE merge_request_review_findings(workspace_id TEXT,attempt_id TEXT,ordinal INTEGER,severity TEXT,code TEXT,path TEXT,line INTEGER,body TEXT);CREATE TABLE merge_request_completion_operations(workspace_id TEXT,operation_id TEXT,ticket_id TEXT,revision_id TEXT,authority_kind TEXT,implementation_assignment_id TEXT,completion_actor_runtime_id TEXT,completion_actor_worker_id TEXT,target_commit TEXT,source_commit TEXT,result_commit TEXT,strategy TEXT,resolution TEXT,fingerprint TEXT,status TEXT,result_ticket_state TEXT,created_at TEXT,updated_at TEXT);INSERT INTO merge_requests VALUES('W','MR','R','open','develop',1,'V','2026-07-26T12:00:00Z','2026-07-26T12:00:00Z',NULL,NULL);INSERT INTO merge_request_ticket_relations VALUES('W','MR','T','implements','2026-07-26T12:00:00Z');INSERT INTO merge_request_revisions VALUES('W','MR','V',1,'base','subject','digest','summary','A','2026-07-26T12:00:00Z');INSERT INTO merge_request_review_attempts VALUES('W','AT','MR','T','V',1,'A','runtime','coder','child','builtin:reviewer','token','submitted','2026-07-26T12:00:00Z','2026-07-26T12:00:01Z');INSERT INTO merge_request_reviews VALUES('W','AT','MR','V','approve','approved','2026-07-26T12:00:01Z');INSERT INTO merge_request_review_attempts VALUES('W','PENDING','MR','T','V',1,'A','runtime','coder','pending-child','builtin:reviewer','pending-token','registered','2026-07-26T12:00:02Z',NULL);").unwrap();
|
||||
merge_request::migrate(&c).unwrap();
|
||||
let selector: Option<String> = c
|
||||
.query_row("SELECT selector_from FROM merge_requests", [], |r| r.get(0))
|
||||
.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(),
|
||||
implementation_assignment_id: "A1".into(),
|
||||
completion_actor_runtime_id: "OR".into(),
|
||||
completion_actor_worker_id: "OW".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
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT authority_kind || ':' || implementation_assignment_id || ':' || completion_actor_runtime_id || ':' || completion_actor_worker_id FROM merge_request_completion_operations WHERE workspace_id='ws-a' AND operation_id='OP1'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"workspace_orchestrator:A1:OR:OW"
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT author FROM typed_ticket_events WHERE workspace_id='ws-a' AND ticket_id='T1' AND kind='state_changed'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"worker:OR:OW"
|
||||
);
|
||||
let authority: String = conn
|
||||
assert!(selector.is_none());
|
||||
let kinds: String = c
|
||||
.query_row(
|
||||
"SELECT value FROM typed_ticket_event_attributes WHERE workspace_id='ws-a' AND ticket_id='T1' AND key='completion_authority'",
|
||||
"SELECT group_concat(kind,',') FROM merge_request_thread_events ORDER BY sequence",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(authority, "workspace_orchestrator");
|
||||
}
|
||||
|
||||
#[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(),
|
||||
implementation_assignment_id: "A1".into(),
|
||||
completion_actor_runtime_id: "OR".into(),
|
||||
completion_actor_worker_id: "OW".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
|
||||
kinds,
|
||||
"review_requested,review,review_requested,review_cancelled"
|
||||
);
|
||||
assert_eq!(
|
||||
outcomes.iter().filter(|outcome| outcome.replayed).count(),
|
||||
1
|
||||
);
|
||||
let conn = Connection::open(store.db_path()).unwrap();
|
||||
let events: i64 = conn
|
||||
let old: bool = c
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id='ws-a' AND ticket_id='T1'",
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='merge_request_revisions')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!old);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authority_reads_full_thread_while_public_pages_remain_bounded() {
|
||||
let (_d, store) = fixture();
|
||||
open(&store);
|
||||
for index in 0..55 {
|
||||
approve(&store, "same-subject", &format!("token-{index}"));
|
||||
}
|
||||
let mr = store.get("W", "T").unwrap();
|
||||
assert!(mr.thread.len() > 100);
|
||||
assert_eq!(
|
||||
mr.effective_review("same-subject").unwrap().decision,
|
||||
ReviewDecision::Approve
|
||||
);
|
||||
assert_eq!(store.thread_page("W", "T", None, 20).unwrap().len(), 20);
|
||||
assert_eq!(
|
||||
store.thread_page("W", "T", Some(100), 20).unwrap().len(),
|
||||
11
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_rejects_superseded_approval_for_same_subject() {
|
||||
let (_d, store) = fixture();
|
||||
open(&store);
|
||||
let old_approval = approve(&store, "subject", "approval");
|
||||
request(&store, "subject", "changes");
|
||||
store
|
||||
.submit_review(SubmitMergeRequestReview {
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "subject".into(),
|
||||
capability_token: "changes".into(),
|
||||
decision: ReviewDecision::RequestChanges,
|
||||
body: "changes required".into(),
|
||||
findings: vec![],
|
||||
now: at(5),
|
||||
})
|
||||
.unwrap();
|
||||
let result = store.complete(CompleteMergeRequest {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "op".into(),
|
||||
approval_event_id: old_approval.event_id,
|
||||
current_subject_ref: "subject".into(),
|
||||
target_ref_before: "before".into(),
|
||||
target_ref_after: "after".into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
now: at(6),
|
||||
});
|
||||
assert!(matches!(result, Err(MergeRequestError::NotReady(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_cancels_outstanding_grants_and_late_submit_fails() {
|
||||
let (_d, store) = fixture();
|
||||
open(&store);
|
||||
let approval = approve(&store, "subject", "approval");
|
||||
request(&store, "other-subject", "pending");
|
||||
store
|
||||
.complete(CompleteMergeRequest {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "op".into(),
|
||||
approval_event_id: approval.event_id,
|
||||
current_subject_ref: "subject".into(),
|
||||
target_ref_before: "before".into(),
|
||||
target_ref_after: "after".into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
now: at(6),
|
||||
})
|
||||
.unwrap();
|
||||
let late = store.submit_review(SubmitMergeRequestReview {
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "other-subject".into(),
|
||||
capability_token: "pending".into(),
|
||||
decision: ReviewDecision::Approve,
|
||||
body: "too late".into(),
|
||||
findings: vec![],
|
||||
now: at(7),
|
||||
});
|
||||
assert!(matches!(late, Err(MergeRequestError::Unauthorized(_))));
|
||||
let mr = store.get("W", "T").unwrap();
|
||||
assert!(mr.thread.iter().any(|event| matches!(event,
|
||||
MergeRequestThreadEvent::ReviewCancelled(value)
|
||||
if value.reason.contains("completed before review submission"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_repair_requires_and_accepts_an_approved_resolved_subject() {
|
||||
let (dir, store) = fixture();
|
||||
open(&store);
|
||||
approve(&store, "approved-subject", "approval");
|
||||
Connection::open(dir.path().join("db")).unwrap()
|
||||
.execute("UPDATE merge_requests SET selector_from=NULL WHERE workspace_id='W' AND merge_request_id='MR'", [])
|
||||
.unwrap();
|
||||
let repaired = store
|
||||
.repair_selector_from(RepairSelectorFrom {
|
||||
workspace_id: "W".into(),
|
||||
ticket_id: "T".into(),
|
||||
selector_from: "restored-work".into(),
|
||||
resolved_subject_ref: "approved-subject".into(),
|
||||
repaired_by: WorkerIdentity {
|
||||
runtime_id: "browser".into(),
|
||||
worker_id: "user".into(),
|
||||
},
|
||||
reason: "confirmed migrated source".into(),
|
||||
now: at(8),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(repaired.selector_from.as_deref(), Some("restored-work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_repair_rejects_unapproved_resolved_subject() {
|
||||
let (dir, store) = fixture();
|
||||
open(&store);
|
||||
approve(&store, "approved-subject", "approval");
|
||||
Connection::open(dir.path().join("db")).unwrap()
|
||||
.execute("UPDATE merge_requests SET selector_from=NULL WHERE workspace_id='W' AND merge_request_id='MR'", [])
|
||||
.unwrap();
|
||||
let result = store.repair_selector_from(RepairSelectorFrom {
|
||||
workspace_id: "W".into(),
|
||||
ticket_id: "T".into(),
|
||||
selector_from: "wrong-work".into(),
|
||||
resolved_subject_ref: "different-subject".into(),
|
||||
repaired_by: WorkerIdentity {
|
||||
runtime_id: "browser".into(),
|
||||
worker_id: "user".into(),
|
||||
},
|
||||
reason: "wrong candidate".into(),
|
||||
now: at(8),
|
||||
});
|
||||
assert!(matches!(result, Err(MergeRequestError::NotReady(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transactional_completion_rejects_assignment_changed_in_control_plane_db() {
|
||||
let (dir, store) = fixture();
|
||||
open(&store);
|
||||
let approval = approve(&store, "subject", "approval");
|
||||
Connection::open(dir.path().join("db")).unwrap()
|
||||
.execute("UPDATE ticket_current_worker_assignments SET assignment_id='B' WHERE workspace_id='W' AND ticket_id='T'", [])
|
||||
.unwrap();
|
||||
let result = store.complete(CompleteMergeRequest {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "op".into(),
|
||||
approval_event_id: approval.event_id,
|
||||
current_subject_ref: "subject".into(),
|
||||
target_ref_before: "before".into(),
|
||||
target_ref_after: "after".into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
now: at(9),
|
||||
});
|
||||
assert!(matches!(result, Err(MergeRequestError::Unauthorized(_))));
|
||||
let state: String = Connection::open(dir.path().join("db"))
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT workflow_state FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(events, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_key_mismatch_and_actor_or_assignment_change_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(),
|
||||
implementation_assignment_id: "A1".into(),
|
||||
completion_actor_runtime_id: "OR".into(),
|
||||
completion_actor_worker_id: "OW".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.completion_actor_worker_id = "other".into();
|
||||
assert!(matches!(
|
||||
store.complete(input.clone()),
|
||||
Err(MergeRequestError::OperationConflict)
|
||||
));
|
||||
input.completion_actor_worker_id = "OW".into();
|
||||
input.implementation_assignment_id = "A2".into();
|
||||
assert!(matches!(
|
||||
store.complete(input.clone()),
|
||||
Err(MergeRequestError::OperationConflict)
|
||||
));
|
||||
input.implementation_assignment_id = "A1".into();
|
||||
input.expected_revision_id = "other".into();
|
||||
assert!(matches!(
|
||||
store.complete(input),
|
||||
Err(MergeRequestError::OperationConflict)
|
||||
));
|
||||
assert_eq!(state, "inprogress");
|
||||
}
|
||||
|
||||
@@ -1537,6 +1537,12 @@ pub trait TicketBackend {
|
||||
id: TicketIdOrSlug,
|
||||
relation: NewTicketRelation,
|
||||
) -> Result<TicketRelation>;
|
||||
fn remove_ticket_relation(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
kind: TicketRelationKind,
|
||||
target: TicketIdOrSlug,
|
||||
) -> Result<TicketRelation>;
|
||||
fn query_ticket_relations(
|
||||
&self,
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
@@ -1616,6 +1622,11 @@ pub enum TicketBackendOperation {
|
||||
id: TicketIdOrSlug,
|
||||
relation: NewTicketRelation,
|
||||
},
|
||||
RemoveTicketRelation {
|
||||
id: TicketIdOrSlug,
|
||||
kind: TicketRelationKind,
|
||||
target: TicketIdOrSlug,
|
||||
},
|
||||
QueryTicketRelations {
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
kind: Option<TicketRelationKind>,
|
||||
@@ -1718,6 +1729,11 @@ where
|
||||
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
||||
TicketBackendOperationResult::Relation(backend.add_ticket_relation(id, relation)?)
|
||||
}
|
||||
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
|
||||
TicketBackendOperationResult::Relation(
|
||||
backend.remove_ticket_relation(id, kind, target)?,
|
||||
)
|
||||
}
|
||||
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
|
||||
TicketBackendOperationResult::Relations(backend.query_ticket_relations(ticket, kind)?)
|
||||
}
|
||||
@@ -3378,6 +3394,58 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_ticket_relation(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
kind: TicketRelationKind,
|
||||
target: TicketIdOrSlug,
|
||||
) -> Result<TicketRelation> {
|
||||
self.with_write(|conn| {
|
||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||
let target = self.resolve_ticket_id(conn, target)?;
|
||||
let relation = conn
|
||||
.query_row(
|
||||
"SELECT note, author, at FROM typed_ticket_relations WHERE workspace_id = ?1 AND ticket_id = ?2 AND kind = ?3 AND target = ?4",
|
||||
params![self.workspace_id, ticket_id, kind.as_str(), target],
|
||||
|row| {
|
||||
Ok(TicketRelation {
|
||||
ticket_id: ticket_id.clone(),
|
||||
kind,
|
||||
target: target.clone(),
|
||||
note: row.get(0)?,
|
||||
author: row.get(1)?,
|
||||
at: row.get(2)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(sqlite_err)?
|
||||
.ok_or_else(|| {
|
||||
TicketError::NotFound(format!(
|
||||
"relation {} {} {}",
|
||||
ticket_id, kind, target
|
||||
))
|
||||
})?;
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM typed_ticket_relations WHERE workspace_id = ?1 AND ticket_id = ?2 AND kind = ?3 AND target = ?4",
|
||||
params![self.workspace_id, ticket_id, kind.as_str(), target],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
if deleted != 1 {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"expected to remove one ticket relation, removed {deleted}"
|
||||
)));
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET updated_at = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2",
|
||||
params![self.workspace_id, ticket_id, now_utc()],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
Ok(relation)
|
||||
})
|
||||
}
|
||||
|
||||
fn query_ticket_relations(
|
||||
&self,
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
@@ -4017,6 +4085,39 @@ impl TicketBackend for LocalTicketBackend {
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn remove_ticket_relation(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
kind: TicketRelationKind,
|
||||
target: TicketIdOrSlug,
|
||||
) -> Result<TicketRelation> {
|
||||
let _lock = self.acquire_lock()?;
|
||||
self.ensure_backend_dirs()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
let item = dir.join("item.md");
|
||||
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
|
||||
let target_id = match target {
|
||||
TicketIdOrSlug::Id(value) => value,
|
||||
other => ticket_id_from_dir(&self.find_ticket_dir(&other)?)?,
|
||||
};
|
||||
let path = self.ticket_relations_path(&dir);
|
||||
let mut relations = read_ticket_relations_artifact(&path, Some(&meta))?;
|
||||
let Some(index) = relations
|
||||
.iter()
|
||||
.position(|relation| relation.kind == kind && relation.target == target_id)
|
||||
else {
|
||||
return Err(TicketError::NotFound(format!(
|
||||
"relation {} {} {}",
|
||||
meta.id, kind, target_id
|
||||
)));
|
||||
};
|
||||
let removed = relations.remove(index);
|
||||
write_ticket_relations_artifact(&path, &relations)?;
|
||||
let at = now_utc();
|
||||
self.set_frontmatter_fields(&item, &[("updated_at", &at)])?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
fn query_ticket_relations(
|
||||
&self,
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
@@ -6103,6 +6204,55 @@ mod tests {
|
||||
assert!(matches!(ambiguous_err, TicketError::Conflict(_)));
|
||||
}
|
||||
|
||||
fn assert_ticket_relation_removal_semantics<B: TicketBackend>(backend: &B) {
|
||||
let source = backend.create(NewTicket::new("source")).unwrap();
|
||||
let target = backend.create(NewTicket::new("target")).unwrap();
|
||||
backend
|
||||
.add_ticket_relation(
|
||||
TicketIdOrSlug::Id(source.id.clone()),
|
||||
NewTicketRelation {
|
||||
kind: TicketRelationKind::DependsOn,
|
||||
target: target.id.clone(),
|
||||
note: Some("obsolete blocker".to_string()),
|
||||
author: Some("tester".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let removed = backend
|
||||
.remove_ticket_relation(
|
||||
TicketIdOrSlug::Id(source.id.clone()),
|
||||
TicketRelationKind::DependsOn,
|
||||
TicketIdOrSlug::Id(target.id.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(removed.ticket_id, source.id);
|
||||
assert_eq!(removed.target, target.id);
|
||||
assert_eq!(removed.note.as_deref(), Some("obsolete blocker"));
|
||||
assert!(
|
||||
backend
|
||||
.relation_view(TicketIdOrSlug::Id(source.id.clone()))
|
||||
.unwrap()
|
||||
.outgoing
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
backend
|
||||
.relation_view(TicketIdOrSlug::Id(target.id.clone()))
|
||||
.unwrap()
|
||||
.incoming
|
||||
.is_empty()
|
||||
);
|
||||
assert!(matches!(
|
||||
backend.remove_ticket_relation(
|
||||
TicketIdOrSlug::Id(source.id),
|
||||
TicketRelationKind::DependsOn,
|
||||
TicketIdOrSlug::Id(target.id),
|
||||
),
|
||||
Err(TicketError::NotFound(_))
|
||||
));
|
||||
}
|
||||
|
||||
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
|
||||
TicketSummary {
|
||||
id: "000TEST".to_string(),
|
||||
@@ -6432,6 +6582,21 @@ state: planning
|
||||
assert_ticket_target_edit_semantics(&backend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_backend_removes_ticket_relations() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
assert_ticket_relation_removal_semantics(&backend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_backend_removes_ticket_relations() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend =
|
||||
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
|
||||
assert_ticket_relation_removal_semantics(&backend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_backend_edit_item_supports_partial_body_replacement() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
+164
-65
@@ -36,8 +36,8 @@ const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
||||
pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"TicketComment",
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
@@ -51,14 +51,15 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
|
||||
];
|
||||
|
||||
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
];
|
||||
|
||||
pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
||||
pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationRemove",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
@@ -67,11 +68,11 @@ 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; 18] = [
|
||||
pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"TicketComment",
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
@@ -83,21 +84,22 @@ pub const TICKET_TOOL_NAMES: [&str; 18] = [
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationRemove",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 12] = [
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketComment",
|
||||
@@ -109,6 +111,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 12] = [
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationRemove",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
];
|
||||
|
||||
@@ -118,13 +121,12 @@ backend assigns the id and writes the local Ticket file layout under the configu
|
||||
const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \
|
||||
This updates the current item title/body and appends an audited item_edit thread event. Intended for \
|
||||
User/Companion authoring surfaces, not Orchestrator implementation control.";
|
||||
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \
|
||||
lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
|
||||
state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
|
||||
routing, closing, planning, or implementation decisions.";
|
||||
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \
|
||||
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
|
||||
artifact metadata.";
|
||||
const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
|
||||
overview. The local backend supports workflow-state selection; Workspace-backed Workers replace this \
|
||||
definition with the richer authoritative text/event/evidence/relation/Objective/time/attention query.";
|
||||
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \
|
||||
Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and artifact \
|
||||
metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection.";
|
||||
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown.";
|
||||
const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown.";
|
||||
const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
|
||||
@@ -146,6 +148,9 @@ a close event.";
|
||||
const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \
|
||||
project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \
|
||||
inverse views are derived, not stored.";
|
||||
const RELATION_REMOVE_DESCRIPTION: &str = "Remove one exact forward typed Ticket relation identified by \
|
||||
source Ticket, relation kind, and target Ticket. Use this to correct obsolete or erroneous project-level \
|
||||
relation metadata; derived inverse views update automatically.";
|
||||
const RELATION_QUERY_DESCRIPTION: &str = "Query durable typed Ticket relation metadata. When a Ticket \
|
||||
is provided, both outgoing records owned by it and incoming forward records that target it are returned.";
|
||||
const ORCHESTRATION_PLAN_RECORD_DESCRIPTION: &str = "Append a typed Ticket orchestration plan record \
|
||||
@@ -163,8 +168,8 @@ fn base_tool_description(name: &str) -> &'static str {
|
||||
match name {
|
||||
"TicketCreate" => CREATE_DESCRIPTION,
|
||||
"TicketEditItem" => EDIT_ITEM_DESCRIPTION,
|
||||
"TicketList" => LIST_DESCRIPTION,
|
||||
"TicketShow" => SHOW_DESCRIPTION,
|
||||
"QueryTicket" => LIST_DESCRIPTION,
|
||||
"ShowTicket" => SHOW_DESCRIPTION,
|
||||
"TicketComment" => COMMENT_DESCRIPTION,
|
||||
"TicketPlan" => PLAN_DESCRIPTION,
|
||||
"TicketDecision" => DECISION_DESCRIPTION,
|
||||
@@ -174,6 +179,7 @@ fn base_tool_description(name: &str) -> &'static str {
|
||||
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||
"TicketClose" => CLOSE_DESCRIPTION,
|
||||
"TicketRelationRecord" => RELATION_RECORD_DESCRIPTION,
|
||||
"TicketRelationRemove" => RELATION_REMOVE_DESCRIPTION,
|
||||
"TicketRelationQuery" => RELATION_QUERY_DESCRIPTION,
|
||||
"TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
|
||||
"TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
|
||||
@@ -324,6 +330,15 @@ impl TicketBackend for TicketToolBackend {
|
||||
self.backend.add_ticket_relation(id, relation)
|
||||
}
|
||||
|
||||
fn remove_ticket_relation(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
kind: TicketRelationKind,
|
||||
target: TicketIdOrSlug,
|
||||
) -> TicketResult<TicketRelation> {
|
||||
self.backend.remove_ticket_relation(id, kind, target)
|
||||
}
|
||||
|
||||
fn query_ticket_relations(
|
||||
&self,
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
@@ -448,7 +463,7 @@ impl TicketWorkflowStateParam {
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TicketListStateParam {
|
||||
enum QueryTicketStateParam {
|
||||
Active,
|
||||
Planning,
|
||||
Ready,
|
||||
@@ -459,7 +474,7 @@ enum TicketListStateParam {
|
||||
All,
|
||||
}
|
||||
|
||||
impl TicketListStateParam {
|
||||
impl QueryTicketStateParam {
|
||||
fn as_list_state(self) -> Option<TicketListState> {
|
||||
match self {
|
||||
Self::Planning => Some(TicketListState::Planning),
|
||||
@@ -474,10 +489,10 @@ impl TicketListStateParam {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketListParams {
|
||||
struct QueryTicketParams {
|
||||
/// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets.
|
||||
#[serde(default)]
|
||||
state: Option<TicketListStateParam>,
|
||||
state: Option<QueryTicketStateParam>,
|
||||
/// Explicit workflow-state filter list. Cannot be combined with `state`.
|
||||
#[serde(default)]
|
||||
states: Option<Vec<TicketWorkflowStateParam>>,
|
||||
@@ -486,28 +501,28 @@ struct TicketListParams {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl TicketListParams {
|
||||
impl QueryTicketParams {
|
||||
fn into_query(self) -> Result<(crate::TicketListQuery, String, Option<usize>), TicketError> {
|
||||
let query = if let Some(states) = self.states {
|
||||
if self.state.is_some() {
|
||||
return Err(TicketError::Conflict(
|
||||
"TicketList accepts either `state` or `states`, not both".to_string(),
|
||||
"QueryTicket accepts either `state` or `states`, not both".to_string(),
|
||||
));
|
||||
}
|
||||
if states.is_empty() {
|
||||
return Err(TicketError::Conflict(
|
||||
"TicketList `states` must include at least one workflow state".to_string(),
|
||||
"QueryTicket `states` must include at least one workflow state".to_string(),
|
||||
));
|
||||
}
|
||||
crate::TicketListQuery::states(states.into_iter().map(|state| state.into_list_state()))
|
||||
} else {
|
||||
match self.state.unwrap_or(TicketListStateParam::Active) {
|
||||
TicketListStateParam::Active => crate::TicketListQuery::active(),
|
||||
TicketListStateParam::All => crate::TicketListQuery::all(),
|
||||
match self.state.unwrap_or(QueryTicketStateParam::Active) {
|
||||
QueryTicketStateParam::Active => crate::TicketListQuery::active(),
|
||||
QueryTicketStateParam::All => crate::TicketListQuery::all(),
|
||||
state => crate::TicketListQuery::state(
|
||||
state
|
||||
.as_list_state()
|
||||
.expect("workflow state list param maps to TicketListState"),
|
||||
.expect("workflow state list param maps to QueryTicketState"),
|
||||
),
|
||||
}
|
||||
};
|
||||
@@ -517,7 +532,7 @@ impl TicketListParams {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketShowParams {
|
||||
struct ShowTicketParams {
|
||||
/// Ticket id. Exactly one of `id` or `query` must be provided.
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
@@ -626,6 +641,16 @@ struct TicketRelationRecordParams {
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketRelationRemoveParams {
|
||||
/// Ticket id that owns the forward relation.
|
||||
ticket: String,
|
||||
/// Forward relation kind to remove.
|
||||
kind: TicketRelationKindParam,
|
||||
/// Target canonical Ticket id.
|
||||
target: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketRelationQueryParams {
|
||||
/// Optional Ticket id to query. Includes outgoing and incoming forward records for that id.
|
||||
@@ -742,17 +767,17 @@ struct TicketRefOutput {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TicketListOutput {
|
||||
struct QueryTicketOutput {
|
||||
state_filter: String,
|
||||
count: usize,
|
||||
returned: usize,
|
||||
truncated: bool,
|
||||
limit: usize,
|
||||
tickets: Vec<TicketListTicketOutput>,
|
||||
tickets: Vec<QueryTicketTicketOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TicketListTicketOutput {
|
||||
struct QueryTicketTicketOutput {
|
||||
id: String,
|
||||
title: String,
|
||||
state: String,
|
||||
@@ -782,12 +807,12 @@ struct TicketEditItemTool {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketListTool {
|
||||
struct QueryTicketTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketShowTool {
|
||||
struct ShowTicketTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
@@ -836,6 +861,11 @@ struct TicketRelationRecordTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketRelationRemoveTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketRelationQueryTool {
|
||||
backend: TicketToolBackend,
|
||||
@@ -945,28 +975,28 @@ impl Tool for TicketEditItemTool {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketListTool {
|
||||
impl Tool for QueryTicketTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketListParams = parse_input("TicketList", input_json)?;
|
||||
let params: QueryTicketParams = parse_input("QueryTicket", input_json)?;
|
||||
let (filter, state_filter, params_limit) = params
|
||||
.into_query()
|
||||
.map_err(|error| backend_error("TicketList", error))?;
|
||||
.map_err(|error| backend_error("QueryTicket", error))?;
|
||||
let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
||||
let tickets = self
|
||||
.backend
|
||||
.list(filter)
|
||||
.map_err(|error| backend_error("TicketList", error))?;
|
||||
.map_err(|error| backend_error("QueryTicket", error))?;
|
||||
let count = tickets.len();
|
||||
let returned_tickets: Vec<_> = tickets
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(ticket_summary_json)
|
||||
.collect();
|
||||
let output = TicketListOutput {
|
||||
let output = QueryTicketOutput {
|
||||
state_filter: state_filter.to_string(),
|
||||
count,
|
||||
returned: returned_tickets.len(),
|
||||
@@ -986,13 +1016,13 @@ impl Tool for TicketListTool {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketShowTool {
|
||||
impl Tool for ShowTicketTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketShowParams = parse_input("TicketShow", input_json)?;
|
||||
let params: ShowTicketParams = parse_input("ShowTicket", input_json)?;
|
||||
let query = id_or_query(params.id, params.query)?;
|
||||
let event_limit = bounded(params.event_limit, DEFAULT_EVENT_LIMIT, MAX_EVENT_LIMIT);
|
||||
let artifact_limit = bounded(
|
||||
@@ -1008,7 +1038,7 @@ impl Tool for TicketShowTool {
|
||||
let ticket = self
|
||||
.backend
|
||||
.show(query)
|
||||
.map_err(|error| backend_error("TicketShow", error))?;
|
||||
.map_err(|error| backend_error("ShowTicket", error))?;
|
||||
let summary = format!(
|
||||
"Ticket {} state {}",
|
||||
ticket.meta.id,
|
||||
@@ -1230,6 +1260,32 @@ impl Tool for TicketRelationRecordTool {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketRelationRemoveTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketRelationRemoveParams = parse_input("TicketRelationRemove", input_json)?;
|
||||
let output = self
|
||||
.backend
|
||||
.remove_ticket_relation(
|
||||
TicketIdOrSlug::Id(params.ticket),
|
||||
params.kind.into_kind(),
|
||||
TicketIdOrSlug::Id(params.target),
|
||||
)
|
||||
.map_err(|error| backend_error("TicketRelationRemove", error))?;
|
||||
Ok(json_output(
|
||||
format!(
|
||||
"Removed ticket relation {} {} {}",
|
||||
output.ticket_id, output.kind, output.target
|
||||
),
|
||||
ticket_relation_json(&output),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketRelationQueryTool {
|
||||
async fn execute(
|
||||
@@ -1427,9 +1483,9 @@ fn id_or_query(id: Option<String>, query: Option<String>) -> Result<TicketIdOrSl
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_summary_json(ticket: TicketSummary) -> TicketListTicketOutput {
|
||||
fn ticket_summary_json(ticket: TicketSummary) -> QueryTicketTicketOutput {
|
||||
let hints = ticket_list_hints(&ticket);
|
||||
TicketListTicketOutput {
|
||||
QueryTicketTicketOutput {
|
||||
id: ticket.id,
|
||||
title: truncate_inline(ticket.title.as_str(), LIST_TITLE_MAX_CHARS),
|
||||
state: ticket.workflow_state.as_str().to_string(),
|
||||
@@ -1665,8 +1721,8 @@ fn input_schema(name: &str) -> Value {
|
||||
match name {
|
||||
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
|
||||
"TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)),
|
||||
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
|
||||
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
|
||||
"QueryTicket" => serde_json::to_value(schemars::schema_for!(QueryTicketParams)),
|
||||
"ShowTicket" => serde_json::to_value(schemars::schema_for!(ShowTicketParams)),
|
||||
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
|
||||
}
|
||||
@@ -1682,6 +1738,9 @@ fn input_schema(name: &str) -> Value {
|
||||
"TicketRelationRecord" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
|
||||
}
|
||||
"TicketRelationRemove" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketRelationRemoveParams))
|
||||
}
|
||||
"TicketRelationQuery" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketRelationQueryParams))
|
||||
}
|
||||
@@ -1709,8 +1768,8 @@ macro_rules! impl_from_backend {
|
||||
|
||||
impl_from_backend!(TicketCreateTool);
|
||||
impl_from_backend!(TicketEditItemTool);
|
||||
impl_from_backend!(TicketListTool);
|
||||
impl_from_backend!(TicketShowTool);
|
||||
impl_from_backend!(QueryTicketTool);
|
||||
impl_from_backend!(ShowTicketTool);
|
||||
impl_from_backend!(TicketCommentTool);
|
||||
impl_from_backend!(TicketPlanTool);
|
||||
impl_from_backend!(TicketDecisionTool);
|
||||
@@ -1720,6 +1779,7 @@ impl_from_backend!(TicketQueueTool);
|
||||
impl_from_backend!(TicketWorkflowStateTool);
|
||||
impl_from_backend!(TicketCloseTool);
|
||||
impl_from_backend!(TicketRelationRecordTool);
|
||||
impl_from_backend!(TicketRelationRemoveTool);
|
||||
impl_from_backend!(TicketRelationQueryTool);
|
||||
impl_from_backend!(TicketOrchestrationPlanRecordTool);
|
||||
impl_from_backend!(TicketOrchestrationPlanQueryTool);
|
||||
@@ -1732,8 +1792,8 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
||||
vec![
|
||||
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
|
||||
tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()),
|
||||
tool_definition::<TicketListTool>("TicketList", backend.clone()),
|
||||
tool_definition::<TicketShowTool>("TicketShow", backend.clone()),
|
||||
tool_definition::<QueryTicketTool>("QueryTicket", backend.clone()),
|
||||
tool_definition::<ShowTicketTool>("ShowTicket", backend.clone()),
|
||||
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
|
||||
tool_definition::<TicketPlanTool>("TicketPlan", backend.clone()),
|
||||
tool_definition::<TicketDecisionTool>("TicketDecision", backend.clone()),
|
||||
@@ -1748,6 +1808,7 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
||||
tool_definition::<TicketDependencyCheckTool>("TicketDependencyCheck", backend.clone()),
|
||||
tool_definition::<TicketDoctorTool>("TicketDoctor", backend.clone()),
|
||||
tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()),
|
||||
tool_definition::<TicketRelationRemoveTool>("TicketRelationRemove", backend.clone()),
|
||||
tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()),
|
||||
tool_definition::<TicketOrchestrationPlanRecordTool>(
|
||||
"TicketOrchestrationPlanRecord",
|
||||
@@ -1799,8 +1860,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
TICKET_READ_ONLY_TOOL_NAMES,
|
||||
[
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationQuery",
|
||||
@@ -1821,6 +1882,7 @@ mod tests {
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationRemove",
|
||||
"TicketOrchestrationPlanRecord"
|
||||
]
|
||||
);
|
||||
@@ -1878,8 +1940,8 @@ mod tests {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let create = tool_by_name(backend.clone(), "TicketCreate");
|
||||
let list = tool_by_name(backend.clone(), "TicketList");
|
||||
let show = tool_by_name(backend.clone(), "TicketShow");
|
||||
let list = tool_by_name(backend.clone(), "QueryTicket");
|
||||
let show = tool_by_name(backend.clone(), "ShowTicket");
|
||||
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
|
||||
|
||||
let created = create
|
||||
@@ -1941,7 +2003,7 @@ mod tests {
|
||||
async fn ticket_list_tool_truncates_long_titles_and_hints() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let list = tool_by_name(backend.clone(), "TicketList");
|
||||
let list = tool_by_name(backend.clone(), "QueryTicket");
|
||||
let mut ticket = NewTicket::new(format!(
|
||||
"Long Title {}",
|
||||
"x".repeat(LIST_TITLE_MAX_CHARS + 40)
|
||||
@@ -1969,7 +2031,7 @@ mod tests {
|
||||
async fn ticket_list_tool_default_and_max_limits_are_bounded() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let list = tool_by_name(backend.clone(), "TicketList");
|
||||
let list = tool_by_name(backend.clone(), "QueryTicket");
|
||||
for index in 0..(MAX_LIST_LIMIT + 5) {
|
||||
backend
|
||||
.create(NewTicket::new(format!("Ticket {index:03}")))
|
||||
@@ -2020,7 +2082,7 @@ mod tests {
|
||||
async fn ticket_list_tool_caps_all_and_closed_default_listing() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let list = tool_by_name(backend.clone(), "TicketList");
|
||||
let list = tool_by_name(backend.clone(), "QueryTicket");
|
||||
for index in 0..(DEFAULT_LIST_LIMIT + 3) {
|
||||
let mut ticket = NewTicket::new(format!("Closed Ticket {index:03}"));
|
||||
ticket.workflow_state = Some(TicketWorkflowState::Closed);
|
||||
@@ -2078,7 +2140,7 @@ mod tests {
|
||||
async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let list = tool_by_name(backend.clone(), "TicketList");
|
||||
let list = tool_by_name(backend.clone(), "QueryTicket");
|
||||
let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
|
||||
let mut ready_input = NewTicket::new("Ready Ticket");
|
||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
@@ -2125,7 +2187,7 @@ mod tests {
|
||||
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let list = tool_by_name(backend.clone(), "TicketList");
|
||||
let list = tool_by_name(backend.clone(), "QueryTicket");
|
||||
let close = tool_by_name(backend.clone(), "TicketClose");
|
||||
let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST";
|
||||
let thread_secret = "THREAD_SECRET_DO_NOT_LIST";
|
||||
@@ -2254,14 +2316,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_relation_tools_record_query_and_show_derived_view() {
|
||||
async fn ticket_relation_tools_record_query_remove_and_show_derived_view() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let source = backend.create(NewTicket::new("Relation Source")).unwrap();
|
||||
let target = backend.create(NewTicket::new("Relation Target")).unwrap();
|
||||
let record = tool_by_name(backend.clone(), "TicketRelationRecord");
|
||||
let remove = tool_by_name(backend.clone(), "TicketRelationRemove");
|
||||
let query = tool_by_name(backend.clone(), "TicketRelationQuery");
|
||||
let show = tool_by_name(backend.clone(), "TicketShow");
|
||||
let show = tool_by_name(backend.clone(), "ShowTicket");
|
||||
|
||||
let recorded = record
|
||||
.execute(
|
||||
@@ -2305,6 +2368,42 @@ mod tests {
|
||||
shown_json["relations"]["incoming"][0]["inverse_kind"],
|
||||
"dependency_of"
|
||||
);
|
||||
|
||||
let removed = remove
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": source.id.clone(),
|
||||
"kind": "depends_on",
|
||||
"target": target.id.clone()
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(removed.summary.contains("Removed ticket relation"));
|
||||
let removed_json: Value = serde_json::from_str(&removed.content.unwrap()).unwrap();
|
||||
assert_eq!(removed_json["kind"], "depends_on");
|
||||
assert_eq!(removed_json["target"], target.id);
|
||||
|
||||
let queried_after_remove = query
|
||||
.execute(
|
||||
&json!({ "ticket": source.id }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let queried_after_remove_json: Value =
|
||||
serde_json::from_str(&queried_after_remove.content.unwrap()).unwrap();
|
||||
assert_eq!(queried_after_remove_json["count"], 0);
|
||||
|
||||
let shown_after_remove = show
|
||||
.execute(&json!({ "id": target.id }).to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let shown_after_remove_json: Value =
|
||||
serde_json::from_str(&shown_after_remove.content.unwrap()).unwrap();
|
||||
assert_eq!(shown_after_remove_json["relations"]["incoming"], json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2685,7 +2784,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn ticket_show_requires_exactly_one_identifier() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let show = tool_by_name(backend(&temp), "TicketShow");
|
||||
let show = tool_by_name(backend(&temp), "ShowTicket");
|
||||
let error = show
|
||||
.execute(
|
||||
&json!({ "id": "a", "query": "b" }).to_string(),
|
||||
|
||||
@@ -41,7 +41,8 @@ impl From<ToolsError> for ToolError {
|
||||
ToolsError::WorkdirSession(
|
||||
workdir::WorkdirError::NotFound(_)
|
||||
| workdir::WorkdirError::Io { .. }
|
||||
| workdir::WorkdirError::Unavailable(_),
|
||||
| workdir::WorkdirError::Unavailable(_)
|
||||
| workdir::WorkdirError::Transport(_),
|
||||
) => ToolError::ExecutionFailed(err.to_string()),
|
||||
ToolsError::FileSystem(_)
|
||||
| ToolsError::WorkdirSession(_)
|
||||
|
||||
@@ -2645,7 +2645,14 @@ mod composer_history_persistence_tests {
|
||||
let mut app = App::new_with_input_history_store("test".into(), store);
|
||||
submit_text(&mut app, "synthetic entry outside workspace yoi");
|
||||
|
||||
assert!(data_dir.path().join("composer-history").exists());
|
||||
assert!(
|
||||
data_dir
|
||||
.path()
|
||||
.join("client")
|
||||
.join("composer-history")
|
||||
.exists()
|
||||
);
|
||||
assert!(!data_dir.path().join("composer-history").exists());
|
||||
assert!(!workspace.path().join(".yoi").exists());
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ impl ComposerHistoryStore {
|
||||
let workspace = workspace_identity(workspace_root);
|
||||
let path = data_dir
|
||||
.as_ref()
|
||||
.join("client")
|
||||
.join("composer-history")
|
||||
.join("workspaces")
|
||||
.join(format!("{}-{}", workspace.label, workspace.key))
|
||||
@@ -186,7 +187,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn store_path_is_workspace_scoped_under_data_dir() {
|
||||
fn store_path_is_workspace_scoped_under_client_data_dir() {
|
||||
let data_dir = TempDir::new().unwrap();
|
||||
let store = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi"));
|
||||
let other = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/other"));
|
||||
@@ -196,11 +197,43 @@ mod tests {
|
||||
store
|
||||
.path()
|
||||
.to_string_lossy()
|
||||
.contains("composer-history/workspaces/yoi-")
|
||||
.contains("client/composer-history/workspaces/yoi-")
|
||||
);
|
||||
assert_ne!(store.path(), other.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_top_level_history_is_ignored_without_migration_or_fallback() {
|
||||
let data_dir = TempDir::new().unwrap();
|
||||
let workspace_root = Path::new("/repo/yoi");
|
||||
let workspace = workspace_identity(workspace_root);
|
||||
let legacy_path = data_dir
|
||||
.path()
|
||||
.join("composer-history")
|
||||
.join("workspaces")
|
||||
.join(format!("{}-{}", workspace.label, workspace.key))
|
||||
.join("history.json");
|
||||
let legacy_file = ComposerHistoryFile {
|
||||
version: COMPOSER_HISTORY_VERSION,
|
||||
workspace,
|
||||
entries: vec![vec![Segment::text("legacy entry")]],
|
||||
};
|
||||
let legacy_bytes = serde_json::to_vec_pretty(&legacy_file).unwrap();
|
||||
fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
|
||||
fs::write(&legacy_path, &legacy_bytes).unwrap();
|
||||
|
||||
let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace_root);
|
||||
assert!(store.load().unwrap().is_empty());
|
||||
assert!(!store.path().exists());
|
||||
assert_eq!(fs::read(&legacy_path).unwrap(), legacy_bytes);
|
||||
|
||||
let current_entries = VecDeque::from([vec![Segment::text("current entry")]]);
|
||||
store.save(¤t_entries).unwrap();
|
||||
|
||||
assert_eq!(store.load().unwrap(), current_entries);
|
||||
assert_eq!(fs::read(&legacy_path).unwrap(), legacy_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_records_workspace_identity_metadata_and_typed_segments() {
|
||||
let data_dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -2045,7 +2045,7 @@ impl DashboardApp {
|
||||
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
|
||||
context.ticket = Some(TicketRef::id(ticket_id.clone()));
|
||||
context.user_instruction = Some(format!(
|
||||
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read TicketShow body/thread/artifacts before making routing or requirements decisions."
|
||||
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
|
||||
));
|
||||
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
|
||||
Ok(store) => store,
|
||||
@@ -3925,7 +3925,7 @@ fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str)
|
||||
|
||||
fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String {
|
||||
format!(
|
||||
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read TicketShow body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
|
||||
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ impl WorkdirTransportError {
|
||||
(Code::UnknownCommand, "Workdir command was not found")
|
||||
}
|
||||
WorkdirError::Unavailable(_) => (Code::Unavailable, "Workdir session is unavailable"),
|
||||
WorkdirError::Transport(_) => (Code::Internal, "Workdir transport failed"),
|
||||
WorkdirError::InvalidPath(_)
|
||||
| WorkdirError::RelativePath(_)
|
||||
| WorkdirError::InvalidGlob(_)
|
||||
@@ -151,7 +152,8 @@ impl WorkdirTransportError {
|
||||
Code::Unsupported => WorkdirError::Unavailable(self.message),
|
||||
Code::UnknownCommand => WorkdirError::UnknownCommand("<remote>".to_string()),
|
||||
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
|
||||
Code::Unavailable | Code::Internal => WorkdirError::Unavailable(self.message),
|
||||
Code::Unavailable => WorkdirError::Unavailable(self.message),
|
||||
Code::Internal => WorkdirError::Transport(self.message),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,6 +487,19 @@ pub use client::{ClientSession as RemoteWorkdirSession, WorkdirHttpAuthorization
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn transport_failure_remains_distinct_from_session_unavailable() {
|
||||
let transport = WorkdirTransportError::from_workdir_error(&WorkdirError::Transport(
|
||||
"Workspace API request timed out".to_string(),
|
||||
));
|
||||
assert_eq!(transport.code, WorkdirTransportErrorCode::Internal);
|
||||
assert_eq!(transport.message, "Workdir transport failed");
|
||||
assert!(matches!(
|
||||
transport.into_workdir_error(),
|
||||
WorkdirError::Transport(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_error_does_not_expose_host_path() {
|
||||
let error = WorkdirError::Io {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod http;
|
||||
mod local;
|
||||
mod operation;
|
||||
pub mod workspace;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -269,6 +270,9 @@ pub enum WorkdirError {
|
||||
#[error("Workdir session is unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
|
||||
#[error("Workdir transport failed: {0}")]
|
||||
Transport(String),
|
||||
|
||||
#[error("Workdir content was modified externally before the operation could be applied: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Shared Workspace-facing Workdir inventory transport contracts.
|
||||
//!
|
||||
//! These projections describe Workspace inventory and durable occupancy. They
|
||||
//! intentionally do not expose provider/session handles, host paths, Runtime
|
||||
//! URLs, or credentials. Runtime-local Workdir operation contracts live in
|
||||
//! [`crate::http`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Stable Workspace identity for a Worker hosted by a Runtime.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeWorkerRef {
|
||||
pub runtime_id: String,
|
||||
/// Runtime-owned opaque Worker id. Consumers must not assume a numeric id.
|
||||
pub worker_id: String,
|
||||
}
|
||||
|
||||
impl RuntimeWorkerRef {
|
||||
pub fn new(runtime_id: impl Into<String>, worker_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
runtime_id: runtime_id.into(),
|
||||
worker_id: worker_id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MaterializerKind {
|
||||
#[default]
|
||||
LocalGitWorktree,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkingDirectoryStatusKind {
|
||||
Active,
|
||||
CleanupPending,
|
||||
Corrupted,
|
||||
NotFound,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl WorkingDirectoryStatusKind {
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::CleanupPending => "cleanup_pending",
|
||||
Self::Corrupted => "corrupted",
|
||||
Self::NotFound => "not_found",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for WorkingDirectoryStatusKind {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryCleanupTarget {
|
||||
pub kind: String,
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
}
|
||||
|
||||
/// Durable Workspace occupancy projection for one Workdir.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct WorkingDirectoryOccupancy {
|
||||
#[serde(flatten)]
|
||||
pub worker: RuntimeWorkerRef,
|
||||
pub display_name: String,
|
||||
pub linked_at: String,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for WorkingDirectoryOccupancy {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Wire {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
display_name: String,
|
||||
linked_at: String,
|
||||
}
|
||||
|
||||
let wire = Wire::deserialize(deserializer)?;
|
||||
Ok(Self {
|
||||
worker: RuntimeWorkerRef::new(wire.runtime_id, wire.worker_id),
|
||||
display_name: wire.display_name,
|
||||
linked_at: wire.linked_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable materialization provenance retained by Workspace inventory.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryProvenance {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||
}
|
||||
|
||||
/// Latest provider-neutral observation attached to Workspace inventory.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryCurrentObservation {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
pub status: WorkingDirectoryStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanliness: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_worker_id: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectorySummary {
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||
pub status: WorkingDirectoryStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanliness: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_worker_id: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
impl WorkingDirectorySummary {
|
||||
/// Workspace-managed inventory rows carry explicit cleanup authority.
|
||||
pub fn is_workspace_managed(&self) -> bool {
|
||||
self.cleanup_target.is_some()
|
||||
}
|
||||
|
||||
pub fn provenance(&self) -> WorkingDirectoryProvenance {
|
||||
WorkingDirectoryProvenance {
|
||||
creation_selector: self.creation_selector.clone(),
|
||||
creation_ref: self.creation_ref.clone(),
|
||||
materializer_kind: self.materializer_kind.clone(),
|
||||
cleanup_target: self.cleanup_target.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_observation(&self) -> WorkingDirectoryCurrentObservation {
|
||||
WorkingDirectoryCurrentObservation {
|
||||
current_selector: self.current_selector.clone(),
|
||||
current_ref: self.current_ref.clone(),
|
||||
status: self.status.clone(),
|
||||
cleanliness: self.cleanliness.clone(),
|
||||
primary_worker_id: self.primary_worker_id,
|
||||
occupied_by: self.occupied_by.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkingDirectoryDiagnosticSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryDiagnostic {
|
||||
pub code: String,
|
||||
pub severity: WorkingDirectoryDiagnosticSeverity,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryListResponse {
|
||||
pub workspace_id: String,
|
||||
pub items: Vec<WorkingDirectorySummary>,
|
||||
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryDetailResponse {
|
||||
pub workspace_id: String,
|
||||
pub item: WorkingDirectorySummary,
|
||||
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn working_directory_status_display_matches_wire_values() {
|
||||
for (status, expected) in [
|
||||
(WorkingDirectoryStatusKind::Active, "active"),
|
||||
(
|
||||
WorkingDirectoryStatusKind::CleanupPending,
|
||||
"cleanup_pending",
|
||||
),
|
||||
(WorkingDirectoryStatusKind::Corrupted, "corrupted"),
|
||||
(WorkingDirectoryStatusKind::NotFound, "not_found"),
|
||||
(WorkingDirectoryStatusKind::Unknown, "unknown"),
|
||||
] {
|
||||
assert_eq!(status.to_string(), expected);
|
||||
assert_eq!(serde_json::to_value(status).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupied_and_free_list_response_round_trips() {
|
||||
let response = WorkingDirectoryListResponse {
|
||||
workspace_id: "workspace".to_string(),
|
||||
items: vec![
|
||||
WorkingDirectorySummary {
|
||||
working_directory_id: "occupied".to_string(),
|
||||
repository_id: "repo".to_string(),
|
||||
creation_selector: Some("develop".to_string()),
|
||||
creation_ref: Some("abc123".to_string()),
|
||||
current_selector: Some("work/ticket".to_string()),
|
||||
current_ref: Some("def456".to_string()),
|
||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||
cleanup_target: Some(WorkingDirectoryCleanupTarget {
|
||||
kind: "git_worktree".to_string(),
|
||||
working_directory_id: "occupied".to_string(),
|
||||
repository_id: "repo".to_string(),
|
||||
}),
|
||||
status: WorkingDirectoryStatusKind::Active,
|
||||
cleanliness: Some("clean".to_string()),
|
||||
primary_worker_id: None,
|
||||
occupied_by: Some(WorkingDirectoryOccupancy {
|
||||
worker: RuntimeWorkerRef::new("arcadia", "worker-opaque-64"),
|
||||
display_name: "Coder".to_string(),
|
||||
linked_at: "2026-08-12T00:00:00Z".to_string(),
|
||||
}),
|
||||
},
|
||||
WorkingDirectorySummary {
|
||||
working_directory_id: "free".to_string(),
|
||||
repository_id: "repo".to_string(),
|
||||
creation_selector: None,
|
||||
creation_ref: None,
|
||||
current_selector: None,
|
||||
current_ref: Some("987fed".to_string()),
|
||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||
cleanup_target: None,
|
||||
status: WorkingDirectoryStatusKind::Active,
|
||||
cleanliness: Some("unknown".to_string()),
|
||||
primary_worker_id: None,
|
||||
occupied_by: None,
|
||||
},
|
||||
],
|
||||
diagnostics: vec![WorkingDirectoryDiagnostic {
|
||||
code: "observed".to_string(),
|
||||
severity: WorkingDirectoryDiagnosticSeverity::Info,
|
||||
message: "inventory observed".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let encoded = serde_json::to_value(&response).unwrap();
|
||||
assert_eq!(
|
||||
encoded["items"][0]["occupied_by"]["worker_id"],
|
||||
"worker-opaque-64"
|
||||
);
|
||||
assert!(
|
||||
encoded["items"][0]["occupied_by"]
|
||||
.get("runtime_worker_id")
|
||||
.is_none()
|
||||
);
|
||||
assert!(encoded["items"][1].get("occupied_by").is_none());
|
||||
|
||||
let mut stale = encoded.clone();
|
||||
stale["items"][0]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
|
||||
assert!(serde_json::from_value::<WorkingDirectoryListResponse>(stale).is_err());
|
||||
|
||||
let decoded: WorkingDirectoryListResponse = serde_json::from_value(encoded).unwrap();
|
||||
assert_eq!(decoded, response);
|
||||
|
||||
let detail = WorkingDirectoryDetailResponse {
|
||||
workspace_id: decoded.workspace_id.clone(),
|
||||
item: decoded.items[0].clone(),
|
||||
diagnostics: decoded.diagnostics.clone(),
|
||||
};
|
||||
let encoded = serde_json::to_value(&detail).unwrap();
|
||||
let decoded: WorkingDirectoryDetailResponse = serde_json::from_value(encoded).unwrap();
|
||||
assert_eq!(decoded, detail);
|
||||
}
|
||||
}
|
||||
@@ -92,12 +92,11 @@ pub struct WorkingDirectoryRepository {
|
||||
pub selector: Option<RepositorySelector>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MaterializerKind {
|
||||
#[default]
|
||||
LocalGitWorktree,
|
||||
}
|
||||
pub use workdir::workspace::{
|
||||
MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryCurrentObservation,
|
||||
WorkingDirectoryOccupancy, WorkingDirectoryProvenance, WorkingDirectoryStatusKind,
|
||||
WorkingDirectorySummary,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkingDirectoryRequest {
|
||||
@@ -117,59 +116,6 @@ pub struct WorkingDirectoryClaim {
|
||||
pub relative_cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkingDirectoryStatusKind {
|
||||
Active,
|
||||
CleanupPending,
|
||||
Corrupted,
|
||||
NotFound,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkingDirectoryCleanupTarget {
|
||||
pub kind: String,
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkingDirectoryOccupancy {
|
||||
#[serde(flatten)]
|
||||
pub worker: RuntimeWorkerRef,
|
||||
pub display_name: String,
|
||||
pub linked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkingDirectorySummary {
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
/// Selector used to create this Workdir, retained as immutable materialization evidence.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_selector: Option<String>,
|
||||
/// Provider-specific immutable ref resolved when this Workdir was created.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
/// Selector currently observed from the materialized Workdir, when one exists.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_selector: Option<String>,
|
||||
/// Provider-specific immutable ref currently observed from the materialized Workdir.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||
pub status: WorkingDirectoryStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanliness: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_worker_id: Option<WorkerId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkingDirectoryStatus {
|
||||
pub summary: WorkingDirectorySummary,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
pub use workdir::workspace::RuntimeWorkerRef;
|
||||
|
||||
/// Runtime-local Worker identity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
@@ -30,29 +31,16 @@ impl fmt::Display for WorkerId {
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-visible Worker identity, namespaced by the Runtime that owns the Worker record.
|
||||
///
|
||||
/// This is intentionally distinct from [`WorkerRef`], which is meaningful only inside one
|
||||
/// Runtime. Do not flatten this reference into a concatenated string for authority decisions.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct RuntimeWorkerRef {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
}
|
||||
/// Convert an opaque Workspace Worker reference only at the Runtime-local boundary.
|
||||
impl TryFrom<&RuntimeWorkerRef> for WorkerRef {
|
||||
type Error = std::num::ParseIntError;
|
||||
|
||||
impl RuntimeWorkerRef {
|
||||
pub fn new(runtime_id: impl Into<String>, worker_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
runtime_id: runtime_id.into(),
|
||||
worker_id: worker_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_worker_ref(&self) -> Result<WorkerRef, std::num::ParseIntError> {
|
||||
self.worker_id
|
||||
fn try_from(value: &RuntimeWorkerRef) -> Result<Self, Self::Error> {
|
||||
value
|
||||
.worker_id
|
||||
.parse::<u64>()
|
||||
.map(WorkerId::new)
|
||||
.map(WorkerRef::new)
|
||||
.map(Self::new)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +66,7 @@ mod tests {
|
||||
assert_eq!(worker.runtime_id, "arcadia");
|
||||
assert_eq!(worker.worker_id, "30");
|
||||
assert_eq!(
|
||||
worker.local_worker_ref().unwrap(),
|
||||
WorkerRef::try_from(&worker).unwrap(),
|
||||
WorkerRef::new(WorkerId::new(30))
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -90,6 +78,6 @@ mod tests {
|
||||
#[test]
|
||||
fn runtime_worker_ref_does_not_treat_composite_text_as_local_worker_id() {
|
||||
let worker = RuntimeWorkerRef::new("arcadia", "embedded-worker-runtime-5");
|
||||
assert!(worker.local_worker_ref().is_err());
|
||||
assert!(WorkerRef::try_from(&worker).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,8 +467,9 @@ impl Runtime {
|
||||
mut status: CatalogWorkingDirectoryStatus,
|
||||
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
|
||||
let state = self.lock()?;
|
||||
status.summary.primary_worker_id =
|
||||
state.primary_worker_id_for_workdir(status.summary.working_directory_id.as_str());
|
||||
status.summary.primary_worker_id = state
|
||||
.primary_worker_id_for_workdir(status.summary.working_directory_id.as_str())
|
||||
.map(|worker_id| worker_id.as_u64());
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider {
|
||||
if grant.runtime_id != self.runtime_id {
|
||||
continue;
|
||||
}
|
||||
let Ok(worker_ref) = grant.local_worker_ref() else {
|
||||
let Ok(worker_ref) = WorkerRef::try_from(grant) else {
|
||||
continue;
|
||||
};
|
||||
let Some((workspace_id, state, _)) = self.hub.get(&worker_ref) else {
|
||||
@@ -192,9 +192,8 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider {
|
||||
if !self.grants.contains(&grant) || runtime_id != &self.runtime_id {
|
||||
return Err(WorkerObservationError::NotFound);
|
||||
}
|
||||
let worker_ref = grant
|
||||
.local_worker_ref()
|
||||
.map_err(|_| WorkerObservationError::NotFound)?;
|
||||
let worker_ref =
|
||||
WorkerRef::try_from(&grant).map_err(|_| WorkerObservationError::NotFound)?;
|
||||
let (workspace_id, _, sink) = self
|
||||
.hub
|
||||
.get(&worker_ref)
|
||||
@@ -1161,7 +1160,10 @@ where
|
||||
match event {
|
||||
Ok(event) => {
|
||||
let _ = bridge_context.publish_protocol_event(event);
|
||||
if bridge_handle.shared_state.get_status() == WorkerStatus::Idle {
|
||||
if matches!(
|
||||
bridge_handle.shared_state.get_status(),
|
||||
WorkerStatus::Idle | WorkerStatus::Paused
|
||||
) {
|
||||
bridge_busy.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
@@ -1241,6 +1243,13 @@ fn method_starts_turn(method: &Method) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn method_can_start_turn_from_status(method: &Method, status: WorkerStatus) -> bool {
|
||||
match method {
|
||||
Method::Resume => matches!(status, WorkerStatus::Idle | WorkerStatus::Paused),
|
||||
_ => status == WorkerStatus::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
fn accepted_notify_run_state(status: WorkerStatus, auto_run: bool) -> WorkerExecutionRunState {
|
||||
match status {
|
||||
WorkerStatus::Running => WorkerExecutionRunState::Busy,
|
||||
@@ -1697,7 +1706,7 @@ where
|
||||
|
||||
let starts_turn = method_starts_turn(&method);
|
||||
if starts_turn
|
||||
&& (worker.shared_state.get_status() != WorkerStatus::Idle
|
||||
&& (!method_can_start_turn_from_status(&method, worker.shared_state.get_status())
|
||||
|| busy
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err())
|
||||
@@ -1843,7 +1852,7 @@ mod tests {
|
||||
use crate::observation::WorkerObservationCursor;
|
||||
use crate::working_directory::LocalGitWorktreeMaterializer;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use futures::{Stream, StreamExt};
|
||||
use llm_engine::Engine;
|
||||
use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
||||
use llm_engine::llm_client::{ClientError, LlmClient, Request};
|
||||
@@ -1904,17 +1913,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_turn_claim_accepts_paused_and_idle_but_not_running_status() {
|
||||
assert!(method_can_start_turn_from_status(
|
||||
&Method::Resume,
|
||||
WorkerStatus::Paused
|
||||
));
|
||||
assert!(method_can_start_turn_from_status(
|
||||
&Method::Resume,
|
||||
WorkerStatus::Idle
|
||||
));
|
||||
assert!(!method_can_start_turn_from_status(
|
||||
&Method::Resume,
|
||||
WorkerStatus::Running
|
||||
));
|
||||
assert!(!method_can_start_turn_from_status(
|
||||
&Method::Compact,
|
||||
WorkerStatus::Paused
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum MockResponse {
|
||||
Complete(Vec<LlmEvent>),
|
||||
Hang(Vec<LlmEvent>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockClient {
|
||||
responses: Arc<Vec<Vec<LlmEvent>>>,
|
||||
responses: Arc<Vec<MockResponse>>,
|
||||
call_count: Arc<AtomicUsize>,
|
||||
captured: Arc<Mutex<Vec<Request>>>,
|
||||
}
|
||||
|
||||
impl MockClient {
|
||||
fn new(events: Vec<LlmEvent>) -> Self {
|
||||
Self::sequential(vec![MockResponse::Complete(events)])
|
||||
}
|
||||
|
||||
fn sequential(responses: Vec<MockResponse>) -> Self {
|
||||
Self {
|
||||
responses: Arc::new(vec![events]),
|
||||
responses: Arc::new(responses),
|
||||
call_count: Arc::new(AtomicUsize::new(0)),
|
||||
captured: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
@@ -1934,8 +1973,20 @@ mod tests {
|
||||
{
|
||||
self.captured.lock().unwrap().push(request);
|
||||
let idx = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
let events = self.responses.get(idx).cloned().unwrap_or_default();
|
||||
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
|
||||
let response = self
|
||||
.responses
|
||||
.get(idx)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| MockResponse::Complete(Vec::new()));
|
||||
match response {
|
||||
MockResponse::Complete(events) => {
|
||||
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
|
||||
}
|
||||
MockResponse::Hang(events) => Ok(Box::pin(
|
||||
futures::stream::iter(events.into_iter().map(Ok))
|
||||
.chain(futures::stream::pending()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2074,6 +2125,31 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn wait_for_adapter_state(
|
||||
backend: &WorkerRuntimeExecutionBackend<MockFactory>,
|
||||
worker_ref: &WorkerRef,
|
||||
expected_status: WorkerStatus,
|
||||
expected_busy: bool,
|
||||
) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let matches = {
|
||||
let workers = backend.workers.lock().unwrap();
|
||||
let execution = workers.get(worker_ref).expect("live Worker execution");
|
||||
execution.handle.shared_state.get_status() == expected_status
|
||||
&& execution.busy.load(Ordering::SeqCst) == expected_busy
|
||||
};
|
||||
if matches {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for adapter state {expected_status:?}, busy={expected_busy}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_text_events() -> Vec<LlmEvent> {
|
||||
vec![
|
||||
LlmEvent::text_block_start(0),
|
||||
@@ -2869,6 +2945,101 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "ws-server")]
|
||||
fn adapter_resumes_paused_turn_once_and_preserves_idle_not_paused_error() {
|
||||
let hanging_events = || simple_text_events().into_iter().take(2).collect::<Vec<_>>();
|
||||
let client = MockClient::sequential(vec![
|
||||
MockResponse::Hang(hanging_events()),
|
||||
MockResponse::Hang(hanging_events()),
|
||||
MockResponse::Complete(simple_text_events()),
|
||||
]);
|
||||
let call_count = client.call_count.clone();
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let repo = create_clean_repo();
|
||||
let store = tempfile::tempdir().unwrap();
|
||||
let factory = MockFactory {
|
||||
client,
|
||||
runtime_base: runtime_base.path().to_path_buf(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
store_dir: store.path().join("sessions"),
|
||||
worker_metadata_dir: store.path().join("workers"),
|
||||
observed_cwds: Arc::new(Mutex::new(Vec::new())),
|
||||
observed_workspace_clients: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
let backend = Arc::new(WorkerRuntimeExecutionBackend::new(factory).unwrap());
|
||||
let runtime =
|
||||
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone())
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let detail = runtime
|
||||
.create_worker(create_request("paused-resume"))
|
||||
.unwrap();
|
||||
|
||||
runtime
|
||||
.send_input(&detail.worker_ref, WorkerInput::user("pause and resume"))
|
||||
.expect("start initial turn");
|
||||
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Running, true);
|
||||
|
||||
let running_resume = runtime
|
||||
.send_protocol_method(&detail.worker_ref, Method::Resume)
|
||||
.expect_err("Resume while Running must be rejected");
|
||||
assert!(
|
||||
running_resume
|
||||
.to_string()
|
||||
.contains("does not queue protocol methods"),
|
||||
"unexpected Running Resume error: {running_resume}"
|
||||
);
|
||||
|
||||
runtime
|
||||
.send_protocol_method(&detail.worker_ref, Method::Pause)
|
||||
.expect("pause initial turn");
|
||||
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Paused, false);
|
||||
|
||||
runtime
|
||||
.send_protocol_method(&detail.worker_ref, Method::Resume)
|
||||
.expect("resume paused turn");
|
||||
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Running, true);
|
||||
|
||||
let duplicate_resume = runtime
|
||||
.send_protocol_method(&detail.worker_ref, Method::Resume)
|
||||
.expect_err("duplicate Resume must be rejected");
|
||||
assert!(
|
||||
duplicate_resume
|
||||
.to_string()
|
||||
.contains("does not queue protocol methods"),
|
||||
"unexpected duplicate Resume error: {duplicate_resume}"
|
||||
);
|
||||
|
||||
runtime
|
||||
.send_protocol_method(&detail.worker_ref, Method::Pause)
|
||||
.expect("pause resumed turn");
|
||||
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Paused, false);
|
||||
runtime
|
||||
.send_protocol_method(&detail.worker_ref, Method::Resume)
|
||||
.expect("resume paused turn a second time");
|
||||
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Idle, false);
|
||||
assert_eq!(call_count.load(Ordering::SeqCst), 3);
|
||||
|
||||
runtime
|
||||
.send_protocol_method(&detail.worker_ref, Method::Resume)
|
||||
.expect("Idle Resume preserves controller NotPaused semantics");
|
||||
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Idle, false);
|
||||
let events = runtime
|
||||
.read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero())
|
||||
.expect("read protocol events");
|
||||
assert!(events.iter().any(|event| {
|
||||
matches!(
|
||||
&event.payload,
|
||||
Event::Error {
|
||||
code: protocol::ErrorCode::NotPaused,
|
||||
..
|
||||
}
|
||||
)
|
||||
}));
|
||||
assert_eq!(call_count.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_and_deleting_worker_preserves_bound_working_directory() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
@@ -291,6 +291,7 @@ pub struct RuntimeOwnedWorkspaceClient {
|
||||
base_url: String,
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
request_timeout: Option<Duration>,
|
||||
worker_remove: Option<RuntimeWorkerMutationForwarder>,
|
||||
}
|
||||
|
||||
@@ -306,6 +307,7 @@ impl RuntimeOwnedWorkspaceClient {
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
runtime_id: runtime_id.into(),
|
||||
worker_id: worker_id.into(),
|
||||
request_timeout: None,
|
||||
worker_remove: None,
|
||||
}
|
||||
}
|
||||
@@ -314,6 +316,12 @@ impl RuntimeOwnedWorkspaceClient {
|
||||
self.worker_remove = Some(worker_remove);
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_request_timeout(mut self, request_timeout: Option<Duration>) -> Self {
|
||||
self.request_timeout = request_timeout;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
|
||||
@@ -351,9 +359,16 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
let base_url = self.base_url.clone();
|
||||
let runtime_id = self.runtime_id.clone();
|
||||
let worker_id = self.worker_id.clone();
|
||||
let request_timeout = self.request_timeout;
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
std::thread::spawn(move || {
|
||||
execute_runtime_owned_workspace_http(&base_url, &runtime_id, &worker_id, request)
|
||||
execute_runtime_owned_workspace_http(
|
||||
&base_url,
|
||||
&runtime_id,
|
||||
&worker_id,
|
||||
request_timeout,
|
||||
request,
|
||||
)
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| {
|
||||
@@ -364,6 +379,7 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
&self.base_url,
|
||||
&self.runtime_id,
|
||||
&self.worker_id,
|
||||
self.request_timeout,
|
||||
request,
|
||||
)
|
||||
}
|
||||
@@ -397,6 +413,7 @@ fn execute_runtime_owned_workspace_http(
|
||||
base_url: &str,
|
||||
runtime_id: &str,
|
||||
worker_id: &str,
|
||||
request_timeout: Option<Duration>,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if !request.path.starts_with('/') || request.path.starts_with("//") {
|
||||
@@ -410,7 +427,16 @@ fn execute_runtime_owned_workspace_http(
|
||||
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
|
||||
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
|
||||
};
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(request_timeout)
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WorkspaceClientError::Unavailable(format!(
|
||||
"failed to build Workspace API HTTP client: {}",
|
||||
reqwest_error_chain(&error)
|
||||
))
|
||||
})?;
|
||||
let request_label = format!("{method} {}", request.path);
|
||||
let mut request_builder = client
|
||||
.request(method, url)
|
||||
.header("x-yoi-runtime-id", runtime_id)
|
||||
@@ -422,14 +448,52 @@ fn execute_runtime_owned_workspace_http(
|
||||
}
|
||||
let response = request_builder
|
||||
.send()
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||
.map_err(|error| workspace_http_error(&request_label, "waiting for response", error))?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.text()
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||
.map_err(|error| workspace_http_error(&request_label, "reading response body", error))?;
|
||||
Ok(WorkspaceResponse { status, body })
|
||||
}
|
||||
|
||||
fn workspace_http_error(
|
||||
request_label: &str,
|
||||
stage: &str,
|
||||
error: reqwest::Error,
|
||||
) -> WorkspaceClientError {
|
||||
let details = reqwest_error_chain(&error);
|
||||
if error.is_timeout() {
|
||||
WorkspaceClientError::Request(format!(
|
||||
"Workspace API {request_label} timed out while {stage}: {details}"
|
||||
))
|
||||
} else if error.is_connect() {
|
||||
WorkspaceClientError::Unavailable(format!(
|
||||
"Workspace API {request_label} could not connect while {stage}: {details}"
|
||||
))
|
||||
} else {
|
||||
WorkspaceClientError::Request(format!(
|
||||
"Workspace API {request_label} transport failed while {stage}: {details}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn reqwest_error_chain(error: &reqwest::Error) -> String {
|
||||
let mut details = error.to_string();
|
||||
let mut source = std::error::Error::source(error);
|
||||
for _ in 0..4 {
|
||||
let Some(current) = source else {
|
||||
break;
|
||||
};
|
||||
let current_text = current.to_string();
|
||||
if !current_text.is_empty() && !details.ends_with(¤t_text) {
|
||||
details.push_str(": ");
|
||||
details.push_str(¤t_text);
|
||||
}
|
||||
source = std::error::Error::source(current);
|
||||
}
|
||||
details
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RuntimeWorkerMutationForwardError {
|
||||
#[error(transparent)]
|
||||
@@ -665,6 +729,64 @@ mod tests {
|
||||
assert_eq!(reason, "retire obsolete Worker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_owned_workspace_client_has_no_fixed_request_timeout() {
|
||||
let (base_url, server) = delayed_workspace_response(Duration::from_millis(75));
|
||||
let client =
|
||||
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a");
|
||||
assert_eq!(client.request_timeout, None);
|
||||
|
||||
let response = client
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/test",
|
||||
"{}",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.body, r#"{"ok":true}"#);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_owned_workspace_client_reports_request_timeouts() {
|
||||
let (base_url, server) = delayed_workspace_response(Duration::from_millis(75));
|
||||
let client =
|
||||
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
|
||||
.with_request_timeout(Some(Duration::from_millis(20)));
|
||||
|
||||
let error = client
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/test",
|
||||
"{}",
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkspaceClientError::Request(_)));
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("POST /api/test"), "{message}");
|
||||
assert!(message.contains("timed out"), "{message}");
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
fn delayed_workspace_response(delay: Duration) -> (String, std::thread::JoinHandle<()>) {
|
||||
use std::io::{Read, 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 bytes = [0_u8; 8192];
|
||||
let _ = stream.read(&mut bytes);
|
||||
std::thread::sleep(delay);
|
||||
let _ = stream.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
|
||||
);
|
||||
});
|
||||
(format!("http://{address}"), server)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_authority_uses_the_same_claim_contract_without_a_credential() {
|
||||
let authority =
|
||||
|
||||
@@ -21,7 +21,6 @@ use crate::shutdown_after_idle::{
|
||||
ShutdownAfterIdleRequest, TicketIntakeReadyShutdownHook, is_ticket_intake_role,
|
||||
take_shutdown_request_after_status,
|
||||
};
|
||||
use crate::spawn::comm_tools::{sub_worker_list_tool, sub_worker_send_tool, sub_worker_stop_tool};
|
||||
use crate::spawn::registry::SpawnedWorkerRegistry;
|
||||
use crate::spawn::tool::sub_worker_spawn_tool;
|
||||
use crate::worker::{
|
||||
@@ -802,6 +801,7 @@ where
|
||||
feature_registry.add_module(
|
||||
crate::feature::builtin::manage_worker::manage_worker_feature(
|
||||
workspace_client,
|
||||
Some(spawned_registry.clone()),
|
||||
feature_config.worker.direct_spawn,
|
||||
),
|
||||
);
|
||||
@@ -920,9 +920,6 @@ where
|
||||
scope_handle,
|
||||
prompts,
|
||||
));
|
||||
engine.register_tool(sub_worker_list_tool(spawned_registry.clone()));
|
||||
engine.register_tool(sub_worker_send_tool(spawned_registry.clone()));
|
||||
engine.register_tool(sub_worker_stop_tool(spawned_registry.clone()));
|
||||
observation_providers.push(Arc::new(
|
||||
crate::feature::builtin::worker_observation::SpawnedSubWorkerObservationProvider::new(
|
||||
spawned_registry,
|
||||
|
||||
@@ -14,6 +14,10 @@ use llm_engine::tool::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
|
||||
use workdir::workspace::{
|
||||
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
|
||||
WorkingDirectoryListResponse as WorkdirListResponse,
|
||||
};
|
||||
use workdir::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
@@ -25,7 +29,10 @@ use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse};
|
||||
use crate::worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
WorkspaceResponse,
|
||||
};
|
||||
|
||||
const FEATURE_ID: &str = "manage-workdir";
|
||||
const FEATURE_NAME: &str = "Manage Workdir";
|
||||
@@ -170,7 +177,7 @@ impl WorkspaceAttachedWorkdirSession {
|
||||
encode_path_segment(workspace_id)
|
||||
),
|
||||
serde_json::to_string(&operation).map_err(|error| {
|
||||
WorkdirError::Unavailable(format!(
|
||||
WorkdirError::Transport(format!(
|
||||
"failed to encode Workspace Workdir operation: {error}"
|
||||
))
|
||||
})?,
|
||||
@@ -178,28 +185,35 @@ impl WorkspaceAttachedWorkdirSession {
|
||||
let response = self
|
||||
.client
|
||||
.execute(request)
|
||||
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
|
||||
.map_err(workspace_workdir_error)?;
|
||||
if !response.is_success() {
|
||||
return Err(WorkdirError::Unavailable(format!(
|
||||
return Err(WorkdirError::Transport(format!(
|
||||
"Workspace Workdir API returned HTTP {}: {}",
|
||||
response.status,
|
||||
bounded_error_body(&response.body)
|
||||
)));
|
||||
}
|
||||
serde_json::from_str(&response.body).map_err(|error| {
|
||||
WorkdirError::Unavailable(format!(
|
||||
WorkdirError::Transport(format!(
|
||||
"failed to decode Workspace Workdir operation result: {error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn mismatch(expected: &str) -> WorkdirError {
|
||||
WorkdirError::Unavailable(format!(
|
||||
WorkdirError::Transport(format!(
|
||||
"Workspace Backend returned a mismatched Workdir operation result; expected {expected}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_workdir_error(error: WorkspaceClientError) -> WorkdirError {
|
||||
match error {
|
||||
WorkspaceClientError::Unavailable(message) => WorkdirError::Unavailable(message),
|
||||
other => WorkdirError::Transport(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
||||
fn workdir(&self) -> &Workdir {
|
||||
@@ -644,71 +658,6 @@ struct WorkdirDeleteInput {
|
||||
working_directory_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct WorkdirListResponse {
|
||||
workspace_id: String,
|
||||
items: Vec<WorkdirSummary>,
|
||||
diagnostics: Vec<WorkdirDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct WorkdirDetailResponse {
|
||||
workspace_id: String,
|
||||
item: WorkdirSummary,
|
||||
diagnostics: Vec<WorkdirDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct WorkdirSummary {
|
||||
working_directory_id: String,
|
||||
repository_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
creation_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
creation_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_ref: Option<String>,
|
||||
materializer_kind: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cleanup_target: Option<WorkdirCleanupTarget>,
|
||||
status: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cleanliness: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "primary_worker_id",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
attached_worker_id: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
occupied_by: Option<WorkdirOccupancy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct WorkdirCleanupTarget {
|
||||
kind: String,
|
||||
working_directory_id: String,
|
||||
repository_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct WorkdirOccupancy {
|
||||
runtime_id: String,
|
||||
runtime_worker_id: u64,
|
||||
worker_id: String,
|
||||
display_name: String,
|
||||
linked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct WorkdirDiagnostic {
|
||||
code: String,
|
||||
severity: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
@@ -783,10 +732,35 @@ mod tests {
|
||||
"repository_id": "main"
|
||||
},
|
||||
"status": "active",
|
||||
"cleanliness": "clean"
|
||||
"cleanliness": "clean",
|
||||
"occupied_by": {
|
||||
"runtime_id": "arcadia",
|
||||
"worker_id": "worker-opaque-64",
|
||||
"display_name": "Coder",
|
||||
"linked_at": "2026-08-12T00:00:00Z"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_request_failure_is_not_reported_as_session_unavailable() {
|
||||
let error = workspace_workdir_error(WorkspaceClientError::Request(
|
||||
"Workspace API POST /workdir timed out".to_string(),
|
||||
));
|
||||
assert!(matches!(error, WorkdirError::Transport(_)));
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("timed out"), "{message}");
|
||||
assert!(!message.contains("session is unavailable"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_unavailable_remains_a_session_error() {
|
||||
let error = workspace_workdir_error(WorkspaceClientError::Unavailable(
|
||||
"Workspace API could not connect".to_string(),
|
||||
));
|
||||
assert!(matches!(error, WorkdirError::Unavailable(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_declares_only_workdir_lifecycle_tools() {
|
||||
let feature = manage_workdir_feature(Arc::new(RecordingWorkspaceClient::new(Vec::new())));
|
||||
@@ -889,6 +863,18 @@ mod tests {
|
||||
assert_eq!(delete_schema()["required"], json!(["working_directory_id"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_runtime_worker_id_is_rejected_by_workspace_workdir_contract() {
|
||||
let mut response = json!({
|
||||
"workspace_id": "workspace/one",
|
||||
"items": [workdir_json("wd-1")],
|
||||
"diagnostics": []
|
||||
});
|
||||
response["items"][0]["occupied_by"]["runtime_worker_id"] = json!(64);
|
||||
|
||||
assert!(serde_json::from_value::<WorkdirListResponse>(response).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_create_explicit_attach_detach_and_delete_use_scoped_workspace_authority_paths() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
||||
@@ -925,7 +911,18 @@ mod tests {
|
||||
]));
|
||||
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
|
||||
|
||||
backend.list().unwrap();
|
||||
let listed = backend.list().unwrap();
|
||||
let listed: serde_json::Value =
|
||||
serde_json::from_str(listed.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
listed["items"][0]["occupied_by"]["worker_id"],
|
||||
"worker-opaque-64"
|
||||
);
|
||||
assert!(
|
||||
listed["items"][0]["occupied_by"]
|
||||
.get("runtime_worker_id")
|
||||
.is_none()
|
||||
);
|
||||
let created = backend
|
||||
.create(WorkdirCreateInput {
|
||||
runtime_id: "runtime/one".to_string(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReadinessCheck",
|
||||
"MergeRequestOpen",
|
||||
"MergeRequestAddRevision",
|
||||
"MergeRequestComplete",
|
||||
];
|
||||
pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit";
|
||||
@@ -20,7 +19,6 @@ enum Kind {
|
||||
Show,
|
||||
Readiness,
|
||||
Open,
|
||||
AddRevision,
|
||||
Complete,
|
||||
Review,
|
||||
}
|
||||
@@ -29,7 +27,6 @@ struct MergeRequestTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ShowInput {
|
||||
ticket: String,
|
||||
@@ -38,27 +35,8 @@ struct ShowInput {
|
||||
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>,
|
||||
selector_from: String,
|
||||
selector_to: String,
|
||||
#[serde(default)]
|
||||
summary: String,
|
||||
}
|
||||
@@ -66,7 +44,24 @@ struct AddRevisionInput {
|
||||
struct CompleteInput {
|
||||
ticket: String,
|
||||
operation_id: String,
|
||||
expected_revision_id: String,
|
||||
approval_event_id: String,
|
||||
target_ref_before: String,
|
||||
target_ref_after: String,
|
||||
strategy: MergeStrategyInput,
|
||||
resolution: MergeResolutionInput,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum MergeStrategyInput {
|
||||
FastForward,
|
||||
Merge,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum MergeResolutionInput {
|
||||
None,
|
||||
Clean,
|
||||
ConflictsResolved,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ReviewInput {
|
||||
@@ -90,63 +85,48 @@ struct ReviewFindingInput {
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
line: Option<u64>,
|
||||
line: Option<u32>,
|
||||
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(|| {
|
||||
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
let ws = 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 => {
|
||||
Kind::Show | Kind::Readiness => {
|
||||
let v: ShowInput = parse(input)?;
|
||||
nonempty(&v.ticket)?;
|
||||
(
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!(
|
||||
"/api/w/{workspace_id}/tickets/{}/merge-request/readiness",
|
||||
v.ticket
|
||||
"/api/w/{ws}/tickets/{}/merge-request{}",
|
||||
v.ticket,
|
||||
if matches!(self.kind, Kind::Readiness) {
|
||||
"/readiness"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -156,23 +136,9 @@ impl Tool for MergeRequestTool {
|
||||
nonempty(&v.ticket)?;
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket),
|
||||
format!("/api/w/{ws}/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}),
|
||||
json!({"repository_id":v.repository_id,"selector_from":v.selector_from,"selector_to":v.selector_to,"summary":v.summary}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -181,28 +147,24 @@ impl Tool for MergeRequestTool {
|
||||
nonempty(&v.ticket)?;
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!(
|
||||
"/api/w/{workspace_id}/tickets/{}/merge-request/complete",
|
||||
v.ticket
|
||||
),
|
||||
format!("/api/w/{ws}/tickets/{}/merge-request/complete", v.ticket),
|
||||
Some(
|
||||
json!({"operation_id":v.operation_id,"expected_revision_id":v.expected_revision_id}),
|
||||
json!({"operation_id":v.operation_id,"approval_event_id":v.approval_event_id,"target_ref_before":v.target_ref_before,"target_ref_after":v.target_ref_after,"strategy":match v.strategy{MergeStrategyInput::FastForward=>"fast_forward",MergeStrategyInput::Merge=>"merge"},"resolution":match v.resolution{MergeResolutionInput::None=>"none",MergeResolutionInput::Clean=>"clean",MergeResolutionInput::ConflictsResolved=>"conflicts_resolved"}}),
|
||||
),
|
||||
)
|
||||
}
|
||||
Kind::Review => {
|
||||
let v: ReviewInput = parse(input)?;
|
||||
let context = self.client.reviewer_attempt_context().ok_or_else(|| {
|
||||
let ctx = self.client.reviewer_context().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(
|
||||
"MergeRequestReviewSubmit is available only to an attested Reviewer child"
|
||||
.into(),
|
||||
"Review submit requires injected Reviewer capability".into(),
|
||||
)
|
||||
})?;
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!(
|
||||
"/api/w/{workspace_id}/tickets/{}/merge-request/reviews",
|
||||
context.ticket_id
|
||||
"/api/w/{ws}/tickets/{}/merge-request/reviews",
|
||||
ctx.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<_>>() }),
|
||||
@@ -210,32 +172,32 @@ impl Tool for MergeRequestTool {
|
||||
)
|
||||
}
|
||||
};
|
||||
let request = match body {
|
||||
Some(body) => WorkspaceRequest::json(method, path, body.to_string()),
|
||||
let req = match body {
|
||||
Some(v) => WorkspaceRequest::json(method, path, v.to_string()),
|
||||
None => WorkspaceRequest::get(path),
|
||||
};
|
||||
let response = self
|
||||
let res = self
|
||||
.client
|
||||
.execute(request)
|
||||
.execute(req)
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
if !response.is_success() {
|
||||
if !res.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Merge Request API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
res.status, res.body
|
||||
)));
|
||||
}
|
||||
Ok(ToolOutput {
|
||||
summary: self.kind.name().to_string(),
|
||||
content: Some(response.body),
|
||||
attachments: Vec::new(),
|
||||
summary: self.kind.name().into(),
|
||||
content: Some(res.body),
|
||||
attachments: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
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 parse<T: serde::de::DeserializeOwned>(v: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(v).map_err(|e| ToolError::InvalidArgument(e.to_string()))
|
||||
}
|
||||
fn nonempty(value: &str) -> Result<(), ToolError> {
|
||||
if value.trim().is_empty() {
|
||||
fn nonempty(v: &str) -> Result<(), ToolError> {
|
||||
if v.trim().is_empty() {
|
||||
Err(ToolError::InvalidArgument(
|
||||
"ticket must not be empty".into(),
|
||||
))
|
||||
@@ -245,55 +207,75 @@ fn nonempty(value: &str) -> Result<(), ToolError> {
|
||||
}
|
||||
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)
|
||||
(
|
||||
ToolMeta::new(kind.name())
|
||||
.description(description(kind.name()).unwrap_or("Merge Request operation."))
|
||||
.input_schema(kind.schema()),
|
||||
Arc::new(MergeRequestTool {
|
||||
client: client.clone(),
|
||||
kind,
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
pub fn common_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
|
||||
pub fn common_tools(c: 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),
|
||||
definition(c.clone(), Kind::Show),
|
||||
definition(c.clone(), Kind::Readiness),
|
||||
definition(c.clone(), Kind::Open),
|
||||
definition(c, Kind::Complete),
|
||||
]
|
||||
}
|
||||
pub fn reviewer_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
|
||||
if client.reviewer_attempt_context().is_some() {
|
||||
pub fn reviewer_tools(c: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
|
||||
if c.reviewer_context().is_some() {
|
||||
vec![
|
||||
definition(client.clone(), Kind::Show),
|
||||
definition(client, Kind::Review),
|
||||
definition(c.clone(), Kind::Show),
|
||||
definition(c, Kind::Review),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
pub fn description(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"MergeRequestShow" => Some(
|
||||
"Read the authoritative Merge Request, immutable current revision, and structured review status.",
|
||||
),
|
||||
pub fn description(n: &str) -> Option<&'static str> {
|
||||
match n {
|
||||
"MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."),
|
||||
"MergeRequestReadinessCheck" => {
|
||||
Some("Check derived merge readiness for the current immutable revision.")
|
||||
Some("Resolve current provider refs and derive readiness from valid review events.")
|
||||
}
|
||||
"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.")
|
||||
Some("Open a Merge Request with immutable source and target selectors.")
|
||||
}
|
||||
"MergeRequestComplete" => {
|
||||
Some("CAS-complete an approved revision with operation-id replay and crash fencing.")
|
||||
Some("Complete using an approved review event and final target-ref evidence.")
|
||||
}
|
||||
"MergeRequestReviewSubmit" => {
|
||||
Some("Submit the injected Reviewer capability result for its captured subject ref.")
|
||||
}
|
||||
"MergeRequestReviewSubmit" => Some(
|
||||
"Submit the attested direct-child Reviewer result bound to its immutable revision.",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn schemas_hide_revision_and_commit_authority() {
|
||||
let schemas = [
|
||||
schemars::schema_for!(OpenInput),
|
||||
schemars::schema_for!(CompleteInput),
|
||||
];
|
||||
for s in schemas {
|
||||
let j = serde_json::to_string(&s).unwrap();
|
||||
for banned in [
|
||||
"revision_id",
|
||||
"attempt_id",
|
||||
"base_commit",
|
||||
"head_commit",
|
||||
"source_commit",
|
||||
"result_commit",
|
||||
] {
|
||||
assert!(!j.contains(banned), "{banned} in {j}")
|
||||
}
|
||||
}
|
||||
assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestRequestReview"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,36 +26,45 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
async fn list(&self, input: ObjectiveListInput) -> Result<ToolOutput, ToolError> {
|
||||
let mut url = format!(
|
||||
"/api/w/{}/objectives",
|
||||
async fn list(&self, input: QueryObjectiveInput) -> Result<ToolOutput, ToolError> {
|
||||
let url = format!(
|
||||
"/api/w/{}/objectives/query",
|
||||
self.client.workspace_id().unwrap_or_default()
|
||||
);
|
||||
if let Some(limit) = input.limit {
|
||||
url.push_str(&format!("?limit={}", limit.min(1000)));
|
||||
}
|
||||
let response = get_json::<ObjectiveListResponse>(self.client.as_ref(), &url)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
let count = response.items.len();
|
||||
let response = send_json::<QueryObjectiveInput, serde_json::Value>(
|
||||
self.client.as_ref(),
|
||||
reqwest::Method::POST,
|
||||
&url,
|
||||
&input,
|
||||
)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Listed {count} objective(s)"),
|
||||
summary: "Queried Objectives".to_string(),
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn show(&self, input: ObjectiveShowInput) -> Result<ToolOutput, ToolError> {
|
||||
let id = validate_id(&input.id, "ObjectiveShow")?;
|
||||
let url = self.objective_url(id);
|
||||
let response = get_json::<ObjectiveDetail>(self.client.as_ref(), &url)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Read objective {}", response.id),
|
||||
response,
|
||||
)?)
|
||||
async fn show(&self, input: ShowObjectiveInput) -> Result<ToolOutput, ToolError> {
|
||||
let id = validate_id(&input.id, "ShowObjective")?;
|
||||
let url = format!("{}/show", self.objective_url(id));
|
||||
let response = send_json::<ObjectiveShowRequest, serde_json::Value>(
|
||||
self.client.as_ref(),
|
||||
reqwest::Method::POST,
|
||||
&url,
|
||||
&ObjectiveShowRequest {
|
||||
event_limit: input.event_limit,
|
||||
event_cursor: input.event_cursor,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Read objective {id}"),
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn create(&self, input: ObjectiveCreateInput) -> Result<ToolOutput, ToolError> {
|
||||
@@ -195,13 +204,6 @@ fn backend_error(error: WorkspaceObjectiveBackendError) -> ToolError {
|
||||
ToolError::ExecutionFailed(error.to_string())
|
||||
}
|
||||
|
||||
async fn get_json<T: for<'de> Deserialize<'de>>(
|
||||
client: &dyn WorkspaceClient,
|
||||
path: &str,
|
||||
) -> Result<T, WorkspaceObjectiveBackendError> {
|
||||
decode_response(client.execute(WorkspaceRequest::get(path))?)
|
||||
}
|
||||
|
||||
async fn send_json<B: Serialize, T: for<'de> Deserialize<'de>>(
|
||||
client: &dyn WorkspaceClient,
|
||||
method: reqwest::Method,
|
||||
@@ -270,14 +272,14 @@ pub fn workspace_http_objective_tools(client: Arc<dyn WorkspaceClient>) -> Vec<T
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(client);
|
||||
vec![
|
||||
objective_tool(
|
||||
"ObjectiveList",
|
||||
"QueryObjective",
|
||||
LIST_DESCRIPTION,
|
||||
list_schema(),
|
||||
backend.clone(),
|
||||
ObjectiveOperation::List,
|
||||
),
|
||||
objective_tool(
|
||||
"ObjectiveShow",
|
||||
"ShowObjective",
|
||||
SHOW_DESCRIPTION,
|
||||
show_schema(),
|
||||
backend.clone(),
|
||||
@@ -367,11 +369,11 @@ impl Tool for WorkspaceHttpObjectiveTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
match self.operation {
|
||||
ObjectiveOperation::List => {
|
||||
let input = parse_input::<ObjectiveListInput>(input_json)?;
|
||||
let input = parse_input::<QueryObjectiveInput>(input_json)?;
|
||||
self.backend.list(input).await
|
||||
}
|
||||
ObjectiveOperation::Show => {
|
||||
let input = parse_input::<ObjectiveShowInput>(input_json)?;
|
||||
let input = parse_input::<ShowObjectiveInput>(input_json)?;
|
||||
self.backend.show(input).await
|
||||
}
|
||||
ObjectiveOperation::Create => {
|
||||
@@ -402,10 +404,8 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
|
||||
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
|
||||
}
|
||||
|
||||
const LIST_DESCRIPTION: &str =
|
||||
"List Objective records through Backend Workspace API authority as bounded summaries.";
|
||||
const SHOW_DESCRIPTION: &str =
|
||||
"Show one Objective record by canonical id through Backend Workspace API authority.";
|
||||
const LIST_DESCRIPTION: &str = "Query authoritative Objectives with bounded typed filters, stable snippets, linked-Ticket context, and cursor metadata.";
|
||||
const SHOW_DESCRIPTION: &str = "Show one authoritative Objective with its revision, full linked-Ticket context, bounded body, and paged event metadata.";
|
||||
const CREATE_DESCRIPTION: &str =
|
||||
"Create an Objective record through Backend Workspace API authority.";
|
||||
const EDIT_DESCRIPTION: &str =
|
||||
@@ -422,13 +422,29 @@ fn list_schema() -> serde_json::Value {
|
||||
"type":"object",
|
||||
"additionalProperties": false,
|
||||
"properties":{
|
||||
"limit":{"type":["integer","null"],"minimum":0,"maximum":1000}
|
||||
"query":{"type":["string","null"]},
|
||||
"states":{"type":"array","items":{"type":"string"},"default":[]},
|
||||
"linked_ticket_id":{"type":["string","null"]},
|
||||
"updated_after":{"type":["string","null"]},
|
||||
"updated_before":{"type":["string","null"]},
|
||||
"sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]},
|
||||
"limit":{"type":["integer","null"],"minimum":1,"maximum":100},
|
||||
"cursor":{"type":["string","null"]}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn show_schema() -> serde_json::Value {
|
||||
id_schema(&["id"])
|
||||
json!({
|
||||
"type":"object",
|
||||
"additionalProperties": false,
|
||||
"required":["id"],
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"event_limit":{"type":["integer","null"],"minimum":1,"maximum":50},
|
||||
"event_cursor":{"type":["string","null"]}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_schema() -> serde_json::Value {
|
||||
@@ -480,17 +496,6 @@ fn unlink_ticket_schema() -> serde_json::Value {
|
||||
id_ticket_schema(&["id", "ticket_id"])
|
||||
}
|
||||
|
||||
fn id_schema(required: &[&str]) -> serde_json::Value {
|
||||
json!({
|
||||
"type":"object",
|
||||
"additionalProperties": false,
|
||||
"required": required,
|
||||
"properties":{
|
||||
"id":{"type":"string"}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
|
||||
json!({
|
||||
"type":"object",
|
||||
@@ -503,14 +508,30 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObjectiveListInput {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct QueryObjectiveInput {
|
||||
query: Option<String>,
|
||||
#[serde(default)]
|
||||
states: Vec<String>,
|
||||
linked_ticket_id: Option<String>,
|
||||
updated_after: Option<String>,
|
||||
updated_before: Option<String>,
|
||||
sort: Option<String>,
|
||||
limit: Option<usize>,
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObjectiveShowInput {
|
||||
struct ShowObjectiveInput {
|
||||
id: String,
|
||||
event_limit: Option<usize>,
|
||||
event_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ObjectiveShowRequest {
|
||||
event_limit: Option<usize>,
|
||||
event_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -574,30 +595,6 @@ fn default_state() -> String {
|
||||
"active".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct ObjectiveListResponse {
|
||||
items: Vec<ObjectiveSummary>,
|
||||
invalid_records: Vec<InvalidProjectRecord>,
|
||||
record_authority: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct InvalidProjectRecord {
|
||||
label: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct ObjectiveSummary {
|
||||
id: String,
|
||||
title: String,
|
||||
state: String,
|
||||
updated_at: Option<String>,
|
||||
summary: String,
|
||||
linked_tickets: Vec<String>,
|
||||
record_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct ObjectiveDetail {
|
||||
id: String,
|
||||
@@ -637,10 +634,10 @@ mod tests {
|
||||
"ObjectiveCreate",
|
||||
"ObjectiveEdit",
|
||||
"ObjectiveLinkTicket",
|
||||
"ObjectiveList",
|
||||
"ObjectiveSetState",
|
||||
"ObjectiveShow",
|
||||
"ObjectiveUnlinkTicket",
|
||||
"QueryObjective",
|
||||
"ShowObjective",
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -648,9 +645,12 @@ mod tests {
|
||||
#[test]
|
||||
fn objective_tool_schemas_are_bounded_and_mutation_scoped() {
|
||||
let list = list_schema();
|
||||
assert_eq!(list["properties"]["limit"]["maximum"], 1000);
|
||||
assert_eq!(list["properties"]["limit"]["maximum"], 100);
|
||||
assert!(list["properties"]["cursor"].is_object());
|
||||
assert!(list["properties"]["linked_ticket_id"].is_object());
|
||||
let show = show_schema();
|
||||
assert_eq!(show["required"][0], "id");
|
||||
assert_eq!(show["properties"]["event_limit"]["maximum"], 50);
|
||||
let create = create_schema();
|
||||
assert_eq!(create["required"][0], "title");
|
||||
let edit = edit_schema();
|
||||
|
||||
@@ -9,6 +9,10 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use ticket::{
|
||||
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
|
||||
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
||||
@@ -25,8 +29,238 @@ use crate::feature::{
|
||||
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
||||
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
|
||||
FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
|
||||
ToolDefinition,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WorkspaceTicketReadKind {
|
||||
Query,
|
||||
Show,
|
||||
}
|
||||
|
||||
impl WorkspaceTicketReadKind {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Query => "QueryTicket",
|
||||
Self::Show => "ShowTicket",
|
||||
}
|
||||
}
|
||||
|
||||
fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Query => {
|
||||
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
|
||||
}
|
||||
Self::Show => {
|
||||
"Show one authoritative Workspace Ticket with its item revision, paged thread, links, implementation reports, and current Merge Request review evidence."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schema(self) -> Value {
|
||||
match self {
|
||||
Self::Query => serde_json::to_value(schemars::schema_for!(WorkspaceQueryTicketInput))
|
||||
.expect("QueryTicket schema serializes"),
|
||||
Self::Show => serde_json::to_value(schemars::schema_for!(WorkspaceShowTicketInput))
|
||||
.expect("ShowTicket schema serializes"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketStateFilter {
|
||||
Planning,
|
||||
Ready,
|
||||
Queued,
|
||||
Inprogress,
|
||||
Done,
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketEvidenceFilter {
|
||||
ImplementationReport,
|
||||
ImplementationReportAfterRescope,
|
||||
MergeRequest,
|
||||
Commit,
|
||||
ApprovedReview,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketReviewFilter {
|
||||
None,
|
||||
Pending,
|
||||
Approved,
|
||||
RequestChanges,
|
||||
UnresolvedChanges,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketAttentionFilter {
|
||||
DoneNotClosed,
|
||||
ImplementationReportNotClosed,
|
||||
ReportAfterRescope,
|
||||
UnresolvedReview,
|
||||
MissingCommit,
|
||||
Blocked,
|
||||
Unblocked,
|
||||
Ready,
|
||||
AwaitingReview,
|
||||
UnresolvedChanges,
|
||||
StaleAfterRescope,
|
||||
MissingEvidence,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketRelationFilter {
|
||||
DependsOn,
|
||||
Blocks,
|
||||
Related,
|
||||
Supersedes,
|
||||
DuplicateOf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketSort {
|
||||
Relevance,
|
||||
UpdatedDesc,
|
||||
CreatedDesc,
|
||||
Priority,
|
||||
Title,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
struct WorkspaceQueryTicketInput {
|
||||
/// Full-text match over Ticket title, item body, and bounded thread excerpts.
|
||||
query: Option<String>,
|
||||
/// Exact workflow states. Empty means every state.
|
||||
#[serde(default)]
|
||||
states: Vec<WorkspaceTicketStateFilter>,
|
||||
/// Exact typed event kinds that must occur in the bounded thread window.
|
||||
#[serde(default)]
|
||||
event_kinds: Vec<String>,
|
||||
/// Required evidence kinds: implementation_report, implementation_report_after_rescope,
|
||||
/// merge_request, commit, or approved_review.
|
||||
#[serde(default)]
|
||||
evidence: Vec<WorkspaceTicketEvidenceFilter>,
|
||||
/// Current authoritative Merge Request review status: none, pending, approved,
|
||||
/// request_changes, or unresolved_changes.
|
||||
review_status: Option<WorkspaceTicketReviewFilter>,
|
||||
/// Attention filters include done_not_closed, implementation_report_not_closed,
|
||||
/// report_after_rescope, unresolved_review, missing_commit, blocked, and unblocked.
|
||||
#[serde(default)]
|
||||
attention: Vec<WorkspaceTicketAttentionFilter>,
|
||||
related_ticket_id: Option<String>,
|
||||
relation_kind: Option<WorkspaceTicketRelationFilter>,
|
||||
linked_objective_id: Option<String>,
|
||||
updated_after: Option<String>,
|
||||
updated_before: Option<String>,
|
||||
/// relevance (default when query is present), updated_desc, created_desc,
|
||||
/// priority, or title.
|
||||
sort: Option<WorkspaceTicketSort>,
|
||||
/// Page size; bounded by the Backend to 1..=100.
|
||||
limit: Option<usize>,
|
||||
/// Opaque cursor returned by a prior QueryTicket page.
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
struct WorkspaceShowTicketInput {
|
||||
id: String,
|
||||
/// Most-recent thread entries to return, bounded by the Backend to 1..=50.
|
||||
event_limit: Option<usize>,
|
||||
/// Opaque event cursor returned by a prior ShowTicket page.
|
||||
event_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WorkspaceTicketReadTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: WorkspaceTicketReadKind,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WorkspaceTicketReadTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input: &str,
|
||||
_context: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
||||
ToolError::InvalidArgument("Workspace Ticket reads require workspace identity".into())
|
||||
})?;
|
||||
let (path, body) = match self.kind {
|
||||
WorkspaceTicketReadKind::Query => {
|
||||
let input: WorkspaceQueryTicketInput = serde_json::from_str(&input)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
(
|
||||
format!("/api/w/{workspace_id}/tickets/query"),
|
||||
serde_json::to_value(input)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
|
||||
)
|
||||
}
|
||||
WorkspaceTicketReadKind::Show => {
|
||||
let input: WorkspaceShowTicketInput = serde_json::from_str(&input)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
if input.id.trim().is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"ShowTicket.id must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let path = format!("/api/w/{workspace_id}/tickets/{}/show", input.id.trim());
|
||||
let body = json!({
|
||||
"event_limit": input.event_limit,
|
||||
"event_cursor": input.event_cursor,
|
||||
});
|
||||
(path, body)
|
||||
}
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
path,
|
||||
serde_json::to_string(&body)
|
||||
.map_err(|error| ToolError::Internal(error.to_string()))?,
|
||||
))
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !response.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Workspace Ticket API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
Ok(ToolOutput {
|
||||
summary: self.kind.name().to_string(),
|
||||
content: Some(response.body),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_ticket_read_definition(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: WorkspaceTicketReadKind,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new(kind.name())
|
||||
.description(kind.description())
|
||||
.input_schema(kind.schema());
|
||||
let tool: Arc<dyn Tool> = Arc::new(WorkspaceTicketReadTool {
|
||||
client: client.clone(),
|
||||
kind,
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
const FEATURE_ID: &str = "ticket";
|
||||
const FEATURE_NAME: &str = "Ticket tools";
|
||||
@@ -142,14 +376,7 @@ impl TicketFeatureAccess {
|
||||
}
|
||||
}
|
||||
|
||||
const READ_ONLY_TOOL_NAMES: &[&str] = &[
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
|
||||
|
||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
@@ -157,6 +384,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketQueue",
|
||||
"TicketClose",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationRemove",
|
||||
];
|
||||
|
||||
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
|
||||
@@ -167,29 +395,25 @@ const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
|
||||
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"TicketComment",
|
||||
"TicketQueue",
|
||||
"TicketClose",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
"TicketRelationRemove",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"TicketComment",
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationQuery",
|
||||
"TicketRelationRemove",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
@@ -197,8 +421,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
||||
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketDependencyCheck",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationRemove",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -362,7 +589,7 @@ impl FeatureModule for TicketFeature {
|
||||
));
|
||||
}
|
||||
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
|
||||
let names: Vec<&str> = if client.reviewer_attempt_context().is_some() {
|
||||
let names: Vec<&str> = if client.reviewer_context().is_some() {
|
||||
vec![
|
||||
"MergeRequestShow",
|
||||
merge_request::MERGE_REQUEST_REVIEW_TOOL_NAME,
|
||||
@@ -409,6 +636,10 @@ impl FeatureModule for TicketFeature {
|
||||
ticket_workflow_instruction(),
|
||||
))?;
|
||||
let allowed_tool_names = self.enabled_tool_names();
|
||||
let workspace_client = match &self.backend {
|
||||
TicketFeatureBackend::WorkspaceClient(client) => Some(client.clone()),
|
||||
TicketFeatureBackend::Local { .. } => None,
|
||||
};
|
||||
let mut tools = context.tools();
|
||||
for definition in ticket_tools(backend) {
|
||||
let (meta, _) = definition();
|
||||
@@ -419,10 +650,19 @@ impl FeatureModule for TicketFeature {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let definition = match (name.as_str(), workspace_client.as_ref()) {
|
||||
("QueryTicket", Some(client)) => {
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)
|
||||
}
|
||||
("ShowTicket", Some(client)) => {
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)
|
||||
}
|
||||
_ => definition,
|
||||
};
|
||||
tools.register(ToolContribution::new(name, definition))?;
|
||||
}
|
||||
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
|
||||
let definitions = if client.reviewer_attempt_context().is_some() {
|
||||
let definitions = if client.reviewer_context().is_some() {
|
||||
merge_request::reviewer_tools(client.clone())
|
||||
} else {
|
||||
merge_request::common_tools(client.clone())
|
||||
@@ -689,6 +929,20 @@ impl WorkspaceHttpTicketBackend {
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
|
||||
let target = match target {
|
||||
TicketIdOrSlug::Id(value)
|
||||
| TicketIdOrSlug::Slug(value)
|
||||
| TicketIdOrSlug::Query(value) => value,
|
||||
};
|
||||
let relation = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Delete,
|
||||
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
||||
Some(serde_json::json!({ "kind": kind, "target": target })),
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
|
||||
let relations = Self::request(
|
||||
client,
|
||||
@@ -915,6 +1169,18 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
||||
)
|
||||
}
|
||||
|
||||
fn remove_ticket_relation(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
kind: TicketRelationKind,
|
||||
target: TicketIdOrSlug,
|
||||
) -> TicketResult<TicketRelation> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::RemoveTicketRelation { id, kind, target }),
|
||||
TicketBackendOperationResult::Relation
|
||||
)
|
||||
}
|
||||
|
||||
fn query_ticket_relations(
|
||||
&self,
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
@@ -1014,6 +1280,47 @@ mod tests {
|
||||
.expect("tool exists")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() {
|
||||
let client: Arc<dyn WorkspaceClient> = Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
|
||||
);
|
||||
let (query, _) =
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
|
||||
assert_eq!(query.name, "QueryTicket");
|
||||
assert!(query.input_schema["properties"]["evidence"].is_object());
|
||||
assert!(query.input_schema["properties"]["attention"].is_object());
|
||||
assert!(query.input_schema["properties"]["cursor"].is_object());
|
||||
let query_schema = serde_json::to_string(&query.input_schema).unwrap();
|
||||
assert!(query_schema.contains("done_not_closed"));
|
||||
assert!(query_schema.contains("request_changes"));
|
||||
assert!(query_schema.contains("created_desc"));
|
||||
assert!(
|
||||
query_schema.len() < 8_000,
|
||||
"QueryTicket schema grew unexpectedly"
|
||||
);
|
||||
let (show, _) = workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Show)();
|
||||
assert_eq!(show.name, "ShowTicket");
|
||||
assert!(show.input_schema["properties"]["event_limit"].is_object());
|
||||
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
|
||||
assert_eq!(tool_names.len(), 9);
|
||||
assert!(
|
||||
tool_names.len() < 13,
|
||||
"authoring catalog must stay below the prior broad catalog"
|
||||
);
|
||||
let workflow_names = TicketFeatureAccess::workflow().tool_names();
|
||||
assert_eq!(workflow_names.len(), 10);
|
||||
assert!(
|
||||
workflow_names.len() < 12,
|
||||
"workflow catalog must stay below the prior broad catalog"
|
||||
);
|
||||
assert_eq!(TicketFeatureAccess::review().tool_names().len(), 2);
|
||||
assert!(tool_names.contains(&"QueryTicket"));
|
||||
assert!(tool_names.contains(&"ShowTicket"));
|
||||
assert!(!tool_names.contains(&"TicketList"));
|
||||
assert!(!tool_names.contains(&"TicketShow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_declares_ticket_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -1170,8 +1477,8 @@ language = "Japanese"
|
||||
let descriptor_description = descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == "TicketShow")
|
||||
.expect("TicketShow declared")
|
||||
.find(|tool| tool.name == "ShowTicket")
|
||||
.expect("ShowTicket declared")
|
||||
.description
|
||||
.clone();
|
||||
assert!(descriptor_description.contains("Ticket record language: Japanese"));
|
||||
@@ -1184,7 +1491,7 @@ language = "Japanese"
|
||||
|
||||
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
|
||||
assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
|
||||
let description = pending_tool_description(&pending_tools, "TicketShow");
|
||||
let description = pending_tool_description(&pending_tools, "ShowTicket");
|
||||
assert!(description.contains("Ticket record language: Japanese"));
|
||||
assert!(description.contains("distinct from worker.language"));
|
||||
assert!(description.contains("Preserve protocol literals"));
|
||||
@@ -1448,6 +1755,55 @@ provider = "github"
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_http_backend_deletes_exact_ticket_relation() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(
|
||||
request
|
||||
.starts_with("DELETE /api/w/workspace-a/tickets/01SOURCE/relations HTTP/1.1")
|
||||
);
|
||||
assert!(request.contains("\"kind\":\"depends_on\""));
|
||||
assert!(request.contains("\"target\":\"01TARGET\""));
|
||||
let response_body = serde_json::to_string(&TicketRelation {
|
||||
ticket_id: "01SOURCE".to_string(),
|
||||
kind: TicketRelationKind::DependsOn,
|
||||
target: "01TARGET".to_string(),
|
||||
note: Some("obsolete".to_string()),
|
||||
author: "tester".to_string(),
|
||||
at: "2026-08-06T00:00:00Z".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
|
||||
));
|
||||
let removed = backend
|
||||
.remove_ticket_relation(
|
||||
TicketIdOrSlug::Id("01SOURCE".to_string()),
|
||||
TicketRelationKind::DependsOn,
|
||||
TicketIdOrSlug::Id("01TARGET".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(removed.ticket_id, "01SOURCE");
|
||||
assert_eq!(removed.target, "01TARGET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_http_backend_executes_ticket_create_operation() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
|
||||
@@ -7,10 +7,11 @@ use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_store::collect_state;
|
||||
|
||||
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
|
||||
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ServiceId,
|
||||
ServiceRequirement, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::session_capture::{
|
||||
ReadDetail, ReadOptions, ReadSelector, ReferenceKind, SearchOptions, SessionCapture,
|
||||
@@ -111,11 +112,17 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
|
||||
async fn list_worker_sessions(
|
||||
&self,
|
||||
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
|
||||
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
||||
WorkerObservationError::Unavailable(
|
||||
"Workspace observation requires a scoped Workspace client".to_string(),
|
||||
)
|
||||
})?;
|
||||
let response = self
|
||||
.client
|
||||
.execute(crate::worker::WorkspaceRequest::get(
|
||||
"/worker-observation/sessions",
|
||||
))
|
||||
.execute(crate::worker::WorkspaceRequest::get(format!(
|
||||
"/api/w/{}/worker-observation/sessions",
|
||||
workspace_id
|
||||
)))
|
||||
.map_err(workspace_client_error)?;
|
||||
let body = workspace_response_body(response)?;
|
||||
serde_json::from_str::<WorkspaceWorkerObservationListResponse>(&body)
|
||||
@@ -129,11 +136,16 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
|
||||
) -> Result<WorkerSessionCapture, WorkerObservationError> {
|
||||
let body = serde_json::to_string(subject)
|
||||
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))?;
|
||||
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
||||
WorkerObservationError::Unavailable(
|
||||
"Workspace observation requires a scoped Workspace client".to_string(),
|
||||
)
|
||||
})?;
|
||||
let response = self
|
||||
.client
|
||||
.execute(crate::worker::WorkspaceRequest::json(
|
||||
crate::worker::WorkspaceRequestMethod::Post,
|
||||
"/worker-observation/session",
|
||||
format!("/api/w/{}/worker-observation/session", workspace_id),
|
||||
body,
|
||||
))
|
||||
.map_err(workspace_client_error)?;
|
||||
@@ -174,6 +186,43 @@ fn workspace_client_error(error: crate::worker::WorkspaceClientError) -> WorkerO
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ControlAuthorizedObservationProvider {
|
||||
control: Arc<dyn WorkerControlService>,
|
||||
inner: Arc<dyn WorkerObservationProvider>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkerObservationProvider for ControlAuthorizedObservationProvider {
|
||||
async fn list_worker_sessions(
|
||||
&self,
|
||||
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
|
||||
let candidates = self.inner.list_worker_sessions().await?;
|
||||
let mut granted = Vec::new();
|
||||
for candidate in candidates {
|
||||
if self
|
||||
.control
|
||||
.ensure_permission(&candidate.subject, "observe")
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
granted.push(candidate);
|
||||
}
|
||||
}
|
||||
Ok(granted)
|
||||
}
|
||||
|
||||
async fn capture_worker_session(
|
||||
&self,
|
||||
subject: &WorkerObservationSubjectRef,
|
||||
) -> Result<WorkerSessionCapture, WorkerObservationError> {
|
||||
self.control
|
||||
.ensure_permission(subject, "observe")
|
||||
.await
|
||||
.map_err(|_| WorkerObservationError::NotFound)?;
|
||||
self.inner.capture_worker_session(subject).await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WorkerObservationFeature {
|
||||
provider: Arc<dyn WorkerObservationProvider>,
|
||||
}
|
||||
@@ -190,11 +239,11 @@ impl FeatureModule for WorkerObservationFeature {
|
||||
.with_description(
|
||||
"Read-only exploration of explicitly granted active Worker sessions.",
|
||||
)
|
||||
.with_instruction(observation_instruction())
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"ListWorkerSessions",
|
||||
"List bounded summaries of active Worker sessions granted to this Worker.",
|
||||
.with_service_requirement(ServiceRequirement::required(
|
||||
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||
"Worker observation extends the known-Worker control authority",
|
||||
))
|
||||
.with_instruction(observation_instruction())
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"ViewSessionOverview",
|
||||
"Show a sparse overview of the latest committed capture for one granted Worker session.",
|
||||
@@ -215,21 +264,25 @@ impl FeatureModule for WorkerObservationFeature {
|
||||
.register(FeatureInstructionContribution::new(
|
||||
observation_instruction(),
|
||||
))?;
|
||||
context.tools().register(ToolContribution::new(
|
||||
"ListWorkerSessions",
|
||||
list_definition(self.provider.clone()),
|
||||
))?;
|
||||
let control = context
|
||||
.services()
|
||||
.require::<dyn WorkerControlService>(&ServiceId::builtin(WORKER_CONTROL_SERVICE_ID))?;
|
||||
let provider: Arc<dyn WorkerObservationProvider> =
|
||||
Arc::new(ControlAuthorizedObservationProvider {
|
||||
control,
|
||||
inner: self.provider.clone(),
|
||||
});
|
||||
context.tools().register(ToolContribution::new(
|
||||
"ViewSessionOverview",
|
||||
overview_definition(self.provider.clone()),
|
||||
overview_definition(provider.clone()),
|
||||
))?;
|
||||
context.tools().register(ToolContribution::new(
|
||||
"SearchSessionEntries",
|
||||
search_definition(self.provider.clone()),
|
||||
search_definition(provider.clone()),
|
||||
))?;
|
||||
context.tools().register(ToolContribution::new(
|
||||
"ReadSessionEntry",
|
||||
read_definition(self.provider.clone()),
|
||||
read_definition(provider),
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -346,20 +399,6 @@ impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn list_definition(provider: Arc<dyn WorkerObservationProvider>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(ListWorkerSessionsParams))
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let meta = ToolMeta::new("ListWorkerSessions")
|
||||
.description("List active Worker sessions explicitly granted to this Worker.")
|
||||
.input_schema(schema);
|
||||
let tool: Arc<dyn Tool> = Arc::new(ListWorkerSessionsTool {
|
||||
provider: provider.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn overview_definition(provider: Arc<dyn WorkerObservationProvider>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(ViewSessionOverviewParams))
|
||||
@@ -402,13 +441,6 @@ fn read_definition(provider: Arc<dyn WorkerObservationProvider>) -> ToolDefiniti
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ListWorkerSessionsParams {
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ViewSessionOverviewParams {
|
||||
@@ -454,43 +486,6 @@ fn default_read_mode() -> String {
|
||||
"compact".to_string()
|
||||
}
|
||||
|
||||
struct ListWorkerSessionsTool {
|
||||
provider: Arc<dyn WorkerObservationProvider>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ListWorkerSessionsTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_context: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: ListWorkerSessionsParams = parse_input("ListWorkerSessions", input_json)?;
|
||||
let limit = bounded_limit(params.limit);
|
||||
let mut subjects = self
|
||||
.provider
|
||||
.list_worker_sessions()
|
||||
.await
|
||||
.map_err(tool_error)?;
|
||||
subjects.truncate(limit);
|
||||
let sessions = subjects
|
||||
.iter()
|
||||
.map(|subject| {
|
||||
serde_json::json!({
|
||||
"subject": bounded_subject(&subject.subject),
|
||||
"display_name": truncate_text(&subject.display_name, 200),
|
||||
"relation": truncate_text(&subject.relation, 64),
|
||||
"status": truncate_text(&subject.status, 64),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
json_output(
|
||||
format!("Listed {} Worker session(s).", sessions.len()),
|
||||
serde_json::json!({ "sessions": sessions }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ViewSessionOverviewTool {
|
||||
provider: Arc<dyn WorkerObservationProvider>,
|
||||
}
|
||||
@@ -692,31 +687,6 @@ fn parse_tool_part(value: &str) -> Result<ToolPart, ToolError> {
|
||||
.ok_or_else(|| ToolError::InvalidArgument(format!("invalid tool_part {value:?}")))
|
||||
}
|
||||
|
||||
fn bounded_subject(subject: &WorkerObservationSubjectRef) -> WorkerObservationSubjectRef {
|
||||
match subject {
|
||||
WorkerObservationSubjectRef::RuntimeWorker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => WorkerObservationSubjectRef::RuntimeWorker {
|
||||
runtime_id: truncate_text(runtime_id, 200),
|
||||
worker_id: truncate_text(worker_id, 200),
|
||||
},
|
||||
WorkerObservationSubjectRef::SubWorker { name } => WorkerObservationSubjectRef::SubWorker {
|
||||
name: truncate_text(name, 200),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_text(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
value.to_string()
|
||||
} else {
|
||||
let mut truncated = value.chars().take(max_chars).collect::<String>();
|
||||
truncated.push('…');
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_limit(limit: Option<usize>) -> usize {
|
||||
limit.unwrap_or(DEFAULT_PAGE_LIMIT).clamp(1, MAX_PAGE_LIMIT)
|
||||
}
|
||||
@@ -801,7 +771,7 @@ mod tests {
|
||||
let catalog = crate::PromptCatalog::builtins_only().unwrap();
|
||||
let source = &catalog.projection().templates["common.worker_observation"];
|
||||
for token in [
|
||||
"ListWorkerSessions",
|
||||
"WorkerList",
|
||||
"ViewSessionOverview",
|
||||
"SearchSessionEntries",
|
||||
"ReadSessionEntry",
|
||||
@@ -812,7 +782,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_observation_installs_without_session_explore_or_memory_extract() {
|
||||
fn worker_observation_requires_worker_control_service() {
|
||||
let provider = Arc::new(FakeProvider {
|
||||
captures: Mutex::new(Vec::new()),
|
||||
});
|
||||
@@ -821,15 +791,15 @@ mod tests {
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(WorkerObservationFeature::new(provider))
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
assert!(report.reports[0].installed);
|
||||
assert!(!report.reports[0].installed);
|
||||
assert!(report.installed_tool_names().is_empty());
|
||||
let descriptor = WorkerObservationFeature::new(Arc::new(FakeProvider {
|
||||
captures: Mutex::new(Vec::new()),
|
||||
}))
|
||||
.descriptor();
|
||||
assert_eq!(
|
||||
report.installed_tool_names(),
|
||||
[
|
||||
"ListWorkerSessions",
|
||||
"ViewSessionOverview",
|
||||
"SearchSessionEntries",
|
||||
"ReadSessionEntry",
|
||||
]
|
||||
descriptor.requires_services[0].id,
|
||||
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -838,13 +808,6 @@ mod tests {
|
||||
let provider = Arc::new(FakeProvider {
|
||||
captures: Mutex::new(vec![message("u1", Role::User, "first")]),
|
||||
});
|
||||
let list = list_definition(provider.clone())().1;
|
||||
let listed = list
|
||||
.execute("{}", llm_engine::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(listed.content.unwrap().contains("granted"));
|
||||
|
||||
let read = read_definition(provider.clone())().1;
|
||||
let hidden = read
|
||||
.execute(
|
||||
|
||||
@@ -527,6 +527,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_capable_roles_classify_commits_by_change_type() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
assert!(catalog.projection.templates.contains_key("common.git"));
|
||||
let context = Value::from_serialize(serde_json::json!({
|
||||
"cwd": "/workspace",
|
||||
"date": "2026-08-16",
|
||||
"language": "match the user's language",
|
||||
"tool_capabilities": {
|
||||
"memory_any": false,
|
||||
"memory_mutation": false,
|
||||
"memory_query": false,
|
||||
"memory_read_document": false
|
||||
}
|
||||
}));
|
||||
|
||||
for prompt in ["default", "role.coder", "role.orchestrator"] {
|
||||
let rendered = catalog.render_name(prompt, context.clone()).unwrap();
|
||||
assert!(rendered.contains("use the change type as the subject prefix"));
|
||||
assert!(rendered.contains("A change made because review"));
|
||||
assert!(rendered.contains("Do not keep reusing a domain prefix"));
|
||||
assert!(rendered.contains("fix: scope merge request foreign key checks"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_render_resolves_catalog_root_dotted_includes() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
|
||||
@@ -132,7 +132,7 @@ mod tests {
|
||||
let request = ShutdownAfterIdleRequest::default();
|
||||
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
|
||||
|
||||
hook.observe_tool_result(&tool_result("TicketShow", false));
|
||||
hook.observe_tool_result(&tool_result("ShowTicket", false));
|
||||
|
||||
assert!(!request.is_requested());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#![cfg_attr(not(test), allow(dead_code, unused_imports))]
|
||||
|
||||
//! Parent-facing tools for in-process Internal SubWorker sessions.
|
||||
//!
|
||||
//! All four tools share the same parent-owned `SpawnedWorkerRegistry` of typed session handles.
|
||||
//! Legacy direct-child tool constructors are test-only; production exposes the
|
||||
//! registry through the unified `worker.control` service and Worker tools.
|
||||
//! There is no Runtime catalog lookup or child socket transport, so a Worker can operate only on
|
||||
//! its direct Internal children. The socket helper at the bottom remains solely for the legacy
|
||||
//! top-level Worker callback protocol and is not part of SubWorker communication.
|
||||
@@ -74,6 +77,7 @@ impl Tool for SubWorkerListTool {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn sub_worker_list_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SubWorkerListInput);
|
||||
@@ -132,6 +136,7 @@ impl Tool for SubWorkerSendTool {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn sub_worker_send_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SubWorkerSendInput);
|
||||
@@ -186,6 +191,7 @@ impl Tool for SubWorkerStopTool {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn sub_worker_stop_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(NameInput);
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::internal_worker::{
|
||||
use crate::prompt::catalog::PromptCatalog;
|
||||
use crate::spawn::registry::SpawnedWorkerRegistry;
|
||||
use crate::worker::{
|
||||
ReviewerAttemptContext, ReviewerChildWorkspaceClient, Worker, WorkerFilesystemAuthority,
|
||||
ReviewerChildWorkspaceClient, ReviewerContext, Worker, WorkerFilesystemAuthority,
|
||||
WorkspaceRequest, WorkspaceRequestMethod,
|
||||
};
|
||||
use protocol::Method;
|
||||
@@ -58,8 +58,8 @@ 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.
|
||||
/// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate.
|
||||
/// Review capability material is generated by the trusted spawn layer.
|
||||
#[serde(default)]
|
||||
review: Option<ReviewerHandoffInput>,
|
||||
}
|
||||
@@ -67,7 +67,6 @@ struct SubWorkerSpawnInput {
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct ReviewerHandoffInput {
|
||||
ticket_id: String,
|
||||
revision_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
@@ -337,9 +336,9 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro
|
||||
let Some(review) = &input.review else {
|
||||
return Ok(());
|
||||
};
|
||||
if review.ticket_id.trim().is_empty() || review.revision_id.trim().is_empty() {
|
||||
if review.ticket_id.trim().is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"reviewer handoff requires non-empty ticket_id and revision_id".to_string(),
|
||||
"reviewer handoff requires non-empty ticket_id".to_string(),
|
||||
));
|
||||
}
|
||||
if input.profile.as_deref() != Some("builtin:reviewer") {
|
||||
@@ -418,11 +417,9 @@ impl Tool for SubWorkerSpawnTool {
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
|
||||
})?;
|
||||
let reviewer_attempt = input.review.as_ref().map(|review| {
|
||||
let reviewer_capability = 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(),
|
||||
@@ -431,7 +428,7 @@ impl Tool for SubWorkerSpawnTool {
|
||||
)
|
||||
});
|
||||
let child_workspace_context =
|
||||
if let Some((ticket_id, revision_id, _, capability_token)) = &reviewer_attempt {
|
||||
if let Some((ticket_id, capability_token)) = &reviewer_capability {
|
||||
let workspace_id =
|
||||
self.workspace_context
|
||||
.workspace_id()
|
||||
@@ -450,9 +447,8 @@ impl Tool for SubWorkerSpawnTool {
|
||||
let child_client: Arc<dyn crate::worker::WorkspaceClient> =
|
||||
Arc::new(ReviewerChildWorkspaceClient::new(
|
||||
parent_client.clone(),
|
||||
ReviewerAttemptContext {
|
||||
ReviewerContext {
|
||||
ticket_id: ticket_id.clone(),
|
||||
revision_id: revision_id.clone(),
|
||||
},
|
||||
capability_token.clone(),
|
||||
));
|
||||
@@ -547,9 +543,9 @@ impl Tool for SubWorkerSpawnTool {
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((ticket_id, revision_id, attempt_id, capability_token)) = &reviewer_attempt {
|
||||
if let Some((ticket_id, capability_token)) = &reviewer_capability {
|
||||
let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("reviewer attempt lost Workspace identity".to_string())
|
||||
ToolError::ExecutionFailed("review capability lost Workspace identity".to_string())
|
||||
})?;
|
||||
let child_session_id = session.session_id_string();
|
||||
let child_registration = WorkspaceRequest::json(
|
||||
@@ -577,15 +573,13 @@ impl Tool for SubWorkerSpawnTool {
|
||||
)));
|
||||
}
|
||||
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",
|
||||
"/api/w/{}/tickets/{}/merge-request/review-capabilities",
|
||||
workspace_id.as_str(),
|
||||
ticket_id
|
||||
),
|
||||
@@ -596,12 +590,12 @@ impl Tool for SubWorkerSpawnTool {
|
||||
.client()
|
||||
.execute(request)
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!("register reviewer attempt: {error}"))
|
||||
ToolError::ExecutionFailed(format!("register review capability: {error}"))
|
||||
})?;
|
||||
if !response.is_success() {
|
||||
let _ = session.stop().await;
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"register reviewer attempt failed with status {}: {}",
|
||||
"register review capability failed with status {}: {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
@@ -1044,21 +1038,21 @@ mod tests {
|
||||
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"}
|
||||
"review":{"ticket_id":"T1"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(validate_reviewer_handoff(&valid).is_ok());
|
||||
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||
"name":"reviewer","task":"review","profile":"builtin:coder",
|
||||
"scope":[{"target":"/tmp/work","permission":"read"}],
|
||||
"review":{"ticket_id":"T1","revision_id":"V1"}
|
||||
"review":{"ticket_id":"T1"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
|
||||
let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||
"scope":[{"target":"/tmp/work","permission":"write"}],
|
||||
"review":{"ticket_id":"T1","revision_id":"V1"}
|
||||
"review":{"ticket_id":"T1"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(validate_reviewer_handoff(&writable).is_err());
|
||||
|
||||
@@ -238,30 +238,29 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
/// Trusted review-attempt context is injected by the Internal SubWorker spawn layer.
|
||||
/// Trusted review capability 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> {
|
||||
fn reviewer_context(&self) -> Option<&ReviewerContext> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReviewerAttemptContext {
|
||||
pub struct ReviewerContext {
|
||||
pub ticket_id: String,
|
||||
pub revision_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReviewerChildWorkspaceClient {
|
||||
inner: Arc<dyn WorkspaceClient>,
|
||||
context: ReviewerAttemptContext,
|
||||
context: ReviewerContext,
|
||||
capability_token: String,
|
||||
}
|
||||
|
||||
impl ReviewerChildWorkspaceClient {
|
||||
pub fn new(
|
||||
inner: Arc<dyn WorkspaceClient>,
|
||||
context: ReviewerAttemptContext,
|
||||
context: ReviewerContext,
|
||||
capability_token: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -282,7 +281,7 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
||||
fn is_available(&self) -> bool {
|
||||
self.inner.is_available()
|
||||
}
|
||||
fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> {
|
||||
fn reviewer_context(&self) -> Option<&ReviewerContext> {
|
||||
Some(&self.context)
|
||||
}
|
||||
|
||||
@@ -306,10 +305,6 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
||||
"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()),
|
||||
@@ -450,9 +445,8 @@ mod reviewer_client_tests {
|
||||
});
|
||||
let client = ReviewerChildWorkspaceClient::new(
|
||||
inner,
|
||||
ReviewerAttemptContext {
|
||||
ReviewerContext {
|
||||
ticket_id: "T1".into(),
|
||||
revision_id: "V1".into(),
|
||||
},
|
||||
"secret".into(),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -342,6 +342,12 @@ pub struct WorkerTicketAssignmentRequest {
|
||||
pub(crate) fn worker_spawn_idempotency(
|
||||
request: &WorkerSpawnRequest,
|
||||
) -> Result<Option<(String, String)>, String> {
|
||||
if let Some(operation) = request.resolved_control_operation.as_ref() {
|
||||
return Ok(Some((
|
||||
operation.operation_id.clone(),
|
||||
operation.input_fingerprint.clone(),
|
||||
)));
|
||||
}
|
||||
let Some(assignment) = request.ticket_assignment.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -353,6 +359,12 @@ pub(crate) fn worker_spawn_idempotency(
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerControlOperation {
|
||||
pub operation_id: String,
|
||||
pub input_fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerSpawnRequest {
|
||||
@@ -384,6 +396,9 @@ pub struct WorkerSpawnRequest {
|
||||
/// Backend-authored peer-session grants. Browser/model input cannot set this field.
|
||||
#[serde(skip, default)]
|
||||
pub resolved_worker_observation_grants: Vec<worker_runtime::identity::RuntimeWorkerRef>,
|
||||
/// Trusted Backend operation identity used to make Worker-owned spawns replay-safe.
|
||||
#[serde(skip, default)]
|
||||
pub resolved_control_operation: Option<WorkerControlOperation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -4801,10 +4816,28 @@ mod tests {
|
||||
resolved_config_bundle: None,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_control_operation_is_runtime_spawn_idempotency_authority() {
|
||||
let mut request = embedded_spawn_request();
|
||||
request.resolved_control_operation = Some(WorkerControlOperation {
|
||||
operation_id: "control-op-1".to_string(),
|
||||
input_fingerprint: "sha256:control-input".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
worker_spawn_idempotency(&request).unwrap(),
|
||||
Some((
|
||||
"control-op-1".to_string(),
|
||||
"sha256:control-input".to_string(),
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_config_bundle_ref_preserves_bundle_identity() {
|
||||
let mut request = embedded_spawn_request();
|
||||
@@ -5016,6 +5049,7 @@ mod tests {
|
||||
resolved_config_bundle: None,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
},
|
||||
)
|
||||
@@ -5113,6 +5147,7 @@ mod tests {
|
||||
resolved_config_bundle: None,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
},
|
||||
)
|
||||
@@ -5149,6 +5184,7 @@ mod tests {
|
||||
resolved_config_bundle: None,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -94,10 +94,6 @@ 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"
|
||||
)]
|
||||
|
||||
@@ -49,9 +49,11 @@ pub struct TicketDetail {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub readiness: Option<String>,
|
||||
pub priority: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub item_revision: String,
|
||||
pub queued_by: Option<String>,
|
||||
pub queued_at: Option<String>,
|
||||
pub assignee: Option<String>,
|
||||
@@ -62,9 +64,15 @@ pub struct TicketDetail {
|
||||
pub body_truncated: bool,
|
||||
pub event_count: usize,
|
||||
pub events: Vec<TicketEventDetail>,
|
||||
pub event_page: QueryPage,
|
||||
pub artifact_count: usize,
|
||||
pub artifacts: Vec<String>,
|
||||
pub relations: TicketRelationView,
|
||||
pub linked_objectives: Vec<ObjectiveLinkSummary>,
|
||||
pub implementation_reports: Vec<TicketEvidenceEvent>,
|
||||
pub current_assignment: Option<TicketAssignmentSummary>,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub resolution: Option<String>,
|
||||
pub record_source: String,
|
||||
}
|
||||
@@ -73,6 +81,7 @@ pub struct TicketDetail {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketEventDetail {
|
||||
pub sequence: usize,
|
||||
pub event_ref: String,
|
||||
pub kind: String,
|
||||
pub author: Option<String>,
|
||||
pub at: Option<String>,
|
||||
@@ -83,6 +92,8 @@ pub struct TicketEventDetail {
|
||||
pub state_field: Option<String>,
|
||||
pub heading: Option<String>,
|
||||
pub body: Option<String>,
|
||||
pub attributes: std::collections::BTreeMap<String, String>,
|
||||
pub references: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -185,11 +196,196 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct QueryPage {
|
||||
pub limit: usize,
|
||||
pub returned: usize,
|
||||
pub has_more: bool,
|
||||
pub next_cursor: Option<String>,
|
||||
pub sort: String,
|
||||
pub source_limit: Option<usize>,
|
||||
pub source_truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct ObjectiveLinkSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketEvidenceEvent {
|
||||
pub event_ref: String,
|
||||
pub sequence: usize,
|
||||
pub kind: String,
|
||||
pub at: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub excerpt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketAssignmentSummary {
|
||||
pub assignment_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketMergeRequestSummary {
|
||||
pub merge_request_id: String,
|
||||
pub state: String,
|
||||
pub review_status: String,
|
||||
pub selector_from: Option<String>,
|
||||
pub selector_to: String,
|
||||
pub updated_at: String,
|
||||
pub review_subject_ref: Option<String>,
|
||||
pub review_submitted_at: Option<String>,
|
||||
pub review_excerpt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketEvidenceSummary {
|
||||
pub has_implementation_report: bool,
|
||||
pub implementation_report_after_rescope: bool,
|
||||
pub has_merge_request: bool,
|
||||
pub has_commit: bool,
|
||||
pub review_status: Option<String>,
|
||||
pub approved: bool,
|
||||
pub unresolved_request_changes: bool,
|
||||
pub complete_for_integration: bool,
|
||||
pub missing: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketQueryRequest {
|
||||
pub query: Option<String>,
|
||||
#[serde(default)]
|
||||
pub states: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub event_kinds: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub evidence: Vec<String>,
|
||||
pub review_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub attention: Vec<String>,
|
||||
pub related_ticket_id: Option<String>,
|
||||
pub relation_kind: Option<String>,
|
||||
pub linked_objective_id: Option<String>,
|
||||
pub updated_after: Option<String>,
|
||||
pub updated_before: Option<String>,
|
||||
pub sort: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketQueryItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub readiness: Option<String>,
|
||||
pub priority: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub item_revision: String,
|
||||
pub workspace_action_priority: String,
|
||||
pub matched_fields: Vec<String>,
|
||||
pub snippet: Option<String>,
|
||||
pub matching_event: Option<TicketEvidenceEvent>,
|
||||
pub linked_objective_ids: Vec<String>,
|
||||
pub relation_count: usize,
|
||||
pub blocker_count: usize,
|
||||
pub unresolved_blocker_count: usize,
|
||||
pub unresolved_review_count: usize,
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketQueryResponse {
|
||||
pub items: Vec<TicketQueryItem>,
|
||||
pub page: QueryPage,
|
||||
pub record_authority: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketShowRequest {
|
||||
pub event_limit: Option<usize>,
|
||||
pub event_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct ObjectiveQueryRequest {
|
||||
pub query: Option<String>,
|
||||
#[serde(default)]
|
||||
pub states: Vec<String>,
|
||||
pub linked_ticket_id: Option<String>,
|
||||
pub updated_after: Option<String>,
|
||||
pub updated_before: Option<String>,
|
||||
pub sort: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveQueryItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub matched_fields: Vec<String>,
|
||||
pub snippet: Option<String>,
|
||||
pub linked_ticket_count: usize,
|
||||
pub linked_tickets: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveQueryResponse {
|
||||
pub items: Vec<ObjectiveQueryItem>,
|
||||
pub page: QueryPage,
|
||||
pub record_authority: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct ObjectiveShowRequest {
|
||||
pub event_limit: Option<usize>,
|
||||
pub event_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct ObjectiveEventDetail {
|
||||
pub event_ref: String,
|
||||
pub kind: String,
|
||||
pub body: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveLinkedTicketSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub summary: String,
|
||||
pub linked_tickets: Vec<String>,
|
||||
@@ -201,12 +397,16 @@ pub struct ObjectiveDetail {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub revision: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub linked_ticket_summaries: Vec<ObjectiveLinkedTicketSummary>,
|
||||
pub resources: Vec<ObjectiveResourceSummary>,
|
||||
pub body: String,
|
||||
pub body_truncated: bool,
|
||||
pub events: Vec<ObjectiveEventDetail>,
|
||||
pub event_page: QueryPage,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
@@ -227,7 +427,17 @@ pub fn ticket_api_typescript() -> String {
|
||||
InvalidProjectRecord::decl(&config),
|
||||
TicketSummary::decl(&config),
|
||||
TicketListResponse::decl(&config),
|
||||
QueryPage::decl(&config),
|
||||
TicketEventDetail::decl(&config),
|
||||
ObjectiveLinkSummary::decl(&config),
|
||||
TicketEvidenceEvent::decl(&config),
|
||||
TicketAssignmentSummary::decl(&config),
|
||||
TicketMergeRequestSummary::decl(&config),
|
||||
TicketEvidenceSummary::decl(&config),
|
||||
TicketQueryRequest::decl(&config),
|
||||
TicketQueryItem::decl(&config),
|
||||
TicketQueryResponse::decl(&config),
|
||||
TicketShowRequest::decl(&config),
|
||||
TicketRelation::decl(&config),
|
||||
DerivedTicketRelation::decl(&config),
|
||||
TicketRelationBlocker::decl(&config),
|
||||
|
||||
@@ -83,10 +83,52 @@ pub struct GitCommitSummary {
|
||||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MergeTargetObservation {
|
||||
pub selector: RepositorySelector,
|
||||
pub commit: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommitObservation {
|
||||
pub commit: String,
|
||||
pub parents: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RepositoryLookupError {
|
||||
UnknownRepository { id: RepositoryId },
|
||||
UnsupportedProvider { id: RepositoryId, provider: String },
|
||||
UnknownRepository {
|
||||
id: RepositoryId,
|
||||
},
|
||||
UnsupportedProvider {
|
||||
id: RepositoryId,
|
||||
provider: String,
|
||||
},
|
||||
MissingDefaultSelector {
|
||||
id: RepositoryId,
|
||||
},
|
||||
InvalidSelector {
|
||||
id: RepositoryId,
|
||||
selector: String,
|
||||
},
|
||||
CommitNotFound {
|
||||
id: RepositoryId,
|
||||
commit: String,
|
||||
},
|
||||
InvalidCommitRelation {
|
||||
id: RepositoryId,
|
||||
detail: String,
|
||||
},
|
||||
TargetMoved {
|
||||
id: RepositoryId,
|
||||
selector: String,
|
||||
expected: String,
|
||||
observed: Option<String>,
|
||||
},
|
||||
ProviderFailure {
|
||||
id: RepositoryId,
|
||||
operation: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -167,6 +209,155 @@ impl RepositoryRegistryReader {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn observe_merge_target(
|
||||
&self,
|
||||
id: &str,
|
||||
requested_selector: Option<&str>,
|
||||
) -> Result<MergeTargetObservation, RepositoryLookupError> {
|
||||
let repository = self.merge_repository(id)?;
|
||||
let selector = requested_selector
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned)
|
||||
.or_else(|| repository.default_selector.clone())
|
||||
.ok_or_else(|| RepositoryLookupError::MissingDefaultSelector { id: id.to_string() })?;
|
||||
let selector = normalize_target_branch_selector(id, &selector)?;
|
||||
let spec = format!("{selector}^{{commit}}");
|
||||
let commit = merge_git_stdout(
|
||||
repository,
|
||||
"resolve target",
|
||||
&["rev-parse", "--verify", "--end-of-options", &spec],
|
||||
)?
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_owned();
|
||||
if commit.is_empty() {
|
||||
return Err(RepositoryLookupError::InvalidSelector {
|
||||
id: id.to_string(),
|
||||
selector,
|
||||
});
|
||||
}
|
||||
Ok(MergeTargetObservation { selector, commit })
|
||||
}
|
||||
|
||||
pub fn observe_commit(
|
||||
&self,
|
||||
id: &str,
|
||||
commit: &str,
|
||||
) -> Result<CommitObservation, RepositoryLookupError> {
|
||||
let repository = self.merge_repository(id)?;
|
||||
let commit = commit.trim();
|
||||
if commit.is_empty() || commit.starts_with('-') {
|
||||
return Err(RepositoryLookupError::CommitNotFound {
|
||||
id: id.to_string(),
|
||||
commit: commit.into(),
|
||||
});
|
||||
}
|
||||
let line = merge_git_stdout(
|
||||
repository,
|
||||
"read commit",
|
||||
&[
|
||||
"show",
|
||||
"--no-patch",
|
||||
"--format=%H %P",
|
||||
"--end-of-options",
|
||||
commit,
|
||||
],
|
||||
)?;
|
||||
let mut parts = line.split_whitespace();
|
||||
let canonical = parts.next().unwrap_or_default().to_owned();
|
||||
if canonical.is_empty() {
|
||||
return Err(RepositoryLookupError::CommitNotFound {
|
||||
id: id.to_string(),
|
||||
commit: commit.into(),
|
||||
});
|
||||
}
|
||||
Ok(CommitObservation {
|
||||
commit: canonical,
|
||||
parents: parts.map(str::to_owned).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ensure_ancestor(
|
||||
&self,
|
||||
id: &str,
|
||||
ancestor: &str,
|
||||
descendant: &str,
|
||||
) -> Result<(), RepositoryLookupError> {
|
||||
let repository = self.merge_repository(id)?;
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repository.path)
|
||||
.args(["merge-base", "--is-ancestor", ancestor, descendant])
|
||||
.status()
|
||||
.map_err(|_| RepositoryLookupError::ProviderFailure {
|
||||
id: id.into(),
|
||||
operation: "check commit ancestry".into(),
|
||||
})?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RepositoryLookupError::InvalidCommitRelation {
|
||||
id: id.into(),
|
||||
detail: format!("commit {ancestor} is not an ancestor of {descendant}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_merge_target(
|
||||
&self,
|
||||
id: &str,
|
||||
selector: &str,
|
||||
expected_target: &str,
|
||||
result_commit: &str,
|
||||
) -> Result<(), RepositoryLookupError> {
|
||||
let repository = self.merge_repository(id)?;
|
||||
let target_ref = normalize_target_branch_selector(id, selector)?;
|
||||
self.observe_commit(id, result_commit)?;
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repository.path)
|
||||
.args([
|
||||
"update-ref",
|
||||
target_ref.as_str(),
|
||||
result_commit,
|
||||
expected_target,
|
||||
])
|
||||
.status()
|
||||
.map_err(|_| RepositoryLookupError::ProviderFailure {
|
||||
id: id.into(),
|
||||
operation: "guarded target update".into(),
|
||||
})?;
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let observed = self
|
||||
.observe_merge_target(id, Some(selector))
|
||||
.ok()
|
||||
.map(|target| target.commit);
|
||||
Err(RepositoryLookupError::TargetMoved {
|
||||
id: id.into(),
|
||||
selector: selector.into(),
|
||||
expected: expected_target.into(),
|
||||
observed,
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_repository(&self, id: &str) -> Result<&ConfiguredRepository, RepositoryLookupError> {
|
||||
let repository = self
|
||||
.find(id)
|
||||
.ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.into() })?;
|
||||
if repository.provider != "git" {
|
||||
return Err(RepositoryLookupError::UnsupportedProvider {
|
||||
id: id.into(),
|
||||
provider: repository.provider.clone(),
|
||||
});
|
||||
}
|
||||
Ok(repository)
|
||||
}
|
||||
|
||||
fn find(&self, id: &str) -> Option<&ConfiguredRepository> {
|
||||
self.repositories
|
||||
.iter()
|
||||
@@ -256,6 +447,50 @@ impl RepositoryRegistryReader {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_target_branch_selector(
|
||||
id: &str,
|
||||
selector: &str,
|
||||
) -> Result<String, RepositoryLookupError> {
|
||||
let selector = selector.trim();
|
||||
let target_ref = if selector.starts_with("refs/heads/") {
|
||||
selector.to_owned()
|
||||
} else if selector.starts_with("refs/") {
|
||||
return Err(RepositoryLookupError::InvalidSelector {
|
||||
id: id.into(),
|
||||
selector: selector.into(),
|
||||
});
|
||||
} else {
|
||||
format!("refs/heads/{selector}")
|
||||
};
|
||||
let valid_ref = Command::new("git")
|
||||
.args(["check-ref-format", target_ref.as_str()])
|
||||
.status()
|
||||
.map_err(|_| RepositoryLookupError::ProviderFailure {
|
||||
id: id.into(),
|
||||
operation: "validate target branch ref".into(),
|
||||
})?;
|
||||
if !valid_ref.success() || selector.starts_with('-') || selector.as_bytes().contains(&0) {
|
||||
return Err(RepositoryLookupError::InvalidSelector {
|
||||
id: id.into(),
|
||||
selector: selector.into(),
|
||||
});
|
||||
}
|
||||
Ok(target_ref)
|
||||
}
|
||||
|
||||
fn merge_git_stdout(
|
||||
repository: &ConfiguredRepository,
|
||||
operation: &str,
|
||||
args: &[&str],
|
||||
) -> Result<String, RepositoryLookupError> {
|
||||
git_stdout(&repository.path, args.iter().copied()).map_err(|_| {
|
||||
RepositoryLookupError::ProviderFailure {
|
||||
id: repository.id.clone(),
|
||||
operation: operation.into(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn git_stdout<'a, I>(repository_path: &PathBuf, args: I) -> Result<String, String>
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
@@ -422,6 +657,124 @@ mod tests {
|
||||
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_evidence_is_resolved_by_repository_identity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path();
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(["init", "-b", "main"])
|
||||
.arg(path)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
for args in [
|
||||
vec!["config", "user.email", "test@example.com"],
|
||||
vec!["config", "user.name", "Test"],
|
||||
] {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(args)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
}
|
||||
std::fs::write(path.join("file.txt"), "base\n").unwrap();
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["add", "file.txt"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["commit", "-m", "base"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let base = git_stdout(&path.to_path_buf(), ["rev-parse", "HEAD"])
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["checkout", "-b", "feature"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
std::fs::write(path.join("file.txt"), "base\nfeature\n").unwrap();
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["commit", "-am", "feature"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let source = git_stdout(&path.to_path_buf(), ["rev-parse", "HEAD"])
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["update-ref", "refs/tags/main", &source])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
|
||||
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
|
||||
id: "main".into(),
|
||||
display_name: Some("Main".into()),
|
||||
provider: "git".into(),
|
||||
path: path.to_path_buf(),
|
||||
uri: path.display().to_string(),
|
||||
default_selector: Some("main".into()),
|
||||
}]);
|
||||
let target = reader.observe_merge_target("main", Some("main")).unwrap();
|
||||
assert_eq!(target.selector, "refs/heads/main");
|
||||
assert_eq!(target.commit, base);
|
||||
assert_eq!(
|
||||
reader.observe_commit("main", &source).unwrap().parents,
|
||||
vec![base.clone()]
|
||||
);
|
||||
reader.ensure_ancestor("main", &base, &source).unwrap();
|
||||
reader
|
||||
.update_merge_target("main", "main", &base, &source)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reader
|
||||
.observe_merge_target("main", Some("refs/heads/main"))
|
||||
.unwrap()
|
||||
.commit,
|
||||
source
|
||||
);
|
||||
assert!(matches!(
|
||||
reader.update_merge_target("main", "refs/heads/main", &base, &base),
|
||||
Err(RepositoryLookupError::TargetMoved { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
reader.ensure_ancestor("main", &source, &base),
|
||||
Err(RepositoryLookupError::InvalidCommitRelation { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_repository_is_not_resolved_from_fallback() {
|
||||
let reader = RepositoryRegistryReader::new(Vec::new());
|
||||
|
||||
+1513
-546
File diff suppressed because it is too large
Load Diff
@@ -181,6 +181,21 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "persist Workspace config schema contribution bundles",
|
||||
apply: persist_workspace_config_schema_bundles,
|
||||
},
|
||||
Migration {
|
||||
version: 33,
|
||||
name: "create durable Runtime Worker control grants",
|
||||
apply: create_worker_control_grant_authority,
|
||||
},
|
||||
Migration {
|
||||
version: 34,
|
||||
name: "create Worker control delegation operation authority",
|
||||
apply: create_worker_control_delegation_operation_authority,
|
||||
},
|
||||
Migration {
|
||||
version: 35,
|
||||
name: "remove Worker control delegation authority",
|
||||
apply: remove_worker_control_delegation_authority,
|
||||
},
|
||||
];
|
||||
|
||||
struct Migration {
|
||||
@@ -325,6 +340,23 @@ pub struct WorkerRegistryRecord {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Durable authority describing which Runtime Worker another Runtime Worker may
|
||||
/// discover and control. Revoked grants remain as audit evidence but are never
|
||||
/// returned by active-grant queries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerControlGrantRecord {
|
||||
pub workspace_id: String,
|
||||
pub grant_id: String,
|
||||
pub controller: RuntimeWorkerRef,
|
||||
pub subject: RuntimeWorkerRef,
|
||||
pub relation: String,
|
||||
pub origin: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub operation_id: String,
|
||||
pub created_at: String,
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TicketWorkerAssignmentRecord {
|
||||
pub workspace_id: String,
|
||||
@@ -594,6 +626,12 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||
|
||||
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>;
|
||||
fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result<Vec<ObjectiveRecord>>;
|
||||
fn list_objectives_for_ticket(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ObjectiveRecord>>;
|
||||
fn get_objective(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -732,6 +770,39 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||
fn delete_worker_registry(&self, workspace_id: &str, worker: &RuntimeWorkerRef)
|
||||
-> Result<bool>;
|
||||
|
||||
fn create_worker_control_grant(
|
||||
&self,
|
||||
record: &WorkerControlGrantRecord,
|
||||
) -> Result<WorkerControlGrantRecord>;
|
||||
fn get_worker_control_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
) -> Result<Option<WorkerControlGrantRecord>>;
|
||||
fn get_worker_control_grant_by_operation(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
controller: &RuntimeWorkerRef,
|
||||
operation_id: &str,
|
||||
) -> Result<Option<WorkerControlGrantRecord>>;
|
||||
fn get_active_worker_control_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
controller: &RuntimeWorkerRef,
|
||||
subject: &RuntimeWorkerRef,
|
||||
) -> Result<Option<WorkerControlGrantRecord>>;
|
||||
fn list_active_worker_control_grants(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
controller: &RuntimeWorkerRef,
|
||||
limit: usize,
|
||||
) -> Result<Vec<WorkerControlGrantRecord>>;
|
||||
fn revoke_worker_control_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
revoked_at: &str,
|
||||
) -> Result<bool>;
|
||||
fn get_ticket_assignment_operation(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -1467,6 +1538,33 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn list_objectives_for_ticket(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ObjectiveRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT o.workspace_id, o.objective_id, o.title, o.state, o.body_md,
|
||||
o.created_at, o.updated_at
|
||||
FROM objectives AS o
|
||||
INNER JOIN objective_ticket_links AS l
|
||||
ON l.workspace_id = o.workspace_id
|
||||
AND l.objective_id = o.objective_id
|
||||
WHERE o.workspace_id = ?1 AND l.ticket_id = ?2
|
||||
ORDER BY o.updated_at DESC, o.objective_id ASC
|
||||
LIMIT ?3"#,
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![workspace_id, ticket_id, limit as i64],
|
||||
read_objective_record,
|
||||
)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_objective(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -2321,6 +2419,178 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_worker_control_grant(
|
||||
&self,
|
||||
record: &WorkerControlGrantRecord,
|
||||
) -> Result<WorkerControlGrantRecord> {
|
||||
self.with_conn(|conn| {
|
||||
let permissions_json = serde_json::to_string(&record.permissions)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
conn.execute(
|
||||
r#"INSERT INTO worker_control_grants (
|
||||
workspace_id, grant_id,
|
||||
controller_runtime_id, controller_worker_id,
|
||||
subject_runtime_id, subject_worker_id,
|
||||
relation, origin, permissions_json, operation_id, created_at, revoked_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
|
||||
ON CONFLICT (
|
||||
workspace_id,
|
||||
controller_runtime_id,
|
||||
controller_worker_id,
|
||||
operation_id
|
||||
) DO NOTHING"#,
|
||||
params![
|
||||
record.workspace_id,
|
||||
record.grant_id,
|
||||
record.controller.runtime_id,
|
||||
record.controller.worker_id,
|
||||
record.subject.runtime_id,
|
||||
record.subject.worker_id,
|
||||
record.relation,
|
||||
record.origin,
|
||||
permissions_json,
|
||||
record.operation_id,
|
||||
record.created_at,
|
||||
record.revoked_at,
|
||||
],
|
||||
)?;
|
||||
|
||||
let persisted = read_worker_control_grant_by_operation(
|
||||
conn,
|
||||
record.workspace_id.as_str(),
|
||||
&record.controller,
|
||||
record.operation_id.as_str(),
|
||||
)?
|
||||
.ok_or_else(|| Error::Store("worker control grant was not persisted".to_string()))?;
|
||||
if persisted.subject != record.subject
|
||||
|| persisted.relation != record.relation
|
||||
|| persisted.origin != record.origin
|
||||
|| persisted.permissions != record.permissions
|
||||
{
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"worker control operation `{}` was already used with different input",
|
||||
record.operation_id
|
||||
)));
|
||||
}
|
||||
Ok(persisted)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_worker_control_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
) -> Result<Option<WorkerControlGrantRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
r#"SELECT workspace_id, grant_id,
|
||||
controller_runtime_id, controller_worker_id,
|
||||
subject_runtime_id, subject_worker_id,
|
||||
relation, origin, permissions_json, operation_id, created_at, revoked_at
|
||||
FROM worker_control_grants
|
||||
WHERE workspace_id = ?1 AND grant_id = ?2"#,
|
||||
params![workspace_id, grant_id],
|
||||
read_worker_control_grant_record,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_worker_control_grant_by_operation(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
controller: &RuntimeWorkerRef,
|
||||
operation_id: &str,
|
||||
) -> Result<Option<WorkerControlGrantRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
read_worker_control_grant_by_operation(conn, workspace_id, controller, operation_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_active_worker_control_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
controller: &RuntimeWorkerRef,
|
||||
subject: &RuntimeWorkerRef,
|
||||
) -> Result<Option<WorkerControlGrantRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
r#"SELECT workspace_id, grant_id,
|
||||
controller_runtime_id, controller_worker_id,
|
||||
subject_runtime_id, subject_worker_id,
|
||||
relation, origin, permissions_json, operation_id, created_at, revoked_at
|
||||
FROM worker_control_grants
|
||||
WHERE workspace_id = ?1
|
||||
AND controller_runtime_id = ?2 AND controller_worker_id = ?3
|
||||
AND subject_runtime_id = ?4 AND subject_worker_id = ?5
|
||||
AND revoked_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1"#,
|
||||
params![
|
||||
workspace_id,
|
||||
controller.runtime_id,
|
||||
controller.worker_id,
|
||||
subject.runtime_id,
|
||||
subject.worker_id,
|
||||
],
|
||||
read_worker_control_grant_record,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn list_active_worker_control_grants(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
controller: &RuntimeWorkerRef,
|
||||
limit: usize,
|
||||
) -> Result<Vec<WorkerControlGrantRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT workspace_id, grant_id,
|
||||
controller_runtime_id, controller_worker_id,
|
||||
subject_runtime_id, subject_worker_id,
|
||||
relation, origin, permissions_json, operation_id, created_at, revoked_at
|
||||
FROM worker_control_grants
|
||||
WHERE workspace_id = ?1
|
||||
AND controller_runtime_id = ?2 AND controller_worker_id = ?3
|
||||
AND revoked_at IS NULL
|
||||
ORDER BY created_at ASC, grant_id ASC
|
||||
LIMIT ?4"#,
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![
|
||||
workspace_id,
|
||||
controller.runtime_id,
|
||||
controller.worker_id,
|
||||
limit as i64,
|
||||
],
|
||||
read_worker_control_grant_record,
|
||||
)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn revoke_worker_control_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
revoked_at: &str,
|
||||
) -> Result<bool> {
|
||||
self.with_conn(|conn| {
|
||||
let changed = conn.execute(
|
||||
r#"UPDATE worker_control_grants
|
||||
SET revoked_at = ?3
|
||||
WHERE workspace_id = ?1 AND grant_id = ?2 AND revoked_at IS NULL"#,
|
||||
params![workspace_id, grant_id, revoked_at],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_ticket_assignment_operation(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -3627,6 +3897,57 @@ fn read_worker_registry_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<Work
|
||||
})
|
||||
}
|
||||
|
||||
fn read_worker_control_grant_record(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<WorkerControlGrantRecord> {
|
||||
let permissions_json: String = row.get(8)?;
|
||||
let permissions = serde_json::from_str(&permissions_json).map_err(|error| {
|
||||
rusqlite::Error::FromSqlConversionFailure(8, rusqlite::types::Type::Text, Box::new(error))
|
||||
})?;
|
||||
Ok(WorkerControlGrantRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
grant_id: row.get(1)?,
|
||||
controller: RuntimeWorkerRef::new(
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, u64>(3)?.to_string(),
|
||||
),
|
||||
subject: RuntimeWorkerRef::new(row.get::<_, String>(4)?, row.get::<_, u64>(5)?.to_string()),
|
||||
relation: row.get(6)?,
|
||||
origin: row.get(7)?,
|
||||
permissions,
|
||||
operation_id: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
revoked_at: row.get(11)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_worker_control_grant_by_operation(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
controller: &RuntimeWorkerRef,
|
||||
operation_id: &str,
|
||||
) -> Result<Option<WorkerControlGrantRecord>> {
|
||||
conn.query_row(
|
||||
r#"SELECT workspace_id, grant_id,
|
||||
controller_runtime_id, controller_worker_id,
|
||||
subject_runtime_id, subject_worker_id,
|
||||
relation, origin, permissions_json, operation_id, created_at, revoked_at
|
||||
FROM worker_control_grants
|
||||
WHERE workspace_id = ?1
|
||||
AND controller_runtime_id = ?2 AND controller_worker_id = ?3
|
||||
AND operation_id = ?4"#,
|
||||
params![
|
||||
workspace_id,
|
||||
controller.runtime_id,
|
||||
controller.worker_id,
|
||||
operation_id,
|
||||
],
|
||||
read_worker_control_grant_record,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
fn current_ticket_worker_assignment_select_sql() -> String {
|
||||
"SELECT a.workspace_id, a.ticket_id, a.assignment_id, a.runtime_id, a.worker_id, \
|
||||
a.assigned_by, a.assigned_at \
|
||||
@@ -4639,6 +4960,144 @@ pub(crate) fn persist_workspace_config_schema_bundles(conn: &Connection) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_worker_control_grant_authority(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE worker_control_grants (
|
||||
workspace_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
controller_runtime_id TEXT NOT NULL,
|
||||
controller_worker_id INTEGER NOT NULL,
|
||||
subject_runtime_id TEXT NOT NULL,
|
||||
subject_worker_id INTEGER NOT NULL,
|
||||
relation TEXT NOT NULL,
|
||||
origin TEXT NOT NULL,
|
||||
permissions_json TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
PRIMARY KEY (workspace_id, grant_id),
|
||||
UNIQUE (
|
||||
workspace_id,
|
||||
controller_runtime_id,
|
||||
controller_worker_id,
|
||||
operation_id
|
||||
),
|
||||
FOREIGN KEY (workspace_id, controller_runtime_id, controller_worker_id)
|
||||
REFERENCES worker_registry (workspace_id, runtime_id, runtime_worker_id)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, subject_runtime_id, subject_worker_id)
|
||||
REFERENCES worker_registry (workspace_id, runtime_id, runtime_worker_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_worker_control_grants_controller_active
|
||||
ON worker_control_grants (
|
||||
workspace_id,
|
||||
controller_runtime_id,
|
||||
controller_worker_id,
|
||||
revoked_at,
|
||||
created_at
|
||||
);
|
||||
|
||||
CREATE INDEX idx_worker_control_grants_subject_active
|
||||
ON worker_control_grants (
|
||||
workspace_id,
|
||||
subject_runtime_id,
|
||||
subject_worker_id,
|
||||
revoked_at
|
||||
);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_worker_control_delegation_operation_authority(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE worker_control_delegation_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
source_controller_runtime_id TEXT NOT NULL,
|
||||
source_controller_worker_id INTEGER NOT NULL,
|
||||
source_grant_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
input_fingerprint TEXT NOT NULL,
|
||||
delegated_grant_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
PRIMARY KEY (
|
||||
workspace_id,
|
||||
source_controller_runtime_id,
|
||||
source_controller_worker_id,
|
||||
operation_id
|
||||
),
|
||||
FOREIGN KEY (workspace_id, source_controller_runtime_id, source_controller_worker_id)
|
||||
REFERENCES worker_registry (workspace_id, runtime_id, runtime_worker_id)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, source_grant_id)
|
||||
REFERENCES worker_control_grants (workspace_id, grant_id)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, delegated_grant_id)
|
||||
REFERENCES worker_control_grants (workspace_id, grant_id)
|
||||
ON DELETE SET NULL
|
||||
);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_worker_control_delegation_authority(conn: &Connection) -> Result<()> {
|
||||
let mut statement =
|
||||
conn.prepare("SELECT workspace_id, grant_id, permissions_json FROM worker_control_grants")?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
let mut permission_updates = Vec::new();
|
||||
for row in rows {
|
||||
let (workspace_id, grant_id, permissions_json) = row?;
|
||||
let mut permissions: Vec<String> =
|
||||
serde_json::from_str(&permissions_json).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"failed to decode Worker control grant `{grant_id}` permissions during delegation removal: {error}"
|
||||
))
|
||||
})?;
|
||||
let previous_len = permissions.len();
|
||||
permissions
|
||||
.retain(|permission| !matches!(permission.as_str(), "share" | "transfer" | "revoke"));
|
||||
if permissions.len() != previous_len {
|
||||
permission_updates.push((
|
||||
workspace_id,
|
||||
grant_id,
|
||||
serde_json::to_string(&permissions).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"failed to encode Worker control grant permissions during delegation removal: {error}"
|
||||
))
|
||||
})?,
|
||||
));
|
||||
}
|
||||
}
|
||||
drop(statement);
|
||||
|
||||
for (workspace_id, grant_id, permissions_json) in permission_updates {
|
||||
conn.execute(
|
||||
"UPDATE worker_control_grants SET permissions_json = ?3 WHERE workspace_id = ?1 AND grant_id = ?2",
|
||||
params![workspace_id, grant_id, permissions_json],
|
||||
)?;
|
||||
}
|
||||
conn.execute(
|
||||
r#"UPDATE worker_control_grants
|
||||
SET revoked_at = COALESCE(revoked_at, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
WHERE relation IN ('shared', 'transferred')"#,
|
||||
[],
|
||||
)?;
|
||||
conn.execute_batch("DROP TABLE IF EXISTS worker_control_delegation_operations;")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_worker_mutation_source_proof_replay_guard(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
@@ -5290,6 +5749,82 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v35_removes_worker_control_delegation_authority() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
for migration in MIGRATIONS
|
||||
.iter()
|
||||
.filter(|migration| migration.version <= 34)
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
(migration.apply)(&tx).unwrap();
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![migration.version, migration.name],
|
||||
)
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
}
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
INSERT INTO workspaces (
|
||||
workspace_id, display_name, state, created_at, updated_at
|
||||
) VALUES ('workspace-a', 'Workspace A', 'active', '1', '1');
|
||||
INSERT INTO worker_registry (
|
||||
workspace_id, runtime_id, runtime_worker_id, display_name,
|
||||
retention_state, created_at, updated_at
|
||||
) VALUES
|
||||
('workspace-a', 'runtime-a', 1, 'Controller', 'normal', '1', '1'),
|
||||
('workspace-a', 'runtime-a', 2, 'Spawned Worker', 'normal', '1', '1'),
|
||||
('workspace-a', 'runtime-a', 3, 'Shared Worker', 'normal', '1', '1'),
|
||||
('workspace-a', 'runtime-a', 4, 'Transferred Worker', 'normal', '1', '1');
|
||||
INSERT INTO worker_control_grants (
|
||||
workspace_id, grant_id,
|
||||
controller_runtime_id, controller_worker_id,
|
||||
subject_runtime_id, subject_worker_id,
|
||||
relation, origin, permissions_json, operation_id, created_at, revoked_at
|
||||
) VALUES
|
||||
('workspace-a', 'spawned', 'runtime-a', 1, 'runtime-a', 2,
|
||||
'spawned', 'spawn', '["observe","share","transfer","revoke","stop"]', 'spawn-op', '1', NULL),
|
||||
('workspace-a', 'shared', 'runtime-a', 1, 'runtime-a', 3,
|
||||
'shared', 'share', '["observe"]', 'share-op', '1', NULL),
|
||||
('workspace-a', 'transferred', 'runtime-a', 1, 'runtime-a', 4,
|
||||
'transferred', 'transfer', '["observe"]', 'transfer-op', '1', NULL);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 34);
|
||||
assert!(table_exists(&conn, "worker_control_delegation_operations").unwrap());
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 35);
|
||||
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
|
||||
let (permissions_json, revoked_at): (String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT permissions_json, revoked_at FROM worker_control_grants WHERE grant_id = 'spawned'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Vec<String>>(&permissions_json).unwrap(),
|
||||
vec!["observe", "stop"]
|
||||
);
|
||||
assert!(revoked_at.is_none());
|
||||
for grant_id in ["shared", "transferred"] {
|
||||
let revoked_at: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT revoked_at FROM worker_control_grants WHERE grant_id = ?1",
|
||||
[grant_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(revoked_at.is_some(), "{grant_id} grant remained active");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v24_adds_attachment_reservations_to_already_applied_v23() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
@@ -5312,7 +5847,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 32);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 35);
|
||||
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
|
||||
}
|
||||
|
||||
@@ -5345,7 +5880,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 32);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 35);
|
||||
assert!(table_exists(&conn, "flow_sources").unwrap());
|
||||
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
|
||||
assert!(!table_exists(&conn, "flow_instances").unwrap());
|
||||
@@ -5412,7 +5947,7 @@ INSERT INTO worker_workdir_attachment_reservations (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 32);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 35);
|
||||
let repositories_sql: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
|
||||
@@ -5592,7 +6127,7 @@ INSERT INTO workdir_registry (
|
||||
let db = dir.path().join("control-plane.sqlite");
|
||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
|
||||
assert_eq!(store.schema_version().await.unwrap(), 32);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 35);
|
||||
assert!(
|
||||
!store
|
||||
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
|
||||
@@ -5609,7 +6144,7 @@ INSERT INTO workdir_registry (
|
||||
store.upsert_workspace(&record).await.unwrap();
|
||||
|
||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 32);
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 35);
|
||||
assert_eq!(
|
||||
reopened.get_workspace("local-dev").await.unwrap(),
|
||||
Some(record)
|
||||
@@ -6156,7 +6691,7 @@ INSERT INTO workdir_registry (
|
||||
.unwrap();
|
||||
|
||||
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 32);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 35);
|
||||
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
@@ -6345,7 +6880,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn repository_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 32);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 35);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -6411,7 +6946,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn memory_authority_records_round_trip_and_close_staging() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 32);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 35);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -6671,10 +7206,138 @@ CREATE TABLE ticket_assignment_operations (
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_control_grants_are_idempotent_scoped_and_support_internal_invalidation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let database = dir.path().join("control-grants.db");
|
||||
let store = SqliteWorkspaceStore::open(&database).unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "workspace-control".to_string(),
|
||||
owner_account_id: None,
|
||||
display_name: "Control grants".to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "2026-07-27T00:00:00Z".to_string(),
|
||||
updated_at: "2026-07-27T00:00:00Z".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let worker_record = |worker_id: &str, display_name: &str| WorkerRegistryRecord {
|
||||
workspace_id: "workspace-control".to_string(),
|
||||
worker: RuntimeWorkerRef::new("runtime-a", worker_id),
|
||||
display_name: display_name.to_string(),
|
||||
profile: None,
|
||||
retention_state: "normal".to_string(),
|
||||
transcript_ref: None,
|
||||
session_ref: None,
|
||||
summary_ref: None,
|
||||
diagnostics_ref: None,
|
||||
created_at: "2026-07-27T00:00:00Z".to_string(),
|
||||
updated_at: "2026-07-27T00:00:00Z".to_string(),
|
||||
};
|
||||
let controller_record = worker_record("1", "Controller");
|
||||
let subject_record = worker_record("2", "Subject");
|
||||
store.upsert_worker_registry(&controller_record).unwrap();
|
||||
store.upsert_worker_registry(&subject_record).unwrap();
|
||||
|
||||
let grant = WorkerControlGrantRecord {
|
||||
workspace_id: "workspace-control".to_string(),
|
||||
grant_id: "grant-1".to_string(),
|
||||
controller: controller_record.worker.clone(),
|
||||
subject: subject_record.worker.clone(),
|
||||
relation: "spawned".to_string(),
|
||||
origin: "worker_spawn".to_string(),
|
||||
permissions: vec![
|
||||
"observe".to_string(),
|
||||
"send_input".to_string(),
|
||||
"stop".to_string(),
|
||||
],
|
||||
operation_id: "spawn-op-1".to_string(),
|
||||
created_at: "2026-07-27T00:00:01Z".to_string(),
|
||||
revoked_at: None,
|
||||
};
|
||||
assert_eq!(store.create_worker_control_grant(&grant).unwrap(), grant);
|
||||
assert_eq!(store.create_worker_control_grant(&grant).unwrap(), grant);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_active_worker_control_grants(
|
||||
"workspace-control",
|
||||
&controller_record.worker,
|
||||
10,
|
||||
)
|
||||
.unwrap(),
|
||||
vec![grant.clone()]
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.get_active_worker_control_grant(
|
||||
"workspace-control",
|
||||
&controller_record.worker,
|
||||
&subject_record.worker,
|
||||
)
|
||||
.unwrap(),
|
||||
Some(grant.clone())
|
||||
);
|
||||
|
||||
drop(store);
|
||||
let store = SqliteWorkspaceStore::open(&database).unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.list_active_worker_control_grants(
|
||||
"workspace-control",
|
||||
&controller_record.worker,
|
||||
10,
|
||||
)
|
||||
.unwrap(),
|
||||
vec![grant.clone()],
|
||||
"known Runtime Worker grants survive Backend restart"
|
||||
);
|
||||
|
||||
let conflicting_replay = WorkerControlGrantRecord {
|
||||
subject: controller_record.worker.clone(),
|
||||
..grant.clone()
|
||||
};
|
||||
assert!(matches!(
|
||||
store.create_worker_control_grant(&conflicting_replay),
|
||||
Err(Error::InvalidInput(_))
|
||||
));
|
||||
assert!(
|
||||
store
|
||||
.revoke_worker_control_grant(
|
||||
"workspace-control",
|
||||
&grant.grant_id,
|
||||
"2026-07-27T00:00:02Z",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.list_active_worker_control_grants(
|
||||
"workspace-control",
|
||||
&controller_record.worker,
|
||||
10,
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.delete_worker_registry("workspace-control", &subject_record.worker)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_worker_control_grant("workspace-control", &grant.grant_id)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"deleting a subject Worker cascades its durable control grants"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_and_login_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 32);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 35);
|
||||
let now = "2026-07-22T00:00:00Z".to_string();
|
||||
let account = AccountRecord {
|
||||
account_id: "acct-user-alice".to_string(),
|
||||
|
||||
@@ -34,15 +34,15 @@ Maintainers can inspect the local `.yoi/tickets/` files directly when debugging
|
||||
Workers with the Ticket built-in feature can use typed Ticket tools:
|
||||
|
||||
- `TicketCreate`
|
||||
- `TicketList` — lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority.
|
||||
- `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds.
|
||||
- `QueryTicket` — bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata.
|
||||
- `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence.
|
||||
- `TicketComment`
|
||||
- `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete`
|
||||
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input.
|
||||
- `TicketClose`
|
||||
- `TicketRelationRecord`
|
||||
- `TicketRelationQuery`
|
||||
- `TicketDoctor`
|
||||
|
||||
Profile-visible Ticket catalogs are intentionally smaller than the former broad read catalog: Workspace authoring exposes 9 tools instead of 13, workflow exposes 10 instead of 12, and review exposes only `QueryTicket` plus `ShowTicket` (2 instead of 6). The `QueryTicket` schema is regression-guarded below 8 KiB while consolidating relation/evidence/attention discovery; diagnostics are not projected into normal profiles, while specialized orchestration-plan commands remain visible only to workflow roles that need their distinct semantics.
|
||||
|
||||
These tools operate through the typed Ticket backend. They are not arbitrary filesystem write permission to `.yoi/tickets/`.
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Merge Request open requires an undiscoverable Ticket repository target
|
||||
|
||||
## Observed
|
||||
|
||||
While implementing Ticket `00001KZRNHB35`, the assigned Coder had a clean committed Workdir and all immutable base/head/changed-path evidence needed by `MergeRequestOpen`.
|
||||
|
||||
`MergeRequestOpen` rejected candidate repository ids with:
|
||||
|
||||
```text
|
||||
invalid input: Merge Request repository must match the authoritative Ticket target
|
||||
```
|
||||
|
||||
The typed `TicketShow` result available to the Coder rendered only the Ticket id and state; it did not expose the authoritative `repository_id` or ref selector. No typed repository/workdir lookup tool was available to the Coder. Continuing would therefore require guessing control-plane identity, mutating the Ticket target without evidence, or bypassing typed authority, all of which are correctly prohibited.
|
||||
|
||||
## Impact
|
||||
|
||||
A Coder can finish and validate implementation but cannot open the required immutable MR revision or start independent review. The failure is safe, but it strands otherwise review-ready work and provides no actionable expected target.
|
||||
|
||||
## Suggested improvement
|
||||
|
||||
At least one trusted read surface in the assigned-Coder flow should return the immutable Ticket target needed by `MergeRequestOpen`:
|
||||
|
||||
- include `repository_id` and `ref_selector` in `TicketShow`'s bounded authoritative projection; or
|
||||
- have `MergeRequestOpen` derive repository identity from the authoritative Ticket target and remove it from model input; or
|
||||
- return a bounded structured mismatch diagnostic containing the authoritative repository id when the caller is already authorized to read that Ticket.
|
||||
|
||||
Deriving the repository in `MergeRequestOpen` is preferable because it removes duplicated model-controlled identity and avoids target drift between Ticket read and MR creation.
|
||||
@@ -26,7 +26,7 @@ feature = {
|
||||
memory = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
image = { enabled = true; };
|
||||
sub_worker = { enabled = true; };
|
||||
sub_worker = { enabled = false; };
|
||||
worker = { enabled = false; };
|
||||
objective = { enabled = true; };
|
||||
ticket = { enabled = true; authoring = true; thread = true; };
|
||||
|
||||
@@ -10,7 +10,7 @@ import "./base.dcdl" // {
|
||||
web = { enabled = true; };
|
||||
sub_worker = { enabled = true; };
|
||||
flow = { enabled = true; };
|
||||
worker = { enabled = false; };
|
||||
worker = { enabled = true; };
|
||||
ticket = { enabled = true; thread = true; };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
let
|
||||
defaultDocument = import "./default.md";
|
||||
commonLanguage = import "./common/language.md";
|
||||
commonGit = import "./common/git.md";
|
||||
commonTickets = import "./common/tickets.md";
|
||||
commonToolUsage = import "./common/tool-usage.md";
|
||||
commonWorkerObservation = import "./common/worker-observation.md";
|
||||
@@ -33,6 +34,7 @@ in
|
||||
{
|
||||
default_prompt = defaultDocument.content;
|
||||
common = {
|
||||
git = commonGit.content;
|
||||
language = commonLanguage.content;
|
||||
tickets = commonTickets.content;
|
||||
tool_usage = commonToolUsage.content;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: "Git commit classification policy for implementation-capable Workers."
|
||||
---
|
||||
|
||||
## Git commit messages
|
||||
|
||||
This policy governs naming only and does not grant authority to commit or rewrite history.
|
||||
|
||||
When creating a commit, use the change type as the subject prefix, not the affected subsystem, feature, Ticket, crate, or domain. Use `feat:` for new behavior or capability, `fix:` for defect corrections, `refactor:` for behavior-preserving restructuring, `test:` for test-only changes, `docs:` for documentation-only changes, and `chore:` for maintenance that fits none of those types. Keep the subject concise and put the affected scope after the prefix, for example `fix: scope merge request foreign key checks`.
|
||||
|
||||
A change made because review, validation, or user feedback found a defect is a `fix:` even when it belongs to the same feature Ticket and has not been merged yet. Do not keep reusing a domain prefix such as `merge-request:`, `runtime:`, or `worker:` across a series; those labels identify where the code lives rather than why each commit exists. If one prospective commit contains distinct change types, split it into coherent validated commits when practical; otherwise name it for the dominant intent.
|
||||
|
||||
Before opening a Merge Request or requesting review, inspect the proposed commit subjects and correct misclassified local, unshared commits when safe. Do not rewrite shared history solely to rename existing commits unless the user explicitly requests it.
|
||||
@@ -1,9 +1,9 @@
|
||||
## Ticket workflow
|
||||
|
||||
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
|
||||
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and current Merge Request context before routing, review, or closure decisions. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
|
||||
|
||||
Read the relevant Ticket before making implementation, routing, review, state, or closure decisions. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work.
|
||||
|
||||
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements.
|
||||
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements.
|
||||
|
||||
Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish implementation completion from review and closure, and perform only lifecycle actions supported by the tools and authority available to the current Worker.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Worker-session tools are a read-only exploration surface over host-granted active Worker sessions.
|
||||
|
||||
- Use `ListWorkerSessions` to discover only the sessions already granted to you. Reuse the returned structured `subject` exactly: Runtime peers use `{ kind: "runtime_worker", runtime_id, worker_id }`, while parent-owned children use `{ kind: "sub_worker", name }`. Do not guess subject identifiers.
|
||||
- Use `WorkerList` to discover known Workers and reuse its returned structured `subject` exactly. Runtime Workers use `{ kind: "runtime_worker", runtime_id, worker_id }`, while parent-owned children use `{ kind: "sub_worker", name }`. Do not guess subject identifiers.
|
||||
- Use `ViewSessionOverview` for sparse orientation, `SearchSessionEntries` for bounded range/filter queries, and `ReadSessionEntry` for one bounded entry.
|
||||
- `SessionEntryRef` is the common entry identity across overview, search, reads, and evidence conversion. Reuse returned `E...` values; never invent them.
|
||||
- Every operation rereads the latest committed capture. Existing references remain stable when entries append.
|
||||
|
||||
@@ -2,6 +2,8 @@ You are here as an agent of the "yoi system".
|
||||
|
||||
Stay precise, edit code directly when asked, and avoid speculative refactoring.
|
||||
|
||||
{% include "common.git" %}
|
||||
|
||||
{% include "common.workspace" %}
|
||||
|
||||
{% include "common.tool_usage" %}
|
||||
|
||||
@@ -2,6 +2,8 @@ You are the assigned Coder. Implement the requested scope in the provided Workdi
|
||||
|
||||
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose.
|
||||
|
||||
Before review, open or append an immutable Merge Request revision containing the exact base/head/tree and changed-path evidence. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and include the structured `review` handoff with the Ticket id and current MR revision id. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected attempt authority.
|
||||
{% include "common.git" %}
|
||||
|
||||
A request-changes result requires a new immutable revision and a fresh Reviewer child attempt. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id and the currently approved revision; the Server revalidates assignment and fences Ticket state side effects.
|
||||
Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected capability authority.
|
||||
|
||||
A request-changes result requires a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id, the approved `Review` event id, and final target-ref evidence; the Server re-resolves selectors, revalidates assignment, and fences Ticket state side effects.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
You are the Ticket Orchestrator role.
|
||||
|
||||
{% include "common.git" %}
|
||||
|
||||
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers, and stop for human authority when merge/closure is not explicitly delegated.
|
||||
|
||||
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If current-revision durable review evidence is missing, indeterminate, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.
|
||||
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.
|
||||
|
||||
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder.
|
||||
|
||||
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the immutable current Merge Request revision against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, or take over implementation.
|
||||
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, or take over implementation.
|
||||
|
||||
Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Attempt identity and revision identity are injected by your child Workspace client and are not model inputs. If the authoritative revision changed, submission must fail rather than approving stale work.
|
||||
Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work.
|
||||
|
||||
Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary compatibility.
|
||||
|
||||
@@ -23,8 +23,19 @@ export type TicketListResponse = {
|
||||
record_authority: string;
|
||||
};
|
||||
|
||||
export type QueryPage = {
|
||||
limit: number;
|
||||
returned: number;
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
sort: string;
|
||||
source_limit: number | null;
|
||||
source_truncated: boolean;
|
||||
};
|
||||
|
||||
export type TicketEventDetail = {
|
||||
sequence: number;
|
||||
event_ref: string;
|
||||
kind: string;
|
||||
author: string | null;
|
||||
at: string | null;
|
||||
@@ -35,6 +46,99 @@ export type TicketEventDetail = {
|
||||
state_field: string | null;
|
||||
heading: string | null;
|
||||
body: string | null;
|
||||
attributes: { [key in string]: string };
|
||||
references: Array<string>;
|
||||
};
|
||||
|
||||
export type ObjectiveLinkSummary = { id: string; title: string; state: string };
|
||||
|
||||
export type TicketEvidenceEvent = {
|
||||
event_ref: string;
|
||||
sequence: number;
|
||||
kind: string;
|
||||
at: string | null;
|
||||
author: string | null;
|
||||
excerpt: string;
|
||||
};
|
||||
|
||||
export type TicketAssignmentSummary = {
|
||||
assignment_id: string;
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
};
|
||||
|
||||
export type TicketMergeRequestSummary = {
|
||||
merge_request_id: string;
|
||||
state: string;
|
||||
review_status: string;
|
||||
selector_from: string | null;
|
||||
selector_to: string;
|
||||
updated_at: string;
|
||||
review_subject_ref: string | null;
|
||||
review_submitted_at: string | null;
|
||||
review_excerpt: string | null;
|
||||
};
|
||||
|
||||
export type TicketEvidenceSummary = {
|
||||
has_implementation_report: boolean;
|
||||
implementation_report_after_rescope: boolean;
|
||||
has_merge_request: boolean;
|
||||
has_commit: boolean;
|
||||
review_status: string | null;
|
||||
approved: boolean;
|
||||
unresolved_request_changes: boolean;
|
||||
complete_for_integration: boolean;
|
||||
missing: Array<string>;
|
||||
};
|
||||
|
||||
export type TicketQueryRequest = {
|
||||
query: string | null;
|
||||
states: Array<string>;
|
||||
event_kinds: Array<string>;
|
||||
evidence: Array<string>;
|
||||
review_status: string | null;
|
||||
attention: Array<string>;
|
||||
related_ticket_id: string | null;
|
||||
relation_kind: string | null;
|
||||
linked_objective_id: string | null;
|
||||
updated_after: string | null;
|
||||
updated_before: string | null;
|
||||
sort: string | null;
|
||||
limit: number | null;
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export type TicketQueryItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
state: string;
|
||||
readiness: string | null;
|
||||
priority: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
item_revision: string;
|
||||
workspace_action_priority: string;
|
||||
matched_fields: Array<string>;
|
||||
snippet: string | null;
|
||||
matching_event: TicketEvidenceEvent | null;
|
||||
linked_objective_ids: Array<string>;
|
||||
relation_count: number;
|
||||
blocker_count: number;
|
||||
unresolved_blocker_count: number;
|
||||
unresolved_review_count: number;
|
||||
evidence: TicketEvidenceSummary;
|
||||
merge_request: TicketMergeRequestSummary | null;
|
||||
};
|
||||
|
||||
export type TicketQueryResponse = {
|
||||
items: Array<TicketQueryItem>;
|
||||
page: QueryPage;
|
||||
record_authority: string;
|
||||
};
|
||||
|
||||
export type TicketShowRequest = {
|
||||
event_limit: number | null;
|
||||
event_cursor: string | null;
|
||||
};
|
||||
|
||||
export type TicketRelation = {
|
||||
@@ -80,9 +184,11 @@ export type TicketDetail = {
|
||||
id: string;
|
||||
title: string;
|
||||
state: string;
|
||||
readiness: string | null;
|
||||
priority: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
item_revision: string;
|
||||
queued_by: string | null;
|
||||
queued_at: string | null;
|
||||
assignee: string | null;
|
||||
@@ -93,9 +199,15 @@ export type TicketDetail = {
|
||||
body_truncated: boolean;
|
||||
event_count: number;
|
||||
events: Array<TicketEventDetail>;
|
||||
event_page: QueryPage;
|
||||
artifact_count: number;
|
||||
artifacts: Array<string>;
|
||||
relations: TicketRelationView;
|
||||
linked_objectives: Array<ObjectiveLinkSummary>;
|
||||
implementation_reports: Array<TicketEvidenceEvent>;
|
||||
current_assignment: TicketAssignmentSummary | null;
|
||||
merge_request: TicketMergeRequestSummary | null;
|
||||
evidence: TicketEvidenceSummary;
|
||||
resolution: string | null;
|
||||
record_source: string;
|
||||
};
|
||||
|
||||
@@ -248,8 +248,8 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
ticketDetailLoad.includes("/repositories") &&
|
||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
||||
ticketDetailPage.includes("/merge-request/merge") &&
|
||||
ticketDetailPage.includes("explicit_confirmation: true") &&
|
||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||
ticketDetailPage.includes("merged_result_commit") &&
|
||||
!ticketDetailPage.includes('mutate("review", "/review"') &&
|
||||
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
||||
|
||||
@@ -17,12 +17,52 @@
|
||||
TicketDetail,
|
||||
} from "$lib/workspace/sidebar/types";
|
||||
|
||||
type MergeRequestThreadEvent =
|
||||
| {
|
||||
kind: "review_requested";
|
||||
event_id: string;
|
||||
sequence: number;
|
||||
subject_ref: string;
|
||||
requested_by: { runtime_id: string; worker_id: string };
|
||||
reviewer: { runtime_id: string; worker_id: string };
|
||||
}
|
||||
| {
|
||||
kind: "review";
|
||||
event_id: string;
|
||||
sequence: number;
|
||||
request_event_id: string;
|
||||
subject_ref: string;
|
||||
decision: "approve" | "request_changes";
|
||||
body: string;
|
||||
reviewer: { runtime_id: string; worker_id: string };
|
||||
}
|
||||
| { kind: "review_revoked"; sequence: number; review_event_id: string; reason: string }
|
||||
| { kind: "review_cancelled"; sequence: number; request_event_id: string; reason: string }
|
||||
| {
|
||||
kind: "comment";
|
||||
sequence: number;
|
||||
body: string;
|
||||
author: { runtime_id: string; worker_id: string };
|
||||
}
|
||||
| {
|
||||
kind: "merge";
|
||||
sequence: number;
|
||||
approval_event_id: string;
|
||||
approved_source_ref: string;
|
||||
target_ref_after: string;
|
||||
strategy: "fast_forward" | "merge";
|
||||
resolution: "none" | "clean" | "conflicts_resolved";
|
||||
merged_by: { runtime_id: string; worker_id: string };
|
||||
};
|
||||
|
||||
type RefProjection = { status: "known" | "unknown" | "requires_repair"; ref?: string };
|
||||
type MergeRequestDetail = {
|
||||
state: "draft" | "open" | "closed" | "merged";
|
||||
review_status: "pending" | "approved" | "changes_requested";
|
||||
current_revision: { revision_id: string; head_commit: string; head_tree: string; diff_digest: string; changed_paths: string[]; summary: string };
|
||||
current_review?: { decision: string; body: string; reviewer_effective_profile: string } | null;
|
||||
merged_at?: string | null;
|
||||
state: "open" | "closed" | "merged";
|
||||
selector_from: string | null;
|
||||
selector_to: string;
|
||||
source: RefProjection;
|
||||
target: RefProjection;
|
||||
thread: MergeRequestThreadEvent[];
|
||||
};
|
||||
|
||||
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done");
|
||||
@@ -46,6 +86,23 @@
|
||||
|
||||
let ticket = $state<TicketDetail>(loadedTicket);
|
||||
let mergeRequest = $state<MergeRequestDetail | null>(initialData.mergeRequest.data ?? null);
|
||||
const currentReviewRequest = $derived(
|
||||
mergeRequest?.thread.findLast((event) => event.kind === "review_requested") ?? null,
|
||||
);
|
||||
const currentReview = $derived.by(() => {
|
||||
if (mergeRequest?.source.status !== "known") return null;
|
||||
const review = mergeRequest.thread.findLast(
|
||||
(event) => event.kind === "review" && event.subject_ref === mergeRequest.source.ref,
|
||||
);
|
||||
if (!review || review.kind !== "review") return null;
|
||||
const revoked = mergeRequest.thread.some(
|
||||
(event) => event.kind === "review_revoked" && event.review_event_id === review.event_id,
|
||||
);
|
||||
return revoked ? null : review;
|
||||
});
|
||||
const mergeEvent = $derived(
|
||||
mergeRequest?.thread.findLast((event) => event.kind === "merge") ?? null,
|
||||
);
|
||||
let editing = $state(false);
|
||||
let editTitle = $state(loadedTicket.title);
|
||||
let editBody = $state(loadedTicket.body);
|
||||
@@ -55,7 +112,6 @@
|
||||
let transitionReason = $state("");
|
||||
let threadRole = $state("comment");
|
||||
let threadBody = $state("");
|
||||
let confirmMerge = $state(false);
|
||||
let resolution = $state("");
|
||||
let busy = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
@@ -145,29 +201,6 @@
|
||||
) threadBody = "";
|
||||
}
|
||||
|
||||
async function mergeConfirmedRevision() {
|
||||
if (!mergeRequest || !confirmMerge || busy) return;
|
||||
busy = "merge";
|
||||
errorMessage = null;
|
||||
try {
|
||||
mergeRequest = await workspaceApiJsonWithBody<MergeRequestDetail>(
|
||||
`${ticketPath}/merge-request/merge`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
expected_revision_id: mergeRequest.current_revision.revision_id,
|
||||
explicit_confirmation: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
confirmMerge = false;
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function closeTicket(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!resolution.trim()) return;
|
||||
@@ -367,18 +400,38 @@
|
||||
{#if data.mergeRequest.error}
|
||||
<p class="workspace-callout is-error">{data.mergeRequest.error}</p>
|
||||
{:else if mergeRequest}
|
||||
<p><strong>{mergeRequest.state}</strong> · {mergeRequest.review_status}</p>
|
||||
<p><code>{mergeRequest.current_revision.revision_id}</code></p>
|
||||
<p>Head <code>{mergeRequest.current_revision.head_commit}</code></p>
|
||||
{#if mergeRequest.current_revision.summary}<p>{mergeRequest.current_revision.summary}</p>{/if}
|
||||
{#if mergeRequest.current_review}
|
||||
<p><strong>{mergeRequest.current_review.decision}</strong> by {mergeRequest.current_review.reviewer_effective_profile}</p>
|
||||
{#if mergeRequest.current_review.body}<RichMarkdown text={mergeRequest.current_review.body} />{/if}
|
||||
<p><strong>{mergeRequest.state}</strong></p>
|
||||
<p>From <code>{mergeRequest.selector_from ?? "requires repair"}</code> · {mergeRequest.source.status}{mergeRequest.source.ref ? ` @ ${mergeRequest.source.ref}` : ""}</p>
|
||||
<p>To <code>{mergeRequest.selector_to}</code> · {mergeRequest.target.status}{mergeRequest.target.ref ? ` @ ${mergeRequest.target.ref}` : ""}</p>
|
||||
{#if currentReviewRequest?.kind === "review_requested"}
|
||||
<p>Review requested for <code>{currentReviewRequest.subject_ref}</code></p>
|
||||
{/if}
|
||||
{#if mergeRequest.state === "open" && mergeRequest.review_status === "approved"}
|
||||
<label><input type="checkbox" bind:checked={confirmMerge} /> Explicitly confirm merge of this revision</label>
|
||||
<button class="workspace-primary-button" type="button" disabled={!confirmMerge || busy !== null} onclick={mergeConfirmedRevision}>Confirm merge</button>
|
||||
{#if currentReview?.kind === "review"}
|
||||
<p><strong>{currentReview.decision}</strong> by <code>{currentReview.reviewer.runtime_id}/{currentReview.reviewer.worker_id}</code></p>
|
||||
{#if currentReview.body}<RichMarkdown text={currentReview.body} />{/if}
|
||||
{/if}
|
||||
{#if mergeEvent?.kind === "merge"}
|
||||
<p>Final merge · {mergeEvent.strategy} / {mergeEvent.resolution}</p>
|
||||
<p>Target ref <code>{mergeEvent.target_ref_after}</code></p>
|
||||
<p>Completed by <code>{mergeEvent.merged_by.runtime_id}/{mergeEvent.merged_by.worker_id}</code></p>
|
||||
{/if}
|
||||
<h4>Thread</h4>
|
||||
{#each mergeRequest.thread as event (event.sequence)}
|
||||
<p>
|
||||
<code>#{event.sequence}</code> · {event.kind}
|
||||
{#if event.kind === "review_requested"}
|
||||
· <code>{event.subject_ref}</code> · {event.requested_by.runtime_id}/{event.requested_by.worker_id}
|
||||
{:else if event.kind === "review"}
|
||||
· <code>{event.subject_ref}</code> · {event.reviewer.runtime_id}/{event.reviewer.worker_id}
|
||||
{:else if event.kind === "comment"}
|
||||
· {event.author.runtime_id}/{event.author.worker_id} · {event.body}
|
||||
{:else if event.kind === "review_cancelled" || event.kind === "review_revoked"}
|
||||
· {event.reason}
|
||||
{:else if event.kind === "merge"}
|
||||
· approval <code>{event.approval_event_id}</code>
|
||||
{/if}
|
||||
</p>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user