feat(ticket): align Web actions and instructions with intent-led work
This commit is contained in:
@@ -79,7 +79,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
selector: Some("builtin:ticket-worker"),
|
||||
path: "profiles/ticket-worker.dcdl",
|
||||
source: include_str!("../../../resources/profiles/ticket-worker.dcdl"),
|
||||
description: "General Ticket work with typed progress and completion tools.",
|
||||
description: "General intent-led Ticket work with optional result recording and authorized conclusion decisions.",
|
||||
imports: &[BuiltinProfileImport {
|
||||
specifier: "./default.dcdl",
|
||||
resolved_path: DEFAULT_PATH,
|
||||
@@ -89,7 +89,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
selector: Some("builtin:coder"),
|
||||
path: "profiles/coder.dcdl",
|
||||
source: include_str!("../../../resources/profiles/coder.dcdl"),
|
||||
description: "Ticket implementation with direct Reviewer SubWorkers.",
|
||||
description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
@@ -117,7 +117,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
selector: Some("builtin:orchestrator"),
|
||||
path: "profiles/orchestrator.dcdl",
|
||||
source: include_str!("../../../resources/profiles/orchestrator.dcdl"),
|
||||
description: "Workspace orchestration and Worker control.",
|
||||
description: "Intent-led Ticket orchestration with separate guarded MR integration authority.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
|
||||
@@ -823,7 +823,7 @@ mod tests {
|
||||
"display_name": "Embedded",
|
||||
"built_in": true,
|
||||
"worker_creation_available": true,
|
||||
"working_directory_required": false,
|
||||
"supports_workdir_attachments": false,
|
||||
"status": "online",
|
||||
"diagnostics": []
|
||||
}],
|
||||
|
||||
@@ -549,7 +549,7 @@ impl BackendWorkspaceProductClient {
|
||||
let runtime = options
|
||||
.runtimes
|
||||
.iter()
|
||||
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
|
||||
.find(|runtime| runtime.worker_creation_available)
|
||||
.ok_or_else(|| {
|
||||
BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
||||
@@ -1340,7 +1340,7 @@ mod tests {
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
(
|
||||
"200 OK",
|
||||
r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"working_directory_required":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
|
||||
r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"supports_workdir_attachments":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
|
||||
),
|
||||
(
|
||||
"200 OK",
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
match slug {
|
||||
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 4,
|
||||
revision: 5,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}),
|
||||
@@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 4,
|
||||
revision: 5,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}];
|
||||
|
||||
@@ -9744,7 +9744,9 @@ pub struct WorkerLaunchRuntimeOption {
|
||||
pub display_name: String,
|
||||
pub built_in: bool,
|
||||
pub worker_creation_available: bool,
|
||||
pub working_directory_required: bool,
|
||||
/// Whether this Runtime supports explicitly selected Workdir attachments.
|
||||
/// Workdirs are optional for generic launches; embedded Runtime does not support them.
|
||||
pub supports_workdir_attachments: bool,
|
||||
pub status: String,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
@@ -120,13 +120,6 @@ impl FormState {
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
}
|
||||
if runtime.working_directory_required {
|
||||
self.status =
|
||||
"The selected Runtime requires a workdir; this launch flow does not select one yet."
|
||||
.to_string();
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
}
|
||||
let Some(profile) = self.current_profile(options) else {
|
||||
self.status = "No Worker profile is available.".to_string();
|
||||
self.field = Field::Profile;
|
||||
@@ -377,8 +370,8 @@ fn profile_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> Str
|
||||
fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String {
|
||||
let availability = if !runtime.worker_creation_available {
|
||||
"unavailable"
|
||||
} else if runtime.working_directory_required {
|
||||
"workdir required"
|
||||
} else if runtime.supports_workdir_attachments {
|
||||
"optional workdirs"
|
||||
} else {
|
||||
"no workdir"
|
||||
};
|
||||
@@ -389,7 +382,7 @@ fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String {
|
||||
}
|
||||
|
||||
fn runtime_supports_workdirless_creation(runtime: &BackendWorkerLaunchRuntimeOption) -> bool {
|
||||
runtime.worker_creation_available && !runtime.working_directory_required
|
||||
runtime.worker_creation_available
|
||||
}
|
||||
|
||||
fn cycle_index(current: usize, len: usize, delta: isize) -> usize {
|
||||
@@ -413,7 +406,7 @@ mod tests {
|
||||
display_name: "External".to_string(),
|
||||
built_in: false,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: true,
|
||||
supports_workdir_attachments: true,
|
||||
status: "online".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
@@ -422,7 +415,7 @@ mod tests {
|
||||
display_name: "Embedded".to_string(),
|
||||
built_in: true,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: false,
|
||||
supports_workdir_attachments: false,
|
||||
status: "online".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
@@ -454,20 +447,18 @@ mod tests {
|
||||
let state = FormState::new(&options);
|
||||
assert_eq!(
|
||||
state.current_runtime(&options).unwrap().runtime_id,
|
||||
"embedded"
|
||||
"external"
|
||||
);
|
||||
assert_eq!(state.current_profile(&options).unwrap().id, "builtin:coder");
|
||||
assert_eq!(state.display_name, "Worker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_required_runtime_cannot_be_submitted() {
|
||||
fn attachment_capable_runtime_can_submit_without_resources() {
|
||||
let options = options();
|
||||
let mut state = FormState::new(&options);
|
||||
state.runtime_index = 0;
|
||||
assert_eq!(state.submit(&options), None);
|
||||
assert!(state.status.contains("requires a workdir"));
|
||||
assert_eq!(state.field, Field::Runtime);
|
||||
assert_eq!(state.submit(&options).unwrap().runtime_id, "external");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -870,12 +870,14 @@ impl WorkerOperation {
|
||||
"Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit."
|
||||
}
|
||||
Self::Cancel => "Cancel the current turn of a known Runtime Worker when allowed.",
|
||||
Self::Stop => "Stop a known Runtime Worker when allowed.",
|
||||
Self::Stop => {
|
||||
"Stop a known Runtime Worker when allowed. Stop is independent of Ticket conclusion: it does not end unfinished work, remove retained session records, release attachments, or delete Workdirs."
|
||||
}
|
||||
Self::Restore => {
|
||||
"Restore a stopped Backend/Runtime Worker session in the current Workspace. Supply expected_observation_token from WorkerList; a conflict requires refreshing and deliberate new intent. New requests default to the durable Tool call ID. For explicit recovery of an unknown/pending outcome, supply the reported request_id and the original token unchanged; never create a new intent or automatically retry."
|
||||
}
|
||||
Self::Remove => {
|
||||
"Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Supply a bounded reason; Backend validation and retention are authoritative."
|
||||
"Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Conclusion, stopping, retention/removal, attachment release, and Workdir deletion are separate decisions. Removal releases attachments but preserves Workdir materialization. Supply a bounded reason; Backend validation and retention are authoritative."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2050,6 +2052,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_lifecycle_descriptions_separate_unfinished_work_retention_and_attachment_cleanup() {
|
||||
let stop = worker_tool_declaration(WorkerOperation::Stop);
|
||||
assert!(stop.description.contains("does not end unfinished work"));
|
||||
assert!(
|
||||
stop.description
|
||||
.contains("release attachments, or delete Workdirs")
|
||||
);
|
||||
let remove = worker_tool_declaration(WorkerOperation::Remove);
|
||||
assert!(
|
||||
remove
|
||||
.description
|
||||
.contains("Terminal Ticket responsibility is retained")
|
||||
);
|
||||
assert!(remove.description.contains("does not require unassignment"));
|
||||
assert!(
|
||||
remove
|
||||
.description
|
||||
.contains("releases attachments but preserves Workdir materialization")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_remove_forwards_only_target_and_bounded_reason() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
|
||||
@@ -27,8 +27,7 @@ use wip_protocol::{
|
||||
|
||||
pub const FEATURE_ID: &str = "merge_request";
|
||||
const FEATURE_NAME: &str = "Merge Request tools";
|
||||
const FEATURE_DESCRIPTION: &str =
|
||||
"Operation-specific Merge Request workflow tools over Workspace authority.";
|
||||
const FEATURE_DESCRIPTION: &str = "Optional repository MR workflow with trusted review and separate Orchestrator integration authority; not a universal Ticket conclusion gate.";
|
||||
const FEATURE_INSTRUCTION_ID: &str = "merge_request.workflow";
|
||||
pub const FEATURE_PROMPT_REF: &str = "common.merge_request";
|
||||
|
||||
@@ -1085,10 +1084,10 @@ pub fn description(n: &str) -> Option<&'static str> {
|
||||
"Open one repository-scoped Merge Request linked to the Ticket; reuse an existing Merge Request for that repository and advance only its selector_from with a normal non-force push.",
|
||||
),
|
||||
"CompleteMergeRequest" => Some(
|
||||
"Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.",
|
||||
"Record Orchestrator-owned integration for the explicitly addressed Merge Request using current trusted exact-source approval and provider evidence. This records an already-applied integration, not a Ticket conclusion or assignment release; a review-not-required Ticket request does not bypass MR integration guards.",
|
||||
),
|
||||
"ReviewMergeRequest" => Some(
|
||||
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
|
||||
"Submit the injected trusted Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not. The capability authenticates the attempt, not the verdict: independent natural-language judgment and validation remain the Reviewer's responsibility.",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::feature::{
|
||||
|
||||
const FEATURE_ID: &str = "orchestration";
|
||||
const TOOL_NAME: &str = "SpawnTicketWorker";
|
||||
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OrchestrationFeature;
|
||||
@@ -41,10 +42,7 @@ impl FeatureModule for OrchestrationFeature {
|
||||
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||
"SpawnTicketWorker requires Workspace Worker lifecycle authority",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
TOOL_NAME,
|
||||
"Spawn and atomically assign a configurable Worker for a Ticket. Supply a registered profile selector, an initial request, and optionally a Flow selector. Workdir attachments may be empty; Backend authority validates claims and derives each attachment's access from the Ticket targets. The guarded operation records acceptance only after spawn, initial input, assignment, and resource finalization. Selecting a profile or Flow does not grant review authority.",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(TOOL_NAME, TOOL_DESCRIPTION))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
@@ -81,8 +79,10 @@ struct SpawnTicketWorkerWorkdirInput {
|
||||
struct SpawnTicketWorkerInput {
|
||||
ticket_id: String,
|
||||
runtime_id: String,
|
||||
/// Registered profile selector. Backend resolves the profile and validates claims.
|
||||
profile: String,
|
||||
/// Optional registered profile selector; omitted/null selects builtin:ticket-worker.
|
||||
/// Backend resolves the profile and validates claims, not natural-language satisfaction.
|
||||
#[serde(default)]
|
||||
profile: Option<String>,
|
||||
/// Initial user request delivered through the normal typed submission path.
|
||||
initial_request: String,
|
||||
/// Optional Flow selector, delivered as a typed Flow segment before the request.
|
||||
@@ -116,7 +116,11 @@ impl Tool for SpawnTicketWorkerTool {
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
let call_id = non_empty(ctx.call_id, "tool call_id")?;
|
||||
let runtime_id = authority_id(input.runtime_id, "runtime_id")?;
|
||||
let profile = non_empty(input.profile, "profile")?;
|
||||
let profile = input
|
||||
.profile
|
||||
.map(|profile| non_empty(profile, "profile"))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| "builtin:ticket-worker".into());
|
||||
let initial_request = non_empty(input.initial_request, "initial_request")?;
|
||||
let flow = input.flow.map(|flow| non_empty(flow, "flow")).transpose()?;
|
||||
let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?;
|
||||
@@ -196,9 +200,7 @@ fn definition(
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput))
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let meta = ToolMeta::new(TOOL_NAME)
|
||||
.description(
|
||||
"Spawn and atomically assign a configurable Ticket Worker with a registered profile, initial request, optional typed Flow, and zero or more alias-keyed Workdir attachments. Backend validates claims and resources; profile and Flow selection do not grant review authority.",
|
||||
)
|
||||
.description(TOOL_DESCRIPTION)
|
||||
.input_schema(schema);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketWorkerTool {
|
||||
ticket_service: ticket_service.clone(),
|
||||
@@ -389,6 +391,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_worker_omitted_profile_uses_general_worker_without_flow_or_repository() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
let definition = definition(
|
||||
Arc::new(FixedTicketService(TicketWorkflowState::Ready)),
|
||||
service.clone(),
|
||||
);
|
||||
let (meta, tool) = definition();
|
||||
let required = meta.input_schema["required"].as_array().unwrap();
|
||||
assert!(!required.iter().any(|field| field == "profile"));
|
||||
assert!(
|
||||
meta.description
|
||||
.contains("neither trusted review nor merge authority")
|
||||
);
|
||||
for profile in [None, Some(serde_json::Value::Null)] {
|
||||
let mut input = input();
|
||||
input.as_object_mut().unwrap().remove("profile");
|
||||
if let Some(profile) = profile {
|
||||
input["profile"] = profile;
|
||||
}
|
||||
input["initial_request"] = serde_json::json!(
|
||||
"Research the question; review is not required. Return without concluding the Ticket."
|
||||
);
|
||||
tool.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-default", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for request in service.requests.lock().unwrap().iter() {
|
||||
assert_eq!(request.profile, "builtin:ticket-worker");
|
||||
assert!(request.workdir_attachments.is_empty());
|
||||
assert_eq!(
|
||||
request.initial_submit,
|
||||
vec![Segment::text(
|
||||
"Research the question; review is not required. Return without concluding the Ticket."
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() {
|
||||
let service = Arc::new(RecordingService {
|
||||
@@ -519,7 +563,7 @@ mod tests {
|
||||
"unexpected error for {field}: {error}"
|
||||
);
|
||||
}
|
||||
for field in ["ticket_id", "runtime_id", "profile", "initial_request"] {
|
||||
for field in ["ticket_id", "runtime_id", "initial_request"] {
|
||||
let mut input = input();
|
||||
input.as_object_mut().unwrap().remove(field);
|
||||
let error = tool
|
||||
@@ -567,13 +611,13 @@ mod tests {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput)).unwrap();
|
||||
assert_eq!(schema["additionalProperties"], false);
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
for field in ["ticket_id", "runtime_id", "profile", "initial_request"] {
|
||||
for field in ["ticket_id", "runtime_id", "initial_request"] {
|
||||
assert!(
|
||||
required.contains(&serde_json::json!(field)),
|
||||
"missing required field {field}"
|
||||
);
|
||||
}
|
||||
for field in ["flow", "workdir_attachments"] {
|
||||
for field in ["profile", "flow", "workdir_attachments"] {
|
||||
assert!(schema["properties"].get(field).is_some());
|
||||
assert!(!required.contains(&serde_json::json!(field)));
|
||||
}
|
||||
|
||||
@@ -335,9 +335,9 @@ struct WorkspaceTicketCloseInput {
|
||||
references: Vec<ticket::TicketReference>,
|
||||
}
|
||||
|
||||
const TICKET_CLOSE_DESCRIPTION: &str = "Close a Ticket with resolution, item/state CAS, and replay key. No Merge Request or enforced state sequence is required. Actor identity is transport-bound; this does not integrate Merge Requests or expand Workdir grants.";
|
||||
const COMPLETE_TICKET_DESCRIPTION: &str = "Record a Ticket completion decision with reason, item/state CAS, and replay key. Merge Requests and approvals are not prerequisites. The Backend authorizes the assigned Worker or registered Workspace Orchestrator; roles supplied by the model never grant authority.";
|
||||
const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. This does not start Workers, queue dependencies, integrate Merge Requests, or enlarge live Workdir grants; the Backend validates actor authority.";
|
||||
const TICKET_CLOSE_DESCRIPTION: &str = "Record an authorized Ticket closure decision with resolution, item/state CAS, and replay key when the requested return path calls for closure. No Merge Request or enforced state sequence is required. Backend checks actor authority, not natural-language satisfaction; this does not integrate MRs, stop/remove Workers, clean attachments, or expand Workdir grants.";
|
||||
const COMPLETE_TICKET_DESCRIPTION: &str = "Record an authorized Ticket completion decision with reason, item/state CAS, and replay key when user intent calls for completion; return without conclusion need not call this tool. Merge Requests and approvals are not prerequisites. Backend checks assigned Worker/Workspace Orchestrator authority, not real judgment of natural-language satisfaction; model-supplied roles never grant authority. Conclusion ends unfinished work while retaining terminal responsibility for display; it does not grant review/merge authority or stop/remove Workers and attachments.";
|
||||
const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. Backend checks actor authority, not natural-language satisfaction. This does not start/stop Workers, queue dependencies, integrate Merge Requests, clean attachments, or enlarge live Workdir grants.";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WorkspaceTicketDecisionKind {
|
||||
@@ -508,8 +508,7 @@ fn workspace_ticket_tool_description(name: &str) -> String {
|
||||
|
||||
const FEATURE_ID: &str = "ticket";
|
||||
const FEATURE_NAME: &str = "Ticket tools";
|
||||
const FEATURE_DESCRIPTION: &str =
|
||||
"Typed Ticket operations through the authoritative Workspace API.";
|
||||
const FEATURE_DESCRIPTION: &str = "Intent-led Ticket reads and authorized decisions through the Workspace API; no universal MR/approval gate, and no Backend judgment of natural-language satisfaction.";
|
||||
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
|
||||
const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets";
|
||||
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||
@@ -2945,6 +2944,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ticket_decision_descriptions_preserve_intent_and_independent_lifecycle() {
|
||||
let tools = native_ticket_tools(
|
||||
decision_client(200, TicketWorkflowState::Ready),
|
||||
TicketFeatureAccess::work_report(),
|
||||
)
|
||||
.unwrap();
|
||||
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
|
||||
let complete = descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.find(|op| op.name == "complete")
|
||||
.unwrap();
|
||||
let summary = &complete.documentation.as_ref().unwrap().summary;
|
||||
assert!(summary.contains("Merge Requests and approvals are not prerequisites"));
|
||||
assert!(summary.contains("return without conclusion need not call this tool"));
|
||||
assert!(summary.contains("not real judgment"));
|
||||
assert!(summary.contains("retaining terminal responsibility"));
|
||||
assert!(summary.contains("does not grant review/merge authority or stop/remove Workers"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() {
|
||||
let client = decision_client(200, TicketWorkflowState::Done);
|
||||
|
||||
@@ -1034,7 +1034,8 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert!(rendered.contains("This policy governs naming only"));
|
||||
assert!(rendered.contains("Coder cleanup is a separate post-completion decision"));
|
||||
assert!(rendered.contains("Worker cleanup is a separate lifecycle decision"));
|
||||
assert!(rendered.contains("not an automatic consequence of a Ticket state"));
|
||||
assert!(rendered.contains("perform one cleanup pass before ending the orchestration turn"));
|
||||
assert!(rendered.contains("Never predeclare `delete_on_completion`"));
|
||||
assert!(rendered.contains("call `WorkerStop`"));
|
||||
|
||||
@@ -322,6 +322,154 @@ fn append_trailing_section(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::feature::builtin::merge_request::MergeRequestFeature;
|
||||
use crate::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess};
|
||||
use crate::hook::HookRegistryBuilder;
|
||||
use crate::worker::TestWorkspaceHttpClient;
|
||||
use manifest::MergeRequestFeatureConfig;
|
||||
|
||||
// These builtin resources are the model-visible workflow compatibility contract.
|
||||
// Fixtures prove composition and tool descriptions, not an agent's real judgment.
|
||||
fn ticket_workflow_fixture(
|
||||
role: &str,
|
||||
mr_config: MergeRequestFeatureConfig,
|
||||
) -> (String, Vec<agen::tool::ToolMeta>) {
|
||||
let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
|
||||
let mut tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(TicketFeature::new(
|
||||
client.clone(),
|
||||
TicketFeatureAccess {
|
||||
thread: true,
|
||||
workflow: true,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.with_module(MergeRequestFeature::new(client, mr_config))
|
||||
.install_into_pending(&mut tools, &mut hooks);
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
let metas: Vec<_> = tools.into_iter().map(|definition| definition().0).collect();
|
||||
let instructions = report.installed_instruction_contributions();
|
||||
let prompts = PromptCatalog::builtins_only().unwrap();
|
||||
let scope = Scope::empty();
|
||||
let rendered = SystemPromptTemplate::parse(role, PromptCatalogSource::builtins_only())
|
||||
.unwrap()
|
||||
.render(&SystemPromptContext {
|
||||
now: DateTime::parse_from_rfc3339("2026-10-08T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
cwd: Cow::Borrowed("."),
|
||||
language: "en",
|
||||
scope: &scope,
|
||||
tool_names: metas.iter().map(|meta| meta.name.clone()).collect(),
|
||||
feature_instructions: &instructions,
|
||||
agents_md: None,
|
||||
resident_summary: None,
|
||||
prompts: &prompts,
|
||||
})
|
||||
.unwrap();
|
||||
(rendered, metas)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_ticket_instructions_allow_review_not_required_without_mr_gate() {
|
||||
let (prompt, tools) =
|
||||
ticket_workflow_fixture("role.ticket_worker", MergeRequestFeatureConfig::default());
|
||||
assert!(prompt.contains("No Merge Request or approval is a prerequisite"));
|
||||
assert!(prompt.contains("optional Ticket comment"));
|
||||
assert!(prompt.contains("optional Drive document/artifact"));
|
||||
assert!(!prompt.contains("## Merge Request workflow"));
|
||||
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteTicket")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("Merge Requests and approvals are not prerequisites")
|
||||
);
|
||||
assert!(complete.description.contains("not real judgment"));
|
||||
assert!(
|
||||
complete.input_schema["properties"]
|
||||
.get("approval_event_id")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_code_instructions_preserve_independent_review_before_merge() {
|
||||
let (prompt, tools) = ticket_workflow_fixture(
|
||||
"role.orchestrator",
|
||||
MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
readiness_check: true,
|
||||
complete: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(prompt.contains("Trusted Reviewer authority"));
|
||||
assert!(
|
||||
prompt.contains(
|
||||
"Target integration and `CompleteMergeRequest` are Orchestrator authority"
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("does not bypass the guarded review requirements for MR integration")
|
||||
);
|
||||
assert!(prompt.contains("not a universal Ticket pipeline"));
|
||||
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteMergeRequest")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("trusted exact-source approval")
|
||||
);
|
||||
assert!(
|
||||
complete.input_schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|field| field == "approval_event_id")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_ticket_instructions_allow_return_without_conclusion_and_separate_cleanup() {
|
||||
for role in ["role.ticket_worker", "role.coder", "role.orchestrator"] {
|
||||
let (prompt, tools) =
|
||||
ticket_workflow_fixture(role, MergeRequestFeatureConfig::default());
|
||||
assert!(prompt.contains("return without conclusion"), "{role}");
|
||||
assert!(
|
||||
prompt.contains(
|
||||
"a turn ending or a result report is not itself a completion decision"
|
||||
),
|
||||
"{role}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("stopping a Worker alone does not end unfinished work"),
|
||||
"{role}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("does not stop, remove, or clean up"),
|
||||
"{role}"
|
||||
);
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteTicket")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("return without conclusion need not call this tool")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_worker_capabilities_follow_the_registered_canonical_control_tools() {
|
||||
|
||||
@@ -30373,7 +30373,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult<WorkerLaunchO
|
||||
display_name: runtime.label,
|
||||
built_in,
|
||||
worker_creation_available: runtime.worker_creation_available,
|
||||
working_directory_required: !built_in,
|
||||
supports_workdir_attachments: !built_in,
|
||||
status: runtime.status,
|
||||
diagnostics: runtime
|
||||
.diagnostics
|
||||
@@ -36614,7 +36614,12 @@ mod tests {
|
||||
assert_eq!(builtin.definition.name, "coder-review");
|
||||
assert_eq!(builtin.selector.to_string(), "builtin:coder-review");
|
||||
assert_eq!(builtin.flow_id, "builtin:coder-review");
|
||||
assert_eq!(builtin.revision, 4);
|
||||
assert_eq!(
|
||||
builtin.revision,
|
||||
flow::builtin_flow_source(flow::CODER_REVIEW_FLOW_SLUG)
|
||||
.unwrap()
|
||||
.revision
|
||||
);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.list_flow_sources(&api.config.workspace_id)
|
||||
@@ -56517,12 +56522,12 @@ mod tests {
|
||||
.iter()
|
||||
.find(|runtime| runtime["runtime_id"] == EMBEDDED_WORKER_RUNTIME_ID)
|
||||
.expect("embedded runtime launch option");
|
||||
assert_eq!(embedded_runtime["working_directory_required"], false);
|
||||
assert_eq!(embedded_runtime["supports_workdir_attachments"], false);
|
||||
let team_runtime = runtimes
|
||||
.iter()
|
||||
.find(|runtime| runtime["runtime_id"] == "team-runtime")
|
||||
.expect("team runtime launch option");
|
||||
assert_eq!(team_runtime["working_directory_required"], true);
|
||||
assert_eq!(team_runtime["supports_workdir_attachments"], true);
|
||||
|
||||
let removal_request = serde_json::json!({
|
||||
"operation_id": "remove-team-runtime",
|
||||
|
||||
+52
-215
@@ -1,258 +1,95 @@
|
||||
# Tickets and development workflow
|
||||
# Tickets and work outcomes
|
||||
|
||||
Yoi project work is tracked through Tickets. For normal use, interact with Tickets through `yoi panel`, Ticket tools, the `yoi ticket ...` CLI, and typed role surfaces. Workspace Server control-plane records plus the append-only Ticket event and Merge Request evidence streams are authoritative.
|
||||
A Ticket is a durable record of natural-language user intent and a concrete work outcome, not a mandatory code/review/merge pipeline. Research, analysis, planning, documentation, operations, and code can all be Ticket work. Requirements, acceptance criteria, and binding decisions govern what must be done; the requested return path governs whether to continue, complete, close, or return results without concluding the Ticket.
|
||||
|
||||
Ticket and Objective records are stored in the Workspace Server database. Repository-local `.yoi/tickets`, `.yoi/objectives`, and config files are ignored legacy input, not a debugging or compatibility backend.
|
||||
|
||||
Do not treat ad-hoc chat summaries, memory records, or Worker notifications as the final source of project state. Notifications are hints to inspect concrete state, not proof of completion.
|
||||
Workspace Server control-plane records, append-only Ticket events, and any linked Merge Request evidence are authoritative. Ticket and Objective records live in the Workspace Server database. Repository-local `.yoi/tickets`, `.yoi/objectives`, and config files are ignored legacy input, not a debugging or compatibility backend. Notifications, chat summaries, and Memory are hints to inspect current authority, not proof of completion.
|
||||
|
||||
## Concepts
|
||||
|
||||
- `Ticket`: durable project/orchestration record. It contains requirements, decisions, plans, implementation reports, reviews, artifacts, and resolution history.
|
||||
- `Objective`: first-class medium-term goal record stored by the Workspace Server. It stores goal, motivation/background, strategy/design direction, success criteria/exit conditions, decision context, current Objective lifecycle, and canonical Ticket links. Objective context is judgment/background context; it is not implementation authority and does not replace reading each Ticket body, thread, and evidence.
|
||||
- `Task`: session-local progress tracking inside a Worker. It is not the project record.
|
||||
- `Assignment`: a concrete delegation from an Orchestrator to a coder/reviewer Worker or task-specific helper Worker.
|
||||
- `IntentPacket`: the short implementation/review contract derived from a Ticket and handed to an Assignment.
|
||||
- `Ticket backend`: the Workspace Server control-plane database exposed through typed Workspace APIs.
|
||||
- `Ticket relation`: durable project-level Ticket-to-Ticket metadata stored as forward canonical-id relations (`depends_on`, `blocks`, `related`, `supersedes`, `duplicate_of`). Inverse views such as `blocked_by` are derived, not stored.
|
||||
- `Ticket`: durable work record containing intent, requirements, decisions, useful results/evidence, and resolution history.
|
||||
- `Objective`: a first-class medium-term goal with background, strategy, success criteria, and canonical Ticket context links. It neither schedules nor authorizes Ticket work.
|
||||
- `Task`: session-local progress tracking, not the project record.
|
||||
- `Assignment`: an authorized delegation of Ticket work to a Worker, independent from its Profile or Flow.
|
||||
- `IntentPacket`: a bounded natural-language handoff of intent, requirements, binding decisions, latitude, escalation conditions, and validation.
|
||||
- `Ticket relation`: non-hierarchical project metadata (`depends_on`, `blocks`, `related`, `supersedes`, `duplicate_of`). Inverse views are derived. Relations do not grant resources or start Workers.
|
||||
|
||||
A Ticket may represent a feature, bug, cleanup, design decision, investigation, workflow change, release task, or orchestration task. The common requirement is that the Ticket is a concrete work item that can be implemented, reviewed, validated, and closed on its own terms.
|
||||
One Ticket should describe an independently useful outcome that can be judged on its own terms. Do not convert temporary execution steps or a broad effort's progress container into mandatory product deliverables.
|
||||
|
||||
## User-facing entry points
|
||||
## Entry points and authority
|
||||
|
||||
Use the highest-level interface that matches the work:
|
||||
Use the highest-level interface matching the request: Workspace Dashboard/Ticket UI, authenticated Workspace APIs, product `yoi ticket ...` or `yoi objective ...` commands, and typed Worker tools. Inside Workers, use the supplied typed Ticket tools; do not substitute a CLI or direct storage access for missing tools. Maintainers inspect authority through the Workspace API, Server administration surfaces, and database diagnostics, not repository-local Ticket files.
|
||||
|
||||
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions.
|
||||
- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links.
|
||||
- Inside Workers, use typed Ticket tools for Ticket records and typed Merge Request tools for immutable implementation/review/completion evidence.
|
||||
- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates.
|
||||
The Server resolves Workspace access, active configuration, and immutable Profile launch material; Runtime does not infer identity or authority from a checkout's `.yoi` files, cwd, or launch prose. The first committed user request supplies bounded action context, not actor identity or new grants. Profile/Flow selection is configuration, not review, merge, or resource authority. Availability of a tool is not authorization to use it for unrelated work.
|
||||
|
||||
Maintainers inspect Ticket state through the authenticated Workspace API, Server administration surfaces, and database diagnostics. Repository-local Ticket files are not storage authority.
|
||||
## Starting and returning Ticket work
|
||||
|
||||
## Ticket tools inside Workers
|
||||
`SpawnTicketWorker` starts and atomically assigns a generic Worker with an `initial_request`. Its registered `profile` is optional (omitted/null selects `builtin:ticket-worker`); `flow` and alias-keyed `workdir_attachments` are optional. No repository or Flow is required for non-code work. Choose an explicit Profile when its tools and instructions fit the task; assignment does not depend on choosing the Coder role. Backend validates claims, derives repository attachment access from declared Ticket targets, and preserves explicit ExternalGrant ceilings. Callers cannot request capabilities through attachment selection.
|
||||
|
||||
Workers with the Ticket and operation-specific Merge Request built-in features can use typed workflow tools:
|
||||
The guarded start records acceptance only after spawn, initial input, assignment, and attachment finalization. Do not pre-write `inprogress` or mistake a state update for a launch. On a failed or unknown outcome, reread durable operation evidence before retrying or spawning a replacement. After acceptance, reread Ticket state and current Worker responsibility.
|
||||
|
||||
- `TicketCreate`
|
||||
- `QueryTicket` — bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata.
|
||||
- `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence.
|
||||
- `TicketComment`
|
||||
- Coder: `ShowMergeRequest`, `OpenMergeRequest`
|
||||
- Reviewer: `ShowMergeRequest`, `ReviewMergeRequest` — available only inside the attested direct-child Reviewer request; grant and subject-ref capability material are not model input.
|
||||
- Orchestrator: `ShowMergeRequest`, `CheckMergeRequestReadiness`, `CompleteMergeRequest`
|
||||
- `TicketClose`
|
||||
- `TicketRelationRecord`
|
||||
Results can be returned in conversation, optionally recorded in a `TicketComment`, optionally published as a Drive document/artifact when that surface is available, or written/published to an authorized repository. Neither comments nor Drive artifacts are universally required. Preserve concise evidence useful to the user without fabricating work, tests, or conclusions. `implementation_report` is optional historical/audit context, not integration readiness or completion authority.
|
||||
|
||||
Profile-visible Ticket catalogs are intentionally smaller than the former broad read catalog: Workspace authoring exposes 9 tools instead of 13, workflow exposes 10 instead of 12, and review exposes only `QueryTicket` plus `ShowTicket` (2 instead of 6). The `QueryTicket` schema is regression-guarded below 8 KiB while consolidating relation/evidence/attention discovery; diagnostics are not projected into normal profiles, while specialized orchestration-plan commands remain visible only to workflow roles that need their distinct semantics.
|
||||
The return choice is independent of result format. Examples:
|
||||
|
||||
These tools operate through the typed Workspace Ticket API. They do not grant arbitrary filesystem access and never select repository-local Ticket storage.
|
||||
- Research with review not required: answer the question and optionally record a useful summary; no MR or approval is required for an authorized completion decision.
|
||||
- Code requiring independent review before merge: use the trusted review path and guarded MR integration. A Profile, Flow transition, tool invocation, or prose approval is not the review judgment.
|
||||
- Return without conclusion: report the result/limitations and leave the Ticket unconcluded. A finished turn, an artifact, or an approved MR does not itself decide completion.
|
||||
|
||||
Relation tools are for non-hierarchical project metadata only. Use canonical opaque Ticket ids, store forward relations only, and keep runtime execution planning (capacity, ordering decisions, do-not-parallelize notes, Worker/session/worktree ownership) in OrchestrationPlan or session-local records instead of relation metadata. Unresolved `depends_on` and incoming unresolved `blocks` are queue/acceptance blockers; `related` is not blocking, and `supersedes` / `duplicate_of` are diagnostics rather than automatic lifecycle transitions.
|
||||
These are instruction/tool contracts, not a claim that a mechanism proves real agent judgment. Backend authenticates actors and enforces typed boundaries; the responsible Worker/human must actually judge satisfaction against the natural-language request.
|
||||
|
||||
Use them when a Worker needs to materialize or update project records:
|
||||
## Ticket tools and decisions
|
||||
|
||||
- Intake creates a new Ticket after user agreement.
|
||||
- Orchestrator records routing decisions and intent packets.
|
||||
- Reviewer commits an approve/request-changes result against one immutable Merge Request revision.
|
||||
- Maintainer closes a Ticket with a resolution when merge/validation/cleanup evidence is complete.
|
||||
`QueryTicket` provides bounded discovery/filtering; `ShowTicket` provides authoritative item revision, paged thread, relations, Objectives, and any current MR context. Read the relevant Ticket before implementation, routing, review, state, or conclusion decisions. Check potential duplicates before creation or material rescope. Use `QueryObjective` and `ShowObjective` for broader context without replacing the Ticket read.
|
||||
|
||||
Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable.
|
||||
Useful mutation surfaces include `TicketCreate`, `TicketComment`, relation/plan tools, and authorized `TicketWorkflowState`, `CompleteTicket`, and `TicketClose` decisions. Exposure depends on the effective Profile/Feature configuration. Decision tools use current item revision, expected state, a reason/resolution, stable operation key, and optional supporting references. Backend checks actor authority, CAS, replay, and resource boundaries. It does not enforce a universal state sequence, MR/approval gate, or natural-language satisfaction.
|
||||
|
||||
## Objective records
|
||||
`CompleteTicket` records completion; `TicketClose` records closure. Neither integrates an MR or expands resource grants. Ticket progress/conclusion can occur without commits, MRs, or approvals when consistent with the request. Conversely, code's default publication/review practices must not be erased by confusing an independent Ticket decision with merge authority. Do not claim an unmerged result was integrated. Reread after conflicts and replay a recorded operation only with the same fingerprint.
|
||||
|
||||
Objectives are Workspace Server control-plane records, not files in a checkout. Use typed Objective tools or `yoi objective ...`; both resolve the selected Backend/Workspace through the shared client target and call the Workspace API.
|
||||
Relations remain metadata, not a scheduling mechanism: record actual project dependencies, but do not encode capacity, Worker/worktree ownership, or execution ordering as relations. Use orchestration/session records for those concerns and inspect current Backend claim rules rather than inferring launch eligibility from a relation alone.
|
||||
|
||||
Objective-to-Ticket links are context links only: they are not dependency, blocking, ordering, ownership, or scheduling relations. Objective lifecycle does not drive Ticket state or authorize implementation. A role reading Objective context must still inspect each Ticket and its current Merge Request evidence.
|
||||
## Optional code Git/MR recipe
|
||||
|
||||
Repository-local `.yoi/objectives` trees are ignored. There is no automatic import or cwd/ancestor fallback.
|
||||
For code work using the default publication/review recipe, select `builtin:coder` and optionally `builtin:coder-review`. The Flow guides implement, review, fix, and handoff; it is not a Backend Ticket completion requirement and grants no authority. Read current repository state, use coherent commits and normal non-force publication only when authorized, validate the changed contracts, and report changed files, validation, and limitations. No commits/push should be inferred from a request explicitly prohibiting them.
|
||||
|
||||
## Ticket configuration
|
||||
Use one open repository-scoped MR per Ticket/repository. `OpenMergeRequest` creates selectors; once an MR exists, explicitly address its ID with `ShowMergeRequest` and preserve its selectors. Advance only the existing source selector with a normal non-force push. Do not invent an add-revision operation, replacement MR, or fresh integration branch for every fix. Publish the exact committed source and verify provider resolution before requesting review.
|
||||
|
||||
Workspace Ticket data and workflow authority live in the Workspace Server's SQLite control-plane store. Repository-local `.yoi/workspace.toml` and `.yoi/ticket.config.toml` are not Ticket, Workspace identity, Backend connection, or role-launch authority. `yoi init --display-name <NAME> --repository-key <KEY>` registers the current Git repository through the Backend API and writes only global client routing under `$XDG_CONFIG_HOME/yoi/client.toml`.
|
||||
The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker, with explicit command grant and appropriate Workdir scope for inspection/validation, plus the structured Ticket/MR handoff. Server authority verifies assignment, Runtime-owned child identity, effective Profile, one-shot attempt, and captured subject. Review capability is injected by the trusted layer, not supplied by the model. The Reviewer makes an independent judgment against intent, acceptance criteria, the complete captured result snapshot, and validation, then records `ReviewMergeRequest`; prose and observation are not approval authority.
|
||||
|
||||
Fixed Ticket workflow roles are `intake`, `orchestrator`, `coder`, and `reviewer`. The Server resolves the selected Profile and launch material from the active Workspace configuration authority, and Runtime receives the resulting immutable launch snapshot. A repository checkout may still contain ordinary project files, but neither the client nor Runtime may infer Workspace identity, Backend routing, role Profile, or Ticket storage from repository-local `.yoi` files.
|
||||
Source movement requires fresh review for the exact new source. Target-only movement preserves unchanged-source approval but requires refreshed integration evidence. Keep routine review/fix/rereview evidence on the MR rather than repeating each iteration in Ticket comments.
|
||||
|
||||
## Ticket lifecycle
|
||||
MR integration remains separate Orchestrator authority. Immediately before integration, reread Ticket/MR authority and `CheckMergeRequestReadiness`. Verify exact approved source, current target, strategy, and approval event. Apply and validate integration through ordinary source control in the bound Workdir, push normally, and verify provider resolution. `CompleteMergeRequest` records that already-applied repository result; it does not move a branch, complete the Ticket, or release assignment. If push succeeded but recording failed, reread/replay the same operation rather than pushing again. A Ticket request saying review is not required does not bypass MR integration guards.
|
||||
|
||||
Ticket-driven development normally moves through these gates:
|
||||
## Responsibility, unfinished work, and cleanup (T-715)
|
||||
|
||||
1. Intake
|
||||
2. Orchestrator routing
|
||||
3. Planning/requirements sync or spike when needed
|
||||
4. Implementation assignment
|
||||
5. Review
|
||||
6. Merge / validation / cleanup
|
||||
7. Close
|
||||
Terminal current responsibility is retained for display after unfinished work ends. It is not the same as an unfinished assignment and does not require an invented unassignment operation before cleanup. A stopped or idle Worker may still own unfinished work; a completed/closed Ticket does not automatically stop/remove the Worker, release its attachments, or delete a Workdir. A result report or Flow terminal state is neither conclusion nor cleanup authority.
|
||||
|
||||
Each gate records its decision or evidence in the Ticket thread or artifacts.
|
||||
Worker stop, retention/removal, attachment release, and Workdir deletion are independent guarded decisions. Do not predeclare `delete_on_completion` or `retain_on_completion` at launch. Retain Workers while a requested review/fix or other handoff can still return; do not remove them just because one turn ended.
|
||||
|
||||
### 1. Intake
|
||||
When authorized cleanup is in scope:
|
||||
|
||||
Use the Intake role launch prompt when a user request is broad, ambiguous, or not yet a Ticket.
|
||||
1. Reread Ticket responsibility, active unfinished work, Worker state, handoffs/notifications, and current retention constraints. Use the exact Worker subject from `WorkerList`.
|
||||
2. Stop the Worker if needed and confirm terminal state. Stop alone does not end unfinished work. `UnfinishedWork` (`unfinished_work`) requires explicit authorized work end or reassignment and a fresh authority read.
|
||||
3. Call `WorkerRemove` only with no unfinished work, running/restoring state, pin/legal hold, pending notification, or handoff. Backend revalidates guards; removal releases attachments but preserves Workdir materialization.
|
||||
4. Reread actual Workdir attachment release, occupancy, cleanliness, ownership, provider availability, and other use. Retain still-needed/existing resources. Only then use `WorkdirDelete` for a proven clean, unoccupied, no-longer-needed Ticket-dedicated Workdir owned/selected for this work.
|
||||
|
||||
Intake should:
|
||||
Never delete before attachment release, force removal, discard changes, or advance on a partial/unknown failure. Reread authority before a bounded retry. Cleanup failures do not roll back recorded Ticket or MR outcomes; report concrete blockers rather than adding routine success comments.
|
||||
|
||||
- clarify user intent;
|
||||
- check duplicate/related Tickets;
|
||||
- draft background, requirements, acceptance criteria, binding decisions/invariants, implementation latitude, readiness, risk flags, and validation;
|
||||
- create or update the Ticket only after user agreement.
|
||||
## Authoring and granularity
|
||||
|
||||
Intake should not schedule implementation, spawn coder/reviewer Workers, create worktrees, merge, or close Tickets.
|
||||
Intake clarifies ambiguous intent, checks duplicates and relevant context, and records agreed work. Create/update only after user agreement or explicit instruction to record the draft. A minimum investigation gate is an authoring responsibility, not a universal implementation approval gate. Intake is not scheduling, implementation, review, merge, or cleanup authority.
|
||||
|
||||
### 2. Orchestrator routing
|
||||
A useful Ticket records background/motivation, requirements, acceptance criteria, binding decisions/invariants, implementation latitude, and relevant open questions/risks. Include useful result evidence and final resolution when appropriate. Separate confirmed facts, user claims, hypotheses, and undecided questions. Do not copy Worker role boundaries, a temporary plan, or a decision to stop after authoring into the Ticket as a product prohibition. Record an approval gate only when the user asks for it; do not prematurely prescribe implementation tactics.
|
||||
|
||||
Use the Orchestrator role launch prompt to classify the next action for an existing Ticket.
|
||||
Risk flags prompt context checks and reviewer focus, not automatic stop gates. Return to planning only for a named missing decision/information after bounded checks; escalate beyond-scope decisions without inventing new constraints.
|
||||
|
||||
Routing classifications include:
|
||||
Do not create new umbrella/progress-container Tickets. Split broad work into concrete useful outcomes, record the split in Ticket/Objective context, and use Objectives for enduring medium-term goals. Do not replace umbrellas with parent/child, sub-ticket, part-of, contains, or other hierarchy relations. A concrete planning/design/investigation Ticket is valid when requested. Existing umbrellas can be retired as superseded/decomposed without rewriting history; distinguish container retirement from completion of future work.
|
||||
|
||||
- `requirements_sync_needed`
|
||||
- `return_to_planning`
|
||||
- `spike_needed`
|
||||
- `implementation_ready`
|
||||
- `review_needed`
|
||||
- `blocked_by_dependency_or_missing_authority`
|
||||
- `close_ready`
|
||||
- `closed_or_noop`
|
||||
Keep long research dumps outside the item body and summarize necessary bounded artifacts. Never store secrets, credentials, private prompt contents, or secret-bearing logs in Ticket/MR/Drive records or model-visible evidence.
|
||||
|
||||
Routing decisions should be recorded with `TicketComment` using `plan` or `decision` role. The decision should state the classification, evidence checked, reason, next action, and escalation conditions. For `return_to_planning`, the record must also state the concrete missing decision/information, context checked, why implementation latitude is insufficient, and the next planning question/action.
|
||||
## Configuration and validation
|
||||
|
||||
### 3. Planning/requirements sync
|
||||
Workspace Ticket data and authority live in Server SQLite and active Workspace configuration. `yoi init --display-name <NAME> --repository-key <KEY>` registers a repository through Backend and writes global client routing only. Repository-local `.yoi/workspace.toml` and `.yoi/ticket.config.toml` do not grant Workspace identity, Backend connection, role selection, or Ticket authority. Legacy `ticket init` and `ticket import-local` are unsupported; no automatic migration reads local Ticket trees.
|
||||
|
||||
Use planning/requirements sync only as a bounded Ticket refinement step. Return `ready` or `queued` Tickets to `planning` only when the Orchestrator can name a concrete missing decision or information item after bounded project-context checks; risk flags and risky domains are context-lookup and reviewer-focus signals, not automatic stop gates.
|
||||
Dashboard actions are explicit user actions, not scheduler/auto-maintainer authority. Launch success without a visible result calls for attaching to or observing the Worker and rereading authority, not assuming conclusion. Select an accessible Workspace and current registered Profile when a launch reports missing configuration.
|
||||
|
||||
Planning sync should resolve or record:
|
||||
|
||||
- requirements and acceptance criteria;
|
||||
- current code map;
|
||||
- binding decisions/invariants and implementation latitude;
|
||||
- critical risks and failure modes;
|
||||
- implementation-ready vs requirements-sync/spike/blocked classification.
|
||||
|
||||
Do not send Tickets with unresolved concrete missing decisions/information directly to coder Workers. If no concrete missing item remains after bounded checks, risky-but-specified Tickets should proceed with an IntentPacket plus escalation conditions and reviewer focus.
|
||||
|
||||
### 4. Implementation assignment
|
||||
|
||||
Use the Coder and Reviewer role launch prompts for implementation-ready Tickets.
|
||||
|
||||
The Orchestrator should prepare an `IntentPacket` with:
|
||||
|
||||
- intent;
|
||||
- requirements;
|
||||
- binding decisions/invariants;
|
||||
- implementation latitude;
|
||||
- escalation conditions;
|
||||
- validation;
|
||||
- current code map;
|
||||
- critical risks.
|
||||
|
||||
Implementation normally happens in a child git worktree created by the Orchestrator, not by the coder Worker. The coder Worker receives narrow write scope to the worktree and must report changed files, implementation summary, validation, unresolved risks, and review readiness.
|
||||
|
||||
### 5. Review
|
||||
|
||||
The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with write scope, so it can use the Workdir command tools required for inspection and validation, and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval.
|
||||
|
||||
The Reviewer records the structured result with `ReviewMergeRequest`. Request changes advances the existing Merge Request source selector with a normal non-force push and requires a fresh child attempt for that exact new source ref; do not create a replacement Merge Request, add-revision operation, or fresh integration branch. Target-only movement preserves approval for an unchanged source and requires refreshed integration evidence. The Orchestrator uses `CheckMergeRequestReadiness` and then `CompleteMergeRequest` for guarded integration with operation-id dedupe/CAS semantics; Flow transitions are not completion authority.
|
||||
|
||||
Blockers must be fixed or explicitly escalated before merge-ready submission.
|
||||
|
||||
### 6. Merge and close
|
||||
|
||||
Unless explicitly authorized otherwise, final merge, cleanup, design-boundary decisions, and Ticket closure remain Orchestrator/human responsibilities.
|
||||
|
||||
Before closing, verify concrete evidence:
|
||||
|
||||
- SubWorker committed session via worker-observation tools;
|
||||
- worktree state and diff;
|
||||
- validation command output;
|
||||
- review result;
|
||||
- Ticket requirements and acceptance criteria;
|
||||
- merge/cleanup state in the main workspace.
|
||||
|
||||
Close with a resolution that summarizes what changed, key commits, validation, review state, and remaining follow-ups.
|
||||
|
||||
## Workspace Dashboard Ticket role actions
|
||||
|
||||
`yoi panel` is the active Ticket/Intake/Orchestrator Dashboard. It owns fixed Ticket role-launch actions and uses the shared client Ticket role launcher. The single-Worker Console no longer supports `:ticket ...` commands; typing them in command mode is treated like any other unknown command.
|
||||
|
||||
Role actions map to the fixed Workspace Ticket roles:
|
||||
|
||||
- intake launches the intake role without an existing Ticket and requires freeform context.
|
||||
- route launches the orchestrator role for an existing Ticket.
|
||||
- implement launches the coder role for an implementation assignment.
|
||||
- review launches the reviewer role for review.
|
||||
|
||||
All actions are explicit and user-triggered. They are not a scheduler, queue, spawned-Worker Dashboard, or automatic maintainer loop.
|
||||
|
||||
### Dashboard execution path
|
||||
|
||||
The Dashboard sends the selected action and Ticket context to the Workspace Server. The Server validates Workspace access, resolves the current Server DB Workspace/Ticket authority and active Profile projection, launches or restores the role Worker through the shared Worker path, commits the typed initial input, and returns durable acceptance evidence. The client does not inspect repository-local `.yoi` files, choose a Ticket storage directory, or construct Runtime launch authority.
|
||||
|
||||
The launched Worker receives dynamic Ticket/action context as its first committed run input. The selected Profile supplies durable system/role behavior. Workspace Ticket metadata does not override system instruction.
|
||||
|
||||
### Dashboard troubleshooting
|
||||
|
||||
- unresolved Workspace selection: select an accessible Workspace from the Server catalog or pass an explicit Workspace selector.
|
||||
- unavailable role Profile: update the active Workspace configuration and retry after the Server projects the new revision.
|
||||
- missing Ticket id for route, implement, or review actions: provide the target Ticket.
|
||||
- launch success but no visible completion: attach to or inspect the launched Worker; completion notifications are hints, not authority.
|
||||
|
||||
## Granularity
|
||||
|
||||
One Ticket should describe a complete change that can be explained as a feature, behavior, design decision, investigation result, or maintenance outcome when closed. It should be concrete enough to implement, review, validate, and close without relying on another open Ticket as its progress container.
|
||||
|
||||
Avoid Tickets that only mirror an implementation step unless that step is independently reviewable and useful. Phase/step lists inside a Ticket are execution order, not a separate dependency system.
|
||||
|
||||
Do not create new umbrella Tickets for broad multi-Ticket efforts. When a request is too broad for one concrete work item:
|
||||
|
||||
- create concrete implementable Tickets for the slices;
|
||||
- record the split decision in the relevant Ticket thread, Objective context, or both;
|
||||
- use Objectives for medium-term goal, motivation, strategy, and success-criteria context when that context would outlive one concrete Ticket;
|
||||
- once typed Ticket relations exist, use them only for non-hierarchical dependency, related, blocking, superseded-by, duplicate, or replacement metadata;
|
||||
- do not replace umbrellas with parent/child, sub-ticket, umbrella, part-of, contains, or other hierarchy/container relations;
|
||||
- do not keep a separate umbrella Ticket open merely as a progress container.
|
||||
|
||||
This policy does not forbid an initial concrete planning, design, or investigation Ticket when the user asks for one. The deprecated pattern is a long-lived umbrella/progress-container Ticket whose main purpose is to keep a broad effort open while other concrete Tickets carry the actual work.
|
||||
|
||||
Existing umbrella Tickets may be retired without rewriting history. Once concrete follow-up Tickets and any needed Objective context exist, close the umbrella as superseded/decomposed. The close resolution should state that the container role is retired, not that every related future concern is complete, and should list completed concrete Tickets plus remaining follow-up Tickets/Objectives.
|
||||
|
||||
## Ticket contents
|
||||
|
||||
A useful Ticket states:
|
||||
|
||||
- background and motivation;
|
||||
- requirements;
|
||||
- acceptance criteria;
|
||||
- relevant binding decisions/invariants, implementation latitude, and escalation conditions;
|
||||
- readiness, open questions, and risk flags when relevant;
|
||||
- implementation reports when work is submitted;
|
||||
- reviews;
|
||||
- final resolution when closed.
|
||||
|
||||
Keep long research dumps out of the item body. Attach necessary bounded artifacts through the Ticket evidence surface and summarize the conclusion in the thread.
|
||||
|
||||
Do not store secrets, credentials, private prompt contents, or raw logs containing secrets in Ticket bodies, thread entries, artifacts, diagnostics, or model-visible prompts.
|
||||
|
||||
## Backend CLI: `yoi ticket`
|
||||
|
||||
The product CLI resolves an explicit or configured Backend target and performs Ticket operations through the Workspace API. It does not open a repository-local backend. Use command help for the current typed command surface.
|
||||
|
||||
Legacy `ticket init` and `ticket import-local` workflows are unsupported. No automatic migration reads old `.yoi/tickets` trees; export data with an older version before upgrading if it must be retained, then import it through an explicit supported Workspace surface.
|
||||
|
||||
## Validation
|
||||
|
||||
Run at least:
|
||||
|
||||
```sh
|
||||
yoi ticket doctor
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Implementation Tickets usually also need focused tests and broader checks, for example:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo check --workspace --all-targets
|
||||
cargo test -p <crate> <filter>
|
||||
```
|
||||
|
||||
Record validation commands and results in the implementation report or resolution.
|
||||
Validate the actual result with checks appropriate to its acceptance criteria. Code changes follow `AGENTS.md` and [Rust testing strategy](rust-testing-strategy.md): begin with the smallest contract-proving target/filter, then the required compile/test/format checks. Record only validation actually run. Narrow effective instruction/tool fixtures protect inclusion, schema, and policy wording; they do not prove real Worker judgment or end-to-end behavior.
|
||||
|
||||
@@ -12902,10 +12902,11 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"worker_creation_available": {
|
||||
"supports_workdir_attachments": {
|
||||
"description": "Whether this Runtime supports explicitly selected Workdir attachments.\nWorkdirs are optional for generic launches; embedded Runtime does not support them.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"working_directory_required": {
|
||||
"worker_creation_available": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -12914,7 +12915,7 @@
|
||||
"display_name",
|
||||
"built_in",
|
||||
"worker_creation_available",
|
||||
"working_directory_required",
|
||||
"supports_workdir_attachments",
|
||||
"status",
|
||||
"diagnostics"
|
||||
],
|
||||
@@ -15671,7 +15672,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933"
|
||||
"x-yoi-source-digest": "sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Optional code Git/MR recipe, selected only when it fits the requested work.
|
||||
# This Flow is not a generic Ticket pipeline or Backend completion requirement.
|
||||
# Transitions guide implementation/review/handoff; they cannot grant review or merge
|
||||
# authority, judge natural-language satisfaction, or conclude the Ticket.
|
||||
{
|
||||
schema_version = 1;
|
||||
name = "coder-review";
|
||||
@@ -5,7 +9,7 @@
|
||||
|
||||
states = {
|
||||
implement = {
|
||||
instructions = "Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/<ticket-id>-<slug>` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. For this assigned Ticket Workdir, you are explicitly authorized to create or switch the local work branch and to use `git add` and `git commit`. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the provider resolves the existing Merge Request `selector_from` to exact local `HEAD`. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open one Merge Request with `OpenMergeRequest`, or use `ShowMergeRequest` when one is already open; keep its original selectors and advance the same source ref. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review; target-only movement does not invalidate source approval and remains Orchestrator integration authority. Do not request review from a dirty Workdir or unpublished source ref. A Flow transition is never Ticket completion authority.";
|
||||
instructions = "This is an optional code recipe for requested Git publication and independent review, not a universal Ticket gate. If the user requests no review or return without conclusion, honor that bounded request and do not treat this recipe as authority to conclude the Ticket. Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/<ticket-id>-<slug>` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. Use local work-branch switching, `git add`, `git commit`, and publication only within the request and existing grants; this recipe does not override an explicit no-commit/no-push instruction or expand resource authority. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the provider resolves the existing Merge Request `selector_from` to exact local `HEAD`. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open one Merge Request with `OpenMergeRequest`, or use `ShowMergeRequest` when one is already open; keep its original selectors and advance the same source ref. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review; target-only movement does not invalidate source approval and remains Orchestrator integration authority. Do not request review from a dirty Workdir or unpublished source ref. A Flow transition is never Ticket completion authority.";
|
||||
transitions = {
|
||||
review = {
|
||||
target = "review";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./base.dcdl" // {
|
||||
slug = "coder";
|
||||
description = "Ticket implementation coder profile.";
|
||||
description = "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.";
|
||||
scope = "workspace_write";
|
||||
model = { ref = "codex-oauth/gpt-6.1-sol"; };
|
||||
engine = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./base.dcdl" // {
|
||||
slug = "orchestrator";
|
||||
description = "Ticket orchestrator profile.";
|
||||
description = "Intent-led Ticket orchestration with separate guarded MR integration authority.";
|
||||
scope = "workspace_write";
|
||||
engine = { instruction = "role.orchestrator"; };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./default.dcdl" // {
|
||||
slug = "ticket-worker";
|
||||
description = "General Ticket work with typed progress and completion tools.";
|
||||
description = "General intent-led Ticket work with optional result recording and authorized conclusion decisions.";
|
||||
engine = {
|
||||
instruction = "role.ticket_worker";
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
## Merge Request workflow
|
||||
|
||||
The Merge Request and its append-only thread are the routine authority for review requests, verdicts, fixes, rereview, readiness, and integration evidence. One Ticket may link multiple repository-scoped Merge Requests. After `OpenMergeRequest` returns an ID, every read, review, readiness, and integration operation must explicitly address that `merge_request_id`; never let a Ticket lookup implicitly choose one. Use the operation-specific tools exposed to your role: `OpenMergeRequest`, `ShowMergeRequest`, `ReviewMergeRequest`, `CheckMergeRequestReadiness`, `CompleteMergeRequest`, and `CompleteTicket`.
|
||||
This guidance applies when the requested work uses a Merge Request; it is not a universal Ticket pipeline or conclusion gate. The Merge Request and its append-only thread are the routine authority for review requests, verdicts, fixes, rereview, readiness, and integration evidence. One Ticket may link multiple repository-scoped Merge Requests. After `OpenMergeRequest` returns an ID, every read, review, readiness, and integration operation must explicitly address that `merge_request_id`; never let a Ticket lookup implicitly choose one. Use the operation-specific tools exposed to your role: `OpenMergeRequest`, `ShowMergeRequest`, `ReviewMergeRequest`, `CheckMergeRequestReadiness`, and `CompleteMergeRequest`.
|
||||
|
||||
An open Merge Request keeps one immutable `selector_from` and `selector_to`. Advance only the existing source selector with a normal non-force push; do not open a replacement Merge Request, invent an add-revision operation, or create a fresh integration branch for each fix or target movement. Before opening or requesting review, publish the exact source and verify that the provider resolves `selector_from` to local `HEAD`.
|
||||
|
||||
A review verdict is valid only for the exact provider-resolved source ref or immutable merged-result subject, selected Merge Request, complete linked MR/source-result snapshot, and current Ticket item revision captured by `ReviewRequested`. Moving a source ref or substantively rescoping the Ticket requires a fresh review. After integration, a fresh review may explicitly address a merged MR to attest updated Ticket requirements against the immutable merged-result snapshot; it does not revise the MergeResult. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. `CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. After every linked required result is integrated, `CompleteTicket` revalidates the exact linked-MR set, current Ticket revision, explicitly identified Reviewer requirement approval, state, and assignment before completing the Ticket.
|
||||
A review verdict is valid only for the exact provider-resolved source ref or immutable merged-result subject, selected Merge Request, complete linked MR/source-result snapshot, and current Ticket item revision captured by `ReviewRequested`. Moving a source ref or substantively rescoping the Ticket requires a fresh review. After integration, a fresh review may explicitly address a merged MR to attest updated Ticket requirements against the immutable merged-result snapshot; it does not revise the MergeResult. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Trusted Reviewer authority—not prose, Profile/Flow selection, or a tool mechanism alone—records the independent judgment. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. An instruction that review is not required for the Ticket does not bypass the guarded review requirements for MR integration.
|
||||
|
||||
Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a revision-based workflow. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or completion, stop and reread current authority rather than reusing stale evidence.
|
||||
`CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. `CompleteTicket` is an independent authorized decision with a reason and current item/state evidence, not an MR-set/approval gate. Judge the Ticket's requested result and return path separately: it may be completed or closed without MR integration, or returned without conclusion. Do not claim an unmerged result was integrated.
|
||||
|
||||
Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a revision-based workflow. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or integration, stop and reread current authority rather than reusing stale evidence.
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
## 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 revision, thread/evidence, relations, linked Objectives, and current Merge Request context before routing, review, or closure decisions. Current linked-MR evidence is completion authority; `implementation_report` entries are optional historical/audit context and must not be required for integration readiness. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
|
||||
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and 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.
|
||||
|
||||
Read the relevant Ticket before making implementation, routing, review, state, or closure decisions. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work.
|
||||
Read the relevant Ticket before making implementation, routing, review, state, or 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 implementation/review 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. For implementation and review workflows, keep routine revision, verdict, fix, and rereview evidence on the Merge Request; use Ticket comments only for blockers or decisions requiring orchestration attention and the final approved handoff. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements.
|
||||
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 revision and linked-Ticket context when coordinating broader work.
|
||||
|
||||
Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish implementation completion from review and closure, and perform only lifecycle actions supported by the tools and authority available to the current Worker.
|
||||
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.
|
||||
|
||||
Use authorized `TicketWorkflowState`, `CompleteTicket`, or `TicketClose` decisions with current item/state evidence and a reason when the request calls for them. 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.
|
||||
|
||||
Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish a result report, Ticket conclusion, MR review/integration, unfinished assignment, and Worker lifecycle. Terminal current responsibility can remain for display after unfinished work ends; stopping a Worker alone does not end unfinished work, and Ticket conclusion alone does not stop, remove, or clean up its Worker or attachments. Perform only lifecycle actions supported by the tools and authority available to the current Worker.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
You are the assigned Coder. Implement the requested scope in the provided Workdir and keep durable evidence on the Ticket and its Merge Request.
|
||||
You are the assigned Coder. Implement the requested scope in the provided Workdir. Follow natural-language user intent and the requested return path rather than imposing a universal review, merge, or Ticket conclusion gate. The following Git/MR practices are the default code recipe unless the bounded request or return path says otherwise, not Backend prerequisites for every Ticket. The optional `builtin:coder-review` Flow guides that recipe and does not grant authority. A request for no review or return without conclusion must not silently become a review cycle or `CompleteTicket` decision; independent review before merge still requires the trusted Reviewer path, and merge remains Orchestrator authority.
|
||||
|
||||
Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state.
|
||||
|
||||
Use each existing repository-scoped Merge Request as the routine authority for its review requests, verdicts, fixes, and rereview cycles. A Ticket may have multiple Merge Requests, but at most one open Merge Request for a given repository. `OpenMergeRequest` creates that repository's selector-based Merge Request; if one already exists, use its explicit ID with `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority.
|
||||
|
||||
Do not add a Ticket comment for each review or fix iteration. Add a Ticket comment only when a blocker or decision requires Orchestrator attention, or once after approval to hand off the final implementation and validation evidence.
|
||||
Do not add a Ticket comment for each review or fix iteration. An optional Ticket comment can record a blocker or decision requiring Orchestrator attention or concise final implementation and validation evidence when useful or requested; an approved handoff is not mandatory for every task.
|
||||
|
||||
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. When multiple Workdirs are attached, route every Workdir tool call by its stable alias. Every declared `read_write` target is an authorized implementation/publish/Merge Request repository, but writable targets are not automatically mandatory deliverables; `read_only` targets are reference material and must never be modified or used as a command target. Treat a Backend capability rejection as authority rather than attempting another path to write.
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@ You are the Ticket Orchestrator role.
|
||||
|
||||
{% include "common.git" %}
|
||||
|
||||
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Explicitly start bounded Ticket work with `SpawnTicketWorker`, selecting a registered `profile`, an `initial_request`, an optional `flow`, and alias-keyed `workdir_attachments`. Work assignment is independent from the selected Profile: ordinary investigation can use `builtin:default` without a Flow or Workdir; Git implementation can select `builtin:coder` with `builtin:coder-review`. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Do not first record `inprogress`: the guarded operation accepts initial input, assignment, and attachment finalization before recording start. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify `inprogress` and `current_worker` before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope.
|
||||
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Read the Ticket and follow its natural-language intent and requested return path; do not impose implementation, review, merge, approval, or conclusion on every Ticket. Work may be investigation, analysis, planning, documentation, or code. Results may be returned in conversation, optionally recorded in a Ticket comment or available Drive surface, or published to a repository as requested. Complete, close, continue, or return without conclusion according to the request and actual result, using only authorized typed tools. Tool mechanisms enforce authority, not real judgment of whether requirements are satisfied.
|
||||
|
||||
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.
|
||||
Explicitly start bounded Ticket work with `SpawnTicketWorker`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Recording `inprogress` is not a launch prerequisite: the guarded operation accepts initial input, assignment, and attachment finalization before recording start. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify `inprogress` and `current_worker` before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope.
|
||||
|
||||
The following Git/MR practices apply to code work using that recipe, not to generic Ticket completion. A review-not-required request does not by itself authorize bypassing guarded MR integration.
|
||||
|
||||
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, do not integrate that MR; return the review requirement to the same assigned Coder when that work is requested. Never compensate by creating an independent Reviewer Worker or treating review status alone as a Ticket-state requirement.
|
||||
|
||||
Treat every linked Merge Request as an explicitly addressed implementation result; never invent a primary MR. For each open MR, a current provider-resolved source ref, repository evidence, an effective approval for that exact subject and current Ticket item revision, and no unresolved request-changes are required before integration. Do not require an `implementation_report`. Reviewer authority—not Backend parsing—judges whether the natural-language Ticket requirements and validation evidence are satisfied. Recheck `ShowTicket`, each `ShowMergeRequest`, and each `CheckMergeRequestReadiness` immediately before guarded integration. Source movement requires a fresh Reviewer attempt. Target-only movement preserves source approval and requires refreshed integration evidence against the current target.
|
||||
|
||||
@@ -14,16 +18,16 @@ Perform integration through normal source-control operations in the bound Orches
|
||||
|
||||
Push the resulting `merge_to` branch through its configured normal push path. Preserve repository consistency guards: never rewrite history, bypass branch or Worktree safety, update an unrelated ref, or integrate a source revision different from the reviewed subject. Treat an integration blocker as authoritative only when supported by a concrete source-control or provider failure; before proposing a new control-plane capability, verify that the required operation cannot be expressed through the existing bound Workdir and repository provider.
|
||||
|
||||
After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: keep the Ticket and assignment in progress while remaining linked MRs are implemented, reviewed, or integrated.
|
||||
After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: accurately report remaining linked MRs and follow the requested continuation or return path; do not claim they were integrated.
|
||||
|
||||
If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Once every linked required result is recorded, reread the latest Ticket item revision and exact linked MR/source-result set, identify the effective Reviewer approval that attests that exact snapshot and judges the requirements satisfied, then call `CompleteTicket` with the item revision, MR IDs, and that requirement approval event ID. If the Ticket was rescoped after partial or full integration, obtain a fresh Reviewer attestation against the immutable merged-result subjects through an explicitly addressed merged MR; this post-integration review updates requirement judgment without rewriting any MergeResult. `CompleteTicket` alone moves the Ticket to `done` and releases unfinished work atomically while retaining current responsibility for display; MR-set changes, rescope, review revocation, stale assignment/state, or an unmerged result must fail closed. Retry an already-recorded operation only with the same fingerprint.
|
||||
If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Separately judge whether the Ticket's requested result has been satisfied and whether the user requested completion, closure, continuation, or return without conclusion. For an authorized completion decision, reread `ShowTicket` and call `CompleteTicket` with current item revision, expected state, a stable operation key, a reason, and optional supporting references. Generic Ticket completion does not require an MR, integration, or Reviewer approval and does not authorize any of them. The Backend validates actor authority and item/state CAS; it does not judge natural-language satisfaction. Retry an already-recorded operation only with the same fingerprint. Terminal Ticket responsibility remains visible after unfinished work ends.
|
||||
|
||||
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
|
||||
|
||||
Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
|
||||
|
||||
Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no unfinished Ticket work, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat unfinished-work, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
|
||||
Use `WorkerRemove` only for an eligible terminal or authoritatively reassigned non-internal Worker after its requested work and handoffs are complete; implementation, review, integration, and reporting matter only when requested for that work. Do not remove a Worker merely because one turn completed or it is temporarily idle; retain it while requested continuation, review, or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no unfinished Ticket work, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat unfinished-work, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
|
||||
|
||||
Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After all `CompleteMergeRequest` operations and guarded `CompleteTicket`, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no unfinished Ticket work (terminal current responsibility may remain), then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied.
|
||||
Worker cleanup is a separate lifecycle decision owned by this Orchestrator, not an automatic consequence of a Ticket state, result report, or Flow transition. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Worker. When the requested handoff and any authorized conclusion are complete, perform one cleanup pass before ending the orchestration turn if cleanup is in scope: reread the current Ticket and `WorkerList`, verify the assigned Worker has no unfinished Ticket work (terminal current responsibility may remain), then inspect its Worker state, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Worker is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Worker self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied.
|
||||
|
||||
For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; verify there is no unfinished Ticket work; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Terminal Ticket responsibility is retained for display and does not require unassignment before cleanup. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `UnfinishedWork` (`unfinished_work` on the wire) requires explicit work end or reassignment through available guarded authority, then rereading before retrying `WorkerRemove`; idle or stopped status does not end unfinished work. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, work end/reassignment, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, active unfinished work, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions.
|
||||
For Ticket-dedicated cleanup, preserve this guarded order: stop the Worker if needed and confirm it is terminal; verify there is no unfinished Ticket work; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Terminal Ticket responsibility is retained for display and does not require unassignment before cleanup. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `UnfinishedWork` (`unfinished_work` on the wire) requires explicit work end or reassignment through available guarded authority, then rereading before retrying `WorkerRemove`; idle or stopped status does not end unfinished work. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, work end/reassignment, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, active unfinished work, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder.
|
||||
|
||||
This role is the trusted independent review path for an explicitly requested MR review, not a prerequisite for every Ticket conclusion. Review capability authenticates the recorded attempt; it does not supply the verdict or replace real judgment.
|
||||
|
||||
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured explicit Merge Request, `ReviewRequested.subject_ref`, `ticket_item_revision`, and exact linked Merge Request subject snapshot against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. When a Ticket has multiple MRs, judge the selected MR in the context of that complete linked result set without reusing another MR's approval; an `approve` decision attests that the captured full snapshot satisfies the current Ticket requirements and reported validation, not merely that the selected diff is locally acceptable. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation.
|
||||
|
||||
Your prose response is not review authority. Before finishing, call `ReviewMergeRequest` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. For an open MR, the Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. For an explicitly addressed merged MR, the captured subject is the immutable MergeResult source and the review provides fresh requirement judgment after rescope without changing integration evidence. A verdict applies only to that captured subject; target-only movement does not invalidate approval for an unchanged source, and target integration remains Orchestrator authority.
|
||||
|
||||
@@ -2,6 +2,6 @@ You are the assigned Ticket Worker. Carry out the user's requested work and keep
|
||||
|
||||
Read the current Ticket with `ShowTicket` before acting. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
|
||||
|
||||
Use `TicketComment` for useful natural-language progress, results, blockers, and decisions that need attention. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the requested work meets its acceptance criteria, use `CompleteTicket` with a natural-language reason describing the result and supporting evidence, and optional typed references when relevant. Use the current item revision and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing.
|
||||
Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the work meets its acceptance criteria and the request calls for completion, use `CompleteTicket` with a natural-language reason describing the result and optional supporting references. Use authorized `TicketClose` for requested closure when available. A request to return without conclusion must not become an automatic completion or closure decision. Use the current item revision and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction.
|
||||
|
||||
Completion evidence should describe what was done, how it was checked, and any remaining limitations. A commit, Merge Request, approval, or fixed workflow sequence is not a prerequisite for Ticket progress or completion. Do not claim work or validation that did not occur. Ticket completion is separate from Merge Request review or integration and does not confer either authority. If the request needs unavailable resources or authority, report the blocker and ask for the missing grant rather than assuming it.
|
||||
|
||||
@@ -418,7 +418,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
display_name: "Embedded Runtime",
|
||||
built_in: true,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: false,
|
||||
supports_workdir_attachments: false,
|
||||
status: "active",
|
||||
diagnostics: [],
|
||||
}],
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
// Synthetic API fixture serving the production static UI; no Worker/Git execution.
|
||||
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
||||
import {
|
||||
fixtureDetail,
|
||||
fixturePage,
|
||||
} from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts";
|
||||
import type { TicketDetail } from "../../../web/workspace/src/lib/generated/ticket-api.ts";
|
||||
import { emptyLaunchOptions } from "../../../web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts";
|
||||
const port = Number(Deno.args[0]);
|
||||
const root = Deno.args[1];
|
||||
const legacy = Deno.args.includes("--legacy");
|
||||
const tickets = new Map<string, TicketDetail>();
|
||||
const bodies: unknown[] = [];
|
||||
function ticket(key: string) {
|
||||
if (!tickets.has(key)) {
|
||||
const value: TicketDetail = fixtureDetail(key);
|
||||
value.state = "planning";
|
||||
value.title = key === "T-2"
|
||||
? "Read-only investigation"
|
||||
: key === "T-3"
|
||||
? "Independent review before merge"
|
||||
: "Compare options without deciding";
|
||||
value.body = key === "T-2"
|
||||
? "Investigate using read-only sources; review is not required."
|
||||
: key === "T-3"
|
||||
? "Implement the change, then independent review before merge."
|
||||
: "Return options to the user. Do not draw a conclusion.";
|
||||
value.action_eligibility.can_queue = true;
|
||||
value.action_eligibility.queue_tickets = [key];
|
||||
value.action_eligibility.can_start_manual_worker = true;
|
||||
if (key === "T-2") {
|
||||
value.targets = [{ repository_key: "main", ref_selector: "develop", access: "read_only" }];
|
||||
}
|
||||
if (key === "T-3") {
|
||||
value.merge_requests = [{
|
||||
merge_request_id: "mr-1",
|
||||
repository_key: "main",
|
||||
state: "open",
|
||||
selector_from: "work/fixture",
|
||||
selector_to: "develop",
|
||||
review_status: "none",
|
||||
source_ref_observation: { status: "unavailable", code: "provider_unavailable" },
|
||||
current_subject_ref: null,
|
||||
review_subject_ref: null,
|
||||
review_excerpt: null,
|
||||
integration_evidence_error: null,
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
}];
|
||||
}
|
||||
tickets.set(key, value);
|
||||
}
|
||||
const value = structuredClone(tickets.get(key)!);
|
||||
if (legacy) {
|
||||
const wire = value as unknown as Record<string, unknown>;
|
||||
wire.current_coder = wire.current_worker;
|
||||
delete wire.current_worker;
|
||||
const actions = wire.action_eligibility as Record<string, unknown>;
|
||||
actions.can_start_manual_coder = actions.can_start_manual_worker;
|
||||
delete actions.can_start_manual_worker;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
if (url.pathname === "/fixture-state") return Response.json(bodies);
|
||||
if (url.pathname === "/api/workspaces") {
|
||||
return Response.json([{
|
||||
workspace_id: "tickets",
|
||||
display_name: "Shared investigation",
|
||||
owner_account_id: "owner",
|
||||
state: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
}]);
|
||||
}
|
||||
const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/);
|
||||
if (match) {
|
||||
const [, id, path] = match;
|
||||
if (path === "/workspace") {
|
||||
return Response.json({
|
||||
workspace_id: id,
|
||||
display_name: "Shared investigation",
|
||||
record_authority: "fixture",
|
||||
schema_version: 1,
|
||||
auth: {
|
||||
Passkey: {
|
||||
rp_id: "127.0.0.1",
|
||||
origin: url.origin,
|
||||
public_base_url: url.origin,
|
||||
cookie_name: "fixture",
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
manage_repositories: false,
|
||||
manage_secrets: false,
|
||||
manage_runtimes: false,
|
||||
delete_workspace: false,
|
||||
},
|
||||
extension_points: {
|
||||
store: "fixture",
|
||||
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
companion_console: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/repositories") {
|
||||
return Response.json({
|
||||
workspace_id: id,
|
||||
items: [{
|
||||
repository_key: "main",
|
||||
default_selector: "develop",
|
||||
kind: "git",
|
||||
provider: "git",
|
||||
source: { kind: "local_path", uri: "/fixture/main" },
|
||||
source_revision: 1,
|
||||
source_fingerprint: "fixture",
|
||||
observed_status: "ready",
|
||||
record_authority: "fixture",
|
||||
}],
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
if (path === "/orchestrator") {
|
||||
return Response.json({
|
||||
workspace_id: id,
|
||||
online: false,
|
||||
disposition: "offline",
|
||||
worker: null,
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
if (path === "/workers/launch-options") {
|
||||
const options = emptyLaunchOptions(id);
|
||||
options.default_profile = "builtin:ticket-worker";
|
||||
options.profiles = [{
|
||||
id: "builtin:ticket-worker",
|
||||
label: "Ticket Worker",
|
||||
description: "Intent-led work",
|
||||
feature_connections: { subjektiv: false },
|
||||
}, {
|
||||
id: "builtin:coder",
|
||||
label: "Coder",
|
||||
description: "Code recipe",
|
||||
feature_connections: { subjektiv: false },
|
||||
}];
|
||||
options.working_directories = [{
|
||||
working_directory_id: "read-only-sources",
|
||||
source: {
|
||||
kind: "external_grant",
|
||||
grant_id: "fixture-grant",
|
||||
grant_permissions: { read: true, write: false, command: false },
|
||||
},
|
||||
materializer_kind: "client_hosted_external",
|
||||
status: "active",
|
||||
cleanliness: "unknown",
|
||||
}];
|
||||
if (legacy) {
|
||||
for (const runtime of options.runtimes) {
|
||||
const wire = runtime as unknown as Record<string, unknown>;
|
||||
wire.working_directory_required = wire.supports_workdir_attachments;
|
||||
delete wire.supports_workdir_attachments;
|
||||
}
|
||||
}
|
||||
return Response.json(options);
|
||||
}
|
||||
if (path === "/workers") {
|
||||
return Response.json({
|
||||
workspace_id: id,
|
||||
items: [],
|
||||
diagnostics: [],
|
||||
limit: 100,
|
||||
source: "fixture",
|
||||
});
|
||||
}
|
||||
if (path === "/working-directories") {
|
||||
return Response.json({ workspace_id: id, items: [], diagnostics: [] });
|
||||
}
|
||||
if (path === "/protocol/ws" && request.headers.get("upgrade") === "websocket") {
|
||||
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||
socket.onmessage = (event) => {
|
||||
const frame = JSON.parse(String(event.data));
|
||||
if (frame?.message?.method === "subscribe_events") {
|
||||
socket.send(JSON.stringify({
|
||||
protocol_version: 2,
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
payload: {
|
||||
request_id: frame.message.params.request_id,
|
||||
subscription_id: "fixture-workers",
|
||||
selector: { topic: "workspace_workers" },
|
||||
snapshot: { topic: "workers", data: { workers: [] } },
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
return response;
|
||||
}
|
||||
if (path === "/tickets" && request.method === "POST") {
|
||||
const body = await request.json();
|
||||
bodies.push({ path, body });
|
||||
ticket("T-4");
|
||||
const created = tickets.get("T-4")!;
|
||||
created.title = body.title;
|
||||
created.body = body.body;
|
||||
created.targets = body.targets ?? [];
|
||||
return Response.json({
|
||||
id: created.id,
|
||||
resource_key: "T-4",
|
||||
slug: "fixture",
|
||||
status: "Open",
|
||||
});
|
||||
}
|
||||
if (path === "/tickets") {
|
||||
const items = ["T-1", "T-2", "T-3"].map(ticket).filter((t) =>
|
||||
!url.searchParams.has("states") ||
|
||||
url.searchParams.get("states")!.split(",").includes(t.state)
|
||||
);
|
||||
return Response.json({
|
||||
workspace_id: id,
|
||||
items: items.map((t) => ({
|
||||
id: t.id,
|
||||
resource_key: t.resource_key,
|
||||
title: t.title,
|
||||
state: t.state,
|
||||
priority: t.priority,
|
||||
updated_at: t.updated_at,
|
||||
queued_at: t.queued_at,
|
||||
queued_by: t.queued_by,
|
||||
record_source: "fixture",
|
||||
workspace_action_priority: "background",
|
||||
})),
|
||||
invalid_records: [],
|
||||
record_authority: "fixture",
|
||||
limit: 30,
|
||||
page: fixturePage(items.length),
|
||||
});
|
||||
}
|
||||
const tm = path.match(/^\/tickets\/([^/]+)(.*)$/);
|
||||
if (tm) {
|
||||
const key = decodeURIComponent(tm[1]).match(/T-\d+/)?.[0] ?? "T-1";
|
||||
if (request.method !== "GET") {
|
||||
const body = await request.json();
|
||||
bodies.push({ path, body });
|
||||
if (url.searchParams.get("failure")) {
|
||||
return Response.json({ message: "Outcome unknown; refresh before retry" }, {
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
const stored = tickets.get(key) ?? (ticket(key), tickets.get(key)!);
|
||||
if (body.expected_item_revision && body.expected_item_revision !== stored.item_revision) {
|
||||
return Response.json({ message: "Ticket revision conflict; refresh" }, { status: 409 });
|
||||
}
|
||||
if (tm[2] === "/state") {
|
||||
stored.state = body.state;
|
||||
stored.events.push({
|
||||
sequence: stored.events.length,
|
||||
kind: "state_changed",
|
||||
body: body.body,
|
||||
reason: body.reason,
|
||||
from: body.expected_state,
|
||||
to: body.state,
|
||||
references: [],
|
||||
attributes: {},
|
||||
event_ref: `event-${stored.events.length}`,
|
||||
});
|
||||
} else if (tm[2] === "/events") {
|
||||
stored.events.push({
|
||||
sequence: stored.events.length,
|
||||
kind: body.role,
|
||||
body: body.body,
|
||||
references: [],
|
||||
attributes: {},
|
||||
event_ref: `event-${stored.events.length}`,
|
||||
});
|
||||
} else if (tm[2] === "/close") {
|
||||
stored.state = "closed";
|
||||
stored.resolution = body.resolution;
|
||||
} else if (tm[2] === "/ready") stored.state = "ready";
|
||||
else if (tm[2] === "/queue") {
|
||||
stored.state = "queued";
|
||||
return Response.json({ requested_ticket: key, queued_tickets: [key] });
|
||||
} else if (request.method === "PATCH") {
|
||||
if (body.target) stored.targets = body.target.targets ?? [];
|
||||
if (body.title) stored.title = body.title;
|
||||
if (body.body) stored.body = body.body;
|
||||
}
|
||||
stored.item_revision += "x";
|
||||
stored.event_count = stored.events.length;
|
||||
}
|
||||
return Response.json(ticket(key));
|
||||
}
|
||||
return Response.json({ message: "Unavailable fixture resource" }, { status: 404 });
|
||||
}
|
||||
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
|
||||
if (relative.startsWith("..")) return new Response("not found", { status: 404 });
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".woff2": "font/woff2",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
let file = join(root, relative);
|
||||
try {
|
||||
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
|
||||
return new Response(await Deno.readFile(file), {
|
||||
headers: { "content-type": mime[extname(file)] ?? "application/octet-stream" },
|
||||
});
|
||||
} catch {
|
||||
return new Response(await Deno.readFile(join(root, "index.html")), {
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||
import { chromium } from "npm:playwright@1.59.1";
|
||||
import { loadScenario } from "../src/scenario.ts";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||
|
||||
// Browser boundary over the production static build and synthetic loopback API.
|
||||
// No Backend, Worker, Repository, review or integration operation is executed.
|
||||
Deno.test("Ticket progress, comment results and reopen work without Git while real MR evidence stays separate", async () => {
|
||||
const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname;
|
||||
const scenario = await loadScenario(scenarioPath);
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
scenario.baseUrl = `http://127.0.0.1:${port}`;
|
||||
scenario.processes![0].args![4] = String(port);
|
||||
scenario.processes![0].readyUrl = `${scenario.baseUrl}/health`;
|
||||
const output = new URL("../../../target/web-ux/t720-browser/", import.meta.url).pathname;
|
||||
const processes = await startOwnedProcesses(
|
||||
scenario.processes!,
|
||||
scenarioPath,
|
||||
`${output}/logs`,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const unexpected: string[] = [];
|
||||
page.on("pageerror", (e) => unexpected.push(String(e)));
|
||||
await page.goto(`${scenario.baseUrl}/w/tickets/tickets`);
|
||||
const addTicket = page.getByRole("button", { name: "Add Ticket", exact: true });
|
||||
await addTicket.waitFor();
|
||||
const action = await addTicket.boundingBox();
|
||||
assert(action && action.x >= 0 && action.x + action.width <= width && action.y < 900);
|
||||
assertEquals(
|
||||
await page.evaluate("document.documentElement.scrollWidth > innerWidth"),
|
||||
false,
|
||||
);
|
||||
await addTicket.click();
|
||||
await page.locator(".ticket-create-form").waitFor();
|
||||
assertEquals(await page.locator(".ticket-target-row").count(), 0);
|
||||
assertEquals(
|
||||
await page.evaluate("document.documentElement.scrollWidth > innerWidth"),
|
||||
false,
|
||||
);
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`,
|
||||
);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
// Intent comes first at one-column widths, unlike the former controls-first layout.
|
||||
if (width < 1024) {
|
||||
const intent = await page.getByRole("heading", { name: "Intent", exact: true })
|
||||
.boundingBox();
|
||||
const progress = await page.getByRole("heading", { name: "Progress decision" })
|
||||
.boundingBox();
|
||||
assert(intent && progress && intent.y < progress.y);
|
||||
}
|
||||
const state = page.getByRole("combobox", { name: "State", exact: true });
|
||||
await state.waitFor();
|
||||
assertEquals(await state.locator("option").allTextContents(), [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
]);
|
||||
await state.selectOption("inprogress");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Investigating options directly",
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply state", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="inprogress"]').waitFor();
|
||||
await page.getByText("Record result or comment", { exact: true }).click();
|
||||
await page.getByLabel("Body", { exact: true }).fill(
|
||||
`Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`,
|
||||
);
|
||||
await page.getByRole("button", { name: "Append event" }).click();
|
||||
await page.getByText(
|
||||
`Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`,
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
await state.selectOption("done");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Requested comparison returned",
|
||||
);
|
||||
await page.getByLabel("Result, references, and remaining work (optional)").fill(
|
||||
"Result: timeline comment. [Reference](https://example.com/options). Remaining: user choice.",
|
||||
);
|
||||
await page.getByRole("button", { name: "Complete Ticket", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("heading", { name: "MR requirement evidence" }).count(),
|
||||
0,
|
||||
);
|
||||
assert(await page.getByText("No Merge Requests linked.", { exact: false }).isVisible());
|
||||
await state.selectOption("planning");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"User requests another comparison",
|
||||
);
|
||||
await page.getByRole("button", { name: "Reopen / apply state" }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="planning"]').waitFor();
|
||||
await page.getByRole("button", { name: "Request Orchestrator (queue)" }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="queued"]').waitFor();
|
||||
await page.locator(".ticket-close-card > summary").click();
|
||||
await page.getByLabel("Resolution").fill("Discussion returned; no code or MR required");
|
||||
await page.getByRole("button", { name: "Close ticket", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="closed"]').waitFor();
|
||||
await page.goto(`${scenario.baseUrl}/w/tickets/tickets/T-2-read-only-investigation`);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("combobox", { name: "Access", exact: true }).inputValue(),
|
||||
"read_only",
|
||||
);
|
||||
assert(await page.getByRole("button", { name: "Mark ready", exact: true }).isEnabled());
|
||||
await page.getByRole("button", { name: "Mark ready", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="ready"]').waitFor();
|
||||
assert(await page.getByRole("combobox", { name: "Repository", exact: true }).isEnabled());
|
||||
await page.getByRole("button", { name: "Save targets", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save targets", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/tickets/T-3-independent-review-before-merge`,
|
||||
);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
await state.selectOption("done");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Progress decision is separate from review",
|
||||
);
|
||||
await page.getByRole("button", { name: "Complete Ticket" }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor();
|
||||
assert(await page.getByText("main: open", { exact: true }).isVisible());
|
||||
assert(
|
||||
await page.getByText("Ticket completion is not MR approval or integration.", {
|
||||
exact: true,
|
||||
}).isVisible(),
|
||||
);
|
||||
assertEquals(await page.getByText("main: merged", { exact: true }).count(), 0);
|
||||
// Member normal operations do not enable owner configuration controls.
|
||||
assertEquals(await page.getByRole("link", { name: "Secrets", exact: true }).count(), 0);
|
||||
assertEquals(
|
||||
await page.evaluate("document.documentElement.scrollWidth > innerWidth"),
|
||||
false,
|
||||
);
|
||||
await Deno.mkdir(output, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: `${output}/${colorScheme}-${width}-mr-done.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
assertEquals(unexpected, []);
|
||||
// Reset synthetic records for the next viewport using the fixture's ordinary state API.
|
||||
for (const key of ["T-1", "T-2", "T-3"]) {
|
||||
const detail =
|
||||
await (await context.request.get(`${scenario.baseUrl}/api/w/tickets/tickets/${key}`))
|
||||
.json();
|
||||
await context.request.post(`${scenario.baseUrl}/api/w/tickets/tickets/${key}/state`, {
|
||||
data: {
|
||||
state: "planning",
|
||||
reason: "Fixture reset",
|
||||
expected_state: detail.state,
|
||||
expected_item_revision: detail.item_revision,
|
||||
},
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
const context = await browser.newContext();
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${scenario.baseUrl}/w/tickets/tickets`);
|
||||
await page.getByRole("button", { name: "Add Ticket", exact: true }).click();
|
||||
await page.getByLabel("Title", { exact: true }).fill("Repository-free investigation");
|
||||
await page.getByLabel("Body", { exact: true }).fill(
|
||||
"Compare options and return without conclusion; review is unnecessary.",
|
||||
);
|
||||
await page.getByRole("button", { name: "Create Ticket", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "Repository-free investigation", exact: true })
|
||||
.waitFor();
|
||||
assert(await page.getByText("No repository targets.", { exact: true }).isVisible());
|
||||
// Generic remote launch: a natural request, not an inferred code/review pipeline.
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/workers/new?ticketId=T-4&initialInput=Return%20without%20conclusion.%20Review%20is%20unnecessary.`,
|
||||
);
|
||||
await page.locator(".worker-launch-form").waitFor();
|
||||
await page.getByRole("combobox", { name: "Runtime", exact: true }).selectOption("remote");
|
||||
const launches: Record<string, unknown>[] = [];
|
||||
await page.route("**/api/w/tickets/workers", async (route) => {
|
||||
launches.push(route.request().postDataJSON());
|
||||
await route.fulfill({
|
||||
status: 403,
|
||||
json: { error: "permission_denied", message: "Permission denied", diagnostics: [] },
|
||||
});
|
||||
});
|
||||
await page.getByRole("button", { name: "Start Worker", exact: true }).click();
|
||||
await page.getByText("permission_denied: Permission denied", { exact: true }).waitFor();
|
||||
assertEquals(launches.length, 1);
|
||||
assertEquals(launches[0].workdir_attachments, []);
|
||||
assertEquals(launches[0].profile, null);
|
||||
assertEquals(launches[0].initial_submit, [{
|
||||
kind: "text",
|
||||
content: "Return without conclusion. Review is unnecessary.",
|
||||
}]);
|
||||
assertEquals((launches[0].ticket_assignment as { ticket_id: string }).ticket_id, "T-4");
|
||||
await page.unroute("**/api/w/tickets/workers");
|
||||
for (
|
||||
const [status, message] of [[409, "Update conflict."], [403, "Permission denied."], [
|
||||
503,
|
||||
"Outcome unknown or request rejected.",
|
||||
]] as const
|
||||
) {
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`,
|
||||
);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
await page.route(
|
||||
"**/tickets/*/state",
|
||||
(route) => route.fulfill({ status, json: { error: "failure", message } }),
|
||||
);
|
||||
await page.getByRole("combobox", { name: "State", exact: true }).selectOption("done");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Keep this draft on rejection",
|
||||
);
|
||||
await page.getByRole("button", { name: "Complete Ticket" }).click();
|
||||
await page.getByRole("alert").getByText(message, { exact: false }).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Reason", { exact: true }).first().inputValue(),
|
||||
"Keep this draft on rejection",
|
||||
);
|
||||
assert(await page.locator('.ticket-detail-kicker [data-status="planning"]').isVisible());
|
||||
assert(await page.getByRole("button", { name: "Refresh Ticket status" }).isEnabled());
|
||||
await page.unroute("**/tickets/*/state");
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
const requests = await (await fetch(`${scenario.baseUrl}/fixture-state`)).json();
|
||||
assert(
|
||||
requests.some((r: { body: { body?: string } }) =>
|
||||
r.body.body?.includes("Remaining: user choice")
|
||||
),
|
||||
);
|
||||
assert(!requests.some((r: { path: string }) => /merge|review|workers/.test(r.path)));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
||||
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
||||
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "tickets-dark",
|
||||
"title": "Flexible Ticket progress and optional resources",
|
||||
"baseUrl": "http://127.0.0.1:15289",
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "member",
|
||||
"label": "Workspace member, not settings owner",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "options",
|
||||
"label": "Return options without conclusion",
|
||||
"path": "/w/tickets/tickets/T-1",
|
||||
"goal": "Read intent, record results and progress without a repository",
|
||||
"dataState": "No repository or MR",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "read-only",
|
||||
"label": "Investigation without review",
|
||||
"path": "/w/tickets/tickets/T-2",
|
||||
"goal": "Use read-only resources without requiring write or MR",
|
||||
"dataState": "Read-only target",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
"label": "Independent review before merge",
|
||||
"path": "/w/tickets/tickets/T-3",
|
||||
"goal": "Separate progress from actual MR review and integration",
|
||||
"dataState": "Open MR, unavailable source observation",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "launch",
|
||||
"label": "Natural request with optional recipe and resources",
|
||||
"path": "/w/tickets/workers/new?ticketId=T-1&ticketTitle=Compare%20options%20without%20deciding&initialInput=Return%20options%20without%20a%20conclusion.%20Review%20is%20unnecessary.",
|
||||
"goal": "Choose the general Ticket worker, no mandatory repository or Flow",
|
||||
"dataState": "Member with embedded and remote Runtimes, read-only external grant available but not claimed",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".worker-launch-form"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket launch",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "list",
|
||||
"label": "Member creates a repository-free Ticket",
|
||||
"path": "/w/tickets/tickets",
|
||||
"goal": "Create work from intent with optional resources, not a fixed delivery pipeline",
|
||||
"dataState": "Three lightweight progress lanes, member normal operations",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Progress lanes",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "create",
|
||||
"label": "Optional resources",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Add Ticket\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-create-form"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "ticket-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"browser-tests/ticket_fixture_server.ts",
|
||||
"15289",
|
||||
"../../web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15289/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "tickets",
|
||||
"title": "Flexible Ticket progress and optional resources",
|
||||
"baseUrl": "http://127.0.0.1:15289",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "member",
|
||||
"label": "Workspace member, not settings owner",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "options",
|
||||
"label": "Return options without conclusion",
|
||||
"path": "/w/tickets/tickets/T-1",
|
||||
"goal": "Read intent, record results and progress without a repository",
|
||||
"dataState": "No repository or MR",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "read-only",
|
||||
"label": "Investigation without review",
|
||||
"path": "/w/tickets/tickets/T-2",
|
||||
"goal": "Use read-only resources without requiring write or MR",
|
||||
"dataState": "Read-only target",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
"label": "Independent review before merge",
|
||||
"path": "/w/tickets/tickets/T-3",
|
||||
"goal": "Separate progress from actual MR review and integration",
|
||||
"dataState": "Open MR, unavailable source observation",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "launch",
|
||||
"label": "Natural request with optional recipe and resources",
|
||||
"path": "/w/tickets/workers/new?ticketId=T-1&ticketTitle=Compare%20options%20without%20deciding&initialInput=Return%20options%20without%20a%20conclusion.%20Review%20is%20unnecessary.",
|
||||
"goal": "Choose the general Ticket worker, no mandatory repository or Flow",
|
||||
"dataState": "Member with embedded and remote Runtimes, read-only external grant available but not claimed",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".worker-launch-form"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket launch",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "list",
|
||||
"label": "Member creates a repository-free Ticket",
|
||||
"path": "/w/tickets/tickets",
|
||||
"goal": "Create work from intent with optional resources, not a fixed delivery pipeline",
|
||||
"dataState": "Three lightweight progress lanes, member normal operations",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Progress lanes",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "create",
|
||||
"label": "Optional resources",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Add Ticket\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-create-form"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "ticket-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"browser-tests/ticket_fixture_server.ts",
|
||||
"15289",
|
||||
"../../web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15289/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export type RunningProcess = {
|
||||
};
|
||||
|
||||
async function appendOutput(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
stream: ReadableStream<BufferSource>,
|
||||
destination: string,
|
||||
secrets: string[],
|
||||
): Promise<void> {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"build": "deno run -A npm:vite@8.3.2 build",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
||||
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// 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.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
||||
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
|
||||
// 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
|
||||
// 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.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
||||
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// 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.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
||||
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
|
||||
// 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
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
||||
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
||||
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// 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
|
||||
@@ -63,7 +63,7 @@ export type WorkerLaunchOptionsResponse = { default_profile?: string | null; dia
|
||||
|
||||
export type WorkerLaunchProfileCandidate = { description: string; feature_connections: WorkerLaunchFeatureConnectionOptions; id: string; label: string; };
|
||||
|
||||
export type WorkerLaunchRuntimeOption = { built_in: boolean; diagnostics: Array<Diagnostic>; display_name: string; runtime_id: string; status: string; worker_creation_available: boolean; working_directory_required: boolean; };
|
||||
export type WorkerLaunchRuntimeOption = { built_in: boolean; diagnostics: Array<Diagnostic>; display_name: string; runtime_id: string; status: string; supports_workdir_attachments: boolean; worker_creation_available: boolean; };
|
||||
|
||||
export type WorkerLaunchWorkerSummary = { diagnostics?: Array<Diagnostic>; display_name: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned: boolean; profile?: string | null; retention_state: string; runtime_id: string; singleton_key?: string | null; state: string; tags: Array<string>; workdir_attachments?: Array<RuntimeWorkerWorkdirAttachmentSummary>; worker_id: string; workspace: WorkerWorkspaceSummary; };
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ function ticketDetailFixture() {
|
||||
blockers: [],
|
||||
can_assign_orchestrator: false,
|
||||
can_queue: false,
|
||||
can_start_manual_coder: false,
|
||||
can_start_manual_worker: false,
|
||||
can_unassign_orchestrator: false,
|
||||
queue_tickets: [],
|
||||
},
|
||||
@@ -97,7 +97,7 @@ function ticketDetailFixture() {
|
||||
assignments: [],
|
||||
body: "Body",
|
||||
body_truncated: false,
|
||||
current_coder: null,
|
||||
current_worker: null,
|
||||
event_count: 0,
|
||||
event_page: { ...page, returned: 0 },
|
||||
events: [],
|
||||
|
||||
@@ -397,7 +397,7 @@ function parseTicketRoleAssignmentRecord(
|
||||
"workspace_id",
|
||||
], label);
|
||||
const role = string(item.role, `${label}.role`);
|
||||
if (!["orchestrator", "coder", "owner", "contributor"].includes(role)) {
|
||||
if (!["orchestrator", "worker", "owner", "contributor"].includes(role)) {
|
||||
throw new Error(`${label}.role is invalid`);
|
||||
}
|
||||
return {
|
||||
@@ -423,7 +423,7 @@ function parseActionEligibility(
|
||||
"blockers",
|
||||
"can_assign_orchestrator",
|
||||
"can_queue",
|
||||
"can_start_manual_coder",
|
||||
"can_start_manual_worker",
|
||||
"can_unassign_orchestrator",
|
||||
"queue_tickets",
|
||||
], label);
|
||||
@@ -434,9 +434,9 @@ function parseActionEligibility(
|
||||
`${label}.can_assign_orchestrator`,
|
||||
),
|
||||
can_queue: boolean(item.can_queue, `${label}.can_queue`),
|
||||
can_start_manual_coder: boolean(
|
||||
item.can_start_manual_coder,
|
||||
`${label}.can_start_manual_coder`,
|
||||
can_start_manual_worker: boolean(
|
||||
item.can_start_manual_worker,
|
||||
`${label}.can_start_manual_worker`,
|
||||
),
|
||||
can_unassign_orchestrator: boolean(
|
||||
item.can_unassign_orchestrator,
|
||||
@@ -730,7 +730,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
"body",
|
||||
"body_truncated",
|
||||
"created_at",
|
||||
"current_coder",
|
||||
"current_worker",
|
||||
"event_count",
|
||||
"event_page",
|
||||
"events",
|
||||
@@ -774,13 +774,13 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
body: string(item.body, `${label}.body`),
|
||||
body_truncated: boolean(item.body_truncated, `${label}.body_truncated`),
|
||||
created_at: optionalNullableString(item.created_at, `${label}.created_at`),
|
||||
current_coder: item.current_coder === undefined
|
||||
current_worker: item.current_worker === undefined
|
||||
? undefined
|
||||
: item.current_coder === null
|
||||
: item.current_worker === null
|
||||
? null
|
||||
: parseTicketAssignmentSummary(
|
||||
item.current_coder,
|
||||
`${label}.current_coder`,
|
||||
item.current_worker,
|
||||
`${label}.current_worker`,
|
||||
),
|
||||
event_count: integer(item.event_count, `${label}.event_count`),
|
||||
event_page: parseQueryPage(item.event_page, `${label}.event_page`),
|
||||
|
||||
@@ -248,7 +248,7 @@ Deno.test("Worker launch options parser accepts the generated wire shape", () =>
|
||||
display_name: "Runtime A",
|
||||
built_in: false,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: true,
|
||||
supports_workdir_attachments: true,
|
||||
status: "connected",
|
||||
diagnostics: [],
|
||||
}],
|
||||
|
||||
@@ -130,7 +130,7 @@ function runtimeOption(
|
||||
"display_name",
|
||||
"built_in",
|
||||
"worker_creation_available",
|
||||
"working_directory_required",
|
||||
"supports_workdir_attachments",
|
||||
"status",
|
||||
"diagnostics",
|
||||
],
|
||||
@@ -144,9 +144,9 @@ function runtimeOption(
|
||||
item.worker_creation_available,
|
||||
`${label}.worker_creation_available`,
|
||||
),
|
||||
working_directory_required: boolean(
|
||||
item.working_directory_required,
|
||||
`${label}.working_directory_required`,
|
||||
supports_workdir_attachments: boolean(
|
||||
item.supports_workdir_attachments,
|
||||
`${label}.supports_workdir_attachments`,
|
||||
),
|
||||
status: string(item.status, `${label}.status`),
|
||||
diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
|
||||
|
||||
@@ -434,11 +434,11 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
ticketDetailPage.includes("`${path}/queue`") &&
|
||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||
ticketDetailPage.includes("mergeRequest.review_status") &&
|
||||
ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") &&
|
||||
ticketDetailPage.includes("mergeRequestPagePath") &&
|
||||
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||
ticketDetailPage.includes("mutateAssignment") &&
|
||||
ticketDetailPage.includes("can_start_manual_coder") &&
|
||||
ticketDetailPage.includes("can_start_manual_worker") &&
|
||||
ticketDetailPage.includes("ticket.relations.outgoing"),
|
||||
"Ticket detail should expose typed lifecycle actions, relations, target selection, assignments, and Merge Request navigation",
|
||||
);
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fixtureDetail(key = "T-101") {
|
||||
complete_for_integration: false,
|
||||
missing: [],
|
||||
},
|
||||
current_coder: key === "T-101"
|
||||
current_worker: key === "T-101"
|
||||
? {
|
||||
assignment_id: "a-1",
|
||||
runtime_id: "internal-runtime",
|
||||
@@ -71,7 +71,7 @@ export function fixtureDetail(key = "T-101") {
|
||||
blockers: [],
|
||||
can_assign_orchestrator: false,
|
||||
can_queue: false,
|
||||
can_start_manual_coder: false,
|
||||
can_start_manual_worker: false,
|
||||
can_unassign_orchestrator: false,
|
||||
queue_tickets: [],
|
||||
},
|
||||
|
||||
@@ -196,10 +196,10 @@ export function loadDashboard(
|
||||
? (blockedKeys.length === blockers.length
|
||||
? `Blocked by ${blockedKeys.join(", ")}`
|
||||
: `${blockers.length} unresolved dependencies`)
|
||||
: current.current_coder
|
||||
: current.current_worker
|
||||
? undefined
|
||||
: "No coder assigned",
|
||||
workerKey: current.current_coder?.worker_resource_key ??
|
||||
: "No Worker assigned",
|
||||
workerKey: current.current_worker?.worker_resource_key ??
|
||||
undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkerLaunchOptionsResponse } from "./types.ts";
|
||||
import type { WorkerLaunchOptionsResponse } from "../../generated/worker-launch-api.ts";
|
||||
|
||||
export function emptyLaunchOptions(
|
||||
workspaceId: string,
|
||||
@@ -12,7 +12,7 @@ export function emptyLaunchOptions(
|
||||
status: "active",
|
||||
worker_creation_available: true,
|
||||
built_in: true,
|
||||
working_directory_required: false,
|
||||
supports_workdir_attachments: false,
|
||||
diagnostics: [],
|
||||
},
|
||||
{
|
||||
@@ -21,7 +21,7 @@ export function emptyLaunchOptions(
|
||||
status: "active",
|
||||
worker_creation_available: true,
|
||||
built_in: false,
|
||||
working_directory_required: true,
|
||||
supports_workdir_attachments: true,
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -10,18 +10,37 @@ declare const Deno: {
|
||||
};
|
||||
|
||||
Deno.test("logical config attachments are never offered as process working directories", () => {
|
||||
const config = { working_directory_id: "config-1", display_name: "Workspace config",
|
||||
source: { kind: "workspace_config" as const, access: "read_write" as const, content_path: "/workspace-config", purpose: "Configuration" },
|
||||
materializer_kind: "logical_workspace_config" as const, status: "active" as const, cleanliness: "clean" };
|
||||
const form = defaultWorkerLaunchForm({ ...options, working_directories: [config] }, emptyForm({
|
||||
workdir_attachments: [{ alias: "config", working_directory_id: "config-1", relative_cwd: "" }],
|
||||
}));
|
||||
if (form.workdir_attachments.some((attachment) => attachment.working_directory_id === "config-1")) {
|
||||
const config = {
|
||||
working_directory_id: "config-1",
|
||||
display_name: "Workspace config",
|
||||
source: {
|
||||
kind: "workspace_config" as const,
|
||||
access: "read_write" as const,
|
||||
content_path: "/workspace-config",
|
||||
purpose: "Configuration",
|
||||
},
|
||||
materializer_kind: "logical_workspace_config" as const,
|
||||
status: "active" as const,
|
||||
cleanliness: "clean",
|
||||
};
|
||||
const form = defaultWorkerLaunchForm(
|
||||
{ ...options, working_directories: [config] },
|
||||
emptyForm({
|
||||
workdir_attachments: [{
|
||||
alias: "config",
|
||||
working_directory_id: "config-1",
|
||||
relative_cwd: "",
|
||||
}],
|
||||
}),
|
||||
);
|
||||
if (
|
||||
form.workdir_attachments.some((attachment) => attachment.working_directory_id === "config-1")
|
||||
) {
|
||||
throw new Error("logical config was converted to a process cwd attachment");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initial Workdir", () => {
|
||||
Deno.test("defaultWorkerLaunchForm selects a Runtime without claiming a profile or Workdir", () => {
|
||||
const form = defaultWorkerLaunchForm(
|
||||
options,
|
||||
emptyForm({ initial_text: "hello" }),
|
||||
@@ -29,14 +48,10 @@ Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initi
|
||||
|
||||
assertEquals(form.runtime_id, "remote");
|
||||
assertEquals(form.display_name, "Worker");
|
||||
assertEquals(form.profile, "builtin:coder");
|
||||
assertEquals(form.profile, "");
|
||||
assertEquals(form.initial_text, "hello");
|
||||
assertEquals(form.workdir_attachments, [{
|
||||
alias: "workdir",
|
||||
working_directory_id: "wd-1-repo",
|
||||
relative_cwd: "",
|
||||
}]);
|
||||
assertEquals(form.working_directory_repository_key, "repo");
|
||||
assertEquals(form.workdir_attachments, []);
|
||||
assertEquals(form.working_directory_repository_key, "");
|
||||
assertEquals(form.working_directory_selector, "HEAD");
|
||||
});
|
||||
|
||||
@@ -71,7 +86,7 @@ Deno.test("defaultWorkerLaunchForm preserves an available read-only External Wor
|
||||
}]);
|
||||
});
|
||||
|
||||
Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => {
|
||||
Deno.test("defaultWorkerLaunchForm does not turn a generic profile choice into trusted review", () => {
|
||||
const reviewerOptions = {
|
||||
...options,
|
||||
profiles: [
|
||||
@@ -95,10 +110,10 @@ Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile",
|
||||
}),
|
||||
);
|
||||
|
||||
assertEquals(form.profile, "builtin:reviewer");
|
||||
assertEquals(form.profile, "");
|
||||
});
|
||||
|
||||
Deno.test("defaultWorkerLaunchForm skips occupied Workdirs and leaves an editable attachment", () => {
|
||||
Deno.test("defaultWorkerLaunchForm does not claim occupied Workdirs", () => {
|
||||
const form = defaultWorkerLaunchForm(
|
||||
{
|
||||
...options,
|
||||
@@ -115,11 +130,7 @@ Deno.test("defaultWorkerLaunchForm skips occupied Workdirs and leaves an editabl
|
||||
emptyForm({ initial_text: "hello" }),
|
||||
);
|
||||
|
||||
assertEquals(form.workdir_attachments, [{
|
||||
alias: "workdir",
|
||||
working_directory_id: "",
|
||||
relative_cwd: "",
|
||||
}]);
|
||||
assertEquals(form.workdir_attachments, []);
|
||||
});
|
||||
|
||||
Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => {
|
||||
@@ -149,11 +160,7 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () =>
|
||||
}),
|
||||
);
|
||||
|
||||
assertEquals(form.workdir_attachments, [{
|
||||
alias: "workdir",
|
||||
working_directory_id: "ticket-workdir",
|
||||
relative_cwd: "",
|
||||
}]);
|
||||
assertEquals(form.workdir_attachments, []);
|
||||
assertEquals(form.working_directory_repository_key, "ticket-repo");
|
||||
assertEquals(form.working_directory_selector, "work/ticket");
|
||||
});
|
||||
@@ -308,8 +315,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits an empty attachment list for
|
||||
});
|
||||
|
||||
function emptyForm(
|
||||
overrides: Partial<Parameters<typeof buildCreateWorkspaceWorkerRequest>[0]> =
|
||||
{},
|
||||
overrides: Partial<Parameters<typeof buildCreateWorkspaceWorkerRequest>[0]> = {},
|
||||
): Parameters<typeof buildCreateWorkspaceWorkerRequest>[0] {
|
||||
return {
|
||||
runtime_id: "",
|
||||
@@ -361,7 +367,7 @@ const options: WorkerLaunchOptionsResponse = {
|
||||
status: "active",
|
||||
worker_creation_available: true,
|
||||
built_in: false,
|
||||
working_directory_required: true,
|
||||
supports_workdir_attachments: true,
|
||||
diagnostics: [],
|
||||
},
|
||||
{
|
||||
@@ -370,7 +376,7 @@ const options: WorkerLaunchOptionsResponse = {
|
||||
status: "active",
|
||||
worker_creation_available: true,
|
||||
built_in: true,
|
||||
working_directory_required: false,
|
||||
supports_workdir_attachments: false,
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
BrowserWorkerWorkingDirectorySelection,
|
||||
CreateWorkspaceWorkerRequest,
|
||||
CreateWorkspaceWorkerTicketAssignmentRequest,
|
||||
RepositoryApiError,
|
||||
WorkerLaunchProfileCandidate,
|
||||
} from "#lib/generated/worker-launch-api.ts";
|
||||
import { parseCreateWorkspaceWorkerRequest } from "#lib/workspace/api/workers.ts";
|
||||
|
||||
@@ -14,6 +17,8 @@ export type WorkerLaunchAttachmentFormState = {
|
||||
|
||||
export type WorkerLaunchFormState = {
|
||||
runtime_id: string;
|
||||
flow?: string;
|
||||
ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null;
|
||||
display_name: string;
|
||||
profile: string;
|
||||
subjektiv_subject_id: string;
|
||||
@@ -35,84 +40,33 @@ export function defaultWorkerLaunchForm(
|
||||
) ??
|
||||
options?.runtimes.find((runtime) => runtime.worker_creation_available) ??
|
||||
options?.runtimes[0];
|
||||
const preferredProfile = options?.profiles.find((candidate) =>
|
||||
candidate.id === options.default_profile
|
||||
);
|
||||
const availableWorkingDirectories =
|
||||
options?.working_directories.filter((directory) =>
|
||||
directory.status === "active" &&
|
||||
directory.source.kind !== "workspace_config" &&
|
||||
(directory.source.kind === "external_grant" ||
|
||||
directory.cleanliness === "clean") &&
|
||||
directory.occupied_by == null
|
||||
) ?? [];
|
||||
const selectedRuntime = current.runtime_id
|
||||
? options?.runtimes.find((runtime) =>
|
||||
runtime.runtime_id === current.runtime_id
|
||||
)
|
||||
: preferredRuntime;
|
||||
const workdirlessRuntime =
|
||||
selectedRuntime?.working_directory_required === false;
|
||||
const preferredWorkingDirectory = workdirlessRuntime
|
||||
? undefined
|
||||
: availableWorkingDirectories.find((directory) =>
|
||||
Boolean(current.working_directory_repository_key) &&
|
||||
directory.source.kind === "repository" &&
|
||||
directory.source.repository_key ===
|
||||
current.working_directory_repository_key &&
|
||||
(!current.working_directory_selector ||
|
||||
(directory.current_selector ?? directory.creation_selector) ===
|
||||
current.working_directory_selector)
|
||||
) ?? availableWorkingDirectories.find((directory) =>
|
||||
Boolean(current.working_directory_repository_key) &&
|
||||
directory.source.kind === "repository" &&
|
||||
directory.source.repository_key ===
|
||||
current.working_directory_repository_key
|
||||
) ?? (current.working_directory_repository_key
|
||||
? undefined
|
||||
: availableWorkingDirectories[0]);
|
||||
const preferredRepository =
|
||||
options?.repositories.find((repository) =>
|
||||
repository.repository_key === current.working_directory_repository_key
|
||||
) ??
|
||||
options?.repositories[0];
|
||||
const runtime =
|
||||
options?.runtimes.find((candidate) => candidate.runtime_id === current.runtime_id) ??
|
||||
preferredRuntime;
|
||||
const availableWorkdirIds = new Set(
|
||||
availableWorkingDirectories.map((directory) =>
|
||||
directory.working_directory_id
|
||||
),
|
||||
(options?.working_directories ?? []).filter((directory) =>
|
||||
directory.status === "active" && directory.source.kind !== "workspace_config" &&
|
||||
(directory.source.kind === "external_grant" || directory.cleanliness === "clean") &&
|
||||
directory.occupied_by == null
|
||||
).map((directory) => directory.working_directory_id),
|
||||
);
|
||||
const currentAttachments = workdirlessRuntime
|
||||
? []
|
||||
: current.workdir_attachments.filter((attachment) =>
|
||||
availableWorkdirIds.has(attachment.working_directory_id)
|
||||
);
|
||||
const workdirAttachments = currentAttachments.length > 0
|
||||
? currentAttachments
|
||||
: preferredWorkingDirectory
|
||||
? [{
|
||||
alias: "workdir",
|
||||
working_directory_id: preferredWorkingDirectory.working_directory_id,
|
||||
relative_cwd: "",
|
||||
}]
|
||||
: selectedRuntime?.working_directory_required === true
|
||||
? [{ alias: "workdir", working_directory_id: "", relative_cwd: "" }]
|
||||
: [];
|
||||
|
||||
// Defaults never claim resource attachments or infer an execution recipe from a Ticket.
|
||||
return {
|
||||
...current,
|
||||
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || "",
|
||||
display_name: current.display_name || "Worker",
|
||||
profile:
|
||||
options?.profiles.some((candidate) => candidate.id === current.profile)
|
||||
genericLaunchProfiles(options?.profiles ?? []).some((candidate) =>
|
||||
candidate.id === current.profile
|
||||
)
|
||||
? current.profile
|
||||
: preferredProfile?.id || "",
|
||||
subjektiv_subject_id: current.subjektiv_subject_id,
|
||||
initial_text: current.initial_text,
|
||||
workdir_attachments: workdirAttachments,
|
||||
working_directory_repository_key:
|
||||
current.working_directory_repository_key ||
|
||||
preferredRepository?.repository_key || "",
|
||||
working_directory_selector: current.working_directory_selector ||
|
||||
preferredRepository?.default_selector || "HEAD",
|
||||
: "",
|
||||
workdir_attachments: runtime?.supports_workdir_attachments === false
|
||||
? []
|
||||
: current.workdir_attachments.filter((attachment) =>
|
||||
availableWorkdirIds.has(attachment.working_directory_id)
|
||||
),
|
||||
working_directory_selector: current.working_directory_selector || "HEAD",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,15 +126,19 @@ function validatedAttachments(
|
||||
export function buildCreateWorkspaceWorkerRequest(
|
||||
form: WorkerLaunchFormState,
|
||||
): CreateWorkspaceWorkerRequest {
|
||||
if (["builtin:reviewer", "reviewer"].includes(form.profile.trim())) {
|
||||
throw new Error("Trusted Reviewer launch requires a bound review request.");
|
||||
}
|
||||
const initialMessage = form.initial_text.trim();
|
||||
return parseCreateWorkspaceWorkerRequest({
|
||||
runtime_id: form.runtime_id.trim(),
|
||||
display_name: form.display_name.trim(),
|
||||
profile: form.profile.trim() || null,
|
||||
ticket_assignment: null,
|
||||
initial_submit: initialMessage
|
||||
? [{ kind: "text", content: form.initial_text }]
|
||||
: [],
|
||||
ticket_assignment: form.ticket_assignment ?? null,
|
||||
initial_submit: [
|
||||
...(initialMessage ? [{ kind: "text", content: form.initial_text }] : []),
|
||||
...(form.flow?.trim() ? [{ kind: "flow", selector: form.flow.trim() }] : []),
|
||||
],
|
||||
workdir_attachments: validatedAttachments(form.workdir_attachments),
|
||||
feature_connections: form.subjektiv_subject_id.trim()
|
||||
? { subjektiv: { subject_id: form.subjektiv_subject_id.trim() } }
|
||||
@@ -188,3 +146,21 @@ export function buildCreateWorkspaceWorkerRequest(
|
||||
control_operation_id: null,
|
||||
});
|
||||
}
|
||||
|
||||
// A trusted Reviewer is launched through the bound review path, not a Profile selection.
|
||||
export function genericLaunchProfiles(
|
||||
profiles: WorkerLaunchProfileCandidate[],
|
||||
): WorkerLaunchProfileCandidate[] {
|
||||
return profiles.filter((candidate) =>
|
||||
candidate.id !== "builtin:reviewer" && candidate.id !== "reviewer"
|
||||
);
|
||||
}
|
||||
|
||||
export function workerLaunchOutcomeUnknown(error: RepositoryApiError): boolean {
|
||||
return [
|
||||
error.error,
|
||||
error.message,
|
||||
...(error.diagnostics ?? []).flatMap((item) => [item.code, item.message]),
|
||||
]
|
||||
.some((value) => /outcome[_\s-]?unknown|OutcomeUnknown/i.test(value));
|
||||
}
|
||||
|
||||
@@ -73,13 +73,22 @@
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.ticket-panel-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ticket-panel-header > div:first-child {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
.ticket-panel-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.orchestrator-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
@@ -480,17 +489,11 @@
|
||||
.ticket-detail-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.ticket-control-rail {
|
||||
grid-row: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 64rem) {
|
||||
.ticket-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.ticket-control-rail {
|
||||
grid-row: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,75 +84,45 @@ Deno.test("ticket lane visibility advances in bounded pages of 30", () => {
|
||||
assertEquals(nextTicketLaneVisibleCount(95, 95), 95);
|
||||
});
|
||||
|
||||
Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => {
|
||||
const ticket = {
|
||||
id: "00001KYRRDVH9",
|
||||
title: "Ticket panel API",
|
||||
targets: [
|
||||
{
|
||||
repository_key: "docs",
|
||||
ref_selector: "main",
|
||||
access: "read_only",
|
||||
},
|
||||
{
|
||||
repository_key: "main repo",
|
||||
ref_selector: "work/ticket",
|
||||
access: "read_write",
|
||||
},
|
||||
],
|
||||
} as TicketDetail;
|
||||
|
||||
assertEquals(
|
||||
ticketWorkerMessage(ticket.id, "coder"),
|
||||
"Work on Ticket 00001KYRRDVH9 as its coder.",
|
||||
);
|
||||
|
||||
const href = ticketWorkerLaunchHref("workspace one", ticket, "reviewer");
|
||||
const url = new URL(href, "https://example.test");
|
||||
assertEquals(url.pathname, "/w/workspace%20one/workers/new");
|
||||
assertEquals(url.searchParams.get("ticketId"), ticket.id);
|
||||
assertEquals(url.searchParams.get("ticketRole"), "reviewer");
|
||||
assertEquals(url.searchParams.get("repositoryKey"), "main repo");
|
||||
assertEquals(url.searchParams.get("refSelector"), "work/ticket");
|
||||
assertEquals(
|
||||
url.searchParams.get("initialInput"),
|
||||
"Work on Ticket 00001KYRRDVH9 as its reviewer.",
|
||||
);
|
||||
Deno.test("generic Ticket launch carries natural text without role or resource claims", () => {
|
||||
const requests = [
|
||||
"Review is unnecessary; complete the requested work.",
|
||||
"Review the change, then merge it if approved.",
|
||||
"Investigate and return without a conclusion.",
|
||||
];
|
||||
const targetSets: TicketDetail["targets"][] = [
|
||||
[],
|
||||
[{ repository_key: "docs", ref_selector: "main", access: "read_only" }],
|
||||
[{ repository_key: "repo", ref_selector: "main", access: "read_write" }],
|
||||
];
|
||||
assertEquals(ticketWorkerMessage("T-720"), "Work on Ticket T-720.");
|
||||
for (const targets of targetSets) {
|
||||
const ticket = { id: "T-720", title: "Flexible launch", targets };
|
||||
for (const request of requests) {
|
||||
const url = new URL(
|
||||
ticketWorkerLaunchHref("workspace one", ticket, request),
|
||||
"https://example.test",
|
||||
);
|
||||
assertEquals(url.pathname, "/w/workspace%20one/workers/new");
|
||||
assertEquals(url.searchParams.get("ticketId"), ticket.id);
|
||||
assertEquals(url.searchParams.get("ticketTitle"), ticket.title);
|
||||
assertEquals(url.searchParams.get("initialInput"), request);
|
||||
for (
|
||||
const field of [
|
||||
"ticketRole",
|
||||
"profile",
|
||||
"repositoryKey",
|
||||
"refSelector",
|
||||
"flow",
|
||||
]
|
||||
) {
|
||||
assertEquals(url.searchParams.has(field), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("ticket worker launch never selects a read_only or ambiguous write target", () => {
|
||||
const readOnly = {
|
||||
id: "ticket-read-only",
|
||||
title: "Read only",
|
||||
targets: [{
|
||||
repository_key: "docs",
|
||||
ref_selector: "main",
|
||||
access: "read_only",
|
||||
}],
|
||||
} as TicketDetail;
|
||||
const readOnlyUrl = new URL(
|
||||
ticketWorkerLaunchHref("workspace", readOnly, "coder"),
|
||||
"https://example.test",
|
||||
);
|
||||
assertEquals(readOnlyUrl.searchParams.has("repositoryKey"), false);
|
||||
assertEquals(readOnlyUrl.searchParams.has("refSelector"), false);
|
||||
|
||||
const ambiguous = {
|
||||
...readOnly,
|
||||
targets: [
|
||||
{ repository_key: "main", ref_selector: "develop", access: "read_write" },
|
||||
{ repository_key: "docs", ref_selector: "main", access: "read_write" },
|
||||
],
|
||||
} as TicketDetail;
|
||||
const ambiguousUrl = new URL(
|
||||
ticketWorkerLaunchHref("workspace", ambiguous, "coder"),
|
||||
"https://example.test",
|
||||
);
|
||||
assertEquals(ambiguousUrl.searchParams.has("repositoryKey"), false);
|
||||
assertEquals(ambiguousUrl.searchParams.has("refSelector"), false);
|
||||
});
|
||||
|
||||
Deno.test("ticket detail uses server-derived role assignment actions", async () => {
|
||||
Deno.test("ticket detail retains backend queue eligibility and generic Worker assignment actions", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||
@@ -162,11 +132,6 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
|
||||
|
||||
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
|
||||
assertEquals(source.includes("ticket.relations.blockers.length > 0"), true);
|
||||
assertEquals(
|
||||
source.includes("ticket.action_eligibility.queue_tickets"),
|
||||
true,
|
||||
);
|
||||
assertEquals(source.includes("This operation queues:"), true);
|
||||
assertEquals(source.includes("outcome.queued_tickets.join"), true);
|
||||
assertEquals(
|
||||
source.includes("resolve the listed blockers before Queue"),
|
||||
@@ -176,7 +141,14 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
|
||||
source.includes("ticket.action_eligibility.can_assign_orchestrator"),
|
||||
true,
|
||||
);
|
||||
assertEquals(source.includes("/assignments/${role}"), true);
|
||||
assertEquals(
|
||||
source.includes('mutateAssignment("start-manual", "worker"'),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
source.includes('mutateAssignment("start-manual", "reviewer"'),
|
||||
false,
|
||||
);
|
||||
assertEquals(source.includes('kind: "workspace_agent"'), true);
|
||||
assertEquals(source.includes('kind: "worker"'), true);
|
||||
assertEquals(source.includes("ticket.assignee"), false);
|
||||
@@ -188,10 +160,6 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
|
||||
source.includes("(repository.diagnostics ?? []).length > 0"),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
source.includes("unique registered repository targets"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Ticket authoring creates the target collection without singular authority", async () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ export const TICKET_STATES = [
|
||||
] as const;
|
||||
|
||||
export type TicketState = (typeof TICKET_STATES)[number];
|
||||
export type TicketWorkerRole = "coder" | "reviewer";
|
||||
|
||||
export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse;
|
||||
|
||||
@@ -98,36 +97,20 @@ export function ticketLanes(tickets: TicketCardSummary[]): TicketLane[] {
|
||||
}));
|
||||
}
|
||||
|
||||
export function ticketWorkerMessage(
|
||||
ticketId: string,
|
||||
role: TicketWorkerRole,
|
||||
): string {
|
||||
return `Work on Ticket ${ticketId} as its ${role}.`;
|
||||
export function ticketWorkerMessage(ticketId: string): string {
|
||||
return `Work on Ticket ${ticketId}.`;
|
||||
}
|
||||
|
||||
export function ticketWorkerLaunchHref(
|
||||
workspaceId: string,
|
||||
ticket: Pick<TicketDetail, "id" | "title" | "targets">,
|
||||
role: TicketWorkerRole,
|
||||
ticket: Pick<TicketDetail, "id" | "title">,
|
||||
request = ticketWorkerMessage(ticket.id),
|
||||
): string {
|
||||
const implementationTargets = ticket.targets.filter((target) =>
|
||||
target.access === "read_write"
|
||||
);
|
||||
const implementationTarget = implementationTargets.length === 1
|
||||
? implementationTargets[0]
|
||||
: null;
|
||||
const params = new URLSearchParams({
|
||||
ticketId: ticket.id,
|
||||
ticketTitle: ticket.title,
|
||||
ticketRole: role,
|
||||
initialInput: ticketWorkerMessage(ticket.id, role),
|
||||
initialInput: request,
|
||||
});
|
||||
if (implementationTarget) {
|
||||
params.set("repositoryKey", implementationTarget.repository_key);
|
||||
if (implementationTarget.ref_selector) {
|
||||
params.set("refSelector", implementationTarget.ref_selector);
|
||||
}
|
||||
}
|
||||
return `/w/${
|
||||
encodeURIComponent(workspaceId)
|
||||
}/workers/new?${params.toString()}`;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
buildCreateWorkspaceWorkerRequest,
|
||||
defaultWorkerLaunchForm,
|
||||
genericLaunchProfiles,
|
||||
workerLaunchOutcomeUnknown,
|
||||
} from "../sidebar/worker-launch.ts";
|
||||
import type { WorkerLaunchFormState } from "../sidebar/worker-launch.ts";
|
||||
import { emptyLaunchOptions } from "#lib/workspace/sidebar/worker-launch.test-fixtures.ts";
|
||||
import { load } from "../../../routes/w/[workspaceId]/workers/new/+page.ts";
|
||||
|
||||
declare const Deno: { test(name: string, fn: () => void): void };
|
||||
const form: WorkerLaunchFormState = {
|
||||
runtime_id: "embedded",
|
||||
display_name: "Worker",
|
||||
profile: "",
|
||||
subjektiv_subject_id: "",
|
||||
initial_text: "",
|
||||
workdir_attachments: [],
|
||||
working_directory_repository_key: "",
|
||||
working_directory_selector: "HEAD",
|
||||
};
|
||||
function equal(actual: unknown, expected: unknown) {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("generic Ticket request keeps conclusion and review instructions as natural text with no mandatory Flow or profile", () => {
|
||||
for (
|
||||
const text of [
|
||||
"Review is unnecessary.",
|
||||
"Review, then merge if approved.",
|
||||
"Return without a conclusion.",
|
||||
]
|
||||
) {
|
||||
const request = buildCreateWorkspaceWorkerRequest({
|
||||
...form,
|
||||
initial_text: text,
|
||||
ticket_assignment: { ticket_id: "T-720", operation_id: "launch-720" },
|
||||
});
|
||||
equal(request.ticket_assignment, {
|
||||
ticket_id: "T-720",
|
||||
operation_id: "launch-720",
|
||||
});
|
||||
equal(request.profile, null);
|
||||
equal(request.workdir_attachments, []);
|
||||
equal(request.initial_submit, [{ kind: "text", content: text }]);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("explicit Flow and resource selections remain separate from the Ticket request", () => {
|
||||
const request = buildCreateWorkspaceWorkerRequest({
|
||||
...form,
|
||||
runtime_id: "remote",
|
||||
profile: "project:analysis",
|
||||
initial_text: " Review is unnecessary.\nReturn without conclusion. ",
|
||||
flow: " project:investigate ",
|
||||
subjektiv_subject_id: "subject-1",
|
||||
ticket_assignment: { ticket_id: "T-720", operation_id: "launch-720" },
|
||||
workdir_attachments: [{
|
||||
alias: "sessions",
|
||||
working_directory_id: "read-only-grant",
|
||||
relative_cwd: "logs",
|
||||
}],
|
||||
});
|
||||
equal(request.initial_submit, [{
|
||||
kind: "text",
|
||||
content: " Review is unnecessary.\nReturn without conclusion. ",
|
||||
}, { kind: "flow", selector: "project:investigate" }]);
|
||||
equal(request.workdir_attachments, [{
|
||||
alias: "sessions",
|
||||
working_directory_id: "read-only-grant",
|
||||
relative_cwd: "logs",
|
||||
}]);
|
||||
equal(request.feature_connections, {
|
||||
subjektiv: { subject_id: "subject-1" },
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("launch defaults never claim repository or Workdir resources", () => {
|
||||
const options = emptyLaunchOptions("workspace");
|
||||
options.repositories = [{ repository_key: "repo", default_selector: "main" }];
|
||||
options.working_directories = [{
|
||||
working_directory_id: "wd-1",
|
||||
source: { kind: "repository", repository_key: "repo" },
|
||||
materializer_kind: "runtime_git_clone",
|
||||
status: "active",
|
||||
cleanliness: "clean",
|
||||
}];
|
||||
const result = defaultWorkerLaunchForm(options, {
|
||||
...form,
|
||||
runtime_id: "remote",
|
||||
});
|
||||
equal(result.profile, "");
|
||||
equal(result.workdir_attachments, []);
|
||||
equal(result.working_directory_repository_key, "");
|
||||
});
|
||||
|
||||
Deno.test("launch defaults preserve explicit attachments only on a supporting Runtime", () => {
|
||||
const options = emptyLaunchOptions("workspace");
|
||||
options.working_directories = [{
|
||||
working_directory_id: "read-only-grant",
|
||||
source: {
|
||||
kind: "external_grant",
|
||||
grant_id: "grant-1",
|
||||
grant_permissions: { read: true, write: false, command: false },
|
||||
},
|
||||
materializer_kind: "client_hosted_external",
|
||||
status: "active",
|
||||
cleanliness: "unknown",
|
||||
}];
|
||||
const attachments = [{
|
||||
alias: "sessions",
|
||||
working_directory_id: "read-only-grant",
|
||||
relative_cwd: "logs",
|
||||
}];
|
||||
for (const runtime of options.runtimes) {
|
||||
const result = defaultWorkerLaunchForm(options, {
|
||||
...form,
|
||||
runtime_id: runtime.runtime_id,
|
||||
workdir_attachments: attachments,
|
||||
});
|
||||
equal(
|
||||
result.workdir_attachments,
|
||||
runtime.supports_workdir_attachments ? attachments : [],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("generic launch cannot select a trusted Reviewer profile or infer it from URL roles", () => {
|
||||
const options = emptyLaunchOptions("workspace");
|
||||
options.profiles.push({
|
||||
...options.profiles[0],
|
||||
id: "builtin:reviewer",
|
||||
label: "Reviewer",
|
||||
});
|
||||
equal(genericLaunchProfiles(options.profiles).map((item) => item.id), [
|
||||
"builtin:companion",
|
||||
]);
|
||||
equal(
|
||||
defaultWorkerLaunchForm(options, { ...form, profile: "builtin:reviewer" })
|
||||
.profile,
|
||||
"",
|
||||
);
|
||||
const data = load({
|
||||
params: { workspaceId: "workspace" },
|
||||
url: new URL(
|
||||
"https://example.test/workers/new?ticketId=T-720&ticketRole=reviewer&profile=builtin:reviewer&repositoryKey=repo",
|
||||
),
|
||||
});
|
||||
equal(data.ticketContext, {
|
||||
ticketId: "T-720",
|
||||
ticketTitle: "T-720",
|
||||
initialInput: "Work on Ticket T-720.",
|
||||
});
|
||||
let rejected = false;
|
||||
try {
|
||||
buildCreateWorkspaceWorkerRequest({ ...form, profile: "builtin:reviewer" });
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
equal(rejected, true);
|
||||
});
|
||||
|
||||
Deno.test("OutcomeUnknown is distinguished from a definite permission rejection", () => {
|
||||
for (const code of ["OutcomeUnknown", "worker_spawn_outcome_unknown"]) {
|
||||
equal(
|
||||
workerLaunchOutcomeUnknown({
|
||||
error: "runtime_operation_failed",
|
||||
message: "Uncertain launch",
|
||||
diagnostics: [{ code, message: "Reconcile", severity: "error" }],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
equal(
|
||||
workerLaunchOutcomeUnknown({
|
||||
error: "permission_denied",
|
||||
message: "Read only",
|
||||
diagnostics: [],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -215,11 +215,7 @@
|
||||
<div class="workspace-page ticket-panel-page">
|
||||
<header class="workspace-page-header ticket-panel-header">
|
||||
<div>
|
||||
<p class="workspace-eyebrow">Delivery</p>
|
||||
<h1>Tickets</h1>
|
||||
<p class="workspace-page-lede">
|
||||
Plan, route, review, and close work without leaving the workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div class="ticket-panel-controls">
|
||||
<button
|
||||
@@ -235,7 +231,7 @@
|
||||
</div>
|
||||
{#if !orchestrator.data?.online}
|
||||
<button
|
||||
class="workspace-primary-button"
|
||||
class="workspace-secondary-button"
|
||||
type="button"
|
||||
disabled={orchestratorStarting}
|
||||
onclick={startOrchestrator}
|
||||
@@ -261,7 +257,7 @@
|
||||
<label>Title<input bind:value={createTitle} required /></label>
|
||||
<label>Body<textarea bind:value={createBody} rows="8"></textarea></label>
|
||||
<div class="ticket-target-list">
|
||||
<strong>Repository targets</strong>
|
||||
<strong>Repository resources (optional)</strong>
|
||||
{#each createTargets as target, index}
|
||||
{@const selectedRepository = repositories.find((repository) => repository.repository_key === target.repository_key)}
|
||||
<fieldset class="ticket-target-row">
|
||||
@@ -287,7 +283,7 @@
|
||||
<button class="workspace-secondary-button" type="button" onclick={addCreateTarget}>Add target</button>
|
||||
</div>
|
||||
{#if !createTargetsSavable}
|
||||
<p class="workspace-empty-copy">Every target row must select a different repository. The exact one read-write target and selector requirements are enforced when the Ticket is marked ready.</p>
|
||||
<p class="workspace-empty-copy">Each optional resource row must select a different registered repository. Empty or read-only resources are allowed.</p>
|
||||
{/if}
|
||||
<button class="workspace-primary-button" type="submit" disabled={createBusy || !createTitle.trim() || !createTargetsSavable}>
|
||||
{createBusy ? "Creating…" : "Create Ticket"}
|
||||
@@ -301,7 +297,7 @@
|
||||
</p>
|
||||
{:else if !orchestrator.data?.online}
|
||||
<p class="workspace-callout">
|
||||
Orchestration actions are unavailable until the embedded Orchestrator is online.
|
||||
Orchestrator is offline. Requests can be queued; progress and results can still be recorded directly.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import {
|
||||
relationLabel,
|
||||
TICKET_STATES,
|
||||
ticketWorkerLaunchHref,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import type { ApiResult } from "#lib/workspace/api/http.ts";
|
||||
@@ -36,9 +37,7 @@
|
||||
access: TicketTargetAccess;
|
||||
};
|
||||
|
||||
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
|
||||
state !== "done" && state !== "ready" && state !== "queued"
|
||||
);
|
||||
const MUTABLE_TICKET_STATES = TICKET_STATES;
|
||||
|
||||
function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] {
|
||||
return targets.map((target) => ({
|
||||
@@ -81,6 +80,7 @@
|
||||
);
|
||||
let nextState = $state(loadedTicket.state);
|
||||
let transitionReason = $state("");
|
||||
let progressBody = $state("");
|
||||
let threadRole = $state("comment");
|
||||
let threadBody = $state("");
|
||||
let resolution = $state("");
|
||||
@@ -90,11 +90,12 @@
|
||||
let readyOperationKey = $state<string | null>(null);
|
||||
let manualRuntimeId = $state("");
|
||||
let manualWorkerId = $state("");
|
||||
let manualBindings = $state<{ alias: string; working_directory_id: string; connection_id: string }[]>([]);
|
||||
let cancellationReason = $state("");
|
||||
let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`;
|
||||
let routeGeneration = 0;
|
||||
const coderAssignment = $derived(
|
||||
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
||||
const workerAssignment = $derived(
|
||||
ticket.assignments.find((assignment) => assignment.role === "worker") ?? null,
|
||||
);
|
||||
function repositoryFor(repositoryKey: string): RepositorySummary | null {
|
||||
return (loadedRepositories?.items ?? []).find((repository: RepositorySummary) =>
|
||||
@@ -108,23 +109,22 @@
|
||||
}
|
||||
|
||||
const targetCandidateValid = $derived.by(() => {
|
||||
if (ticket.state !== "planning" || targetDrafts.length === 0) return false;
|
||||
|
||||
const repositoryKeys = new Set<string>();
|
||||
let readWriteCount = 0;
|
||||
|
||||
for (const target of targetDrafts) {
|
||||
const repository = repositoryFor(target.repository_key);
|
||||
if (
|
||||
!target.repository_key || repository === null ||
|
||||
!effectiveRefSelector(target) ||
|
||||
repositoryKeys.has(target.repository_key)
|
||||
) return false;
|
||||
repositoryKeys.add(target.repository_key);
|
||||
if (target.access === "read_write") readWriteCount += 1;
|
||||
|
||||
}
|
||||
return readWriteCount === 1;
|
||||
return true;
|
||||
});
|
||||
const implementationStartEligible = $derived(
|
||||
ticket.action_eligibility.can_start_manual_coder,
|
||||
ticket.action_eligibility.can_start_manual_worker,
|
||||
);
|
||||
|
||||
const ticketPath = $derived(
|
||||
@@ -146,6 +146,7 @@
|
||||
applyTicket(updatedTicket);
|
||||
editing = false;
|
||||
transitionReason = "";
|
||||
progressBody = "";
|
||||
threadRole = "comment";
|
||||
threadBody = "";
|
||||
resolution = "";
|
||||
@@ -155,6 +156,7 @@
|
||||
readyOperationKey = null;
|
||||
manualRuntimeId = "";
|
||||
manualWorkerId = "";
|
||||
manualBindings = [];
|
||||
cancellationReason = "";
|
||||
}
|
||||
|
||||
@@ -172,6 +174,14 @@
|
||||
});
|
||||
});
|
||||
|
||||
function ticketMutationError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/\(409\)/.test(message)) return `Update conflict. Refresh the Ticket and resolve the current revision or resource binding before retrying. ${message}`;
|
||||
if (/\(401\)|\(403\)/.test(message)) return `Permission denied. This action did not grant access. ${message}`;
|
||||
if (/\(404\)/.test(message)) return `Resource unavailable or not connected. Check the Ticket and authorized resource connection. ${message}`;
|
||||
return `Outcome unknown or request rejected. Refresh before retrying; no success is inferred. ${message}`;
|
||||
}
|
||||
|
||||
async function mutate(
|
||||
action: string,
|
||||
suffix: string,
|
||||
@@ -193,7 +203,7 @@
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (generation === routeGeneration) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
errorMessage = ticketMutationError(error);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
@@ -201,6 +211,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTicket(): Promise<void> {
|
||||
if (busy) return;
|
||||
const generation = routeGeneration;
|
||||
busy = "refresh";
|
||||
try {
|
||||
const updated = await workspaceApiJson(ticketPath, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
if (generation !== routeGeneration) return;
|
||||
applyTicket(updated);
|
||||
errorMessage = null;
|
||||
} catch (error) {
|
||||
if (generation === routeGeneration) errorMessage = ticketMutationError(error);
|
||||
} finally {
|
||||
if (generation === routeGeneration) busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function queueTicket(): Promise<void> {
|
||||
if (busy) return;
|
||||
const generation = routeGeneration;
|
||||
@@ -226,7 +252,7 @@
|
||||
applyTicket(updatedTicket);
|
||||
} catch (error) {
|
||||
if (generation === routeGeneration) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
errorMessage = ticketMutationError(error);
|
||||
}
|
||||
} finally {
|
||||
if (generation === routeGeneration) busy = null;
|
||||
@@ -235,7 +261,7 @@
|
||||
|
||||
async function mutateAssignment(
|
||||
action: string,
|
||||
role: "orchestrator" | "coder",
|
||||
role: "orchestrator" | "worker",
|
||||
principal: Record<string, string>,
|
||||
): Promise<void> {
|
||||
if (busy) return;
|
||||
@@ -252,6 +278,7 @@
|
||||
operation_id: crypto.randomUUID(),
|
||||
principal,
|
||||
expected_assignment_id: null,
|
||||
...(role === "worker" ? { workdir_bindings: manualBindings } : {}),
|
||||
}),
|
||||
},
|
||||
parseTicketRoleAssignmentMutationResponse,
|
||||
@@ -267,7 +294,7 @@
|
||||
applyTicket(updatedTicket);
|
||||
} catch (error) {
|
||||
if (generation === routeGeneration) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
errorMessage = ticketMutationError(error);
|
||||
}
|
||||
} finally {
|
||||
if (generation === routeGeneration) busy = null;
|
||||
@@ -281,10 +308,10 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function startManualCoder(event: SubmitEvent): Promise<void> {
|
||||
async function assignExistingWorker(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!manualRuntimeId.trim() || !manualWorkerId.trim()) return;
|
||||
await mutateAssignment("start-manual", "coder", {
|
||||
await mutateAssignment("start-manual", "worker", {
|
||||
kind: "worker",
|
||||
runtime_id: manualRuntimeId.trim(),
|
||||
worker_id: manualWorkerId.trim(),
|
||||
@@ -293,11 +320,11 @@
|
||||
|
||||
async function cancelImplementation(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!coderAssignment || !cancellationReason.trim()) return;
|
||||
if (!workerAssignment || !cancellationReason.trim()) return;
|
||||
if (
|
||||
await mutate("cancel-implementation", "/implementation-cancellations", {
|
||||
operation_id: crypto.randomUUID(),
|
||||
assignment_id: coderAssignment.assignment_id,
|
||||
assignment_id: workerAssignment.assignment_id,
|
||||
reason: cancellationReason.trim(),
|
||||
})
|
||||
) cancellationReason = "";
|
||||
@@ -314,12 +341,10 @@
|
||||
}
|
||||
|
||||
function addTarget(access: TicketTargetAccess = "read_only"): void {
|
||||
if (ticket.state !== "planning") return;
|
||||
targetDrafts.push({ repository_key: "", ref_selector: "", access });
|
||||
}
|
||||
|
||||
function removeTarget(index: number): void {
|
||||
if (ticket.state !== "planning") return;
|
||||
targetDrafts.splice(index, 1);
|
||||
}
|
||||
|
||||
@@ -338,12 +363,7 @@
|
||||
}
|
||||
|
||||
async function markReady() {
|
||||
if (!targetCandidateValid || busy) return;
|
||||
const targets = normalizedTargets(targetDrafts);
|
||||
if (JSON.stringify(ticket.targets) !== JSON.stringify(targets)) {
|
||||
const saved = await mutate("target", "", targetEditBody(), "PATCH");
|
||||
if (!saved) return;
|
||||
}
|
||||
if (ticket.state !== "planning" || busy) return;
|
||||
readyOperationKey ??= crypto.randomUUID();
|
||||
if (
|
||||
await mutate("ready", "/ready", {
|
||||
@@ -364,9 +384,10 @@
|
||||
operation_key: crypto.randomUUID(),
|
||||
expected_item_revision: ticket.item_revision,
|
||||
expected_state: ticket.state,
|
||||
reason: transitionReason.trim() || "Progress decision from the Ticket page",
|
||||
reason: transitionReason.trim(),
|
||||
body: progressBody.trim() || null,
|
||||
})
|
||||
) transitionReason = "";
|
||||
) { transitionReason = ""; progressBody = ""; }
|
||||
}
|
||||
|
||||
async function appendThread(event: SubmitEvent) {
|
||||
@@ -421,7 +442,7 @@
|
||||
</header>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="workspace-callout is-error" role="alert">{errorMessage}</div>
|
||||
<div class="workspace-callout is-error" role="alert"><p>{errorMessage}</p><p>No success or approval is inferred. An uncertain response may already have applied; refresh the current Ticket before retrying.</p><button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={refreshTicket}>Refresh Ticket status</button></div>
|
||||
{/if}
|
||||
|
||||
{#if queueMessage}
|
||||
@@ -432,7 +453,7 @@
|
||||
<form class="ticket-editor" onsubmit={saveEdit}>
|
||||
<label>Title<input bind:value={editTitle} required /></label>
|
||||
<label>Body<textarea bind:value={editBody} rows="12"></textarea></label>
|
||||
<button class="workspace-primary-button" type="submit" disabled={busy === "edit" || !editTitle.trim()}>
|
||||
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
|
||||
{busy === "edit" ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
</form>
|
||||
@@ -515,8 +536,9 @@
|
||||
</header>
|
||||
{#if event.author}<p class="ticket-event-author">{event.author}</p>{/if}
|
||||
{#if event.from || event.to}<p>{event.from ?? "—"} → {event.to ?? "—"}</p>{/if}
|
||||
{#if event.reason}<p>{event.reason}</p>{/if}
|
||||
{#if event.reason}<RichMarkdown text={event.reason} />{/if}
|
||||
{#if event.body}<RichMarkdown text={event.body} />{/if}
|
||||
{#each event.references as reference}<p>{reference}</p>{/each}
|
||||
</div>
|
||||
</article>
|
||||
{:else}
|
||||
@@ -527,6 +549,29 @@
|
||||
</main>
|
||||
|
||||
<aside class="ticket-control-rail">
|
||||
<section class="ticket-control-card">
|
||||
<header><h2>Progress decision</h2></header>
|
||||
<p class="workspace-empty-copy">State records progress only. It does not start or stop a Worker, grant access, approve review, or merge code. Reopening does not resume an old Worker.</p>
|
||||
<form class="ticket-control-form" onsubmit={transition}>
|
||||
<label>State
|
||||
<select bind:value={nextState}>
|
||||
{#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>Reason<input bind:value={transitionReason} placeholder="Completion, start, or reopening decision" required /></label>
|
||||
<label>Result, references, and remaining work (optional)<textarea bind:value={progressBody} rows="3" placeholder="A comment can be the result. Add links and remaining questions here."></textarea></label>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || nextState === ticket.state || !transitionReason.trim()}>
|
||||
{nextState === "done" ? "Complete Ticket" : ticket.state === "done" || ticket.state === "closed" ? "Reopen / apply state" : "Apply state"}
|
||||
</button>
|
||||
</form>
|
||||
{#if ticket.state === "planning"}
|
||||
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Marking ready…" : "Mark ready"}</button>
|
||||
{/if}
|
||||
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>{busy === "queue" ? "Queueing…" : "Request Orchestrator (queue)"}</button>
|
||||
<p class="workspace-empty-copy">Queue requests only this Ticket. Dependencies remain diagnostic information and are not automatically queued.</p>
|
||||
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
|
||||
</section>
|
||||
|
||||
<section class="ticket-control-card ticket-worker-card">
|
||||
<header><h2>Role assignments</h2><span>Retained responsibility</span></header>
|
||||
<p class="workspace-empty-copy">Responsibility is retained after work ends and Worker removal; it does not indicate a running Worker or active work authority.</p>
|
||||
@@ -561,24 +606,37 @@
|
||||
</button>
|
||||
{/if}
|
||||
{#if implementationStartEligible}
|
||||
<form class="ticket-control-form" onsubmit={startManualCoder}>
|
||||
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket)}>Start a Ticket Worker</a>
|
||||
<details><summary>Assign an existing Worker</summary>
|
||||
<p class="workspace-empty-copy">Explicit assignment is separate from state. Backend validates the Worker and resource binding; a retained assignment is not execution authority.</p>
|
||||
<form class="ticket-control-form" onsubmit={assignExistingWorker}>
|
||||
<label>Runtime ID<input bind:value={manualRuntimeId} required /></label>
|
||||
<label>Worker ID<input bind:value={manualWorkerId} required /></label>
|
||||
<p class="workspace-empty-copy">Select only resources needed for this request. No selection binds no Workdirs; unrelated existing attachments are not inherited. Use authorized connection IDs for explicit rebind.</p>
|
||||
{#each manualBindings as binding, index}
|
||||
<fieldset class="ticket-target-row"><legend>Resource binding {index + 1}</legend>
|
||||
<label>Alias<input bind:value={binding.alias} required /></label>
|
||||
<label>Workdir ID<input bind:value={binding.working_directory_id} required /></label>
|
||||
<label>Connection ID<input bind:value={binding.connection_id} required /></label>
|
||||
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={() => manualBindings.splice(index, 1)}>Remove binding</button>
|
||||
</fieldset>
|
||||
{/each}
|
||||
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={() => manualBindings.push({ alias: "", working_directory_id: "", connection_id: "" })}>Add resource binding</button>
|
||||
<button
|
||||
class="workspace-secondary-button"
|
||||
type="submit"
|
||||
disabled={busy !== null || !manualRuntimeId.trim() || !manualWorkerId.trim()}
|
||||
>
|
||||
{busy === "start-manual" ? "Starting…" : "Assign Coder and start"}
|
||||
{busy === "start-manual" ? "Assigning…" : "Assign Worker"}
|
||||
</button>
|
||||
</form>
|
||||
</form></details>
|
||||
{/if}
|
||||
{#if ticket.state === "inprogress" && coderAssignment}
|
||||
{#if ticket.state === "inprogress" && workerAssignment}
|
||||
<details class="ticket-cancel-implementation">
|
||||
<summary>Cancel implementation</summary>
|
||||
<form class="ticket-control-form" onsubmit={cancelImplementation}>
|
||||
<p class="workspace-empty-copy">
|
||||
Cancel the assigned Coder, remove its assignment, and return this Ticket to ready.
|
||||
Cancel the assigned Worker’s unfinished work and return this Ticket to ready. Responsibility is retained. This does not delete the Worker or Workdir.
|
||||
</p>
|
||||
<label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label>
|
||||
<button
|
||||
@@ -599,7 +657,9 @@
|
||||
</section>
|
||||
|
||||
<section class="ticket-control-card">
|
||||
<header><h2>Repository targets</h2><span>{targetDrafts.length}</span></header>
|
||||
<header><h2>Repository resources (optional)</h2><span>{targetDrafts.length}</span></header>
|
||||
<p class="workspace-empty-copy">Targets describe permitted resources, not connected attachments. Editing targets or the body does not expand live permissions. Changed resources require explicit reconnection; stale or unavailable connections are rejected by Backend.</p>
|
||||
{#if data.repositories.error}<p class="workspace-callout is-error">Repository catalog unavailable: {data.repositories.error}</p>{/if}
|
||||
<form class="ticket-control-form" onsubmit={saveTarget}>
|
||||
<div class="ticket-target-list">
|
||||
{#each targetDrafts as target, index}
|
||||
@@ -607,78 +667,41 @@
|
||||
<fieldset class="ticket-target-row">
|
||||
<legend>Target {index + 1}</legend>
|
||||
<label>Repository
|
||||
<select bind:value={target.repository_key} disabled={ticket.state !== "planning"} required>
|
||||
<select bind:value={target.repository_key} disabled={busy !== null} required>
|
||||
<option value="">Choose repository</option>
|
||||
{#each loadedRepositories?.items ?? [] as repository}
|
||||
<option value={repository.repository_key}>{repository.repository_key}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or revision"} disabled={ticket.state !== "planning"} /></label>
|
||||
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or revision"} disabled={busy !== null} /></label>
|
||||
<label>Access
|
||||
<select bind:value={target.access} disabled={ticket.state !== "planning"}>
|
||||
<select bind:value={target.access} disabled={busy !== null}>
|
||||
<option value="read_write">Read and write</option>
|
||||
<option value="read_only">Read only</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if ticket.state === "planning"}
|
||||
<button class="workspace-secondary-button" type="button" onclick={() => removeTarget(index)}>Remove target</button>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{:else}
|
||||
<p class="workspace-empty-copy">No repository targets.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{#if ticket.state === "planning"}
|
||||
<button
|
||||
class="workspace-secondary-button"
|
||||
type="button"
|
||||
onclick={() => addTarget(targetDrafts.some((target) => target.access === "read_write") ? "read_only" : "read_write")}
|
||||
onclick={() => addTarget()}
|
||||
>Add target</button>
|
||||
{/if}
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy === "target" || ticket.state !== "planning"}>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !targetCandidateValid}>
|
||||
{busy === "target" ? "Saving…" : "Save targets"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="ticket-control-card">
|
||||
<header><h2>Workflow</h2></header>
|
||||
<form class="ticket-control-form" onsubmit={transition}>
|
||||
<label>State
|
||||
<select bind:value={nextState}>
|
||||
{#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>Reason<input bind:value={transitionReason} placeholder="Optional decision context" /></label>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy === "state" || nextState === ticket.state}>
|
||||
Apply state
|
||||
</button>
|
||||
</form>
|
||||
{#if ticket.state === "planning"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy !== null || !targetCandidateValid} onclick={markReady}>
|
||||
{busy === "ready" ? "Marking ready…" : "Mark ready"}
|
||||
</button>
|
||||
{#if !targetCandidateValid}
|
||||
<p class="workspace-empty-copy">Add unique registered repository targets with effective ref selectors and exactly one read-write target before marking ready.</p>
|
||||
{/if}
|
||||
{:else if ticket.state === "ready"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>
|
||||
{busy === "queue" ? "Queueing…" : `Queue ${ticket.action_eligibility.queue_tickets.length} Ticket(s)`}
|
||||
</button>
|
||||
{#if !ticket.action_eligibility.can_queue}
|
||||
<p class="workspace-empty-copy">Queue requires a valid target, an active Orchestrator assignment, no active Coder assignment, and no dependency still in planning.</p>
|
||||
{:else if ticket.action_eligibility.queue_tickets.length > 0}
|
||||
<p class="workspace-empty-copy">This operation queues: {ticket.action_eligibility.queue_tickets.join(", ")}.</p>
|
||||
{#if ticket.relations.blockers.length > 0}
|
||||
<p class="workspace-empty-copy">Ready dependencies are queued atomically. Queued or in-progress dependencies remain unchanged for the Orchestrator to schedule.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
<details class="ticket-control-card">
|
||||
<summary>Append timeline event</summary>
|
||||
<summary>Record result or comment</summary>
|
||||
<form class="ticket-control-form" onsubmit={appendThread}>
|
||||
<label>Role<select bind:value={threadRole}>
|
||||
<option value="comment">Comment</option>
|
||||
@@ -687,12 +710,13 @@
|
||||
<option value="implementation_report">Implementation report</option>
|
||||
</select></label>
|
||||
<label>Body<textarea bind:value={threadBody} rows="5" required></textarea></label>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy === "thread" || !threadBody.trim()}>Append event</button>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !threadBody.trim()}>Append event</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{#if mergeRequests.length > 0}
|
||||
<section class="ticket-control-card">
|
||||
<header><h2>Current requirement evidence</h2></header>
|
||||
<header><h2>MR requirement evidence</h2></header>
|
||||
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
|
||||
<p><strong>Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}.</p>
|
||||
{#if ticket.evidence.missing.length > 0}
|
||||
@@ -700,14 +724,16 @@
|
||||
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="workspace-empty-copy">This evidence evaluates current Ticket requirements, separately from persisted integration approval.</p>
|
||||
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from Ticket progress and persisted integration approval.</p>
|
||||
{#if ticket.state === "done"}
|
||||
<p class="workspace-empty-copy">This Ticket remains done. Incomplete current requirement evidence does not cancel recorded completion.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{/if}
|
||||
<section class="ticket-control-card">
|
||||
<header><h2>Merge Requests</h2></header>
|
||||
<p class="workspace-empty-copy">Ticket completion is not MR approval or integration.</p>
|
||||
{#if mergeRequests.length > 0}
|
||||
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
|
||||
<article class="ticket-control-card">
|
||||
@@ -733,7 +759,7 @@
|
||||
</article>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
|
||||
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be completed with a comment or document result.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -742,7 +768,7 @@
|
||||
<summary>Close ticket</summary>
|
||||
<form class="ticket-control-form" onsubmit={closeTicket}>
|
||||
<label>Resolution<textarea bind:value={resolution} rows="5" required></textarea></label>
|
||||
<button class="workspace-danger-button" type="submit" disabled={busy === "close" || !resolution.trim()}>Close ticket</button>
|
||||
<button class="workspace-danger-button" type="submit" disabled={busy !== null || !resolution.trim()}>Close ticket</button>
|
||||
</form>
|
||||
</details>
|
||||
{:else if ticket.resolution}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
buildCreateWorkspaceWorkerRequest,
|
||||
defaultWorkerLaunchForm,
|
||||
workerLaunchAttachmentError,
|
||||
genericLaunchProfiles,
|
||||
workerLaunchOutcomeUnknown,
|
||||
} from '#lib/workspace/sidebar/worker-launch.ts';
|
||||
import type { WorkerLaunchAttachmentFormState } from '#lib/workspace/sidebar/worker-launch.ts';
|
||||
import type {
|
||||
@@ -58,19 +60,11 @@
|
||||
let optionsError = $state<string | null>(null);
|
||||
let submitting = $state(false);
|
||||
let submitError = $state<DisplayError | null>(null);
|
||||
let displayName = $state(
|
||||
ticketContext
|
||||
? `${ticketContext.ticketTitle} · ${ticketContext.ticketRole || 'Worker'}`
|
||||
: 'Worker',
|
||||
);
|
||||
let outcomeUnknown = $state(false);
|
||||
let displayName = $state(ticketContext?.ticketTitle ?? 'Worker');
|
||||
let runtimeId = $state('');
|
||||
let profile = $state(
|
||||
ticketContext
|
||||
? ticketContext.ticketRole === 'reviewer'
|
||||
? 'builtin:reviewer'
|
||||
: 'builtin:coder'
|
||||
: '',
|
||||
);
|
||||
let profile = $state('');
|
||||
let flow = $state('');
|
||||
let subjektivSubjectId = $state('');
|
||||
let subjects = $state<SubjektivSubjectResponse[]>([]);
|
||||
let subjectsLoading = $state(false);
|
||||
@@ -82,24 +76,25 @@
|
||||
let initialText = $state(ticketContext?.initialInput ?? '');
|
||||
let workdirAttachments = $state<WorkerLaunchAttachmentFormState[]>([]);
|
||||
let workingDirectoryDisplayName = $state('');
|
||||
let workingDirectoryRepositoryKey = $state(ticketContext?.repositoryKey ?? '');
|
||||
let workingDirectorySelector = $state(ticketContext?.refSelector ?? 'HEAD');
|
||||
let workingDirectoryRepositoryKey = $state('');
|
||||
let workingDirectorySelector = $state('HEAD');
|
||||
let creatingWorkingDirectory = $state(false);
|
||||
let newWorkingDirectoryAttachmentIndex = $derived(
|
||||
workdirAttachments.findIndex((attachment) => attachment.working_directory_id === NEW_WORKING_DIRECTORY_VALUE),
|
||||
);
|
||||
let isNewWorkingDirectorySelected = $derived(newWorkingDirectoryAttachmentIndex >= 0);
|
||||
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
|
||||
let selectedProfile = $derived(options?.profiles.find((candidate) => candidate.id === profile));
|
||||
let availableProfiles = $derived(genericLaunchProfiles(options?.profiles ?? []));
|
||||
let selectedProfile = $derived(availableProfiles.find((candidate) => candidate.id === (profile || options?.default_profile)));
|
||||
let subjektivConnectionAvailable = $derived(selectedProfile?.feature_connections.subjektiv === true);
|
||||
let activeSubjects = $derived(subjects.filter((subject) => subject.state === 'active'));
|
||||
let selectedSubject = $derived(activeSubjects.find((subject) => subject.id === subjektivSubjectId));
|
||||
let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
|
||||
let selectedRuntimeForbidsWorkdirAttachments = $derived(selectedRuntime?.supports_workdir_attachments === false);
|
||||
let availableWorkingDirectories = $derived(
|
||||
selectedRuntimeAllowsNoWorkdir
|
||||
selectedRuntimeForbidsWorkdirAttachments
|
||||
? []
|
||||
: (options?.working_directories ?? []).filter((directory) =>
|
||||
directory.status === 'active' &&
|
||||
directory.status === 'active' && directory.source.kind !== 'workspace_config' &&
|
||||
(directory.source.kind === 'external_grant' || directory.cleanliness === 'clean') &&
|
||||
directory.occupied_by == null
|
||||
),
|
||||
@@ -110,11 +105,9 @@
|
||||
: workerLaunchAttachmentError(workdirAttachments),
|
||||
);
|
||||
let canStartWorker = $derived(Boolean(
|
||||
runtimeId &&
|
||||
profile &&
|
||||
(selectedRuntimeAllowsNoWorkdir
|
||||
? workdirAttachments.length === 0
|
||||
: workdirAttachments.length > 0 && !attachmentValidationError),
|
||||
runtimeId && selectedRuntime?.worker_creation_available && !outcomeUnknown &&
|
||||
!attachmentValidationError &&
|
||||
(!selectedRuntimeForbidsWorkdirAttachments || workdirAttachments.length === 0),
|
||||
));
|
||||
|
||||
function workerApiPath(path: string): string {
|
||||
@@ -133,14 +126,8 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedRuntimeAllowsNoWorkdir && workdirAttachments.length > 0) {
|
||||
if (selectedRuntimeForbidsWorkdirAttachments && workdirAttachments.length > 0) {
|
||||
workdirAttachments = [];
|
||||
} else if (selectedRuntime?.working_directory_required === true && workdirAttachments.length === 0) {
|
||||
workdirAttachments = [{
|
||||
alias: 'workdir',
|
||||
working_directory_id: availableWorkingDirectories[0]?.working_directory_id ?? '',
|
||||
relative_cwd: '',
|
||||
}];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -163,15 +150,9 @@
|
||||
}
|
||||
|
||||
function addAttachment(): void {
|
||||
const selectedWorkdirs = new Set(
|
||||
workdirAttachments.map((attachment) => attachment.working_directory_id),
|
||||
);
|
||||
const available = availableWorkingDirectories.find((directory) =>
|
||||
!selectedWorkdirs.has(directory.working_directory_id)
|
||||
);
|
||||
workdirAttachments = [...workdirAttachments, {
|
||||
alias: nextAttachmentAlias(),
|
||||
working_directory_id: available?.working_directory_id ?? '',
|
||||
working_directory_id: '',
|
||||
relative_cwd: '',
|
||||
}];
|
||||
}
|
||||
@@ -212,21 +193,7 @@
|
||||
displayName = form.display_name;
|
||||
profile = form.profile;
|
||||
subjektivSubjectId = form.subjektiv_subject_id;
|
||||
const runtimeRequiresWorkdir = payload.runtimes.find((runtime) =>
|
||||
runtime.runtime_id === form.runtime_id
|
||||
)?.working_directory_required !== false;
|
||||
workdirAttachments = form.workdir_attachments;
|
||||
if (
|
||||
runtimeRequiresWorkdir &&
|
||||
ticketContext?.repositoryKey &&
|
||||
workdirAttachments.every((attachment) => !attachment.working_directory_id)
|
||||
) {
|
||||
workdirAttachments = [{
|
||||
alias: 'workdir',
|
||||
working_directory_id: NEW_WORKING_DIRECTORY_VALUE,
|
||||
relative_cwd: '',
|
||||
}];
|
||||
}
|
||||
workingDirectoryRepositoryKey = form.working_directory_repository_key;
|
||||
workingDirectorySelector = form.working_directory_selector;
|
||||
} catch (err) {
|
||||
@@ -290,8 +257,8 @@
|
||||
submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (selectedRuntimeAllowsNoWorkdir) {
|
||||
submitError = { message: 'embedded Runtime does not create workdirs', diagnostics: [] };
|
||||
if (selectedRuntimeForbidsWorkdirAttachments) {
|
||||
submitError = { message: 'the selected Runtime does not support Workdir attachments', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (!workingDirectoryRepositoryKey) {
|
||||
@@ -342,18 +309,15 @@
|
||||
}
|
||||
|
||||
async function createWorker() {
|
||||
if (submitting || outcomeUnknown) return;
|
||||
if (!workspaceId) {
|
||||
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (selectedRuntimeAllowsNoWorkdir && workdirAttachments.length > 0) {
|
||||
if (selectedRuntimeForbidsWorkdirAttachments && workdirAttachments.length > 0) {
|
||||
submitError = { message: 'the selected Runtime must start without Workdir attachments', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (!selectedRuntimeAllowsNoWorkdir && workdirAttachments.length === 0) {
|
||||
submitError = { message: 'add at least one Workdir attachment before starting a Worker; only embedded Runtime can start without one', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (attachmentValidationError) {
|
||||
submitError = { message: attachmentValidationError, diagnostics: [] };
|
||||
return;
|
||||
@@ -361,23 +325,31 @@
|
||||
|
||||
submitError = null;
|
||||
submitting = true;
|
||||
let dispatched = false;
|
||||
try {
|
||||
const request = buildCreateWorkspaceWorkerRequest({
|
||||
runtime_id: runtimeId,
|
||||
display_name: displayName,
|
||||
profile,
|
||||
flow,
|
||||
ticket_assignment: ticketContext
|
||||
? { ticket_id: ticketContext.ticketId, operation_id: crypto.randomUUID() }
|
||||
: null,
|
||||
subjektiv_subject_id: subjektivSubjectId,
|
||||
initial_text: initialText,
|
||||
workdir_attachments: workdirAttachments,
|
||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||
working_directory_selector: workingDirectorySelector,
|
||||
});
|
||||
dispatched = true;
|
||||
const response = await fetch(workerApiPath('/workers'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(buildCreateWorkspaceWorkerRequest({
|
||||
runtime_id: runtimeId,
|
||||
display_name: displayName,
|
||||
profile,
|
||||
subjektiv_subject_id: subjektivSubjectId,
|
||||
initial_text: initialText,
|
||||
workdir_attachments: workdirAttachments,
|
||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||
working_directory_selector: workingDirectorySelector,
|
||||
})),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!response.ok) {
|
||||
submitError = await responseDisplayError(response, 'worker create failed');
|
||||
outcomeUnknown = workerLaunchOutcomeUnknown({ error: submitError.message, message: '', diagnostics: submitError.diagnostics });
|
||||
return;
|
||||
}
|
||||
const payload = parseBrowserCreateWorkerResponse(
|
||||
@@ -385,6 +357,7 @@
|
||||
);
|
||||
await goto(payload.console_href);
|
||||
} catch (err) {
|
||||
outcomeUnknown = dispatched;
|
||||
submitError = exceptionDisplayError(err, 'worker create failed');
|
||||
} finally {
|
||||
submitting = false;
|
||||
@@ -422,14 +395,14 @@
|
||||
<header class="worker-new-page-header">
|
||||
<div>
|
||||
<h1 id="new-worker-heading">New Worker</h1>
|
||||
<p>Create a Worker on a selected Runtime. Workdir-less conversation Workers are only available on embedded Runtime.</p>
|
||||
<p>Choose a Runtime and any resources needed for this request.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if ticketContext}
|
||||
<aside class="worker-ticket-context">
|
||||
<div>
|
||||
<span>Ticket {ticketContext.ticketRole || 'Worker'}</span>
|
||||
<span>Ticket assignment</span>
|
||||
<strong>{ticketContext.ticketTitle}</strong>
|
||||
<code>{ticketContext.ticketId}</code>
|
||||
</div>
|
||||
@@ -461,7 +434,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if selectedRuntimeAllowsNoWorkdir}
|
||||
{#if selectedRuntimeForbidsWorkdirAttachments}
|
||||
<p class="worker-workdir-note">No filesystem tools or Bash will be available without a Workdir.</p>
|
||||
{:else}
|
||||
<div class="worker-attachment-heading">
|
||||
@@ -536,6 +509,7 @@
|
||||
<label>
|
||||
<span>Repository</span>
|
||||
<select bind:value={workingDirectoryRepositoryKey}>
|
||||
<option value="">Select repository</option>
|
||||
{#if options?.repositories.length}
|
||||
{#each options.repositories as repository}
|
||||
<option value={repository.repository_key}>{repository.repository_key}</option>
|
||||
@@ -565,15 +539,12 @@
|
||||
<input bind:value={displayName} required maxlength="80" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Profile</span>
|
||||
<select bind:value={profile} required>
|
||||
{#if options?.profiles.length}
|
||||
{#each options.profiles as candidate}
|
||||
<option value={candidate.id}>{candidate.label}</option>
|
||||
{/each}
|
||||
{:else}
|
||||
<option value="" disabled>No profile candidates</option>
|
||||
{/if}
|
||||
<span>Profile (optional)</span>
|
||||
<select bind:value={profile} aria-label="Profile">
|
||||
<option value="">Workspace default</option>
|
||||
{#each availableProfiles as candidate}
|
||||
<option value={candidate.id}>{candidate.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -627,7 +598,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
<label>
|
||||
<span>Initial text</span>
|
||||
<span>Flow (optional)</span>
|
||||
<input bind:value={flow} autocomplete="off" placeholder="Optional Flow selector" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Initial request</span>
|
||||
<textarea bind:value={initialText} rows="7" placeholder="Optional first instruction"></textarea>
|
||||
</label>
|
||||
</section>
|
||||
@@ -635,6 +610,10 @@
|
||||
{#if submitError}
|
||||
<div class="section-state error worker-submit-error">
|
||||
<p>{submitError.message}</p>
|
||||
{#if outcomeUnknown}
|
||||
<p>The launch outcome is unknown. Check the Worker list or Ticket before starting another Worker.</p>
|
||||
<a href={`/w/${workspaceId}/workers`}>Check Workers</a>
|
||||
{/if}
|
||||
{#if submitError.diagnostics.length > 0}
|
||||
<ul class="worker-error-diagnostics">
|
||||
{#each submitError.diagnostics as diagnostic}
|
||||
|
||||
@@ -2,17 +2,14 @@ export function load(
|
||||
{ params, url }: { params: { workspaceId: string }; url: URL },
|
||||
) {
|
||||
const ticketId = url.searchParams.get("ticketId") ?? "";
|
||||
const ticketRole = url.searchParams.get("ticketRole") ?? "";
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
ticketContext: ticketId
|
||||
? {
|
||||
ticketId,
|
||||
ticketTitle: url.searchParams.get("ticketTitle") ?? ticketId,
|
||||
ticketRole,
|
||||
initialInput: url.searchParams.get("initialInput") ?? "",
|
||||
repositoryKey: url.searchParams.get("repositoryKey") ?? "",
|
||||
refSelector: url.searchParams.get("refSelector") ?? "HEAD",
|
||||
initialInput: url.searchParams.get("initialInput") ??
|
||||
`Work on Ticket ${ticketId}.`,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
+155
-10
@@ -9,7 +9,11 @@ import {
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import NewWorker from "./+page.svelte";
|
||||
function mount() {
|
||||
function mount(
|
||||
ticketContext:
|
||||
| { ticketId: string; ticketTitle: string; initialInput: string }
|
||||
| null = null,
|
||||
) {
|
||||
const data = {
|
||||
workspaceId: "empty",
|
||||
repositories: {
|
||||
@@ -18,7 +22,7 @@ function mount() {
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
},
|
||||
ticketContext: null,
|
||||
ticketContext,
|
||||
} as unknown as ComponentProps<typeof NewWorker>["data"];
|
||||
return render(NewWorker, {
|
||||
props: { data, params: { workspaceId: "empty" } },
|
||||
@@ -58,14 +62,14 @@ test("zero-repository Workspace launches a normal embedded Worker request withou
|
||||
const body = JSON.parse(String(api.mock.calls[1][1]?.body));
|
||||
expect(body).toMatchObject({
|
||||
runtime_id: "embedded",
|
||||
profile: "builtin:companion",
|
||||
profile: null,
|
||||
workdir_attachments: [],
|
||||
ticket_assignment: null,
|
||||
});
|
||||
expect(body).not.toHaveProperty("repository_key");
|
||||
});
|
||||
|
||||
test("zero-repository Workspace does not bypass a remote Runtime Workdir requirement", async () => {
|
||||
test("zero-repository Workspace allows a remote Runtime launch without attachments", async () => {
|
||||
const options = emptyLaunchOptions("empty");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -74,10 +78,151 @@ test("zero-repository Workspace does not bypass a remote Runtime Workdir require
|
||||
),
|
||||
);
|
||||
mount();
|
||||
await screen.findByRole("button", { name: "Start Worker" });
|
||||
await screen.findByLabelText("Attachment 1 Workdir");
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Start Worker" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
const start = await screen.findByRole("button", { name: "Start Worker" });
|
||||
expect(screen.queryByLabelText("Attachment 1 Workdir")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Add attachment" })).toBeTruthy();
|
||||
expect((start as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test.each(["embedded", "remote"].flatMap((runtime) =>
|
||||
[
|
||||
"Review is unnecessary.",
|
||||
"Review, then merge if approved.",
|
||||
"Return without a conclusion.",
|
||||
].map((text) => ({ runtime, text }))
|
||||
))(
|
||||
"$runtime Ticket launch retains natural request without attachments: $text",
|
||||
async ({ runtime, text }) => {
|
||||
const options = emptyLaunchOptions("empty");
|
||||
if (runtime === "remote") options.runtimes.reverse();
|
||||
options.profiles.push({
|
||||
...options.profiles[0],
|
||||
id: "builtin:reviewer",
|
||||
label: "Trusted Reviewer",
|
||||
});
|
||||
const api = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
String(input).endsWith("/launch-options")
|
||||
? Response.json(options)
|
||||
: Response.json({
|
||||
error: "permission_denied",
|
||||
message: "Backend rejected this assignment",
|
||||
diagnostics: [],
|
||||
}, { status: 403 })
|
||||
);
|
||||
vi.stubGlobal("fetch", api);
|
||||
mount({
|
||||
ticketId: "T-720",
|
||||
ticketTitle: "Investigate launch",
|
||||
initialInput: text,
|
||||
});
|
||||
const start = await screen.findByRole("button", { name: "Start Worker" });
|
||||
expect((start as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByRole("option", { name: "Trusted Reviewer" }))
|
||||
.toBeNull();
|
||||
await fireEvent.click(start);
|
||||
await screen.findByText(
|
||||
"permission_denied: Backend rejected this assignment",
|
||||
);
|
||||
const request = JSON.parse(String(api.mock.calls[1][1]?.body));
|
||||
expect(request.runtime_id).toBe(runtime);
|
||||
expect(request.ticket_assignment.ticket_id).toBe("T-720");
|
||||
expect(request.ticket_assignment.operation_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(request.initial_submit).toEqual([{ kind: "text", content: text }]);
|
||||
expect(request.profile).toBeNull();
|
||||
expect(request.workdir_attachments).toEqual([]);
|
||||
expect(request.ticket_assignment).not.toHaveProperty("role");
|
||||
},
|
||||
);
|
||||
|
||||
test("read-only Workdir requires an explicit selection and keeps backend permissions authoritative", async () => {
|
||||
const options = emptyLaunchOptions("empty");
|
||||
options.runtimes.reverse();
|
||||
options.working_directories = [{
|
||||
working_directory_id: "read-only-grant",
|
||||
display_name: "Read-only sessions",
|
||||
source: {
|
||||
kind: "external_grant",
|
||||
grant_id: "grant-1",
|
||||
grant_permissions: { read: true, write: false, command: false },
|
||||
},
|
||||
materializer_kind: "client_hosted_external",
|
||||
status: "active",
|
||||
cleanliness: "unknown",
|
||||
}];
|
||||
const api = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
String(input).endsWith("/launch-options")
|
||||
? Response.json(options)
|
||||
: Response.json({
|
||||
error: "permission_denied",
|
||||
message: "No write permission",
|
||||
diagnostics: [],
|
||||
}, { status: 403 })
|
||||
);
|
||||
vi.stubGlobal("fetch", api);
|
||||
mount({
|
||||
ticketId: "T-720",
|
||||
ticketTitle: "Read-only investigation",
|
||||
initialInput: "Return without a conclusion.",
|
||||
});
|
||||
const start = await screen.findByRole("button", {
|
||||
name: "Start Worker",
|
||||
}) as HTMLButtonElement;
|
||||
expect(start.disabled).toBe(false);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Add attachment" }));
|
||||
const select = await screen.findByLabelText("Attachment 1 Workdir");
|
||||
expect((select as HTMLSelectElement).value).toBe("");
|
||||
expect(start.disabled).toBe(true);
|
||||
await fireEvent.change(select, { target: { value: "read-only-grant" } });
|
||||
await waitFor(() => expect(start.disabled).toBe(false));
|
||||
await fireEvent.click(start);
|
||||
await screen.findByText("permission_denied: No write permission");
|
||||
const request = JSON.parse(String(api.mock.calls[1][1]?.body));
|
||||
expect(request.workdir_attachments).toEqual([{
|
||||
alias: "workdir",
|
||||
working_directory_id: "read-only-grant",
|
||||
relative_cwd: null,
|
||||
}]);
|
||||
expect(request).not.toHaveProperty("targets");
|
||||
expect(request.workdir_attachments[0]).not.toHaveProperty("permissions");
|
||||
});
|
||||
|
||||
test.each(["backend", "network"])(
|
||||
"explicit Flow stays optional and uncertain %s outcome prevents duplicate launch",
|
||||
async (failure) => {
|
||||
const api = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
|
||||
if (String(input).endsWith("/launch-options")) {
|
||||
return Response.json(emptyLaunchOptions("empty"));
|
||||
}
|
||||
if (failure === "network") {
|
||||
throw new TypeError("Connection lost after dispatch");
|
||||
}
|
||||
return Response.json({
|
||||
error: "OutcomeUnknown",
|
||||
message: "Runtime result needs reconciliation",
|
||||
diagnostics: [],
|
||||
}, { status: 503 });
|
||||
});
|
||||
vi.stubGlobal("fetch", api);
|
||||
mount({
|
||||
ticketId: "T-720",
|
||||
ticketTitle: "Investigate launch",
|
||||
initialInput: "Return without a conclusion.",
|
||||
});
|
||||
const start = await screen.findByRole("button", { name: "Start Worker" });
|
||||
await fireEvent.input(screen.getByLabelText("Flow (optional)"), {
|
||||
target: { value: "project:investigate" },
|
||||
});
|
||||
await fireEvent.click(start);
|
||||
await screen.findByText(
|
||||
"The launch outcome is unknown. Check the Worker list or Ticket before starting another Worker.",
|
||||
);
|
||||
expect((start as HTMLButtonElement).disabled).toBe(true);
|
||||
const request = JSON.parse(String(api.mock.calls[1][1]?.body));
|
||||
expect(request.initial_submit).toEqual([{
|
||||
kind: "text",
|
||||
content: "Return without a conclusion.",
|
||||
}, { kind: "flow", selector: "project:investigate" }]);
|
||||
await fireEvent.click(start);
|
||||
expect(api).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||
import { chromium } from "npm:playwright@1.59.1";
|
||||
import { loadScenario } from "../../../../tools/web-ux/src/scenario.ts";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
} from "../../../../tools/web-ux/src/processes.ts";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
|
||||
import { creationResponse } from "../../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||
|
||||
// Browser-only contract over the production static build and loopback API fixtures;
|
||||
// no actual Backend, Runtime, Worker, account or repository is created.
|
||||
Deno.test("production empty Workspace supports optional Git validation, reopening, and Workdir-less Worker submission at all review widths", async () => {
|
||||
const scenarioPath =
|
||||
new URL("./optional-repository.json", import.meta.url).pathname;
|
||||
const scenarioPath = new URL("./optional-repository.json", import.meta.url).pathname;
|
||||
const scenario = await loadScenario(scenarioPath);
|
||||
async function freePort() {
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
@@ -28,8 +24,7 @@ Deno.test("production empty Workspace supports optional Git validation, reopenin
|
||||
scenario.processes![1].args![3] = String(proxyPort);
|
||||
scenario.processes![1].args![4] = `http://127.0.0.1:${upstreamPort}`;
|
||||
scenario.processes![1].readyUrl = `${scenario.baseUrl}/health`;
|
||||
const output =
|
||||
new URL("../../.svelte-kit/t717/browser/", import.meta.url).pathname;
|
||||
const output = new URL("../../.svelte-kit/t717/browser/", import.meta.url).pathname;
|
||||
const processes = await startOwnedProcesses(
|
||||
scenario.processes!,
|
||||
scenarioPath,
|
||||
@@ -138,8 +133,9 @@ Deno.test("production empty Workspace supports optional Git validation, reopenin
|
||||
.selectOption("remote");
|
||||
assert(
|
||||
await page.getByRole("button", { name: "Start Worker" })
|
||||
.isDisabled(),
|
||||
.isEnabled(),
|
||||
);
|
||||
await page.getByRole("button", { name: "Add attachment", exact: true }).click();
|
||||
await page.getByLabel("Attachment 1 Workdir").selectOption(
|
||||
"__new_working_directory__",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user