feat(worker): project tickets as native WIP objects

This commit is contained in:
2026-10-04 02:22:45 +09:00
parent 28c9d752e3
commit 31ee6b3c6a
5 changed files with 1269 additions and 25 deletions
+30
View File
@@ -1558,6 +1558,36 @@ where
let workspace_client = worker.workspace_client_handle();
let engine = worker.engine_mut();
if feature_config.ticket.enabled && wip_mode {
let ticket_access = crate::feature::builtin::ticket::TicketFeatureAccess {
authoring: feature_config.ticket.authoring,
thread: feature_config.ticket.thread,
intake: feature_config.ticket.intake,
workflow: feature_config.ticket.workflow,
};
let feature_route = wip_mount_registry
.allocate_feature_route("ticket")
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("allocate Ticket WIP route: {error}"),
)
})?;
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
&mut wip_mount_registry,
workspace_client.clone(),
ticket_access,
wip_permissions.clone(),
&feature_route,
)
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("mount Ticket WIP projection: {error}"),
)
})?;
}
// Objective tools expose scoped project Objective reads and mutations through
// the Backend Workspace API. Workers must not guess local `.yoi/objectives`
// paths or read/edit Objective files directly.
@@ -34,6 +34,7 @@ struct ModelTicketQueryItem {
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketDetail {
ticket: String,
item_revision: String,
title: String,
body: String,
state: String,
@@ -277,6 +278,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
Ok(ModelTicketDetail {
ticket: resource_ref(root, "resource_key", "T-")?,
item_revision: string_field(root, "item_revision")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
@@ -780,6 +782,7 @@ mod tests {
let projected = project_ticket_detail(json!({
"id": "internal-ticket",
"resource_key": "T-588",
"item_revision": "revision-1",
"title": "Queued Submit",
"body": "Body",
"state": "planning",
@@ -829,6 +832,7 @@ mod tests {
.expect("current Ticket blocker shape must project");
let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
assert_eq!(projected["item_revision"], "revision-1");
assert_eq!(
projected["relations"]["blockers"],
json!([{
File diff suppressed because it is too large Load Diff
+194
View File
@@ -2713,6 +2713,200 @@ mod tests {
);
}
#[tokio::test]
async fn ticket_native_runtime_covers_authoring_and_workflow_role_surfaces() {
let query_response = crate::worker::WorkspaceResponse {
status: 200,
body: json!({
"items": [],
"page": {"next_cursor": null, "has_more": false}
})
.to_string(),
};
let create_response = crate::worker::WorkspaceResponse {
status: 200,
body: json!({
"id": "00001TICKET",
"resource_key": "T-9",
"slug": "new-ticket",
"status": "Open"
})
.to_string(),
};
let comment_response = crate::worker::WorkspaceResponse {
status: 204,
body: String::new(),
};
let authoring_client = Arc::new(ScriptedWorkspaceClient::new([
query_response,
create_response,
comment_response,
]));
let authoring_access =
crate::feature::builtin::ticket::TicketFeatureAccess::workspace_authoring();
let mut authoring_engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
for definition in crate::feature::builtin::ticket::enabled_ticket_definitions(
authoring_client.clone(),
authoring_access,
) {
authoring_engine.register_tool(definition);
}
let mut authoring_registry = WipMountRegistry::new();
let route = authoring_registry.allocate_feature_route("ticket").unwrap();
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
&mut authoring_registry,
authoring_client.clone(),
authoring_access,
None,
&route,
)
.unwrap();
let authoring = install_wip_mode_with_mounts(
&mut authoring_engine,
None,
"authoring-worker".into(),
authoring_registry,
)
.unwrap();
let discovered = authoring.discover("/".into(), 3, false).await.unwrap();
let discovered = discovered.content.unwrap();
assert!(discovered.contains("/features/ticket/tickets"));
assert!(!discovered.contains("/tools/QueryTicket"));
assert!(!discovered.contains("/tools/TicketCreate"));
authoring
.inspect("yoi.ticket/collection/v1".into(), false)
.await
.unwrap();
let collection = "/features/ticket/tickets";
authoring
.call(
collection.into(),
"yoi.ticket/collection/v1".into(),
"query".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
let created = authoring
.call(
collection.into(),
"yoi.ticket/collection/v1".into(),
"create".into(),
json!({"title": "New ticket"}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert!(
created
.content
.unwrap()
.contains("/features/ticket/tickets/T-9")
);
let item = "/features/ticket/tickets/T-9";
authoring.discover(item.into(), 0, false).await.unwrap();
authoring
.inspect("yoi.ticket/item/v1".into(), false)
.await
.unwrap();
authoring
.call(
item.into(),
"yoi.ticket/item/v1".into(),
"comment".into(),
json!({"body": "Native comment"}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
let authoring_requests = authoring_client.requests();
assert_eq!(authoring_requests.len(), 3);
assert!(authoring_requests[0].path.ends_with("/tickets/query"));
assert!(authoring_requests[1].path.ends_with("/tickets"));
assert!(
authoring_requests[2]
.path
.ends_with("/tickets/T-9/thread-events")
);
let workflow_client = Arc::new(ScriptedWorkspaceClient::new([
crate::worker::WorkspaceResponse {
status: 200,
body: json!({"id": "00001SOURCE", "resource_key": "T-9"}).to_string(),
},
crate::worker::WorkspaceResponse {
status: 200,
body: json!({"id": "00001TARGET", "resource_key": "T-10"}).to_string(),
},
crate::worker::WorkspaceResponse {
status: 200,
body: json!({
"ticket_id": "00001SOURCE",
"kind": "depends_on",
"target": "00001TARGET",
"note": null,
"author": "workspace",
"at": "2026-10-03T00:00:00Z"
})
.to_string(),
},
]));
let workflow_access = crate::feature::builtin::ticket::TicketFeatureAccess::workflow();
let mut workflow_engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
for definition in crate::feature::builtin::ticket::enabled_ticket_definitions(
workflow_client.clone(),
workflow_access,
) {
workflow_engine.register_tool(definition);
}
let mut workflow_registry = WipMountRegistry::new();
let route = workflow_registry.allocate_feature_route("ticket").unwrap();
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
&mut workflow_registry,
workflow_client.clone(),
workflow_access,
None,
&route,
)
.unwrap();
let workflow = install_wip_mode_with_mounts(
&mut workflow_engine,
None,
"workflow-worker".into(),
workflow_registry,
)
.unwrap();
workflow.discover(item.into(), 0, false).await.unwrap();
workflow
.inspect("yoi.ticket/item/v1".into(), false)
.await
.unwrap();
let result = workflow
.call(
item.into(),
"yoi.ticket/item/v1".into(),
"record_relation".into(),
json!({"kind": "depends_on", "target": "T-10"}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
let result = result.content.unwrap();
assert!(result.contains("T-9"));
assert!(result.contains("T-10"));
let workflow_requests = workflow_client.requests();
assert_eq!(workflow_requests.len(), 3);
assert!(workflow_requests[0].path.ends_with("/tickets/T-9"));
assert!(workflow_requests[1].path.ends_with("/tickets/T-10"));
assert!(
workflow_requests[2]
.path
.ends_with("/tickets/T-9/relations")
);
}
#[tokio::test]
async fn objective_native_runtime_covers_discovery_and_all_backend_operations() {
let query_response = crate::worker::WorkspaceResponse {
+80
View File
@@ -0,0 +1,80 @@
# Ticket native WIP projection
## Purpose and authority
When a Worker explicitly runs in `wip` mode and its Ticket Feature is enabled, the Worker Host projects the enabled Ticket surface as native Worldspace objects. The projection is an adapter over the existing scoped Workspace Ticket API and typed Ticket tools. It does not add a Ticket store, resolver, audit path, state machine, readiness rule, queue path, or relation implementation.
The Host allocates `/features/ticket` and the Feature mounts:
```text
/features/ticket/tickets
/features/ticket/tickets/<ticket-reference>
```
`<ticket-reference>` is either a canonical `T-<digits>` key or an existing canonical internal alphanumeric ID. Only a direct child is accepted. Nested suffixes, Objective keys, unrelated prefixes, and hidden routes do not resolve.
Every call retains the Worker's scoped `WorkspaceClient`, current Ticket Feature access, and ordinary Tool permission policy. Discovery is not mutation authority. A direct call to an unpublished operation fails even when the caller knows its name. `deny` and interactive `ask` permission outcomes fail closed before Backend dispatch.
## Current operation inventory
The native surface is assembled from the tools actually enabled by `TicketFeatureAccess`; it is not a second broad Ticket catalog. The native operation delegates to the named ordinary Tool and validates the reconstructed input against that Tool's exact JSON Schema.
Collection interface `yoi.ticket/collection/v1`:
| Native operation | Existing Tool identity | Subject identity |
| --- | --- | --- |
| `query` | `QueryTicket` | Collection; preserves bounded filters, limit and cursor |
| `create` | `TicketCreate` | Collection; Backend assigns identity |
Item interface `yoi.ticket/item/v1`:
| Native operation | Existing Tool identity | Non-subject inputs |
| --- | --- | --- |
| `read` | `ShowTicket` | `event_limit`, `event_cursor` |
| `edit` | `TicketEditItem` | title/body replacement and targets edit |
| `comment` | `TicketComment` | comment body |
| `mark_ready` | `TicketMarkReady` | optional reason |
| `intake_ready` | `TicketIntakeReady` | intake summary and optional reason |
| `queue` | `TicketQueue` | none |
| `transition` | `TicketWorkflowState` | expected/target state, reason and body |
| `close` | `TicketClose` | resolution |
| `dependency_check` | `TicketDependencyCheck` | none |
| `record_relation` | `TicketRelationRecord` | relation kind, **target Ticket**, optional note |
| `remove_relation` | `TicketRelationRemove` | relation kind and **target Ticket** |
| `record_orchestration_plan` | `TicketOrchestrationPlanRecord` | kind, related Ticket, note and accepted-plan data |
| `query_orchestration_plans` | `TicketOrchestrationPlanQuery` | relation kind and bounded limit |
The subject `ticket`/`id` parameter is removed from every item descriptor and reconstructed exclusively from the resolved route. A caller cannot replace it through arguments. A relation target or related Ticket remains an explicit, distinct input and is resolved by the existing typed Backend.
Access profiles therefore expose different native interfaces:
- read-only/review: collection `query`; item `read`;
- workspace authoring: query/create plus read/edit/comment/ready/queue/close/relation operations;
- intake: the authoring surface plus `intake_ready`;
- workflow/orchestration: query/read/comment, workflow transition/close/dependency checks, relation operations and orchestration-plan operations;
- work-report: query/read/comment only.
No native operation is inferred for a Tool that the Feature did not register. Enabled Ticket tools claimed by the native projection are removed from `/tools`; normal Tool mode is unchanged.
## Bounded reads and references
`query` uses the authoritative Workspace bounded query and cursor contract. Results include canonical `T-*` references and native item paths without embedding unbounded thread or evidence history. `read` uses the authoritative bounded detail projection for thread events, relations, Objective references, assignments, Merge Requests and evidence. Objective and Merge Request values remain references/data; this projection neither assumes their native routes nor grants their authority.
Backend 401/403 failures map to permission denial, 404 to missing objects, and deterministic 400/409/422 validation or workflow failures to invalid arguments. Cancellation and interruption remain distinct. Transport, server, or malformed post-dispatch results retain unknown outcome, and mutating operations are not retried automatically.
Successful reads observe the authoritative update marker. Successful mutations advance a local observation generation and invalidate every known canonical/internal alias for the same Ticket. A stale validator is rejected by the common WIP Host before another Backend dispatch; the caller must rediscover/reinspect and explicitly decide whether to call again.
## Preserved Ticket contracts
The projection invokes the existing Tool and scoped Backend routes, so the following remain authoritative and are not reimplemented by WIP:
- item revision and audited item/thread/state events;
- title/body/targets input validation;
- planning-to-ready target validation and write-target requirement;
- queue dependency traversal, planning-dependency rejection and cycle detection;
- expected-state workflow transitions and close semantics;
- canonical `T-*`/internal-ID resolution and Workspace isolation;
- exact forward relation ownership, inverse views and relation target validation;
- orchestration-plan validation and bounded query behavior.
The ordinary JSON Schema is retained as a second validation boundary behind the WIP descriptor. Optional fields omitted by the caller stay omitted when reconstructing permission input; defaults are applied only by the same ordinary typed Tool that applies them in Tool mode.