merge develop into T-651 repository access UI

This commit is contained in:
2026-09-24 18:44:45 +09:00
36 changed files with 5022 additions and 1377 deletions
+50 -1
View File
@@ -357,7 +357,9 @@ pub struct LogicalWorkdirAttachment {
}
fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities {
workdir::WorkdirSessionCapabilities::ALL
// Capability-less wire and persisted records must not silently regain write
// or command authority when read by a newer Runtime.
workdir::WorkdirSessionCapabilities::READ_ONLY
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -367,6 +369,9 @@ pub struct WorkingDirectoryAttachmentClaim {
pub working_directory_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relative_cwd: Option<String>,
/// Backend-authoritative maximum capabilities for the attachment session.
#[serde(default = "default_workdir_session_capabilities")]
pub capabilities: workdir::WorkdirSessionCapabilities,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -956,6 +961,50 @@ mod tests {
RuntimeApiError::new(status, "test_error", "test error")
}
#[test]
fn capability_less_attachment_claims_default_to_read_only() {
let claim: WorkingDirectoryAttachmentClaim = serde_json::from_value(serde_json::json!({
"alias": "docs",
"working_directory_id": "workdir-docs"
}))
.unwrap();
assert_eq!(
claim.capabilities,
workdir::WorkdirSessionCapabilities::READ_ONLY
);
let logical: LogicalWorkdirAttachment = serde_json::from_value(serde_json::json!({
"alias": "docs",
"working_directory_id": "workdir-docs"
}))
.unwrap();
assert_eq!(
logical.capabilities,
workdir::WorkdirSessionCapabilities::READ_ONLY
);
}
#[test]
fn attachment_claim_serializes_backend_authored_capabilities() {
let claim = WorkingDirectoryAttachmentClaim {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory_id: "workdir-main".to_string(),
relative_cwd: None,
capabilities: workdir::WorkdirSessionCapabilities::ALL,
};
let value = serde_json::to_value(&claim).unwrap();
assert_eq!(
value.get("capabilities"),
Some(&serde_json::to_value(workdir::WorkdirSessionCapabilities::ALL).unwrap())
);
assert_eq!(
serde_json::from_value::<WorkingDirectoryAttachmentClaim>(value)
.unwrap()
.capabilities,
workdir::WorkdirSessionCapabilities::ALL
);
}
impl RuntimeApi for RoundTripService {
async fn ping(
&self,
+62 -4
View File
@@ -5918,8 +5918,8 @@ pub struct TicketDetail {
pub item_revision: String,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub repository_key: Option<String>,
pub ref_selector: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "Array<TicketTarget>"))]
pub targets: Vec<ticket::TicketTarget>,
pub risk_flags: Vec<String>,
pub body: String,
pub body_truncated: bool,
@@ -6081,8 +6081,8 @@ pub struct TicketRoleAssignmentMutationResponse {
#[cfg_attr(feature = "typescript", ts(tag = "action", rename_all = "snake_case"))]
pub enum BrowserTicketTargetEdit {
Set {
repository_key: String,
ref_selector: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "Array<TicketTarget>"))]
targets: Vec<ticket::TicketTarget>,
},
Clear,
}
@@ -9269,6 +9269,64 @@ mod skill_typescript_tests {
mod tests {
use super::*;
#[test]
fn browser_ticket_targets_are_one_collection_authority() {
let edit = BrowserTicketTargetEdit::Set {
targets: vec![
ticket::TicketTarget {
repository_key: "main".to_string(),
ref_selector: Some("develop".to_string()),
access: ticket::TicketTargetAccess::ReadWrite,
},
ticket::TicketTarget {
repository_key: "docs".to_string(),
ref_selector: None,
access: ticket::TicketTargetAccess::ReadOnly,
},
],
};
assert_eq!(
serde_json::to_value(edit).unwrap(),
serde_json::json!({
"action": "set",
"targets": [
{
"repository_key": "main",
"ref_selector": "develop",
"access": "read_write"
},
{
"repository_key": "docs",
"access": "read_only"
}
]
})
);
assert!(
serde_json::from_value::<BrowserTicketTargetEdit>(serde_json::json!({
"action": "set",
"repository_key": "main",
"ref_selector": "develop"
}))
.is_err(),
"the removed singular target fields must not remain an accepted authority"
);
assert!(
serde_json::from_value::<BrowserEditTicketRequest>(serde_json::json!({
"target": {
"action": "set",
"targets": [{
"repository_key": "main",
"ref_selector": "develop",
"access": "read_write"
}]
}
}))
.is_ok()
);
}
#[test]
fn websocket_operations_preserve_routes_and_directional_frame_authorities() {
use api_macros::{TransportMetadata, WebSocketOperation};
@@ -430,6 +430,13 @@ fn validate_ticket_browser_operations(
request: None,
successes: &[("200", "TicketListResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets",
method: "post",
operation_id: "ticket_create_record",
request: Some("NewTicket"),
successes: &[("200", "TicketRef")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}",
method: "get",
@@ -1837,6 +1844,45 @@ mod tests {
const TICKET_BROWSER_TYPESCRIPT: &str =
include_str!("../../../web/workspace/src/lib/generated/ticket-api.ts");
#[test]
fn ticket_browser_generator_projects_target_collections_without_singular_authority() {
let openapi = crate::canonical_openapi_document()
.expect("canonical OpenAPI contract must build")
.to_json()
.expect("canonical OpenAPI contract must serialize");
let output = generate_ticket_browser_typescript(&openapi).unwrap();
assert!(output.contains(
"export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; };"
));
assert!(
output.contains("export type TicketTargetAccess = \"read_only\" | \"read_write\";")
);
assert!(output.contains(
"export type BrowserTicketTargetEdit = { action: \"set\"; targets: Array<TicketTarget>; } | { action: \"clear\"; };"
));
let edit = output
.lines()
.find(|line| line.starts_with("export type BrowserEditTicketRequest ="))
.expect("BrowserEditTicketRequest declaration");
assert!(edit.contains("target?: BrowserTicketTargetEdit | null;"));
let create = output
.lines()
.find(|line| line.starts_with("export type NewTicket ="))
.expect("NewTicket declaration");
assert!(create.contains("targets?: Array<TicketTarget>;"));
assert!(!create.contains("repository_key"));
assert!(!create.contains("ref_selector"));
assert!(!create.contains(" target?:"));
let detail = output
.lines()
.find(|line| line.starts_with("export type TicketDetail ="))
.expect("TicketDetail declaration");
assert!(detail.contains("targets: Array<TicketTarget>;"));
assert!(!detail.contains("repository_key"));
assert!(!detail.contains("ref_selector"));
assert!(!detail.contains(" target:"));
}
#[test]
fn checked_in_ticket_browser_typescript_is_current() {
assert_eq!(
+12 -1
View File
@@ -84,9 +84,20 @@ CREATE TABLE typed_tickets (
workflow_state_explicit INTEGER NOT NULL,
queued_by TEXT,
queued_at TEXT,
resolution TEXT, repository_id TEXT, ref_selector TEXT,
resolution TEXT,
PRIMARY KEY (workspace_id, ticket_id)
);
CREATE TABLE typed_ticket_targets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
repository_key TEXT NOT NULL,
ref_selector TEXT,
access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')),
PRIMARY KEY (workspace_id, ticket_id, ordinal),
UNIQUE (workspace_id, ticket_id, repository_key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE "workspace_resource_key_counters" (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
+531 -158
View File
File diff suppressed because it is too large Load Diff
+304 -5
View File
@@ -7,7 +7,9 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7;
const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
#[derive(Clone, Copy)]
struct Migration {
@@ -18,7 +20,7 @@ struct Migration {
const MIGRATIONS: &[Migration] = &[Migration {
version: LATEST_SQLITE_TICKET_SCHEMA_VERSION,
name: "ticket schema baseline",
name: BASELINE_MIGRATION_NAME,
apply: create_latest_ticket_schema,
}];
@@ -40,6 +42,7 @@ struct ExpectedForeignKey {
const OWNED_TABLES: &[&str] = &[
"typed_tickets",
"typed_ticket_targets",
"typed_ticket_labels",
"typed_ticket_risk_flags",
"typed_ticket_raw_frontmatter",
@@ -91,8 +94,15 @@ const TICKET_COLUMNS: &[ExpectedColumn] = &[
column("queued_by", "TEXT", false, 0),
column("queued_at", "TEXT", false, 0),
column("resolution", "TEXT", false, 0),
column("repository_id", "TEXT", false, 0),
];
const TARGET_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("ordinal", "INTEGER", true, 3),
column("repository_key", "TEXT", true, 0),
column("ref_selector", "TEXT", false, 0),
column("access", "TEXT", true, 0),
];
const LABEL_COLUMNS: &[ExpectedColumn] = &[
@@ -273,6 +283,30 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
],
)
.map_err(sqlite_err)?;
} else if applied
== BTreeMap::from([(
PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION,
BASELINE_MIGRATION_NAME.to_string(),
)])
{
migrate_v6_to_v7(connection)?;
connection
.execute(
"DELETE FROM ticket_schema_migrations WHERE version = ?1",
params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION],
)
.map_err(sqlite_err)?;
connection
.execute(
"INSERT INTO ticket_schema_migrations (version, name, applied_at)
VALUES (?1, ?2, ?3)",
params![
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
BASELINE_MIGRATION_NAME,
chrono::Utc::now().to_rfc3339()
],
)
.map_err(sqlite_err)?;
} else {
validate_applied_migrations(&applied)?;
}
@@ -321,6 +355,7 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
for (table, columns, foreign_keys) in [
("typed_tickets", TICKET_COLUMNS, TICKET_FOREIGN_KEYS),
("typed_ticket_targets", TARGET_COLUMNS, CHILD_FOREIGN_KEYS),
("typed_ticket_labels", LABEL_COLUMNS, CHILD_FOREIGN_KEYS),
(
"typed_ticket_risk_flags",
@@ -362,6 +397,14 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics);
}
collect_index_diagnostics(
connection,
"typed_ticket_targets",
None,
true,
&["workspace_id", "ticket_id", "repository_key"],
&mut diagnostics,
);
collect_column_diagnostics(
connection,
"workspace_resource_keys",
@@ -431,6 +474,144 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
.map_err(sqlite_err)
}
fn migrate_v6_to_v7(connection: &Connection) -> Result<()> {
connection
.execute_batch(
"CREATE TABLE typed_ticket_targets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
repository_key TEXT NOT NULL,
ref_selector TEXT,
access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')),
PRIMARY KEY (workspace_id, ticket_id, ordinal),
UNIQUE (workspace_id, ticket_id, repository_key),
FOREIGN KEY (workspace_id, ticket_id)
REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);",
)
.map_err(sqlite_err)?;
let legacy_targets = {
let mut statement = connection
.prepare(
"SELECT workspace_id, ticket_id, repository_id, ref_selector
FROM typed_tickets
WHERE repository_id IS NOT NULL OR ref_selector IS NOT NULL
ORDER BY workspace_id, ticket_id",
)
.map_err(sqlite_err)?;
let rows = statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
))
})
.map_err(sqlite_err)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)?
};
for (workspace_id, ticket_id, repository_key, ref_selector) in legacy_targets {
let repository_key = repository_key.ok_or_else(|| {
TicketError::Sqlite(format!(
"legacy Ticket {ticket_id:?} in Workspace {workspace_id:?} has ref_selector without repository_id"
))
})?;
connection
.execute(
"INSERT INTO typed_ticket_targets
(workspace_id, ticket_id, ordinal, repository_key, ref_selector, access)
VALUES (?1, ?2, 0, ?3, ?4, 'read_write')",
params![workspace_id, ticket_id, repository_key, ref_selector],
)
.map_err(sqlite_err)?;
}
let legacy_event_targets = {
let mut statement = connection
.prepare(
"SELECT repository.workspace_id, repository.ticket_id, repository.event_index,
repository.value, selector.value
FROM typed_ticket_event_attributes AS repository
JOIN typed_ticket_event_attributes AS selector
ON selector.workspace_id = repository.workspace_id
AND selector.ticket_id = repository.ticket_id
AND selector.event_index = repository.event_index
AND selector.key = 'ref_selector'
WHERE repository.key = 'repository_id'
ORDER BY repository.workspace_id, repository.ticket_id, repository.event_index",
)
.map_err(sqlite_err)?;
let rows = statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
})
.map_err(sqlite_err)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)?
};
for (workspace_id, ticket_id, event_index, repository_key, ref_selector) in legacy_event_targets
{
let targets = serde_json::to_string(&[serde_json::json!({
"repository_key": repository_key,
"ref_selector": ref_selector,
"access": "read_write",
})])
.map_err(sqlite_err)?;
connection
.execute(
"INSERT OR IGNORE INTO typed_ticket_event_attributes
(workspace_id, ticket_id, event_index, key, value)
VALUES (?1, ?2, ?3, 'targets', ?4)",
params![workspace_id, ticket_id, event_index, targets],
)
.map_err(sqlite_err)?;
connection
.execute(
"INSERT OR IGNORE INTO typed_ticket_event_attributes
(workspace_id, ticket_id, event_index, key, value)
SELECT ?1, ?2, ?3, 'fingerprint_version', '1'
WHERE EXISTS (
SELECT 1 FROM typed_ticket_event_attributes
WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = ?3
AND key = 'request_fingerprint'
)",
params![workspace_id, ticket_id, event_index],
)
.map_err(sqlite_err)?;
}
connection
.execute(
"DELETE FROM typed_ticket_event_attributes AS legacy
WHERE legacy.key IN ('repository_id', 'ref_selector')
AND EXISTS (
SELECT 1 FROM typed_ticket_event_attributes AS canonical
WHERE canonical.workspace_id = legacy.workspace_id
AND canonical.ticket_id = legacy.ticket_id
AND canonical.event_index = legacy.event_index
AND canonical.key = 'targets'
)",
[],
)
.map_err(sqlite_err)?;
connection
.execute_batch(
"ALTER TABLE typed_tickets DROP COLUMN repository_id;
ALTER TABLE typed_tickets DROP COLUMN ref_selector;",
)
.map_err(sqlite_err)
}
fn load_applied_migrations(connection: &Connection) -> Result<BTreeMap<i64, String>> {
let mut statement = connection
.prepare("SELECT version, name FROM ticket_schema_migrations ORDER BY version")
@@ -842,6 +1023,124 @@ mod tests {
assert_eq!(load_applied_migrations(&connection).unwrap(), versions);
}
#[test]
fn migrates_single_target_rows_to_ordered_read_write_targets() {
let connection = Connection::open_in_memory().unwrap();
create_latest_ticket_schema(&connection).unwrap();
connection
.execute_batch(
"DROP TABLE typed_ticket_targets;
ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT;
ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT;
CREATE TABLE ticket_schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);
INSERT INTO ticket_schema_migrations (version, name, applied_at)
VALUES (6, 'ticket schema baseline', '2026-09-23T00:00:00Z');
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit, repository_id, ref_selector
) VALUES (
'workspace-1', 'ticket-1', 'ticket-1', 'Migrated', 'open', 'task', 'P2',
'body', 'ready', 1, 'main', 'develop'
);
INSERT INTO typed_ticket_events (
workspace_id, ticket_id, event_index, kind, body
) VALUES (
'workspace-1', 'ticket-1', 0, 'state_changed', 'ready'
);
INSERT INTO typed_ticket_event_attributes (
workspace_id, ticket_id, event_index, key, value
) VALUES
('workspace-1', 'ticket-1', 0, 'repository_id', 'main'),
('workspace-1', 'ticket-1', 0, 'ref_selector', 'develop'),
('workspace-1', 'ticket-1', 0, 'request_fingerprint', 'legacy');",
)
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
let target = connection
.query_row(
"SELECT ordinal, repository_key, ref_selector, access
FROM typed_ticket_targets
WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1'",
[],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
))
},
)
.unwrap();
assert_eq!(
target,
(
0,
"main".to_owned(),
Some("develop".to_owned()),
"read_write".to_owned()
)
);
let columns = load_columns(&connection, "typed_tickets").unwrap();
assert!(
columns
.iter()
.all(|column| { column.name != "repository_id" && column.name != "ref_selector" })
);
let encoded_targets: String = connection
.query_row(
"SELECT value FROM typed_ticket_event_attributes
WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1'
AND event_index = 0 AND key = 'targets'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&encoded_targets).unwrap(),
serde_json::json!([{
"repository_key": "main",
"ref_selector": "develop",
"access": "read_write"
}])
);
let migrated_attributes = {
let mut statement = connection
.prepare(
"SELECT key, value FROM typed_ticket_event_attributes
WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1'
AND event_index = 0 ORDER BY key",
)
.unwrap();
statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<std::result::Result<BTreeMap<_, _>, _>>()
.unwrap()
};
assert_eq!(
migrated_attributes
.get("fingerprint_version")
.map(String::as_str),
Some("1")
);
assert!(!migrated_attributes.contains_key("repository_id"));
assert!(!migrated_attributes.contains_key("ref_selector"));
assert_eq!(
load_applied_migrations(&connection).unwrap(),
BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())])
);
}
#[test]
fn rejects_unknown_future_migration_history_without_changing_schema() {
let connection = Connection::open_in_memory().unwrap();
@@ -856,7 +1155,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 6 baseline marker"
"migration history must contain only the canonical version 7 baseline marker"
));
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
}
@@ -878,7 +1177,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 6 baseline marker"
"migration history must contain only the canonical version 7 baseline marker"
));
assert!(!table_exists(&connection, "typed_tickets").unwrap());
}
+65 -26
View File
@@ -134,11 +134,11 @@ const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body`
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
"Append a typed Ticket implementation_report event. `body` is Markdown.";
const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
The backend atomically validates and normalizes the persisted repository/ref target, records one typed \
state_changed event, and transitions planning -> ready. `reason` is optional.";
The backend atomically validates and normalizes every persisted repository target, requires exactly one \
read_write target, records one typed state_changed event, and transitions planning -> ready. `reason` is optional.";
const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \
The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \
state_changed event, effective target, and planning -> ready transition atomically.";
The backend applies the same targets validation and lock as TicketMarkReady and commits the summary, \
state_changed event, effective targets, and planning -> ready transition atomically.";
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \
requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged.";
@@ -397,12 +397,9 @@ struct TicketCreateParams {
/// Optional queued_at frontmatter value.
#[serde(default)]
queued_at: Option<String>,
/// Optional target Workspace repository id.
/// Ordered implementation targets. Selectors may be omitted while planning.
#[serde(default)]
repository_key: Option<String>,
/// Optional target Git ref selector. Requires `repository_key`.
#[serde(default)]
ref_selector: Option<String>,
targets: Vec<crate::TicketTarget>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -424,9 +421,9 @@ struct TicketEditItemParams {
/// Replace every occurrence of `old_string`; by default exactly one occurrence is required.
#[serde(default)]
replace_all: bool,
/// Optional target repository/ref update.
/// Optional ordered targets replacement or clear operation.
#[serde(default)]
target: Option<crate::TicketTargetEdit>,
targets: Option<crate::TicketTargetsEdit>,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
@@ -941,8 +938,7 @@ impl Tool for TicketCreateTool {
input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state);
input.queued_by = None;
input.queued_at = params.queued_at;
input.repository_id = params.repository_key;
input.ref_selector = params.ref_selector;
input.targets = params.targets;
let created = self
.backend
@@ -986,7 +982,7 @@ impl Tool for TicketEditItemTool {
title: params.title,
body: params.body.map(MarkdownText::new),
body_replacement,
target: params.target,
targets: params.targets,
author: None,
};
let ticket = self
@@ -1170,8 +1166,7 @@ impl Tool for TicketMarkReadyTool {
json!({
"ticket": ticket.meta.id,
"state": ticket.meta.workflow_state.as_str(),
"repository_key": ticket.meta.repository_id,
"ref_selector": ticket.meta.ref_selector,
"targets": ticket.meta.targets,
"ok": true
}),
))
@@ -1203,8 +1198,7 @@ impl Tool for TicketIntakeReadyTool {
json!({
"ticket": ticket.meta.id,
"state": ticket.meta.workflow_state.as_str(),
"repository_key": ticket.meta.repository_id,
"ref_selector": ticket.meta.ref_selector,
"targets": ticket.meta.targets,
"ok": true
}),
))
@@ -1719,6 +1713,7 @@ fn ticket_json(
"risk_flags": ticket.meta.risk_flags,
"queued_by": ticket.meta.queued_by,
"queued_at": ticket.meta.queued_at,
"targets": ticket.meta.targets,
},
"body": truncate_text(ticket.document.body.as_str(), body_max_bytes),
"events": {
@@ -1937,16 +1932,25 @@ mod tests {
fn resolve_target(
&self,
_workspace_id: &str,
repository_key: Option<&str>,
repository_key: &str,
ref_selector: Option<&str>,
) -> crate::Result<crate::ResolvedTicketTarget> {
Ok(crate::ResolvedTicketTarget {
repository_id: repository_key.unwrap_or("main").to_owned(),
repository_key: repository_key.to_owned(),
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
access: crate::TicketTargetAccess::ReadOnly,
})
}
}
fn write_target(repository_key: &str) -> crate::TicketTarget {
crate::TicketTarget {
repository_key: repository_key.to_owned(),
ref_selector: None,
access: crate::TicketTargetAccess::ReadWrite,
}
}
fn backend(temp: &TempDir) -> crate::SqliteTicketBackend {
sqlite_backend(temp)
}
@@ -2076,7 +2080,18 @@ mod tests {
.execute(
&json!({
"title": "Tool Created",
"body": "## Background\n\nCreated by tool.\n"
"body": "## Background\n\nCreated by tool.\n",
"targets": [
{
"repository_key": "main",
"ref_selector": "develop",
"access": "read_write"
},
{
"repository_key": "docs",
"access": "read_only"
}
]
})
.to_string(),
Default::default(),
@@ -2114,6 +2129,11 @@ mod tests {
.unwrap();
assert!(shown.summary.contains(&id));
let shown_content = shown.content.unwrap();
let shown_json: Value = serde_json::from_str(&shown_content).unwrap();
assert_eq!(shown_json["meta"]["targets"][0]["repository_key"], "main");
assert_eq!(shown_json["meta"]["targets"][0]["access"], "read_write");
assert_eq!(shown_json["meta"]["targets"][1]["repository_key"], "docs");
assert_eq!(shown_json["meta"]["targets"][1]["access"], "read_only");
assert!(shown_content.contains("Created by tool"));
assert!(!shown_content.contains("legacy_ticket"));
assert!(!shown_content.contains("needs_preflight"));
@@ -2574,10 +2594,10 @@ mod tests {
let temp = TempDir::new().unwrap();
let inner = sqlite_backend(&temp);
let mut dependency_input = NewTicket::new("Dependency");
dependency_input.repository_id = Some("main".to_string());
dependency_input.targets = vec![write_target("main")];
let dependency = inner.create(dependency_input).unwrap();
let mut target_input = NewTicket::new("Target");
target_input.repository_id = Some("main".to_string());
target_input.targets = vec![write_target("main")];
let target = inner.create(target_input).unwrap();
inner
.add_ticket_relation(
@@ -2669,7 +2689,7 @@ mod tests {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let mut input = NewTicket::new("Workflow Tool");
input.repository_id = Some("main".to_owned());
input.targets = vec![write_target("main")];
let created = backend.create(input).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady");
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
@@ -2752,7 +2772,7 @@ mod tests {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let mut input = NewTicket::new("Intake Workflow");
input.repository_id = Some("main".to_owned());
input.targets = vec![write_target("main")];
let created = backend.create(input).unwrap();
tool_by_name(backend.clone(), "TicketIntakeReady")
.execute(
@@ -2768,7 +2788,10 @@ mod tests {
.unwrap();
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
assert_eq!(record.meta.ref_selector.as_deref(), Some("develop"));
assert_eq!(
record.meta.targets[0].ref_selector.as_deref(),
Some("develop")
);
assert_eq!(
record
.events
@@ -3088,6 +3111,15 @@ mod tests {
assert!(!create_schema.contains("needs_preflight"));
assert!(!create_schema.contains("action_required"));
assert!(!create_schema.contains("attention_required"));
let create_properties = input_schema("TicketCreate")["properties"]
.as_object()
.unwrap()
.clone();
assert!(create_properties.contains_key("targets"));
assert!(!create_properties.contains_key("repository_key"));
assert!(!create_properties.contains_key("ref_selector"));
assert!(create_schema.contains("read_only"));
assert!(create_schema.contains("read_write"));
let plan_record_schema = tools
.iter()
.map(|definition| definition().0)
@@ -3115,6 +3147,13 @@ mod tests {
assert!(edit_schema.contains("old_string"));
assert!(edit_schema.contains("new_string"));
assert!(edit_schema.contains("replace_all"));
assert!(edit_schema.contains("targets"));
assert!(
!input_schema("TicketEditItem")["properties"]
.as_object()
.unwrap()
.contains_key("target")
);
for name in [
"TicketCreate",
"TicketEditItem",
+6
View File
@@ -140,6 +140,12 @@ impl WorkdirSessionCapabilities {
}
}
pub const fn intersection(self, other: Self) -> Self {
Self {
bits: self.bits & other.bits,
}
}
pub const fn supports(self, capability: WorkdirSessionCapability) -> bool {
let bit = match capability {
WorkdirSessionCapability::Read => Self::READ,
+10
View File
@@ -84,6 +84,16 @@ impl WorkdirToolBroker {
/// Own the parent Worker's active session and mediate every scoped child operation.
pub fn new(source: WorkdirSessionHandle) -> Self {
let capabilities = source.capabilities();
Self::with_capabilities(source, capabilities)
}
/// Own a session behind an additional Backend-authored capability ceiling.
/// The ceiling can only attenuate provider capabilities; it can never add one.
pub fn with_capabilities(
source: WorkdirSessionHandle,
capabilities: WorkdirSessionCapabilities,
) -> Self {
let capabilities = source.capabilities().intersection(capabilities);
let (command_events, _) = broadcast::channel(64);
let authority = Arc::new(ScopedWorkdirSession {
source,
+6 -1
View File
@@ -209,7 +209,9 @@ pub struct LogicalWorkdirAttachment {
}
fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities {
workdir::WorkdirSessionCapabilities::ALL
// Capability-less wire and persisted records fail closed instead of
// silently regaining write or command authority.
workdir::WorkdirSessionCapabilities::READ_ONLY
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -218,6 +220,9 @@ pub struct WorkingDirectoryAttachmentClaim {
pub working_directory_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relative_cwd: Option<String>,
/// Backend-authoritative maximum capabilities for the attachment session.
#[serde(default = "default_workdir_session_capabilities")]
pub capabilities: workdir::WorkdirSessionCapabilities,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+37 -1
View File
@@ -1801,6 +1801,11 @@ impl<'de> Deserialize<'de> for WorkerExecutionRecord {
alias,
working_directory_id: claim.working_directory_id,
relative_cwd: claim.relative_cwd,
// The removed singular field represented a full local
// Workdir binding. Preserve that known legacy contract
// explicitly; missing capabilities on the current claim
// shape default to read-only instead.
capabilities: workdir::WorkdirSessionCapabilities::ALL,
});
}
}
@@ -2474,10 +2479,41 @@ mod tests {
assert_eq!(attachment.alias.as_str(), "workdir");
assert_eq!(attachment.working_directory_id, "wd-legacy");
assert_eq!(attachment.relative_cwd.as_deref(), Some("crates/yoi"));
assert_eq!(
attachment.capabilities,
workdir::WorkdirSessionCapabilities::ALL
);
let serialized = serde_json::to_value(execution).unwrap();
assert!(serialized["request"].get("working_directory").is_none());
}
#[test]
fn current_attachment_claim_without_capabilities_defaults_to_read_only() {
let worker_id = WorkerId::now_v7();
let mut request = schema_v7_worker_document(worker_id)["request"].clone();
request["workdir_attachments"] = serde_json::json!([{
"alias": "docs",
"working_directory_id": "wd-docs"
}]);
let execution: WorkerExecutionRecord = serde_json::from_value(serde_json::json!({
"schema_version": SCHEMA_VERSION,
"request": request,
"binding": {},
"restore_intent": "automatic"
}))
.unwrap();
assert_eq!(
execution.request.workdir_attachments[0].capabilities,
workdir::WorkdirSessionCapabilities::READ_ONLY
);
let serialized = serde_json::to_value(execution).unwrap();
assert_eq!(
serialized["request"]["workdir_attachments"][0]["capabilities"],
serde_json::to_value(workdir::WorkdirSessionCapabilities::READ_ONLY).unwrap()
);
}
#[test]
fn worker_identity_round_trips_logical_workdir_attachments() {
let worker_id = WorkerId::now_v7();
@@ -2512,7 +2548,7 @@ mod tests {
[LogicalWorkdirAttachment {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory_id: "remote-workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::ALL,
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
}]
);
}
+55 -12
View File
@@ -2215,8 +2215,13 @@ impl Runtime {
let _ = worker.apply_worker_state(snapshot);
}
worker.restore_intent = restore_intent_for_status(worker.status);
let claims = worker
.request
.as_ref()
.map(|request| request.workdir_attachments.as_slice())
.unwrap_or_default();
worker.logical_workdir_attachments =
logical_workdir_attachments_from_statuses(&workdir_attachments);
logical_workdir_attachments_from_statuses(&workdir_attachments, claims);
worker.workdir_attachments = workdir_attachments;
worker.detail()
};
@@ -2867,8 +2872,13 @@ impl Runtime {
candidate.execution_bound = true;
candidate.status = WorkerStatus::Idle;
candidate.restore_intent = WorkerRestoreIntent::Explicit;
let claims = candidate
.request
.as_ref()
.map(|request| request.workdir_attachments.as_slice())
.unwrap_or_default();
candidate.logical_workdir_attachments =
logical_workdir_attachments_from_statuses(&workdir_attachments);
logical_workdir_attachments_from_statuses(&workdir_attachments, claims);
candidate.workdir_attachments = workdir_attachments;
state.workers.insert(worker_ref.worker_id, candidate);
state.publish_worker_upsert(worker_ref.worker_id)?;
@@ -3213,7 +3223,11 @@ impl RuntimeState {
}
};
let logical_workdir_attachments = if worker.logical_workdir_attachments.is_empty() {
logical_workdir_attachments_from_statuses(&worker.workdir_attachments)
let claims = request
.as_ref()
.map(|request| request.workdir_attachments.as_slice())
.unwrap_or_default();
logical_workdir_attachments_from_statuses(&worker.workdir_attachments, claims)
} else {
worker.logical_workdir_attachments
};
@@ -4283,17 +4297,29 @@ fn requested_workdir_ids(request: &CreateWorkerRequest) -> Vec<&str> {
fn logical_workdir_attachments_from_statuses(
attachments: &[crate::catalog::WorkingDirectoryAttachmentStatus],
claims: &[crate::catalog::WorkingDirectoryAttachmentClaim],
) -> Vec<LogicalWorkdirAttachment> {
attachments
.iter()
.map(|attachment| LogicalWorkdirAttachment {
alias: attachment.alias.clone(),
working_directory_id: attachment
.working_directory
.summary
.working_directory_id
.clone(),
capabilities: workdir::WorkdirSessionCapabilities::ALL,
.map(|attachment| {
let working_directory_id = &attachment.working_directory.summary.working_directory_id;
let capabilities = match claims.iter().find(|claim| claim.alias == attachment.alias) {
Some(claim) if claim.working_directory_id == *working_directory_id => {
claim.capabilities
}
// An alias/id mismatch is invalid at the operation boundary. If
// corrupted persisted state reaches this compatibility path,
// fail closed rather than silently restoring write authority.
Some(_) => workdir::WorkdirSessionCapabilities::READ_ONLY,
// Runtime-materialized attachment requests predate capability
// claims and retain their existing full local-session behavior.
None => workdir::WorkdirSessionCapabilities::ALL,
};
LogicalWorkdirAttachment {
alias: attachment.alias.clone(),
working_directory_id: working_directory_id.clone(),
capabilities,
}
})
.collect()
}
@@ -6584,6 +6610,7 @@ mod tests {
alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
working_directory_id: "workdir-idempotent".to_string(),
relative_cwd: None,
capabilities: workdir::WorkdirSessionCapabilities::ALL,
}];
let first = runtime.create_worker(request.clone()).unwrap();
@@ -6974,7 +7001,13 @@ mod tests {
.unwrap(),
}],
});
let request = task_request("uncertain create cleanup");
let mut request = task_request("uncertain create cleanup");
request.workdir_attachments = vec![WorkingDirectoryAttachmentClaim {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory_id: "wd-uncertain".to_string(),
relative_cwd: None,
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
}];
let worker_ref = WorkerRef::new(request.worker_id);
let error = runtime.create_worker(request).unwrap_err();
@@ -6986,6 +7019,16 @@ mod tests {
let retained = runtime.worker_detail(&worker_ref).unwrap();
assert_eq!(retained.status, WorkerStatus::Idle);
assert!(!retained.execution_metadata_available);
assert_eq!(
runtime
.lock()
.unwrap()
.worker(&worker_ref)
.unwrap()
.logical_workdir_attachments[0]
.capabilities,
workdir::WorkdirSessionCapabilities::READ_ONLY
);
assert!(matches!(
runtime.delete_worker(&worker_ref),
Err(RuntimeError::InvalidRequest(_))
+117 -3
View File
@@ -755,11 +755,25 @@ async fn fetch_workspace_config_http<T>(
Err("Workspace Config fetch requires the worker-runtime http-server feature".to_string())
}
fn initial_workdir_session_capabilities(
alias: &WorkdirAttachmentAlias,
claims: &[crate::catalog::WorkingDirectoryAttachmentClaim],
) -> WorkdirSessionCapabilities {
claims
.iter()
.find(|claim| &claim.alias == alias)
.map(|claim| claim.capabilities)
// New Runtime materialization requests have no claim and retain their
// established full local-session behavior.
.unwrap_or(WorkdirSessionCapabilities::ALL)
}
fn runtime_local_workdir_session(
workdir_id: &str,
root: &Path,
cwd: &Path,
scope: manifest::SharedScope,
capabilities: WorkdirSessionCapabilities,
command_environment: std::collections::BTreeMap<String, String>,
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
) -> WorkdirSessionHandle {
@@ -768,7 +782,7 @@ fn runtime_local_workdir_session(
root.to_path_buf(),
cwd.to_path_buf(),
scope,
WorkdirSessionCapabilities::ALL,
capabilities,
command_environment,
resources,
))
@@ -776,6 +790,7 @@ fn runtime_local_workdir_session(
fn runtime_local_workdir_router(
attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
capabilities: &BTreeMap<WorkdirAttachmentAlias, WorkdirSessionCapabilities>,
scope: manifest::SharedScope,
) -> Result<Arc<WorkdirSessionRouter>, String> {
let router = Arc::new(WorkdirSessionRouter::new());
@@ -788,6 +803,10 @@ fn runtime_local_workdir_router(
binding.root(),
binding.cwd(),
scope.clone(),
capabilities
.get(alias)
.copied()
.unwrap_or(WorkdirSessionCapabilities::READ_ONLY),
binding.command_environment(),
binding.session_resources(),
),
@@ -800,10 +819,25 @@ fn runtime_local_workdir_router(
fn restored_workdir_router(
local_attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
logical_attachments: &[crate::catalog::LogicalWorkdirAttachment],
claims: &[crate::catalog::WorkingDirectoryAttachmentClaim],
scope: manifest::SharedScope,
workspace_client: Arc<dyn WorkspaceClient>,
) -> Result<Arc<WorkdirSessionRouter>, String> {
let router = runtime_local_workdir_router(local_attachments, scope)?;
let mut local_capabilities = local_attachments
.keys()
.map(|alias| {
(
alias.clone(),
initial_workdir_session_capabilities(alias, claims),
)
})
.collect::<BTreeMap<_, _>>();
for attachment in logical_attachments {
if local_attachments.contains_key(&attachment.alias) {
local_capabilities.insert(attachment.alias.clone(), attachment.capabilities);
}
}
let router = runtime_local_workdir_router(local_attachments, &local_capabilities, scope)?;
for attachment in logical_attachments {
if let Some(local) = local_attachments.get(&attachment.alias) {
if local.working_directory.id != attachment.working_directory_id {
@@ -1068,8 +1102,22 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
})?;
let worker = prepared.worker_mut();
validate_worker_memory_settings(worker.manifest(), &request.request)?;
let workdir_capabilities = request
.workdir_attachments
.keys()
.map(|alias| {
(
alias.clone(),
initial_workdir_session_capabilities(
alias,
&request.request.workdir_attachments,
),
)
})
.collect::<BTreeMap<_, _>>();
worker.bind_workdir_sessions(runtime_local_workdir_router(
&request.workdir_attachments,
&workdir_capabilities,
worker.scope().clone(),
)?);
if let (Some(runtime_id), Some(workspace_id)) =
@@ -1300,6 +1348,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
let workdir_sessions = restored_workdir_router(
&request.workdir_attachments,
&request.logical_workdir_attachments,
&request.request.workdir_attachments,
worker.scope().clone(),
worker.workspace_client_handle(),
)?;
@@ -2177,6 +2226,7 @@ where
binding.root(),
binding.cwd(),
manifest::SharedScope::new(scope),
WorkdirSessionCapabilities::ALL,
binding.command_environment(),
binding.session_resources(),
))
@@ -2979,6 +3029,7 @@ mod tests {
working_directory_id: "remote-workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
}],
&[],
scope,
client,
)
@@ -2992,6 +3043,37 @@ mod tests {
);
}
#[test]
fn restored_router_preserves_read_only_capabilities_for_local_attachment() {
let runtime_base = tempfile::tempdir().unwrap();
let repo = create_clean_repo();
let materializer = RuntimeGitMaterializer::new(runtime_base.path());
let worker_ref = WorkerRef::new(crate::identity::WorkerId::now_v7());
let binding = materializer
.materialize(&worker_ref, &working_directory_request(repo.path()))
.unwrap();
let alias = WorkdirAttachmentAlias::new("docs").unwrap();
let workdir_id = binding.working_directory.id.clone();
let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap());
let attachments = BTreeMap::from([(alias.clone(), binding)]);
let logical = [LogicalWorkdirAttachment {
alias: alias.clone(),
working_directory_id: workdir_id,
capabilities: WorkdirSessionCapabilities::READ_ONLY,
}];
let client = WorkerWorkspaceContext::local_filesystem(Some(
WorkspaceId::new("workspace-a").unwrap(),
))
.client_handle();
let router = restored_workdir_router(&attachments, &logical, &[], scope, client).unwrap();
assert_eq!(
router.session(&alias).unwrap().capabilities(),
WorkdirSessionCapabilities::READ_ONLY
);
}
#[test]
fn profile_factory_routes_workspace_requests_by_workspace_id() {
let profiles = tempfile::tempdir().unwrap();
@@ -3949,13 +4031,35 @@ mod tests {
}
#[test]
fn restore_opens_a_fresh_session_for_the_same_workdir_identity() {
fn initial_claim_capabilities_override_full_materialization_default() {
let checkout = WorkdirAttachmentAlias::new("checkout").unwrap();
let docs = WorkdirAttachmentAlias::new("docs").unwrap();
let claims = [WorkingDirectoryAttachmentClaim {
alias: docs.clone(),
working_directory_id: "workdir-docs".to_string(),
relative_cwd: None,
capabilities: WorkdirSessionCapabilities::READ_ONLY,
}];
assert_eq!(
initial_workdir_session_capabilities(&docs, &claims),
WorkdirSessionCapabilities::READ_ONLY
);
assert_eq!(
initial_workdir_session_capabilities(&checkout, &claims),
WorkdirSessionCapabilities::ALL
);
}
#[test]
fn restore_opens_a_fresh_session_with_the_same_read_only_capabilities() {
let root = tempfile::tempdir().unwrap();
let spawned = runtime_local_workdir_session(
"working-directory-42",
root.path(),
root.path(),
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
WorkdirSessionCapabilities::READ_ONLY,
Default::default(),
Vec::new(),
);
@@ -3964,12 +4068,21 @@ mod tests {
root.path(),
root.path(),
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
WorkdirSessionCapabilities::READ_ONLY,
Default::default(),
Vec::new(),
);
assert_eq!(spawned.workdir().id().as_str(), "working-directory-42");
assert_eq!(restored.workdir().id().as_str(), "working-directory-42");
assert_eq!(
spawned.capabilities(),
WorkdirSessionCapabilities::READ_ONLY
);
assert_eq!(
restored.capabilities(),
WorkdirSessionCapabilities::READ_ONLY
);
assert!(!Arc::ptr_eq(&spawned, &restored));
}
@@ -4924,6 +5037,7 @@ mod tests {
alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
working_directory_id: workdir_id.clone(),
relative_cwd: None,
capabilities: workdir::WorkdirSessionCapabilities::ALL,
}];
let error = runtime.create_worker(request).unwrap_err();
@@ -458,11 +458,21 @@ pub trait WorkerLifecycleService: Send + Sync {
) -> Result<WorkspaceResponse, WorkspaceClientError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerLifecycleWorkdirAttachment {
/// Stable Worker-local routing alias preserved into the canonical create request.
pub alias: String,
/// Workspace-authoritative Workdir id preserved into the canonical create request.
pub working_directory_id: String,
/// Optional normalized initial cwd relative to this Workdir.
pub relative_cwd: Option<String>,
}
#[derive(Debug, Clone)]
pub struct WorkerLifecycleSpawnRequest {
pub runtime_id: String,
pub working_directory_id: String,
pub relative_cwd: Option<String>,
/// Ordered alias-keyed selections forwarded as canonical Workdir attachments.
pub workdir_attachments: Vec<WorkerLifecycleWorkdirAttachment>,
pub profile: String,
pub ticket_id: Option<String>,
pub operation_id: Option<String>,
@@ -501,11 +511,15 @@ fn workspace_worker_create_request(
profile: Some(request.profile),
ticket_assignment,
initial_submit: request.initial_submit,
workdir_attachments: vec![BrowserWorkerWorkingDirectorySelection {
alias: INITIAL_WORKDIR_ALIAS.to_string(),
working_directory_id: request.working_directory_id,
relative_cwd: request.relative_cwd,
}],
workdir_attachments: request
.workdir_attachments
.into_iter()
.map(|attachment| BrowserWorkerWorkingDirectorySelection {
alias: attachment.alias,
working_directory_id: attachment.working_directory_id,
relative_cwd: attachment.relative_cwd,
})
.collect(),
control_operation_id: Some(control_operation_id),
})
}
@@ -698,6 +712,8 @@ struct WorkerListInput {}
#[serde(deny_unknown_fields)]
struct WorkerSpawnInput {
runtime_id: String,
/// Singular Workdir selection wrapped into the shared attachment collection
/// under the stable `workdir` alias.
working_directory_id: String,
profile: String,
/// Optional inprogress Ticket already accepted by the Orchestrator. Set
@@ -831,7 +847,7 @@ impl WorkerOperation {
"List only known Runtime Workers and direct SubWorkers granted to the current Worker."
}
Self::Spawn => {
"Spawn a Backend/Runtime Worker session in an existing Workspace Workdir. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
}
Self::SendInput => "Send user input to a known Runtime Worker when allowed.",
Self::Notify => "Send an advisory notification to a known Runtime Worker when allowed.",
@@ -878,14 +894,17 @@ impl Tool for WorkspaceWorkerTool {
self.control
.spawn_worker(WorkerLifecycleSpawnRequest {
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
working_directory_id: authority_id(
&input.working_directory_id,
"working_directory_id",
)?,
relative_cwd: input
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
workdir_attachments: vec![WorkerLifecycleWorkdirAttachment {
alias: INITIAL_WORKDIR_ALIAS.to_string(),
working_directory_id: authority_id(
&input.working_directory_id,
"working_directory_id",
)?,
relative_cwd: input
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
}],
profile: non_empty(input.profile, "profile")?,
ticket_id,
operation_id,
@@ -1390,6 +1409,7 @@ mod tests {
&serde_json::json!({
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1",
"relative_cwd": "crates/worker",
"profile": "builtin:coder",
"ticket_id": "00001KZ9E0DBS",
"initial_submit": [
@@ -1435,7 +1455,7 @@ mod tests {
);
assert_eq!(
body["workdir_attachments"][0]["relative_cwd"],
serde_json::Value::Null
"crates/worker"
);
assert!(body.get("working_directory").is_none());
assert!(body.get("initial_text").is_none());
@@ -1709,11 +1729,21 @@ mod tests {
}
#[test]
fn worker_spawn_request_uses_canonical_initial_attachment_contract() {
fn worker_lifecycle_spawn_request_preserves_canonical_attachment_collection() {
let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
runtime_id: "runtime-1".to_string(),
working_directory_id: "wd-1".to_string(),
relative_cwd: Some("repo".to_string()),
workdir_attachments: vec![
WorkerLifecycleWorkdirAttachment {
alias: "checkout".to_string(),
working_directory_id: "wd-1".to_string(),
relative_cwd: Some("repo".to_string()),
},
WorkerLifecycleWorkdirAttachment {
alias: "docs".to_string(),
working_directory_id: "wd-2".to_string(),
relative_cwd: None,
},
],
profile: "builtin:coder".to_string(),
ticket_id: None,
operation_id: None,
@@ -1728,16 +1758,22 @@ mod tests {
.unwrap();
let value = serde_json::to_value(request).unwrap();
assert_eq!(value["runtime_id"], "runtime-1");
assert_eq!(value["workdir_attachments"].as_array().unwrap().len(), 1);
assert_eq!(
value["workdir_attachments"][0]["alias"],
INITIAL_WORKDIR_ALIAS
);
assert_eq!(value["workdir_attachments"].as_array().unwrap().len(), 2);
assert_eq!(value["workdir_attachments"][0]["alias"], "checkout");
assert_eq!(
value["workdir_attachments"][0]["working_directory_id"],
"wd-1"
);
assert_eq!(value["workdir_attachments"][0]["relative_cwd"], "repo");
assert_eq!(value["workdir_attachments"][1]["alias"], "docs");
assert_eq!(
value["workdir_attachments"][1]["working_directory_id"],
"wd-2"
);
assert_eq!(
value["workdir_attachments"][1]["relative_cwd"],
serde_json::Value::Null
);
assert!(value.get("working_directory").is_none());
assert!(value.get("cwd").is_none());
assert!(value.get("runtime_url").is_none());
@@ -1,5 +1,6 @@
//! Semantic Ticket orchestration tools backed by Feature Services.
use std::collections::BTreeSet;
use std::sync::Arc;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -10,6 +11,7 @@ use serde::Deserialize;
use super::manage_worker::{
WORKER_LIFECYCLE_SERVICE_ID, WorkerLifecycleService, WorkerLifecycleSpawnRequest,
WorkerLifecycleWorkdirAttachment,
};
use super::ticket::{TICKET_SERVICE_ID, TicketService};
use crate::feature::{
@@ -43,7 +45,7 @@ impl FeatureModule for OrchestrationFeature {
))
.with_tool(ToolDeclaration::new(
TOOL_NAME,
"Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. The guarded operation records queued acceptance only after spawn, initial input, assignment, and Workdir finalization. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.",
"Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. Select every required Workdir by stable alias; Backend authority derives each attachment's access from the Ticket targets. The guarded operation records queued acceptance only after spawn, initial input, assignment, and Workdir finalization. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.",
))
}
@@ -64,14 +66,26 @@ impl FeatureModule for OrchestrationFeature {
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SpawnTicketCoderWorkdirInput {
/// Stable Worker-local routing alias (for example `checkout` or `docs`).
alias: String,
/// Workspace-authoritative Workdir id. Paths and URLs are not accepted.
working_directory_id: String,
/// Optional normalized path inside this Workdir for the Worker's initial cwd.
#[serde(default)]
relative_cwd: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SpawnTicketCoderInput {
ticket_id: String,
runtime_id: String,
working_directory_id: String,
#[serde(default)]
relative_cwd: Option<String>,
/// Every Workdir required by the Ticket target set. Backend authority derives
/// effective read/write capabilities; callers cannot request capabilities.
workdir_attachments: Vec<SpawnTicketCoderWorkdirInput>,
}
struct SpawnTicketCoderTool {
@@ -105,16 +119,13 @@ impl Tool for SpawnTicketCoderTool {
)));
}
let call_id = non_empty(ctx.call_id, "tool call_id")?;
let relative_cwd = input.relative_cwd.map(validate_relative_cwd).transpose()?;
let runtime_id = authority_id(input.runtime_id, "runtime_id")?;
let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?;
let response = self
.worker_service
.spawn(WorkerLifecycleSpawnRequest {
runtime_id: authority_id(input.runtime_id, "runtime_id")?,
working_directory_id: authority_id(
input.working_directory_id,
"working_directory_id",
)?,
relative_cwd,
runtime_id,
workdir_attachments,
profile: CODER_PROFILE.to_string(),
ticket_id: Some(ticket.id.clone()),
operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)),
@@ -142,6 +153,47 @@ impl Tool for SpawnTicketCoderTool {
}
}
fn validate_workdir_attachments(
attachments: Vec<SpawnTicketCoderWorkdirInput>,
) -> Result<Vec<WorkerLifecycleWorkdirAttachment>, ToolError> {
if attachments.is_empty() {
return Err(ToolError::InvalidArgument(
"workdir_attachments must contain at least one Workdir".to_string(),
));
}
let mut aliases = BTreeSet::new();
let mut workdir_ids = BTreeSet::new();
attachments
.into_iter()
.map(|attachment| {
let alias = authority_id(attachment.alias, "workdir_attachments.alias")?;
if !aliases.insert(alias.clone()) {
return Err(ToolError::InvalidArgument(format!(
"duplicate Workdir attachment alias `{alias}`"
)));
}
let working_directory_id = authority_id(
attachment.working_directory_id,
"workdir_attachments.working_directory_id",
)?;
if !workdir_ids.insert(working_directory_id.clone()) {
return Err(ToolError::InvalidArgument(format!(
"duplicate Workdir attachment `{working_directory_id}`"
)));
}
Ok(WorkerLifecycleWorkdirAttachment {
alias,
working_directory_id,
relative_cwd: attachment
.relative_cwd
.map(validate_relative_cwd)
.transpose()?,
})
})
.collect()
}
fn definition(
ticket_service: Arc<dyn TicketService>,
worker_service: Arc<dyn WorkerLifecycleService>,
@@ -150,7 +202,9 @@ fn definition(
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketCoderInput))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new(TOOL_NAME)
.description("Spawn and atomically assign a policy-configured Coder for a Ticket.")
.description(
"Spawn and atomically assign a policy-configured Coder for a Ticket with every required alias-keyed Workdir attachment.",
)
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketCoderTool {
ticket_service: ticket_service.clone(),
@@ -260,8 +314,17 @@ mod tests {
&serde_json::json!({
"ticket_id": "T-482",
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1",
"relative_cwd": "crates/yoi"
"workdir_attachments": [
{
"alias": "checkout",
"working_directory_id": "workdir-1",
"relative_cwd": "crates/yoi"
},
{
"alias": "docs",
"working_directory_id": "workdir-2"
}
]
})
.to_string(),
ToolExecutionContext::new("call-7", "batch-1", 0),
@@ -278,8 +341,22 @@ mod tests {
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
);
assert_eq!(request.display_name, "Coder · T-482");
assert_eq!(request.working_directory_id, "workdir-1");
assert_eq!(request.relative_cwd.as_deref(), Some("crates/yoi"));
assert_eq!(request.workdir_attachments.len(), 2);
assert_eq!(request.workdir_attachments[0].alias, "checkout");
assert_eq!(
request.workdir_attachments[0].working_directory_id,
"workdir-1"
);
assert_eq!(
request.workdir_attachments[0].relative_cwd.as_deref(),
Some("crates/yoi")
);
assert_eq!(request.workdir_attachments[1].alias, "docs");
assert_eq!(
request.workdir_attachments[1].working_directory_id,
"workdir-2"
);
assert_eq!(request.workdir_attachments[1].relative_cwd, None);
assert_eq!(
request.initial_submit,
vec![
@@ -307,7 +384,10 @@ mod tests {
&serde_json::json!({
"ticket_id": "00001KZXN51C7",
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1"
"workdir_attachments": [{
"alias": "checkout",
"working_directory_id": "workdir-1"
}]
})
.to_string(),
ToolExecutionContext::new("call-queued", "batch-1", 0),
@@ -318,6 +398,106 @@ mod tests {
assert!(worker_service.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn spawn_ticket_coder_rejects_invalid_workdir_collections_before_worker_side_effect() {
let worker_service = Arc::new(RecordingService::default());
let tool = SpawnTicketCoderTool {
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
worker_service: worker_service.clone(),
};
let cases = [
(
serde_json::json!({
"ticket_id": "T-482",
"runtime_id": "runtime-1",
"workdir_attachments": []
}),
"must contain at least one Workdir",
),
(
serde_json::json!({
"ticket_id": "T-482",
"runtime_id": "runtime-1",
"workdir_attachments": [
{"alias": "checkout", "working_directory_id": "workdir-1"},
{"alias": " checkout ", "working_directory_id": "workdir-2"}
]
}),
"duplicate Workdir attachment alias `checkout`",
),
(
serde_json::json!({
"ticket_id": "T-482",
"runtime_id": "runtime-1",
"workdir_attachments": [
{"alias": "checkout", "working_directory_id": "workdir-1"},
{"alias": "docs", "working_directory_id": " workdir-1 "}
]
}),
"duplicate Workdir attachment `workdir-1`",
),
(
serde_json::json!({
"ticket_id": "T-482",
"runtime_id": "runtime-1",
"workdir_attachments": [
{"alias": "docs/path", "working_directory_id": "workdir-1"}
]
}),
"workdir_attachments.alias must be an authority id",
),
(
serde_json::json!({
"ticket_id": "T-482",
"runtime_id": "runtime-1",
"workdir_attachments": [
{"alias": "docs", "working_directory_id": "https://example.test/workdir"}
]
}),
"workdir_attachments.working_directory_id must be an authority id",
),
(
serde_json::json!({
"ticket_id": "T-482",
"runtime_id": "runtime/1",
"workdir_attachments": [
{"alias": "checkout", "working_directory_id": "workdir-1"}
]
}),
"runtime_id must be an authority id",
),
(
serde_json::json!({
"ticket_id": "T-482",
"runtime_id": "runtime-1",
"workdir_attachments": [
{
"alias": "checkout",
"working_directory_id": "workdir-1",
"relative_cwd": "../outside"
}
]
}),
"relative_cwd must be a normalized relative path inside the Workdir",
),
];
for (index, (input, expected)) in cases.into_iter().enumerate() {
let error = tool
.execute(
&input.to_string(),
ToolExecutionContext::new(format!("call-{index}"), "batch-validation", index),
)
.await
.unwrap_err();
assert!(
error.to_string().contains(expected),
"unexpected error for case {index}: {error}"
);
}
assert!(worker_service.requests.lock().unwrap().is_empty());
}
#[test]
fn orchestration_descriptor_requires_ticket_and_worker_services() {
let descriptor = orchestration_feature().descriptor();
@@ -341,6 +521,8 @@ mod tests {
for field in [
"ticket_id",
"runtime_id",
"workdir_attachments",
"alias",
"working_directory_id",
"relative_cwd",
] {
+87 -26
View File
@@ -840,7 +840,24 @@ impl SqliteWorkspaceAuthority {
let has_coder = role_assignments
.iter()
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some();
let targets = ticket
.meta
.targets
.iter()
.map(|target| {
let repository = self
.store
.get_repository_by_key(&self.workspace_id, &target.repository_key)?
.ok_or_else(|| Error::UnknownRepository(target.repository_key.clone()))?;
Ok::<_, Error>(ticket::TicketTarget {
repository_key: repository.repository_key,
ref_selector: target.ref_selector.clone(),
access: target.access,
})
})
.collect::<Result<Vec<_>>>()?;
let write_repository_key = ticket_write_target_repository_key(&targets);
let has_target = write_repository_key.is_some();
let has_blockers = !ticket.relations.blockers.is_empty();
let mut queue_assignment_blockers = Vec::new();
for ticket_id in &dependency_check.queue_tickets {
@@ -932,22 +949,8 @@ impl SqliteWorkspaceAuthority {
Err(MergeRequestError::NotFound) => None,
Err(error) => return Err(Error::Store(error.to_string())),
};
let repository_key = ticket
.meta
.repository_id
.as_deref()
.map(|repository_id| {
self.store
.get_repository(&self.workspace_id, repository_id)?
.map(|repository| repository.repository_key)
.ok_or_else(|| Error::UnknownRepository(repository_id.to_string()))
})
.transpose()?;
let evidence = ticket_evidence_summary(
repository_key.as_deref(),
&ticket.events,
merge_request.as_ref(),
);
let evidence =
ticket_evidence_summary(write_repository_key, &ticket.events, merge_request.as_ref());
let item_revision = ticket
.events
.iter()
@@ -1000,8 +1003,7 @@ impl SqliteWorkspaceAuthority {
item_revision,
queued_by: ticket.meta.queued_by,
queued_at: ticket.meta.queued_at,
repository_key,
ref_selector: ticket.meta.ref_selector,
targets,
risk_flags: ticket.meta.risk_flags,
body,
body_truncated,
@@ -1837,7 +1839,7 @@ fn substantive_item_edit(event: &TicketEvent) -> bool {
changes
.split(',')
.map(str::trim)
.any(|field| matches!(field, "title" | "body" | "target"))
.any(|field| matches!(field, "title" | "body" | "target" | "targets"))
}
fn event_timestamp(event: &TicketEvent) -> Option<DateTime<Utc>> {
@@ -1848,15 +1850,26 @@ fn event_timestamp(event: &TicketEvent) -> Option<DateTime<Utc>> {
.map(|value| value.with_timezone(&Utc))
}
fn ticket_write_target_repository_key(targets: &[ticket::TicketTarget]) -> Option<&str> {
let mut write_targets = targets
.iter()
.filter(|target| target.access == ticket::TicketTargetAccess::ReadWrite);
let write_target = write_targets.next()?;
write_targets
.next()
.is_none()
.then_some(write_target.repository_key.as_str())
}
fn ticket_evidence_summary(
ticket_repository_id: Option<&str>,
ticket_write_repository_key: Option<&str>,
events: &[TicketEvent],
merge_request: Option<&TicketMergeRequestSummary>,
) -> TicketEvidenceSummary {
let linked_merge_request = merge_request.filter(|request| {
request.state == "open"
&& ticket_repository_id
.is_some_and(|repository_id| repository_id == request.repository_key)
&& ticket_write_repository_key
.is_some_and(|repository_key| repository_key == request.repository_key)
});
let has_merge_request = linked_merge_request.is_some();
let has_current_subject_ref = linked_merge_request.is_some_and(|request| {
@@ -1906,8 +1919,8 @@ fn ticket_evidence_summary(
None => missing.push("merge_request".to_string()),
Some(request) if request.state != "open" => missing.push("open_merge_request".to_string()),
Some(request)
if ticket_repository_id
.is_none_or(|repository_id| repository_id != request.repository_key) =>
if ticket_write_repository_key
.is_none_or(|repository_key| repository_key != request.repository_key) =>
{
missing.push("merge_request_repository".to_string())
}
@@ -2865,6 +2878,54 @@ mod tests {
assert_eq!(moved.review_submitted_at, None);
}
#[test]
fn ticket_target_projection_requires_exactly_one_write_target() {
let target = |repository_key: &str, access| ticket::TicketTarget {
repository_key: repository_key.to_string(),
ref_selector: Some("develop".to_string()),
access,
};
assert_eq!(ticket_write_target_repository_key(&[]), None);
assert_eq!(
ticket_write_target_repository_key(&[target(
"docs",
ticket::TicketTargetAccess::ReadOnly,
)]),
None,
);
let one_write = [
target("main", ticket::TicketTargetAccess::ReadWrite),
target("docs", ticket::TicketTargetAccess::ReadOnly),
];
assert_eq!(ticket_write_target_repository_key(&one_write), Some("main"),);
let two_writes = [
target("main", ticket::TicketTargetAccess::ReadWrite),
target("docs", ticket::TicketTargetAccess::ReadWrite),
];
assert_eq!(ticket_write_target_repository_key(&two_writes), None);
}
#[test]
fn ticket_evidence_is_tied_only_to_the_write_target() {
let read_only_request = merge_request_summary(
reviewed_merge_request(ReviewDecision::Approve, false),
"docs".to_string(),
Some("commit-1".to_string()),
);
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&read_only_request));
assert!(!evidence.has_merge_request);
assert!(!evidence.complete_for_integration);
assert!(
evidence
.missing
.contains(&"merge_request_repository".to_string())
);
}
#[test]
fn ticket_readiness_requires_current_unrevoked_approval_without_a_report() {
let approved = merge_request_summary(
@@ -3013,7 +3074,7 @@ mod tests {
let stale_events = vec![ticket_event(
"item_edit",
"2026-01-01T00:05:00Z",
Some("target"),
Some("targets"),
)];
let stale = ticket_evidence_summary(Some("main"), &stale_events, Some(&approved_summary));
assert!(ticket_attention_matches(
+1
View File
@@ -6857,6 +6857,7 @@ mod tests {
alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
working_directory_id: "workdir-1".to_string(),
relative_cwd: Some("crates/yoi".to_string()),
capabilities: workdir::WorkdirSessionCapabilities::ALL,
}];
let profile_source =
profile_source_archive_source(&request, &request.profile).expect("profile source");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -56,6 +56,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"typed_ticket_raw_frontmatter",
"typed_ticket_relations",
"typed_ticket_risk_flags",
"typed_ticket_targets",
"typed_tickets",
"workdir_create_credential_candidates",
"workdir_create_credential_revision_retentions",
+66 -58
View File
@@ -408,19 +408,16 @@
"const": "set",
"type": "string"
},
"ref_selector": {
"type": [
"string",
"null"
]
},
"repository_key": {
"type": "string"
"targets": {
"items": {
"$ref": "#/components/schemas/TicketTarget"
},
"type": "array"
}
},
"required": [
"action",
"repository_key"
"targets"
],
"type": "object"
},
@@ -4254,18 +4251,6 @@
"null"
]
},
"ref_selector": {
"type": [
"string",
"null"
]
},
"repository_key": {
"type": [
"string",
"null"
]
},
"risk_flags": {
"items": {
"type": "string"
@@ -4278,6 +4263,13 @@
"null"
]
},
"targets": {
"default": [],
"items": {
"$ref": "#/components/schemas/TicketTarget"
},
"type": "array"
},
"title": {
"type": "string"
},
@@ -8974,21 +8966,9 @@
"record_source": {
"type": "string"
},
"ref_selector": {
"type": [
"string",
"null"
]
},
"relations": {
"$ref": "#/components/schemas/TicketDetailRelationView"
},
"repository_key": {
"type": [
"string",
"null"
]
},
"resolution": {
"type": [
"string",
@@ -9007,6 +8987,12 @@
"state": {
"type": "string"
},
"targets": {
"items": {
"$ref": "#/components/schemas/TicketTarget"
},
"type": "array"
},
"title": {
"type": "string"
},
@@ -9024,6 +9010,7 @@
"state",
"priority",
"item_revision",
"targets",
"risk_flags",
"body",
"body_truncated",
@@ -9659,10 +9646,10 @@
],
"default": null
},
"target": {
"targets": {
"anyOf": [
{
"$ref": "#/components/schemas/TicketTargetEdit"
"$ref": "#/components/schemas/TicketTargetsEdit"
},
{
"type": "null"
@@ -9951,18 +9938,6 @@
"null"
]
},
"ref_selector": {
"type": [
"string",
"null"
]
},
"repository_key": {
"type": [
"string",
"null"
]
},
"resource_key": {
"type": [
"string",
@@ -9981,6 +9956,13 @@
"status": {
"$ref": "#/components/schemas/ExtensibleTicketStatus"
},
"targets": {
"default": [],
"items": {
"$ref": "#/components/schemas/TicketTarget"
},
"type": "array"
},
"title": {
"type": "string"
},
@@ -10825,7 +10807,36 @@
},
"type": "object"
},
"TicketTargetEdit": {
"TicketTarget": {
"additionalProperties": false,
"properties": {
"access": {
"$ref": "#/components/schemas/TicketTargetAccess"
},
"ref_selector": {
"type": [
"string",
"null"
]
},
"repository_key": {
"type": "string"
}
},
"required": [
"repository_key",
"access"
],
"type": "object"
},
"TicketTargetAccess": {
"enum": [
"read_only",
"read_write"
],
"type": "string"
},
"TicketTargetsEdit": {
"oneOf": [
{
"properties": {
@@ -10833,19 +10844,16 @@
"const": "set",
"type": "string"
},
"ref_selector": {
"type": [
"string",
"null"
]
},
"repository_key": {
"type": "string"
"targets": {
"items": {
"$ref": "#/components/schemas/TicketTarget"
},
"type": "array"
}
},
"required": [
"action",
"repository_key"
"targets"
],
"type": "object"
},
@@ -13893,7 +13901,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f"
"x-yoi-source-digest": "sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c"
},
"openapi": "3.1.0",
"paths": {
+1 -1
View File
@@ -4,6 +4,6 @@ Use the existing Merge Request as the routine authority for review requests, ver
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.
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose.
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. Only the Ticket's sole `read_write` target is an implementation/publish/Merge Request repository; `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.
Before opening a Merge Request, publish only the committed Ticket work branch with a normal non-force push and verify that the Ticket repository remote resolves it to the exact local `HEAD`; a local branch name or dirty Workdir is not immutable review evidence. Do not push the target branch, tags, or unrelated refs, and never force-push.
+1 -1
View File
@@ -2,7 +2,7 @@ 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. For an actionable `queued` Ticket, call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, current assignment, Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If spawn or finalization fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication of the Ticket source work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope.
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. For an actionable `queued` Ticket, select exactly one current Workdir for every declared Ticket target and call `SpawnTicketCoder` once with the complete alias-keyed `workdir_attachments` collection; use stable semantic aliases such as `checkout` for the sole `read_write` target and repository-derived aliases for `read_only` targets. Never omit a target, attach an undeclared repository, or request attachment capabilities: Ticket access and Backend validation are authoritative. Call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, one current assignment, every Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If any attachment, Runtime, assignment, or finalization step fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication from the sole `read_write` target's Ticket work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope.
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.
@@ -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:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
// 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:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
// 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:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
// 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
+15 -3
View File
@@ -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:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
// 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
@@ -13,7 +13,7 @@ export type BrowserEditTicketRequest = { author?: string | null; body?: string |
export type BrowserQueueTicketRequest = { [key: string]: never; };
export type BrowserTicketTargetEdit = { action: "set"; ref_selector?: string | null; repository_key: string; } | { action: "clear"; };
export type BrowserTicketTargetEdit = { action: "set"; targets: Array<TicketTarget>; } | { action: "clear"; };
export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report";
@@ -55,6 +55,8 @@ export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string
export type MergeStrategy = "fast_forward" | "merge";
export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketWorkflowState | null; };
export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array<ObjectiveEventDetail>; id: string; linked_ticket_summaries: Array<ObjectiveLinkedTicketSummary>; linked_tickets: Array<string>; record_source: string; resource_key: string; resources: Array<ObjectiveResourceSummary>; revision: string; state: string; title: string; updated_at?: string | null; };
export type ObjectiveEventDetail = { body?: string | null; created_at: string; event_ref: string; kind: string; };
@@ -87,7 +89,7 @@ export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contrib
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; item_revision: string; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; ref_selector?: string | null; relations: TicketDetailRelationView; repository_key?: string | null; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; title: string; updated_at?: string | null; };
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; item_revision: string; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
@@ -117,6 +119,8 @@ export type TicketMergeRequestSummary = { current_subject_ref?: string | null; m
export type TicketQueueOutcome = { queued_tickets: Array<string>; requested_ticket: string; };
export type TicketRef = { id: string; resource_key?: string | null; slug: string; status: TicketStatus; };
export type TicketReference = { kind: string; target: string; };
export type TicketRoleAssignmentMutationResponse = { assignment?: TicketRoleAssignmentRecord | null; ticket_id: string; workspace_id: string; };
@@ -124,3 +128,11 @@ export type TicketRoleAssignmentMutationResponse = { assignment?: TicketRoleAssi
export type TicketRoleAssignmentRecord = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: TicketAssignmentRole; ticket_id: string; workspace_id: string; };
export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: string; };
export type TicketStatus = "Open" | "Closed";
export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; };
export type TicketTargetAccess = "read_only" | "read_write";
export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed";
@@ -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:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
// 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
@@ -5,7 +5,9 @@ declare const Deno: {
import {
parseMergeRequestDetailResponse,
parseObjectiveListResponse,
parseTicketDetail,
parseTicketListResponse,
parseTicketRecordRef,
} from "./ticket-browser.ts";
function assertEquals(actual: unknown, expected: unknown): void {
@@ -78,6 +80,107 @@ Deno.test("Ticket Browser parser rejects unknown fields and out-of-range limits"
);
});
Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => {
const fixture = {
action_eligibility: {
blockers: [],
can_assign_orchestrator: false,
can_queue: false,
can_start_manual_coder: false,
can_unassign_orchestrator: false,
queue_tickets: [],
},
artifact_count: 0,
artifacts: [],
assignment_diagnostics: [],
assignments: [],
body: "Body",
body_truncated: false,
current_coder: null,
event_count: 0,
event_page: { ...page, returned: 0 },
events: [],
evidence: {
approved_current_subject: false,
complete_for_integration: false,
has_commit: false,
has_current_subject_ref: false,
has_merge_request: false,
has_review_request: false,
missing: [],
review_after_rescope: false,
review_status: null,
unresolved_request_changes: false,
},
id: "ticket-id",
implementation_reports: [],
item_revision: "revision-1",
linked_objectives: [],
merge_request: null,
priority: "P2",
queued_at: null,
queued_by: null,
readiness: null,
record_source: "sqlite",
relations: { blockers: [], incoming: [], notices: [], outgoing: [] },
resolution: null,
resource_key: "T-1",
risk_flags: [],
state: "planning",
targets: [
{
repository_key: "docs",
ref_selector: "main",
access: "read_only",
},
{
repository_key: "main",
ref_selector: "develop",
access: "read_write",
},
],
title: "Ticket",
};
const parsed = parseTicketDetail(fixture);
assertEquals(parsed.targets.length, 2);
assertEquals(parsed.targets[0]?.repository_key, "docs");
assertEquals(parsed.targets[0]?.access, "read_only");
assertEquals(parsed.targets[1]?.repository_key, "main");
assertEquals(parsed.targets[1]?.access, "read_write");
assertThrows(
() =>
parseTicketDetail({
...fixture,
targets: [{ ...fixture.targets[0], access: "write" }],
}),
"targets[0].access is invalid",
);
assertThrows(
() =>
parseTicketDetail({
...fixture,
repository_key: "main",
ref_selector: "develop",
}),
"unknown field repository_key",
);
});
Deno.test("Ticket Browser parser validates Ticket create responses", () => {
const parsed = parseTicketRecordRef({
id: "ticket-id",
resource_key: "T-1",
slug: "ticket",
status: "Open",
});
assertEquals(parsed.resource_key, "T-1");
assertThrows(
() => parseTicketRecordRef({ ...parsed, status: "open" }),
"status is invalid",
);
});
Deno.test("Ticket Browser parser accepts the generated Objective list shape", () => {
const parsed = parseObjectiveListResponse({
workspace_id: "workspace-a",
@@ -32,9 +32,11 @@ import type {
TicketListResponse,
TicketMergeRequestSummary,
TicketQueueOutcome,
TicketRef,
TicketRoleAssignmentMutationResponse,
TicketRoleAssignmentRecord,
TicketRoleAssignmentSummary,
TicketTarget,
} from "$lib/generated/ticket-api.ts";
export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
@@ -671,6 +673,23 @@ function parseObjectiveLink(value: unknown, label: string) {
};
}
function parseTicketTarget(value: unknown, label: string): TicketTarget {
const item = object(value, label);
exact(item, ["access", "ref_selector", "repository_key"], label);
const access = string(item.access, `${label}.access`);
if (access !== "read_only" && access !== "read_write") {
throw new Error(`${label}.access is invalid`);
}
return {
access,
ref_selector: optionalNullableString(
item.ref_selector,
`${label}.ref_selector`,
),
repository_key: string(item.repository_key, `${label}.repository_key`),
};
}
export function parseTicketDetail(value: unknown): TicketDetail {
const label = "Ticket detail";
const item = object(value, label);
@@ -698,13 +717,12 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"queued_by",
"readiness",
"record_source",
"ref_selector",
"relations",
"repository_key",
"resolution",
"resource_key",
"risk_flags",
"state",
"targets",
"title",
"updated_at",
], label);
@@ -764,24 +782,36 @@ export function parseTicketDetail(value: unknown): TicketDetail {
queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`),
readiness: optionalNullableString(item.readiness, `${label}.readiness`),
record_source: string(item.record_source, `${label}.record_source`),
ref_selector: optionalNullableString(
item.ref_selector,
`${label}.ref_selector`,
),
relations: parseRelationView(item.relations, `${label}.relations`),
repository_key: optionalNullableString(
item.repository_key,
`${label}.repository_key`,
),
resolution: optionalNullableString(item.resolution, `${label}.resolution`),
resource_key: string(item.resource_key, `${label}.resource_key`),
risk_flags: strings(item.risk_flags, `${label}.risk_flags`),
state: string(item.state, `${label}.state`),
targets: array(item.targets, `${label}.targets`, parseTicketTarget),
title: string(item.title, `${label}.title`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
};
}
export function parseTicketRecordRef(value: unknown): TicketRef {
const label = "Ticket create response";
const item = object(value, label);
exact(item, ["id", "resource_key", "slug", "status"], label);
const status = string(item.status, `${label}.status`);
if (status !== "Open" && status !== "Closed") {
throw new Error(`${label}.status is invalid`);
}
return {
id: string(item.id, `${label}.id`),
resource_key: optionalNullableString(
item.resource_key,
`${label}.resource_key`,
),
slug: string(item.slug, `${label}.slug`),
status,
};
}
export function parseTicketQueueOutcome(value: unknown): TicketQueueOutcome {
const item = object(value, "Ticket queue outcome");
exact(item, ["queued_tickets", "requested_ticket"], "Ticket queue outcome");
@@ -342,6 +342,27 @@
display: grid;
gap: 0.75rem;
}
.ticket-target-list, .ticket-target-row {
display: grid;
gap: 0.75rem;
}
.ticket-target-row {
min-width: 0;
margin: 0;
border: 0;
border-top: 1px solid var(--line);
padding: var(--space-3) 0 0;
}
.ticket-target-row:first-child {
border-top: 0;
padding-top: 0;
}
.ticket-target-row legend {
color: var(--text-strong);
font-size: var(--font-size-compact);
font-weight: 700;
padding: 0;
}
.ticket-editor label, .ticket-control-form label {
display: grid;
gap: 0.3rem;
@@ -349,6 +370,7 @@
font-size: var(--font-size-body);
}
.ticket-editor input,
.ticket-editor select,
.ticket-editor textarea,
.ticket-control-form input,
.ticket-control-form select,
@@ -88,8 +88,18 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
const ticket = {
id: "00001KYRRDVH9",
title: "Ticket panel API",
repository_key: "main repo",
ref_selector: "work/ticket",
targets: [
{
repository_key: "docs",
ref_selector: "main",
access: "read_only",
},
{
repository_key: "main repo",
ref_selector: "work/ticket",
access: "read_write",
},
],
} as TicketDetail;
assertEquals(
@@ -110,6 +120,38 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
);
});
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 () => {
const source = await Deno.readTextFile(
new URL(
@@ -138,6 +180,26 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
assertEquals(source.includes('kind: "workspace_agent"'), true);
assertEquals(source.includes('kind: "worker"'), true);
assertEquals(source.includes("ticket.assignee"), false);
assertEquals(source.includes('action: "set", targets'), true);
assertEquals(source.includes("updatedTicket.targets"), true);
assertEquals(source.includes("ticket.repository_key"), false);
assertEquals(source.includes("ticket.ref_selector"), false);
});
Deno.test("Ticket authoring creates the target collection without singular authority", async () => {
const source = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/tickets/+page.svelte",
import.meta.url,
),
);
assertEquals(source.includes("const request: NewTicket"), true);
assertEquals(source.includes("targets: normalizedCreateTargets()"), true);
assertEquals(source.includes('value="read_write"'), true);
assertEquals(source.includes('value="read_only"'), true);
assertEquals(source.includes("repository_key: create"), false);
assertEquals(source.includes("ref_selector: create"), false);
});
Deno.test("ticket detail keeps the operation rail outside main content", async () => {
@@ -107,23 +107,26 @@ export function ticketWorkerMessage(
export function ticketWorkerLaunchHref(
workspaceId: string,
ticket: Pick<
TicketDetail,
"id" | "title" | "repository_key" | "ref_selector"
>,
ticket: Pick<TicketDetail, "id" | "title" | "targets">,
role: TicketWorkerRole,
): 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),
});
if (ticket.repository_key) {
params.set("repositoryKey", ticket.repository_key);
}
if (ticket.ref_selector) {
params.set("refSelector", ticket.ref_selector);
if (implementationTarget) {
params.set("repositoryKey", implementationTarget.repository_key);
if (implementationTarget.ref_selector) {
params.set("refSelector", implementationTarget.ref_selector);
}
}
return `/w/${
encodeURIComponent(workspaceId)
@@ -1,15 +1,25 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { untrack } from "svelte";
import type { ApiResult } from "$lib/workspace/api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
loadJson,
workspaceApiJsonWithBody,
workspaceApiPath,
} from "$lib/workspace/api/http";
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
import {
TICKET_BROWSER_API_LOAD_POLICY,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketListResponse,
parseTicketRecordRef,
} from "$lib/workspace/api/ticket-browser";
import type {
NewTicket,
QueryPage,
TicketListItemSummary as TicketSummary,
TicketTarget,
TicketTargetAccess,
} from "$lib/generated/ticket-api";
import { ticketHref } from "$lib/workspace/resource-links";
import {
@@ -26,6 +36,12 @@
error: string | null;
};
type EditableTicketTarget = {
repository_key: string;
ref_selector: string;
access: TicketTargetAccess;
};
let { data }: { data: PageData } = $props();
// svelte-ignore state_referenced_locally
let laneState = $state<Record<string, LaneState>>(
@@ -46,6 +62,25 @@
untrack(() => data.orchestrator),
);
let orchestratorStarting = $state(false);
let creatingTicket = $state(false);
let createBusy = $state(false);
let createError = $state<string | null>(null);
let createTitle = $state("");
let createBody = $state("");
let createTargets = $state<EditableTicketTarget[]>([]);
const repositories = $derived(data.repositories.data?.items ?? []);
const createTargetsSavable = $derived.by(() => {
const keys = new Set<string>();
for (const target of createTargets) {
if (
!repositories.some((repository) =>
repository.repository_key === target.repository_key
) || keys.has(target.repository_key)
) return false;
keys.add(target.repository_key);
}
return true;
});
const tickets = $derived(
Object.values(laneState).flatMap((lane) => lane.tickets),
);
@@ -99,6 +134,61 @@
if (remaining <= 96) void loadMore(laneId);
}
function addCreateTarget(): void {
createTargets.push({
repository_key: "",
ref_selector: "",
access: createTargets.some((target) => target.access === "read_write")
? "read_only"
: "read_write",
});
}
function removeCreateTarget(index: number): void {
createTargets.splice(index, 1);
}
function normalizedCreateTargets(): TicketTarget[] {
return createTargets.map((target) => ({
repository_key: target.repository_key.trim(),
ref_selector: target.ref_selector.trim() || null,
access: target.access,
}));
}
async function createTicket(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (createBusy || !createTitle.trim() || !createTargetsSavable) return;
createBusy = true;
createError = null;
const request: NewTicket = {
title: createTitle.trim(),
kind: "task",
priority: "P2",
labels: [],
body: createBody,
risk_flags: [],
workflow_state: "planning",
targets: normalizedCreateTargets(),
};
try {
const created = await workspaceApiJsonWithBody(
workspaceApiPath(data.workspaceId, "/tickets"),
{ method: "POST", body: JSON.stringify(request) },
parseTicketRecordRef,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
const reference = created.resource_key ?? created.id;
await goto(
`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(reference)}`,
);
} catch (error) {
createError = error instanceof Error ? error.message : String(error);
} finally {
createBusy = false;
}
}
async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return;
orchestratorStarting = true;
@@ -132,6 +222,11 @@
</p>
</div>
<div class="ticket-panel-controls">
<button
class="workspace-primary-button"
type="button"
onclick={() => creatingTicket = !creatingTicket}
>{creatingTicket ? "Cancel" : "Add Ticket"}</button>
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
<span class="orchestrator-status-dot"></span>
<div>
@@ -156,6 +251,50 @@
</div>
</header>
{#if creatingTicket}
<form class="ticket-editor ticket-create-form" onsubmit={createTicket}>
<h2>New Ticket</h2>
{#if createError}<p class="workspace-callout is-error" role="alert">{createError}</p>{/if}
{#if data.repositories.error}
<p class="workspace-callout is-error" role="alert">Repositories: {data.repositories.error}</p>
{/if}
<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>
{#each createTargets as target, index}
{@const selectedRepository = repositories.find((repository) => repository.repository_key === target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} required>
<option value="">Choose repository</option>
{#each repositories 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"} /></label>
<label>Access
<select bind:value={target.access}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<button class="workspace-secondary-button" type="button" onclick={() => removeCreateTarget(index)}>Remove target</button>
</fieldset>
{/each}
<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>
{/if}
<button class="workspace-primary-button" type="submit" disabled={createBusy || !createTitle.trim() || !createTargetsSavable}>
{createBusy ? "Creating…" : "Create Ticket"}
</button>
</form>
{/if}
{#if orchestrator.error}
<p class="workspace-callout is-error">
Orchestrator status: {orchestrator.error}
@@ -1,4 +1,5 @@
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseTicketListResponse,
@@ -46,18 +47,24 @@ async function loadLane(
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const [readyPlanning, inprogressQueued, doneClosed, orchestrator] =
await Promise.all([
loadLane(fetch, workspaceId, "ready-planning"),
loadLane(fetch, workspaceId, "inprogress-queued"),
loadLane(fetch, workspaceId, "done-closed"),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
const [
readyPlanning,
inprogressQueued,
doneClosed,
orchestrator,
repositories,
] = await Promise.all([
loadLane(fetch, workspaceId, "ready-planning"),
loadLane(fetch, workspaceId, "inprogress-queued"),
loadLane(fetch, workspaceId, "done-closed"),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
loadWorkspaceRepositoryList(fetch, workspaceId),
]);
return {
workspaceId,
@@ -67,5 +74,6 @@ export const load: PageLoad = async ({ fetch, params }) => {
"done-closed": doneClosed,
},
orchestrator,
repositories,
};
};
@@ -19,16 +19,42 @@
type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel";
import type { ApiResult } from "$lib/workspace/api/http";
import type {
TicketTarget,
TicketTargetAccess,
} from "$lib/generated/ticket-api";
import type {
RepositoryListResponse,
RepositorySummary,
TicketDetail,
} from "$lib/workspace/sidebar/types";
type EditableTicketTarget = {
repository_key: string;
ref_selector: string;
access: TicketTargetAccess;
};
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
state !== "done" && state !== "ready" && state !== "queued"
);
function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] {
return targets.map((target) => ({
repository_key: target.repository_key,
ref_selector: target.ref_selector ?? "",
access: target.access,
}));
}
function normalizedTargets(targets: EditableTicketTarget[]): TicketTarget[] {
return targets.map((target) => ({
repository_key: target.repository_key.trim(),
ref_selector: target.ref_selector.trim() || null,
access: target.access,
}));
}
const { data } = $props<{
data: {
workspaceId: string;
@@ -49,8 +75,9 @@
let editing = $state(false);
let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body);
let repositoryKey = $state(loadedTicket.repository_key ?? "");
let refSelector = $state(loadedTicket.ref_selector ?? "");
let targetDrafts = $state<EditableTicketTarget[]>(
editableTargets(loadedTicket.targets),
);
let nextState = $state(loadedTicket.state);
let transitionReason = $state("");
let threadRole = $state("comment");
@@ -68,23 +95,34 @@
const coderAssignment = $derived(
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
);
const selectedRepository = $derived(
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.repository_key === repositoryKey) ?? null,
);
const effectiveRefSelector = $derived(refSelector.trim() || selectedRepository?.default_ref || "");
const targetCandidateValid = $derived(
ticket.state === "planning" &&
selectedRepository !== null &&
(selectedRepository.diagnostics ?? []).length === 0 &&
effectiveRefSelector.length > 0,
);
const persistedTargetValid = $derived(
ticket.repository_key !== null &&
ticket.ref_selector !== null &&
(loadedRepositories?.items ?? []).some((repository: RepositorySummary) =>
repository.repository_key === ticket.repository_key && (repository.diagnostics ?? []).length === 0
),
);
function repositoryFor(repositoryKey: string): RepositorySummary | null {
return (loadedRepositories?.items ?? []).find((repository: RepositorySummary) =>
repository.repository_key === repositoryKey
) ?? null;
}
function effectiveRefSelector(target: EditableTicketTarget): string {
return target.ref_selector.trim() ||
repositoryFor(target.repository_key)?.default_selector || "";
}
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 ||
(repository.diagnostics ?? []).length > 0 ||
!effectiveRefSelector(target) ||
repositoryKeys.has(target.repository_key)
) return false;
repositoryKeys.add(target.repository_key);
if (target.access === "read_write") readWriteCount += 1;
}
return readWriteCount === 1;
});
const implementationStartEligible = $derived(
ticket.action_eligibility.can_start_manual_coder,
);
@@ -100,8 +138,7 @@
ticket = updatedTicket;
editTitle = updatedTicket.title;
editBody = updatedTicket.body;
repositoryKey = updatedTicket.repository_key ?? "";
refSelector = updatedTicket.ref_selector ?? "";
targetDrafts = editableTargets(updatedTicket.targets);
nextState = updatedTicket.state;
}
@@ -276,32 +313,35 @@
) editing = false;
}
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);
}
function targetEditBody(): Record<string, unknown> {
const targets = normalizedTargets(targetDrafts);
return {
target: targets.length > 0
? { action: "set", targets }
: { action: "clear" },
};
}
async function saveTarget(event: SubmitEvent) {
event.preventDefault();
await mutate("target", "", {
target: repositoryKey
? {
action: "set",
repository_key: repositoryKey,
ref_selector: refSelector.trim() || null,
}
: { action: "clear" },
}, "PATCH");
await mutate("target", "", targetEditBody(), "PATCH");
}
async function markReady() {
if (!targetCandidateValid || busy) return;
if (
ticket.repository_key !== repositoryKey ||
(ticket.ref_selector ?? "") !== refSelector.trim()
) {
const saved = await mutate("target", "", {
target: {
action: "set",
repository_key: repositoryKey,
ref_selector: refSelector.trim() || null,
},
}, "PATCH");
const targets = normalizedTargets(targetDrafts);
if (JSON.stringify(ticket.targets) !== JSON.stringify(targets)) {
const saved = await mutate("target", "", targetEditBody(), "PATCH");
if (!saved) return;
}
readyOperationKey ??= crypto.randomUUID();
@@ -550,19 +590,45 @@
</section>
<section class="ticket-control-card">
<header><h2>Repository target</h2></header>
<header><h2>Repository targets</h2><span>{targetDrafts.length}</span></header>
<form class="ticket-control-form" onsubmit={saveTarget}>
<label>Repository
<select bind:value={repositoryKey} disabled={ticket.state !== "planning"}>
<option value="">Not assigned</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={refSelector} placeholder={selectedRepository?.default_ref ?? "branch, tag, or revision"} disabled={ticket.state !== "planning"} /></label>
<div class="ticket-target-list">
{#each targetDrafts as target, index}
{@const selectedRepository = repositoryFor(target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} disabled={ticket.state !== "planning"} 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>Access
<select bind:value={target.access} disabled={ticket.state !== "planning"}>
<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")}
>Add target</button>
{/if}
<button class="workspace-secondary-button" type="submit" disabled={busy === "target" || ticket.state !== "planning"}>
{busy === "target" ? "Saving…" : "Save target"}
{busy === "target" ? "Saving…" : "Save targets"}
</button>
</form>
</section>
@@ -585,7 +651,7 @@
{busy === "ready" ? "Marking ready…" : "Mark ready"}
</button>
{#if !targetCandidateValid}
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
<p class="workspace-empty-copy">Add unique healthy 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()}>