feat: support permissioned multi-repository ticket targets
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(_))
|
||||
|
||||
@@ -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",
|
||||
] {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user