Compare commits
8
Commits
develop
...
hare/develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d7da04502 | ||
|
|
b4099ddda4 | ||
|
|
04410a5022 | ||
|
|
682747e9f7 | ||
|
|
00f327da4b | ||
|
|
aa7bb6282d | ||
|
|
33d7ef8fa0 | ||
|
|
0ea6308dae |
@@ -72,6 +72,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compiled_handoff_uses_host_owned_retry_identity() {
|
||||||
|
let flow = builtin_flow_source(CODER_REVIEW_FLOW_SLUG)
|
||||||
|
.unwrap()
|
||||||
|
.compile()
|
||||||
|
.unwrap();
|
||||||
|
let complete = flow
|
||||||
|
.state(&crate::StateId::new("complete").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
complete
|
||||||
|
.instructions
|
||||||
|
.contains("The Host owns transport retry identity")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
complete
|
||||||
|
.instructions
|
||||||
|
.contains("do not supply operation_key")
|
||||||
|
);
|
||||||
|
assert!(!complete.instructions.contains("same key and full input"));
|
||||||
|
let fix = flow.state(&crate::StateId::new("fix").unwrap()).unwrap();
|
||||||
|
assert!(
|
||||||
|
fix.instructions
|
||||||
|
.contains("rerun validation affected by the fixes")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fix.instructions
|
||||||
|
.contains("Cite reusable evidence for unaffected scope")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fix.instructions
|
||||||
|
.contains("Source movement invalidates the prior verdict")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
||||||
assert!(!builtin_flow_sources().is_empty());
|
assert!(!builtin_flow_sources().is_empty());
|
||||||
|
|||||||
@@ -8978,6 +8978,9 @@ pub struct MergeRequestListQuery {
|
|||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct MergeRequestThreadQuery {
|
pub struct MergeRequestThreadQuery {
|
||||||
|
/// Show only: summary omits history; detail returns an exact bounded thread page.
|
||||||
|
/// Omission retains the existing HTTP detail response.
|
||||||
|
pub view: Option<MergeRequestReadView>,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
pub after: Option<u64>,
|
pub after: Option<u64>,
|
||||||
#[schemars(range(min = 0, max = 1000))]
|
#[schemars(range(min = 0, max = 1000))]
|
||||||
@@ -9005,6 +9008,73 @@ pub struct MergeRequestLinkedTicketResponse {
|
|||||||
pub key: Option<String>,
|
pub key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MergeRequestReadView {
|
||||||
|
Summary,
|
||||||
|
#[default]
|
||||||
|
Detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MergeRequestEventReference {
|
||||||
|
pub event_id: String,
|
||||||
|
/// Exact event: read detail with after = sequence - 1 and limit = 1.
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
|
pub sequence: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MergeRequestReviewSummary {
|
||||||
|
pub event: MergeRequestEventReference,
|
||||||
|
pub request_event_id: String,
|
||||||
|
pub subject_ref: String,
|
||||||
|
pub ticket_content_digest: String,
|
||||||
|
pub decision: ReviewDecision,
|
||||||
|
/// Findings retain their order in the exact review event; no finding body is truncated here.
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
|
pub findings_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computed from complete persisted evidence, never from the returned history page.
|
||||||
|
/// This observation is not integration authority or current-requirements approval.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MergeRequestStatusSummary {
|
||||||
|
/// unknown, none, pending, approved, or request_changes for the observed source.
|
||||||
|
pub review_status: String,
|
||||||
|
pub review: Option<MergeRequestReviewSummary>,
|
||||||
|
pub latest_review_subject_ref: Option<String>,
|
||||||
|
pub latest_event: Option<MergeRequestEventReference>,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
|
pub thread_event_count: usize,
|
||||||
|
/// Existing readiness semantics; review bodies are omitted in this compact projection.
|
||||||
|
pub readiness: MergeRequestReadinessResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MergeRequestEventPage {
|
||||||
|
/// Summary deliberately omits events. Read detail from the beginning to obtain history.
|
||||||
|
pub omitted: bool,
|
||||||
|
pub has_more: bool,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
|
pub next_after: Option<u64>,
|
||||||
|
#[schemars(range(min = 1, max = 200))]
|
||||||
|
pub limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -9014,6 +9084,9 @@ pub struct MergeRequestDetailResponse {
|
|||||||
pub source: MergeRequestRefResponse,
|
pub source: MergeRequestRefResponse,
|
||||||
pub target: MergeRequestRefResponse,
|
pub target: MergeRequestRefResponse,
|
||||||
pub linked_tickets: Vec<MergeRequestLinkedTicketResponse>,
|
pub linked_tickets: Vec<MergeRequestLinkedTicketResponse>,
|
||||||
|
pub view: MergeRequestReadView,
|
||||||
|
pub summary: MergeRequestStatusSummary,
|
||||||
|
pub event_page: MergeRequestEventPage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ pub struct PublicMaterialLimits {
|
|||||||
pub max_entries: usize,
|
pub max_entries: usize,
|
||||||
/// Allocation ceiling for one persisted JSONL record, not an entire run.
|
/// Allocation ceiling for one persisted JSONL record, not an entire run.
|
||||||
pub max_record_bytes: u64,
|
pub max_record_bytes: u64,
|
||||||
/// Aggregate fixed storage extent, bounding replay metadata as well as body decoding.
|
|
||||||
pub max_storage_bytes: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PublicMaterialLimits {
|
impl Default for PublicMaterialLimits {
|
||||||
@@ -63,7 +61,6 @@ impl Default for PublicMaterialLimits {
|
|||||||
max_segments: 256,
|
max_segments: 256,
|
||||||
max_entries: 1_000_000,
|
max_entries: 1_000_000,
|
||||||
max_record_bytes: 64 * 1024 * 1024,
|
max_record_bytes: 64 * 1024 * 1024,
|
||||||
max_storage_bytes: 256 * 1024 * 1024,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,7 +373,6 @@ impl CommittedPublicReplay {
|
|||||||
Layout::Fs(root) => root.clone(),
|
Layout::Fs(root) => root.clone(),
|
||||||
};
|
};
|
||||||
let mut file_ends = HashMap::new();
|
let mut file_ends = HashMap::new();
|
||||||
let mut fixed_bytes = 0u64;
|
|
||||||
for entry in std::fs::read_dir(directory)? {
|
for entry in std::fs::read_dir(directory)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
@@ -394,14 +390,8 @@ impl CommittedPublicReplay {
|
|||||||
return Err(PublicMaterialError::ResourceLimit);
|
return Err(PublicMaterialError::ResourceLimit);
|
||||||
}
|
}
|
||||||
let reader = RecordReader::open(&path, limits.max_record_bytes)?;
|
let reader = RecordReader::open(&path, limits.max_record_bytes)?;
|
||||||
// This also bounds retained descriptor/identity allocations during replay,
|
// Freeze the extent, but do not cap the lifetime storage total. Records
|
||||||
// not just the final capture. Oversized histories fail explicitly.
|
// are decoded individually; segment and entry limits bound replay metadata.
|
||||||
fixed_bytes = fixed_bytes
|
|
||||||
.checked_add(reader.end)
|
|
||||||
.ok_or(PublicMaterialError::ResourceLimit)?;
|
|
||||||
if fixed_bytes > limits.max_storage_bytes {
|
|
||||||
return Err(PublicMaterialError::ResourceLimit);
|
|
||||||
}
|
|
||||||
file_ends.insert(id, reader.end);
|
file_ends.insert(id, reader.end);
|
||||||
}
|
}
|
||||||
let mut chain = Vec::new();
|
let mut chain = Vec::new();
|
||||||
|
|||||||
@@ -1802,7 +1802,7 @@ fn recording_capture_fence_blocks_publication_and_value_survives_later_append_an
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recording_capture_rejects_foreign_guard_and_storage_or_body_allocation_excess() {
|
fn recording_capture_rejects_foreign_guard_and_record_or_body_allocation_excess() {
|
||||||
use crate::capture::*;
|
use crate::capture::*;
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let root = temp.path().join("session");
|
let root = temp.path().join("session");
|
||||||
@@ -1834,7 +1834,7 @@ fn recording_capture_rejects_foreign_guard_and_storage_or_body_allocation_excess
|
|||||||
));
|
));
|
||||||
let guard = retention.lock_live(session).unwrap();
|
let guard = retention.lock_live(session).unwrap();
|
||||||
let limits = PublicMaterialLimits {
|
let limits = PublicMaterialLimits {
|
||||||
max_storage_bytes: 1,
|
max_record_bytes: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -2457,10 +2457,6 @@ fn recording_catalog_errors_do_not_invoke_callback_or_relax_resource_and_identit
|
|||||||
Err(PublicMaterialError::Inconsistent)
|
Err(PublicMaterialError::Inconsistent)
|
||||||
));
|
));
|
||||||
for limits in [
|
for limits in [
|
||||||
PublicMaterialLimits {
|
|
||||||
max_storage_bytes: 1,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
PublicMaterialLimits {
|
PublicMaterialLimits {
|
||||||
max_record_bytes: 1,
|
max_record_bytes: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
+319
-38
@@ -132,9 +132,7 @@ System events and case judgments cannot be edited. The Workspace backend supplie
|
|||||||
const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
|
const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
|
||||||
overview. Workspace-backed Workers replace this definition with the richer authoritative \
|
overview. Workspace-backed Workers replace this definition with the richer authoritative \
|
||||||
text/event/evidence/relation/Objective/time/attention query.";
|
text/event/evidence/relation/Objective/time/attention query.";
|
||||||
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \
|
const SHOW_DESCRIPTION: &str = "Read one Ticket by id or exact query. Use view=summary for status/CAS without prose or history; detail (default) for canonical initial_post and bounded recent discussion; history with event_cursor for older thread pages; post with an observed event_ref for the exact full post. Never use truncated bodies for edit CAS or guess event identities. Workspace-backed Workers retain the same views with authoritative delegation and MR evidence.";
|
||||||
Ticket backend. Output includes the initial comment and recent events with stable event_ref, author/time and edit metadata, bounded Markdown body, resolution, and artifact \
|
|
||||||
metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection.";
|
|
||||||
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown.";
|
const 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.";
|
||||||
@@ -549,8 +547,50 @@ impl QueryTicketParams {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Model-facing read modes shared by generic Ticket and Workspace Tool/WIP reads.
|
||||||
|
#[derive(
|
||||||
|
Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ShowTicketView {
|
||||||
|
Summary,
|
||||||
|
#[default]
|
||||||
|
Detail,
|
||||||
|
History,
|
||||||
|
Post,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShowTicketView {
|
||||||
|
pub fn validate(
|
||||||
|
self,
|
||||||
|
event_cursor: Option<&str>,
|
||||||
|
event_ref: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if (self == Self::Post) != event_ref.is_some()
|
||||||
|
|| event_ref.is_some_and(|value| value.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"event_ref is required only for view=post; use an observed identity".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if event_cursor.is_some() && !matches!(self, Self::Detail | Self::History) {
|
||||||
|
return Err("event_cursor is only valid for detail/history".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct ShowTicketParams {
|
struct ShowTicketParams {
|
||||||
|
/// summary omits prose/history; detail is the default; history returns only a thread page; post returns an exact full post up to 1 MiB (larger posts fail, never truncate).
|
||||||
|
#[serde(default)]
|
||||||
|
view: ShowTicketView,
|
||||||
|
/// Opaque continuation from event_page. Valid for detail/history only.
|
||||||
|
#[serde(default)]
|
||||||
|
event_cursor: Option<String>,
|
||||||
|
/// Exact observed post identity. Required only for view=post.
|
||||||
|
#[serde(default)]
|
||||||
|
event_ref: Option<String>,
|
||||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided.
|
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
id: Option<String>,
|
id: Option<String>,
|
||||||
@@ -1096,6 +1136,10 @@ impl Tool for ShowTicketTool {
|
|||||||
_ctx: agen::tool::ToolExecutionContext,
|
_ctx: agen::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let params: ShowTicketParams = parse_input("ShowTicket", input_json)?;
|
let params: ShowTicketParams = parse_input("ShowTicket", input_json)?;
|
||||||
|
params
|
||||||
|
.view
|
||||||
|
.validate(params.event_cursor.as_deref(), params.event_ref.as_deref())
|
||||||
|
.map_err(ToolError::InvalidArgument)?;
|
||||||
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(
|
||||||
@@ -1117,10 +1161,45 @@ impl Tool for ShowTicketTool {
|
|||||||
ticket.meta.id,
|
ticket.meta.id,
|
||||||
ticket.meta.workflow_state.as_str()
|
ticket.meta.workflow_state.as_str()
|
||||||
);
|
);
|
||||||
Ok(json_output(
|
let value = match params.view {
|
||||||
summary,
|
ShowTicketView::Summary => ticket_status_json(&ticket),
|
||||||
ticket_json(&ticket, event_limit, artifact_limit, body_max_bytes),
|
ShowTicketView::Post => {
|
||||||
))
|
let event = ticket
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|event| Some(event.event_ref.as_str()) == params.event_ref.as_deref())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::InvalidArgument(
|
||||||
|
"Observed post identity not found in this Ticket".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// Match the Workspace full-post route: never silently truncate CAS input.
|
||||||
|
if event.body.as_str().len() > 1024 * 1024 {
|
||||||
|
return Err(ToolError::ExecutionFailed(
|
||||||
|
"Exact post exceeds the 1 MiB full-read limit; do not edit from a truncated preview".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
json!({"ticket": ticket.meta.id, "post": ticket_event_json(event, usize::MAX)})
|
||||||
|
}
|
||||||
|
ShowTicketView::Detail | ShowTicketView::History => {
|
||||||
|
let (events, page) = ticket_event_page(
|
||||||
|
&ticket,
|
||||||
|
params.event_cursor.as_deref(),
|
||||||
|
event_limit,
|
||||||
|
body_max_bytes,
|
||||||
|
)?;
|
||||||
|
if params.view == ShowTicketView::History {
|
||||||
|
json!({"ticket": ticket.meta.id, "thread": events, "event_page": page})
|
||||||
|
} else {
|
||||||
|
let mut detail =
|
||||||
|
ticket_json(&ticket, event_limit, artifact_limit, body_max_bytes);
|
||||||
|
detail["events"] = events;
|
||||||
|
detail["event_page"] = page;
|
||||||
|
detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(json_output(summary, value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1694,8 +1773,70 @@ fn ticket_relations_json(ticket: &Ticket) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_event_json(event: &crate::TicketEvent, body_max_bytes: usize) -> Value {
|
fn ticket_status_json(ticket: &Ticket) -> Value {
|
||||||
json!({
|
json!({
|
||||||
|
"ticket": ticket.meta.resource_key.as_deref().unwrap_or(&ticket.meta.id),
|
||||||
|
"title": ticket.meta.title,
|
||||||
|
"state": ticket.meta.workflow_state,
|
||||||
|
"content_digest": crate::ticket_content_digest(ticket),
|
||||||
|
"closure_operation_key": ticket.closure_operation_key,
|
||||||
|
"current_closure": ticket.current_closure.as_ref().map(|j| json!({
|
||||||
|
"operation_key": j.operation_key, "reason": j.reason, "author": j.author, "at": j.at
|
||||||
|
})),
|
||||||
|
"targets": ticket.meta.targets,
|
||||||
|
"tags": ticket.tags,
|
||||||
|
"assignee": ticket.meta.assignee,
|
||||||
|
"readiness": ticket.meta.readiness,
|
||||||
|
"updated_at": ticket.meta.updated_at,
|
||||||
|
"latest_event_ref": ticket.events.last().map(|event| &event.event_ref),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor is the persisted identity of the oldest returned event, never an offset.
|
||||||
|
// Appending newer events between pages therefore cannot shift the continuation.
|
||||||
|
fn ticket_event_page(
|
||||||
|
ticket: &Ticket,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
body_max_bytes: usize,
|
||||||
|
) -> Result<(Value, Value), ToolError> {
|
||||||
|
let events: Vec<_> = ticket
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| !event.is_initial)
|
||||||
|
.collect();
|
||||||
|
let end = match cursor {
|
||||||
|
None => events.len(),
|
||||||
|
Some(cursor) => events
|
||||||
|
.iter()
|
||||||
|
.position(|event| event.event_ref == cursor)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::InvalidArgument(
|
||||||
|
"Unknown Ticket event_cursor; reuse a returned cursor".into(),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
let start = end.saturating_sub(limit.max(1));
|
||||||
|
let items: Vec<_> = events[start..end]
|
||||||
|
.iter()
|
||||||
|
.map(|event| ticket_event_json(event, body_max_bytes))
|
||||||
|
.collect();
|
||||||
|
let next_cursor = if start > 0 {
|
||||||
|
Some(&events[start].event_ref)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok((
|
||||||
|
json!({"count": events.len(), "returned": items.len(), "truncated": start > 0, "items": items}),
|
||||||
|
json!({"next_cursor": next_cursor, "has_more": start > 0}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_event_json(event: &crate::TicketEvent, body_max_bytes: usize) -> Value {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
json!({
|
||||||
|
"body_digest": Sha256::digest(event.body.as_str().as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
|
||||||
|
"editable": event.kind == TicketEventKind::Comment,
|
||||||
"event_ref": event.event_ref,
|
"event_ref": event.event_ref,
|
||||||
"is_initial": event.is_initial,
|
"is_initial": event.is_initial,
|
||||||
"kind": event.kind.as_str(),
|
"kind": event.kind.as_str(),
|
||||||
@@ -1722,13 +1863,8 @@ fn ticket_json(
|
|||||||
artifact_limit: usize,
|
artifact_limit: usize,
|
||||||
body_max_bytes: usize,
|
body_max_bytes: usize,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
let event_count = ticket.events.len();
|
let (events, event_page) = ticket_event_page(ticket, None, event_limit, body_max_bytes)
|
||||||
let events: Vec<_> = ticket
|
.expect("first thread page has no cursor to reject");
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.skip(event_count.saturating_sub(event_limit))
|
|
||||||
.map(|event| ticket_event_json(event, body_max_bytes))
|
|
||||||
.collect();
|
|
||||||
let artifact_count = ticket.artifacts.len();
|
let artifact_count = ticket.artifacts.len();
|
||||||
let artifacts: Vec<_> = ticket
|
let artifacts: Vec<_> = ticket
|
||||||
.artifacts
|
.artifacts
|
||||||
@@ -1753,17 +1889,12 @@ fn ticket_json(
|
|||||||
},
|
},
|
||||||
"content_digest": crate::ticket_content_digest(ticket),
|
"content_digest": crate::ticket_content_digest(ticket),
|
||||||
"closure_operation_key": ticket.closure_operation_key,
|
"closure_operation_key": ticket.closure_operation_key,
|
||||||
"current_closure": ticket.current_closure.as_ref().map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})),
|
"current_closure": ticket.current_closure.as_ref().map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "author": j.author, "references": j.references, "at": j.at})),
|
||||||
"case_history": { "count": ticket.case_history.len(), "items": ticket.case_history.iter().rev().take(event_limit).map(|j| json!({"operation_key": j.operation_key, "previous_closure_operation_key": j.previous_closure_operation_key, "action": j.action, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})).collect::<Vec<_>>() },
|
"case_history": { "count": ticket.case_history.len(), "items": ticket.case_history.iter().rev().take(event_limit).map(|j| json!({"operation_key": j.operation_key, "previous_closure_operation_key": j.previous_closure_operation_key, "action": j.action, "author": j.author, "references": j.references, "at": j.at})).collect::<Vec<_>>() },
|
||||||
"legacy_workflow_snapshot": ticket.legacy_workflow_snapshot,
|
"legacy_workflow_snapshot": ticket.legacy_workflow_snapshot,
|
||||||
"initial_post": ticket.events.iter().find(|event| event.is_initial).map(|event| ticket_event_json(event, body_max_bytes)),
|
"initial_post": ticket.events.iter().find(|event| event.is_initial).map(|event| ticket_event_json(event, body_max_bytes)),
|
||||||
"body": truncate_text(ticket.document.body.as_str(), body_max_bytes),
|
"events": events,
|
||||||
"events": {
|
"event_page": event_page,
|
||||||
"count": event_count,
|
|
||||||
"returned": events.len(),
|
|
||||||
"truncated": event_count > events.len(),
|
|
||||||
"items": events,
|
|
||||||
},
|
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"count": artifact_count,
|
"count": artifact_count,
|
||||||
"returned": artifacts.len(),
|
"returned": artifacts.len(),
|
||||||
@@ -2063,13 +2194,17 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||||
assert_eq!(value["events"]["count"], 2);
|
assert_eq!(value["events"]["count"], 1);
|
||||||
let projected = value["events"]["items"]
|
let projected = if post.is_initial {
|
||||||
.as_array()
|
&value["initial_post"]
|
||||||
.unwrap()
|
} else {
|
||||||
.iter()
|
value["events"]["items"]
|
||||||
.find(|e| e["event_ref"] == post.event_ref)
|
.as_array()
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|e| e["event_ref"] == post.event_ref)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
assert_eq!(projected["author"], json!(post.author));
|
assert_eq!(projected["author"], json!(post.author));
|
||||||
assert_eq!(projected["at"], json!(post.at));
|
assert_eq!(projected["at"], json!(post.at));
|
||||||
assert_eq!(projected["is_initial"], post.is_initial);
|
assert_eq!(projected["is_initial"], post.is_initial);
|
||||||
@@ -2104,7 +2239,8 @@ mod tests {
|
|||||||
value["initial_post"]["event_ref"],
|
value["initial_post"]["event_ref"],
|
||||||
before.events[0].event_ref
|
before.events[0].event_ref
|
||||||
);
|
);
|
||||||
assert_eq!(value["initial_post"]["body"], value["body"]);
|
assert_eq!(value["initial_post"]["body"], "Edited Initial body");
|
||||||
|
assert!(value.get("body").is_none());
|
||||||
assert_eq!(value["meta"]["created_by"], "creator");
|
assert_eq!(value["meta"]["created_by"], "creator");
|
||||||
assert_eq!(value["initial_post"]["author"], "creator");
|
assert_eq!(value["initial_post"]["author"], "creator");
|
||||||
let initial_keys = value["initial_post"]
|
let initial_keys = value["initial_post"]
|
||||||
@@ -2518,7 +2654,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
value["body"].as_str().unwrap().trim_start_matches('\n'),
|
value["initial_post"]["body"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.trim_start_matches('\n'),
|
||||||
"ONE\ntwo\nONE\n"
|
"ONE\ntwo\nONE\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2980,13 +3119,13 @@ mod tests {
|
|||||||
assert_eq!(value["closure_operation_key"], "tool-correct");
|
assert_eq!(value["closure_operation_key"], "tool-correct");
|
||||||
assert_eq!(value["case_history"]["count"], 2);
|
assert_eq!(value["case_history"]["count"], 2);
|
||||||
assert_eq!(value["case_history"]["items"].as_array().unwrap().len(), 1);
|
assert_eq!(value["case_history"]["items"].as_array().unwrap().len(), 1);
|
||||||
|
assert!(value["current_closure"].get("explanation").is_none());
|
||||||
assert!(
|
assert!(
|
||||||
value["current_closure"]["explanation"]
|
value["case_history"]["items"][0]
|
||||||
.as_str()
|
.get("explanation")
|
||||||
.unwrap()
|
.is_none()
|
||||||
.len()
|
|
||||||
< 256
|
|
||||||
);
|
);
|
||||||
|
assert_eq!(value["events"]["items"][0]["body_truncated"], true);
|
||||||
assert!(value["content_digest"].as_str().unwrap().len() == 64);
|
assert!(value["content_digest"].as_str().unwrap().len() == 64);
|
||||||
let mut reopen = correction;
|
let mut reopen = correction;
|
||||||
reopen["expected_closure_operation_key"] = json!("tool-correct");
|
reopen["expected_closure_operation_key"] = json!("tool-correct");
|
||||||
@@ -3015,6 +3154,148 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn show_views_deduplicate_bound_history_and_read_exact_unicode_posts() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&temp);
|
||||||
|
let body = "long-requirement-".repeat(700);
|
||||||
|
let mut input = NewTicket::new("Read projections");
|
||||||
|
input.body = MarkdownText::new(body.clone());
|
||||||
|
let reference = backend.create(input).unwrap();
|
||||||
|
for index in 0..5 {
|
||||||
|
backend
|
||||||
|
.add_event(
|
||||||
|
reference.id.clone().into(),
|
||||||
|
NewTicketEvent::new(TicketEventKind::Comment, format!("comment-{index}")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let before = backend.show(reference.id.clone().into()).unwrap();
|
||||||
|
let detail = ticket_json(&before, 20, 20, DEFAULT_BODY_MAX_BYTES);
|
||||||
|
assert_eq!(detail.to_string().matches(&body).count(), 1);
|
||||||
|
assert!(detail.get("body").is_none());
|
||||||
|
assert!(
|
||||||
|
detail["events"]["items"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|event| event["is_initial"] == false)
|
||||||
|
);
|
||||||
|
let mut larger = before.clone();
|
||||||
|
larger.document.body = MarkdownText::new("prose".repeat(100_000));
|
||||||
|
larger.events[0].body = larger.document.body.clone();
|
||||||
|
larger
|
||||||
|
.events
|
||||||
|
.extend(vec![before.events.last().unwrap().clone(); 1000]);
|
||||||
|
let small_summary = ticket_status_json(&before).to_string();
|
||||||
|
let large_summary = ticket_status_json(&larger).to_string();
|
||||||
|
assert_eq!(small_summary.len(), large_summary.len());
|
||||||
|
eprintln!(
|
||||||
|
"Ticket generic bytes: detail={}, summary={} (unchanged with 500KB body / 1000 extra events)",
|
||||||
|
detail.to_string().len(),
|
||||||
|
small_summary.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let show = tool_by_name(backend.clone(), "ShowTicket");
|
||||||
|
let mut cursor = Value::Null;
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
loop {
|
||||||
|
let output = show.execute(&json!({"id": reference.id, "view": "history", "event_limit": 2, "event_cursor": cursor}).to_string(), Default::default()).await.unwrap();
|
||||||
|
let page: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(page.as_object().unwrap().len(), 3);
|
||||||
|
for event in page["thread"]["items"].as_array().unwrap() {
|
||||||
|
seen.push(event["event_ref"].as_str().unwrap().to_owned());
|
||||||
|
}
|
||||||
|
if cursor.is_null() {
|
||||||
|
backend
|
||||||
|
.add_event(
|
||||||
|
reference.id.clone().into(),
|
||||||
|
NewTicketEvent::new(TicketEventKind::Comment, "newer append"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
cursor = page["event_page"]["next_cursor"].clone();
|
||||||
|
if cursor.is_null() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen.sort();
|
||||||
|
let mut expected: Vec<_> = before
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| !event.is_initial)
|
||||||
|
.map(|event| event.event_ref.clone())
|
||||||
|
.collect();
|
||||||
|
expected.sort();
|
||||||
|
assert_eq!(seen, expected);
|
||||||
|
assert!(
|
||||||
|
show.execute(
|
||||||
|
&json!({"id": reference.id, "view":"history", "event_cursor":"guessed"})
|
||||||
|
.to_string(),
|
||||||
|
Default::default()
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let unicode = "全文🦀é\n".repeat(20_000);
|
||||||
|
let post = &before.events[0];
|
||||||
|
let edit = tool_by_name(backend.clone(), "TicketEditPost");
|
||||||
|
let edit_args = json!({"ticket":reference.id, "event_ref":post.event_ref, "expected_body":post.body.as_str(), "body":unicode});
|
||||||
|
edit.execute(&edit_args.to_string(), Default::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let full = show.execute(&json!({"id":reference.id, "view":"post", "event_ref":post.event_ref, "body_max_bytes":1}).to_string(), Default::default()).await.unwrap();
|
||||||
|
let full: Value = serde_json::from_str(&full.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(full["post"]["body"], unicode);
|
||||||
|
assert_eq!(full["post"]["body_truncated"], false);
|
||||||
|
assert_eq!(full["post"]["editable"], true);
|
||||||
|
assert!(
|
||||||
|
edit.execute(&edit_args.to_string(), Default::default())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
for args in [
|
||||||
|
json!({"view":"post"}),
|
||||||
|
json!({"view":"summary", "event_ref":post.event_ref}),
|
||||||
|
json!({"view":"summary", "event_cursor":"x"}),
|
||||||
|
] {
|
||||||
|
let mut args = args;
|
||||||
|
args["id"] = json!(reference.id);
|
||||||
|
assert!(
|
||||||
|
show.execute(&args.to_string(), Default::default())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exact_post_read_rejects_oversize_instead_of_truncating_edit_input() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&temp);
|
||||||
|
let mut input = NewTicket::new("Full-post limit");
|
||||||
|
input.body = MarkdownText::new("x".repeat(1024 * 1024 + 1));
|
||||||
|
let reference = backend.create(input).unwrap();
|
||||||
|
let observed = backend.show(reference.id.clone().into()).unwrap();
|
||||||
|
let event_ref = &observed
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.is_initial)
|
||||||
|
.unwrap()
|
||||||
|
.event_ref;
|
||||||
|
let show = tool_by_name(backend.clone(), "ShowTicket");
|
||||||
|
let error = show
|
||||||
|
.execute(
|
||||||
|
&json!({"id":reference.id, "view":"post", "event_ref":event_ref}).to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("1 MiB"));
|
||||||
|
assert_eq!(backend.show(reference.id.into()).unwrap(), observed);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn case_tool_rejects_legacy_states_and_unrecognized_input_fields() {
|
async fn case_tool_rejects_legacy_states_and_unrecognized_input_fields() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -2069,6 +2069,103 @@ mod tests {
|
|||||||
compact
|
compact
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archive_removal_preserves_history_larger_than_256_mib_and_replays_receipt() {
|
||||||
|
use session_store::{
|
||||||
|
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
|
||||||
|
LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin,
|
||||||
|
Store,
|
||||||
|
};
|
||||||
|
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let worker = WorkerId::now_v7();
|
||||||
|
let worker_dir = temp.path().join("workers").join(worker.to_string());
|
||||||
|
write_json(
|
||||||
|
&worker_dir.join("worker.json"),
|
||||||
|
&serde_json::json!({"workspace_id": "workspace-a"}),
|
||||||
|
);
|
||||||
|
let session = session_store::new_session_id();
|
||||||
|
let segment = session_store::new_segment_id();
|
||||||
|
let store = session_store::WorkerSessionStore::new(worker_dir.join("session")).unwrap();
|
||||||
|
store
|
||||||
|
.create_segment(
|
||||||
|
session,
|
||||||
|
segment,
|
||||||
|
&[LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 1,
|
||||||
|
session_id: session,
|
||||||
|
system_prompt: None,
|
||||||
|
config: Default::default(),
|
||||||
|
history: Vec::new(),
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Each record is small enough to decode independently. Only the lifetime
|
||||||
|
// total exceeds the former ceiling; no individual resource limit is relaxed.
|
||||||
|
let text = "x".repeat(1024 * 1024);
|
||||||
|
for index in 0..257 {
|
||||||
|
store
|
||||||
|
.append(
|
||||||
|
session,
|
||||||
|
segment,
|
||||||
|
&LogEntry::AnnotatedUserInput {
|
||||||
|
ts: index + 2,
|
||||||
|
segments: Vec::new(),
|
||||||
|
extensions: Vec::new(),
|
||||||
|
history: vec![LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::Message {
|
||||||
|
role: LoggedRole::User,
|
||||||
|
content: vec![LoggedContentPart::Text { text: text.clone() }],
|
||||||
|
},
|
||||||
|
metadata: LoggedSessionHistoryMetadata {
|
||||||
|
input_metadata: None,
|
||||||
|
entry_id: LoggedSessionHistoryEntryId(format!("input-{index}")),
|
||||||
|
origin: LoggedSessionHistoryOrigin::LocalHumanInput,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let mut metadata = session_store::WorkerMetadata::new(
|
||||||
|
worker.to_string(),
|
||||||
|
Some(session_store::WorkerActiveSegmentRef::active_segment(
|
||||||
|
session, segment,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
metadata.workspace_id = Some("workspace-a".into());
|
||||||
|
write_json(&worker_dir.join("metadata.json"), &metadata);
|
||||||
|
let segment_path = format!("session/segments/{segment}.jsonl");
|
||||||
|
let source_bytes = fs::metadata(worker_dir.join(&segment_path)).unwrap().len();
|
||||||
|
assert!(source_bytes > 256 * 1024 * 1024);
|
||||||
|
|
||||||
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
|
let request = request(worker, SessionDisposition::Archive);
|
||||||
|
let result = provider.execute(&request).unwrap();
|
||||||
|
assert!(result.source_removed);
|
||||||
|
assert!(!worker_dir.exists());
|
||||||
|
let archive = provider.archive_dir("archive-a").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(archive.join(&segment_path)).unwrap().len(),
|
||||||
|
source_bytes
|
||||||
|
);
|
||||||
|
let reader = session_store::public_material::SessionPublicMaterialReader::open_worker(
|
||||||
|
&archive.join("session"),
|
||||||
|
segment,
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(reader.session_id(), session);
|
||||||
|
assert_eq!(reader.descriptors().len(), 257);
|
||||||
|
let retry = provider.execute(&request).unwrap();
|
||||||
|
assert!(retry.source_removed);
|
||||||
|
assert_eq!(retry.archive, result.archive);
|
||||||
|
assert_eq!(retry.operation_id, result.operation_id);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_migration_repairs_failed_archive_and_preserves_ingestion_receipt_on_retry() {
|
fn session_migration_repairs_failed_archive_and_preserves_ingestion_receipt_on_retry() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
//! identity only and report CleanupPending/unknown, not current cleanliness or removal permission.
|
//! identity only and report CleanupPending/unknown, not current cleanliness or removal permission.
|
||||||
//!
|
//!
|
||||||
//! The cleanup walker never reads ordinary file bodies or computes content digests. It records
|
//! The cleanup walker never reads ordinary file bodies or computes content digests. It records
|
||||||
//! device/inode, type, regular-file executable bits, and size/nanosecond mtime for files and symlinks.
|
//! device/inode, type, regular-file executable bits, and size/nanosecond mtime for non-directory
|
||||||
|
//! entries, including FIFOs and Unix sockets. IPC endpoints are never opened for I/O or connected.
|
||||||
//! Directories retain identity/type only because child unlink changes their timestamps. Symlink
|
//! Directories retain identity/type only because child unlink changes their timestamps. Symlink
|
||||||
//! targets are neither read nor followed. This is metadata protection, not byte equality:
|
//! targets are neither read nor followed. This is metadata protection, not byte equality:
|
||||||
//! same-size writes restoring mtime are not guaranteed to be detected. Normal Git inspection may
|
//! same-size writes restoring mtime are not guaranteed to be detected. Normal Git inspection may
|
||||||
@@ -735,7 +736,7 @@ mod linux {
|
|||||||
use std::ffi::{CString, OsStr};
|
use std::ffi::{CString, OsStr};
|
||||||
use std::os::fd::{AsRawFd, FromRawFd};
|
use std::os::fd::{AsRawFd, FromRawFd};
|
||||||
use std::os::unix::ffi::OsStrExt;
|
use std::os::unix::ffi::OsStrExt;
|
||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::{FileTypeExt, MetadataExt};
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
struct OpenHow {
|
struct OpenHow {
|
||||||
@@ -916,7 +917,16 @@ mod linux {
|
|||||||
io_observation::record(io_observation::Event::EntryInspection);
|
io_observation::record(io_observation::Event::EntryInspection);
|
||||||
let file = open(dir, name, libc::O_PATH)?;
|
let file = open(dir, name, libc::O_PATH)?;
|
||||||
let metadata = file.metadata()?;
|
let metadata = file.metadata()?;
|
||||||
if !metadata.is_file() && !metadata.is_dir() && !metadata.file_type().is_symlink() {
|
let kind = metadata.file_type();
|
||||||
|
// O_PATH inspects IPC entries without opening a FIFO stream or connecting
|
||||||
|
// to a socket. They are unlinked, never traversed, under the same identity
|
||||||
|
// and mount checks as regular entries; their type alone is not occupancy.
|
||||||
|
if !kind.is_file()
|
||||||
|
&& !kind.is_dir()
|
||||||
|
&& !kind.is_symlink()
|
||||||
|
&& !kind.is_fifo()
|
||||||
|
&& !kind.is_socket()
|
||||||
|
{
|
||||||
return Err(io::Error::from_raw_os_error(libc::EBUSY));
|
return Err(io::Error::from_raw_os_error(libc::EBUSY));
|
||||||
}
|
}
|
||||||
Ok(Entry {
|
Ok(Entry {
|
||||||
@@ -1163,6 +1173,91 @@ pub(super) mod tests {
|
|||||||
(runtime, materializer, binding)
|
(runtime, materializer, binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_fifo(path: &Path) {
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
let name = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
|
||||||
|
// SAFETY: NUL-terminated path inside the isolated test directory.
|
||||||
|
assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_unlinks_fifo_and_socket_without_opening_or_connecting() {
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
|
for retry in [false, true] {
|
||||||
|
let (runtime, materializer, binding) = fixture();
|
||||||
|
fs::write(binding.root.join(".git/info/exclude"), "target/\n").unwrap();
|
||||||
|
let output = binding.root.join("target");
|
||||||
|
fs::create_dir(&output).unwrap();
|
||||||
|
let fifo = output.join("ready.fifo");
|
||||||
|
let socket = output.join("service.sock");
|
||||||
|
create_fifo(&fifo);
|
||||||
|
let listener = UnixListener::bind(&socket).unwrap();
|
||||||
|
listener.set_nonblocking(true).unwrap();
|
||||||
|
let mut watch = BodyWatch::new(&[fifo, socket]);
|
||||||
|
let id = &binding.working_directory.id;
|
||||||
|
if retry {
|
||||||
|
let (result, audit) =
|
||||||
|
io_observation::observe(Some(1), || materializer.cleanup_working_directory(id));
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_err().code,
|
||||||
|
"working_directory_cleanup_permission_denied"
|
||||||
|
);
|
||||||
|
assert_eq!(audit.unlinks, 1);
|
||||||
|
assert_eq!(audit.entry_readable_opens, 0);
|
||||||
|
}
|
||||||
|
drop(materializer);
|
||||||
|
let materializer = RuntimeGitMaterializer::new(runtime.path());
|
||||||
|
let (result, audit) =
|
||||||
|
io_observation::observe(None, || materializer.cleanup_working_directory(id));
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap().summary.status,
|
||||||
|
WorkingDirectoryStatusKind::NotFound
|
||||||
|
);
|
||||||
|
assert!(!binding.working_directory_root.exists());
|
||||||
|
assert_eq!(audit.entry_readable_opens, 0);
|
||||||
|
watch.assert_no_body_access();
|
||||||
|
assert_eq!(
|
||||||
|
listener.accept().unwrap_err().kind(),
|
||||||
|
io::ErrorKind::WouldBlock
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_preserves_replaced_fifo_and_socket_before_unlink() {
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
|
for socket in [false, true] {
|
||||||
|
let (runtime, materializer, binding) = fixture();
|
||||||
|
fs::write(binding.root.join(".git/info/exclude"), "target/\n").unwrap();
|
||||||
|
let output = binding.root.join("target");
|
||||||
|
fs::create_dir(&output).unwrap();
|
||||||
|
let path = output.join("endpoint");
|
||||||
|
let _original = if socket {
|
||||||
|
Some(UnixListener::bind(&path).unwrap())
|
||||||
|
} else {
|
||||||
|
create_fifo(&path);
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let error = remove_with_eraser(
|
||||||
|
&materializer,
|
||||||
|
&binding.working_directory.id,
|
||||||
|
|tree, relative, entries| {
|
||||||
|
fs::rename(&path, runtime.path().join("original-endpoint")).unwrap();
|
||||||
|
let _replacement = if socket {
|
||||||
|
Some(UnixListener::bind(&path).unwrap())
|
||||||
|
} else {
|
||||||
|
create_fifo(&path);
|
||||||
|
None
|
||||||
|
};
|
||||||
|
erase(tree, relative, entries)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.code, "working_directory_cleanup_changes_present");
|
||||||
|
assert!(path.symlink_metadata().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn fail_once(materializer: &RuntimeGitMaterializer, id: &str) {
|
pub(crate) fn fail_once(materializer: &RuntimeGitMaterializer, id: &str) {
|
||||||
let error = remove_with_eraser(materializer, id, |_, _, _| {
|
let error = remove_with_eraser(materializer, id, |_, _, _| {
|
||||||
Err(EraseError::Io(io::Error::from_raw_os_error(libc::EACCES)))
|
Err(EraseError::Io(io::Error::from_raw_os_error(libc::EACCES)))
|
||||||
|
|||||||
@@ -578,7 +578,8 @@ where
|
|||||||
"flow:{}:{}",
|
"flow:{}:{}",
|
||||||
attempt.instance_id, attempt.attempt_id
|
attempt.instance_id, attempt.attempt_id
|
||||||
)),
|
)),
|
||||||
max_turns: Some(12),
|
// Evidence exploration is not bounded by an arbitrary model-turn count.
|
||||||
|
max_turns: None,
|
||||||
compaction_budget: None,
|
compaction_budget: None,
|
||||||
engine_configurator: None,
|
engine_configurator: None,
|
||||||
features,
|
features,
|
||||||
@@ -882,35 +883,45 @@ mod tests {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct FinishVerifierClient {
|
struct FinishVerifierClient {
|
||||||
calls: Arc<AtomicUsize>,
|
calls: Arc<AtomicUsize>,
|
||||||
|
exploration_turns: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LlmClient for FinishVerifierClient {
|
impl LlmClient for FinishVerifierClient {
|
||||||
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
|
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
|
||||||
let call = self.calls.fetch_add(1, Ordering::SeqCst);
|
let call = self.calls.fetch_add(1, Ordering::SeqCst);
|
||||||
if call == 0 {
|
if call <= self.exploration_turns {
|
||||||
let input = serde_json::json!({
|
let input = if call < self.exploration_turns {
|
||||||
"results": [
|
"{}".to_string()
|
||||||
{
|
} else {
|
||||||
"transition_id": "done",
|
serde_json::json!({
|
||||||
"verdict": "met",
|
"results": [
|
||||||
"rationale": "the captured implementation evidence is complete"
|
{
|
||||||
},
|
"transition_id": "done",
|
||||||
{
|
"verdict": "met",
|
||||||
"transition_id": "cancel",
|
"rationale": "the captured implementation evidence is complete"
|
||||||
"verdict": "not_met",
|
},
|
||||||
"rationale": "no exceptional cancellation condition exists"
|
{
|
||||||
}
|
"transition_id": "cancel",
|
||||||
]
|
"verdict": "not_met",
|
||||||
})
|
"rationale": "no exceptional cancellation condition exists"
|
||||||
.to_string();
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
Ok(Box::pin(stream::iter(vec![
|
Ok(Box::pin(stream::iter(vec![
|
||||||
Ok(LlmEvent::BlockStart(BlockStart {
|
Ok(LlmEvent::BlockStart(BlockStart {
|
||||||
index: 0,
|
index: 0,
|
||||||
block_type: BlockType::ToolUse,
|
block_type: BlockType::ToolUse,
|
||||||
metadata: BlockMetadata::ToolUse {
|
metadata: BlockMetadata::ToolUse {
|
||||||
id: "finish-flow".to_string(),
|
id: format!("flow-evidence-{call}"),
|
||||||
name: "FinishFlowVerification".to_string(),
|
name: if call < self.exploration_turns {
|
||||||
|
"ShowOverview"
|
||||||
|
} else {
|
||||||
|
"FinishFlowVerification"
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
Ok(LlmEvent::BlockDelta(BlockDelta {
|
Ok(LlmEvent::BlockDelta(BlockDelta {
|
||||||
@@ -978,6 +989,15 @@ permission = "read"
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn worker_backed_verifier_requires_structured_finish_result() {
|
async fn worker_backed_verifier_requires_structured_finish_result() {
|
||||||
|
assert_worker_backed_verifier_completes(0).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_backed_verifier_can_finish_after_more_than_twelve_turns() {
|
||||||
|
assert_worker_backed_verifier_completes(16).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_worker_backed_verifier_completes(exploration_turns: usize) {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
let store = session_store::FsStore::new(directory.path()).unwrap();
|
let store = session_store::FsStore::new(directory.path()).unwrap();
|
||||||
let session_id: SessionId = Uuid::now_v7();
|
let session_id: SessionId = Uuid::now_v7();
|
||||||
@@ -998,11 +1018,16 @@ permission = "read"
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let mut manifest = verifier_manifest();
|
||||||
|
// A parent's own turn limit must not cap the private verifier either.
|
||||||
|
manifest.engine.max_turns = std::num::NonZeroU32::new(2);
|
||||||
let verifier = WorkerBackedFlowVerifier::new(
|
let verifier = WorkerBackedFlowVerifier::new(
|
||||||
FinishVerifierClient {
|
FinishVerifierClient {
|
||||||
calls: Arc::new(AtomicUsize::new(0)),
|
calls: calls.clone(),
|
||||||
|
exploration_turns,
|
||||||
},
|
},
|
||||||
verifier_manifest(),
|
manifest,
|
||||||
StoreFlowParentCapture::new(store, session_id, segment_id),
|
StoreFlowParentCapture::new(store, session_id, segment_id),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
);
|
);
|
||||||
@@ -1013,6 +1038,7 @@ permission = "read"
|
|||||||
assert_eq!(results.len(), 2);
|
assert_eq!(results.len(), 2);
|
||||||
assert_eq!(results[0].verdict, ConditionVerdict::Met);
|
assert_eq!(results[0].verdict, ConditionVerdict::Met);
|
||||||
assert_eq!(results[1].verdict, ConditionVerdict::NotMet);
|
assert_eq!(results[1].verdict, ConditionVerdict::NotMet);
|
||||||
|
assert!(calls.load(Ordering::SeqCst) > exploration_turns);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -91,13 +91,22 @@ struct MergeRequestInput {
|
|||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct ShowMergeRequestInput {
|
struct ShowMergeRequestInput {
|
||||||
merge_request_id: String,
|
merge_request_id: String,
|
||||||
|
/// summary (default) omits review bodies/history; detail returns exact paged events.
|
||||||
|
/// For compatibility, supplying after/limit without view selects detail.
|
||||||
|
view: Option<MergeRequestReadView>,
|
||||||
/// Return thread events strictly after this sequence.
|
/// Return thread events strictly after this sequence.
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
after: Option<u64>,
|
after: Option<u64>,
|
||||||
/// Bounded thread page size. The Backend also enforces its authoritative cap.
|
/// Exact detail/history page size (default 20, maximum 200). Continue with event_page.next_after.
|
||||||
#[schemars(range(min = 1, max = 200))]
|
#[schemars(range(min = 1, max = 200))]
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum MergeRequestReadView {
|
||||||
|
Summary,
|
||||||
|
Detail,
|
||||||
|
}
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct OpenMergeRequestInput {
|
struct OpenMergeRequestInput {
|
||||||
ticket: String,
|
ticket: String,
|
||||||
@@ -203,7 +212,27 @@ impl Tool for MergeRequestTool {
|
|||||||
"limit must be between 1 and 200".into(),
|
"limit must be between 1 and 200".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return self.show_merge_request(ws, &value.merge_request_id, value.after, value.limit);
|
let view = value.view.unwrap_or_else(|| {
|
||||||
|
if value.after.is_some() || value.limit.is_some() {
|
||||||
|
MergeRequestReadView::Detail
|
||||||
|
} else {
|
||||||
|
MergeRequestReadView::Summary
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if matches!(view, MergeRequestReadView::Summary)
|
||||||
|
&& (value.after.is_some() || value.limit.is_some())
|
||||||
|
{
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"after/limit require view=detail".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return self.show_merge_request(
|
||||||
|
ws,
|
||||||
|
&value.merge_request_id,
|
||||||
|
view,
|
||||||
|
value.after,
|
||||||
|
value.limit,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let (method, path, body) = match self.kind {
|
let (method, path, body) = match self.kind {
|
||||||
Kind::Readiness => {
|
Kind::Readiness => {
|
||||||
@@ -290,12 +319,19 @@ impl MergeRequestTool {
|
|||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
merge_request_id: &str,
|
merge_request_id: &str,
|
||||||
|
view: MergeRequestReadView,
|
||||||
after: Option<u64>,
|
after: Option<u64>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let merge_request_id = encode_path_segment(merge_request_id);
|
let merge_request_id = encode_path_segment(merge_request_id);
|
||||||
let mut path = format!("/api/w/{workspace_id}/merge-requests/{merge_request_id}");
|
let mut path = format!("/api/w/{workspace_id}/merge-requests/{merge_request_id}");
|
||||||
let mut query = Vec::new();
|
let mut query = vec![format!(
|
||||||
|
"view={}",
|
||||||
|
match view {
|
||||||
|
MergeRequestReadView::Summary => "summary",
|
||||||
|
MergeRequestReadView::Detail => "detail",
|
||||||
|
}
|
||||||
|
)];
|
||||||
if let Some(after) = after {
|
if let Some(after) = after {
|
||||||
query.push(format!("after={after}"));
|
query.push(format!("after={after}"));
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1135,7 @@ fn merge_request_operation_not_found() -> WipOperationError {
|
|||||||
pub fn description(n: &str) -> Option<&'static str> {
|
pub fn description(n: &str) -> Option<&'static str> {
|
||||||
match n {
|
match n {
|
||||||
"ShowMergeRequest" => Some(
|
"ShowMergeRequest" => Some(
|
||||||
"Read the selector-based Merge Request, append-only thread, source-review freshness, and target-integration evidence before review, fix, or handoff decisions.",
|
"Read an authoritative compact MR summary by default: source/target observations, full-evidence review status/readiness and stable event references, without review bodies/history. Use view=detail with bounded after/limit for exact reviews/findings and history; continue with event_page.next_after. Status is never inferred from a history page. Summary is not approval or integration authority.",
|
||||||
),
|
),
|
||||||
"CheckMergeRequestReadiness" => Some(
|
"CheckMergeRequestReadiness" => Some(
|
||||||
"Resolve current provider refs and derive readiness from exact-source review evidence; source movement requires fresh review while target-only movement preserves unchanged-source approval.",
|
"Resolve current provider refs and derive readiness from exact-source review evidence; source movement requires fresh review while target-only movement preserves unchanged-source approval.",
|
||||||
@@ -1222,7 +1258,10 @@ mod tests {
|
|||||||
let requests = client.requests.lock().expect("request lock");
|
let requests = client.requests.lock().expect("request lock");
|
||||||
assert_eq!(requests.len(), 1);
|
assert_eq!(requests.len(), 1);
|
||||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||||
assert_eq!(requests[0].path, "/api/w/ws/merge-requests/MR%2F1");
|
assert_eq!(
|
||||||
|
requests[0].path,
|
||||||
|
"/api/w/ws/merge-requests/MR%2F1?view=summary"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1243,7 +1282,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.requests.lock().expect("request lock")[0].path,
|
client.requests.lock().expect("request lock")[0].path,
|
||||||
"/api/w/ws/merge-requests/MR-1?after=42&limit=200"
|
"/api/w/ws/merge-requests/MR-1?view=detail&after=42&limit=200"
|
||||||
);
|
);
|
||||||
|
|
||||||
let rejected = tool
|
let rejected = tool
|
||||||
@@ -1257,6 +1296,64 @@ mod tests {
|
|||||||
assert_eq!(client.requests.lock().expect("request lock").len(), 1);
|
assert_eq!(client.requests.lock().expect("request lock").len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn native_read_forwards_explicit_projection_and_rejects_ambiguous_summary_paging() {
|
||||||
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||||
|
"merge_request_id": "MR-1", "view": "detail", "thread": [],
|
||||||
|
"summary": {"review_status": "approved"},
|
||||||
|
"event_page": {"omitted": false, "has_more": true, "next_after": 9, "limit": 1}
|
||||||
|
}))]));
|
||||||
|
let tool = native_merge_request_tools(
|
||||||
|
client.clone(),
|
||||||
|
MergeRequestFeatureConfig {
|
||||||
|
show: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.remove(0);
|
||||||
|
let input = native_merge_request_input(
|
||||||
|
&BTreeMap::from([
|
||||||
|
("view".into(), WipValue::String("detail".into())),
|
||||||
|
("after".into(), json_to_wip(&json!(8)).unwrap()),
|
||||||
|
("limit".into(), json_to_wip(&json!(1)).unwrap()),
|
||||||
|
]),
|
||||||
|
Some("MR-1"),
|
||||||
|
Some("merge_request_id"),
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| panic!("native read arguments should bind"));
|
||||||
|
let output = execute_native_merge_request_tool(
|
||||||
|
&tool,
|
||||||
|
&None,
|
||||||
|
input,
|
||||||
|
Some("MR-1"),
|
||||||
|
WipCallContext {
|
||||||
|
execution: ToolExecutionContext::direct(),
|
||||||
|
security_context: "reader".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("native detail read should succeed"));
|
||||||
|
let output = merge_request_tool_output_json(output)
|
||||||
|
.unwrap_or_else(|_| panic!("valid JSON response"));
|
||||||
|
assert_eq!(output["summary"]["review_status"], "approved");
|
||||||
|
assert_eq!(output["event_page"]["next_after"], 9);
|
||||||
|
assert_eq!(
|
||||||
|
client.requests.lock().unwrap()[0].path,
|
||||||
|
"/api/w/ws/merge-requests/MR-1?view=detail&after=8&limit=1"
|
||||||
|
);
|
||||||
|
let rejected = tool
|
||||||
|
.tool
|
||||||
|
.execute(
|
||||||
|
r#"{"merge_request_id":"MR-1","view":"summary","limit":1}"#,
|
||||||
|
ToolExecutionContext::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(rejected.to_string().contains("require view=detail"));
|
||||||
|
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn show_requires_explicit_merge_request_identity() {
|
async fn show_requires_explicit_merge_request_identity() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![]));
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![]));
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct ModelTicketQueryItem {
|
|||||||
title: String,
|
title: String,
|
||||||
state: ticket::TicketState,
|
state: ticket::TicketState,
|
||||||
closure_operation_key: Option<String>,
|
closure_operation_key: Option<String>,
|
||||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
current_closure: Option<Value>,
|
||||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||||
assignment_tracking: Vec<ModelAssignmentTracking>,
|
assignment_tracking: Vec<ModelAssignmentTracking>,
|
||||||
tags: Vec<ticket::WorkspaceTag>,
|
tags: Vec<ticket::WorkspaceTag>,
|
||||||
@@ -34,7 +34,6 @@ struct ModelTicketQueryItem {
|
|||||||
unresolved_review_count: usize,
|
unresolved_review_count: usize,
|
||||||
evidence: Option<ModelTicketEvidence>,
|
evidence: Option<ModelTicketEvidence>,
|
||||||
merge_requests: Vec<ModelMergeRequest>,
|
merge_requests: Vec<ModelMergeRequest>,
|
||||||
merge_request: Option<ModelMergeRequest>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, Serialize)]
|
#[derive(Debug, serde::Deserialize, Serialize)]
|
||||||
@@ -50,14 +49,12 @@ pub(super) struct ModelTicketDetail {
|
|||||||
ticket: String,
|
ticket: String,
|
||||||
content_digest: String,
|
content_digest: String,
|
||||||
title: String,
|
title: String,
|
||||||
body: String,
|
|
||||||
body_truncated: bool,
|
|
||||||
initial_post: ModelTicketEvent,
|
initial_post: ModelTicketEvent,
|
||||||
targets: Vec<ModelTicketTarget>,
|
targets: Vec<ModelTicketTarget>,
|
||||||
created_by: Option<String>,
|
created_by: Option<String>,
|
||||||
state: ticket::TicketState,
|
state: ticket::TicketState,
|
||||||
closure_operation_key: Option<String>,
|
closure_operation_key: Option<String>,
|
||||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
current_closure: Option<Value>,
|
||||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||||
assignment_tracking: Vec<ModelAssignmentTracking>,
|
assignment_tracking: Vec<ModelAssignmentTracking>,
|
||||||
tags: Vec<ticket::WorkspaceTag>,
|
tags: Vec<ticket::WorkspaceTag>,
|
||||||
@@ -65,7 +62,7 @@ pub(super) struct ModelTicketDetail {
|
|||||||
priority: Option<String>,
|
priority: Option<String>,
|
||||||
created_at: Option<String>,
|
created_at: Option<String>,
|
||||||
updated_at: Option<String>,
|
updated_at: Option<String>,
|
||||||
closure_history: Vec<ticket::TicketCaseHistoryEntry>,
|
closure_history: Vec<Value>,
|
||||||
thread: Vec<ModelTicketEvent>,
|
thread: Vec<ModelTicketEvent>,
|
||||||
relations: ModelTicketRelations,
|
relations: ModelTicketRelations,
|
||||||
linked_objectives: Vec<ModelObjectiveSummary>,
|
linked_objectives: Vec<ModelObjectiveSummary>,
|
||||||
@@ -73,7 +70,6 @@ pub(super) struct ModelTicketDetail {
|
|||||||
current_worker: Option<ModelWorkerSummary>,
|
current_worker: Option<ModelWorkerSummary>,
|
||||||
implementation_reports: Vec<ModelEvidenceEvent>,
|
implementation_reports: Vec<ModelEvidenceEvent>,
|
||||||
merge_requests: Vec<ModelMergeRequest>,
|
merge_requests: Vec<ModelMergeRequest>,
|
||||||
merge_request: Option<ModelMergeRequest>,
|
|
||||||
evidence: Option<ModelTicketEvidence>,
|
evidence: Option<ModelTicketEvidence>,
|
||||||
actions: Option<ModelTicketActions>,
|
actions: Option<ModelTicketActions>,
|
||||||
event_page: Option<ModelEventPage>,
|
event_page: Option<ModelEventPage>,
|
||||||
@@ -280,6 +276,21 @@ fn typed_field<T: serde::de::DeserializeOwned>(
|
|||||||
serde_json::from_value(value.clone()).map_err(|error| format!("Invalid Ticket {key}: {error}"))
|
serde_json::from_value(value.clone()).map_err(|error| format!("Invalid Ticket {key}: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn closure_metadata<T: Serialize>(value: T) -> Result<Value, String> {
|
||||||
|
let mut value = serde_json::to_value(value).map_err(|error| error.to_string())?;
|
||||||
|
if let Some(object) = value.as_object_mut() {
|
||||||
|
// The full explanation is available in thread history/post, not repeated
|
||||||
|
// in current judgment and closure-index metadata as well.
|
||||||
|
object.remove("explanation");
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_current_closure(root: &Map<String, Value>) -> Result<Option<Value>, String> {
|
||||||
|
let closure: Option<ticket::TicketClosureJudgment> = typed_field(root, "current_closure")?;
|
||||||
|
closure.map(closure_metadata).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
|
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
|
||||||
let root = object(&value, "Ticket query response")?;
|
let root = object(&value, "Ticket query response")?;
|
||||||
let page = object_field(root, "page")?;
|
let page = object_field(root, "page")?;
|
||||||
@@ -301,7 +312,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
|||||||
title: string_field(item, "title")?,
|
title: string_field(item, "title")?,
|
||||||
state: typed_field(item, "state")?,
|
state: typed_field(item, "state")?,
|
||||||
closure_operation_key: typed_field(item, "closure_operation_key")?,
|
closure_operation_key: typed_field(item, "closure_operation_key")?,
|
||||||
current_closure: typed_field(item, "current_closure")?,
|
current_closure: project_current_closure(item)?,
|
||||||
legacy_handoff: typed_field(item, "legacy_handoff")?,
|
legacy_handoff: typed_field(item, "legacy_handoff")?,
|
||||||
assignment_tracking: project_assignment_tracking(item)?,
|
assignment_tracking: project_assignment_tracking(item)?,
|
||||||
tags: project_tags(item)?,
|
tags: project_tags(item)?,
|
||||||
@@ -330,14 +341,84 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
|||||||
.iter()
|
.iter()
|
||||||
.map(project_merge_request)
|
.map(project_merge_request)
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
merge_request: item
|
|
||||||
.get("merge_request")
|
|
||||||
.filter(|value| !value.is_null())
|
|
||||||
.map(project_merge_request)
|
|
||||||
.transpose()?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Status is deliberately projected from authority fields, not from serialized detail:
|
||||||
|
/// prose/history size and history parsing have no effect on this response.
|
||||||
|
pub(super) fn project_ticket_status(value: Value) -> Result<Value, String> {
|
||||||
|
use serde_json::json;
|
||||||
|
let root = object(&value, "Ticket summary response")?;
|
||||||
|
let state: ticket::TicketState = typed_field(root, "state")?;
|
||||||
|
let closure_key: Option<String> = typed_field(root, "closure_operation_key")?;
|
||||||
|
let closure: Option<ticket::TicketClosureJudgment> = typed_field(root, "current_closure")?;
|
||||||
|
let targets: Vec<ModelTicketTarget> = typed_field(root, "targets")?;
|
||||||
|
let tracking = project_assignment_tracking(root)?.into_iter().map(|row| json!({
|
||||||
|
"assignment_id": row.assignment_id, "role": row.role, "principal": row.principal,
|
||||||
|
"assigned_by": row.assigned_by, "assigned_at": row.assigned_at, "current": row.current,
|
||||||
|
"status": row.tracking.map(|tracking| tracking.status),
|
||||||
|
"last_event_id": row.last_event_id, "updated_at": row.updated_at,
|
||||||
|
})).collect::<Vec<_>>();
|
||||||
|
let merges = array_field(root, "merge_requests")?
|
||||||
|
.iter()
|
||||||
|
.map(|value| {
|
||||||
|
let mut merge = serde_json::to_value(project_merge_request(value)?)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
merge
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("typed merge object")
|
||||||
|
.remove("review_excerpt");
|
||||||
|
Ok(merge)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<Value>, String>>()?;
|
||||||
|
let latest_event_ref = array_field(root, "events")?
|
||||||
|
.last()
|
||||||
|
.map(|event| string_field(object(event, "Ticket event")?, "event_ref"))
|
||||||
|
.transpose()?;
|
||||||
|
Ok(json!({
|
||||||
|
"ticket": resource_ref(root, "resource_key", "T-")?,
|
||||||
|
"title": string_field(root, "title")?,
|
||||||
|
"content_digest": string_field(root, "content_digest")?,
|
||||||
|
"state": state, "closure_operation_key": closure_key,
|
||||||
|
"current_closure": closure.map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "author": j.author, "at": j.at})),
|
||||||
|
"targets": targets, "tags": project_tags(root)?,
|
||||||
|
"readiness": optional_string(root, "readiness")?,
|
||||||
|
"priority": optional_string(root, "priority")?,
|
||||||
|
"updated_at": optional_string(root, "updated_at")?,
|
||||||
|
"assignment_tracking": tracking,
|
||||||
|
"current_worker": root.get("current_worker").filter(|value| !value.is_null()).map(project_worker).transpose()?,
|
||||||
|
"merge_requests": merges,
|
||||||
|
"evidence": root.get("evidence").map(project_evidence).transpose()?,
|
||||||
|
"actions": root.get("action_eligibility").filter(|value| !value.is_null()).map(project_actions).transpose()?,
|
||||||
|
"latest_event_ref": latest_event_ref,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn project_ticket_history(value: Value) -> Result<Value, String> {
|
||||||
|
let root = object(&value, "Ticket history response")?;
|
||||||
|
let thread = array_field(root, "events")?
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.get("is_initial") != Some(&Value::Bool(true)))
|
||||||
|
.map(project_ticket_event)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"ticket": resource_ref(root, "resource_key", "T-")?,
|
||||||
|
"thread": thread,
|
||||||
|
"event_page": project_event_page(root.get("event_page").ok_or("missing event_page")?)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn project_ticket_post(value: Value) -> Result<Value, String> {
|
||||||
|
let post = project_ticket_event(&value)?;
|
||||||
|
if post.body_truncated || post.body.is_none() {
|
||||||
|
return Err(
|
||||||
|
"Full-post read returned an absent or truncated body; do not use it for edit CAS"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
serde_json::to_value(post).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
|
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
|
||||||
let root = object(&value, "Ticket detail response")?;
|
let root = object(&value, "Ticket detail response")?;
|
||||||
let current_worker = root
|
let current_worker = root
|
||||||
@@ -354,8 +435,6 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
|||||||
ticket: resource_ref(root, "resource_key", "T-")?,
|
ticket: resource_ref(root, "resource_key", "T-")?,
|
||||||
content_digest: string_field(root, "content_digest")?,
|
content_digest: string_field(root, "content_digest")?,
|
||||||
title: string_field(root, "title")?,
|
title: string_field(root, "title")?,
|
||||||
body: string_field(root, "body")?,
|
|
||||||
body_truncated: bool_field(root, "body_truncated")?,
|
|
||||||
initial_post: project_ticket_event(
|
initial_post: project_ticket_event(
|
||||||
root.get("initial_post")
|
root.get("initial_post")
|
||||||
.ok_or("Ticket detail response is missing initial_post")?,
|
.ok_or("Ticket detail response is missing initial_post")?,
|
||||||
@@ -364,9 +443,15 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
|||||||
created_by: optional_string(root, "created_by")?,
|
created_by: optional_string(root, "created_by")?,
|
||||||
state: typed_field(root, "state")?,
|
state: typed_field(root, "state")?,
|
||||||
closure_operation_key: typed_field(root, "closure_operation_key")?,
|
closure_operation_key: typed_field(root, "closure_operation_key")?,
|
||||||
current_closure: typed_field(root, "current_closure")?,
|
current_closure: project_current_closure(root)?,
|
||||||
legacy_handoff: typed_field(root, "legacy_handoff")?,
|
legacy_handoff: typed_field(root, "legacy_handoff")?,
|
||||||
closure_history: typed_field(root, "closure_history")?,
|
closure_history: typed_field::<Vec<ticket::TicketCaseHistoryEntry>>(
|
||||||
|
root,
|
||||||
|
"closure_history",
|
||||||
|
)?
|
||||||
|
.into_iter()
|
||||||
|
.map(closure_metadata)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
assignment_tracking: project_assignment_tracking(root)?,
|
assignment_tracking: project_assignment_tracking(root)?,
|
||||||
tags: project_tags(root)?,
|
tags: project_tags(root)?,
|
||||||
readiness: optional_string(root, "readiness")?,
|
readiness: optional_string(root, "readiness")?,
|
||||||
@@ -375,6 +460,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
|||||||
updated_at: optional_string(root, "updated_at")?,
|
updated_at: optional_string(root, "updated_at")?,
|
||||||
thread: array_field(root, "events")?
|
thread: array_field(root, "events")?
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|event| event.get("is_initial") != Some(&Value::Bool(true)))
|
||||||
.map(project_ticket_event)
|
.map(project_ticket_event)
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
relations: project_relations(root.get("relations"))?,
|
relations: project_relations(root.get("relations"))?,
|
||||||
@@ -392,11 +478,6 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
|||||||
.iter()
|
.iter()
|
||||||
.map(project_merge_request)
|
.map(project_merge_request)
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
merge_request: root
|
|
||||||
.get("merge_request")
|
|
||||||
.filter(|value| !value.is_null())
|
|
||||||
.map(project_merge_request)
|
|
||||||
.transpose()?,
|
|
||||||
evidence: root.get("evidence").map(project_evidence).transpose()?,
|
evidence: root.get("evidence").map(project_evidence).transpose()?,
|
||||||
actions: root
|
actions: root
|
||||||
.get("action_eligibility")
|
.get("action_eligibility")
|
||||||
@@ -919,7 +1000,18 @@ mod tests {
|
|||||||
assert_eq!(projected["state"], "open");
|
assert_eq!(projected["state"], "open");
|
||||||
assert_eq!(projected["closure_operation_key"], "close-1");
|
assert_eq!(projected["closure_operation_key"], "close-1");
|
||||||
assert_eq!(projected["current_closure"], Value::Null);
|
assert_eq!(projected["current_closure"], Value::Null);
|
||||||
assert_eq!(projected["closure_history"], history);
|
assert_eq!(
|
||||||
|
projected["closure_history"],
|
||||||
|
json!(
|
||||||
|
history
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(closure_metadata)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
);
|
||||||
assert_eq!(projected["legacy_handoff"], legacy);
|
assert_eq!(projected["legacy_handoff"], legacy);
|
||||||
assert_eq!(projected["tags"], response["tags"]);
|
assert_eq!(projected["tags"], response["tags"]);
|
||||||
assert_eq!(projected["content_digest"], "content-digest");
|
assert_eq!(projected["content_digest"], "content-digest");
|
||||||
@@ -1191,7 +1283,8 @@ mod tests {
|
|||||||
.expect("source evidence must project");
|
.expect("source evidence must project");
|
||||||
let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
|
let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
|
||||||
|
|
||||||
for merge in [&projected["merge_requests"][0], &projected["merge_request"]] {
|
assert!(projected.get("merge_request").is_none());
|
||||||
|
for merge in [&projected["merge_requests"][0]] {
|
||||||
assert_eq!(merge["subject_ref"], json!(subject_ref));
|
assert_eq!(merge["subject_ref"], json!(subject_ref));
|
||||||
assert_eq!(merge["source_ref_observation"], observation);
|
assert_eq!(merge["source_ref_observation"], observation);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1378,9 +1471,14 @@ mod tests {
|
|||||||
"current_closure",
|
"current_closure",
|
||||||
"legacy_handoff",
|
"legacy_handoff",
|
||||||
] {
|
] {
|
||||||
assert_eq!(projected_detail[field], detail[field], "detail {field}");
|
let expected = if field == "current_closure" {
|
||||||
|
closure_metadata(&detail[field]).unwrap()
|
||||||
|
} else {
|
||||||
|
detail[field].clone()
|
||||||
|
};
|
||||||
|
assert_eq!(projected_detail[field], expected, "detail {field}");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
projected_query["tickets"][0][field], detail[field],
|
projected_query["tickets"][0][field], expected,
|
||||||
"query {field}"
|
"query {field}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1392,10 +1490,22 @@ mod tests {
|
|||||||
assert!(actual.get("workspace_id").is_none());
|
assert!(actual.get("workspace_id").is_none());
|
||||||
assert!(actual.get("ticket_id").is_none());
|
assert!(actual.get("ticket_id").is_none());
|
||||||
}
|
}
|
||||||
assert_eq!(projected_detail["closure_history"], history);
|
assert_eq!(
|
||||||
|
projected_detail["closure_history"],
|
||||||
|
json!(
|
||||||
|
history
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(closure_metadata)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
);
|
||||||
assert_eq!(projected_detail["content_digest"], "content-1");
|
assert_eq!(projected_detail["content_digest"], "content-1");
|
||||||
assert_eq!(projected_detail["created_by"], "workspace-user:creator");
|
assert_eq!(projected_detail["created_by"], "workspace-user:creator");
|
||||||
assert_eq!(projected_detail["body_truncated"], false);
|
assert_eq!(projected_detail["initial_post"]["body_truncated"], false);
|
||||||
|
assert!(projected_detail.get("body").is_none());
|
||||||
for initial in [true, false] {
|
for initial in [true, false] {
|
||||||
let input = post_fixture(initial, "The same post contract");
|
let input = post_fixture(initial, "The same post contract");
|
||||||
let output = serde_json::to_value(project_ticket_event(&input).unwrap()).unwrap();
|
let output = serde_json::to_value(project_ticket_event(&input).unwrap()).unwrap();
|
||||||
@@ -1488,6 +1598,93 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_views_keep_prose_once_and_summary_independent_of_prose_and_history() {
|
||||||
|
let body = "unique-long-requirement-".repeat(600);
|
||||||
|
let post = post_fixture(true, &body);
|
||||||
|
let closure = json!({"operation_key":"close", "reason":"completed", "explanation":"closure prose", "references":[], "author":"user", "at":"today"});
|
||||||
|
let tracking = json!({"workspace_id":"ws", "ticket_id":"internal", "assignment_id":"assignment", "role":"orchestrator", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}, "assigned_by":"user", "assigned_at":"today", "release":null, "current":true, "last_event_id":"handling-event", "updated_at":"today", "migration_diagnostic":null, "tracking":{"status":"waiting", "waiting_reason":"prose", "progress":null, "next_action":"prose", "result":null}});
|
||||||
|
let merge = json!({"merge_request_id":"MR-1", "repository_key":"main", "state":"open", "selector_from":"topic", "selector_to":"main", "review_status":"request_changes", "current_subject_ref":"sha1", "source_ref_observation":{"status":"observed"}, "integration_evidence_error":null, "review_excerpt":"review prose"});
|
||||||
|
let mut input = json!({
|
||||||
|
"resource_key":"T-848", "title":"Views", "body":body, "body_truncated":false, "initial_post":post,
|
||||||
|
"content_digest":"content", "state":"closed", "closure_operation_key":"close", "current_closure":closure,
|
||||||
|
"closure_history":[], "legacy_handoff":null, "targets":[], "tags":[], "assignment_tracking":[tracking],
|
||||||
|
"events":[post, post_fixture(false, "discussion")], "assignments":[], "linked_objectives":[],
|
||||||
|
"implementation_reports":[], "merge_requests":[merge.clone()], "merge_request":merge,
|
||||||
|
"event_page":{"next_cursor":"stable-cursor", "has_more":true}
|
||||||
|
});
|
||||||
|
let detail = serde_json::to_value(project_ticket_detail(input.clone()).unwrap()).unwrap();
|
||||||
|
assert_eq!(detail.to_string().matches(&body).count(), 1);
|
||||||
|
assert!(detail.get("body").is_none());
|
||||||
|
assert!(detail.get("merge_request").is_none());
|
||||||
|
assert_eq!(detail["thread"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(detail["initial_post"]["event_ref"], "post-initial");
|
||||||
|
assert_eq!(detail["initial_post"]["body_digest"], "a".repeat(64));
|
||||||
|
assert!(detail["current_closure"].get("explanation").is_none());
|
||||||
|
let history = project_ticket_history(input.clone()).unwrap();
|
||||||
|
assert_eq!(history.as_object().unwrap().len(), 3);
|
||||||
|
assert_eq!(history["event_page"]["next_cursor"], "stable-cursor");
|
||||||
|
let summary = project_ticket_status(input.clone()).unwrap();
|
||||||
|
// Reconstruct the removed model-facing aliases on this same fixture.
|
||||||
|
let mut duplicated = detail.clone();
|
||||||
|
duplicated["body"] = detail["initial_post"]["body"].clone();
|
||||||
|
duplicated["body_truncated"] = detail["initial_post"]["body_truncated"].clone();
|
||||||
|
duplicated["merge_request"] = detail["merge_requests"][0].clone();
|
||||||
|
duplicated["thread"]
|
||||||
|
.as_array_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert(0, detail["initial_post"].clone());
|
||||||
|
assert_eq!(duplicated.to_string().matches(&body).count(), 3);
|
||||||
|
assert!(duplicated.to_string().len() > detail.to_string().len());
|
||||||
|
let mut changed = input.clone();
|
||||||
|
changed["content_digest"] = json!("new-requirements-digest");
|
||||||
|
changed["merge_requests"][0]["current_subject_ref"] = json!("sha2");
|
||||||
|
changed["merge_requests"][0]["review_status"] = json!("pending");
|
||||||
|
let changed = project_ticket_status(changed).unwrap();
|
||||||
|
assert_ne!(summary["content_digest"], changed["content_digest"]);
|
||||||
|
assert_ne!(
|
||||||
|
summary["merge_requests"][0]["subject_ref"],
|
||||||
|
changed["merge_requests"][0]["subject_ref"]
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
summary["merge_requests"][0]["review_status"],
|
||||||
|
changed["merge_requests"][0]["review_status"]
|
||||||
|
);
|
||||||
|
assert_eq!(summary["content_digest"], "content");
|
||||||
|
assert_eq!(summary["closure_operation_key"], "close");
|
||||||
|
assert_eq!(summary["assignment_tracking"][0]["status"], "waiting");
|
||||||
|
assert_eq!(
|
||||||
|
summary["assignment_tracking"][0]["last_event_id"],
|
||||||
|
"handling-event"
|
||||||
|
);
|
||||||
|
assert_eq!(summary["merge_requests"][0]["subject_ref"], "sha1");
|
||||||
|
assert_eq!(
|
||||||
|
summary["merge_requests"][0]["review_status"],
|
||||||
|
"request_changes"
|
||||||
|
);
|
||||||
|
let huge = "全文🦀".repeat(100_000);
|
||||||
|
input["body"] = json!(huge);
|
||||||
|
input["initial_post"]["body"] = json!(huge);
|
||||||
|
input["current_closure"]["explanation"] = json!(huge);
|
||||||
|
input["assignment_tracking"][0]["tracking"]["waiting_reason"] = json!(huge);
|
||||||
|
input["merge_requests"][0]["review_excerpt"] = json!(huge);
|
||||||
|
input["closure_history"] = json!(vec![json!({"explanation":huge}); 20]);
|
||||||
|
input["implementation_reports"] = json!(vec![json!({"excerpt":huge}); 20]);
|
||||||
|
input["events"] = json!(vec![post_fixture(false, "history prose"); 1000]);
|
||||||
|
let larger = project_ticket_status(input).unwrap();
|
||||||
|
assert_eq!(summary, larger);
|
||||||
|
eprintln!(
|
||||||
|
"Ticket workspace bytes: duplicated fixture={}, detail={}, summary={} (identical with large prose and 1000 events)",
|
||||||
|
duplicated.to_string().len(),
|
||||||
|
detail.to_string().len(),
|
||||||
|
summary.to_string().len()
|
||||||
|
);
|
||||||
|
let mut full = post_fixture(false, &huge);
|
||||||
|
assert_eq!(project_ticket_post(full.clone()).unwrap()["body"], huge);
|
||||||
|
full["body_truncated"] = json!(true);
|
||||||
|
assert!(project_ticket_post(full).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_tag_history_projection_preserves_observed_names_and_identities() {
|
fn ticket_tag_history_projection_preserves_observed_names_and_identities() {
|
||||||
let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false});
|
let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false});
|
||||||
|
|||||||
@@ -80,7 +80,11 @@ use crate::wip::{
|
|||||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
|
|
||||||
use super::resource_projection::{project_ticket_detail, project_ticket_query};
|
use super::resource_projection::{
|
||||||
|
project_ticket_detail, project_ticket_history, project_ticket_post, project_ticket_query,
|
||||||
|
project_ticket_status,
|
||||||
|
};
|
||||||
|
use ticket::tool::ShowTicketView;
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum WorkspaceTicketReadKind {
|
enum WorkspaceTicketReadKind {
|
||||||
@@ -102,7 +106,7 @@ impl WorkspaceTicketReadKind {
|
|||||||
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
|
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
|
||||||
}
|
}
|
||||||
Self::Show => {
|
Self::Show => {
|
||||||
"Show one authoritative Workspace Ticket with its ordered implementation targets (repository_key, ref_selector, access), content digest, paged thread, links, historical implementation reports, and current Merge Request readiness evidence. Read targets here before choosing a work repository; a null selector remains unspecified, not an inferred default."
|
"Read one authoritative Workspace Ticket. Use view=summary for current state/CAS, targets, delegation identities/status and MR evidence without prose/history; detail (default) for canonical initial_post and bounded discussion; history with event_cursor for only thread/page metadata; post with an observed event_ref for the exact full post. Never use truncated text for edit CAS or guess event IDs. Read targets before choosing a repository; a null selector remains unspecified. Summary alone is not approval or completion authority."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,9 +231,14 @@ struct WorkspaceQueryTicketInput {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
struct WorkspaceShowTicketInput {
|
struct WorkspaceShowTicketInput {
|
||||||
|
/// summary omits prose/history; detail is default; history returns only a thread page; post returns the exact full post up to 1 MiB (larger posts fail, never truncate).
|
||||||
|
#[serde(default)]
|
||||||
|
view: ShowTicketView,
|
||||||
|
/// Exact observed event_ref, required only for view=post. Never a display ordinal.
|
||||||
|
event_ref: Option<String>,
|
||||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||||
id: String,
|
id: String,
|
||||||
/// Most-recent thread entries to return, bounded by the Backend to 1..=50.
|
/// Most-recent thread entries to return, bounded by the Backend to 1..=100.
|
||||||
event_limit: Option<usize>,
|
event_limit: Option<usize>,
|
||||||
/// Opaque event cursor returned by a prior ShowTicket page.
|
/// Opaque event cursor returned by a prior ShowTicket page.
|
||||||
event_cursor: Option<String>,
|
event_cursor: Option<String>,
|
||||||
@@ -251,6 +260,8 @@ impl Tool for WorkspaceTicketReadTool {
|
|||||||
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
||||||
ToolError::InvalidArgument("Workspace Ticket reads require workspace identity".into())
|
ToolError::InvalidArgument("Workspace Ticket reads require workspace identity".into())
|
||||||
})?;
|
})?;
|
||||||
|
let mut view = ShowTicketView::Detail;
|
||||||
|
let mut method = WorkspaceRequestMethod::Post;
|
||||||
let (path, body) = match self.kind {
|
let (path, body) = match self.kind {
|
||||||
WorkspaceTicketReadKind::Query => {
|
WorkspaceTicketReadKind::Query => {
|
||||||
let input: WorkspaceQueryTicketInput = serde_json::from_str(&input)
|
let input: WorkspaceQueryTicketInput = serde_json::from_str(&input)
|
||||||
@@ -276,22 +287,41 @@ impl Tool for WorkspaceTicketReadTool {
|
|||||||
"ShowTicket.id must not be empty".into(),
|
"ShowTicket.id must not be empty".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let path = format!("/api/w/{workspace_id}/tickets/{}/show", input.id.trim());
|
view = input.view;
|
||||||
let body = json!({
|
view.validate(input.event_cursor.as_deref(), input.event_ref.as_deref())
|
||||||
"event_limit": input.event_limit,
|
.map_err(ToolError::InvalidArgument)?;
|
||||||
"event_cursor": input.event_cursor,
|
let id =
|
||||||
});
|
WorkspaceHttpTicketBackend::ticket_path(&TicketIdOrSlug::from(input.id.trim()));
|
||||||
(path, body)
|
if let Some(event_ref) = input.event_ref {
|
||||||
|
method = WorkspaceRequestMethod::Get;
|
||||||
|
let event_ref =
|
||||||
|
WorkspaceHttpTicketBackend::ticket_path(&TicketIdOrSlug::from(event_ref));
|
||||||
|
(
|
||||||
|
format!("/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}"),
|
||||||
|
Value::Null,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let path = format!("/api/w/{workspace_id}/tickets/{id}/show");
|
||||||
|
let body = json!({
|
||||||
|
"event_limit": if view == ShowTicketView::Summary { Some(1) } else { input.event_limit },
|
||||||
|
"event_cursor": input.event_cursor,
|
||||||
|
});
|
||||||
|
(path, body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
.execute(WorkspaceRequest::json(
|
.execute(if method == WorkspaceRequestMethod::Get {
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequest::get(path)
|
||||||
path,
|
} else {
|
||||||
serde_json::to_string(&body)
|
WorkspaceRequest::json(
|
||||||
.map_err(|error| ToolError::Internal(error.to_string()))?,
|
method,
|
||||||
))
|
path,
|
||||||
|
serde_json::to_string(&body)
|
||||||
|
.map_err(|error| ToolError::Internal(error.to_string()))?,
|
||||||
|
)
|
||||||
|
})
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
if !response.is_success() {
|
if !response.is_success() {
|
||||||
return Err(ToolError::ExecutionFailed(ticket_api_error_message(
|
return Err(ToolError::ExecutionFailed(ticket_api_error_message(
|
||||||
@@ -308,9 +338,20 @@ impl Tool for WorkspaceTicketReadTool {
|
|||||||
WorkspaceTicketReadKind::Query => serde_json::to_string(
|
WorkspaceTicketReadKind::Query => serde_json::to_string(
|
||||||
&project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?,
|
&project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||||
),
|
),
|
||||||
WorkspaceTicketReadKind::Show => serde_json::to_string(
|
WorkspaceTicketReadKind::Show => match view {
|
||||||
&project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?,
|
ShowTicketView::Detail => serde_json::to_string(
|
||||||
),
|
&project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||||
|
),
|
||||||
|
ShowTicketView::Summary => serde_json::to_string(
|
||||||
|
&project_ticket_status(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||||
|
),
|
||||||
|
ShowTicketView::History => serde_json::to_string(
|
||||||
|
&project_ticket_history(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||||
|
),
|
||||||
|
ShowTicketView::Post => serde_json::to_string(
|
||||||
|
&json!({"post": project_ticket_post(response_value).map_err(ToolError::ExecutionFailed)?}),
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
||||||
Ok(ToolOutput {
|
Ok(ToolOutput {
|
||||||
@@ -2894,6 +2935,127 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn show_views_share_tool_and_route_bound_wip_wiring() {
|
||||||
|
for view in ["summary", "detail", "history", "post"] {
|
||||||
|
let mut detail = target_detail_fixture(
|
||||||
|
json!([{"repository_key":"main", "ref_selector":null, "access":"read_write"}]),
|
||||||
|
);
|
||||||
|
let unicode = "全文🦀é\n".repeat(20_000);
|
||||||
|
detail["event_page"] = json!({"next_cursor":"opaque-before", "has_more":true});
|
||||||
|
let mut post = detail["initial_post"].clone();
|
||||||
|
post["event_ref"] = json!("persisted:id/with unicode界");
|
||||||
|
post["body"] = json!(unicode);
|
||||||
|
let response = if view == "post" { post.clone() } else { detail };
|
||||||
|
let client = Arc::new(DecisionClient {
|
||||||
|
requests: Mutex::new(Vec::new()),
|
||||||
|
response: crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: response.to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let (meta, tool) =
|
||||||
|
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
|
||||||
|
assert!(meta.input_schema["properties"]["view"].is_object());
|
||||||
|
assert!(meta.input_schema["properties"]["event_ref"].is_object());
|
||||||
|
let mut input = json!({"id":"T-739", "view":view});
|
||||||
|
if view == "post" {
|
||||||
|
input["event_ref"] = post["event_ref"].clone();
|
||||||
|
}
|
||||||
|
if view == "history" {
|
||||||
|
input["event_cursor"] = json!("opaque-before");
|
||||||
|
}
|
||||||
|
let direct = ticket_tool_output_json(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| panic!("Ticket {view} returned invalid JSON"));
|
||||||
|
let tools =
|
||||||
|
native_ticket_tools(client.clone(), TicketFeatureAccess::read_only()).unwrap();
|
||||||
|
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
|
||||||
|
let read = descriptor
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.find(|op| op.name == "read")
|
||||||
|
.unwrap();
|
||||||
|
assert!(read.parameters.iter().any(|param| param.name == "view"));
|
||||||
|
assert!(
|
||||||
|
read.parameters
|
||||||
|
.iter()
|
||||||
|
.any(|param| param.name == "event_ref")
|
||||||
|
);
|
||||||
|
assert!(!read.parameters.iter().any(|param| param.name == "id"));
|
||||||
|
let resolver = TicketItemResolver {
|
||||||
|
tools: operation_map(tools),
|
||||||
|
permissions: None,
|
||||||
|
collection_route: "/tickets".into(),
|
||||||
|
observations: Arc::new(Mutex::new(TicketObservationState::default())),
|
||||||
|
};
|
||||||
|
input.as_object_mut().unwrap().remove("id");
|
||||||
|
let arguments = input
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), json_to_wip(value).unwrap()))
|
||||||
|
.collect();
|
||||||
|
let bound = resolver.resolve("T-739").unwrap();
|
||||||
|
let output = bound
|
||||||
|
.handler
|
||||||
|
.call(
|
||||||
|
"read",
|
||||||
|
&arguments,
|
||||||
|
WipCallContext {
|
||||||
|
execution: ToolExecutionContext::direct(),
|
||||||
|
security_context: "reader".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("WIP {view} failed"));
|
||||||
|
assert_eq!(wip_to_json(&output.value).unwrap(), direct);
|
||||||
|
match view {
|
||||||
|
"post" => {
|
||||||
|
assert_eq!(direct["post"]["body"], unicode);
|
||||||
|
assert_eq!(direct["post"]["body_truncated"], false);
|
||||||
|
}
|
||||||
|
"history" => {
|
||||||
|
assert_eq!(direct.as_object().unwrap().len(), 3);
|
||||||
|
}
|
||||||
|
"summary" => {
|
||||||
|
assert!(direct.get("initial_post").is_none());
|
||||||
|
assert_eq!(direct["content_digest"], "observed-content");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
assert_eq!(direct["initial_post"]["body"], "Requirements");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let requests = client.requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
assert_eq!(requests[0].method, requests[1].method);
|
||||||
|
assert_eq!(requests[0].path, requests[1].path);
|
||||||
|
assert_eq!(requests[0].body, requests[1].body);
|
||||||
|
if view == "post" {
|
||||||
|
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].path,
|
||||||
|
"/api/w/workspace/tickets/T-739/posts/persisted%3Aid%2Fwith%20unicode%E7%95%8C"
|
||||||
|
);
|
||||||
|
assert!(requests[0].body.is_none());
|
||||||
|
} else {
|
||||||
|
let body: Value =
|
||||||
|
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||||
|
assert!(body.get("view").is_none());
|
||||||
|
assert!(body.get("event_ref").is_none());
|
||||||
|
if view == "summary" {
|
||||||
|
assert_eq!(body["event_limit"], 1);
|
||||||
|
}
|
||||||
|
if view == "history" {
|
||||||
|
assert_eq!(body["event_cursor"], "opaque-before");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn show_rejects_missing_or_malformed_targets_instead_of_reporting_unassigned() {
|
async fn show_rejects_missing_or_malformed_targets_instead_of_reporting_unassigned() {
|
||||||
for malformed in [
|
for malformed in [
|
||||||
|
|||||||
@@ -823,6 +823,20 @@ mod tests {
|
|||||||
assert!(prompt.contains("SubmitBackendJobResult"));
|
assert!(prompt.contains("SubmitBackendJobResult"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_verifier_reuses_inspected_evidence_without_claiming_live_read_authority() {
|
||||||
|
let prompt = PromptCatalog::builtins_only()
|
||||||
|
.unwrap()
|
||||||
|
.flow_verifier_system()
|
||||||
|
.unwrap();
|
||||||
|
assert!(prompt.contains("stable entry references"));
|
||||||
|
assert!(prompt.contains("unchanged tree under equivalent conditions"));
|
||||||
|
assert!(prompt.contains("never transfers an old approval or authority"));
|
||||||
|
assert!(prompt.contains("cannot perform live MR or other-session reads"));
|
||||||
|
assert!(prompt.contains("return `indeterminate`"));
|
||||||
|
assert!(prompt.contains("FinishFlowVerification"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_request_prompts_keep_recording_and_reviewer_contracts() {
|
fn merge_request_prompts_keep_recording_and_reviewer_contracts() {
|
||||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||||
|
|||||||
@@ -16148,6 +16148,10 @@ mod build_summary_prompt_tests {
|
|||||||
assert!(worker.history().is_empty());
|
assert!(worker.history().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod prompt_wiring_tests {
|
||||||
|
include!("worker/prompt_wiring_tests.rs");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn worker_without_initial_system_prompt_does_not_load_feature_contribution() {
|
async fn worker_without_initial_system_prompt_does_not_load_feature_contribution() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Construction coverage only, not model-behavior measurement or a restored prompt update.
|
||||||
|
use super::*;
|
||||||
|
use crate::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct GuidanceHost;
|
||||||
|
impl WorkspaceClient for GuidanceHost {
|
||||||
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
|
Some("workspace-test")
|
||||||
|
}
|
||||||
|
fn kind(&self) -> &str {
|
||||||
|
"guidance-fixture"
|
||||||
|
}
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn authorize_feature_activation(&self, _feature: &str) -> Result<(), WorkspaceClientError> {
|
||||||
|
// This fixture tests construction, not Backend authorization policy.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
if request.path.ends_with("/authorization/check") {
|
||||||
|
return Ok(WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: "{}".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert_eq!(request.path, "/api/w/workspace-test/flows/resolve");
|
||||||
|
let definition = flow::builtin_flow_source("coder-review")
|
||||||
|
.unwrap()
|
||||||
|
.compile()
|
||||||
|
.unwrap();
|
||||||
|
let source = flow::ResolvedFlowSource {
|
||||||
|
selector: "builtin:coder-review".parse().unwrap(),
|
||||||
|
workspace_id: "workspace-test".into(),
|
||||||
|
flow_id: "guidance-fixture".into(),
|
||||||
|
content_digest: definition.content_digest.clone(),
|
||||||
|
definition,
|
||||||
|
};
|
||||||
|
Ok(WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: serde_json::to_string(&source).unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fresh_builtin_profiles_materialize_validation_guidance() {
|
||||||
|
for (profile, guidance) in [
|
||||||
|
("coder", "own diagnosis, test selection, and fix iterations"),
|
||||||
|
("orchestrator", "Same-named files on separate branches"),
|
||||||
|
("reviewer", "Responsibility for the complete snapshot"),
|
||||||
|
] {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = manifest::ProfileResolver::new()
|
||||||
|
.with_workspace_base(dir.path())
|
||||||
|
.resolve(
|
||||||
|
&manifest::ProfileSelector::parse_cli(&format!("builtin:{profile}")),
|
||||||
|
manifest::ProfileResolveOptions::with_worker_name("prompt-wiring"),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.manifest;
|
||||||
|
let instruction = manifest.engine.instruction.clone();
|
||||||
|
let config = manifest.feature.merge_request;
|
||||||
|
let ticket = &manifest.feature.ticket;
|
||||||
|
let access = TicketFeatureAccess {
|
||||||
|
authoring: ticket.authoring,
|
||||||
|
thread: ticket.thread,
|
||||||
|
intake: ticket.intake,
|
||||||
|
workflow: ticket.workflow,
|
||||||
|
};
|
||||||
|
let orchestration = manifest.feature.sub_worker.enabled || manifest.feature.worker.enabled;
|
||||||
|
assert!(config.any() && ticket.enabled);
|
||||||
|
let cwd = dir.path().join("workspace");
|
||||||
|
std::fs::create_dir_all(&cwd).unwrap();
|
||||||
|
let mut worker = Worker::new(
|
||||||
|
manifest,
|
||||||
|
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient),
|
||||||
|
session_store::FsStore::new(dir.path().join("sessions")).unwrap(),
|
||||||
|
WorkerWorkspaceContext::with_client(
|
||||||
|
Some(WorkspaceId::new("workspace-test").unwrap()),
|
||||||
|
Arc::new(GuidanceHost),
|
||||||
|
),
|
||||||
|
WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()),
|
||||||
|
Scope::writable(&cwd).unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let feature = crate::feature::builtin::merge_request::MergeRequestFeature::new(
|
||||||
|
worker.workspace_client_handle(),
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
let report = worker.install_features(
|
||||||
|
FeatureRegistryBuilder::new()
|
||||||
|
.with_module(feature)
|
||||||
|
.with_module(TicketFeature::new(worker.workspace_client_handle(), access)),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!report.has_errors(),
|
||||||
|
"{profile}: {}",
|
||||||
|
report.error_message()
|
||||||
|
);
|
||||||
|
if orchestration {
|
||||||
|
worker.register_worker_orchestration_instruction();
|
||||||
|
}
|
||||||
|
worker.set_system_prompt_template(
|
||||||
|
SystemPromptTemplate::parse(
|
||||||
|
&instruction,
|
||||||
|
crate::prompt::source::PromptCatalogSource::builtins_only(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
worker.ensure_system_prompt_materialized().await.unwrap();
|
||||||
|
let prompt = worker.engine().get_system_prompt().unwrap();
|
||||||
|
assert!(prompt.contains(guidance), "{profile}");
|
||||||
|
assert_eq!(
|
||||||
|
prompt.matches("Record validation evidence once").count(),
|
||||||
|
1,
|
||||||
|
"{profile}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
prompt.matches("Record a Ticket dependency when").count(),
|
||||||
|
1,
|
||||||
|
"{profile}"
|
||||||
|
);
|
||||||
|
assert!(prompt.contains("rerun affected or uncertain checks"));
|
||||||
|
assert!(prompt.contains("Changed source or requirements need fresh attestation"));
|
||||||
|
if orchestration {
|
||||||
|
assert!(prompt.contains("SubWorkerSpawn"));
|
||||||
|
assert!(prompt.contains("delegated authority"));
|
||||||
|
}
|
||||||
|
if profile == "coder" {
|
||||||
|
worker.ensure_segment_head().await.unwrap();
|
||||||
|
let report = worker.install_runtime_flow_transition_feature().unwrap();
|
||||||
|
assert!(!report.has_errors(), "{}", report.error_message());
|
||||||
|
let (segments, state, projection) = worker
|
||||||
|
.prepare_flow_input(vec![Segment::Flow {
|
||||||
|
selector: "builtin:coder-review".into(),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
let state = state.expect("new Flow");
|
||||||
|
let builtin = flow::builtin_flow_source("coder-review")
|
||||||
|
.unwrap()
|
||||||
|
.compile()
|
||||||
|
.unwrap();
|
||||||
|
let entry = worker.projected_input_history(
|
||||||
|
&segments,
|
||||||
|
projection.as_ref(),
|
||||||
|
&[SessionHistoryEntryId::new()],
|
||||||
|
&WorkerHistoryProvenance::HumanInput {
|
||||||
|
account_id: "fixture".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(entry.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
entry[0].annotation.origin,
|
||||||
|
WorkerHistoryProvenance::FlowInstruction { .. }
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
entry[0].item.as_text().as_deref(),
|
||||||
|
Some(
|
||||||
|
builtin
|
||||||
|
.state(&builtin.initial)
|
||||||
|
.unwrap()
|
||||||
|
.instructions
|
||||||
|
.as_str()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// The resolved immutable Flow, rather than a hard-coded test recipe, was installed.
|
||||||
|
assert!(
|
||||||
|
serde_json::to_string(&state)
|
||||||
|
.unwrap()
|
||||||
|
.contains(&builtin.content_digest)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4194,6 +4194,7 @@ pub struct RemoteWorkerRuntime {
|
|||||||
host_id: String,
|
host_id: String,
|
||||||
resource_broker: BackendResourceBroker,
|
resource_broker: BackendResourceBroker,
|
||||||
http: BlockingHttpClient,
|
http: BlockingHttpClient,
|
||||||
|
workdir_cleanup_http: BlockingHttpClient,
|
||||||
async_http: AsyncHttpClient,
|
async_http: AsyncHttpClient,
|
||||||
request_timeout: Duration,
|
request_timeout: Duration,
|
||||||
}
|
}
|
||||||
@@ -4346,15 +4347,20 @@ impl RemoteWorkerRuntime {
|
|||||||
};
|
};
|
||||||
let timeout = config.timeout;
|
let timeout = config.timeout;
|
||||||
let blocking_resolution = pinned_endpoint.clone();
|
let blocking_resolution = pinned_endpoint.clone();
|
||||||
let http = run_blocking_http(move || {
|
let (http, workdir_cleanup_http) = run_blocking_http(move || {
|
||||||
let mut builder = BlockingHttpClient::builder()
|
let build_client = |timeout: Option<Duration>| {
|
||||||
.timeout(timeout)
|
let mut builder = BlockingHttpClient::builder()
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.timeout(timeout)
|
||||||
.no_proxy();
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
if let Some((host, addresses)) = &blocking_resolution {
|
.no_proxy();
|
||||||
builder = builder.resolve_to_addrs(host, addresses);
|
if let Some((host, addresses)) = &blocking_resolution {
|
||||||
}
|
builder = builder.resolve_to_addrs(host, addresses);
|
||||||
builder.build()
|
}
|
||||||
|
builder.build()
|
||||||
|
};
|
||||||
|
// Cleanup duration depends on the retained tree. A response deadline
|
||||||
|
// does not cancel Runtime deletion, so it must not bound this wait.
|
||||||
|
Ok::<_, reqwest::Error>((build_client(Some(timeout))?, build_client(None)?))
|
||||||
})
|
})
|
||||||
.map_err(|err| RuntimeRegistryError::RuntimeOperationFailed {
|
.map_err(|err| RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
runtime_id: config.runtime_id.clone(),
|
runtime_id: config.runtime_id.clone(),
|
||||||
@@ -4393,6 +4399,7 @@ impl RemoteWorkerRuntime {
|
|||||||
cached_status: config.cached_status,
|
cached_status: config.cached_status,
|
||||||
resource_broker: BackendResourceBroker::default(),
|
resource_broker: BackendResourceBroker::default(),
|
||||||
http,
|
http,
|
||||||
|
workdir_cleanup_http,
|
||||||
async_http,
|
async_http,
|
||||||
request_timeout: timeout,
|
request_timeout: timeout,
|
||||||
})
|
})
|
||||||
@@ -5325,9 +5332,13 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
&self,
|
&self,
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
) -> RuntimeWorkingDirectoryResult {
|
) -> RuntimeWorkingDirectoryResult {
|
||||||
match self.delete_json::<RuntimeHttpWorkingDirectoryResponse>(&format!(
|
let path = format!("/v1/working-directories/{working_directory_id}");
|
||||||
"/v1/working-directories/{working_directory_id}"
|
match self.send_json::<RuntimeHttpWorkingDirectoryResponse>(
|
||||||
)) {
|
&path,
|
||||||
|
"DELETE",
|
||||||
|
&[],
|
||||||
|
self.workdir_cleanup_http.delete(self.endpoint(&path)),
|
||||||
|
) {
|
||||||
Ok(response) => RuntimeWorkingDirectoryResult {
|
Ok(response) => RuntimeWorkingDirectoryResult {
|
||||||
state: InternalWorkerOperationState::Accepted,
|
state: InternalWorkerOperationState::Accepted,
|
||||||
working_directory: Some(response.working_directory),
|
working_directory: Some(response.working_directory),
|
||||||
@@ -6828,6 +6839,7 @@ pub fn placeholder_spawn_response(host_id: impl Into<String>) -> WorkerSpawnResu
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
mod restore_auth_tests;
|
mod restore_auth_tests;
|
||||||
|
mod workdir_cleanup_tests;
|
||||||
mod workspace_auth_tests;
|
mod workspace_auth_tests;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! Exercises the production RemoteWorkerRuntime HTTP client against a gated loopback
|
||||||
|
//! provider. Only the provider is replaced; no live Runtime or Workdir is used.
|
||||||
|
//! Runs with the ordinary workspace-server crate tests.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use axum::{Json, Router, routing::delete};
|
||||||
|
use tokio::{sync::Notify, task::JoinSet, time::timeout};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workdir_cleanup_waits_past_ordinary_request_timeout_for_provider_confirmation() {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let runtime = RemoteWorkerRuntime::new(
|
||||||
|
RemoteRuntimeConfig::new(
|
||||||
|
"runtime-test",
|
||||||
|
"Test Runtime",
|
||||||
|
format!("http://{}", listener.local_addr().unwrap()),
|
||||||
|
Some("secret-token".into()),
|
||||||
|
)
|
||||||
|
.with_timeout(Duration::from_millis(100)),
|
||||||
|
"workspace-test".into(),
|
||||||
|
"http://127.0.0.1:1".into(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let entered = Arc::new(Notify::new());
|
||||||
|
let release = Arc::new(Notify::new());
|
||||||
|
let provider_entered = entered.clone();
|
||||||
|
let provider_release = release.clone();
|
||||||
|
let app = Router::new().route(
|
||||||
|
"/v1/working-directories/workdir-test",
|
||||||
|
delete(move |headers: axum::http::HeaderMap| async move {
|
||||||
|
assert_eq!(headers[AUTHORIZATION], "Bearer secret-token");
|
||||||
|
assert_eq!(headers["x-yoi-workspace-id"], "workspace-test");
|
||||||
|
provider_entered.notify_one();
|
||||||
|
// The fixture's deadline only bounds test failure/teardown. Completion
|
||||||
|
// is controlled by the test after an ordinary request has timed out.
|
||||||
|
let _ = timeout(Duration::from_secs(5), provider_release.notified()).await;
|
||||||
|
Json(json!({
|
||||||
|
"working_directory": { "summary": {
|
||||||
|
"working_directory_id": "workdir-test",
|
||||||
|
"repository_id": "main",
|
||||||
|
"materializer_kind": "runtime_git_clone",
|
||||||
|
"status": "not_found"
|
||||||
|
}}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.get(|| async {
|
||||||
|
let _ = timeout(Duration::from_secs(5), std::future::pending::<()>()).await;
|
||||||
|
Json(json!({}))
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let mut server = JoinSet::new();
|
||||||
|
server.spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||||
|
let cleanup_runtime = runtime.clone();
|
||||||
|
let cleanup = tokio::task::spawn_blocking(move || {
|
||||||
|
cleanup_runtime.cleanup_working_directory("workdir-test")
|
||||||
|
});
|
||||||
|
timeout(Duration::from_secs(5), entered.notified())
|
||||||
|
.await
|
||||||
|
.expect("provider received cleanup");
|
||||||
|
|
||||||
|
let ordinary = timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
tokio::task::spawn_blocking(move || runtime.working_directory("workdir-test")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("ordinary request remains bounded")
|
||||||
|
.unwrap();
|
||||||
|
let cleanup_still_waiting = !cleanup.is_finished();
|
||||||
|
release.notify_one();
|
||||||
|
let removed = timeout(Duration::from_secs(5), cleanup)
|
||||||
|
.await
|
||||||
|
.expect("cleanup receives provider confirmation")
|
||||||
|
.unwrap();
|
||||||
|
server.shutdown().await;
|
||||||
|
|
||||||
|
assert_eq!(ordinary.state, InternalWorkerOperationState::Rejected);
|
||||||
|
assert!(
|
||||||
|
ordinary
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.code == "remote_runtime_timeout")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
cleanup_still_waiting,
|
||||||
|
"cleanup must not inherit the ordinary timeout"
|
||||||
|
);
|
||||||
|
assert_eq!(removed.state, InternalWorkerOperationState::Accepted);
|
||||||
|
assert!(removed.diagnostics.is_empty());
|
||||||
|
let summary = removed.working_directory.unwrap().summary;
|
||||||
|
assert_eq!(summary.working_directory_id, "workdir-test");
|
||||||
|
assert_eq!(
|
||||||
|
summary.status,
|
||||||
|
workdir::workspace::WorkingDirectoryStatusKind::NotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18752,6 +18752,130 @@ fn integrated_merge_refs(
|
|||||||
Some((source, target))
|
Some((source, target))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_request_event_reference(
|
||||||
|
event: &merge_request::MergeRequestThreadEvent,
|
||||||
|
) -> server_api::MergeRequestEventReference {
|
||||||
|
use merge_request::MergeRequestThreadEvent as Event;
|
||||||
|
let event_id = match event {
|
||||||
|
Event::ReviewRequested(event) => &event.event_id,
|
||||||
|
Event::Review(event) => &event.event_id,
|
||||||
|
Event::ReviewRevoked(event) => &event.event_id,
|
||||||
|
Event::ReviewCancelled(event) => &event.event_id,
|
||||||
|
Event::Comment(event) => &event.event_id,
|
||||||
|
Event::Merge(event) => &event.event_id,
|
||||||
|
};
|
||||||
|
server_api::MergeRequestEventReference {
|
||||||
|
event_id: event_id.clone(),
|
||||||
|
sequence: event.sequence(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_request_status_summary(
|
||||||
|
mr: &merge_request::MergeRequest,
|
||||||
|
source: &server_api::MergeRequestRefResponse,
|
||||||
|
mut readiness: server_api::MergeRequestReadinessResponse,
|
||||||
|
) -> server_api::MergeRequestStatusSummary {
|
||||||
|
use merge_request::MergeRequestThreadEvent as Event;
|
||||||
|
// Never trust a ref retained alongside an unknown/invalid observation.
|
||||||
|
let subject = (source.status == "known")
|
||||||
|
.then_some(source.resolved_ref.as_deref())
|
||||||
|
.flatten();
|
||||||
|
let review = subject.and_then(|subject| {
|
||||||
|
if mr.state == merge_request::MergeRequestState::Merged {
|
||||||
|
mr.integration_approval().ok()
|
||||||
|
} else {
|
||||||
|
mr.effective_review(subject)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let pending = subject.is_some_and(|subject| {
|
||||||
|
mr.thread.iter().any(|event| {
|
||||||
|
let Event::ReviewRequested(request) = event else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
request.subject_ref == subject
|
||||||
|
&& !mr.thread.iter().any(|event| match event {
|
||||||
|
Event::Review(review) => review.request_event_id == request.event_id,
|
||||||
|
Event::ReviewCancelled(cancelled) => {
|
||||||
|
cancelled.request_event_id == request.event_id
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let review_status = match (subject, review) {
|
||||||
|
(None, _) => "unknown",
|
||||||
|
(_, Some(review)) => match review.decision {
|
||||||
|
merge_request::ReviewDecision::Approve => "approved",
|
||||||
|
merge_request::ReviewDecision::RequestChanges => "request_changes",
|
||||||
|
},
|
||||||
|
(_, None) if pending => "pending",
|
||||||
|
_ => "none",
|
||||||
|
};
|
||||||
|
// Readiness performs a second store read. A concurrent review/revocation must
|
||||||
|
// not turn this older summary snapshot into ready evidence for another review.
|
||||||
|
let snapshot_review = review.cloned().map(public_review_event);
|
||||||
|
if readiness.subject_ref.as_deref() != subject || readiness.review != snapshot_review {
|
||||||
|
readiness.ready = false;
|
||||||
|
readiness.blockers.push("readiness_evidence_changed".into());
|
||||||
|
}
|
||||||
|
// Retain readiness's established checks, but not a duplicate review body.
|
||||||
|
readiness.review = None;
|
||||||
|
server_api::MergeRequestStatusSummary {
|
||||||
|
review_status: review_status.into(),
|
||||||
|
review: review.map(|review| server_api::MergeRequestReviewSummary {
|
||||||
|
event: server_api::MergeRequestEventReference {
|
||||||
|
event_id: review.event_id.clone(),
|
||||||
|
sequence: review.sequence,
|
||||||
|
},
|
||||||
|
request_event_id: review.request_event_id.clone(),
|
||||||
|
subject_ref: review.subject_ref.clone(),
|
||||||
|
ticket_content_digest: review.ticket_content_digest.clone(),
|
||||||
|
decision: public_review_decision(review.decision),
|
||||||
|
findings_count: review.findings.len(),
|
||||||
|
}),
|
||||||
|
latest_review_subject_ref: mr.thread.iter().rev().find_map(|event| match event {
|
||||||
|
Event::ReviewRequested(event) => Some(event.subject_ref.clone()),
|
||||||
|
Event::Review(event) => Some(event.subject_ref.clone()),
|
||||||
|
_ => None,
|
||||||
|
}),
|
||||||
|
latest_event: mr.thread.last().map(merge_request_event_reference),
|
||||||
|
thread_event_count: mr.thread.len(),
|
||||||
|
readiness,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page the same complete snapshot used for the canonical summary. A second store
|
||||||
|
// read could include a revocation newer than the summary (or miss one it includes).
|
||||||
|
fn project_merge_request_thread(
|
||||||
|
mr: &mut merge_request::MergeRequest,
|
||||||
|
query: &server_api::MergeRequestThreadQuery,
|
||||||
|
) -> server_api::MergeRequestEventPage {
|
||||||
|
// Preserve legacy HTTP callers; explicit compact/detail reads use the smaller default.
|
||||||
|
let default_limit = if query.view.is_none() { 100 } else { 20 };
|
||||||
|
let limit = query.limit.unwrap_or(default_limit).clamp(1, 200);
|
||||||
|
if query.view == Some(server_api::MergeRequestReadView::Summary) {
|
||||||
|
let has_more = !mr.thread.is_empty();
|
||||||
|
mr.thread.clear();
|
||||||
|
return server_api::MergeRequestEventPage {
|
||||||
|
omitted: true,
|
||||||
|
has_more,
|
||||||
|
next_after: None,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut events = std::mem::take(&mut mr.thread)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|event| query.after.is_none_or(|after| event.sequence() > after));
|
||||||
|
mr.thread = events.by_ref().take(limit).collect();
|
||||||
|
let has_more = events.next().is_some();
|
||||||
|
server_api::MergeRequestEventPage {
|
||||||
|
omitted: false,
|
||||||
|
has_more,
|
||||||
|
next_after: has_more.then(|| mr.thread.last().unwrap().sequence()),
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_show_merge_request(
|
async fn scoped_show_merge_request(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
||||||
@@ -18765,12 +18889,16 @@ async fn scoped_show_merge_request(
|
|||||||
let store = merge_request_store(&api, &workspace_id)?;
|
let store = merge_request_store(&api, &workspace_id)?;
|
||||||
let mut mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
let mut mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
||||||
let integrated_refs = integrated_merge_refs(&mr);
|
let integrated_refs = integrated_merge_refs(&mr);
|
||||||
mr.thread = store.thread_page_by_id(
|
if query.view == Some(server_api::MergeRequestReadView::Summary)
|
||||||
&workspace_id,
|
&& (query.after.is_some() || query.limit.is_some())
|
||||||
&merge_request_id,
|
{
|
||||||
query.after,
|
return Err(
|
||||||
query.limit.unwrap_or(100),
|
Error::MergeRequest(merge_request::MergeRequestError::Validation(
|
||||||
)?;
|
"after/limit require view=detail".into(),
|
||||||
|
))
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
let ticket_id = mr.ticket_ids.first().ok_or_else(|| {
|
let ticket_id = mr.ticket_ids.first().ok_or_else(|| {
|
||||||
Error::MergeRequest(merge_request::MergeRequestError::Conflict(
|
Error::MergeRequest(merge_request::MergeRequestError::Conflict(
|
||||||
"Merge Request has no linked Ticket".into(),
|
"Merge Request has no linked Ticket".into(),
|
||||||
@@ -18824,6 +18952,38 @@ async fn scoped_show_merge_request(
|
|||||||
};
|
};
|
||||||
(source, target)
|
(source, target)
|
||||||
};
|
};
|
||||||
|
let mut report = store.readiness(merge_request::ReadinessCheck {
|
||||||
|
merge_request_id: mr.merge_request_id.clone(),
|
||||||
|
ticket_id: ticket_id.clone(),
|
||||||
|
current_subject_ref: (source.status == "known")
|
||||||
|
.then(|| source.resolved_ref.clone())
|
||||||
|
.flatten(),
|
||||||
|
auth: merge_request::MergeRequestAuth {
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
repository_id: mr.repository_id.clone(),
|
||||||
|
runtime_id: String::new(),
|
||||||
|
worker_id: String::new(),
|
||||||
|
assignment_id: String::new(),
|
||||||
|
},
|
||||||
|
})?;
|
||||||
|
if let Some(diagnostic) = &source.diagnostic {
|
||||||
|
let blocker = source_ref_readiness_blocker(&diagnostic.code).to_string();
|
||||||
|
if !report.blockers.contains(&blocker) {
|
||||||
|
report.blockers.push(blocker);
|
||||||
|
}
|
||||||
|
report.ready = false;
|
||||||
|
}
|
||||||
|
let summary = merge_request_status_summary(
|
||||||
|
&mr,
|
||||||
|
&source,
|
||||||
|
server_api::MergeRequestReadinessResponse {
|
||||||
|
ready: report.ready,
|
||||||
|
blockers: report.blockers,
|
||||||
|
subject_ref: report.subject_ref,
|
||||||
|
review: report.review.map(public_review_event),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let event_page = project_merge_request_thread(&mut mr, &query);
|
||||||
let linked_tickets = mr
|
let linked_tickets = mr
|
||||||
.ticket_ids
|
.ticket_ids
|
||||||
.iter()
|
.iter()
|
||||||
@@ -18844,6 +19004,9 @@ async fn scoped_show_merge_request(
|
|||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
linked_tickets,
|
linked_tickets,
|
||||||
|
view: query.view.unwrap_or_default(),
|
||||||
|
summary,
|
||||||
|
event_page,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -65940,6 +66103,321 @@ mod tests {
|
|||||||
assert!(!projected.contains("different-runtime"));
|
assert!(!projected.contains("different-runtime"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mr_read_fixture() -> merge_request::MergeRequest {
|
||||||
|
serde_json::from_value(json!({
|
||||||
|
"workspace_id": TEST_WORKSPACE_ID, "merge_request_id": "mr-read",
|
||||||
|
"repository_id": "repo", "state": "open", "selector_from": "work/read",
|
||||||
|
"selector_to": "develop", "ticket_ids": ["ticket"],
|
||||||
|
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"thread": [
|
||||||
|
{"kind": "review_requested", "event_id": "request-1", "sequence": 1,
|
||||||
|
"subject_ref": "source-1", "ticket_content_digest": "digest",
|
||||||
|
"ticket_merge_request_subjects": [],
|
||||||
|
"requested_by": {"runtime_id": "runtime", "worker_id": "coder"},
|
||||||
|
"reviewer": {"runtime_id": "runtime", "worker_id": "reviewer"},
|
||||||
|
"created_at": "2026-01-01T00:00:00Z"},
|
||||||
|
{"kind": "review", "event_id": "approval-1", "sequence": 2,
|
||||||
|
"request_event_id": "request-1", "subject_ref": "source-1",
|
||||||
|
"ticket_content_digest": "digest", "ticket_merge_request_subjects": [],
|
||||||
|
"decision": "approve", "body": "Exact review body", "findings": [],
|
||||||
|
"reviewer": {"runtime_id": "runtime", "worker_id": "reviewer"},
|
||||||
|
"created_at": "2026-01-01T00:00:00Z"},
|
||||||
|
{"kind": "comment", "event_id": "comment-1", "sequence": 3,
|
||||||
|
"body": "Later history", "author": {"runtime_id": "runtime", "worker_id": "coder"},
|
||||||
|
"created_at": "2026-01-01T00:00:00Z"}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mr_read_source() -> server_api::MergeRequestRefResponse {
|
||||||
|
server_api::MergeRequestRefResponse {
|
||||||
|
status: "known".into(),
|
||||||
|
resolved_ref: Some("source-1".into()),
|
||||||
|
observed_at: "2026-01-01T00:00:00Z".into(),
|
||||||
|
diagnostic: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mr_read_summary(mr: &merge_request::MergeRequest) -> server_api::MergeRequestStatusSummary {
|
||||||
|
merge_request_status_summary(
|
||||||
|
mr,
|
||||||
|
&mr_read_source(),
|
||||||
|
server_api::MergeRequestReadinessResponse {
|
||||||
|
ready: false,
|
||||||
|
blockers: vec!["fixture readiness".into()],
|
||||||
|
subject_ref: Some("source-1".into()),
|
||||||
|
review: mr
|
||||||
|
.effective_review("source-1")
|
||||||
|
.cloned()
|
||||||
|
.map(public_review_event),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mr_read_http_default_page_preserves_legacy_limit() {
|
||||||
|
let mut complete = mr_read_fixture();
|
||||||
|
let merge_request::MergeRequestThreadEvent::Comment(comment) =
|
||||||
|
complete.thread.pop().unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
complete.thread = (1..=205)
|
||||||
|
.map(|sequence| {
|
||||||
|
let mut comment = comment.clone();
|
||||||
|
comment.sequence = sequence;
|
||||||
|
comment.event_id = format!("comment-{sequence}");
|
||||||
|
merge_request::MergeRequestThreadEvent::Comment(comment)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (view, limit, expected) in [
|
||||||
|
(None, None, 100),
|
||||||
|
(Some(server_api::MergeRequestReadView::Detail), None, 20),
|
||||||
|
(None, Some(7), 7),
|
||||||
|
(
|
||||||
|
Some(server_api::MergeRequestReadView::Detail),
|
||||||
|
Some(500),
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut mr = complete.clone();
|
||||||
|
let page = project_merge_request_thread(
|
||||||
|
&mut mr,
|
||||||
|
&server_api::MergeRequestThreadQuery {
|
||||||
|
view,
|
||||||
|
limit,
|
||||||
|
after: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(mr.thread.len(), expected);
|
||||||
|
assert_eq!(page.limit, expected);
|
||||||
|
assert!(page.has_more && !page.omitted);
|
||||||
|
assert_eq!(page.next_after, Some(expected as u64));
|
||||||
|
}
|
||||||
|
let page = project_merge_request_thread(
|
||||||
|
&mut complete,
|
||||||
|
&server_api::MergeRequestThreadQuery {
|
||||||
|
after: Some(200),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(complete.thread.len(), 5);
|
||||||
|
assert!(!page.has_more);
|
||||||
|
assert_eq!(page.next_after, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mr_read_readiness_requires_same_review_snapshot() {
|
||||||
|
let mut mr = mr_read_fixture();
|
||||||
|
let report = server_api::MergeRequestReadinessResponse {
|
||||||
|
ready: true,
|
||||||
|
blockers: vec![],
|
||||||
|
subject_ref: Some("source-1".into()),
|
||||||
|
review: mr
|
||||||
|
.effective_review("source-1")
|
||||||
|
.cloned()
|
||||||
|
.map(public_review_event),
|
||||||
|
};
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), report.clone());
|
||||||
|
assert!(summary.readiness.ready);
|
||||||
|
assert!(summary.readiness.blockers.is_empty());
|
||||||
|
assert!(
|
||||||
|
summary.readiness.review.is_none(),
|
||||||
|
"exact evidence is checked before body omission"
|
||||||
|
);
|
||||||
|
assert_eq!(summary.review.unwrap().event.event_id, "approval-1");
|
||||||
|
|
||||||
|
let mut changed = report.clone();
|
||||||
|
changed.review.as_mut().unwrap().event_id = "new-approval".into();
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), changed);
|
||||||
|
assert!(
|
||||||
|
!summary.readiness.ready,
|
||||||
|
"different approval cannot validate the older snapshot"
|
||||||
|
);
|
||||||
|
assert_eq!(summary.readiness.blockers, ["readiness_evidence_changed"]);
|
||||||
|
assert_eq!(summary.review.unwrap().event.event_id, "approval-1");
|
||||||
|
|
||||||
|
let mut revoked = report.clone();
|
||||||
|
revoked.ready = false;
|
||||||
|
revoked.review = None;
|
||||||
|
revoked.blockers.push("review revoked".into());
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), revoked);
|
||||||
|
assert!(!summary.readiness.ready);
|
||||||
|
assert_eq!(
|
||||||
|
summary.readiness.blockers,
|
||||||
|
["review revoked", "readiness_evidence_changed"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut moved = report.clone();
|
||||||
|
moved.subject_ref = Some("source-2".into());
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), moved);
|
||||||
|
assert!(!summary.readiness.ready);
|
||||||
|
assert_eq!(summary.readiness.subject_ref.as_deref(), Some("source-2"));
|
||||||
|
assert_eq!(summary.review.unwrap().subject_ref, "source-1");
|
||||||
|
|
||||||
|
let merge_request::MergeRequestThreadEvent::Review(review) = &mut mr.thread[1] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
review.decision = merge_request::ReviewDecision::RequestChanges;
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), report.clone());
|
||||||
|
assert_eq!(summary.review_status, "request_changes");
|
||||||
|
assert!(
|
||||||
|
!summary.readiness.ready,
|
||||||
|
"new approval cannot contradict snapshot request_changes"
|
||||||
|
);
|
||||||
|
mr.thread.truncate(1);
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), report);
|
||||||
|
assert_eq!(summary.review_status, "pending");
|
||||||
|
assert!(
|
||||||
|
!summary.readiness.ready,
|
||||||
|
"new approval cannot contradict snapshot pending review"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mr_read_status_includes_page_external_approval_and_revocation() {
|
||||||
|
let complete = mr_read_fixture();
|
||||||
|
let summary = mr_read_summary(&complete);
|
||||||
|
let mut page = complete.clone();
|
||||||
|
let query = server_api::MergeRequestThreadQuery {
|
||||||
|
view: Some(server_api::MergeRequestReadView::Detail),
|
||||||
|
after: Some(2),
|
||||||
|
limit: Some(1),
|
||||||
|
};
|
||||||
|
let pagination = project_merge_request_thread(&mut page, &query);
|
||||||
|
assert_eq!(summary.review_status, "approved");
|
||||||
|
assert_eq!(
|
||||||
|
summary.review.as_ref().unwrap().event.event_id,
|
||||||
|
"approval-1"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
page.effective_review("source-1").is_none(),
|
||||||
|
"approval is outside the page"
|
||||||
|
);
|
||||||
|
assert!(!pagination.has_more);
|
||||||
|
assert_eq!(pagination.next_after, None);
|
||||||
|
|
||||||
|
let mut revoked = complete;
|
||||||
|
revoked.thread.push(serde_json::from_value(json!({
|
||||||
|
"kind": "review_revoked", "event_id": "revoke-1", "sequence": 4,
|
||||||
|
"review_event_id": "approval-1", "subject_ref": "source-1", "reason": "Found a defect",
|
||||||
|
"revoked_by": {"runtime_id": "runtime", "worker_id": "reviewer"},
|
||||||
|
"created_at": "2026-01-01T00:00:00Z"
|
||||||
|
})).unwrap());
|
||||||
|
let summary = mr_read_summary(&revoked);
|
||||||
|
let pagination = project_merge_request_thread(
|
||||||
|
&mut revoked,
|
||||||
|
&server_api::MergeRequestThreadQuery {
|
||||||
|
after: Some(1),
|
||||||
|
..query
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(summary.review_status, "none");
|
||||||
|
assert!(summary.review.is_none());
|
||||||
|
assert_eq!(summary.latest_event.unwrap().event_id, "revoke-1");
|
||||||
|
assert!(
|
||||||
|
revoked.effective_review("source-1").is_some(),
|
||||||
|
"page alone would falsely approve"
|
||||||
|
);
|
||||||
|
assert!(pagination.has_more);
|
||||||
|
assert_eq!(pagination.next_after, Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mr_read_summary_is_body_independent_and_detail_preserves_exact_findings() {
|
||||||
|
let mut complete = mr_read_fixture();
|
||||||
|
let merge_request::MergeRequestThreadEvent::Review(review) = &mut complete.thread[1] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
review.decision = merge_request::ReviewDecision::RequestChanges;
|
||||||
|
review.findings = vec![
|
||||||
|
serde_json::from_value(json!({
|
||||||
|
"severity": "major", "code": "regression", "path": "src/lib.rs", "line": 42,
|
||||||
|
"body": "Exact finding"
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
];
|
||||||
|
let small = serde_json::to_vec(&mr_read_summary(&complete)).unwrap();
|
||||||
|
let merge_request::MergeRequestThreadEvent::Review(review) = &mut complete.thread[1] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
review.body = "review evidence ".repeat(10_000);
|
||||||
|
review.findings[0].body = "finding evidence ".repeat(10_000);
|
||||||
|
let summary = mr_read_summary(&complete);
|
||||||
|
let compact = serde_json::to_vec(&summary).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
small, compact,
|
||||||
|
"summary contains no review/finding excerpts or bodies"
|
||||||
|
);
|
||||||
|
assert_eq!(summary.review_status, "request_changes");
|
||||||
|
assert_eq!(summary.review.as_ref().unwrap().findings_count, 1);
|
||||||
|
let original_review = complete.thread[1].clone();
|
||||||
|
let legacy = serde_json::to_vec(&complete).unwrap();
|
||||||
|
eprintln!(
|
||||||
|
"MR fixture bytes: full={}, summary={}",
|
||||||
|
legacy.len(),
|
||||||
|
compact.len()
|
||||||
|
);
|
||||||
|
assert!(compact.len() * 100 < legacy.len());
|
||||||
|
|
||||||
|
let mut summary_mr = complete.clone();
|
||||||
|
let omitted = project_merge_request_thread(
|
||||||
|
&mut summary_mr,
|
||||||
|
&server_api::MergeRequestThreadQuery {
|
||||||
|
view: Some(server_api::MergeRequestReadView::Summary),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(summary_mr.thread.is_empty());
|
||||||
|
assert!(omitted.omitted && omitted.has_more);
|
||||||
|
assert_eq!(
|
||||||
|
omitted.next_after, None,
|
||||||
|
"start detail at the beginning, not the latest event"
|
||||||
|
);
|
||||||
|
|
||||||
|
let event = &summary.review.unwrap().event;
|
||||||
|
let page = project_merge_request_thread(
|
||||||
|
&mut complete,
|
||||||
|
&server_api::MergeRequestThreadQuery {
|
||||||
|
view: Some(server_api::MergeRequestReadView::Detail),
|
||||||
|
after: Some(event.sequence - 1),
|
||||||
|
limit: Some(1),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(complete.thread, vec![original_review]);
|
||||||
|
assert_eq!(page.next_after, Some(event.sequence));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mr_read_summary_preserves_unknown_movement_pending_and_merged_evidence() {
|
||||||
|
let mut mr = mr_read_fixture();
|
||||||
|
let readiness = mr_read_summary(&mr).readiness;
|
||||||
|
let mut source = mr_read_source();
|
||||||
|
source.status = "unknown".into();
|
||||||
|
let summary = merge_request_status_summary(&mr, &source, readiness.clone());
|
||||||
|
assert_eq!(summary.review_status, "unknown");
|
||||||
|
assert!(
|
||||||
|
summary.review.is_none(),
|
||||||
|
"retained ref does not establish observation"
|
||||||
|
);
|
||||||
|
source.status = "known".into();
|
||||||
|
source.resolved_ref = Some("source-2".into());
|
||||||
|
let summary = merge_request_status_summary(&mr, &source, readiness.clone());
|
||||||
|
assert_eq!(summary.review_status, "none");
|
||||||
|
assert_eq!(
|
||||||
|
summary.latest_review_subject_ref.as_deref(),
|
||||||
|
Some("source-1")
|
||||||
|
);
|
||||||
|
mr.thread.truncate(1);
|
||||||
|
assert_eq!(mr_read_summary(&mr).review_status, "pending");
|
||||||
|
mr.state = merge_request::MergeRequestState::Merged;
|
||||||
|
assert!(
|
||||||
|
mr_read_summary(&mr).review.is_none(),
|
||||||
|
"merged state alone is not approval"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn merge_request_reads_use_first_class_workspace_resources() {
|
async fn merge_request_reads_use_first_class_workspace_resources() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -392,6 +392,33 @@ impl EvidenceApiFixture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mr_summary_route_omits_history_but_keeps_page_external_approval() {
|
||||||
|
let fixture = EvidenceApiFixture::new().await;
|
||||||
|
let approval = fixture.approve("summary-route");
|
||||||
|
fixture.integrate(&approval);
|
||||||
|
let value = get_json(
|
||||||
|
fixture.app.clone(),
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/merge-requests/{EVIDENCE_MR}?view=summary"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let summary: MergeRequestDetailResponse = serde_json::from_value(value).unwrap();
|
||||||
|
assert!(summary.merge_request.thread.is_empty());
|
||||||
|
assert!(summary.event_page.omitted);
|
||||||
|
assert!(summary.event_page.has_more);
|
||||||
|
assert_eq!(summary.summary.review_status, "approved");
|
||||||
|
assert_eq!(
|
||||||
|
summary.summary.review.as_ref().unwrap().event.event_id,
|
||||||
|
approval.event_id
|
||||||
|
);
|
||||||
|
assert!(summary.summary.readiness.review.is_none());
|
||||||
|
let page = fixture.detail(0, 1).await;
|
||||||
|
assert_eq!(page.merge_request.thread.len(), 1);
|
||||||
|
assert!(page.event_page.has_more);
|
||||||
|
assert!(page.event_page.next_after.is_some());
|
||||||
|
assert_eq!(page.summary, summary.summary);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn closed_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() {
|
async fn closed_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() {
|
||||||
let fixture = EvidenceApiFixture::new().await;
|
let fixture = EvidenceApiFixture::new().await;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ Assignは担当への委譲であり、readyやQueueの操作を前提にしな
|
|||||||
|
|
||||||
実装・routing・レビュー・終了の判断前には、現行Ticketと必要な関連記録を読む。通知、要約、過去の実装報告を現行の要求や承認の証拠にしない。Worker内の操作は、提供されたtyped toolsとsystem instructionsに従う。CLIや保存先の直接編集で欠けた権限を代替しない。typed Ticket toolsのないクライアントでは、Ticketの作成・更新をYoi Workerへ委ねる。
|
実装・routing・レビュー・終了の判断前には、現行Ticketと必要な関連記録を読む。通知、要約、過去の実装報告を現行の要求や承認の証拠にしない。Worker内の操作は、提供されたtyped toolsとsystem instructionsに従う。CLIや保存先の直接編集で欠けた権限を代替しない。typed Ticket toolsのないクライアントでは、Ticketの作成・更新をYoi Workerへ委ねる。
|
||||||
|
|
||||||
|
通常の進捗確認は`ShowTicket(view="summary")`を使い、要求初読・`content_digest`の変化・内容を要する判断では`detail`へ進む。モデル向け要求本文は`initial_post.body`に一度だけ置き、MRは`merge_requests`を正規の一覧とする。`history`は本文を反復しないページ、`post`は観測済み`event_ref`による全文取得であり、切詰め本文を編集CASに使わない。全文post読取りは1 MiBまでで、それを超える投稿は切詰めずエラーにする(分割全文読取りは未対応)。`ShowMergeRequest`のTool/WIPはsummaryを既定とし、履歴・review本文・findingは明示的な`detail`とcursorで読む。MR状態要約は完全な証拠から計算し、表示中の履歴ページから承認・失効を推測しない。これらはモデルへの出力を分ける契約であり、保存正本やDB読込み量の削減を意味しない。
|
||||||
|
|
||||||
成果には、実際の変更、検証結果、未確認事項、判断に必要な証拠を残す。すべての作業にコメントやartifactを義務付けず、利用者が必要とする返却方法を選ぶ。長い調査結果、生ログ、秘密情報はTicket本文へ転載しない。
|
成果には、実際の変更、検証結果、未確認事項、判断に必要な証拠を残す。すべての作業にコメントやartifactを義務付けず、利用者が必要とする返却方法を選ぶ。長い調査結果、生ログ、秘密情報はTicket本文へ転載しない。
|
||||||
|
|
||||||
対応終了と案件終了はそれぞれ明示的に判断する。結果を返して案件を開いたままにすることもできる。要求充足の判断は担当Worker・利用者の責任であり、Backendの認可・競合検査の成功や、成果物・承認済みMRの存在が代わりに判定するものではない。
|
対応終了と案件終了はそれぞれ明示的に判断する。結果を返して案件を開いたままにすることもできる。要求充足の判断は担当Worker・利用者の責任であり、Backendの認可・競合検査の成功や、成果物・承認済みMRの存在が代わりに判定するものではない。
|
||||||
@@ -32,4 +34,8 @@ Assignは担当への委譲であり、readyやQueueの操作を前提にしな
|
|||||||
|
|
||||||
source変更後は変更後のsourceを再レビューする。targetだけの変化では、未変更sourceへの承認と統合可否の再確認を区別する。通常の修正・再レビュー証拠はMerge Requestに置き、Ticketには調整が必要な阻害要因、決定、最終引継ぎを残す。
|
source変更後は変更後のsourceを再レビューする。targetだけの変化では、未変更sourceへの承認と統合可否の再確認を区別する。通常の修正・再レビュー証拠はMerge Requestに置き、Ticketには調整が必要な阻害要因、決定、最終引継ぎを残す。
|
||||||
|
|
||||||
|
再レビューの判断対象は現在の候補全体だが、全試験の再実行とは区別する。同一tree・同等条件の証拠は再利用し、新しい差分では依存・lockfile・feature・生成物・共有初期化・migration・環境への影響から必要な試験を選ぶ。元のsource/dirty snapshot、command、条件、exit/timeoutと到達範囲、ログ参照、未解決事項を一度記録し、後続報告では参照する。再利用した証拠を新sourceでの実行成功と呼ばず、旧承認を新候補へ移さない。
|
||||||
|
|
||||||
|
組込みPrompt/Flowの変更は、保存済みWorker指示や既存Flow定義を自動更新しない。新規Profileからの指示構成・Flowコンパイルの試験と、実モデルでの待機・再試験・出力量の測定を区別する。前者だけで運用改善を実証済みとしない。
|
||||||
|
|
||||||
統合、Ticketの結論、Worker停止、接続解除、資源削除は別の判断である。対応終了後の担当表示の保持と、未完作業による資源拘束も区別する。終了通知や最終回答だけから後続操作を開始せず、依頼範囲と現在の正本を確認する。未反映の変更を統合済みと報告しない。
|
統合、Ticketの結論、Worker停止、接続解除、資源削除は別の判断である。対応終了後の担当表示の保持と、未完作業による資源拘束も区別する。終了通知や最終回答だけから後続操作を開始せず、依頼範囲と現在の正本を確認する。未反映の変更を統合済みと報告しない。
|
||||||
|
|||||||
+173
-2
@@ -4933,6 +4933,9 @@
|
|||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"event_page": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestEventPage"
|
||||||
|
},
|
||||||
"linked_tickets": {
|
"linked_tickets": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/MergeRequestLinkedTicketResponse"
|
"$ref": "#/components/schemas/MergeRequestLinkedTicketResponse"
|
||||||
@@ -4960,6 +4963,9 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"$ref": "#/components/schemas/MergeRequestState"
|
"$ref": "#/components/schemas/MergeRequestState"
|
||||||
},
|
},
|
||||||
|
"summary": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestStatusSummary"
|
||||||
|
},
|
||||||
"target": {
|
"target": {
|
||||||
"$ref": "#/components/schemas/MergeRequestRefResponse"
|
"$ref": "#/components/schemas/MergeRequestRefResponse"
|
||||||
},
|
},
|
||||||
@@ -4978,6 +4984,9 @@
|
|||||||
"updated_at": {
|
"updated_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"view": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestReadView"
|
||||||
|
},
|
||||||
"workspace_id": {
|
"workspace_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -4994,7 +5003,63 @@
|
|||||||
"thread",
|
"thread",
|
||||||
"source",
|
"source",
|
||||||
"target",
|
"target",
|
||||||
"linked_tickets"
|
"linked_tickets",
|
||||||
|
"view",
|
||||||
|
"summary",
|
||||||
|
"event_page"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"MergeRequestEventPage": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"has_more": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"format": "uint",
|
||||||
|
"maximum": 200,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"next_after": {
|
||||||
|
"format": "uint64",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"omitted": {
|
||||||
|
"description": "Summary deliberately omits events. Read detail from the beginning to obtain history.",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"omitted",
|
||||||
|
"has_more",
|
||||||
|
"limit"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"MergeRequestEventReference": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"event_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"description": "Exact event: read detail with after = sequence - 1 and limit = 1.",
|
||||||
|
"format": "uint64",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"event_id",
|
||||||
|
"sequence"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -5120,6 +5185,13 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"MergeRequestReadView": {
|
||||||
|
"enum": [
|
||||||
|
"summary",
|
||||||
|
"detail"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"MergeRequestReadinessResponse": {
|
"MergeRequestReadinessResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5219,6 +5291,42 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"MergeRequestReviewSummary": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"decision": {
|
||||||
|
"$ref": "#/components/schemas/ReviewDecision"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestEventReference"
|
||||||
|
},
|
||||||
|
"findings_count": {
|
||||||
|
"description": "Findings retain their order in the exact review event; no finding body is truncated here.",
|
||||||
|
"format": "uint",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"request_event_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subject_ref": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ticket_content_digest": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"event",
|
||||||
|
"request_event_id",
|
||||||
|
"subject_ref",
|
||||||
|
"ticket_content_digest",
|
||||||
|
"decision",
|
||||||
|
"findings_count"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"MergeRequestState": {
|
"MergeRequestState": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"open",
|
"open",
|
||||||
@@ -5227,6 +5335,58 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"MergeRequestStatusSummary": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Computed from complete persisted evidence, never from the returned history page.\nThis observation is not integration authority or current-requirements approval.",
|
||||||
|
"properties": {
|
||||||
|
"latest_event": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MergeRequestEventReference"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"latest_review_subject_ref": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"readiness": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestReadinessResponse",
|
||||||
|
"description": "Existing readiness semantics; review bodies are omitted in this compact projection."
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MergeRequestReviewSummary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"review_status": {
|
||||||
|
"description": "unknown, none, pending, approved, or request_changes for the observed source.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"thread_event_count": {
|
||||||
|
"format": "uint",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"review_status",
|
||||||
|
"thread_event_count",
|
||||||
|
"readiness"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"MergeRequestThreadEvent": {
|
"MergeRequestThreadEvent": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -5548,6 +5708,17 @@
|
|||||||
"integer",
|
"integer",
|
||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MergeRequestReadView"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Show only: summary omits history; detail returns an exact bounded thread page.\nOmission retains the existing HTTP detail response."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -18307,7 +18478,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962"
|
"x-yoi-source-digest": "sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
fix = {
|
fix = {
|
||||||
instructions = "Resolve every open Reviewer finding on the same Ticket work branch and existing Merge Request source selector, rerun validation affected by the fixes, and commit the corrected implementation. Publish only that updated source ref with a normal non-force push and verify that the provider resolves it to exact new HEAD. Source movement invalidates the prior verdict, so request a fresh Reviewer child after publication. Do not open a replacement Merge Request, invent an operation to append another source candidate, create a fresh integration branch, rewrite previously reviewed commits, reuse the prior request_changes result as approval, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected source ref is published and ready for fresh independent review.";
|
instructions = "Resolve every open Reviewer finding on the same Ticket work branch and existing Merge Request source selector, rerun validation affected by the fixes, and commit the corrected implementation. Cite reusable evidence for unaffected scope under equivalent conditions, including its original source and execution conditions; do not claim those checks were rerun on the new source. Assess dependencies, features, generated code, shared initialization, migrations, and environment when choosing the affected checks. Fresh independent judgment covers the complete current candidate without automatically rerunning every prior suite. Publish only that updated source ref with a normal non-force push and verify that the provider resolves it to exact new HEAD. Source movement invalidates the prior verdict, so request a fresh Reviewer child after publication. Do not open a replacement Merge Request, invent an operation to append another source candidate, create a fresh integration branch, rewrite previously reviewed commits, reuse the prior request_changes result as approval, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected source ref is published and ready for fresh independent review.";
|
||||||
transitions = {
|
transitions = {
|
||||||
review = {
|
review = {
|
||||||
target = "review";
|
target = "review";
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
complete = {
|
complete = {
|
||||||
instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, Ticket content digest, and complete linked result snapshot, keep that reviewed source ref immutable, save concise implementation and validation evidence with `TicketImplementationResult` using the Ticket, stable operation_key, kind, bounded body and optional retained MR/source evidence. Backend authenticates an active Workspace Worker; no current or pinned implementation assignment or Orchestrator delegation is required. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. A saved MR approval and this typed handoff are separate facts; do not add a general comment or AssignmentAnswer solely for notification. Target-only movement does not invalidate this source approval; the Orchestrator refreshes readiness/integration evidence against the current target. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After the typed result receipt exists, request a Flow transition. Retry unknown outcomes only with the same key and full input.";
|
instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, Ticket content digest, and complete linked result snapshot, keep that reviewed source ref immutable, save concise implementation and validation evidence with `TicketImplementationResult` using the Ticket, kind, bounded body and optional retained MR/source evidence. The Host owns transport retry identity; do not supply operation_key. Backend authenticates an active Workspace Worker; no current or pinned implementation assignment or Orchestrator delegation is required. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. A saved MR approval and this typed handoff are separate facts; do not add a general comment or AssignmentAnswer solely for notification. Target-only movement does not invalidate this source approval; the Orchestrator refreshes readiness/integration evidence against the current target. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After the typed result receipt exists, request a Flow transition. If the report outcome is uncertain, reconcile saved evidence before a new invocation; a new invocation is a new report, not a transport retry.";
|
||||||
transitions = {
|
transitions = {
|
||||||
completed = {
|
completed = {
|
||||||
target = "done";
|
target = "done";
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
MR bookkeeping is separate from selecting and adopting changes. `CompleteMergeRequest` records an already-applied exact-source integration; its approval requirement is not a general Git adoption gate. Partial or adapted adoption is not full MR integration.
|
MR bookkeeping is separate from selecting and adopting changes. `CompleteMergeRequest` records an already-applied exact-source integration; its approval requirement is not a general Git adoption gate. Partial or adapted adoption is not full MR integration.
|
||||||
|
|
||||||
|
`ShowMergeRequest` defaults to a compact `summary` computed from complete evidence, not the returned history page. For review bodies/findings or prior events, request `view: "detail"`, follow `event_page.next_after`, or use a returned event sequence with `after: sequence - 1, limit: 1`. Summary readiness is an observation, not permission to integrate; unknown source observations remain unknown.
|
||||||
|
|
||||||
When using MRs, address each by `merge_request_id`. Reuse the repository's open MR and its `selector_from`/`selector_to`; publish source updates with a normal non-force push. Before formal review, verify the published source matches committed HEAD. Keep review/fix evidence on the MR, not repeated Ticket comments.
|
When using MRs, address each by `merge_request_id`. Reuse the repository's open MR and its `selector_from`/`selector_to`; publish source updates with a normal non-force push. Before formal review, verify the published source matches committed HEAD. Keep review/fix evidence on the MR, not repeated Ticket comments.
|
||||||
|
|
||||||
|
Record validation evidence once on the MR or an accessible session entry, then cite it: source/tree or dirty snapshot identity, command and scope, relevant features/toolchain/environment, exit or timeout and coverage reached, log reference, and unresolved issues. Reuse evidence for unchanged code under equivalent conditions. For a new candidate, assess dependencies, generated code, lockfiles, features, harness/shared initialization, migrations, and environment—not just non-overlapping files—and rerun affected or uncertain checks. Distinguish reused evidence from tests actually executed on the new source. Classify failures as regression, reproduced on the baseline, environment-dependent, or unresolved; do not call an unclassified failure or a timeout success. Broad tests are appropriate when the impact warrants them, not automatically after every local fix.
|
||||||
|
|
||||||
Formal approval binds the source, Ticket `content_digest`, and linked MR/result snapshot. Changed source or requirements need fresh attestation; target-only movement preserves approval. `CheckMergeRequestReadiness` checks this recording workflow. Ticket close/work release does not require reopen/reassignment for MR processing.
|
Formal approval binds the source, Ticket `content_digest`, and linked MR/result snapshot. Changed source or requirements need fresh attestation; target-only movement preserves approval. `CheckMergeRequestReadiness` checks this recording workflow. Ticket close/work release does not require reopen/reassignment for MR processing.
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
## Ticket workflow
|
## Ticket workflow
|
||||||
|
|
||||||
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 content and `content_digest`, thread/evidence, relations, linked Objectives, and any current Merge Request context before routing, review, or conclusion decisions. 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 content and `content_digest`, thread/evidence, relations, linked Objectives, and any current Merge Request context before routing, review, or conclusion decisions.
|
||||||
|
|
||||||
|
For routine progress or notification checks, use `ShowTicket` with `view: "summary"`; it omits prose and history, not authority checks. Read `view: "detail"` for the initial requirements, changed `content_digest`, or decisions needing content. Use `view: "history"` with the returned cursor for older events, and `view: "post"` with an observed `event_ref` for exact text before editing a truncated preview. The canonical requirement body is `initial_post.body`; linked MRs are in `merge_requests`. A summary or cached observation never substitutes for required rereads, edit CAS, or approval.
|
||||||
|
|
||||||
Read the relevant Ticket before making implementation, routing, review, state, or conclusion decisions. Natural-language user intent, requirements, acceptance criteria, and binding decisions govern the work; a Ticket is not necessarily code or a fixed implementation/review/merge pipeline. 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 conclusion decisions. Natural-language user intent, requirements, acceptance criteria, and binding decisions govern the work; a Ticket is not necessarily code or a fixed implementation/review/merge pipeline. 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 useful evidence. Do not copy your own role boundary, current-turn plan, work report, or decision to stop after Ticket authoring into the Ticket as a product prohibition or execution constraint. Do not add constraints the user did not request; record an approval gate only when the user places that gate on the Ticket itself. These are authoring rules that you apply with the request context, not facts for an independent text-only checker to infer. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative content and linked-Ticket context when coordinating broader work.
|
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and useful evidence. Do not copy your own role boundary, current-turn plan, work report, or decision to stop after Ticket authoring into the Ticket as a product prohibition or execution constraint. Do not add constraints the user did not request; record an approval gate only when the user places that gate on the Ticket itself. These are authoring rules that you apply with the request context, not facts for an independent text-only checker to infer. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative content and linked-Ticket context when coordinating broader work.
|
||||||
|
|
||||||
|
Record a Ticket dependency when delivering its outcome requires another Ticket's outcome. Editing the same file on separate branches does not by itself create a dependency or require exclusive editing. Keep independent changes self-contained on their own branches and reconcile them during merge.
|
||||||
|
|
||||||
Choose a result and return path suited to the request: conversation, an optional Ticket comment, an optional Drive document/artifact when that surface is available, or a repository result. Results need not be persisted as comments or artifacts when the request does not require it. In an MR workflow, keep routine review requests, verdicts, fixes, and rereview evidence on the explicitly addressed MR rather than duplicating each iteration on the Ticket. Do not require an `implementation_report` or treat linked-MR evidence as universal Ticket completion authority.
|
Choose a result and return path suited to the request: conversation, an optional Ticket comment, an optional Drive document/artifact when that surface is available, or a repository result. Results need not be persisted as comments or artifacts when the request does not require it. In an MR workflow, keep routine review requests, verdicts, fixes, and rereview evidence on the explicitly addressed MR rather than duplicating each iteration on the Ticket. Do not require an `implementation_report` or treat linked-MR evidence as universal Ticket completion authority.
|
||||||
|
|
||||||
Ticket cases have only `open` (still handled) and `closed` (handling ended for now); open is not execution readiness and closed is not necessarily fulfilled. Use authorized `TicketCaseUpdate` with `close`, `reopen`, or `correct_closure`, an explanation and typed closure reason when the request calls for it. Observe `content_digest`, case state, and nullable `closure_operation_key` from `ShowTicket` for CAS, and use a stable operation key for exact replay. Closure reasons and immutable history are separate from classification tags; tag changes never alter the closure judgment. Reread and reconcile after conflict; never guess a newer observation. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied.
|
Ticket cases have only `open` (still handled) and `closed` (handling ended for now); open is not execution readiness and closed is not necessarily fulfilled. Use authorized `TicketCaseUpdate` with `close`, `reopen`, or `correct_closure`, an explanation and typed closure reason when the request calls for it. Observe `content_digest`, case state, and nullable `closure_operation_key` from `ShowTicket` for CAS, and use a stable operation key for exact replay. Closure reasons and immutable history are separate from classification tags; tag changes never alter the closure judgment. Reread and reconcile after conflict; never guess a newer observation. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Worker-session tools are a read-only exploration surface over host-granted activ
|
|||||||
- Use `WorkerList` to discover known Workers and reuse its returned structured `subject` exactly. Runtime Workers use `{ kind: "runtime_worker", runtime_id, worker_id }`, while parent-owned children use `{ kind: "sub_worker", name }`. Do not guess subject identifiers.
|
- Use `WorkerList` to discover known Workers and reuse its returned structured `subject` exactly. Runtime Workers use `{ kind: "runtime_worker", runtime_id, worker_id }`, while parent-owned children use `{ kind: "sub_worker", name }`. Do not guess subject identifiers.
|
||||||
- Use `ViewSessionOverview` for sparse orientation, `SearchSessionEntries` for bounded range/filter queries, and `ReadSessionEntry` for one bounded entry.
|
- Use `ViewSessionOverview` for sparse orientation, `SearchSessionEntries` for bounded range/filter queries, and `ReadSessionEntry` for one bounded entry.
|
||||||
- `SessionEntryRef` is the common entry identity across overview, search, reads, and evidence conversion. Reuse returned `E...` values; never invent them.
|
- `SessionEntryRef` is the common entry identity across overview, search, reads, and evidence conversion. Reuse returned `E...` values; never invent them.
|
||||||
|
- For routine progress, read new results, failures, and blockers with their stable evidence references rather than reopening and transcribing all earlier logs. Before adoption or completion, inspect the concrete evidence relevant to that decision; an inaccessible reference remains unverified. Keep handling updates focused on current status, next action, and material result references.
|
||||||
- Every operation rereads the latest committed capture. Existing references remain stable when entries append.
|
- Every operation rereads the latest committed capture. Existing references remain stable when entries append.
|
||||||
- A missing subject and an unauthorized subject intentionally produce the same result. Treat either as inaccessible.
|
- A missing subject and an unauthorized subject intentionally produce the same result. Treat either as inaccessible.
|
||||||
- Observation is not mutation authority and does not prove approval, completion, or Ticket state. Reread the relevant domain authority before acting.
|
- Observation is not mutation authority and does not prove approval, completion, or Ticket state. Reread the relevant domain authority before acting.
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ The host gives you one immutable attempt snapshot containing the current state,
|
|||||||
|
|
||||||
Use ShowOverview, SearchEntries, and ReadEntry to inspect bounded committed parent-session evidence. When read-only Workdir tools are available, use Read, Glob, and Grep only as needed to check current repository evidence. You have no authority to mutate files, Tickets, Memory, Workers, Workdirs, Flow state, or any other domain.
|
Use ShowOverview, SearchEntries, and ReadEntry to inspect bounded committed parent-session evidence. When read-only Workdir tools are available, use Read, Glob, and Grep only as needed to check current repository evidence. You have no authority to mutate files, Tickets, Memory, Workers, Workdirs, Flow state, or any other domain.
|
||||||
|
|
||||||
|
Use stable entry references in the capture to inspect already-recorded validation rather than requiring the parent to repeat the same logs or commands. Evidence from an unchanged tree under equivalent conditions can remain applicable; for a changed candidate, inspect the impact on dependencies, generated code, features, shared initialization, migrations, and environment before reusing unaffected evidence. Reuse is not a claim that checks ran on the new source, and never transfers an old approval or authority.
|
||||||
|
|
||||||
|
A reference alone is not verification: inaccessible evidence, timeouts, and checks that did not reach their target remain unverified. You cannot perform live MR or other-session reads with tools that are not provided. If a condition requires a current authoritative observation that the captured evidence cannot establish, return `indeterminate`; do not invent freshness or demand copied prose as a substitute.
|
||||||
|
|
||||||
For every supplied transition, decide exactly one verdict:
|
For every supplied transition, decide exactly one verdict:
|
||||||
|
|
||||||
- `met`: the available evidence establishes the condition.
|
- `met`: the available evidence establishes the condition.
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ You are the assigned Coder. Implement and validate the requested scope in the bo
|
|||||||
|
|
||||||
Use `TicketImplementationResult` for handoffs and blockers, not `TicketAssignmentAnswer` or a comment as a notification substitute. `SpawnTicketWorker` defaults this Profile to `builtin:coder-review` when Flow is omitted; explicit Flow selection or disabling is respected. When that Flow is active, own implementation, validation, publication, the direct Reviewer SubWorker, fixes, and confirmation of the structured MR verdict before the reviewed handoff. Do not leave the review cycle for the Orchestrator to start. Blocker and partial-result reports do not require MR approval; do not describe them as a completed reviewed handoff. Without that Flow, follow the requested review and return scope rather than imposing a universal MR approval gate.
|
Use `TicketImplementationResult` for handoffs and blockers, not `TicketAssignmentAnswer` or a comment as a notification substitute. `SpawnTicketWorker` defaults this Profile to `builtin:coder-review` when Flow is omitted; explicit Flow selection or disabling is respected. When that Flow is active, own implementation, validation, publication, the direct Reviewer SubWorker, fixes, and confirmation of the structured MR verdict before the reviewed handoff. Do not leave the review cycle for the Orchestrator to start. Blocker and partial-result reports do not require MR approval; do not describe them as a completed reviewed handoff. Without that Flow, follow the requested review and return scope rather than imposing a universal MR approval gate.
|
||||||
|
|
||||||
|
Within the delegated scope, own diagnosis, test selection, and fix iterations. A partial report or a non-reproducing test does not create a new approval gate: update the hypothesis or method and continue useful authorized work. Respect explicit stop conditions, finite timeouts, actual resource conflicts, and authority limits; report a blocker when those prevent progress rather than repeatedly running the same inconclusive test. Connect a representative production caller-to-result path early, including persistence and registered tests where relevant, so missing APIs or integration ownership are discovered before handoff.
|
||||||
|
|
||||||
Publish the committed work branch, not the target branch or unrelated refs. Use Workdir aliases when multiple attachments exist; writable Ticket targets are available repositories, not mandatory deliverables.
|
Publish the committed work branch, not the target branch or unrelated refs. Use Workdir aliases when multiple attachments exist; writable Ticket targets are available repositories, not mandatory deliverables.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ After integrating changes, push the target branch through its configured normal
|
|||||||
|
|
||||||
Delegate bounded Ticket work with `SpawnTicketWorker` using the current `orchestrator_assignment_id`; choose Profile, Flow, and Workdir attachments for the task. For code implementation, select `builtin:coder`: omitting `flow` applies `builtin:coder-review`. Use explicit `flow: null` when the request calls for no Flow, or an explicit selector for a different Flow. Other Profiles receive no default Flow.
|
Delegate bounded Ticket work with `SpawnTicketWorker` using the current `orchestrator_assignment_id`; choose Profile, Flow, and Workdir attachments for the task. For code implementation, select `builtin:coder`: omitting `flow` applies `builtin:coder-review`. Use explicit `flow: null` when the request calls for no Flow, or an explicit selector for a different Flow. Other Profiles receive no default Flow.
|
||||||
|
|
||||||
|
Delegate outcomes, interfaces, validation expectations, and real stop conditions, leaving diagnosis and iteration within that scope to the Coder. Do not impose fixed build slots, diagnostic-attempt quotas, or permission waits without a user requirement, Host constraint, or observed resource pressure. Same-named files on separate branches are not exclusive resources; protect actual shared Workdir write scopes, databases, ports, and deliverable prerequisites instead. A normal partial report is progress, not a request to reauthorize unchanged scope.
|
||||||
|
|
||||||
In the coder-review cycle, the Coder owns publication, its direct Reviewer SubWorker, fixes, and the structured MR verdict before the reviewed handoff. If that cycle is incomplete, first have the Coder continue it; do not routinely start a second review of the same candidate yourself. Additional independent review remains appropriate when needed. A recorded review request is not evidence of an active Reviewer; confirm the current MR result and responsible Worker's progress before reporting review as running. You own candidate selection, combined validation, and integration.
|
In the coder-review cycle, the Coder owns publication, its direct Reviewer SubWorker, fixes, and the structured MR verdict before the reviewed handoff. If that cycle is incomplete, first have the Coder continue it; do not routinely start a second review of the same candidate yourself. Additional independent review remains appropriate when needed. A recorded review request is not evidence of an active Reviewer; confirm the current MR result and responsible Worker's progress before reporting review as running. You own candidate selection, combined validation, and integration.
|
||||||
|
|
||||||
For `implementation-result:<result ID>` notifications, read `TicketImplementationResultQuery`; for `mr-event:<MR ID>:<event ID>`, reread the MR.
|
For `implementation-result:<result ID>` notifications, read `TicketImplementationResultQuery`; for `mr-event:<MR ID>:<event ID>`, reread the MR.
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ You are the independent MR Reviewer, a direct SubWorker of the review requester.
|
|||||||
|
|
||||||
Evaluate the captured MR subject, Ticket `content_digest`, and complete linked MR/result snapshot against the Ticket requirements. Approval attests that snapshot, not just the selected diff. You may use Bash for Git and other commands.
|
Evaluate the captured MR subject, Ticket `content_digest`, and complete linked MR/result snapshot against the Ticket requirements. Approval attests that snapshot, not just the selected diff. You may use Bash for Git and other commands.
|
||||||
|
|
||||||
|
Independently inspect the current candidate and its impact boundary. Responsibility for the complete snapshot does not require rerunning every unchanged test: verify and cite applicable prior evidence, execute affected or uncertain checks, and make a fresh judgment for the current snapshot. Inaccessible evidence remains unverified, and an old approval is never transferable to a changed source or requirements.
|
||||||
|
|
||||||
Submit `ReviewMergeRequest` once with `approve` or `request_changes`, evidence, and findings. The child client injects capability and subject identity; prose is not a verdict. Source movement cancels submission; target-only movement preserves unchanged-source approval. For a merged MR, review the immutable result against the current requirements.
|
Submit `ReviewMergeRequest` once with `approve` or `request_changes`, evidence, and findings. The child client injects capability and subject identity; prose is not a verdict. Source movement cancels submission; target-only movement preserves unchanged-source approval. For a merged MR, review the immutable result against the current requirements.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
|
// Input contract: 0.1.0 (sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
|
// Input contract: 0.1.0 (sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
|
// Input contract: 0.1.0 (sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
|
// Input contract: 0.1.0 (sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
@@ -37,7 +37,11 @@ export type InvalidProjectRecord = { label: string; reason: string; };
|
|||||||
|
|
||||||
export type MarkdownText = string;
|
export type MarkdownText = string;
|
||||||
|
|
||||||
export type MergeRequestDetailResponse = { created_at: string; linked_tickets: Array<MergeRequestLinkedTicketResponse>; merge_request_id: string; repository_key: string; selector_from?: string | null; selector_to: string; source: MergeRequestRefResponse; state: MergeRequestState; target: MergeRequestRefResponse; thread: Array<MergeRequestThreadEvent>; ticket_ids: Array<string>; updated_at: string; workspace_id: string; };
|
export type MergeRequestDetailResponse = { created_at: string; event_page: MergeRequestEventPage; linked_tickets: Array<MergeRequestLinkedTicketResponse>; merge_request_id: string; repository_key: string; selector_from?: string | null; selector_to: string; source: MergeRequestRefResponse; state: MergeRequestState; summary: MergeRequestStatusSummary; target: MergeRequestRefResponse; thread: Array<MergeRequestThreadEvent>; ticket_ids: Array<string>; updated_at: string; view: MergeRequestReadView; workspace_id: string; };
|
||||||
|
|
||||||
|
export type MergeRequestEventPage = { has_more: boolean; limit: number & { readonly "__yoiIntegerRange": "1..200"; }; next_after?: number | null; omitted: boolean; };
|
||||||
|
|
||||||
|
export type MergeRequestEventReference = { event_id: string; sequence: number; };
|
||||||
|
|
||||||
export type MergeRequestLinkedTicketResponse = { key?: string | null; ticket_id: string; };
|
export type MergeRequestLinkedTicketResponse = { key?: string | null; ticket_id: string; };
|
||||||
|
|
||||||
@@ -45,14 +49,22 @@ export type MergeRequestListItem = { ref_diagnostics?: Array<MergeRequestRefDiag
|
|||||||
|
|
||||||
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>; next_cursor?: string | null; };
|
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>; next_cursor?: string | null; };
|
||||||
|
|
||||||
|
export type MergeRequestReadView = "summary" | "detail";
|
||||||
|
|
||||||
|
export type MergeRequestReadinessResponse = { blockers: Array<string>; ready: boolean; review?: ReviewEvent | null; subject_ref?: string | null; };
|
||||||
|
|
||||||
export type MergeRequestRefDiagnostic = { code: string; message: string; };
|
export type MergeRequestRefDiagnostic = { code: string; message: string; };
|
||||||
|
|
||||||
export type MergeRequestRefResponse = { diagnostic?: MergeRequestRefDiagnostic | null; observed_at: string; ref?: string | null; status: string; };
|
export type MergeRequestRefResponse = { diagnostic?: MergeRequestRefDiagnostic | null; observed_at: string; ref?: string | null; status: string; };
|
||||||
|
|
||||||
export type MergeRequestReviewSubject = { merge_request_id: string; subject_ref: string; };
|
export type MergeRequestReviewSubject = { merge_request_id: string; subject_ref: string; };
|
||||||
|
|
||||||
|
export type MergeRequestReviewSummary = { decision: ReviewDecision; event: MergeRequestEventReference; findings_count: number; request_event_id: string; subject_ref: string; ticket_content_digest: string; };
|
||||||
|
|
||||||
export type MergeRequestState = "open" | "merged" | "closed";
|
export type MergeRequestState = "open" | "merged" | "closed";
|
||||||
|
|
||||||
|
export type MergeRequestStatusSummary = { latest_event?: MergeRequestEventReference | null; latest_review_subject_ref?: string | null; readiness: MergeRequestReadinessResponse; review?: MergeRequestReviewSummary | null; review_status: string; thread_event_count: number; };
|
||||||
|
|
||||||
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
|
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
|
||||||
|
|
||||||
export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; };
|
export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; };
|
||||||
@@ -91,6 +103,8 @@ export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: strin
|
|||||||
|
|
||||||
export type ReviewDecision = "approve" | "request_changes";
|
export type ReviewDecision = "approve" | "request_changes";
|
||||||
|
|
||||||
|
export type ReviewEvent = { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; };
|
||||||
|
|
||||||
export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; };
|
export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; };
|
||||||
|
|
||||||
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; orchestrator_assignment_id?: string | null; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; };
|
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; orchestrator_assignment_id?: string | null; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
|
// Input contract: 0.1.0 (sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -608,6 +608,16 @@ Deno.test("Ticket Browser parser accepts the generated Objective list shape", ()
|
|||||||
|
|
||||||
Deno.test("Ticket Browser parser validates the canonical Merge Request thread", () => {
|
Deno.test("Ticket Browser parser validates the canonical Merge Request thread", () => {
|
||||||
const parsed = parseMergeRequestDetailResponse({
|
const parsed = parseMergeRequestDetailResponse({
|
||||||
|
view: "detail",
|
||||||
|
summary: {
|
||||||
|
review_status: "pending",
|
||||||
|
review: null,
|
||||||
|
latest_review_subject_ref: "abc123",
|
||||||
|
latest_event: { event_id: "event-1", sequence: 1 },
|
||||||
|
thread_event_count: 1,
|
||||||
|
readiness: { ready: false, blockers: ["review_missing"], subject_ref: "abc123" },
|
||||||
|
},
|
||||||
|
event_page: { omitted: false, has_more: false, next_after: null, limit: 100 },
|
||||||
workspace_id: "workspace-a",
|
workspace_id: "workspace-a",
|
||||||
merge_request_id: "mr-1",
|
merge_request_id: "mr-1",
|
||||||
repository_key: "main",
|
repository_key: "main",
|
||||||
@@ -643,6 +653,16 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread",
|
|||||||
linked_tickets: [{ ticket_id: "ticket-id", key: "T-1" }],
|
linked_tickets: [{ ticket_id: "ticket-id", key: "T-1" }],
|
||||||
});
|
});
|
||||||
assertEquals(parsed.thread[0]?.kind, "review_requested");
|
assertEquals(parsed.thread[0]?.kind, "review_requested");
|
||||||
|
assertEquals(parsed.summary.review_status, "pending");
|
||||||
|
assertEquals(parsed.summary.latest_event?.event_id, "event-1");
|
||||||
|
assertThrows(
|
||||||
|
() => parseMergeRequestDetailResponse({ ...parsed, summary: { ...parsed.summary, review_status: "invented" } }),
|
||||||
|
"review_status is invalid",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => parseMergeRequestDetailResponse({ ...parsed, event_page: { ...parsed.event_page, next_after: -1 } }),
|
||||||
|
"next_after",
|
||||||
|
);
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() =>
|
() =>
|
||||||
parseMergeRequestDetailResponse({
|
parseMergeRequestDetailResponse({
|
||||||
|
|||||||
@@ -1727,6 +1727,82 @@ function parseLinkedTicket(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMergeRequestEventReference(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): NonNullable<MergeRequestDetailResponse["summary"]["latest_event"]> | null {
|
||||||
|
if (value === null) return null;
|
||||||
|
const item = object(value, label);
|
||||||
|
exact(item, ["event_id", "sequence"], label);
|
||||||
|
return {
|
||||||
|
event_id: string(item.event_id, `${label}.event_id`),
|
||||||
|
sequence: integer(item.sequence, `${label}.sequence`, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMergeRequestStatusSummary(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): MergeRequestDetailResponse["summary"] {
|
||||||
|
const item = object(value, label);
|
||||||
|
exact(item, ["review_status", "review", "latest_review_subject_ref", "latest_event", "thread_event_count", "readiness"], label);
|
||||||
|
const status = string(item.review_status, `${label}.review_status`);
|
||||||
|
if (!["unknown", "none", "pending", "approved", "request_changes"].includes(status)) {
|
||||||
|
throw new TicketBrowserContractError(`${label}.review_status is invalid`);
|
||||||
|
}
|
||||||
|
let review: MergeRequestDetailResponse["summary"]["review"] = null;
|
||||||
|
if (item.review !== null) {
|
||||||
|
const r = object(item.review, `${label}.review`);
|
||||||
|
exact(r, ["event", "request_event_id", "subject_ref", "ticket_content_digest", "decision", "findings_count"], `${label}.review`);
|
||||||
|
const event = parseMergeRequestEventReference(r.event, `${label}.review.event`);
|
||||||
|
if (event === null) throw new TicketBrowserContractError(`${label}.review.event is required`);
|
||||||
|
const decision = string(r.decision, `${label}.review.decision`);
|
||||||
|
if (decision !== "approve" && decision !== "request_changes") {
|
||||||
|
throw new TicketBrowserContractError(`${label}.review.decision is invalid`);
|
||||||
|
}
|
||||||
|
review = {
|
||||||
|
event,
|
||||||
|
request_event_id: string(r.request_event_id, `${label}.review.request_event_id`),
|
||||||
|
subject_ref: string(r.subject_ref, `${label}.review.subject_ref`),
|
||||||
|
ticket_content_digest: string(r.ticket_content_digest, `${label}.review.ticket_content_digest`),
|
||||||
|
decision,
|
||||||
|
findings_count: integer(r.findings_count, `${label}.review.findings_count`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const readiness = object(item.readiness, `${label}.readiness`);
|
||||||
|
exact(readiness, ["ready", "blockers", "subject_ref", "review"], `${label}.readiness`);
|
||||||
|
// The compact summary never embeds a full review; its stable reference is above.
|
||||||
|
if (readiness.review !== undefined && readiness.review !== null) {
|
||||||
|
throw new TicketBrowserContractError(`${label}.readiness.review must be omitted`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
review_status: status,
|
||||||
|
review,
|
||||||
|
latest_review_subject_ref: nullableString(item.latest_review_subject_ref, `${label}.latest_review_subject_ref`),
|
||||||
|
latest_event: parseMergeRequestEventReference(item.latest_event, `${label}.latest_event`),
|
||||||
|
thread_event_count: integer(item.thread_event_count, `${label}.thread_event_count`),
|
||||||
|
readiness: {
|
||||||
|
ready: boolean(readiness.ready, `${label}.readiness.ready`),
|
||||||
|
blockers: strings(readiness.blockers, `${label}.readiness.blockers`),
|
||||||
|
subject_ref: optionalNullableString(readiness.subject_ref, `${label}.readiness.subject_ref`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMergeRequestEventPage(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): MergeRequestDetailResponse["event_page"] {
|
||||||
|
const item = object(value, label);
|
||||||
|
exact(item, ["omitted", "has_more", "next_after", "limit"], label);
|
||||||
|
return {
|
||||||
|
omitted: boolean(item.omitted, `${label}.omitted`),
|
||||||
|
has_more: boolean(item.has_more, `${label}.has_more`),
|
||||||
|
next_after: item.next_after === null ? null : integer(item.next_after, `${label}.next_after`, 1),
|
||||||
|
limit: integer(item.limit, `${label}.limit`, 1, 200) as MergeRequestDetailResponse["event_page"]["limit"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function parseMergeRequestDetailResponse(
|
export function parseMergeRequestDetailResponse(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): MergeRequestDetailResponse {
|
): MergeRequestDetailResponse {
|
||||||
@@ -1743,6 +1819,9 @@ export function parseMergeRequestDetailResponse(
|
|||||||
"state",
|
"state",
|
||||||
"target",
|
"target",
|
||||||
"thread",
|
"thread",
|
||||||
|
"summary",
|
||||||
|
"view",
|
||||||
|
"event_page",
|
||||||
"ticket_ids",
|
"ticket_ids",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"workspace_id",
|
"workspace_id",
|
||||||
@@ -1751,7 +1830,14 @@ export function parseMergeRequestDetailResponse(
|
|||||||
if (!["open", "merged", "closed"].includes(state)) {
|
if (!["open", "merged", "closed"].includes(state)) {
|
||||||
throw new TicketBrowserContractError(`${label}.state is invalid`);
|
throw new TicketBrowserContractError(`${label}.state is invalid`);
|
||||||
}
|
}
|
||||||
|
const view = string(item.view, `${label}.view`);
|
||||||
|
if (view !== "summary" && view !== "detail") {
|
||||||
|
throw new TicketBrowserContractError(`${label}.view is invalid`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
|
view,
|
||||||
|
summary: parseMergeRequestStatusSummary(item.summary, `${label}.summary`),
|
||||||
|
event_page: parseMergeRequestEventPage(item.event_page, `${label}.event_page`),
|
||||||
created_at: string(item.created_at, `${label}.created_at`),
|
created_at: string(item.created_at, `${label}.created_at`),
|
||||||
linked_tickets: array(
|
linked_tickets: array(
|
||||||
item.linked_tickets,
|
item.linked_tickets,
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ Deno.test("closed Ticket can retain immutable approval while current requirement
|
|||||||
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without changing case closure", async () => {
|
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without changing case closure", async () => {
|
||||||
const source = await Deno.readTextFile(
|
const source = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
"../../routes/w/[workspaceId]/tickets/[ticketId]/PageContent.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -164,7 +164,7 @@ Deno.test("Ticket route separates current requirement evidence from immutable in
|
|||||||
Deno.test("Merge Request routes distinguish recorded approval from live source observation and expose errors", async () => {
|
Deno.test("Merge Request routes distinguish recorded approval from live source observation and expose errors", async () => {
|
||||||
const list = await Deno.readTextFile(
|
const list = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"../../routes/w/[workspaceId]/merge-requests/+page.svelte",
|
"../../routes/w/[workspaceId]/merge-requests/PageContent.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -172,7 +172,7 @@ Deno.test("Merge Request routes distinguish recorded approval from live source o
|
|||||||
assertEquals(list.includes("Integration evidence error:"), true);
|
assertEquals(list.includes("Integration evidence error:"), true);
|
||||||
const detail = await Deno.readTextFile(
|
const detail = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte",
|
"../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/PageContent.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -183,6 +183,23 @@ Deno.test("Merge Request routes distinguish recorded approval from live source o
|
|||||||
|
|
||||||
Deno.test("merged detail uses backend validated immutable evidence even with a partial thread", () => {
|
Deno.test("merged detail uses backend validated immutable evidence even with a partial thread", () => {
|
||||||
const detail: MergeRequestDetail = {
|
const detail: MergeRequestDetail = {
|
||||||
|
view: "detail",
|
||||||
|
summary: {
|
||||||
|
review_status: "approved",
|
||||||
|
review: {
|
||||||
|
event: { event_id: "approval-1", sequence: 2 },
|
||||||
|
request_event_id: "request-1",
|
||||||
|
subject_ref: "source-1",
|
||||||
|
ticket_content_digest: "a".repeat(64),
|
||||||
|
decision: "approve",
|
||||||
|
findings_count: 0,
|
||||||
|
},
|
||||||
|
latest_review_subject_ref: "source-1",
|
||||||
|
latest_event: { event_id: "merge-1", sequence: 3 },
|
||||||
|
thread_event_count: 3,
|
||||||
|
readiness: { ready: false, blockers: ["already_merged"] },
|
||||||
|
},
|
||||||
|
event_page: { omitted: false, has_more: false, next_after: null, limit: 100 as MergeRequestDetail["event_page"]["limit"] },
|
||||||
merge_request_id: "mr-1",
|
merge_request_id: "mr-1",
|
||||||
workspace_id: "workspace-a",
|
workspace_id: "workspace-a",
|
||||||
repository_key: "main",
|
repository_key: "main",
|
||||||
@@ -224,7 +241,7 @@ Deno.test("merged detail uses backend validated immutable evidence even with a p
|
|||||||
);
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
sourceReviewFreshness({ ...detail, thread: [] }),
|
sourceReviewFreshness({ ...detail, thread: [] }),
|
||||||
"Recorded integration approval for immutable merged source source-1.",
|
"Recorded integration approval approval-1 for immutable merged source source-1.",
|
||||||
);
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
sourceReviewFreshness({
|
sourceReviewFreshness({
|
||||||
|
|||||||
@@ -1,99 +1,52 @@
|
|||||||
/** reviewの有効性はsubject_refと現在source refの一致で判断する。
|
/** Review status comes from the Backend's complete-evidence summary, never the
|
||||||
* 取消し済みreviewを除き、Serverが渡したthread順で有効なeventを選ぶ。
|
* returned history page. Unknown source observations remain unknown; target-only
|
||||||
* 新しい応答やtarget移動だけでは別sourceへの承認にならない。merged表示は
|
* movement does not invalidate unchanged-source approval.
|
||||||
* Backendが検証したimmutable integration evidenceを使い、新しいlive観測で上書きしない。
|
|
||||||
*/
|
*/
|
||||||
import type {
|
import type {
|
||||||
TicketEvidenceSummary,
|
TicketEvidenceSummary,
|
||||||
TicketMergeRequestSummary,
|
TicketMergeRequestSummary,
|
||||||
} from "#lib/generated/ticket-api.ts";
|
} from "#lib/generated/ticket-api.ts";
|
||||||
import type {
|
import type { MergeRequestDetail } from "./api/merge-requests.ts";
|
||||||
MergeRequestDetail,
|
|
||||||
MergeRequestThreadEvent,
|
|
||||||
} from "./api/merge-requests.ts";
|
|
||||||
|
|
||||||
type ReviewEvent = Extract<MergeRequestThreadEvent, { kind: "review" }>;
|
|
||||||
type SourceEvidenceEvent = Extract<
|
|
||||||
MergeRequestThreadEvent,
|
|
||||||
{ kind: "review" | "review_requested" }
|
|
||||||
>;
|
|
||||||
|
|
||||||
function isCurrentSourceEvent(
|
|
||||||
event: MergeRequestThreadEvent,
|
|
||||||
source: string,
|
|
||||||
): boolean {
|
|
||||||
return "subject_ref" in event && event.subject_ref === source;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestHasTerminalOutcome(
|
|
||||||
thread: MergeRequestThreadEvent[],
|
|
||||||
requestEventId: unknown,
|
|
||||||
): boolean {
|
|
||||||
return thread.some((event) =>
|
|
||||||
(event.kind === "review" || event.kind === "review_cancelled") &&
|
|
||||||
event.request_event_id === requestEventId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sourceReviewFreshness(
|
export function sourceReviewFreshness(
|
||||||
mergeRequest: MergeRequestDetail,
|
mergeRequest: MergeRequestDetail,
|
||||||
): string {
|
): string {
|
||||||
|
const source = mergeRequest.source.ref;
|
||||||
if (mergeRequest.state === "merged") {
|
if (mergeRequest.state === "merged") {
|
||||||
// The Backend validates the full persisted result before paging the thread.
|
|
||||||
// A merge event in a page alone is not evidence of a valid approval.
|
|
||||||
if (mergeRequest.source.diagnostic) {
|
if (mergeRequest.source.diagnostic) {
|
||||||
return `Immutable integration evidence unavailable: ${mergeRequest.source.diagnostic.code}.`;
|
return `Immutable integration evidence unavailable: ${mergeRequest.source.diagnostic.code}.`;
|
||||||
}
|
}
|
||||||
const source = mergeRequest.source.ref;
|
|
||||||
if (mergeRequest.source.status !== "known" || !source) {
|
if (mergeRequest.source.status !== "known" || !source) {
|
||||||
return "Immutable integration evidence unavailable: no validated persisted source.";
|
return "Immutable integration evidence unavailable: no validated persisted source.";
|
||||||
}
|
}
|
||||||
const merge = [...mergeRequest.thread].reverse().find(
|
const approval = mergeRequest.summary?.review;
|
||||||
(event): event is Extract<MergeRequestThreadEvent, { kind: "merge" }> =>
|
return approval && mergeRequest.summary.review_status === "approved"
|
||||||
event.kind === "merge",
|
? `Recorded integration approval ${approval.event.event_id} for immutable merged source ${source}.`
|
||||||
);
|
: `Integration approval unavailable for immutable merged source ${source}.`;
|
||||||
return merge
|
|
||||||
? `Recorded integration approval ${merge.approval_event_id} for immutable merged source ${source}.`
|
|
||||||
: `Recorded integration approval for immutable merged source ${source}.`;
|
|
||||||
}
|
}
|
||||||
const source = mergeRequest.source.ref;
|
if (mergeRequest.source.status !== "known" || !source) {
|
||||||
if (!source) return "Source review unavailable: selector_from is unresolved.";
|
return mergeRequest.source.diagnostic
|
||||||
|
? `Source review unavailable: ${mergeRequest.source.diagnostic.code}.`
|
||||||
const effectiveReview = [...mergeRequest.thread].reverse().find(
|
: "Source review unavailable: selector_from is unresolved.";
|
||||||
(event): event is ReviewEvent => {
|
|
||||||
if (event.kind !== "review" || !isCurrentSourceEvent(event, source)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !mergeRequest.thread.some(
|
|
||||||
(candidate) =>
|
|
||||||
candidate.kind === "review_revoked" &&
|
|
||||||
candidate.review_event_id === event.event_id,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (effectiveReview) {
|
|
||||||
return effectiveReview.decision === "approve"
|
|
||||||
? `Current source approved at exact ref ${source}.`
|
|
||||||
: `Current source requests changes at exact ref ${source}.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestEvidence = [...mergeRequest.thread].reverse().find(
|
const summary = mergeRequest.summary;
|
||||||
(event): event is SourceEvidenceEvent =>
|
// Do not fall back to partial-thread inference for an older/malformed response.
|
||||||
event.kind === "review" || event.kind === "review_requested",
|
if (!summary || summary.review_status === "unknown") {
|
||||||
);
|
return "Source review unavailable: authoritative summary is unavailable.";
|
||||||
if (latestEvidence?.subject_ref && latestEvidence.subject_ref !== source) {
|
|
||||||
return `Fresh source review required: selector_from moved from ${latestEvidence.subject_ref} to ${source}.`;
|
|
||||||
}
|
}
|
||||||
|
switch (summary.review_status) {
|
||||||
const pendingRequest = [...mergeRequest.thread].reverse().find((event) =>
|
case "approved":
|
||||||
event.kind === "review_requested" &&
|
return `Current source approved at exact ref ${source}.`;
|
||||||
isCurrentSourceEvent(event, source) &&
|
case "request_changes":
|
||||||
!requestHasTerminalOutcome(mergeRequest.thread, event.event_id)
|
return `Current source requests changes at exact ref ${source}.`;
|
||||||
);
|
case "pending":
|
||||||
if (pendingRequest) {
|
return `Current source review pending for exact ref ${source}.`;
|
||||||
return `Current source review pending for exact ref ${source}.`;
|
}
|
||||||
|
const previous = summary.latest_review_subject_ref;
|
||||||
|
if (previous && previous !== source) {
|
||||||
|
return `Fresh source review required: selector_from moved from ${previous} to ${source}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `Fresh source review required: no effective verdict exists for ${source}.`;
|
return `Fresh source review required: no effective verdict exists for ${source}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,8 +106,22 @@ function reviewCancelled(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function detail(thread: MergeRequestThreadEvent[]): MergeRequestDetail {
|
function detail(
|
||||||
|
thread: MergeRequestThreadEvent[],
|
||||||
|
reviewStatus = "none",
|
||||||
|
latestSubject = "source-2",
|
||||||
|
): MergeRequestDetail {
|
||||||
return {
|
return {
|
||||||
|
view: "detail",
|
||||||
|
summary: {
|
||||||
|
review_status: reviewStatus,
|
||||||
|
review: null,
|
||||||
|
latest_review_subject_ref: latestSubject,
|
||||||
|
latest_event: null,
|
||||||
|
thread_event_count: thread.length,
|
||||||
|
readiness: { ready: false, blockers: [] },
|
||||||
|
},
|
||||||
|
event_page: { omitted: false, has_more: false, next_after: null, limit: 100 as MergeRequestDetail["event_page"]["limit"] },
|
||||||
merge_request_id: "MR-1",
|
merge_request_id: "MR-1",
|
||||||
workspace_id: "W",
|
workspace_id: "W",
|
||||||
repository_key: "main",
|
repository_key: "main",
|
||||||
@@ -148,7 +162,7 @@ Deno.test("revoked review requires a fresh review instead of appearing pending",
|
|||||||
Deno.test("unresolved review request for the current source is pending", () => {
|
Deno.test("unresolved review request for the current source is pending", () => {
|
||||||
const mergeRequest = detail([
|
const mergeRequest = detail([
|
||||||
reviewRequested("request-2", "source-2"),
|
reviewRequested("request-2", "source-2"),
|
||||||
]);
|
], "pending");
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
sourceReviewFreshness(mergeRequest),
|
sourceReviewFreshness(mergeRequest),
|
||||||
@@ -160,7 +174,7 @@ Deno.test("completed or cancelled request is not projected as pending", () => {
|
|||||||
const approved = detail([
|
const approved = detail([
|
||||||
reviewRequested("request-3", "source-2"),
|
reviewRequested("request-3", "source-2"),
|
||||||
review("review-3", "request-3", "source-2"),
|
review("review-3", "request-3", "source-2"),
|
||||||
]);
|
], "approved");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
sourceReviewFreshness(approved),
|
sourceReviewFreshness(approved),
|
||||||
"Current source approved at exact ref source-2.",
|
"Current source approved at exact ref source-2.",
|
||||||
@@ -176,10 +190,22 @@ Deno.test("completed or cancelled request is not projected as pending", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("status uses complete backend evidence rather than a selected history page", () => {
|
||||||
|
// The visible approval has been revoked outside this page.
|
||||||
|
const revoked = detail([review("old-approval", "old-request", "source-2")], "none");
|
||||||
|
assertEquals(sourceReviewFreshness(revoked),
|
||||||
|
"Fresh source review required: no effective verdict exists for source-2.");
|
||||||
|
// The current verdict need not appear in the returned page at all.
|
||||||
|
assertEquals(sourceReviewFreshness(detail([], "approved")),
|
||||||
|
"Current source approved at exact ref source-2.");
|
||||||
|
assertEquals(sourceReviewFreshness(detail([], "request_changes")),
|
||||||
|
"Current source requests changes at exact ref source-2.");
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("source movement explains the exact stale and current refs", () => {
|
Deno.test("source movement explains the exact stale and current refs", () => {
|
||||||
const mergeRequest = detail([
|
const mergeRequest = detail([
|
||||||
review("review-old", "request-old", "source-1"),
|
review("review-old", "request-old", "source-1"),
|
||||||
]);
|
], "none", "source-1");
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
sourceReviewFreshness(mergeRequest),
|
sourceReviewFreshness(mergeRequest),
|
||||||
|
|||||||
Reference in New Issue
Block a user