chore: merge orchestration into develop

This commit is contained in:
2026-08-17 11:02:47 +09:00
27 changed files with 4655 additions and 4106 deletions
+3 -4
View File
@@ -70,11 +70,10 @@ Intake は以下を行う。
利用可能なら、以下の typed Ticket tools を使う。 利用可能なら、以下の typed Ticket tools を使う。
- `TicketList`: 既存 Ticket の一覧・重複確認。 - `QueryTicket`: 既存 Ticket の一覧・重複確認。
- `TicketShow`: 関連 Ticket の詳細確認。 - `ShowTicket`: 関連 Ticket の詳細確認。
- `TicketCreate`: 合意済み Ticket の作成。 - `TicketCreate`: 合意済み Ticket の作成。
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。 - `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の責務である。 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 を確認する ### 2. 既存 Ticket を確認する
`TicketList` / `TicketShow` で duplicate / related work を探す。 `QueryTicket` / `ShowTicket` で duplicate / related work を探す。
確認観点: 確認観点:
+10 -10
View File
@@ -36,8 +36,8 @@ TicketCreate / TicketComment
Orchestrator は以下を行う。 Orchestrator は以下を行う。
- Ticket を `TicketShow` で読む。 - Ticket を `ShowTicket` で読む。
- 必要に応じて関連 Ticket を `TicketList` / `TicketShow` で確認する。 - 必要に応じて関連 Ticket を `QueryTicket` / `ShowTicket` で確認する。
- Ticket body / thread / artifacts / resolution / review / implementation report を読む。 - 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 を読む代替ではない。 - 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 を必要に応じて明示的に確認する。 - repository 状態、関連 docs/code、既存 worktree、visible Pods を必要に応じて明示的に確認する。
@@ -73,21 +73,21 @@ Orchestrator は以下を行う。
利用可能なら、以下を使う。 利用可能なら、以下を使う。
- `TicketList`: routing 候補関連 Ticket の確認 - `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 判断にはしない
- `TicketShow`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata と derived inverse/blocker view を確認。 - `ShowTicket`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata、linked Objective、assignment、implementation/review evidence を確認する
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。 - `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
- `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。 - `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 前に必ず確認する。 - `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 変更ではない。 - `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、state 変更ではない。
- `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。 - `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
- `TicketDoctor`: routing 前後の整合性確認。
`TicketCreate` は通常 Intake の責務だが、routing 中に follow-up Ticket が必要だと判断した場合は、ユーザー/上位 Orchestrator の合意後にだけ使う。 `TicketCreate` は通常 Intake の責務だが、routing 中に follow-up Ticket が必要だと判断した場合は、ユーザー/上位 Orchestrator の合意後にだけ使う。
## Queued acceptance contract ## 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 として扱わない。 - 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 を読んで、次のどちらかを行う。 `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: 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 だけである。 - 追加で開始してよいのは、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 を記録する。 - 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 を個別に記録する。 - この pass は scheduler、background runner、resource graph solver、automatic queue drain loop ではない。unqueued Ticket を開始せず、各 Ticket の `queued -> inprogress` acceptance を個別に記録する。
@@ -269,8 +269,8 @@ Action:
### 1. 状態確認 ### 1. 状態確認
- `git state --short --branch` - `git state --short --branch`
- `TicketShow <target>` - `ShowTicket <target>`
- 関連 Ticket の `TicketList` / `TicketShow` - 関連 Ticket の `QueryTicket` / `ShowTicket`
- 必要に応じて docs/code/workflow/history - 必要に応じて docs/code/workflow/history
- 必要に応じて visible Pods / worktrees / branches - 必要に応じて visible Pods / worktrees / branches
Generated
+3 -1
View File
@@ -2533,11 +2533,13 @@ dependencies = [
name = "merge-request" name = "merge-request"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono",
"rusqlite", "rusqlite",
"serde", "serde",
"sha2 0.11.0", "serde_json",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"uuid",
] ]
[[package]] [[package]]
+3 -1
View File
@@ -5,10 +5,12 @@ edition.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
rusqlite.workspace = true rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
sha2.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+362 -273
View File
@@ -1,297 +1,386 @@
use chrono::{TimeZone, Utc};
use merge_request::*; use merge_request::*;
use rusqlite::{Connection, params}; use rusqlite::Connection;
use std::sync::{Arc, Barrier}; use std::sync::{Arc, Mutex};
use std::thread; #[derive(Clone)]
use tempfile::TempDir; struct Assignments(Arc<Mutex<CurrentAssignment>>);
impl AssignmentSource for Assignments {
fn setup() -> (TempDir, SqliteMergeRequestStore) { fn current_assignment(&self, _: &str, _: &str) -> Result<Option<CurrentAssignment>, String> {
let dir = tempfile::tempdir().unwrap(); Ok(Some(self.0.lock().unwrap().clone()))
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(),
changed_paths: vec!["src/lib.rs".into()],
summary: format!("revision {id}"),
assignment_id: "A1".into(),
created_at: format!("t{ordinal}"),
} }
} }
struct Repositories;
fn open(store: &SqliteMergeRequestStore) { impl RepositorySource for Repositories {
store fn repository_belongs_to_workspace(&self, w: &str, r: &str) -> Result<bool, String> {
.open_merge_request(OpenMergeRequest { Ok(w == "W" && r == "R")
merge_request_id: "MR1".into(), }
ticket_id: "T1".into(), }
repository_id: "repo".into(), fn at(s: u32) -> chrono::DateTime<Utc> {
target_ref_selector: "refs/heads/develop".into(), Utc.with_ymd_and_hms(2026, 7, 26, 12, 0, s)
revision: revision("V1", 1, "head"), .single()
authenticated_runtime_id: "R1".into(), .unwrap()
authenticated_worker_id: "W1".into(), }
now: "t1".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(); .unwrap();
} }
fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequestedEvent {
fn attempt(store: &SqliteMergeRequestStore, revision: &str, token: &str) { s.register_reviewer_child_session(RegisterReviewerChildSession {
let child = format!("child-{revision}"); workspace_id: "W".into(),
store parent_runtime_id: "runtime".into(),
.register_reviewer_child_session(RegisterReviewerChildSession { parent_worker_id: "coder".into(),
parent_runtime_id: "R1".into(), child_session_id: format!("child-{token}"),
parent_worker_id: "W1".into(), reviewer_profile: "builtin:reviewer".into(),
child_session_id: child.clone(), now: at(2),
now: "t2".into(),
}) })
.unwrap(); .unwrap();
store s.request_review(RequestMergeRequestReview {
.register_review_attempt(RegisterReviewAttempt { ticket_id: "T".into(),
attempt_id: format!("attempt-{revision}"), subject_ref: subject.into(),
ticket_id: "T1".into(), child_session_id: format!("child-{token}"),
revision_id: revision.into(),
parent_assignment_id: "A1".into(),
parent_runtime_id: "R1".into(),
parent_worker_id: "W1".into(),
child_session_id: child,
capability_token: token.into(), capability_token: token.into(),
now: "t2".into(), auth: auth(),
now: at(3),
}) })
.unwrap(); .unwrap()
.request_event
} }
fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
fn approve(store: &SqliteMergeRequestStore, revision: &str, token: &str) { request(s, subject, token);
attempt(store, revision, token); s.submit_review(SubmitMergeRequestReview {
store ticket_id: "T".into(),
.submit_review(SubmitReview { current_subject_ref: subject.into(),
ticket_id: "T1".into(),
revision_id: revision.into(),
capability_token: token.into(), capability_token: token.into(),
decision: ReviewDecision::Approve, decision: ReviewDecision::Approve,
body: "approved".into(), body: "approved".into(),
findings: vec![], findings: vec![],
now: "t3".into(), now: at(4),
})
.unwrap()
}
#[test]
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(); .unwrap();
} assert!(ready.ready);
let merged = s
fn completion(operation_id: &str) -> CompleteMergeRequest { .complete(CompleteMergeRequest {
CompleteMergeRequest { ticket_id: "T".into(),
operation_id: operation_id.into(), operation_id: "op".into(),
ticket_id: "T1".into(), approval_event_id: review.event_id,
expected_revision_id: "V1".into(), current_subject_ref: "opaque-source-ref".into(),
target_commit: "base".into(), target_ref_before: "old-target-ref".into(),
source_commit: "head".into(), target_ref_after: "new-target-ref".into(),
result_commit: "head".into(),
strategy: MergeStrategy::FastForward, strategy: MergeStrategy::FastForward,
resolution: MergeResolution::None, resolution: ConflictResolution::None,
implementation_assignment_id: "A1".into(), auth: auth(),
completion_actor_runtime_id: "OR".into(), now: at(5),
completion_actor_worker_id: "OW".into(), })
now: "t4".into(),
}
}
#[test]
fn target_movement_does_not_invalidate_source_revision_approval() {
let (_dir, store) = setup();
open(&store);
approve(&store, "V1", "token-v1");
for target in ["base", "advanced-target"] {
let readiness = store
.readiness_for_ticket_with_target("T1", Some(target))
.unwrap(); .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",
] {
assert!(!json.contains(banned), "{banned} in {json}")
}
}
#[test]
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!( assert!(
readiness.ready, s.submit_review(SubmitMergeRequestReview {
"target movement must not invalidate source approval" ticket_id: "T".into(),
); current_subject_ref: "source-c".into(),
assert_eq!(readiness.review_status, ReviewStatus::Approved); capability_token: "two".into(),
assert_eq!(readiness.observed_target_commit.as_deref(), Some(target));
}
store
.add_revision(AddRevision {
ticket_id: "T1".into(),
expected_current_revision_id: "V1".into(),
revision: revision("V2", 2, "head2"),
authenticated_runtime_id: "R1".into(),
authenticated_worker_id: "W1".into(),
now: "t5".into(),
})
.unwrap();
assert_eq!(
store.readiness_for_ticket("T1").unwrap().review_status,
ReviewStatus::Pending
);
}
#[test]
fn completion_records_one_final_merge_outcome_and_replays_idempotently() {
let (_dir, store) = setup();
open(&store);
approve(&store, "V1", "token-v1");
let first = store.complete(completion("OP1")).unwrap();
assert!(!first.replayed);
assert_eq!(first.ticket_state, "done");
let merged = store.show_for_ticket("T1").unwrap().unwrap();
assert_eq!(merged.state, MergeRequestState::Merged);
assert_eq!(merged.merged_revision_id.as_deref(), Some("V1"));
assert_eq!(merged.merged_target_commit.as_deref(), Some("base"));
assert_eq!(merged.merged_result_commit.as_deref(), Some("head"));
assert_eq!(merged.merge_strategy, Some(MergeStrategy::FastForward));
assert_eq!(merged.merge_resolution, Some(MergeResolution::None));
assert_eq!(merged.merged_by_runtime_id.as_deref(), Some("OR"));
assert_eq!(merged.merged_by_worker_id.as_deref(), Some("OW"));
assert!(store.complete(completion("OP1")).unwrap().replayed);
let mut conflicting = completion("OP1");
conflicting.target_commit = "other".into();
assert!(matches!(
store.complete(conflicting),
Err(MergeRequestError::OperationConflict)
));
}
#[test]
fn completion_rejects_invalid_or_non_current_source_outcomes_without_side_effects() {
let (_dir, store) = setup();
open(&store);
approve(&store, "V1", "token-v1");
let mut invalid_ff = completion("bad-ff");
invalid_ff.result_commit = "different".into();
assert!(matches!(
store.complete(invalid_ff),
Err(MergeRequestError::InvalidMergeOutcome(_))
));
let mut invalid_merge = completion("bad-merge");
invalid_merge.strategy = MergeStrategy::Merge;
assert!(matches!(
store.complete(invalid_merge),
Err(MergeRequestError::InvalidMergeOutcome(_))
));
let mut wrong_source = completion("wrong-source");
wrong_source.source_commit = "not-approved".into();
wrong_source.result_commit = "not-approved".into();
assert!(matches!(
store.complete(wrong_source),
Err(MergeRequestError::InvalidMergeOutcome(_))
));
assert_eq!(
store.show_for_ticket("T1").unwrap().unwrap().state,
MergeRequestState::Open
);
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'",
[],
|row| row.get::<_, String>(0)
)
.unwrap(),
"inprogress"
);
}
#[test]
fn concurrent_completion_converges_on_one_operation() {
let (_dir, store) = setup();
open(&store);
approve(&store, "V1", "token-v1");
let path = store.db_path().to_path_buf();
let barrier = Arc::new(Barrier::new(3));
let mut handles = Vec::new();
for _ in 0..2 {
let path = path.clone();
let barrier = barrier.clone();
handles.push(thread::spawn(move || {
let store = SqliteMergeRequestStore::open_verified(path, "ws-a").unwrap();
barrier.wait();
store.complete(completion("OP-concurrent"))
}));
}
barrier.wait();
let outcomes: Vec<_> = handles
.into_iter()
.map(|handle| handle.join().unwrap().unwrap())
.collect();
assert_eq!(
outcomes.iter().filter(|outcome| !outcome.replayed).count(),
1
);
assert_eq!(
outcomes.iter().filter(|outcome| outcome.replayed).count(),
1
);
}
#[test]
fn reviewer_attempt_is_bound_to_direct_child_and_current_assignment() {
let (_dir, store) = setup();
open(&store);
store
.register_reviewer_child_session(RegisterReviewerChildSession {
parent_runtime_id: "R1".into(),
parent_worker_id: "W1".into(),
child_session_id: "child".into(),
now: "t2".into(),
})
.unwrap();
store
.register_review_attempt(RegisterReviewAttempt {
attempt_id: "attempt".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: "child".into(),
capability_token: "token".into(),
now: "t2".into(),
})
.unwrap();
let wrong_token = store.submit_review(SubmitReview {
ticket_id: "T1".into(),
revision_id: "V1".into(),
capability_token: "wrong".into(),
decision: ReviewDecision::Approve, decision: ReviewDecision::Approve,
body: "approved".into(), body: "stale".into(),
findings: vec![], findings: vec![],
now: "t3".into(), now: at(6)
})
.is_err()
);
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!(!r.ready);
}
#[test]
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!(selector.is_none());
let kinds: String = c
.query_row(
"SELECT group_concat(kind,',') FROM merge_request_thread_events ORDER BY sequence",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
kinds,
"review_requested,review,review_requested,review_cancelled"
);
let old: bool = c
.query_row(
"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!( assert!(matches!(result, Err(MergeRequestError::NotReady(_))));
wrong_token, }
Err(MergeRequestError::InvalidReviewAttempt)
)); #[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!(state, "inprogress");
} }
+60 -61
View File
@@ -36,8 +36,8 @@ const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
"TicketCreate", "TicketCreate",
"TicketEditItem", "TicketEditItem",
"TicketList", "QueryTicket",
"TicketShow", "ShowTicket",
"TicketComment", "TicketComment",
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
@@ -51,8 +51,8 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
]; ];
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [ pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [
"TicketList", "QueryTicket",
"TicketShow", "ShowTicket",
"TicketDependencyCheck", "TicketDependencyCheck",
"TicketDoctor", "TicketDoctor",
]; ];
@@ -71,8 +71,8 @@ pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
pub const TICKET_TOOL_NAMES: [&str; 19] = [ pub const TICKET_TOOL_NAMES: [&str; 19] = [
"TicketCreate", "TicketCreate",
"TicketEditItem", "TicketEditItem",
"TicketList", "QueryTicket",
"TicketShow", "ShowTicket",
"TicketComment", "TicketComment",
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
@@ -91,8 +91,8 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
]; ];
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketList", "QueryTicket",
"TicketShow", "ShowTicket",
"TicketDependencyCheck", "TicketDependencyCheck",
"TicketDoctor", "TicketDoctor",
"TicketRelationQuery", "TicketRelationQuery",
@@ -121,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. \ 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 \ 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."; User/Companion authoring surfaces, not Orchestrator implementation control.";
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \ const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \ overview. The local backend supports workflow-state selection; Workspace-backed Workers replace this \
state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \ definition with the richer authoritative text/event/evidence/relation/Objective/time/attention query.";
routing, closing, planning, or implementation decisions."; const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \ Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and artifact \
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \ metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection.";
artifact metadata.";
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown."; 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 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."; const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
@@ -169,8 +168,8 @@ fn base_tool_description(name: &str) -> &'static str {
match name { match name {
"TicketCreate" => CREATE_DESCRIPTION, "TicketCreate" => CREATE_DESCRIPTION,
"TicketEditItem" => EDIT_ITEM_DESCRIPTION, "TicketEditItem" => EDIT_ITEM_DESCRIPTION,
"TicketList" => LIST_DESCRIPTION, "QueryTicket" => LIST_DESCRIPTION,
"TicketShow" => SHOW_DESCRIPTION, "ShowTicket" => SHOW_DESCRIPTION,
"TicketComment" => COMMENT_DESCRIPTION, "TicketComment" => COMMENT_DESCRIPTION,
"TicketPlan" => PLAN_DESCRIPTION, "TicketPlan" => PLAN_DESCRIPTION,
"TicketDecision" => DECISION_DESCRIPTION, "TicketDecision" => DECISION_DESCRIPTION,
@@ -464,7 +463,7 @@ impl TicketWorkflowStateParam {
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum TicketListStateParam { enum QueryTicketStateParam {
Active, Active,
Planning, Planning,
Ready, Ready,
@@ -475,7 +474,7 @@ enum TicketListStateParam {
All, All,
} }
impl TicketListStateParam { impl QueryTicketStateParam {
fn as_list_state(self) -> Option<TicketListState> { fn as_list_state(self) -> Option<TicketListState> {
match self { match self {
Self::Planning => Some(TicketListState::Planning), Self::Planning => Some(TicketListState::Planning),
@@ -490,10 +489,10 @@ impl TicketListStateParam {
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[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. /// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets.
#[serde(default)] #[serde(default)]
state: Option<TicketListStateParam>, state: Option<QueryTicketStateParam>,
/// Explicit workflow-state filter list. Cannot be combined with `state`. /// Explicit workflow-state filter list. Cannot be combined with `state`.
#[serde(default)] #[serde(default)]
states: Option<Vec<TicketWorkflowStateParam>>, states: Option<Vec<TicketWorkflowStateParam>>,
@@ -502,28 +501,28 @@ struct TicketListParams {
limit: Option<usize>, limit: Option<usize>,
} }
impl TicketListParams { impl QueryTicketParams {
fn into_query(self) -> Result<(crate::TicketListQuery, String, Option<usize>), TicketError> { fn into_query(self) -> Result<(crate::TicketListQuery, String, Option<usize>), TicketError> {
let query = if let Some(states) = self.states { let query = if let Some(states) = self.states {
if self.state.is_some() { if self.state.is_some() {
return Err(TicketError::Conflict( 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() { if states.is_empty() {
return Err(TicketError::Conflict( 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())) crate::TicketListQuery::states(states.into_iter().map(|state| state.into_list_state()))
} else { } else {
match self.state.unwrap_or(TicketListStateParam::Active) { match self.state.unwrap_or(QueryTicketStateParam::Active) {
TicketListStateParam::Active => crate::TicketListQuery::active(), QueryTicketStateParam::Active => crate::TicketListQuery::active(),
TicketListStateParam::All => crate::TicketListQuery::all(), QueryTicketStateParam::All => crate::TicketListQuery::all(),
state => crate::TicketListQuery::state( state => crate::TicketListQuery::state(
state state
.as_list_state() .as_list_state()
.expect("workflow state list param maps to TicketListState"), .expect("workflow state list param maps to QueryTicketState"),
), ),
} }
}; };
@@ -533,7 +532,7 @@ impl TicketListParams {
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketShowParams { struct ShowTicketParams {
/// Ticket id. Exactly one of `id` or `query` must be provided. /// Ticket id. Exactly one of `id` or `query` must be provided.
#[serde(default)] #[serde(default)]
id: Option<String>, id: Option<String>,
@@ -768,17 +767,17 @@ struct TicketRefOutput {
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct TicketListOutput { struct QueryTicketOutput {
state_filter: String, state_filter: String,
count: usize, count: usize,
returned: usize, returned: usize,
truncated: bool, truncated: bool,
limit: usize, limit: usize,
tickets: Vec<TicketListTicketOutput>, tickets: Vec<QueryTicketTicketOutput>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct TicketListTicketOutput { struct QueryTicketTicketOutput {
id: String, id: String,
title: String, title: String,
state: String, state: String,
@@ -808,12 +807,12 @@ struct TicketEditItemTool {
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketListTool { struct QueryTicketTool {
backend: TicketToolBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketShowTool { struct ShowTicketTool {
backend: TicketToolBackend, backend: TicketToolBackend,
} }
@@ -976,28 +975,28 @@ impl Tool for TicketEditItemTool {
} }
#[async_trait] #[async_trait]
impl Tool for TicketListTool { impl Tool for QueryTicketTool {
async fn execute( async fn execute(
&self, &self,
input_json: &str, input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext, _ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> 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 let (filter, state_filter, params_limit) = params
.into_query() .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 limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
let tickets = self let tickets = self
.backend .backend
.list(filter) .list(filter)
.map_err(|error| backend_error("TicketList", error))?; .map_err(|error| backend_error("QueryTicket", error))?;
let count = tickets.len(); let count = tickets.len();
let returned_tickets: Vec<_> = tickets let returned_tickets: Vec<_> = tickets
.into_iter() .into_iter()
.take(limit) .take(limit)
.map(ticket_summary_json) .map(ticket_summary_json)
.collect(); .collect();
let output = TicketListOutput { let output = QueryTicketOutput {
state_filter: state_filter.to_string(), state_filter: state_filter.to_string(),
count, count,
returned: returned_tickets.len(), returned: returned_tickets.len(),
@@ -1017,13 +1016,13 @@ impl Tool for TicketListTool {
} }
#[async_trait] #[async_trait]
impl Tool for TicketShowTool { impl Tool for ShowTicketTool {
async fn execute( async fn execute(
&self, &self,
input_json: &str, input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext, _ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> 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 query = id_or_query(params.id, params.query)?;
let event_limit = bounded(params.event_limit, DEFAULT_EVENT_LIMIT, MAX_EVENT_LIMIT); let event_limit = bounded(params.event_limit, DEFAULT_EVENT_LIMIT, MAX_EVENT_LIMIT);
let artifact_limit = bounded( let artifact_limit = bounded(
@@ -1039,7 +1038,7 @@ impl Tool for TicketShowTool {
let ticket = self let ticket = self
.backend .backend
.show(query) .show(query)
.map_err(|error| backend_error("TicketShow", error))?; .map_err(|error| backend_error("ShowTicket", error))?;
let summary = format!( let summary = format!(
"Ticket {} state {}", "Ticket {} state {}",
ticket.meta.id, ticket.meta.id,
@@ -1484,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); let hints = ticket_list_hints(&ticket);
TicketListTicketOutput { QueryTicketTicketOutput {
id: ticket.id, id: ticket.id,
title: truncate_inline(ticket.title.as_str(), LIST_TITLE_MAX_CHARS), title: truncate_inline(ticket.title.as_str(), LIST_TITLE_MAX_CHARS),
state: ticket.workflow_state.as_str().to_string(), state: ticket.workflow_state.as_str().to_string(),
@@ -1722,8 +1721,8 @@ fn input_schema(name: &str) -> Value {
match name { match name {
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)), "TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
"TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)), "TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)),
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)), "QueryTicket" => serde_json::to_value(schemars::schema_for!(QueryTicketParams)),
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)), "ShowTicket" => serde_json::to_value(schemars::schema_for!(ShowTicketParams)),
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => { "TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams)) serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
} }
@@ -1769,8 +1768,8 @@ macro_rules! impl_from_backend {
impl_from_backend!(TicketCreateTool); impl_from_backend!(TicketCreateTool);
impl_from_backend!(TicketEditItemTool); impl_from_backend!(TicketEditItemTool);
impl_from_backend!(TicketListTool); impl_from_backend!(QueryTicketTool);
impl_from_backend!(TicketShowTool); impl_from_backend!(ShowTicketTool);
impl_from_backend!(TicketCommentTool); impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketPlanTool); impl_from_backend!(TicketPlanTool);
impl_from_backend!(TicketDecisionTool); impl_from_backend!(TicketDecisionTool);
@@ -1793,8 +1792,8 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
vec![ vec![
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()), tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()), tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()),
tool_definition::<TicketListTool>("TicketList", backend.clone()), tool_definition::<QueryTicketTool>("QueryTicket", backend.clone()),
tool_definition::<TicketShowTool>("TicketShow", backend.clone()), tool_definition::<ShowTicketTool>("ShowTicket", backend.clone()),
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()), tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
tool_definition::<TicketPlanTool>("TicketPlan", backend.clone()), tool_definition::<TicketPlanTool>("TicketPlan", backend.clone()),
tool_definition::<TicketDecisionTool>("TicketDecision", backend.clone()), tool_definition::<TicketDecisionTool>("TicketDecision", backend.clone()),
@@ -1861,8 +1860,8 @@ mod tests {
assert_eq!( assert_eq!(
TICKET_READ_ONLY_TOOL_NAMES, TICKET_READ_ONLY_TOOL_NAMES,
[ [
"TicketList", "QueryTicket",
"TicketShow", "ShowTicket",
"TicketDependencyCheck", "TicketDependencyCheck",
"TicketDoctor", "TicketDoctor",
"TicketRelationQuery", "TicketRelationQuery",
@@ -1941,8 +1940,8 @@ mod tests {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = backend(&temp); let backend = backend(&temp);
let create = tool_by_name(backend.clone(), "TicketCreate"); let create = tool_by_name(backend.clone(), "TicketCreate");
let list = tool_by_name(backend.clone(), "TicketList"); let list = tool_by_name(backend.clone(), "QueryTicket");
let show = tool_by_name(backend.clone(), "TicketShow"); let show = tool_by_name(backend.clone(), "ShowTicket");
let doctor = tool_by_name(backend.clone(), "TicketDoctor"); let doctor = tool_by_name(backend.clone(), "TicketDoctor");
let created = create let created = create
@@ -2004,7 +2003,7 @@ mod tests {
async fn ticket_list_tool_truncates_long_titles_and_hints() { async fn ticket_list_tool_truncates_long_titles_and_hints() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = backend(&temp); 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!( let mut ticket = NewTicket::new(format!(
"Long Title {}", "Long Title {}",
"x".repeat(LIST_TITLE_MAX_CHARS + 40) "x".repeat(LIST_TITLE_MAX_CHARS + 40)
@@ -2032,7 +2031,7 @@ mod tests {
async fn ticket_list_tool_default_and_max_limits_are_bounded() { async fn ticket_list_tool_default_and_max_limits_are_bounded() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = backend(&temp); 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) { for index in 0..(MAX_LIST_LIMIT + 5) {
backend backend
.create(NewTicket::new(format!("Ticket {index:03}"))) .create(NewTicket::new(format!("Ticket {index:03}")))
@@ -2083,7 +2082,7 @@ mod tests {
async fn ticket_list_tool_caps_all_and_closed_default_listing() { async fn ticket_list_tool_caps_all_and_closed_default_listing() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = backend(&temp); 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) { for index in 0..(DEFAULT_LIST_LIMIT + 3) {
let mut ticket = NewTicket::new(format!("Closed Ticket {index:03}")); let mut ticket = NewTicket::new(format!("Closed Ticket {index:03}"));
ticket.workflow_state = Some(TicketWorkflowState::Closed); ticket.workflow_state = Some(TicketWorkflowState::Closed);
@@ -2141,7 +2140,7 @@ mod tests {
async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() { async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = backend(&temp); 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 planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
let mut ready_input = NewTicket::new("Ready Ticket"); let mut ready_input = NewTicket::new("Ready Ticket");
ready_input.workflow_state = Some(TicketWorkflowState::Ready); ready_input.workflow_state = Some(TicketWorkflowState::Ready);
@@ -2188,7 +2187,7 @@ mod tests {
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() { async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = backend(&temp); 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 close = tool_by_name(backend.clone(), "TicketClose");
let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST"; let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST";
let thread_secret = "THREAD_SECRET_DO_NOT_LIST"; let thread_secret = "THREAD_SECRET_DO_NOT_LIST";
@@ -2325,7 +2324,7 @@ mod tests {
let record = tool_by_name(backend.clone(), "TicketRelationRecord"); let record = tool_by_name(backend.clone(), "TicketRelationRecord");
let remove = tool_by_name(backend.clone(), "TicketRelationRemove"); let remove = tool_by_name(backend.clone(), "TicketRelationRemove");
let query = tool_by_name(backend.clone(), "TicketRelationQuery"); 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 let recorded = record
.execute( .execute(
@@ -2785,7 +2784,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn ticket_show_requires_exactly_one_identifier() { async fn ticket_show_requires_exactly_one_identifier() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let show = tool_by_name(backend(&temp), "TicketShow"); let show = tool_by_name(backend(&temp), "ShowTicket");
let error = show let error = show
.execute( .execute(
&json!({ "id": "a", "query": "b" }).to_string(), &json!({ "id": "a", "query": "b" }).to_string(),
+2 -2
View File
@@ -2045,7 +2045,7 @@ impl DashboardApp {
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake); TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
context.ticket = Some(TicketRef::id(ticket_id.clone())); context.ticket = Some(TicketRef::id(ticket_id.clone()));
context.user_instruction = Some(format!( 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) { let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
Ok(store) => store, 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 { fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String {
format!( 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}"
) )
} }
@@ -11,7 +11,6 @@ pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[
"MergeRequestShow", "MergeRequestShow",
"MergeRequestReadinessCheck", "MergeRequestReadinessCheck",
"MergeRequestOpen", "MergeRequestOpen",
"MergeRequestAddRevision",
"MergeRequestComplete", "MergeRequestComplete",
]; ];
pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit"; pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit";
@@ -20,7 +19,6 @@ enum Kind {
Show, Show,
Readiness, Readiness,
Open, Open,
AddRevision,
Complete, Complete,
Review, Review,
} }
@@ -29,7 +27,6 @@ struct MergeRequestTool {
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
kind: Kind, kind: Kind,
} }
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct ShowInput { struct ShowInput {
ticket: String, ticket: String,
@@ -38,23 +35,8 @@ struct ShowInput {
struct OpenInput { struct OpenInput {
ticket: String, ticket: String,
repository_id: String, repository_id: String,
revision_id: String, selector_from: String,
base_commit: String, selector_to: String,
head_commit: 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,
#[serde(default)]
changed_paths: Vec<String>,
#[serde(default)] #[serde(default)]
summary: String, summary: String,
} }
@@ -62,10 +44,9 @@ struct AddRevisionInput {
struct CompleteInput { struct CompleteInput {
ticket: String, ticket: String,
operation_id: String, operation_id: String,
expected_revision_id: String, approval_event_id: String,
target_commit: String, target_ref_before: String,
source_commit: String, target_ref_after: String,
result_commit: String,
strategy: MergeStrategyInput, strategy: MergeStrategyInput,
resolution: MergeResolutionInput, resolution: MergeResolutionInput,
} }
@@ -104,63 +85,48 @@ struct ReviewFindingInput {
#[serde(default)] #[serde(default)]
path: Option<String>, path: Option<String>,
#[serde(default)] #[serde(default)]
line: Option<u64>, line: Option<u32>,
body: String, body: String,
} }
impl Kind { impl Kind {
fn name(self) -> &'static str { fn name(self) -> &'static str {
match self { match self {
Self::Show => "MergeRequestShow", Self::Show => "MergeRequestShow",
Self::Readiness => "MergeRequestReadinessCheck", Self::Readiness => "MergeRequestReadinessCheck",
Self::Open => "MergeRequestOpen", Self::Open => "MergeRequestOpen",
Self::AddRevision => "MergeRequestAddRevision",
Self::Complete => "MergeRequestComplete", Self::Complete => "MergeRequestComplete",
Self::Review => "MergeRequestReviewSubmit", Self::Review => "MergeRequestReviewSubmit",
} }
} }
fn description(self) -> &'static str {
description(self.name()).unwrap_or("Merge Request operation.")
}
fn schema(self) -> serde_json::Value { fn schema(self) -> serde_json::Value {
match self { match self {
Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)), Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)),
Self::Open => json!(schemars::schema_for!(OpenInput)), Self::Open => json!(schemars::schema_for!(OpenInput)),
Self::AddRevision => json!(schemars::schema_for!(AddRevisionInput)),
Self::Complete => json!(schemars::schema_for!(CompleteInput)), Self::Complete => json!(schemars::schema_for!(CompleteInput)),
Self::Review => json!(schemars::schema_for!(ReviewInput)), Self::Review => json!(schemars::schema_for!(ReviewInput)),
} }
} }
} }
#[async_trait] #[async_trait]
impl Tool for MergeRequestTool { impl Tool for MergeRequestTool {
async fn execute( async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
&self, let ws = self.client.workspace_id().ok_or_else(|| {
input: &str,
_context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let workspace_id = self.client.workspace_id().ok_or_else(|| {
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into()) ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
})?; })?;
let (method, path, body) = match self.kind { let (method, path, body) = match self.kind {
Kind::Show => { Kind::Show | Kind::Readiness => {
let v: ShowInput = parse(input)?;
nonempty(&v.ticket)?;
(
WorkspaceRequestMethod::Get,
format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket),
None,
)
}
Kind::Readiness => {
let v: ShowInput = parse(input)?; let v: ShowInput = parse(input)?;
nonempty(&v.ticket)?; nonempty(&v.ticket)?;
( (
WorkspaceRequestMethod::Get, WorkspaceRequestMethod::Get,
format!( format!(
"/api/w/{workspace_id}/tickets/{}/merge-request/readiness", "/api/w/{ws}/tickets/{}/merge-request{}",
v.ticket v.ticket,
if matches!(self.kind, Kind::Readiness) {
"/readiness"
} else {
""
}
), ),
None, None,
) )
@@ -170,62 +136,35 @@ impl Tool for MergeRequestTool {
nonempty(&v.ticket)?; nonempty(&v.ticket)?;
( (
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket), format!("/api/w/{ws}/tickets/{}/merge-request", v.ticket),
Some( Some(
json!({"repository_id":v.repository_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"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}),
),
)
}
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,"changed_paths":v.changed_paths,"summary":v.summary}),
), ),
) )
} }
Kind::Complete => { Kind::Complete => {
let v: CompleteInput = parse(input)?; let v: CompleteInput = parse(input)?;
nonempty(&v.ticket)?; nonempty(&v.ticket)?;
let strategy = match v.strategy {
MergeStrategyInput::FastForward => "fast_forward",
MergeStrategyInput::Merge => "merge",
};
let resolution = match v.resolution {
MergeResolutionInput::None => "none",
MergeResolutionInput::Clean => "clean",
MergeResolutionInput::ConflictsResolved => "conflicts_resolved",
};
( (
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!( format!("/api/w/{ws}/tickets/{}/merge-request/complete", v.ticket),
"/api/w/{workspace_id}/tickets/{}/merge-request/complete",
v.ticket
),
Some( Some(
json!({"operation_id":v.operation_id,"expected_revision_id":v.expected_revision_id,"target_commit":v.target_commit,"source_commit":v.source_commit,"result_commit":v.result_commit,"strategy":strategy,"resolution":resolution}), 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 => { Kind::Review => {
let v: ReviewInput = parse(input)?; 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( ToolError::ExecutionFailed(
"MergeRequestReviewSubmit is available only to an attested Reviewer child" "Review submit requires injected Reviewer capability".into(),
.into(),
) )
})?; })?;
( (
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!( format!(
"/api/w/{workspace_id}/tickets/{}/merge-request/reviews", "/api/w/{ws}/tickets/{}/merge-request/reviews",
context.ticket_id ctx.ticket_id
), ),
Some( 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<_>>() }), 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<_>>() }),
@@ -233,32 +172,32 @@ impl Tool for MergeRequestTool {
) )
} }
}; };
let request = match body { let req = match body {
Some(body) => WorkspaceRequest::json(method, path, body.to_string()), Some(v) => WorkspaceRequest::json(method, path, v.to_string()),
None => WorkspaceRequest::get(path), None => WorkspaceRequest::get(path),
}; };
let response = self let res = self
.client .client
.execute(request) .execute(req)
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
if !response.is_success() { if !res.is_success() {
return Err(ToolError::ExecutionFailed(format!( return Err(ToolError::ExecutionFailed(format!(
"Merge Request API returned HTTP {}: {}", "Merge Request API returned HTTP {}: {}",
response.status, response.body res.status, res.body
))); )));
} }
Ok(ToolOutput { Ok(ToolOutput {
summary: self.kind.name().to_string(), summary: self.kind.name().into(),
content: Some(response.body), content: Some(res.body),
attachments: Vec::new(), attachments: vec![],
}) })
} }
} }
fn parse<T: serde::de::DeserializeOwned>(value: &str) -> Result<T, ToolError> { fn parse<T: serde::de::DeserializeOwned>(v: &str) -> Result<T, ToolError> {
serde_json::from_str(value).map_err(|e| ToolError::InvalidArgument(e.to_string())) serde_json::from_str(v).map_err(|e| ToolError::InvalidArgument(e.to_string()))
} }
fn nonempty(value: &str) -> Result<(), ToolError> { fn nonempty(v: &str) -> Result<(), ToolError> {
if value.trim().is_empty() { if v.trim().is_empty() {
Err(ToolError::InvalidArgument( Err(ToolError::InvalidArgument(
"ticket must not be empty".into(), "ticket must not be empty".into(),
)) ))
@@ -268,74 +207,75 @@ fn nonempty(value: &str) -> Result<(), ToolError> {
} }
fn definition(client: Arc<dyn WorkspaceClient>, kind: Kind) -> ToolDefinition { fn definition(client: Arc<dyn WorkspaceClient>, kind: Kind) -> ToolDefinition {
Arc::new(move || { Arc::new(move || {
let meta = ToolMeta::new(kind.name()) (
.description(kind.description()) ToolMeta::new(kind.name())
.input_schema(kind.schema()); .description(description(kind.name()).unwrap_or("Merge Request operation."))
let tool: Arc<dyn Tool> = Arc::new(MergeRequestTool { .input_schema(kind.schema()),
Arc::new(MergeRequestTool {
client: client.clone(), client: client.clone(),
kind, kind,
}); }) as Arc<dyn Tool>,
(meta, tool) )
}) })
} }
pub fn common_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> { pub fn common_tools(c: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
vec![ vec![
definition(client.clone(), Kind::Show), definition(c.clone(), Kind::Show),
definition(client.clone(), Kind::Readiness), definition(c.clone(), Kind::Readiness),
definition(client.clone(), Kind::Open), definition(c.clone(), Kind::Open),
definition(client.clone(), Kind::AddRevision), definition(c, Kind::Complete),
definition(client, Kind::Complete),
] ]
} }
pub fn reviewer_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> { pub fn reviewer_tools(c: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
if client.reviewer_attempt_context().is_some() { if c.reviewer_context().is_some() {
vec![ vec![
definition(client.clone(), Kind::Show), definition(c.clone(), Kind::Show),
definition(client, Kind::Review), definition(c, Kind::Review),
] ]
} else { } else {
Vec::new() vec![]
} }
} }
pub fn description(name: &str) -> Option<&'static str> { pub fn description(n: &str) -> Option<&'static str> {
match name { match n {
"MergeRequestShow" => Some( "MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."),
"Read the authoritative Merge Request, immutable current revision, and structured review status.",
),
"MergeRequestReadinessCheck" => { "MergeRequestReadinessCheck" => {
Some("Check derived merge readiness for the current immutable revision.") Some("Resolve current provider refs and derive readiness from valid review events.")
} }
"MergeRequestOpen" => { "MergeRequestOpen" => {
Some("Open an immutable Merge Request revision for the current assigned Coder.") Some("Open a Merge Request with immutable source and target selectors.")
}
"MergeRequestAddRevision" => {
Some("Append an immutable revision; prior approval cannot carry to the new revision.")
} }
"MergeRequestComplete" => { "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, _ => None,
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn merge_request_tool_contract_omits_redundant_revision_evidence_and_candidate_result_tool() { fn schemas_hide_revision_and_commit_authority() {
let open = serde_json::to_string(&schemars::schema_for!(OpenInput)).unwrap(); let schemas = [
let add = serde_json::to_string(&schemars::schema_for!(AddRevisionInput)).unwrap(); schemars::schema_for!(OpenInput),
let complete = serde_json::to_string(&schemars::schema_for!(CompleteInput)).unwrap(); schemars::schema_for!(CompleteInput),
assert!(!open.contains("head_tree")); ];
assert!(!add.contains("head_tree")); for s in schemas {
assert!(!open.contains("diff_digest")); let j = serde_json::to_string(&s).unwrap();
assert!(!add.contains("diff_digest")); for banned in [
assert!(complete.contains("result_commit")); "revision_id",
assert!(complete.contains("conflicts_resolved")); "attempt_id",
assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestRecordMergeResult")); "base_commit",
"head_commit",
"source_commit",
"result_commit",
] {
assert!(!j.contains(banned), "{banned} in {j}")
}
}
assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestRequestReview"));
} }
} }
+76 -76
View File
@@ -26,36 +26,45 @@ impl WorkspaceHttpObjectiveBackend {
Self { client } Self { client }
} }
async fn list(&self, input: ObjectiveListInput) -> Result<ToolOutput, ToolError> { async fn list(&self, input: QueryObjectiveInput) -> Result<ToolOutput, ToolError> {
let mut url = format!( let url = format!(
"/api/w/{}/objectives", "/api/w/{}/objectives/query",
self.client.workspace_id().unwrap_or_default() self.client.workspace_id().unwrap_or_default()
); );
if let Some(limit) = input.limit { let response = send_json::<QueryObjectiveInput, serde_json::Value>(
url.push_str(&format!("?limit={}", limit.min(1000))); self.client.as_ref(),
} reqwest::Method::POST,
let response = get_json::<ObjectiveListResponse>(self.client.as_ref(), &url) &url,
&input,
)
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
let count = response.items.len();
Ok(ToolOutput { 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)?), content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
attachments: Vec::new(), attachments: Vec::new(),
}) })
} }
async fn show(&self, input: ObjectiveShowInput) -> Result<ToolOutput, ToolError> { async fn show(&self, input: ShowObjectiveInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveShow")?; let id = validate_id(&input.id, "ShowObjective")?;
let url = self.objective_url(id); let url = format!("{}/show", self.objective_url(id));
let response = get_json::<ObjectiveDetail>(self.client.as_ref(), &url) 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 .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(ToolOutput {
format!("Read objective {}", response.id), summary: format!("Read objective {id}"),
response, content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
)?) attachments: Vec::new(),
})
} }
async fn create(&self, input: ObjectiveCreateInput) -> Result<ToolOutput, ToolError> { async fn create(&self, input: ObjectiveCreateInput) -> Result<ToolOutput, ToolError> {
@@ -195,13 +204,6 @@ fn backend_error(error: WorkspaceObjectiveBackendError) -> ToolError {
ToolError::ExecutionFailed(error.to_string()) 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>>( async fn send_json<B: Serialize, T: for<'de> Deserialize<'de>>(
client: &dyn WorkspaceClient, client: &dyn WorkspaceClient,
method: reqwest::Method, method: reqwest::Method,
@@ -270,14 +272,14 @@ pub fn workspace_http_objective_tools(client: Arc<dyn WorkspaceClient>) -> Vec<T
let backend = WorkspaceHttpObjectiveBackend::new(client); let backend = WorkspaceHttpObjectiveBackend::new(client);
vec![ vec![
objective_tool( objective_tool(
"ObjectiveList", "QueryObjective",
LIST_DESCRIPTION, LIST_DESCRIPTION,
list_schema(), list_schema(),
backend.clone(), backend.clone(),
ObjectiveOperation::List, ObjectiveOperation::List,
), ),
objective_tool( objective_tool(
"ObjectiveShow", "ShowObjective",
SHOW_DESCRIPTION, SHOW_DESCRIPTION,
show_schema(), show_schema(),
backend.clone(), backend.clone(),
@@ -367,11 +369,11 @@ impl Tool for WorkspaceHttpObjectiveTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
match self.operation { match self.operation {
ObjectiveOperation::List => { ObjectiveOperation::List => {
let input = parse_input::<ObjectiveListInput>(input_json)?; let input = parse_input::<QueryObjectiveInput>(input_json)?;
self.backend.list(input).await self.backend.list(input).await
} }
ObjectiveOperation::Show => { ObjectiveOperation::Show => {
let input = parse_input::<ObjectiveShowInput>(input_json)?; let input = parse_input::<ShowObjectiveInput>(input_json)?;
self.backend.show(input).await self.backend.show(input).await
} }
ObjectiveOperation::Create => { 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())) serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
} }
const LIST_DESCRIPTION: &str = const LIST_DESCRIPTION: &str = "Query authoritative Objectives with bounded typed filters, stable snippets, linked-Ticket context, and cursor metadata.";
"List Objective records through Backend Workspace API authority as bounded summaries."; const SHOW_DESCRIPTION: &str = "Show one authoritative Objective with its revision, full linked-Ticket context, bounded body, and paged event metadata.";
const SHOW_DESCRIPTION: &str =
"Show one Objective record by canonical id through Backend Workspace API authority.";
const CREATE_DESCRIPTION: &str = const CREATE_DESCRIPTION: &str =
"Create an Objective record through Backend Workspace API authority."; "Create an Objective record through Backend Workspace API authority.";
const EDIT_DESCRIPTION: &str = const EDIT_DESCRIPTION: &str =
@@ -422,13 +422,29 @@ fn list_schema() -> serde_json::Value {
"type":"object", "type":"object",
"additionalProperties": false, "additionalProperties": false,
"properties":{ "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 { 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 { fn create_schema() -> serde_json::Value {
@@ -480,17 +496,6 @@ fn unlink_ticket_schema() -> serde_json::Value {
id_ticket_schema(&["id", "ticket_id"]) 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 { fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
json!({ json!({
"type":"object", "type":"object",
@@ -503,14 +508,30 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
}) })
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct ObjectiveListInput { 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>, limit: Option<usize>,
cursor: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ObjectiveShowInput { struct ShowObjectiveInput {
id: String, 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)] #[derive(Debug, Serialize, Deserialize)]
@@ -574,30 +595,6 @@ fn default_state() -> String {
"active".to_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)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ObjectiveDetail { struct ObjectiveDetail {
id: String, id: String,
@@ -637,10 +634,10 @@ mod tests {
"ObjectiveCreate", "ObjectiveCreate",
"ObjectiveEdit", "ObjectiveEdit",
"ObjectiveLinkTicket", "ObjectiveLinkTicket",
"ObjectiveList",
"ObjectiveSetState", "ObjectiveSetState",
"ObjectiveShow",
"ObjectiveUnlinkTicket", "ObjectiveUnlinkTicket",
"QueryObjective",
"ShowObjective",
] ]
); );
} }
@@ -648,9 +645,12 @@ mod tests {
#[test] #[test]
fn objective_tool_schemas_are_bounded_and_mutation_scoped() { fn objective_tool_schemas_are_bounded_and_mutation_scoped() {
let list = list_schema(); 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(); let show = show_schema();
assert_eq!(show["required"][0], "id"); assert_eq!(show["required"][0], "id");
assert_eq!(show["properties"]["event_limit"]["maximum"], 50);
let create = create_schema(); let create = create_schema();
assert_eq!(create["required"][0], "title"); assert_eq!(create["required"][0], "title");
let edit = edit_schema(); let edit = edit_schema();
+300 -23
View File
@@ -9,6 +9,10 @@ use std::{
sync::Arc, sync::Arc,
}; };
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
@@ -25,8 +29,238 @@ use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError, FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId, FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration, FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
ToolDefinition,
}; };
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; 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_ID: &str = "ticket";
const FEATURE_NAME: &str = "Ticket tools"; const FEATURE_NAME: &str = "Ticket tools";
@@ -142,14 +376,7 @@ impl TicketFeatureAccess {
} }
} }
const READ_ONLY_TOOL_NAMES: &[&str] = &[ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
"TicketList",
"TicketShow",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
];
const AUTHORING_TOOL_NAMES: &[&str] = &[ const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate", "TicketCreate",
@@ -168,31 +395,25 @@ const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate", "TicketCreate",
"TicketEditItem", "TicketEditItem",
"TicketList", "QueryTicket",
"TicketShow", "ShowTicket",
"TicketComment", "TicketComment",
"TicketQueue", "TicketQueue",
"TicketClose", "TicketClose",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove", "TicketRelationRemove",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
]; ];
#[cfg(test)] #[cfg(test)]
const WORKFLOW_TOOL_NAMES: &[&str] = &[ const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketList", "QueryTicket",
"TicketShow", "ShowTicket",
"TicketComment", "TicketComment",
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketDependencyCheck", "TicketDependencyCheck",
"TicketDoctor",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove", "TicketRelationRemove",
"TicketRelationQuery",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
]; ];
@@ -200,9 +421,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketDependencyCheck",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove", "TicketRelationRemove",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
]; ];
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -366,7 +589,7 @@ impl FeatureModule for TicketFeature {
)); ));
} }
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend { 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![ vec![
"MergeRequestShow", "MergeRequestShow",
merge_request::MERGE_REQUEST_REVIEW_TOOL_NAME, merge_request::MERGE_REQUEST_REVIEW_TOOL_NAME,
@@ -413,6 +636,10 @@ impl FeatureModule for TicketFeature {
ticket_workflow_instruction(), ticket_workflow_instruction(),
))?; ))?;
let allowed_tool_names = self.enabled_tool_names(); 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(); let mut tools = context.tools();
for definition in ticket_tools(backend) { for definition in ticket_tools(backend) {
let (meta, _) = definition(); let (meta, _) = definition();
@@ -423,10 +650,19 @@ impl FeatureModule for TicketFeature {
{ {
continue; 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))?; tools.register(ToolContribution::new(name, definition))?;
} }
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend { 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()) merge_request::reviewer_tools(client.clone())
} else { } else {
merge_request::common_tools(client.clone()) merge_request::common_tools(client.clone())
@@ -1044,6 +1280,47 @@ mod tests {
.expect("tool exists") .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] #[test]
fn descriptor_declares_ticket_tools() { fn descriptor_declares_ticket_tools() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@@ -1200,8 +1477,8 @@ language = "Japanese"
let descriptor_description = descriptor let descriptor_description = descriptor
.tools .tools
.iter() .iter()
.find(|tool| tool.name == "TicketShow") .find(|tool| tool.name == "ShowTicket")
.expect("TicketShow declared") .expect("ShowTicket declared")
.description .description
.clone(); .clone();
assert!(descriptor_description.contains("Ticket record language: Japanese")); assert!(descriptor_description.contains("Ticket record language: Japanese"));
@@ -1214,7 +1491,7 @@ language = "Japanese"
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len()); assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES); 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("Ticket record language: Japanese"));
assert!(description.contains("distinct from worker.language")); assert!(description.contains("distinct from worker.language"));
assert!(description.contains("Preserve protocol literals")); assert!(description.contains("Preserve protocol literals"));
+1 -1
View File
@@ -132,7 +132,7 @@ mod tests {
let request = ShutdownAfterIdleRequest::default(); let request = ShutdownAfterIdleRequest::default();
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true); 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()); assert!(!request.is_requested());
} }
+16 -22
View File
@@ -28,7 +28,7 @@ use crate::internal_worker::{
use crate::prompt::catalog::PromptCatalog; use crate::prompt::catalog::PromptCatalog;
use crate::spawn::registry::SpawnedWorkerRegistry; use crate::spawn::registry::SpawnedWorkerRegistry;
use crate::worker::{ use crate::worker::{
ReviewerAttemptContext, ReviewerChildWorkspaceClient, Worker, WorkerFilesystemAuthority, ReviewerChildWorkspaceClient, ReviewerContext, Worker, WorkerFilesystemAuthority,
WorkspaceRequest, WorkspaceRequestMethod, WorkspaceRequest, WorkspaceRequestMethod,
}; };
use protocol::Method; use protocol::Method;
@@ -58,8 +58,8 @@ struct SubWorkerSpawnInput {
/// spawner's explicit delegation authority; direct tool scope alone is not /// spawner's explicit delegation authority; direct tool scope alone is not
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true. /// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
scope: Vec<ScopeRuleInput>, scope: Vec<ScopeRuleInput>,
/// Binds an actual read-only builtin Reviewer child to an immutable Merge Request revision. /// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate.
/// Review attempt identity and capability material are generated by the trusted spawn layer. /// Review capability material is generated by the trusted spawn layer.
#[serde(default)] #[serde(default)]
review: Option<ReviewerHandoffInput>, review: Option<ReviewerHandoffInput>,
} }
@@ -67,7 +67,6 @@ struct SubWorkerSpawnInput {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ReviewerHandoffInput { struct ReviewerHandoffInput {
ticket_id: String, ticket_id: String,
revision_id: String,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -337,9 +336,9 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro
let Some(review) = &input.review else { let Some(review) = &input.review else {
return Ok(()); 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( 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") { if input.profile.as_deref() != Some("builtin:reviewer") {
@@ -418,11 +417,9 @@ impl Tool for SubWorkerSpawnTool {
.map_err(|error| { .map_err(|error| {
ToolError::ExecutionFailed(format!("resolve child manifest: {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.ticket_id.clone(),
review.revision_id.clone(),
uuid::Uuid::now_v7().to_string(),
format!( format!(
"{}{}", "{}{}",
uuid::Uuid::now_v7().simple(), uuid::Uuid::now_v7().simple(),
@@ -431,7 +428,7 @@ impl Tool for SubWorkerSpawnTool {
) )
}); });
let child_workspace_context = 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 = let workspace_id =
self.workspace_context self.workspace_context
.workspace_id() .workspace_id()
@@ -450,9 +447,8 @@ impl Tool for SubWorkerSpawnTool {
let child_client: Arc<dyn crate::worker::WorkspaceClient> = let child_client: Arc<dyn crate::worker::WorkspaceClient> =
Arc::new(ReviewerChildWorkspaceClient::new( Arc::new(ReviewerChildWorkspaceClient::new(
parent_client.clone(), parent_client.clone(),
ReviewerAttemptContext { ReviewerContext {
ticket_id: ticket_id.clone(), ticket_id: ticket_id.clone(),
revision_id: revision_id.clone(),
}, },
capability_token.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(|| { 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_session_id = session.session_id_string();
let child_registration = WorkspaceRequest::json( let child_registration = WorkspaceRequest::json(
@@ -577,15 +573,13 @@ impl Tool for SubWorkerSpawnTool {
))); )));
} }
let body = serde_json::json!({ let body = serde_json::json!({
"attempt_id": attempt_id,
"revision_id": revision_id,
"child_session_id": child_session_id, "child_session_id": child_session_id,
"capability_token": capability_token, "capability_token": capability_token,
}); });
let request = WorkspaceRequest::json( let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!( format!(
"/api/w/{}/tickets/{}/merge-request/review-attempts", "/api/w/{}/tickets/{}/merge-request/review-capabilities",
workspace_id.as_str(), workspace_id.as_str(),
ticket_id ticket_id
), ),
@@ -596,12 +590,12 @@ impl Tool for SubWorkerSpawnTool {
.client() .client()
.execute(request) .execute(request)
.map_err(|error| { .map_err(|error| {
ToolError::ExecutionFailed(format!("register reviewer attempt: {error}")) ToolError::ExecutionFailed(format!("register review capability: {error}"))
})?; })?;
if !response.is_success() { if !response.is_success() {
let _ = session.stop().await; let _ = session.stop().await;
return Err(ToolError::ExecutionFailed(format!( return Err(ToolError::ExecutionFailed(format!(
"register reviewer attempt failed with status {}: {}", "register review capability failed with status {}: {}",
response.status, response.body response.status, response.body
))); )));
} }
@@ -1044,21 +1038,21 @@ mod tests {
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer", "name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"/tmp/work","permission":"read"}], "scope":[{"target":"/tmp/work","permission":"read"}],
"review":{"ticket_id":"T1","revision_id":"V1"} "review":{"ticket_id":"T1"}
})) }))
.unwrap(); .unwrap();
assert!(validate_reviewer_handoff(&valid).is_ok()); assert!(validate_reviewer_handoff(&valid).is_ok());
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:coder", "name":"reviewer","task":"review","profile":"builtin:coder",
"scope":[{"target":"/tmp/work","permission":"read"}], "scope":[{"target":"/tmp/work","permission":"read"}],
"review":{"ticket_id":"T1","revision_id":"V1"} "review":{"ticket_id":"T1"}
})) }))
.unwrap(); .unwrap();
assert!(validate_reviewer_handoff(&wrong_profile).is_err()); assert!(validate_reviewer_handoff(&wrong_profile).is_err());
let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer", "name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"/tmp/work","permission":"write"}], "scope":[{"target":"/tmp/work","permission":"write"}],
"review":{"ticket_id":"T1","revision_id":"V1"} "review":{"ticket_id":"T1"}
})) }))
.unwrap(); .unwrap();
assert!(validate_reviewer_handoff(&writable).is_err()); assert!(validate_reviewer_handoff(&writable).is_err());
+7 -13
View File
@@ -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. /// It is never accepted from a model-visible tool argument.
fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> { fn reviewer_context(&self) -> Option<&ReviewerContext> {
None None
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewerAttemptContext { pub struct ReviewerContext {
pub ticket_id: String, pub ticket_id: String,
pub revision_id: String,
} }
#[derive(Debug)] #[derive(Debug)]
pub struct ReviewerChildWorkspaceClient { pub struct ReviewerChildWorkspaceClient {
inner: Arc<dyn WorkspaceClient>, inner: Arc<dyn WorkspaceClient>,
context: ReviewerAttemptContext, context: ReviewerContext,
capability_token: String, capability_token: String,
} }
impl ReviewerChildWorkspaceClient { impl ReviewerChildWorkspaceClient {
pub fn new( pub fn new(
inner: Arc<dyn WorkspaceClient>, inner: Arc<dyn WorkspaceClient>,
context: ReviewerAttemptContext, context: ReviewerContext,
capability_token: String, capability_token: String,
) -> Self { ) -> Self {
Self { Self {
@@ -282,7 +281,7 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
fn is_available(&self) -> bool { fn is_available(&self) -> bool {
self.inner.is_available() self.inner.is_available()
} }
fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> { fn reviewer_context(&self) -> Option<&ReviewerContext> {
Some(&self.context) Some(&self.context)
} }
@@ -306,10 +305,6 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
"review submission body must be an object".to_string(), "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( object.insert(
"capability_token".to_string(), "capability_token".to_string(),
serde_json::Value::String(self.capability_token.clone()), serde_json::Value::String(self.capability_token.clone()),
@@ -450,9 +445,8 @@ mod reviewer_client_tests {
}); });
let client = ReviewerChildWorkspaceClient::new( let client = ReviewerChildWorkspaceClient::new(
inner, inner,
ReviewerAttemptContext { ReviewerContext {
ticket_id: "T1".into(), ticket_id: "T1".into(),
revision_id: "V1".into(),
}, },
"secret".into(), "secret".into(),
); );
File diff suppressed because it is too large Load Diff
+210
View File
@@ -49,9 +49,11 @@ pub struct TicketDetail {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub readiness: Option<String>,
pub priority: String, pub priority: String,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub item_revision: String,
pub queued_by: Option<String>, pub queued_by: Option<String>,
pub queued_at: Option<String>, pub queued_at: Option<String>,
pub assignee: Option<String>, pub assignee: Option<String>,
@@ -62,9 +64,15 @@ pub struct TicketDetail {
pub body_truncated: bool, pub body_truncated: bool,
pub event_count: usize, pub event_count: usize,
pub events: Vec<TicketEventDetail>, pub events: Vec<TicketEventDetail>,
pub event_page: QueryPage,
pub artifact_count: usize, pub artifact_count: usize,
pub artifacts: Vec<String>, pub artifacts: Vec<String>,
pub relations: TicketRelationView, 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 resolution: Option<String>,
pub record_source: String, pub record_source: String,
} }
@@ -73,6 +81,7 @@ pub struct TicketDetail {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketEventDetail { pub struct TicketEventDetail {
pub sequence: usize, pub sequence: usize,
pub event_ref: String,
pub kind: String, pub kind: String,
pub author: Option<String>, pub author: Option<String>,
pub at: Option<String>, pub at: Option<String>,
@@ -83,6 +92,8 @@ pub struct TicketEventDetail {
pub state_field: Option<String>, pub state_field: Option<String>,
pub heading: Option<String>, pub heading: Option<String>,
pub body: Option<String>, pub body: Option<String>,
pub attributes: std::collections::BTreeMap<String, String>,
pub references: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveSummary { pub struct ObjectiveSummary {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub summary: String, pub summary: String,
pub linked_tickets: Vec<String>, pub linked_tickets: Vec<String>,
@@ -201,12 +397,16 @@ pub struct ObjectiveDetail {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub revision: String,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub linked_tickets: Vec<String>, pub linked_tickets: Vec<String>,
pub linked_ticket_summaries: Vec<ObjectiveLinkedTicketSummary>,
pub resources: Vec<ObjectiveResourceSummary>, pub resources: Vec<ObjectiveResourceSummary>,
pub body: String, pub body: String,
pub body_truncated: bool, pub body_truncated: bool,
pub events: Vec<ObjectiveEventDetail>,
pub event_page: QueryPage,
pub record_source: String, pub record_source: String,
} }
@@ -227,7 +427,17 @@ pub fn ticket_api_typescript() -> String {
InvalidProjectRecord::decl(&config), InvalidProjectRecord::decl(&config),
TicketSummary::decl(&config), TicketSummary::decl(&config),
TicketListResponse::decl(&config), TicketListResponse::decl(&config),
QueryPage::decl(&config),
TicketEventDetail::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), TicketRelation::decl(&config),
DerivedTicketRelation::decl(&config), DerivedTicketRelation::decl(&config),
TicketRelationBlocker::decl(&config), TicketRelationBlocker::decl(&config),
File diff suppressed because it is too large Load Diff
+33
View File
@@ -626,6 +626,12 @@ pub trait ControlPlaneStore: Send + Sync {
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>; fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>;
fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result<Vec<ObjectiveRecord>>; 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( fn get_objective(
&self, &self,
workspace_id: &str, workspace_id: &str,
@@ -1532,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( fn get_objective(
&self, &self,
workspace_id: &str, workspace_id: &str,
+4 -4
View File
@@ -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: Workers with the Ticket built-in feature can use typed Ticket tools:
- `TicketCreate` - `TicketCreate`
- `TicketList`lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority. - `QueryTicket`bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata.
- `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds. - `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` - `TicketComment`
- `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete` - `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete`
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input. - `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input.
- `TicketClose` - `TicketClose`
- `TicketRelationRecord` - `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/`. 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.
+1 -1
View File
@@ -10,4 +10,4 @@ When creating a commit, use the change type as the subject prefix, not the affec
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. 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 or appending an immutable Merge Request revision, 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. 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.
+2 -2
View File
@@ -1,9 +1,9 @@
## Ticket workflow ## 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. 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. 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 -2
View File
@@ -4,6 +4,6 @@ Treat the first committed user message as the bounded Ticket/action context and
{% include "common.git" %} {% include "common.git" %}
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. 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 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. 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 -1
View File
@@ -4,7 +4,7 @@ You are the Ticket Orchestrator role.
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. 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. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
+2 -2
View File
@@ -1,7 +1,7 @@
You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder. 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. 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; 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 = { export type TicketEventDetail = {
sequence: number; sequence: number;
event_ref: string;
kind: string; kind: string;
author: string | null; author: string | null;
at: string | null; at: string | null;
@@ -35,6 +46,99 @@ export type TicketEventDetail = {
state_field: string | null; state_field: string | null;
heading: string | null; heading: string | null;
body: 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 = { export type TicketRelation = {
@@ -80,9 +184,11 @@ export type TicketDetail = {
id: string; id: string;
title: string; title: string;
state: string; state: string;
readiness: string | null;
priority: string; priority: string;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
item_revision: string;
queued_by: string | null; queued_by: string | null;
queued_at: string | null; queued_at: string | null;
assignee: string | null; assignee: string | null;
@@ -93,9 +199,15 @@ export type TicketDetail = {
body_truncated: boolean; body_truncated: boolean;
event_count: number; event_count: number;
events: Array<TicketEventDetail>; events: Array<TicketEventDetail>;
event_page: QueryPage;
artifact_count: number; artifact_count: number;
artifacts: Array<string>; artifacts: Array<string>;
relations: TicketRelationView; relations: TicketRelationView;
linked_objectives: Array<ObjectiveLinkSummary>;
implementation_reports: Array<TicketEvidenceEvent>;
current_assignment: TicketAssignmentSummary | null;
merge_request: TicketMergeRequestSummary | null;
evidence: TicketEvidenceSummary;
resolution: string | null; resolution: string | null;
record_source: string; record_source: string;
}; };
@@ -17,22 +17,52 @@
TicketDetail, TicketDetail,
} from "$lib/workspace/sidebar/types"; } 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 = { type MergeRequestDetail = {
state: "draft" | "open" | "closed" | "merged"; state: "open" | "closed" | "merged";
review_status: "pending" | "approved" | "changes_requested"; selector_from: string | null;
target_ref_selector?: string | null; selector_to: string;
target_status: "known" | "unknown"; source: RefProjection;
observed_target_commit?: string | null; target: RefProjection;
current_revision: { revision_id: string; head_commit: string; changed_paths: string[]; summary: string }; thread: MergeRequestThreadEvent[];
current_review?: { decision: string; body: string; reviewer_effective_profile: string } | null;
merged_revision_id?: string | null;
merged_target_commit?: string | null;
merged_result_commit?: string | null;
merge_strategy?: "fast_forward" | "merge" | null;
merge_resolution?: "none" | "clean" | "conflicts_resolved" | null;
merged_by_runtime_id?: string | null;
merged_by_worker_id?: string | null;
merged_at?: string | null;
}; };
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done"); const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done");
@@ -56,6 +86,23 @@
let ticket = $state<TicketDetail>(loadedTicket); let ticket = $state<TicketDetail>(loadedTicket);
let mergeRequest = $state<MergeRequestDetail | null>(initialData.mergeRequest.data ?? null); 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 editing = $state(false);
let editTitle = $state(loadedTicket.title); let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body); let editBody = $state(loadedTicket.body);
@@ -353,29 +400,38 @@
{#if data.mergeRequest.error} {#if data.mergeRequest.error}
<p class="workspace-callout is-error">{data.mergeRequest.error}</p> <p class="workspace-callout is-error">{data.mergeRequest.error}</p>
{:else if mergeRequest} {:else if mergeRequest}
<p><strong>{mergeRequest.state}</strong> · {mergeRequest.review_status}</p> <p><strong>{mergeRequest.state}</strong></p>
<p>Target <code>{mergeRequest.target_ref_selector ?? "unknown"}</code> · {mergeRequest.target_status}</p> <p>From <code>{mergeRequest.selector_from ?? "requires repair"}</code> · {mergeRequest.source.status}{mergeRequest.source.ref ? ` @ ${mergeRequest.source.ref}` : ""}</p>
{#if mergeRequest.observed_target_commit}<p>Target tip <code>{mergeRequest.observed_target_commit}</code></p>{/if} <p>To <code>{mergeRequest.selector_to}</code> · {mergeRequest.target.status}{mergeRequest.target.ref ? ` @ ${mergeRequest.target.ref}` : ""}</p>
<p><code>{mergeRequest.current_revision.revision_id}</code></p> {#if currentReviewRequest?.kind === "review_requested"}
<p>Head <code>{mergeRequest.current_revision.head_commit}</code></p> <p>Review requested for <code>{currentReviewRequest.subject_ref}</code></p>
{#if mergeRequest.merged_result_commit}
<p>
Final merge · {mergeRequest.merge_strategy} / {mergeRequest.merge_resolution}
</p>
<p>
Target before <code>{mergeRequest.merged_target_commit}</code> · result
<code>{mergeRequest.merged_result_commit}</code>
</p>
<p>
Revision <code>{mergeRequest.merged_revision_id}</code> · completed by
<code>{mergeRequest.merged_by_runtime_id}/{mergeRequest.merged_by_worker_id}</code>
</p>
{/if} {/if}
{#if mergeRequest.current_revision.summary}<p>{mergeRequest.current_revision.summary}</p>{/if} {#if currentReview?.kind === "review"}
{#if mergeRequest.current_review} <p><strong>{currentReview.decision}</strong> by <code>{currentReview.reviewer.runtime_id}/{currentReview.reviewer.worker_id}</code></p>
<p><strong>{mergeRequest.current_review.decision}</strong> by {mergeRequest.current_review.reviewer_effective_profile}</p> {#if currentReview.body}<RichMarkdown text={currentReview.body} />{/if}
{#if mergeRequest.current_review.body}<RichMarkdown text={mergeRequest.current_review.body} />{/if}
{/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} {:else}
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p> <p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
{/if} {/if}