26 Commits
Author SHA1 Message Date
Hare a10507c54f merge: integrate orchestration
# Conflicts:
#	crates/flow/src/builtin.rs
#	crates/manifest/src/profile.rs
#	crates/worker/src/prompt/catalog.rs
#	crates/worker/src/prompt/system.rs
#	resources/flows/coder-review.dcdl
#	resources/prompts/role/coder.md
#	resources/prompts/role/orchestrator.md
#	web/workspace/src/lib/workspace/console/worker-console.ui.test.ts
#	web/workspace/src/lib/workspace/styles/tickets.css
#	web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte
#	web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts
2026-08-18 09:55:27 +09:00
Hare 6a607ccbed feat: integrate bounded workspace query pagination 2026-08-18 09:03:59 +09:00
Hare 4ac79b3665 fix: publish coder revisions before review 2026-08-18 08:14:58 +09:00
Hare a4fdf9cc45 docs: record test code quality audit 2026-08-18 08:14:58 +09:00
Hare 981c422122 fix: preserve completed merge request replays 2026-08-18 08:14:43 +09:00
Hare ab0f57c00a fix: include incoming Ticket relation filters 2026-08-18 08:14:05 +09:00
Hare b723c64fa1 fix: preserve accepted Ticket query filters 2026-08-18 08:06:21 +09:00
Hare f86ae6d52f feat: connect selector-based merge request flow 2026-08-18 08:06:02 +09:00
Hare 9a548d2b5e fix: require observed merge target completion 2026-08-18 08:05:57 +09:00
Hare 4e738ac5eb fix: query authoritative merge request review events 2026-08-18 07:57:20 +09:00
Hare a6f3e30652 fix: preserve Ticket lane order across pages 2026-08-18 07:50:42 +09:00
Hare 6ca5dfbe11 feat: paginate Ticket board lanes independently 2026-08-18 03:08:39 +09:00
Hare bc835b8503 feat: add bounded Ticket and Objective query pages 2026-08-18 03:08:30 +09:00
Hare 46767daf49 test: remove duplicated resource content assertions 2026-08-18 02:20:32 +09:00
Hare 99170d47ab fix: delegate queued tickets through merge 2026-08-18 02:20:32 +09:00
Hare 796fa2ee85 feat: integrate ticket ready target guards 2026-08-18 01:12:32 +09:00
Hare 200c24bc00 fix: avoid unbounded ticket board queries 2026-08-18 00:46:31 +09:00
Hare 0a2b24bf5e fix: ignore transient vite config bundles 2026-08-17 23:33:12 +09:00
Hare eec2be87ad feat: paginate ticket workflow lanes 2026-08-17 23:33:12 +09:00
Hare 71cd58f868 feat: scope merge request tools by profile flags 2026-08-17 22:53:09 +09:00
Hare 58ed04ac59 Merge branch 'orchestration' into develop 2026-08-17 21:50:21 +09:00
Hare c6a476d65d feat: derive ticket readiness from merge requests 2026-08-17 21:26:42 +09:00
Hare 4c31ea2228 fix: check queue state before local readiness 2026-08-17 14:59:24 +09:00
Hare f9e5fca67d fix: preserve intake and companion ticket workflows 2026-08-17 14:44:59 +09:00
Hare a2cd860199 feat: gate ticket readiness on validated targets 2026-08-17 14:23:47 +09:00
Hare f5fdce2d07 fix: migrate legacy merge request schema marker 2026-08-17 11:54:24 +09:00
53 changed files with 3998 additions and 1592 deletions
+2 -1
View File
@@ -49,6 +49,7 @@ Orchestrator は以下を行う。
- 既存 umbrella/progress-container Ticket が concrete follow-up Ticket / Objective context で置き換え済みなら、superseded/decomposed として退役・close する routing を検討する。 - 既存 umbrella/progress-container Ticket が concrete follow-up Ticket / Objective context で置き換え済みなら、superseded/decomposed として退役・close する routing を検討する。
- implementation-ready の場合は `multi-agent-workflow` に渡す `IntentPacket` を作る。 - implementation-ready の場合は `multi-agent-workflow` に渡す `IntentPacket` を作る。
- implementation-ready かつ Ticket が `queued` の場合は、worktree 作成 / implementation Pod `SpawnPod` / coder routing などの side effect の前に、既存の typed Ticket backend/tool path で `queued -> inprogress` を記録する。 - implementation-ready かつ Ticket が `queued` の場合は、worktree 作成 / implementation Pod `SpawnPod` / coder routing などの side effect の前に、既存の typed Ticket backend/tool path で `queued -> inprogress` を記録する。
- 人間による `ready -> queued` は、記録済み Ticket scopeについて、実装、current MRのguarded merge、completion記録、Ticket closeまでをWorkspace Orchestratorへ委任するdurable gateである。Orchestratorは`queued -> inprogress`を受理した後、current-ref approval、repository evidence、blocking relations、merge CASを確認して完了まで進め、別のmerge確認を待たない。Ticketがseparate approval gateを明記する場合、またはqueued scope外の新しい判断が必要な場合だけ停止する。
- 明示的な queue review 中に、他にも queued Ticket が見え、capacity が空いている場合は、各 Ticket について relation / orchestration-plan / dirty state / visible Pods / worktree / conflict risk を確認し、独立して受理できるものを同じ routing pass で追加の `queued -> inprogress` 候補にする。 - 明示的な queue review 中に、他にも queued Ticket が見え、capacity が空いている場合は、各 Ticket について relation / orchestration-plan / dirty state / visible Pods / worktree / conflict risk を確認し、独立して受理できるものを同じ routing pass で追加の `queued -> inprogress` 候補にする。
- queued Ticket を capacity が見える状態で idle のまま残す場合は、dependency / conflict / capacity / missing planning decision / dirty workspace / reviewer-coder bottleneck / migration boundary / human gate のいずれかに絞った bounded reason を Ticket thread または `TicketOrchestrationPlanRecord` に残す。 - queued Ticket を capacity が見える状態で idle のまま残す場合は、dependency / conflict / capacity / missing planning decision / dirty workspace / reviewer-coder bottleneck / migration boundary / human gate のいずれかに絞った bounded reason を Ticket thread または `TicketOrchestrationPlanRecord` に残す。
- `ready` または `queued` に concrete missing decision / information がある場合だけ、typed state-change/routing event 付きで `planning` に戻す。その event/comment には missing item、checked context、implementation latitude では足りない理由、次の planning question/action を含める。 - `ready` または `queued` に concrete missing decision / information がある場合だけ、typed state-change/routing event 付きで `planning` に戻す。その event/comment には missing item、checked context、implementation latitude では足りない理由、次の planning question/action を含める。
@@ -61,7 +62,7 @@ Orchestrator は以下を行う。
- 人間/上位 Orchestrator の許可または明示的な routing acceptance なしに coder / reviewer Pod や read-only investigation helper Pod を起動しない。 - 人間/上位 Orchestrator の許可または明示的な routing acceptance なしに coder / reviewer Pod や read-only investigation helper Pod を起動しない。
- unqueued Ticket を capacity 埋めのために開始しない。parallel start の候補は、個別に `queued` であり、人間が routing を許可済みの Ticket に限る。 - unqueued Ticket を capacity 埋めのために開始しない。parallel start の候補は、個別に `queued` であり、人間が routing を許可済みの Ticket に限る。
- 設計境界の未決定を勝手に implementation-ready として固定しない。 - 設計境界の未決定を勝手に implementation-ready として固定しない。
- merge / close / cleanup 権限を持たない場面で勝手に完了処理しない - Ticketが`ready -> queued`されておらず完了権限を委任されていない場合、またはTicketがseparate approval gateを明記する場合に、勝手にmerge / close / cleanupしない。queued delegationとguarded completion evidenceが揃っている場合は、追加のhuman gateを作らず完了まで進める
- Ticket tools があるからといって arbitrary filesystem write を行わない。 - Ticket tools があるからといって arbitrary filesystem write を行わない。
- broad multi-Ticket effort のために新しい umbrella/progress-container Ticket を作らない。 - broad multi-Ticket effort のために新しい umbrella/progress-container Ticket を作らない。
- parent/child、sub-ticket、umbrella、part-of、contains などの hierarchy/container relation を split/refinement の代替として扱わない。 - parent/child、sub-ticket、umbrella、part-of、contains などの hierarchy/container relation を split/refinement の代替として扱わない。
Generated
+1
View File
@@ -4417,6 +4417,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"sha2 0.11.0",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
+2 -25
View File
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
match slug { match slug {
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource { CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG, slug: CODER_REVIEW_FLOW_SLUG,
revision: 2, revision: 3,
path: "builtin/flows/coder-review.dcdl", path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE, content: CODER_REVIEW_FLOW_SOURCE,
}), }),
@@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] { pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource { const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG, slug: CODER_REVIEW_FLOW_SLUG,
revision: 2, revision: 3,
path: "builtin/flows/coder-review.dcdl", path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE, content: CODER_REVIEW_FLOW_SOURCE,
}]; }];
@@ -67,27 +67,4 @@ mod tests {
assert_eq!(selected.revision, source.revision); assert_eq!(selected.revision, source.revision);
} }
} }
#[test]
fn coder_review_starts_on_a_ticket_branch_and_requires_committed_review_evidence() {
let source =
builtin_flow_source(CODER_REVIEW_FLOW_SLUG).expect("coder-review Flow must exist");
for required in [
"detached HEAD",
"work/<ticket-id>-<slug>",
"explicitly authorized",
"git add",
"git commit",
"Workdir is clean",
"current head commit",
"same Ticket work branch",
"new revision",
] {
assert!(
source.content.contains(required),
"coder-review Flow must preserve branch/commit policy token {required:?}"
);
}
}
} }
+100 -3
View File
@@ -18,9 +18,10 @@ use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig; use crate::plugin::PluginConfig;
use crate::{ use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits, CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, ScopeConfig, McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig,
SessionConfig, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig,
ToolPermissionRule, WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
WorkerManifest, WorkerMeta,
}; };
/// Partial-form Worker manifest. Every field is optional; one or more /// Partial-form Worker manifest. Every field is optional; one or more
@@ -97,6 +98,8 @@ pub struct FeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>, pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)] #[serde(default)]
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
#[serde(default)]
pub orchestration: Option<FeatureFlagConfigPartial>, pub orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)] #[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>, pub plugins: Option<FeatureFlagConfigPartial>,
@@ -127,6 +130,11 @@ impl FeatureConfigPartial {
FeatureFlagConfigPartial::merge, FeatureFlagConfigPartial::merge,
), ),
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge), ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
merge_request: merge_option(
self.merge_request,
other.merge_request,
MergeRequestFeatureConfigPartial::merge,
),
orchestration: merge_option( orchestration: merge_option(
self.orchestration, self.orchestration,
other.orchestration, other.orchestration,
@@ -216,6 +224,28 @@ impl TicketFeatureConfigPartial {
} }
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct MergeRequestFeatureConfigPartial {
pub show: Option<bool>,
pub open: Option<bool>,
pub review: Option<bool>,
pub readiness_check: Option<bool>,
pub complete: Option<bool>,
}
impl MergeRequestFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
show: other.show.or(self.show),
open: other.open.or(self.open),
review: other.review.or(self.review),
readiness_check: other.readiness_check.or(self.readiness_check),
complete: other.complete.or(self.complete),
}
}
}
impl From<FeatureConfigPartial> for FeatureConfig { impl From<FeatureConfigPartial> for FeatureConfig {
fn from(value: FeatureConfigPartial) -> Self { fn from(value: FeatureConfigPartial) -> Self {
Self { Self {
@@ -247,6 +277,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.ticket .ticket
.map(TicketFeatureConfig::from) .map(TicketFeatureConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
merge_request: value
.merge_request
.map(MergeRequestFeatureConfig::from)
.unwrap_or_default(),
orchestration: value orchestration: value
.orchestration .orchestration
.map(FeatureFlagConfig::from) .map(FeatureFlagConfig::from)
@@ -326,6 +360,30 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
} }
} }
impl From<MergeRequestFeatureConfigPartial> for MergeRequestFeatureConfig {
fn from(value: MergeRequestFeatureConfigPartial) -> Self {
Self {
show: value.show.unwrap_or_default(),
open: value.open.unwrap_or_default(),
review: value.review.unwrap_or_default(),
readiness_check: value.readiness_check.unwrap_or_default(),
complete: value.complete.unwrap_or_default(),
}
}
}
impl From<MergeRequestFeatureConfig> for MergeRequestFeatureConfigPartial {
fn from(value: MergeRequestFeatureConfig) -> Self {
Self {
show: Some(value.show),
open: Some(value.open),
review: Some(value.review),
readiness_check: Some(value.readiness_check),
complete: Some(value.complete),
}
}
}
impl From<FeatureConfig> for FeatureConfigPartial { impl From<FeatureConfig> for FeatureConfigPartial {
fn from(value: FeatureConfig) -> Self { fn from(value: FeatureConfig) -> Self {
Self { Self {
@@ -339,6 +397,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
objective: Some(value.objective.into()), objective: Some(value.objective.into()),
manage_workdir: Some(value.manage_workdir.into()), manage_workdir: Some(value.manage_workdir.into()),
ticket: Some(value.ticket.into()), ticket: Some(value.ticket.into()),
merge_request: Some(value.merge_request.into()),
orchestration: Some(value.orchestration.into()), orchestration: Some(value.orchestration.into()),
plugins: Some(value.plugins.into()), plugins: Some(value.plugins.into()),
} }
@@ -1880,6 +1939,7 @@ worker_max_turns = 7
assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.objective.enabled);
assert!(!manifest.feature.manage_workdir.enabled); assert!(!manifest.feature.manage_workdir.enabled);
assert!(!manifest.feature.ticket.enabled); assert!(!manifest.feature.ticket.enabled);
assert!(!manifest.feature.merge_request.any());
} }
#[test] #[test]
@@ -1899,6 +1959,13 @@ thread = false
intake = false intake = false
workflow = false workflow = false
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[feature.orchestration] [feature.orchestration]
enabled = false enabled = false
"#, "#,
@@ -1934,6 +2001,14 @@ enabled = false
assert!(!manifest.feature.ticket.thread); assert!(!manifest.feature.ticket.thread);
assert!(!manifest.feature.ticket.intake); assert!(!manifest.feature.ticket.intake);
assert!(!manifest.feature.ticket.workflow); assert!(!manifest.feature.ticket.workflow);
assert_eq!(
manifest.feature.merge_request,
MergeRequestFeatureConfig {
show: true,
review: true,
..Default::default()
}
);
assert!(!manifest.feature.orchestration.enabled); assert!(!manifest.feature.orchestration.enabled);
assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.memory.enabled);
assert!(!manifest.feature.memory.staging); assert!(!manifest.feature.memory.staging);
@@ -1957,6 +2032,13 @@ thread = false
intake = false intake = false
workflow = false workflow = false
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[feature.orchestration] [feature.orchestration]
enabled = false enabled = false
"#, "#,
@@ -1968,6 +2050,11 @@ enabled = false
thread = true thread = true
workflow = true workflow = true
[feature.merge_request]
open = true
review = false
readiness_check = true
[feature.orchestration] [feature.orchestration]
enabled = true enabled = true
@@ -2017,6 +2104,16 @@ enabled = true
assert!(manifest.feature.ticket.thread); assert!(manifest.feature.ticket.thread);
assert!(!manifest.feature.ticket.intake); assert!(!manifest.feature.ticket.intake);
assert!(manifest.feature.ticket.workflow); assert!(manifest.feature.ticket.workflow);
assert_eq!(
manifest.feature.merge_request,
MergeRequestFeatureConfig {
show: true,
open: true,
review: false,
readiness_check: true,
complete: false,
}
);
assert!(manifest.feature.orchestration.enabled); assert!(manifest.feature.orchestration.enabled);
assert!(manifest.feature.objective.enabled); assert!(manifest.feature.objective.enabled);
assert!(manifest.feature.web.enabled); assert!(manifest.feature.web.enabled);
+24
View File
@@ -125,6 +125,8 @@ pub struct FeatureConfig {
#[serde(default)] #[serde(default)]
pub ticket: TicketFeatureConfig, pub ticket: TicketFeatureConfig,
#[serde(default)] #[serde(default)]
pub merge_request: MergeRequestFeatureConfig,
#[serde(default)]
pub orchestration: FeatureFlagConfig, pub orchestration: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
pub plugins: FeatureFlagConfig, pub plugins: FeatureFlagConfig,
@@ -143,6 +145,7 @@ impl Default for FeatureConfig {
objective: FeatureFlagConfig::disabled(), objective: FeatureFlagConfig::disabled(),
manage_workdir: FeatureFlagConfig::disabled(), manage_workdir: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(), ticket: TicketFeatureConfig::default(),
merge_request: MergeRequestFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(), orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(), plugins: FeatureFlagConfig::disabled(),
} }
@@ -252,6 +255,27 @@ pub struct TicketFeatureConfig {
pub workflow: bool, pub workflow: bool,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct MergeRequestFeatureConfig {
#[serde(default)]
pub show: bool,
#[serde(default)]
pub open: bool,
#[serde(default)]
pub review: bool,
#[serde(default)]
pub readiness_check: bool,
#[serde(default)]
pub complete: bool,
}
impl MergeRequestFeatureConfig {
pub fn any(self) -> bool {
self.show || self.open || self.review || self.readiness_check || self.complete
}
}
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are /// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
/// loaded *only* from the directories listed here — there is no /// loaded *only* from the directories listed here — there is no
/// implicit `$config_dir/skills/` or builtin probe. Profile and Manifest /// implicit `$config_dir/skills/` or builtin probe. Profile and Manifest
+59 -113
View File
@@ -373,108 +373,57 @@ mod tests {
use super::*; use super::*;
use serial_test::serial; use serial_test::serial;
#[test] fn test_catalogs() -> (Vec<ProviderEntry>, Vec<ModelEntry>) {
fn builtin_has_four_providers() { (
let entries = load_builtin_providers().unwrap(); vec![ProviderEntry {
let ids: Vec<&str> = entries.iter().map(|e| e.id.as_str()).collect(); id: "test-provider".to_string(),
assert_eq!( display_name: "Test Provider".to_string(),
ids, scheme: SchemeKind::OpenaiChat,
vec!["anthropic", "ollama-local", "codex-oauth", "openrouter"] base_url: Some("https://example.invalid/v1".to_string()),
); auth_hint: AuthHint::None,
} default_capability: None,
default_context_window: Some(100_000),
#[test] }],
fn builtin_provider_default_capability_present() { vec![ModelEntry {
let entries = load_builtin_providers().unwrap(); id: "test-model".to_string(),
let anthropic = entries.iter().find(|e| e.id == "anthropic").unwrap(); provider: "test-provider".to_string(),
assert!(anthropic.default_capability.is_some()); context_window: Some(100_000),
} max_context_window: Some(80_000),
capability: None,
#[test] }],
fn builtin_models_cover_each_provider() { )
let entries = load_builtin_models().unwrap();
let providers: std::collections::BTreeSet<&str> =
entries.iter().map(|m| m.provider.as_str()).collect();
for p in ["anthropic", "ollama-local", "codex-oauth", "openrouter"] {
assert!(
providers.contains(p),
"model catalog should cover provider `{p}`"
);
}
} }
#[test] #[test]
fn resolve_ref_merges_provider_and_model_catalog() { fn resolve_ref_merges_provider_and_model_catalog() {
let providers = load_builtin_providers().unwrap(); let (providers, models) = test_catalogs();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest { let manifest = ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()), ref_: Some("test-provider/test-model".into()),
..Default::default() ..Default::default()
}; };
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap(); let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
assert_eq!(cfg.scheme, SchemeKind::Anthropic); assert_eq!(cfg.scheme, SchemeKind::OpenaiChat);
assert_eq!(cfg.model_id, "claude-sonnet-4-6"); assert_eq!(cfg.model_id, "test-model");
assert_eq!(cfg.base_url.as_deref(), Some("https://api.anthropic.com")); assert_eq!(cfg.base_url.as_deref(), Some("https://example.invalid/v1"));
match cfg.auth { assert!(matches!(cfg.auth, AuthRef::None));
AuthRef::SecretRef { ref_ } => { assert_eq!(cfg.context_window, 80_000);
assert_eq!(ref_, "providers/anthropic/default");
}
_ => panic!("expected SecretRef auth from provider hint"),
}
assert!(
cfg.capability.is_some(),
"model catalog should provide capability"
);
assert_eq!(cfg.context_window, 1_000_000);
} }
#[test] #[test]
fn context_window_manifest_overrides_catalog() { fn context_window_manifest_overrides_catalog() {
let providers = load_builtin_providers().unwrap(); let (providers, models) = test_catalogs();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest { let manifest = ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()), ref_: Some("test-provider/test-model".into()),
context_window: Some(123_456), context_window: Some(70_000),
..Default::default() ..Default::default()
}; };
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap(); let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
assert_eq!(cfg.context_window, 123_456); assert_eq!(cfg.context_window, 70_000);
}
#[test]
fn codex_gpt56_sol_catalog_clamps_public_window_to_backend_limit() {
let providers = load_builtin_providers().unwrap();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest {
ref_: Some("codex-oauth/gpt-5.6-sol".into()),
..Default::default()
};
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
assert_eq!(cfg.model_id, "gpt-5.6-sol");
assert_eq!(cfg.context_window, 272_000);
assert_eq!(cfg.max_context_window, Some(272_000));
let capability = cfg.capability.expect("catalog capability");
assert!(capability.vision);
assert!(capability.reasoning.is_some());
}
#[test]
fn codex_gpt55_catalog_records_effective_context_window() {
let providers = load_builtin_providers().unwrap();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest {
ref_: Some("codex-oauth/gpt-5.5".into()),
..Default::default()
};
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
assert_eq!(cfg.context_window, 272_000);
assert_eq!(cfg.max_context_window, None);
} }
#[test] #[test]
fn inline_context_window_is_clamped_by_manifest_backend_max() { fn inline_context_window_is_clamped_by_manifest_backend_max() {
let providers = load_builtin_providers().unwrap(); let (providers, models) = test_catalogs();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest { let manifest = ModelManifest {
scheme: Some(SchemeKind::Anthropic), scheme: Some(SchemeKind::Anthropic),
model_id: Some("custom".into()), model_id: Some("custom".into()),
@@ -490,25 +439,23 @@ mod tests {
#[test] #[test]
fn manifest_backend_max_clamps_ref_context_override() { fn manifest_backend_max_clamps_ref_context_override() {
let providers = load_builtin_providers().unwrap(); let (providers, models) = test_catalogs();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest { let manifest = ModelManifest {
ref_: Some("codex-oauth/gpt-5.5".into()), ref_: Some("test-provider/test-model".into()),
context_window: Some(1_000_000), context_window: Some(100_000),
max_context_window: Some(500_000), max_context_window: Some(50_000),
..Default::default() ..Default::default()
}; };
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap(); let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
assert_eq!(cfg.context_window, 500_000); assert_eq!(cfg.context_window, 50_000);
assert_eq!(cfg.max_context_window, Some(500_000)); assert_eq!(cfg.max_context_window, Some(50_000));
} }
#[test] #[test]
fn resolve_ref_with_inline_overrides() { fn resolve_ref_with_inline_overrides() {
let providers = load_builtin_providers().unwrap(); let (providers, models) = test_catalogs();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest { let manifest = ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()), ref_: Some("test-provider/test-model".into()),
auth: Some(AuthRef::ApiKey { auth: Some(AuthRef::ApiKey {
file: Some(PathBuf::from("/tmp/sk-ant")), file: Some(PathBuf::from("/tmp/sk-ant")),
}), }),
@@ -525,22 +472,26 @@ mod tests {
#[test] #[test]
fn resolve_ref_with_nested_model_id() { fn resolve_ref_with_nested_model_id() {
// OpenRouter: `<router>/<provider>/<model>` 形式の model_id を持つ let (providers, mut models) = test_catalogs();
let providers = load_builtin_providers().unwrap(); models.push(ModelEntry {
let models = load_builtin_models().unwrap(); id: "namespace/model".to_string(),
provider: "test-provider".to_string(),
context_window: None,
max_context_window: None,
capability: None,
});
let manifest = ModelManifest { let manifest = ModelManifest {
ref_: Some("openrouter/anthropic/claude-sonnet-4.6".into()), ref_: Some("test-provider/namespace/model".into()),
..Default::default() ..Default::default()
}; };
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap(); let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
assert_eq!(cfg.scheme, SchemeKind::OpenaiChat); assert_eq!(cfg.scheme, SchemeKind::OpenaiChat);
assert_eq!(cfg.model_id, "anthropic/claude-sonnet-4.6"); assert_eq!(cfg.model_id, "namespace/model");
} }
#[test] #[test]
fn resolve_ref_unknown_provider_is_hard_error() { fn resolve_ref_unknown_provider_is_hard_error() {
let providers = load_builtin_providers().unwrap(); let (providers, models) = test_catalogs();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest { let manifest = ModelManifest {
ref_: Some("nope/some-model".into()), ref_: Some("nope/some-model".into()),
..Default::default() ..Default::default()
@@ -551,21 +502,19 @@ mod tests {
#[test] #[test]
fn resolve_ref_unknown_model_is_warn_not_error() { fn resolve_ref_unknown_model_is_warn_not_error() {
let providers = load_builtin_providers().unwrap(); let (providers, models) = test_catalogs();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest { let manifest = ModelManifest {
ref_: Some("anthropic/some-future-claude".into()), ref_: Some("test-provider/unknown-model".into()),
..Default::default() ..Default::default()
}; };
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap(); let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
assert_eq!(cfg.model_id, "some-future-claude"); assert_eq!(cfg.model_id, "unknown-model");
assert!(cfg.capability.is_some(), "should use provider default"); assert!(cfg.capability.is_none(), "should use provider default");
} }
#[test] #[test]
fn resolve_inline_full_form() { fn resolve_inline_full_form() {
let providers = load_builtin_providers().unwrap(); let (providers, models) = test_catalogs();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest { let manifest = ModelManifest {
scheme: Some(SchemeKind::Anthropic), scheme: Some(SchemeKind::Anthropic),
model_id: Some("claude-sonnet-4-6".into()), model_id: Some("claude-sonnet-4-6".into()),
@@ -583,8 +532,7 @@ mod tests {
#[test] #[test]
fn resolve_inline_context_window_override() { fn resolve_inline_context_window_override() {
let providers = load_builtin_providers().unwrap(); let (providers, models) = test_catalogs();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest { let manifest = ModelManifest {
scheme: Some(SchemeKind::Anthropic), scheme: Some(SchemeKind::Anthropic),
model_id: Some("claude-sonnet-4-6".into()), model_id: Some("claude-sonnet-4-6".into()),
@@ -600,8 +548,7 @@ mod tests {
#[test] #[test]
fn resolve_inline_missing_auth_errors() { fn resolve_inline_missing_auth_errors() {
let providers = load_builtin_providers().unwrap(); let (providers, models) = test_catalogs();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest { let manifest = ModelManifest {
scheme: Some(SchemeKind::Anthropic), scheme: Some(SchemeKind::Anthropic),
model_id: Some("claude".into()), model_id: Some("claude".into()),
@@ -613,8 +560,7 @@ mod tests {
#[test] #[test]
fn malformed_ref_errors() { fn malformed_ref_errors() {
let providers = load_builtin_providers().unwrap(); let (providers, models) = test_catalogs();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest { let manifest = ModelManifest {
ref_: Some("noslash".into()), ref_: Some("noslash".into()),
..Default::default() ..Default::default()
@@ -711,6 +657,6 @@ auth_hint = { kind = "none" }
// override ファイルは作らない // override ファイルは作らない
let _g = ConfigDirGuard::new(dir.path()); let _g = ConfigDirGuard::new(dir.path());
let entries = load_providers().unwrap(); let entries = load_providers().unwrap();
assert_eq!(entries.len(), 4); assert!(!entries.is_empty());
} }
} }
+53 -196
View File
@@ -919,6 +919,37 @@ fn apply_role_profile(
_ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }), _ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
}; };
value["feature"]["ticket"] = ticket; value["feature"]["ticket"] = ticket;
let merge_request = match slug {
"coder" => serde_json::json!({
"show": true,
"open": true,
"review": false,
"readiness_check": false,
"complete": false
}),
"reviewer" => serde_json::json!({
"show": true,
"open": false,
"review": true,
"readiness_check": false,
"complete": false
}),
"orchestrator" => serde_json::json!({
"show": true,
"open": false,
"review": false,
"readiness_check": true,
"complete": true
}),
_ => serde_json::json!({
"show": false,
"open": false,
"review": false,
"readiness_check": false,
"complete": false
}),
};
value["feature"]["merge_request"] = merge_request;
} }
fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> { fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> {
@@ -1282,179 +1313,23 @@ mod tests {
let registry = ProfileDiscovery::with_sources(None, None) let registry = ProfileDiscovery::with_sources(None, None)
.discover() .discover()
.unwrap(); .unwrap();
for expected in ["companion", "intake", "orchestrator", "coder", "reviewer"] { assert!(!registry.entries().is_empty());
let entry = registry for entry in registry.entries() {
.select(&ProfileSelector::source_named(
ProfileRegistrySource::Builtin,
expected,
))
.unwrap();
assert_eq!(entry.source, ProfileRegistrySource::Builtin); assert_eq!(entry.source, ProfileRegistrySource::Builtin);
assert_eq!(entry.path, None); assert_eq!(entry.path, None);
assert_eq!(entry.provenance, format!("builtin:{expected}"));
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, expected),
ProfileResolveOptions::with_worker_name("role-worker"),
)
.unwrap();
assert_eq!(
resolved.profile.as_ref().unwrap().name.as_deref(),
Some(expected)
);
assert_eq!(resolved.manifest.worker.name, "role-worker");
}
}
#[test]
fn builtin_memory_consolidation_profile_enables_staging_by_feature() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new() let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve( .resolve(
&ProfileSelector::source_named( &ProfileSelector::source_named(
ProfileRegistrySource::Builtin, ProfileRegistrySource::Builtin,
"memory-consolidation", entry.name.as_str(),
), ),
ProfileResolveOptions::with_worker_name("arbitrary-worker-name"),
)
.unwrap();
assert_eq!(
resolved.profile.as_ref().unwrap().name.as_deref(),
Some("memory-consolidation")
);
assert_eq!(resolved.manifest.worker.name, "arbitrary-worker-name");
assert!(resolved.manifest.feature.memory.enabled);
assert!(resolved.manifest.feature.memory.staging);
assert!(!resolved.manifest.feature.objective.enabled);
}
#[test]
fn builtin_role_profiles_preserve_role_tool_policy() {
let tmp = TempDir::new().unwrap();
let resolve = |role: &str| {
ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, role),
ProfileResolveOptions::with_worker_name("role-worker"), ProfileResolveOptions::with_worker_name("role-worker"),
) )
.unwrap() .unwrap();
.manifest assert_eq!(resolved.manifest.worker.name, "role-worker");
}; }
let companion = resolve("companion");
assert!(companion.feature.task.enabled);
assert!(companion.feature.sub_worker.enabled);
assert!(companion.feature.worker.enabled);
assert!(companion.scope.allow.is_empty());
assert!(companion.scope.deny.is_empty());
assert!(companion.delegation_scope.allow.is_empty());
assert_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
assert!(companion.web.is_some());
assert!(companion.feature.ticket.enabled);
assert!(companion.feature.ticket.authoring);
assert!(companion.feature.ticket.thread);
assert!(companion.feature.objective.enabled);
assert!(!companion.feature.ticket.intake);
assert!(!companion.feature.orchestration.enabled);
assert_eq!(
companion.compaction.as_ref().unwrap().threshold,
Some(240000)
);
assert_eq!(
companion.compaction.as_ref().unwrap().request_threshold,
Some(270000)
);
assert_eq!(
companion
.compaction
.as_ref()
.unwrap()
.worker_context_max_tokens,
100000
);
let intake = resolve("intake");
assert!(intake.feature.task.enabled);
assert!(!intake.feature.sub_worker.enabled);
assert!(!intake.feature.worker.enabled);
assert!(intake.feature.ticket.enabled);
assert!(intake.feature.ticket.enabled);
assert!(intake.feature.ticket.authoring);
assert!(intake.feature.ticket.thread);
assert!(intake.feature.objective.enabled);
assert!(!intake.feature.manage_workdir.enabled);
assert!(intake.feature.ticket.intake);
assert!(!intake.feature.orchestration.enabled);
assert!(intake.scope.allow.is_empty());
assert!(intake.delegation_scope.allow.is_empty());
assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
assert!(intake.web.is_some());
assert!(intake.compaction.is_some());
let orchestrator = resolve("orchestrator");
assert!(orchestrator.feature.task.enabled);
assert!(!orchestrator.feature.sub_worker.enabled);
assert!(orchestrator.feature.worker.enabled);
assert!(!orchestrator.feature.worker.direct_spawn);
assert!(orchestrator.feature.ticket.enabled);
assert!(orchestrator.feature.ticket.enabled);
assert!(!orchestrator.feature.ticket.authoring);
assert!(orchestrator.feature.ticket.thread);
assert!(orchestrator.feature.objective.enabled);
assert!(orchestrator.feature.manage_workdir.enabled);
assert!(!orchestrator.feature.ticket.intake);
assert!(orchestrator.feature.ticket.workflow);
assert!(orchestrator.feature.orchestration.enabled);
assert!(orchestrator.scope.allow.is_empty());
assert!(orchestrator.delegation_scope.allow.is_empty());
assert_eq!(
orchestrator.model.ref_.as_deref(),
Some("codex-oauth/gpt-5.5")
);
assert!(orchestrator.web.is_some());
assert!(orchestrator.compaction.is_some());
let coder = resolve("coder");
assert!(coder.feature.task.enabled);
assert!(coder.feature.sub_worker.enabled);
assert!(coder.feature.flow.enabled);
assert!(!coder.feature.worker.enabled);
assert!(coder.scope.allow.is_empty());
assert!(coder.delegation_scope.allow.is_empty());
assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
assert!(coder.web.is_some());
assert!(coder.compaction.is_some());
assert!(coder.feature.ticket.enabled);
assert!(coder.feature.ticket.enabled);
assert!(!coder.feature.ticket.authoring);
assert!(coder.feature.ticket.thread);
assert!(coder.feature.objective.enabled);
assert!(!coder.feature.manage_workdir.enabled);
assert!(!coder.feature.ticket.intake);
assert!(!coder.feature.orchestration.enabled);
let reviewer = resolve("reviewer");
assert!(reviewer.feature.task.enabled);
assert!(!reviewer.feature.sub_worker.enabled);
assert!(!reviewer.feature.flow.enabled);
assert!(!reviewer.feature.worker.enabled);
assert!(reviewer.feature.ticket.enabled);
assert!(reviewer.feature.ticket.enabled);
assert!(!reviewer.feature.ticket.authoring);
assert!(reviewer.feature.ticket.thread);
assert!(reviewer.feature.objective.enabled);
assert!(!reviewer.feature.manage_workdir.enabled);
assert!(!reviewer.feature.ticket.intake);
assert!(!reviewer.feature.orchestration.enabled);
assert!(reviewer.scope.allow.is_empty());
assert!(reviewer.delegation_scope.allow.is_empty());
assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
assert!(reviewer.web.is_some());
assert!(reviewer.compaction.is_some());
} }
#[test] #[test]
@@ -1604,6 +1479,14 @@ enabled = true
authoring = false authoring = false
thread = false thread = false
intake = false intake = false
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[feature.orchestration] [feature.orchestration]
enabled = false enabled = false
"#, "#,
@@ -1626,6 +1509,14 @@ enabled = false
assert!(!resolved.manifest.feature.ticket.authoring); assert!(!resolved.manifest.feature.ticket.authoring);
assert!(!resolved.manifest.feature.ticket.thread); assert!(!resolved.manifest.feature.ticket.thread);
assert!(!resolved.manifest.feature.ticket.intake); assert!(!resolved.manifest.feature.ticket.intake);
assert_eq!(
resolved.manifest.feature.merge_request,
crate::MergeRequestFeatureConfig {
show: true,
review: true,
..Default::default()
}
);
assert!(!resolved.manifest.feature.orchestration.enabled); assert!(!resolved.manifest.feature.orchestration.enabled);
assert_eq!( assert_eq!(
resolved.manifest.delegation_scope.allow[0].target, resolved.manifest.delegation_scope.allow[0].target,
@@ -1695,40 +1586,6 @@ worker_context_max_tokens = 68000
assert!(err.to_string().contains("model.auth.file")); assert!(err.to_string().contains("model.auth.file"));
} }
#[test] #[test]
fn builtin_companion_resolves_without_external_evaluator() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "companion"),
ProfileResolveOptions::with_worker_name("runtime-workspace"),
)
.unwrap();
assert_eq!(resolved.manifest.worker.name, "runtime-workspace");
assert_eq!(
resolved.manifest.model.ref_.as_deref(),
Some("codex-oauth/gpt-5.5")
);
assert!(resolved.manifest.feature.ticket.enabled);
assert!(resolved.manifest.feature.ticket.authoring);
assert!(resolved.manifest.feature.ticket.thread);
assert!(!resolved.manifest.feature.ticket.intake);
assert!(!resolved.manifest.feature.orchestration.enabled);
assert_eq!(
resolved.profile.as_ref().unwrap().name.as_deref(),
Some("companion")
);
assert_eq!(
resolved.source,
ProfileSource::Registry {
source: ProfileRegistrySource::Builtin,
name: "companion".into(),
path: None,
provenance: Some("builtin:companion".into()),
}
);
}
#[test]
fn unsupported_profile_extension_has_clear_diagnostic() { fn unsupported_profile_extension_has_clear_diagnostic() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let path = write_profile(tmp.path(), "legacy.txt", "{}"); let path = write_profile(tmp.path(), "legacy.txt", "{}");
+132 -28
View File
@@ -11,6 +11,13 @@ use uuid::Uuid;
const SCHEMA_VERSION: i64 = 12; const SCHEMA_VERSION: i64 = 12;
const PREVIOUS_SCHEMA_VERSION: i64 = 11; const PREVIOUS_SCHEMA_VERSION: i64 = 11;
const MAX_BODY_BYTES: usize = 16 * 1024; const MAX_BODY_BYTES: usize = 16 * 1024;
const DOMAIN_TABLES: [&str; 5] = [
"merge_requests",
"merge_request_ticket_relations",
"merge_request_thread_events",
"merge_request_review_grants",
"merge_request_reviewer_child_sessions",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -68,6 +75,15 @@ pub struct MergeRequestAuth {
pub assignment_id: String, pub assignment_id: String,
} }
impl MergeRequestAuth {
fn actor(&self) -> WorkerIdentity {
WorkerIdentity {
runtime_id: self.runtime_id.clone(),
worker_id: self.worker_id.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewRequestedEvent { pub struct ReviewRequestedEvent {
pub event_id: String, pub event_id: String,
@@ -641,7 +657,7 @@ impl MergeRequestStore {
} }
pub fn validate_completion(&self, i: &CompleteMergeRequest) -> Result<(), MergeRequestError> { pub fn validate_completion(&self, i: &CompleteMergeRequest) -> Result<(), MergeRequestError> {
let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?;
self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?; self.repo(&i.auth, &mr.repository_id)?;
if let Some(existing) = mr.thread.iter().find_map(|event| match event { if let Some(existing) = mr.thread.iter().find_map(|event| match event {
MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => { MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => {
Some(value) Some(value)
@@ -649,8 +665,12 @@ impl MergeRequestStore {
_ => None, _ => None,
}) { }) {
if existing.approval_event_id == i.approval_event_id if existing.approval_event_id == i.approval_event_id
&& existing.approved_source_ref == i.current_subject_ref
&& existing.target_ref_before == i.target_ref_before && existing.target_ref_before == i.target_ref_before
&& existing.target_ref_after == i.target_ref_after && existing.target_ref_after == i.target_ref_after
&& existing.strategy == i.strategy
&& existing.resolution == i.resolution
&& existing.merged_by == i.auth.actor()
{ {
return Ok(()); return Ok(());
} }
@@ -658,6 +678,7 @@ impl MergeRequestStore {
"operation fingerprint mismatch".into(), "operation fingerprint mismatch".into(),
)); ));
} }
self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?;
if mr.state != MergeRequestState::Open { if mr.state != MergeRequestState::Open {
return Err(MergeRequestError::Conflict( return Err(MergeRequestError::Conflict(
"Merge Request is not open".into(), "Merge Request is not open".into(),
@@ -711,8 +732,12 @@ impl MergeRequestStore {
_ => None, _ => None,
}) { }) {
if existing.approval_event_id == i.approval_event_id if existing.approval_event_id == i.approval_event_id
&& existing.approved_source_ref == i.current_subject_ref
&& existing.target_ref_before == i.target_ref_before && existing.target_ref_before == i.target_ref_before
&& existing.target_ref_after == i.target_ref_after && existing.target_ref_after == i.target_ref_after
&& existing.strategy == i.strategy
&& existing.resolution == i.resolution
&& existing.merged_by == i.auth.actor()
{ {
return Ok(existing.clone()); return Ok(existing.clone());
} }
@@ -767,6 +792,16 @@ impl MergeRequestStore {
WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'", WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'",
params![mr.workspace_id, i.ticket_id, i.now.to_rfc3339()], params![mr.workspace_id, i.ticket_id, i.now.to_rfc3339()],
)?; )?;
let released_assignment = transaction.execute(
"DELETE FROM ticket_current_worker_assignments
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3",
params![mr.workspace_id, i.ticket_id, i.auth.assignment_id],
)?;
if released_assignment != 1 {
return Err(MergeRequestError::Unauthorized(
"completion assignment changed while closing Ticket".into(),
));
}
let issued_grants = { let issued_grants = {
let mut statement = transaction.prepare( let mut statement = transaction.prepare(
"SELECT request_event_id,subject_ref,capability_token "SELECT request_event_id,subject_ref,capability_token
@@ -1143,27 +1178,87 @@ fn ticket_event(
} }
pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> { pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
match schema_version(c)? { match schema_state(c)? {
None => fresh(c), SchemaState::Fresh => fresh(c),
Some(SCHEMA_VERSION) => verify(c), SchemaState::Current(SCHEMA_VERSION) => verify(c),
Some(PREVIOUS_SCHEMA_VERSION) => from_v11(c), SchemaState::Current(PREVIOUS_SCHEMA_VERSION) => from_v11(c, PreviousSchemaMarker::Current),
Some(v) => Err(MergeRequestError::Operation(format!( SchemaState::Legacy(PREVIOUS_SCHEMA_VERSION) => from_v11(c, PreviousSchemaMarker::Legacy),
SchemaState::Current(v) => Err(MergeRequestError::Operation(format!(
"unsupported schema {v}" "unsupported schema {v}"
))), ))),
SchemaState::Legacy(v) => Err(MergeRequestError::Operation(format!(
"unsupported legacy schema {v}"
))),
} }
} }
fn schema_version(c: &Connection) -> Result<Option<i64>, MergeRequestError> {
let e:bool=c.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')",[],|r|r.get(0))?; #[derive(Debug, Clone, Copy, PartialEq, Eq)]
if !e { enum SchemaState {
return Ok(None); Fresh,
Current(i64),
Legacy(i64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PreviousSchemaMarker {
Current,
Legacy,
}
fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> {
let (current, legacy): (bool, bool) = c.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema'),EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema_migrations')",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
if current && legacy {
return Err(MergeRequestError::Corrupt(
"both current and legacy schema markers exist".into(),
));
} }
c.query_row( if current {
"SELECT version FROM merge_request_schema WHERE singleton=1", let (count, singleton, version): (i64, Option<i64>, Option<i64>) = c.query_row(
"SELECT COUNT(*),MIN(singleton),MAX(version) FROM merge_request_schema",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)?;
if count != 1 || singleton != Some(1) {
return Err(MergeRequestError::Corrupt(
"current schema marker must contain exactly singleton 1".into(),
));
}
let version = version.ok_or_else(|| {
MergeRequestError::Corrupt("current schema marker version is null".into())
})?;
return Ok(SchemaState::Current(version));
}
if legacy {
let (count, version): (i64, Option<i64>) = c.query_row(
"SELECT COUNT(*),MAX(version) FROM merge_request_schema_migrations",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
if count != 1 {
return Err(MergeRequestError::Corrupt(
"legacy schema marker must contain exactly one version".into(),
));
}
let version = version.ok_or_else(|| {
MergeRequestError::Corrupt("legacy schema marker version is null".into())
})?;
return Ok(SchemaState::Legacy(version));
}
let domain_tables: bool = c.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name GLOB 'merge_request*')",
[], [],
|r| r.get(0), |r| r.get(0),
) )?;
.optional() if domain_tables {
.map_err(Into::into) return Err(MergeRequestError::Corrupt(
"merge request tables exist without a schema marker".into(),
));
}
Ok(SchemaState::Fresh)
} }
fn fresh(c: &Connection) -> Result<(), MergeRequestError> { fn fresh(c: &Connection) -> Result<(), MergeRequestError> {
let t = c.unchecked_transaction()?; let t = c.unchecked_transaction()?;
@@ -1180,13 +1275,26 @@ fn tables(t: &Transaction<'_>, marker: bool) -> Result<(), MergeRequestError> {
t.execute_batch("CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN('open','merged','closed')),selector_from TEXT,selector_to TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id),FOREIGN KEY(workspace_id,repository_id)REFERENCES repositories(workspace_id,repository_id));CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL CHECK(relation_kind='implements'),created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE,FOREIGN KEY(workspace_id,ticket_id)REFERENCES typed_tickets(workspace_id,ticket_id)ON DELETE CASCADE);CREATE TABLE merge_request_thread_events(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,event_id TEXT NOT NULL,sequence INTEGER NOT NULL,kind TEXT NOT NULL CHECK(kind IN('review_requested','review','review_revoked','review_cancelled','comment','merge')),payload_json TEXT NOT NULL,operation_id TEXT,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,event_id),UNIQUE(workspace_id,merge_request_id,sequence),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE);CREATE UNIQUE INDEX merge_request_merge_operations ON merge_request_thread_events(workspace_id,operation_id)WHERE operation_id IS NOT NULL;CREATE TABLE merge_request_review_grants(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,request_event_id TEXT NOT NULL,subject_ref TEXT NOT NULL,reviewer_runtime_id TEXT NOT NULL,reviewer_worker_id TEXT NOT NULL,capability_token TEXT PRIMARY KEY,issued_at TEXT NOT NULL,consumed_at TEXT,revoked_at TEXT,status TEXT NOT NULL CHECK(status IN('issued','consumed','revoked')),FOREIGN KEY(workspace_id,merge_request_id,request_event_id)REFERENCES merge_request_thread_events(workspace_id,merge_request_id,event_id)ON DELETE CASCADE);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT NOT NULL,child_session_id TEXT NOT NULL,parent_runtime_id TEXT NOT NULL,parent_worker_id TEXT NOT NULL,reviewer_profile TEXT NOT NULL,registered_at TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('active','consumed')),PRIMARY KEY(workspace_id,child_session_id));")?; t.execute_batch("CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN('open','merged','closed')),selector_from TEXT,selector_to TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id),FOREIGN KEY(workspace_id,repository_id)REFERENCES repositories(workspace_id,repository_id));CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL CHECK(relation_kind='implements'),created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE,FOREIGN KEY(workspace_id,ticket_id)REFERENCES typed_tickets(workspace_id,ticket_id)ON DELETE CASCADE);CREATE TABLE merge_request_thread_events(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,event_id TEXT NOT NULL,sequence INTEGER NOT NULL,kind TEXT NOT NULL CHECK(kind IN('review_requested','review','review_revoked','review_cancelled','comment','merge')),payload_json TEXT NOT NULL,operation_id TEXT,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,event_id),UNIQUE(workspace_id,merge_request_id,sequence),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE);CREATE UNIQUE INDEX merge_request_merge_operations ON merge_request_thread_events(workspace_id,operation_id)WHERE operation_id IS NOT NULL;CREATE TABLE merge_request_review_grants(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,request_event_id TEXT NOT NULL,subject_ref TEXT NOT NULL,reviewer_runtime_id TEXT NOT NULL,reviewer_worker_id TEXT NOT NULL,capability_token TEXT PRIMARY KEY,issued_at TEXT NOT NULL,consumed_at TEXT,revoked_at TEXT,status TEXT NOT NULL CHECK(status IN('issued','consumed','revoked')),FOREIGN KEY(workspace_id,merge_request_id,request_event_id)REFERENCES merge_request_thread_events(workspace_id,merge_request_id,event_id)ON DELETE CASCADE);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT NOT NULL,child_session_id TEXT NOT NULL,parent_runtime_id TEXT NOT NULL,parent_worker_id TEXT NOT NULL,reviewer_profile TEXT NOT NULL,registered_at TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('active','consumed')),PRIMARY KEY(workspace_id,child_session_id));")?;
Ok(()) Ok(())
} }
fn from_v11(c: &Connection) -> Result<(), MergeRequestError> { fn from_v11(
c: &Connection,
previous_marker: PreviousSchemaMarker,
) -> Result<(), MergeRequestError> {
let t = c.unchecked_transaction()?; let t = c.unchecked_transaction()?;
if previous_marker == PreviousSchemaMarker::Legacy {
t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?;
t.execute(
"INSERT INTO merge_request_schema VALUES(1,?1)",
params![PREVIOUS_SCHEMA_VERSION],
)?;
}
t.execute_batch("ALTER TABLE merge_requests RENAME TO merge_requests_v11;ALTER TABLE merge_request_ticket_relations RENAME TO merge_request_ticket_relations_v11;ALTER TABLE merge_request_revisions RENAME TO merge_request_revisions_v11;ALTER TABLE merge_request_revision_paths RENAME TO merge_request_revision_paths_v11;ALTER TABLE merge_request_reviewer_child_sessions RENAME TO merge_request_reviewer_child_sessions_v11;ALTER TABLE merge_request_review_attempts RENAME TO merge_request_review_attempts_v11;ALTER TABLE merge_request_reviews RENAME TO merge_request_reviews_v11;ALTER TABLE merge_request_review_findings RENAME TO merge_request_review_findings_v11;ALTER TABLE merge_request_completion_operations RENAME TO merge_request_completion_operations_v11;")?; t.execute_batch("ALTER TABLE merge_requests RENAME TO merge_requests_v11;ALTER TABLE merge_request_ticket_relations RENAME TO merge_request_ticket_relations_v11;ALTER TABLE merge_request_revisions RENAME TO merge_request_revisions_v11;ALTER TABLE merge_request_revision_paths RENAME TO merge_request_revision_paths_v11;ALTER TABLE merge_request_reviewer_child_sessions RENAME TO merge_request_reviewer_child_sessions_v11;ALTER TABLE merge_request_review_attempts RENAME TO merge_request_review_attempts_v11;ALTER TABLE merge_request_reviews RENAME TO merge_request_reviews_v11;ALTER TABLE merge_request_review_findings RENAME TO merge_request_review_findings_v11;ALTER TABLE merge_request_completion_operations RENAME TO merge_request_completion_operations_v11;")?;
tables(&t, false)?; tables(&t, false)?;
t.execute("INSERT INTO merge_requests SELECT workspace_id,merge_request_id,repository_id,CASE state WHEN 'draft'THEN'open'ELSE state END,NULL,target_ref_selector,created_at,updated_at FROM merge_requests_v11",[])?; t.execute("INSERT INTO merge_requests SELECT workspace_id,merge_request_id,repository_id,CASE state WHEN 'draft'THEN'open'ELSE state END,NULL,target_ref_selector,created_at,updated_at FROM merge_requests_v11",[])?;
t.execute("INSERT INTO merge_request_ticket_relations SELECT * FROM merge_request_ticket_relations_v11",[])?; t.execute("INSERT INTO merge_request_ticket_relations SELECT * FROM merge_request_ticket_relations_v11",[])?;
migrate_events(&t)?; migrate_events(&t)?;
if previous_marker == PreviousSchemaMarker::Legacy {
t.execute("DROP TABLE merge_request_schema_migrations", [])?;
}
t.execute_batch("DROP TABLE merge_request_review_findings_v11;DROP TABLE merge_request_reviews_v11;DROP TABLE merge_request_review_attempts_v11;DROP TABLE merge_request_reviewer_child_sessions_v11;DROP TABLE merge_request_revision_paths_v11;DROP TABLE merge_request_revisions_v11;DROP TABLE merge_request_completion_operations_v11;DROP TABLE merge_request_ticket_relations_v11;DROP TABLE merge_requests_v11;UPDATE merge_request_schema SET version=12 WHERE singleton=1;")?; t.execute_batch("DROP TABLE merge_request_review_findings_v11;DROP TABLE merge_request_reviews_v11;DROP TABLE merge_request_review_attempts_v11;DROP TABLE merge_request_reviewer_child_sessions_v11;DROP TABLE merge_request_revision_paths_v11;DROP TABLE merge_request_revisions_v11;DROP TABLE merge_request_completion_operations_v11;DROP TABLE merge_request_ticket_relations_v11;DROP TABLE merge_requests_v11;UPDATE merge_request_schema SET version=12 WHERE singleton=1;")?;
fk(&t)?; fk(&t)?;
t.commit()?; t.commit()?;
@@ -1363,12 +1471,7 @@ fn migrate_events(t: &Transaction<'_>) -> Result<(), MergeRequestError> {
Ok(()) Ok(())
} }
fn verify(c: &Connection) -> Result<(), MergeRequestError> { fn verify(c: &Connection) -> Result<(), MergeRequestError> {
for n in [ for n in DOMAIN_TABLES {
"merge_requests",
"merge_request_ticket_relations",
"merge_request_thread_events",
"merge_request_review_grants",
] {
let e: bool = c.query_row( let e: bool = c.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table'AND name=?1)", "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table'AND name=?1)",
params![n], params![n],
@@ -1381,15 +1484,16 @@ fn verify(c: &Connection) -> Result<(), MergeRequestError> {
fk(c) fk(c)
} }
fn fk(c: &Connection) -> Result<(), MergeRequestError> { fn fk(c: &Connection) -> Result<(), MergeRequestError> {
let v: Option<(String, i64)> = c for table in DOMAIN_TABLES {
.query_row("PRAGMA foreign_key_check", [], |r| { let sql = format!("PRAGMA foreign_key_check('{table}')");
Ok((r.get(0)?, r.get(1)?)) let violation: Option<(String, Option<i64>, String)> = c
}) .query_row(&sql, [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.optional()?; .optional()?;
if let Some((t, r)) = v { if let Some((child, row, parent)) = violation {
return Err(MergeRequestError::Corrupt(format!( return Err(MergeRequestError::Corrupt(format!(
"foreign key violation {t}:{r}" "foreign key violation in `{child}` row {row:?}, parent `{parent}`"
))); )));
} }
}
Ok(()) Ok(())
} }
+86 -3
View File
@@ -93,7 +93,7 @@ fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
} }
#[test] #[test]
fn selectors_thread_and_completion_have_no_revision_or_commit_api() { fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
let (_d, s) = fixture(); let (d, s) = fixture();
open(&s); open(&s);
let review = approve(&s, "opaque-source-ref", "token"); let review = approve(&s, "opaque-source-ref", "token");
let ready = s let ready = s
@@ -122,6 +122,33 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
let mr = s.get("W", "T").unwrap(); let mr = s.get("W", "T").unwrap();
assert_eq!(mr.selector_from.as_deref(), Some("work/t")); assert_eq!(mr.selector_from.as_deref(), Some("work/t"));
assert_eq!(mr.state, MergeRequestState::Merged); assert_eq!(mr.state, MergeRequestState::Merged);
let current_assignment: bool = Connection::open(d.path().join("db"))
.unwrap()
.query_row(
"SELECT EXISTS(
SELECT 1 FROM ticket_current_worker_assignments
WHERE workspace_id='W' AND ticket_id='T'
)",
[],
|row| row.get(0),
)
.unwrap();
assert!(!current_assignment);
let replayed = s
.complete(CompleteMergeRequest {
ticket_id: "T".into(),
operation_id: "op".into(),
approval_event_id: merged.approval_event_id.clone(),
current_subject_ref: merged.approved_source_ref.clone(),
target_ref_before: merged.target_ref_before.clone(),
target_ref_after: merged.target_ref_after.clone(),
strategy: merged.strategy,
resolution: merged.resolution,
auth: auth(),
now: at(6),
})
.unwrap();
assert_eq!(replayed, merged);
let json = serde_json::to_string(&mr).unwrap(); let json = serde_json::to_string(&mr).unwrap();
for banned in [ for banned in [
"revision_id", "revision_id",
@@ -188,10 +215,37 @@ fn review_revocation_invalidates_readiness() {
} }
#[test] #[test]
fn v11_migration_preserves_review_events_and_requires_selector_repair() { fn legacy_v11_migration_preserves_review_events_and_replaces_marker() {
let c = Connection::open_in_memory().unwrap(); let c = Connection::open_in_memory().unwrap();
c.execute_batch("CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T');CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY,version INTEGER);INSERT INTO merge_request_schema VALUES(1,11);CREATE TABLE merge_requests(workspace_id TEXT,merge_request_id TEXT,repository_id TEXT,state TEXT,target_ref_selector TEXT,current_revision_ordinal INTEGER,current_revision_id TEXT,created_at TEXT,updated_at TEXT,merged_revision_id TEXT,merged_at TEXT);CREATE TABLE merge_request_ticket_relations(workspace_id TEXT,merge_request_id TEXT,ticket_id TEXT,relation_kind TEXT,created_at TEXT);CREATE TABLE merge_request_revisions(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,base_commit TEXT,head_commit TEXT,diff_digest TEXT,summary TEXT,assignment_id TEXT,created_at TEXT);CREATE TABLE merge_request_revision_paths(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,path TEXT);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT,child_session_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,reviewer_profile TEXT,registered_at TEXT);CREATE TABLE merge_request_review_attempts(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,ticket_id TEXT,revision_id TEXT,revision_ordinal INTEGER,parent_assignment_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,child_session_id TEXT,reviewer_effective_profile TEXT,capability_token TEXT,status TEXT,created_at TEXT,consumed_at TEXT);CREATE TABLE merge_request_reviews(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,revision_id TEXT,decision TEXT,body TEXT,submitted_at TEXT);CREATE TABLE merge_request_review_findings(workspace_id TEXT,attempt_id TEXT,ordinal INTEGER,severity TEXT,code TEXT,path TEXT,line INTEGER,body TEXT);CREATE TABLE merge_request_completion_operations(workspace_id TEXT,operation_id TEXT,ticket_id TEXT,revision_id TEXT,authority_kind TEXT,implementation_assignment_id TEXT,completion_actor_runtime_id TEXT,completion_actor_worker_id TEXT,target_commit TEXT,source_commit TEXT,result_commit TEXT,strategy TEXT,resolution TEXT,fingerprint TEXT,status TEXT,result_ticket_state TEXT,created_at TEXT,updated_at TEXT);INSERT INTO merge_requests VALUES('W','MR','R','open','develop',1,'V','2026-07-26T12:00:00Z','2026-07-26T12:00:00Z',NULL,NULL);INSERT INTO merge_request_ticket_relations VALUES('W','MR','T','implements','2026-07-26T12:00:00Z');INSERT INTO merge_request_revisions VALUES('W','MR','V',1,'base','subject','digest','summary','A','2026-07-26T12:00:00Z');INSERT INTO merge_request_review_attempts VALUES('W','AT','MR','T','V',1,'A','runtime','coder','child','builtin:reviewer','token','submitted','2026-07-26T12:00:00Z','2026-07-26T12:00:01Z');INSERT INTO merge_request_reviews VALUES('W','AT','MR','V','approve','approved','2026-07-26T12:00:01Z');INSERT INTO merge_request_review_attempts VALUES('W','PENDING','MR','T','V',1,'A','runtime','coder','pending-child','builtin:reviewer','pending-token','registered','2026-07-26T12:00:02Z',NULL);").unwrap(); c.execute_batch("CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T');CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);INSERT INTO merge_request_schema_migrations(version) VALUES(11);CREATE TABLE merge_requests(workspace_id TEXT,merge_request_id TEXT,repository_id TEXT,state TEXT,target_ref_selector TEXT,current_revision_ordinal INTEGER,current_revision_id TEXT,created_at TEXT,updated_at TEXT,merged_revision_id TEXT,merged_at TEXT);CREATE TABLE merge_request_ticket_relations(workspace_id TEXT,merge_request_id TEXT,ticket_id TEXT,relation_kind TEXT,created_at TEXT);CREATE TABLE merge_request_revisions(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,base_commit TEXT,head_commit TEXT,diff_digest TEXT,summary TEXT,assignment_id TEXT,created_at TEXT);CREATE TABLE merge_request_revision_paths(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,path TEXT);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT,child_session_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,reviewer_profile TEXT,registered_at TEXT);CREATE TABLE merge_request_review_attempts(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,ticket_id TEXT,revision_id TEXT,revision_ordinal INTEGER,parent_assignment_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,child_session_id TEXT,reviewer_effective_profile TEXT,capability_token TEXT,status TEXT,created_at TEXT,consumed_at TEXT);CREATE TABLE merge_request_reviews(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,revision_id TEXT,decision TEXT,body TEXT,submitted_at TEXT);CREATE TABLE merge_request_review_findings(workspace_id TEXT,attempt_id TEXT,ordinal INTEGER,severity TEXT,code TEXT,path TEXT,line INTEGER,body TEXT);CREATE TABLE merge_request_completion_operations(workspace_id TEXT,operation_id TEXT,ticket_id TEXT,revision_id TEXT,authority_kind TEXT,implementation_assignment_id TEXT,completion_actor_runtime_id TEXT,completion_actor_worker_id TEXT,target_commit TEXT,source_commit TEXT,result_commit TEXT,strategy TEXT,resolution TEXT,fingerprint TEXT,status TEXT,result_ticket_state TEXT,created_at TEXT,updated_at TEXT);INSERT INTO merge_requests VALUES('W','MR','R','open','develop',1,'V','2026-07-26T12:00:00Z','2026-07-26T12:00:00Z',NULL,NULL);INSERT INTO merge_request_ticket_relations VALUES('W','MR','T','implements','2026-07-26T12:00:00Z');INSERT INTO merge_request_revisions VALUES('W','MR','V',1,'base','subject','digest','summary','A','2026-07-26T12:00:00Z');INSERT INTO merge_request_review_attempts VALUES('W','AT','MR','T','V',1,'A','runtime','coder','child','builtin:reviewer','token','submitted','2026-07-26T12:00:00Z','2026-07-26T12:00:01Z');INSERT INTO merge_request_reviews VALUES('W','AT','MR','V','approve','approved','2026-07-26T12:00:01Z');INSERT INTO merge_request_review_attempts VALUES('W','PENDING','MR','T','V',1,'A','runtime','coder','pending-child','builtin:reviewer','pending-token','registered','2026-07-26T12:00:02Z',NULL);").unwrap();
c.execute_batch(
"CREATE TABLE unrelated_parent(left_id TEXT,right_id TEXT,PRIMARY KEY(left_id,right_id));CREATE TABLE unrelated_child(left_id TEXT REFERENCES unrelated_parent(left_id));",
)
.unwrap();
let unrelated_mismatch = c
.query_row("PRAGMA foreign_key_check", [], |_| Ok(()))
.unwrap_err();
assert!(
unrelated_mismatch
.to_string()
.contains("foreign key mismatch")
);
merge_request::migrate(&c).unwrap(); merge_request::migrate(&c).unwrap();
assert_eq!(
c.query_row("SELECT version FROM merge_request_schema", [], |r| {
r.get::<_, i64>(0)
})
.unwrap(),
12
);
let legacy_marker: bool = c
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema_migrations')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!legacy_marker);
let selector: Option<String> = c let selector: Option<String> = c
.query_row("SELECT selector_from FROM merge_requests", [], |r| r.get(0)) .query_row("SELECT selector_from FROM merge_requests", [], |r| r.get(0))
.unwrap(); .unwrap();
@@ -217,6 +271,35 @@ fn v11_migration_preserves_review_events_and_requires_selector_repair() {
assert!(!old); assert!(!old);
} }
#[test]
fn failed_legacy_v11_migration_rolls_back_marker_bridge() {
let c = Connection::open_in_memory().unwrap();
c.execute_batch(
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);INSERT INTO merge_request_schema_migrations(version) VALUES(11);CREATE TABLE merge_requests(merge_request_id TEXT);",
)
.unwrap();
assert!(merge_request::migrate(&c).is_err());
for table in ["merge_request_schema_migrations", "merge_requests"] {
let exists: bool = c
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
[table],
|r| r.get(0),
)
.unwrap();
assert!(exists, "{table} was not rolled back");
}
let current_marker: bool = c
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!current_marker);
}
#[test] #[test]
fn authority_reads_full_thread_while_public_pages_remain_bounded() { fn authority_reads_full_thread_while_public_pages_remain_bounded() {
let (_d, store) = fixture(); let (_d, store) = fixture();
+1
View File
@@ -14,6 +14,7 @@ schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
sha2.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
thiserror.workspace = true thiserror.workspace = true
tempfile.workspace = true tempfile.workspace = true
+851 -113
View File
File diff suppressed because it is too large Load Diff
+34 -6
View File
@@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32; const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 3; pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 4;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct Migration { struct Migration {
@@ -32,6 +32,11 @@ const MIGRATIONS: &[Migration] = &[
name: "convert_legacy_reviews_to_comments", name: "convert_legacy_reviews_to_comments",
apply: retire_legacy_ticket_review_events, apply: retire_legacy_ticket_review_events,
}, },
Migration {
version: 4,
name: "add_ticket_query_indexes",
apply: add_ticket_query_indexes,
},
]; ];
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -507,6 +512,29 @@ fn retire_legacy_ticket_review_events(connection: &Connection) -> Result<()> {
.map_err(sqlite_err) .map_err(sqlite_err)
} }
fn add_ticket_query_indexes(connection: &Connection) -> Result<()> {
connection
.execute_batch(
r#"
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_state_updated
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_updated
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_created
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_title
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
CREATE INDEX IF NOT EXISTS typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE INDEX IF NOT EXISTS typed_ticket_relations_workspace_source_kind
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
CREATE INDEX IF NOT EXISTS typed_ticket_relations_workspace_target_kind
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
"#,
)
.map_err(sqlite_err)
}
fn add_column_if_missing( fn add_column_if_missing(
connection: &Connection, connection: &Connection,
table: &str, table: &str,
@@ -841,10 +869,10 @@ mod tests {
verify_sqlite_ticket_schema(&connection).unwrap(); verify_sqlite_ticket_schema(&connection).unwrap();
let versions = load_applied_migrations(&connection).unwrap(); let versions = load_applied_migrations(&connection).unwrap();
assert_eq!(versions.len(), 3); assert_eq!(versions.len(), 4);
assert_eq!( assert_eq!(
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION), versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION),
Some(&"convert_legacy_reviews_to_comments".to_string()) Some(&"add_ticket_query_indexes".to_string())
); );
} }
@@ -989,7 +1017,7 @@ mod tests {
.to_string() .to_string()
.contains("unsupported Ticket schema migration version 99") .contains("unsupported Ticket schema migration version 99")
); );
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 4); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 5);
} }
#[test] #[test]
@@ -1090,7 +1118,7 @@ mod tests {
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,status,heading,body) VALUES ('workspace-1','ticket-1',0,'review','reviewer','2026-08-11T00:00:00Z','approve','Review','legacy evidence')",[]).unwrap(); connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,status,heading,body) VALUES ('workspace-1','ticket-1',0,'review','reviewer','2026-08-11T00:00:00Z','approve','Review','legacy evidence')",[]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES ('workspace-1','ticket-1',0,'result','approve')",[]).unwrap(); connection.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES ('workspace-1','ticket-1',0,'result','approve')",[]).unwrap();
connection connection
.execute("DELETE FROM ticket_schema_migrations WHERE version=3", []) .execute("DELETE FROM ticket_schema_migrations WHERE version>=3", [])
.unwrap(); .unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap(); migrate_sqlite_ticket_schema(&connection).unwrap();
let (kind,status,heading,body):(String,Option<String>,Option<String>,Option<String>)=connection.query_row("SELECT kind,status,heading,body FROM typed_ticket_events WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND event_index=0",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap(); let (kind,status,heading,body):(String,Option<String>,Option<String>,Option<String>)=connection.query_row("SELECT kind,status,heading,body FROM typed_ticket_events WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND event_index=0",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap();
@@ -1129,6 +1157,6 @@ mod tests {
let connection = Connection::open(database).unwrap(); let connection = Connection::open(database).unwrap();
verify_sqlite_ticket_schema(&connection).unwrap(); verify_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 3); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 4);
} }
} }
+185 -61
View File
@@ -16,8 +16,9 @@ use crate::{
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic, Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
TicketIntakeSummary, TicketListState, TicketRef, TicketRelation, TicketRelationKind, TicketIntakeSummary, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author, TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState,
default_author,
}; };
const DEFAULT_LIST_LIMIT: usize = 50; const DEFAULT_LIST_LIMIT: usize = 50;
@@ -42,7 +43,7 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
"TicketImplementationReport", "TicketImplementationReport",
"TicketIntakeReady", "TicketMarkReady",
"TicketQueue", "TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
@@ -68,7 +69,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] = pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
["TicketRelationQuery", "TicketOrchestrationPlanQuery"]; ["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
pub const TICKET_TOOL_NAMES: [&str; 19] = [ pub const TICKET_TOOL_NAMES: [&str; 20] = [
"TicketCreate", "TicketCreate",
"TicketEditItem", "TicketEditItem",
"QueryTicket", "QueryTicket",
@@ -77,6 +78,7 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
"TicketImplementationReport", "TicketImplementationReport",
"TicketMarkReady",
"TicketIntakeReady", "TicketIntakeReady",
"TicketQueue", "TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
@@ -99,13 +101,14 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
]; ];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [
"TicketCreate", "TicketCreate",
"TicketEditItem", "TicketEditItem",
"TicketComment", "TicketComment",
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
"TicketImplementationReport", "TicketImplementationReport",
"TicketMarkReady",
"TicketIntakeReady", "TicketIntakeReady",
"TicketQueue", "TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
@@ -132,9 +135,12 @@ const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Mark
const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown."; const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
const IMPLEMENTATION_REPORT_DESCRIPTION: &str = const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
"Append a typed Ticket implementation_report event. `body` is Markdown."; "Append a typed Ticket implementation_report event. `body` is Markdown.";
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \ const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \ The backend atomically validates and normalizes the persisted repository/ref target, records one typed \
for `state`, and transitions state to `ready`."; 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.";
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \ const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \ Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
and rejects unresolved blocking relations."; and rejects unresolved blocking relations.";
@@ -174,6 +180,7 @@ fn base_tool_description(name: &str) -> &'static str {
"TicketPlan" => PLAN_DESCRIPTION, "TicketPlan" => PLAN_DESCRIPTION,
"TicketDecision" => DECISION_DESCRIPTION, "TicketDecision" => DECISION_DESCRIPTION,
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION, "TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
"TicketMarkReady" => MARK_READY_DESCRIPTION,
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION, "TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
"TicketQueue" => QUEUE_DESCRIPTION, "TicketQueue" => QUEUE_DESCRIPTION,
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION, "TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
@@ -305,13 +312,8 @@ impl TicketBackend for TicketToolBackend {
self.backend.set_workflow_state(id, change) self.backend.set_workflow_state(id, change)
} }
fn mark_intake_ready( fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult<Ticket> {
&self, self.backend.mark_ready(id, request)
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> TicketResult<()> {
self.backend.mark_intake_ready(id, summary, change)
} }
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> { fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
@@ -558,18 +560,24 @@ struct TicketThreadEventParams {
body: String, body: String,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketMarkReadyParams {
/// Ticket id.
ticket: String,
/// Optional reason attached to the state_changed event.
#[serde(default)]
reason: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams { struct TicketIntakeReadyParams {
/// Ticket id. /// Ticket id.
ticket: String, ticket: String,
/// Concise bounded intake summary to append as a typed intake_summary event. /// Concise bounded intake summary appended before the ready transition.
intake_summary: String, intake_summary: String,
/// Reason attached to the state_changed event. Defaults to `planning_ready`. /// Optional reason attached to the state_changed event.
#[serde(default)] #[serde(default)]
reason: Option<String>, reason: Option<String>,
/// Optional state_changed body. If omitted, a concise default is used.
#[serde(default)]
state_change_body: Option<String>,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -836,6 +844,11 @@ struct TicketImplementationReportTool {
backend: TicketToolBackend, backend: TicketToolBackend,
} }
#[derive(Clone)]
struct TicketMarkReadyTool {
backend: TicketToolBackend,
}
#[derive(Clone)] #[derive(Clone)]
struct TicketIntakeReadyTool { struct TicketIntakeReadyTool {
backend: TicketToolBackend, backend: TicketToolBackend,
@@ -899,6 +912,23 @@ impl Tool for TicketCreateTool {
_ctx: llm_engine::tool::ToolExecutionContext, _ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let params: TicketCreateParams = parse_input("TicketCreate", input_json)?; let params: TicketCreateParams = parse_input("TicketCreate", input_json)?;
if params
.state
.is_some_and(|state| !matches!(state.into_state(), TicketWorkflowState::Planning))
{
return Err(backend_error(
"TicketCreate",
TicketError::InvalidWorkflowTransition {
from: "creation".to_owned(),
to: params
.state
.expect("checked non-planning state")
.into_state()
.as_str()
.to_owned(),
},
));
}
let mut input = NewTicket::new(params.title); let mut input = NewTicket::new(params.title);
if let Some(body) = params.body { if let Some(body) = params.body {
input.body = MarkdownText::new(body); input.body = MarkdownText::new(body);
@@ -1114,41 +1144,68 @@ impl_ticket_thread_event_tool!(
TicketEventKind::ImplementationReport TicketEventKind::ImplementationReport
); );
#[async_trait]
impl Tool for TicketMarkReadyTool {
async fn execute(
&self,
input_json: &str,
ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketMarkReadyParams = parse_input("TicketMarkReady", input_json)?;
let ticket = self
.backend
.mark_ready(
TicketIdOrSlug::Query(params.ticket.clone()),
TicketMarkReady {
operation_key: format!("ticket-mark-ready:{}", ctx.call_id),
reason: params.reason,
author: None,
intake_summary: None,
},
)
.map_err(|error| backend_error("TicketMarkReady", error))?;
Ok(json_output(
format!("Marked ticket {} state ready", params.ticket),
json!({
"ticket": ticket.meta.id,
"state": ticket.meta.workflow_state.as_str(),
"repository_id": ticket.meta.repository_id,
"ref_selector": ticket.meta.ref_selector,
"ok": true
}),
))
}
}
#[async_trait] #[async_trait]
impl Tool for TicketIntakeReadyTool { impl Tool for TicketIntakeReadyTool {
async fn execute( async fn execute(
&self, &self,
input_json: &str, input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext, ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?; let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?;
let from = TicketWorkflowState::Planning; let ticket = self
let reason = params .backend
.reason .mark_ready(
.unwrap_or_else(|| "planning_ready".to_string());
let body = params.state_change_body.unwrap_or_else(|| {
self.backend
.default_intake_ready_state_change_body(from.as_str())
});
let mut summary = TicketIntakeSummary::new(params.intake_summary);
summary.author = None;
let mut change = TicketStateChange::new(
from.as_str(),
TicketWorkflowState::Ready.as_str(),
reason,
body,
);
change.author = None;
self.backend
.mark_intake_ready(
TicketIdOrSlug::Query(params.ticket.clone()), TicketIdOrSlug::Query(params.ticket.clone()),
summary, TicketMarkReady {
change, operation_key: format!("ticket-intake-ready:{}", ctx.call_id),
reason: params.reason,
author: None,
intake_summary: Some(TicketIntakeSummary::new(params.intake_summary)),
},
) )
.map_err(|error| backend_error("TicketIntakeReady", error))?; .map_err(|error| backend_error("TicketIntakeReady", error))?;
Ok(json_output( Ok(json_output(
format!("Marked ticket {} state ready", params.ticket), format!("Marked ticket {} state ready after intake", params.ticket),
json!({ "ticket": params.ticket, "state": "ready", "ok": true }), json!({
"ticket": ticket.meta.id,
"state": ticket.meta.workflow_state.as_str(),
"repository_id": ticket.meta.repository_id,
"ref_selector": ticket.meta.ref_selector,
"ok": true
}),
)) ))
} }
} }
@@ -1726,6 +1783,7 @@ fn input_schema(name: &str) -> Value {
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => { "TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams)) serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
} }
"TicketMarkReady" => serde_json::to_value(schemars::schema_for!(TicketMarkReadyParams)),
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)), "TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)), "TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
"TicketWorkflowState" => { "TicketWorkflowState" => {
@@ -1774,6 +1832,7 @@ impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketPlanTool); impl_from_backend!(TicketPlanTool);
impl_from_backend!(TicketDecisionTool); impl_from_backend!(TicketDecisionTool);
impl_from_backend!(TicketImplementationReportTool); impl_from_backend!(TicketImplementationReportTool);
impl_from_backend!(TicketMarkReadyTool);
impl_from_backend!(TicketIntakeReadyTool); impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketQueueTool); impl_from_backend!(TicketQueueTool);
impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketWorkflowStateTool);
@@ -1801,6 +1860,7 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
"TicketImplementationReport", "TicketImplementationReport",
backend.clone(), backend.clone(),
), ),
tool_definition::<TicketMarkReadyTool>("TicketMarkReady", backend.clone()),
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()), tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()), tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()), tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
@@ -1826,8 +1886,26 @@ mod tests {
use super::*; use super::*;
use tempfile::TempDir; use tempfile::TempDir;
#[derive(Debug)]
struct TestTargetAuthority;
impl crate::TicketTargetAuthority for TestTargetAuthority {
fn resolve_target(
&self,
_workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> crate::Result<crate::ResolvedTicketTarget> {
Ok(crate::ResolvedTicketTarget {
repository_id: repository_id.unwrap_or("main").to_owned(),
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
})
}
}
fn backend(temp: &TempDir) -> LocalTicketBackend { fn backend(temp: &TempDir) -> LocalTicketBackend {
LocalTicketBackend::new(temp.path().join("tickets")) LocalTicketBackend::new(temp.path().join("tickets"))
.with_target_authority(Arc::new(TestTargetAuthority))
} }
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> { fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
@@ -1877,6 +1955,7 @@ mod tests {
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
"TicketImplementationReport", "TicketImplementationReport",
"TicketMarkReady",
"TicketIntakeReady", "TicketIntakeReady",
"TicketQueue", "TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
@@ -2460,16 +2539,17 @@ mod tests {
async fn ticket_workflow_tools_mark_ready_and_transition_state() { async fn ticket_workflow_tools_mark_ready_and_transition_state() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = backend(&temp); let backend = backend(&temp);
let created = backend.create(NewTicket::new("Workflow Tool")).unwrap(); let mut input = NewTicket::new("Workflow Tool");
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady"); input.repository_id = Some("main".to_owned());
let created = backend.create(input).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady");
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
intake_ready intake_ready
.execute( .execute(
&json!({ &json!({
"ticket": created.id.clone(), "ticket": created.id.clone(),
"intake_summary": "Requirements accepted; implementation can be queued.", "reason": "requirements accepted"
"author": "intake-worker"
}) })
.to_string(), .to_string(),
Default::default(), Default::default(),
@@ -2512,12 +2592,12 @@ mod tests {
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
assert!( assert!(record.events.iter().any(|event| {
record event.kind == TicketEventKind::StateChanged
.events && event.from.as_deref() == Some("planning")
.iter() && event.to.as_deref() == Some("ready")
.any(|event| event.kind == TicketEventKind::IntakeSummary) && event.attributes.contains_key("request_fingerprint")
); }));
let transitions = record let transitions = record
.events .events
.iter() .iter()
@@ -2538,6 +2618,38 @@ mod tests {
); );
} }
#[tokio::test]
async fn ticket_intake_ready_records_summary_with_validated_target() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let mut input = NewTicket::new("Intake Workflow");
input.repository_id = Some("main".to_owned());
let created = backend.create(input).unwrap();
tool_by_name(backend.clone(), "TicketIntakeReady")
.execute(
&json!({
"ticket": created.id.clone(),
"intake_summary": "Requirements and target are accepted.",
"reason": "intake_complete"
})
.to_string(),
Default::default(),
)
.await
.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
.events
.iter()
.filter(|event| event.kind == TicketEventKind::IntakeSummary)
.count(),
1
);
}
#[tokio::test] #[tokio::test]
async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() { async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@@ -2661,7 +2773,11 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(ready_error.to_string().contains("not allowed")); assert!(
ready_error
.to_string()
.contains("invalid ticket workflow transition")
);
let mut done_input = NewTicket::new("Backward Bypass"); let mut done_input = NewTicket::new("Backward Bypass");
done_input.workflow_state = Some(TicketWorkflowState::Done); done_input.workflow_state = Some(TicketWorkflowState::Done);
@@ -2680,7 +2796,11 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(backward_error.to_string().contains("not allowed")); assert!(
backward_error
.to_string()
.contains("invalid ticket workflow transition")
);
let mut queued_input = NewTicket::new("Skip Bypass"); let mut queued_input = NewTicket::new("Skip Bypass");
queued_input.workflow_state = Some(TicketWorkflowState::Queued); queued_input.workflow_state = Some(TicketWorkflowState::Queued);
@@ -2699,17 +2819,21 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(skip_error.to_string().contains("not allowed")); assert!(
skip_error
.to_string()
.contains("invalid ticket workflow transition")
);
} }
#[tokio::test] #[tokio::test]
async fn ticket_intake_ready_tool_rejects_non_planning_ticket() { async fn ticket_mark_ready_tool_rejects_non_planning_ticket() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = backend(&temp); let backend = backend(&temp);
let mut input = NewTicket::new("Already Ready"); let mut input = NewTicket::new("Already Ready");
input.workflow_state = Some(TicketWorkflowState::Ready); input.workflow_state = Some(TicketWorkflowState::Ready);
let created = backend.create(input).unwrap(); let created = backend.create(input).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady"); let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady");
let error = intake_ready let error = intake_ready
.execute( .execute(
@@ -2723,7 +2847,7 @@ mod tests {
.await .await
.unwrap_err(); .unwrap_err();
assert!(error.to_string().contains("state changed concurrently")); assert!(error.to_string().contains("stale ticket workflow state"));
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
assert!(!record.events.iter().any(|event| { assert!(!record.events.iter().any(|event| {
@@ -2867,7 +2991,7 @@ mod tests {
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
"TicketImplementationReport", "TicketImplementationReport",
"TicketIntakeReady", "TicketMarkReady",
"TicketQueue", "TicketQueue",
"TicketRelationRecord", "TicketRelationRecord",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
+15
View File
@@ -769,6 +769,21 @@ where
), ),
); );
} }
if feature_config.merge_request.any() {
let workspace_client = worker.workspace_client_handle();
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Merge Request tools require Backend Workspace API authority",
));
}
feature_registry.add_module(
crate::feature::builtin::merge_request::MergeRequestFeature::new(
workspace_client,
feature_config.merge_request,
),
);
}
if feature_config.manage_workdir.enabled { if feature_config.manage_workdir.enabled {
// Workdir lifecycle is Workspace control-plane authority. The Worker // Workdir lifecycle is Workspace control-plane authority. The Worker
// receives only the injected WorkspaceClient and never Runtime URLs, // receives only the injected WorkspaceClient and never Runtime URLs,
@@ -1,19 +1,41 @@
use crate::feature::ToolDefinition; use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ToolContribution,
ToolDeclaration, ToolDefinition,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use async_trait::async_trait; use async_trait::async_trait;
use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use manifest::MergeRequestFeatureConfig;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[ pub const FEATURE_ID: &str = "merge_request";
"MergeRequestShow", const FEATURE_NAME: &str = "Merge Request tools";
"MergeRequestReadinessCheck", const FEATURE_DESCRIPTION: &str =
"MergeRequestOpen", "Operation-specific Merge Request workflow tools over Workspace authority.";
"MergeRequestComplete", const FEATURE_INSTRUCTION_ID: &str = "merge_request.workflow";
pub const FEATURE_PROMPT_REF: &str = "common.merge_request";
fn workflow_instruction() -> FeatureInstructionDeclaration {
FeatureInstructionDeclaration::new(
FeatureInstructionId::builtin(FEATURE_INSTRUCTION_ID),
FEATURE_PROMPT_REF,
"Operation-specific Merge Request workflow guidance",
)
.expect("static Merge Request workflow instruction declaration is valid")
}
const ALL_KINDS: [Kind; 5] = [
Kind::Show,
Kind::Open,
Kind::Review,
Kind::Readiness,
Kind::Complete,
]; ];
pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit";
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum Kind { enum Kind {
Show, Show,
@@ -89,13 +111,23 @@ struct ReviewFindingInput {
body: String, body: String,
} }
impl Kind { impl Kind {
fn enabled(self, config: MergeRequestFeatureConfig) -> bool {
match self {
Self::Show => config.show,
Self::Open => config.open,
Self::Review => config.review,
Self::Readiness => config.readiness_check,
Self::Complete => config.complete,
}
}
fn name(self) -> &'static str { fn name(self) -> &'static str {
match self { match self {
Self::Show => "MergeRequestShow", Self::Show => "MergeRequestShow",
Self::Readiness => "MergeRequestReadinessCheck", Self::Readiness => "MergeRequestReadinessCheck",
Self::Open => "MergeRequestOpen", Self::Open => "MergeRequestOpen",
Self::Complete => "MergeRequestComplete", Self::Complete => "MergeRequestComplete",
Self::Review => "MergeRequestReviewSubmit", Self::Review => "MergeRequestReview",
} }
} }
fn schema(self) -> serde_json::Value { fn schema(self) -> serde_json::Value {
@@ -218,24 +250,53 @@ fn definition(client: Arc<dyn WorkspaceClient>, kind: Kind) -> ToolDefinition {
) )
}) })
} }
pub fn common_tools(c: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> { pub struct MergeRequestFeature {
vec![ client: Arc<dyn WorkspaceClient>,
definition(c.clone(), Kind::Show), config: MergeRequestFeatureConfig,
definition(c.clone(), Kind::Readiness),
definition(c.clone(), Kind::Open),
definition(c, Kind::Complete),
]
} }
pub fn reviewer_tools(c: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
if c.reviewer_context().is_some() { impl MergeRequestFeature {
vec![ pub fn new(client: Arc<dyn WorkspaceClient>, config: MergeRequestFeatureConfig) -> Self {
definition(c.clone(), Kind::Show), Self { client, config }
definition(c, Kind::Review), }
]
} else { fn kinds(&self) -> impl Iterator<Item = Kind> + '_ {
vec![] ALL_KINDS
.into_iter()
.filter(|kind| kind.enabled(self.config))
} }
} }
impl FeatureModule for MergeRequestFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
.with_description(FEATURE_DESCRIPTION);
if self.config.any() {
descriptor = descriptor.with_instruction(workflow_instruction());
}
for kind in self.kinds() {
descriptor = descriptor.with_tool(ToolDeclaration::new(
kind.name(),
description(kind.name()).unwrap_or("Merge Request operation."),
));
}
descriptor
}
fn install(&self, ctx: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
if self.config.any() {
ctx.instructions()
.register(FeatureInstructionContribution::new(workflow_instruction()))?;
}
let mut tools = ctx.tools();
for kind in self.kinds() {
let definition = definition(self.client.clone(), kind);
tools.register(ToolContribution::new(kind.name(), definition))?;
}
Ok(())
}
}
pub fn description(n: &str) -> Option<&'static str> { pub fn description(n: &str) -> Option<&'static str> {
match n { match n {
"MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."), "MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."),
@@ -248,7 +309,7 @@ pub fn description(n: &str) -> Option<&'static str> {
"MergeRequestComplete" => { "MergeRequestComplete" => {
Some("Complete using an approved review event and final target-ref evidence.") Some("Complete using an approved review event and final target-ref evidence.")
} }
"MergeRequestReviewSubmit" => { "MergeRequestReview" => {
Some("Submit the injected Reviewer capability result for its captured subject ref.") Some("Submit the injected Reviewer capability result for its captured subject ref.")
} }
_ => None, _ => None,
@@ -257,6 +318,72 @@ pub fn description(n: &str) -> Option<&'static str> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::feature::FeatureRegistryBuilder;
use crate::hook::HookRegistryBuilder;
use crate::worker::TestWorkspaceHttpClient;
fn install(config: MergeRequestFeatureConfig) -> (Vec<String>, Vec<String>) {
let client: Arc<dyn WorkspaceClient> =
Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
let mut pending_tools = Vec::new();
let mut hook_builder = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(MergeRequestFeature::new(client, config))
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(!report.has_errors(), "{}", report.error_message());
(
report.installed_tool_names(),
report
.installed_instruction_contributions()
.into_iter()
.map(|instruction| instruction.prompt_ref)
.collect(),
)
}
fn tool_names(config: MergeRequestFeatureConfig) -> Vec<String> {
install(config).0
}
#[test]
fn flags_define_the_exact_registered_tool_surface() {
let coder = MergeRequestFeatureConfig {
show: true,
open: true,
..Default::default()
};
assert_eq!(tool_names(coder), ["MergeRequestShow", "MergeRequestOpen"]);
let reviewer = MergeRequestFeatureConfig {
show: true,
review: true,
..Default::default()
};
assert_eq!(
tool_names(reviewer),
["MergeRequestShow", "MergeRequestReview"]
);
let orchestrator = MergeRequestFeatureConfig {
show: true,
readiness_check: true,
complete: true,
..Default::default()
};
assert_eq!(
tool_names(orchestrator),
[
"MergeRequestShow",
"MergeRequestReadinessCheck",
"MergeRequestComplete"
]
);
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
let unspecified = install(MergeRequestFeatureConfig::default());
assert!(unspecified.0.is_empty());
assert!(unspecified.1.is_empty());
}
#[test] #[test]
fn schemas_hide_revision_and_commit_authority() { fn schemas_hide_revision_and_commit_authority() {
let schemas = [ let schemas = [
@@ -276,6 +403,5 @@ mod tests {
assert!(!j.contains(banned), "{banned} in {j}") assert!(!j.contains(banned), "{banned} in {j}")
} }
} }
assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestRequestReview"));
} }
} }
+23 -60
View File
@@ -24,7 +24,6 @@ use ticket::{
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools}, tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
}; };
use super::merge_request;
use crate::feature::{ use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError, FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId, FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
@@ -54,7 +53,7 @@ impl WorkspaceTicketReadKind {
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata." "Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
} }
Self::Show => { Self::Show => {
"Show one authoritative Workspace Ticket with its item revision, paged thread, links, implementation reports, and current Merge Request review evidence." "Show one authoritative Workspace Ticket with its item revision, paged thread, links, historical implementation reports, and current Merge Request readiness evidence."
} }
} }
} }
@@ -83,8 +82,6 @@ enum WorkspaceTicketStateFilter {
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum WorkspaceTicketEvidenceFilter { enum WorkspaceTicketEvidenceFilter {
ImplementationReport,
ImplementationReportAfterRescope,
MergeRequest, MergeRequest,
Commit, Commit,
ApprovedReview, ApprovedReview,
@@ -104,8 +101,6 @@ enum WorkspaceTicketReviewFilter {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum WorkspaceTicketAttentionFilter { enum WorkspaceTicketAttentionFilter {
DoneNotClosed, DoneNotClosed,
ImplementationReportNotClosed,
ReportAfterRescope,
UnresolvedReview, UnresolvedReview,
MissingCommit, MissingCommit,
Blocked, Blocked,
@@ -147,15 +142,15 @@ struct WorkspaceQueryTicketInput {
/// Exact typed event kinds that must occur in the bounded thread window. /// Exact typed event kinds that must occur in the bounded thread window.
#[serde(default)] #[serde(default)]
event_kinds: Vec<String>, event_kinds: Vec<String>,
/// Required evidence kinds: implementation_report, implementation_report_after_rescope, /// Required evidence kinds: merge_request, commit, or approved_review.
/// merge_request, commit, or approved_review.
#[serde(default)] #[serde(default)]
evidence: Vec<WorkspaceTicketEvidenceFilter>, evidence: Vec<WorkspaceTicketEvidenceFilter>,
/// Current authoritative Merge Request review status: none, pending, approved, /// Current authoritative Merge Request review status: none, pending, approved,
/// request_changes, or unresolved_changes. /// request_changes, or unresolved_changes.
review_status: Option<WorkspaceTicketReviewFilter>, review_status: Option<WorkspaceTicketReviewFilter>,
/// Attention filters include done_not_closed, implementation_report_not_closed, /// Attention filters include done_not_closed, unresolved_review, missing_commit,
/// report_after_rescope, unresolved_review, missing_commit, blocked, and unblocked. /// blocked, unblocked, ready, awaiting_review, unresolved_changes,
/// stale_after_rescope, and missing_evidence.
#[serde(default)] #[serde(default)]
attention: Vec<WorkspaceTicketAttentionFilter>, attention: Vec<WorkspaceTicketAttentionFilter>,
related_ticket_id: Option<String>, related_ticket_id: Option<String>,
@@ -381,6 +376,7 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
const AUTHORING_TOOL_NAMES: &[&str] = &[ const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate", "TicketCreate",
"TicketEditItem", "TicketEditItem",
"TicketMarkReady",
"TicketQueue", "TicketQueue",
"TicketClose", "TicketClose",
"TicketRelationRecord", "TicketRelationRecord",
@@ -398,6 +394,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"QueryTicket", "QueryTicket",
"ShowTicket", "ShowTicket",
"TicketComment", "TicketComment",
"TicketMarkReady",
"TicketQueue", "TicketQueue",
"TicketClose", "TicketClose",
"TicketRelationRecord", "TicketRelationRecord",
@@ -588,22 +585,6 @@ impl FeatureModule for TicketFeature {
ticket_tool_description(name, self.record_language.as_deref()), ticket_tool_description(name, self.record_language.as_deref()),
)); ));
} }
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
let names: Vec<&str> = if client.reviewer_context().is_some() {
vec![
"MergeRequestShow",
merge_request::MERGE_REQUEST_REVIEW_TOOL_NAME,
]
} else {
merge_request::MERGE_REQUEST_COMMON_TOOL_NAMES.to_vec()
};
for name in names {
descriptor = descriptor.with_tool(ToolDeclaration::new(
name,
merge_request::description(name).unwrap_or("Merge Request operation."),
));
}
}
descriptor descriptor
} }
@@ -661,17 +642,6 @@ impl FeatureModule for TicketFeature {
}; };
tools.register(ToolContribution::new(name, definition))?; tools.register(ToolContribution::new(name, definition))?;
} }
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
let definitions = if client.reviewer_context().is_some() {
merge_request::reviewer_tools(client.clone())
} else {
merge_request::common_tools(client.clone())
};
for definition in definitions {
let (meta, _) = definition();
tools.register(ToolContribution::new(meta.name.clone(), definition))?;
}
}
Ok(()) Ok(())
} }
} }
@@ -894,16 +864,15 @@ impl WorkspaceHttpTicketBackend {
TicketError::Conflict(format!("serialize Ticket workflow change: {error}")) TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
})?), })?),
), ),
TicketBackendOperation::MarkIntakeReady { TicketBackendOperation::MarkReady { id, request } => Self::request(
id,
summary,
change,
} => Self::request_unit(
client, client,
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!("{base}/{}/intake-ready", Self::ticket_path(&id)), format!("{base}/{}/workflow/mark-ready", Self::ticket_path(&id)),
Some(serde_json::json!({ "summary": summary, "change": change })), Some(serde_json::to_value(request).map_err(|error| {
), TicketError::Conflict(format!("serialize Ticket mark-ready request: {error}"))
})?),
)
.map(TicketBackendOperationResult::Ticket),
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit( TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
client, client,
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
@@ -1119,22 +1088,15 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
} }
} }
fn mark_intake_ready( fn mark_ready(
&self, &self,
id: TicketIdOrSlug, id: TicketIdOrSlug,
summary: TicketIntakeSummary, request: ticket::TicketMarkReady,
change: TicketStateChange, ) -> TicketResult<Ticket> {
) -> TicketResult<()> { expect_ticket_result!(
match self.invoke(TicketBackendOperation::MarkIntakeReady { self.invoke(TicketBackendOperation::MarkReady { id, request }),
id, TicketBackendOperationResult::Ticket
summary, )
change,
})? {
TicketBackendOperationResult::Unit => Ok(()),
other => Err(TicketError::Conflict(format!(
"unexpected ticket backend response: {other:?}"
))),
}
} }
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> { fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
@@ -1303,7 +1265,7 @@ mod tests {
assert_eq!(show.name, "ShowTicket"); assert_eq!(show.name, "ShowTicket");
assert!(show.input_schema["properties"]["event_limit"].is_object()); assert!(show.input_schema["properties"]["event_limit"].is_object());
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names(); let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
assert_eq!(tool_names.len(), 9); assert_eq!(tool_names.len(), 10);
assert!( assert!(
tool_names.len() < 13, tool_names.len() < 13,
"authoring catalog must stay below the prior broad catalog" "authoring catalog must stay below the prior broad catalog"
@@ -1520,6 +1482,7 @@ language = "Japanese"
assert!(installed.iter().any(|tool| *tool == "TicketCreate")); assert!(installed.iter().any(|tool| *tool == "TicketCreate"));
assert!(installed.iter().any(|tool| *tool == "TicketEditItem")); assert!(installed.iter().any(|tool| *tool == "TicketEditItem"));
assert!(installed.iter().any(|tool| *tool == "TicketQueue")); assert!(installed.iter().any(|tool| *tool == "TicketQueue"));
assert!(installed.iter().any(|tool| *tool == "TicketMarkReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady")); assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState")); assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
assert!( assert!(
@@ -766,21 +766,6 @@ mod tests {
} }
} }
#[test]
fn prompt_source_names_the_worker_observation_contract() {
let catalog = crate::PromptCatalog::builtins_only().unwrap();
let source = &catalog.projection().templates["common.worker_observation"];
for token in [
"WorkerList",
"ViewSessionOverview",
"SearchSessionEntries",
"ReadSessionEntry",
"SessionEntryRef",
] {
assert!(source.contains(token), "missing {token}");
}
}
#[test] #[test]
fn worker_observation_requires_worker_control_service() { fn worker_observation_requires_worker_control_service() {
let provider = Arc::new(FakeProvider { let provider = Arc::new(FakeProvider {
+17 -95
View File
@@ -506,66 +506,9 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn builtin_dcdl_catalog_covers_worker_prompts() { fn builtin_dcdl_catalog_loads() {
let catalog = PromptCatalog::builtins_only().unwrap(); let catalog = PromptCatalog::builtins_only().unwrap();
for prompt in WorkerPrompt::ALL { assert!(!catalog.projection.templates.is_empty());
assert!(catalog.projection.templates.contains_key(prompt.key()));
}
assert!(catalog.projection.templates.contains_key("default"));
assert!(
catalog
.projection
.templates
.contains_key("common.workspace")
);
assert!(catalog.projection.templates.contains_key("role.coder"));
assert!(
catalog
.projection
.templates
.contains_key("panel.orchestrator_idle_queue_notice")
);
}
#[test]
fn commit_capable_roles_classify_commits_by_change_type() {
let catalog = PromptCatalog::builtins_only().unwrap();
assert!(catalog.projection.templates.contains_key("common.git"));
let context = Value::from_serialize(serde_json::json!({
"cwd": "/workspace",
"date": "2026-08-16",
"language": "match the user's language",
"tool_capabilities": {
"memory_any": false,
"memory_mutation": false,
"memory_query": false,
"memory_read_document": false
}
}));
for prompt in ["default", "role.coder", "role.orchestrator"] {
let rendered = catalog.render_name(prompt, context.clone()).unwrap();
assert!(rendered.contains("use the change type as the subject prefix"));
assert!(rendered.contains("A change made because review"));
assert!(rendered.contains("Do not keep reusing a domain prefix"));
assert!(rendered.contains("fix: scope merge request foreign key checks"));
}
}
#[test]
fn builtin_render_resolves_catalog_root_dotted_includes() {
let catalog = PromptCatalog::builtins_only().unwrap();
let source = &catalog.projection.templates["default"];
assert!(source.contains("{% include \"common.workspace\" %}"));
assert!(source.contains("{% include \"common.tool_usage\" %}"));
}
#[test]
fn schema_is_closed_and_materializes_builtin_defaults() {
let source = prompt_schema_source().unwrap();
assert!(source.starts_with("{ prompts = {"));
assert!(source.contains("compact_system = String default"));
assert!(source.contains("role = {"));
} }
#[test] #[test]
@@ -591,7 +534,10 @@ mod tests {
#[test] #[test]
fn workspace_projection_digest_is_stable_and_verified() { fn workspace_projection_digest_is_stable_and_verified() {
let templates = builtin_prompt_templates().unwrap(); let templates = BTreeMap::from([
("first".to_string(), "FIRST".to_string()),
("second".to_string(), "SECOND".to_string()),
]);
let projection = EffectivePromptCatalog::new(templates, 42, "schema", "toolchain").unwrap(); let projection = EffectivePromptCatalog::new(templates, 42, "schema", "toolchain").unwrap();
projection.verify_digest().unwrap(); projection.verify_digest().unwrap();
let mut tampered = projection.clone(); let mut tampered = projection.clone();
@@ -606,62 +552,38 @@ mod tests {
#[test] #[test]
fn catalog_source_preserves_workspace_projection_for_subworkers() { fn catalog_source_preserves_workspace_projection_for_subworkers() {
let mut templates = builtin_prompt_templates().unwrap(); let templates = BTreeMap::from([("template".to_string(), "OVERRIDE".to_string())]);
templates.insert("common.workspace".into(), "CHILD OVERRIDE".into());
let catalog = PromptCatalog::from_projection( let catalog = PromptCatalog::from_projection(
EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap(), EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap(),
) )
.unwrap(); .unwrap();
let child = PromptCatalog::load(&catalog.source()).unwrap(); let child = PromptCatalog::load(&catalog.source()).unwrap();
assert_eq!(child.projection.config_revision, 9); assert_eq!(child.projection.config_revision, 9);
assert_eq!( assert_eq!(child.projection.templates["template"], "OVERRIDE");
child.projection.templates["common.workspace"],
"CHILD OVERRIDE"
);
} }
#[test] #[test]
fn orchestrator_role_keeps_review_routing_owned_by_coder() { fn internal_prompt_helpers_load_and_render_arguments() {
let catalog = PromptCatalog::builtins_only().unwrap(); let catalog = PromptCatalog::builtins_only().unwrap();
let prompt = &catalog.projection.templates["role.orchestrator"]; catalog.compact_system().unwrap();
assert!(prompt.contains("assigned Coder owns its review/fix loop"));
assert!(prompt.contains("then use `SpawnTicketCoder`"));
assert!(prompt.contains("verify its current assignment names that Coder"));
assert!(prompt.contains("never route implementation to an unassigned Coder"));
assert!(prompt.contains(
"Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers"
));
assert!(prompt.contains("never compensate by creating an independent Reviewer Worker"));
assert!(!prompt.contains("sibling Coder/Reviewer Workers"));
}
#[test]
fn existing_internal_prompt_render_contracts_are_preserved() {
let catalog = PromptCatalog::builtins_only().unwrap();
assert!(catalog.compact_system().unwrap().contains("write_summary"));
assert!( assert!(
catalog catalog
.memory_extract_system("Japanese") .memory_extract_system("LANGUAGE_MARKER")
.unwrap() .unwrap()
.contains("`language`: `Japanese`") .contains("LANGUAGE_MARKER")
); );
assert!( assert!(
catalog catalog
.notify_wrapper("changed") .notify_wrapper("NOTIFICATION_MARKER")
.unwrap() .unwrap()
.contains("changed") .contains("NOTIFICATION_MARKER")
); );
assert!( assert!(
catalog catalog
.working_boundaries_section("Readable: /a") .working_boundaries_section("BOUNDARY_MARKER")
.unwrap() .unwrap()
.contains("Readable: /a") .contains("BOUNDARY_MARKER")
);
assert!(
catalog
.worker_orchestration_guidance_section()
.unwrap()
.contains("## SubWorker orchestration")
); );
catalog.worker_orchestration_guidance_section().unwrap();
} }
} }
-90
View File
@@ -15,8 +15,6 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[cfg(test)]
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use chrono::{DateTime, SecondsFormat, Utc}; use chrono::{DateTime, SecondsFormat, Utc};
@@ -26,8 +24,6 @@ use thiserror::Error;
use crate::feature::{FeatureInstructionDeclaration, dedupe_instruction_contributions}; use crate::feature::{FeatureInstructionDeclaration, dedupe_instruction_contributions};
use crate::prompt::catalog::{CatalogError, PromptCatalog}; use crate::prompt::catalog::{CatalogError, PromptCatalog};
#[cfg(test)]
use crate::prompt::catalog::{EffectivePromptCatalog, builtin_prompt_templates};
use crate::prompt::source::PromptCatalogSource; use crate::prompt::source::PromptCatalogSource;
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -319,76 +315,6 @@ fn append_trailing_section(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::TimeZone;
use manifest::{Permission, ScopeConfig, ScopeRule};
use tempfile::TempDir;
fn fixed_now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 4, 15, 9, 30, 0).unwrap()
}
fn build_scope(dir: &Path) -> Scope {
Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: dir.to_path_buf(),
permission: Permission::Write,
recursive: true,
}],
deny: Vec::new(),
})
.unwrap()
}
fn context<'a>(
cwd: &'a Path,
scope: &'a Scope,
prompts: &'a PromptCatalog,
) -> SystemPromptContext<'a> {
SystemPromptContext {
now: fixed_now(),
cwd: cwd.to_string_lossy(),
tool_names: vec!["Read".into(), "Write".into()],
scope,
agents_md: Some("PROJECT RULES".into()),
resident_summary: Some("DURABLE MEMORY"),
language: "Japanese",
feature_instructions: &[],
prompts,
}
}
#[test]
fn exact_catalog_name_renders_once_with_trailing_sections() {
let tmp = TempDir::new().unwrap();
let scope = build_scope(tmp.path());
let prompts = PromptCatalog::builtins_only().unwrap();
let template =
SystemPromptTemplate::parse("default", PromptCatalogSource::builtins_only()).unwrap();
let rendered = template
.render(&context(tmp.path(), &scope, &prompts))
.unwrap();
assert!(rendered.contains("2026-08-14") || rendered.contains("2026-04-15"));
assert!(rendered.contains("## Working boundaries"));
assert!(rendered.contains("PROJECT RULES"));
assert!(rendered.contains("DURABLE MEMORY"));
}
#[test]
fn workspace_override_is_visible_through_builtin_static_include() {
let mut templates = builtin_prompt_templates().unwrap();
templates.insert("common.workspace".into(), "WORKSPACE OVERRIDE".into());
let projection = EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap();
let loader =
PromptCatalogSource::builtins_only().with_effective_catalog(projection.clone());
let prompts = PromptCatalog::from_projection(projection).unwrap();
let template = SystemPromptTemplate::parse("default", loader).unwrap();
let tmp = TempDir::new().unwrap();
let scope = build_scope(tmp.path());
let rendered = template
.render(&context(tmp.path(), &scope, &prompts))
.unwrap();
assert!(rendered.contains("WORKSPACE OVERRIDE"));
}
#[test] #[test]
fn rejects_legacy_prefix_relative_and_missing_names() { fn rejects_legacy_prefix_relative_and_missing_names() {
@@ -399,20 +325,4 @@ mod tests {
); );
} }
} }
#[test]
fn role_templates_are_selected_without_filesystem_resolution() {
let loader = PromptCatalogSource::builtins_only();
for role in [
"role.coder",
"role.intake",
"role.orchestrator",
"role.reviewer",
] {
assert!(
SystemPromptTemplate::parse(role, loader.clone()).is_ok(),
"{role}"
);
}
}
} }
+23 -6
View File
@@ -5890,6 +5890,13 @@ model_id = "claude-sonnet-4-20250514"
[engine] [engine]
instruction = "saved" instruction = "saved"
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[[scope.allow]] [[scope.allow]]
target = "/snapshot/workspace" target = "/snapshot/workspace"
permission = "read" permission = "read"
@@ -5912,6 +5919,13 @@ model_id = "claude-sonnet-4-20250514"
[engine] [engine]
instruction = "current" instruction = "current"
[feature.merge_request]
show = true
open = true
review = true
readiness_check = true
complete = true
[[scope.allow]] [[scope.allow]]
target = "/current/workspace" target = "/current/workspace"
permission = "write" permission = "write"
@@ -5931,6 +5945,14 @@ permission = "write"
.unwrap(); .unwrap();
assert_eq!(restored.engine.instruction, "saved"); assert_eq!(restored.engine.instruction, "saved");
assert_eq!(
restored.feature.merge_request,
manifest::MergeRequestFeatureConfig {
show: true,
review: true,
..Default::default()
}
);
assert_eq!(restored.scope.allow.len(), 1); assert_eq!(restored.scope.allow.len(), 1);
assert_eq!( assert_eq!(
restored.scope.allow[0].target, restored.scope.allow[0].target,
@@ -6955,7 +6977,6 @@ mod build_summary_prompt_tests {
) )
.await; .await;
assert!(rendered.contains("## Resident memory summary"));
assert!(rendered.contains("summary body for resident prompt")); assert!(rendered.contains("summary body for resident prompt"));
assert!(!rendered.contains("updated_at: 2026-01-01T00:00:00Z")); assert!(!rendered.contains("updated_at: 2026-01-01T00:00:00Z"));
assert!(!rendered.contains("---\nupdated_at")); assert!(!rendered.contains("---\nupdated_at"));
@@ -6974,7 +6995,6 @@ mod build_summary_prompt_tests {
) )
.await; .await;
assert!(!rendered.contains("Resident memory summary"));
assert!(!rendered.contains("disabled summary body")); assert!(!rendered.contains("disabled summary body"));
} }
@@ -6987,7 +7007,6 @@ mod build_summary_prompt_tests {
) )
.await; .await;
assert!(!rendered.contains("Resident memory summary"));
assert!(!rendered.contains("memory-disabled summary body")); assert!(!rendered.contains("memory-disabled summary body"));
} }
@@ -7000,8 +7019,7 @@ mod build_summary_prompt_tests {
) )
.await; .await;
assert!(rendered.contains("## Working boundaries")); assert!(!rendered.trim().is_empty());
assert!(!rendered.contains("Resident memory summary"));
assert!(!rendered.contains("bad summary body")); assert!(!rendered.contains("bad summary body"));
} }
@@ -7015,7 +7033,6 @@ mod build_summary_prompt_tests {
) )
.await; .await;
assert!(!prompt.contains("Resident memory summary"));
assert!(!prompt.contains("resident summary marker")); assert!(!prompt.contains("resident summary marker"));
} }
@@ -176,10 +176,7 @@ async fn template_is_not_materialised_before_first_run() {
#[tokio::test] #[tokio::test]
async fn materialise_on_first_turn_populates_worker() { async fn materialise_on_first_turn_populates_worker() {
let client = MockClient::new(vec![single_text_events("ok")]); let client = MockClient::new(vec![single_text_events("ok")]);
let (mut worker, pwd) = make_worker_with_body( let (mut worker, pwd) = make_worker_with_body("date={{ date }}", client)
"date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}",
client,
)
.await .await
.unwrap(); .unwrap();
worker.run_text("hi").await.unwrap(); worker.run_text("hi").await.unwrap();
@@ -189,19 +186,14 @@ async fn materialise_on_first_turn_populates_worker() {
.expect("system prompt materialised") .expect("system prompt materialised")
.to_string(); .to_string();
assert!(rendered.contains("date=")); assert!(rendered.contains("date="));
assert!(rendered.contains("cwd="));
assert!(rendered.contains(&pwd.display().to_string())); assert!(rendered.contains(&pwd.display().to_string()));
assert!(rendered.starts_with("date=")); assert!(rendered.starts_with("date="));
// Trailing fixed section must be appended.
assert!(rendered.contains("## Working boundaries"));
} }
#[tokio::test] #[tokio::test]
async fn session_start_state_captures_rendered_prompt() { async fn session_start_state_captures_rendered_prompt() {
let client = MockClient::new(vec![single_text_events("ok")]); let client = MockClient::new(vec![single_text_events("ok")]);
let (mut worker, pwd) = make_worker_with_body("hello cwd={{ cwd }}", client) let (mut worker, pwd) = make_worker_with_body("hello", client).await.unwrap();
.await
.unwrap();
worker.run_text("hi").await.unwrap(); worker.run_text("hi").await.unwrap();
let entries = worker let entries = worker
@@ -212,9 +204,8 @@ async fn session_start_state_captures_rendered_prompt() {
match first { match first {
LogEntry::SegmentStart { system_prompt, .. } => { LogEntry::SegmentStart { system_prompt, .. } => {
let sp = system_prompt.as_deref().expect("system prompt set"); let sp = system_prompt.as_deref().expect("system prompt set");
assert!(sp.starts_with("hello cwd=")); assert!(sp.starts_with("hello"));
assert!(sp.contains(&pwd.display().to_string())); assert!(sp.contains(&pwd.display().to_string()));
assert!(sp.contains("## Working boundaries"));
} }
other => panic!("expected SegmentStart as first entry, got {other:?}"), other => panic!("expected SegmentStart as first entry, got {other:?}"),
} }
@@ -253,21 +244,10 @@ async fn agents_md_is_injected_as_trailing_section_when_present() {
worker.run_text("hi").await.unwrap(); worker.run_text("hi").await.unwrap();
let rendered = worker.engine().get_system_prompt().unwrap().to_string(); let rendered = worker.engine().get_system_prompt().unwrap().to_string();
assert!(rendered.starts_with("BODY")); assert!(rendered.starts_with("BODY"));
assert!(rendered.contains("## Project instructions (AGENTS.md)"));
assert!(rendered.contains("# project rules")); assert!(rendered.contains("# project rules"));
assert!(rendered.contains("be kind")); assert!(rendered.contains("be kind"));
} }
#[tokio::test]
async fn agents_md_absent_omits_trailing_section() {
let client = MockClient::new(vec![single_text_events("ok")]);
let (mut worker, _pwd) = make_worker_with_body("BODY", client).await.unwrap();
worker.run_text("hi").await.unwrap();
let rendered = worker.engine().get_system_prompt().unwrap().to_string();
assert!(!rendered.contains("## Project instructions"));
assert!(!rendered.contains("AGENTS.md"));
}
#[tokio::test] #[tokio::test]
async fn agents_md_not_reread_after_compact() { async fn agents_md_not_reread_after_compact() {
let client = MockClient::new(vec![ let client = MockClient::new(vec![
File diff suppressed because it is too large Load Diff
+6 -79
View File
@@ -4217,13 +4217,12 @@ mod tests {
} }
#[test] #[test]
fn embedded_orchestrator_profile_enables_workdir_and_worker_authority() { fn embedded_builtin_decodal_profiles_resolve_through_archive() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let broker = BackendResourceBroker::default(); let broker = BackendResourceBroker::default();
let runtime_id = "runtime-test"; let runtime_id = "runtime-test";
let selector = ProfileSelector::Builtin("builtin:orchestrator".to_string());
let bundle = builtin_profile_config_bundle( let bundle = builtin_profile_config_bundle(
&selector, &ProfileSelector::Builtin("builtin:companion".to_string()),
"workspace-test", "workspace-test",
Some(runtime_id), Some(runtime_id),
&broker, &broker,
@@ -4231,6 +4230,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let handle = bundle.profile_source_archive_handle.as_ref().unwrap(); let handle = bundle.profile_source_archive_handle.as_ref().unwrap();
assert!(bundle.profile_source_archive.is_none());
let response = broker let response = broker
.fetch_profile_source_archive(worker_runtime::resource::BackendResourceFetchRequest { .fetch_profile_source_archive(worker_runtime::resource::BackendResourceFetchRequest {
handle: handle.clone(), handle: handle.clone(),
@@ -4244,85 +4244,12 @@ mod tests {
.unwrap() .unwrap()
.verify() .verify()
.unwrap(); .unwrap();
assert!(!archive.reference().source_graph.entrypoints.is_empty());
for selector_key in archive.reference().source_graph.entrypoints.keys() {
let manifest = archive let manifest = archive
.resolve_profile("builtin:orchestrator", root.path(), "embedded-orchestrator") .resolve_profile(selector_key, root.path(), "embedded-test-worker")
.unwrap();
assert!(manifest.feature.manage_workdir.enabled);
assert!(!manifest.feature.sub_worker.enabled);
assert!(manifest.feature.worker.enabled);
}
#[test]
fn embedded_companion_profile_enables_worker_management() {
let root = tempfile::tempdir().unwrap();
let selector = ProfileSelector::Builtin("builtin:companion".to_string());
let archive = builtin_profile_source_archive(&selector)
.unwrap()
.verify()
.unwrap();
let manifest = archive
.resolve_profile("builtin:companion", root.path(), "companion-test-worker")
.unwrap();
assert!(manifest.feature.worker.enabled);
assert!(manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.manage_workdir.enabled);
}
#[test]
fn embedded_builtin_decodal_profiles_resolve_through_archive() {
let root = tempfile::tempdir().unwrap();
let broker = BackendResourceBroker::default();
let runtime_id = "runtime-test";
for selector in [
ProfileSelector::Builtin("builtin:companion".to_string()),
ProfileSelector::Builtin("builtin:intake".to_string()),
ProfileSelector::Builtin("builtin:orchestrator".to_string()),
ProfileSelector::Builtin("builtin:coder".to_string()),
ProfileSelector::Builtin("builtin:reviewer".to_string()),
ProfileSelector::Builtin("builtin:memory-consolidation".to_string()),
] {
let bundle = builtin_profile_config_bundle(
&selector,
"workspace-test",
Some(runtime_id),
&broker,
ProfileSourceArchiveTransport::BackendResourceHandle,
)
.unwrap();
let handle = bundle.profile_source_archive_handle.as_ref().unwrap();
assert!(bundle.profile_source_archive.is_none());
let response = broker
.fetch_profile_source_archive(
worker_runtime::resource::BackendResourceFetchRequest {
handle: handle.clone(),
runtime_id: runtime_id.to_string(),
worker_id: None,
audit_correlation_id: handle.audit_correlation_id.clone(),
},
)
.unwrap();
let archive =
worker_runtime::resource::profile_source_archive_from_response(handle, response)
.unwrap()
.verify()
.unwrap();
let selector_key = match &selector {
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => name.clone(),
};
let manifest = archive
.resolve_profile(&selector_key, root.path(), "embedded-test-worker")
.unwrap(); .unwrap();
assert_eq!(manifest.worker.name, "embedded-test-worker"); assert_eq!(manifest.worker.name, "embedded-test-worker");
assert_eq!(
manifest.model.ref_.as_deref(),
Some("codex-oauth/gpt-5.6-sol")
);
if selector_key == "builtin:memory-consolidation" {
assert!(manifest.feature.memory.enabled);
assert!(manifest.feature.memory.staging);
}
} }
} }
+2 -1
View File
@@ -31,7 +31,8 @@ mod workspace_subscription;
pub use authority::{ pub use authority::{
MemoryAuthority, MemoryDocument, MemoryStagingEntry, MemoryStagingResolution, MemoryAuthority, MemoryDocument, MemoryStagingEntry, MemoryStagingResolution,
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, WorkspaceAuthority, ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
WorkspaceAuthority,
}; };
pub use config::{ pub use config::{
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig, BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
@@ -740,32 +740,6 @@ mod tests {
} }
} }
#[test]
fn virtual_config_projection_is_builtin_only_by_default() {
let state = virtual_state(vec![
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
"{}",
)
.unwrap(),
]);
let projection = project_profiles_from_workspace_config("workspace-test", &state).unwrap();
assert_eq!(
projection.settings.default_profile.as_deref(),
Some("builtin:companion")
);
assert_eq!(projection.settings.config_revision, Some(7));
assert!(projection.settings.projection_digest.is_some());
assert!(
projection
.settings
.profiles
.iter()
.all(|item| !item.editable)
);
}
#[test] #[test]
fn virtual_config_projection_builds_archive_from_active_revision() { fn virtual_config_projection_builds_archive_from_active_revision() {
let state = virtual_state(vec![ let state = virtual_state(vec![
@@ -795,8 +769,7 @@ mod tests {
assert_eq!(projection.settings.config_revision, Some(7)); assert_eq!(projection.settings.config_revision, Some(7));
let prompt_catalog = bundle.prompt_catalog.as_ref().unwrap(); let prompt_catalog = bundle.prompt_catalog.as_ref().unwrap();
assert_eq!(prompt_catalog.config_revision, 7); assert_eq!(prompt_catalog.config_revision, 7);
assert!(prompt_catalog.templates.contains_key("default")); assert!(!prompt_catalog.templates.is_empty());
assert!(prompt_catalog.templates.contains_key("common.workspace"));
assert!( assert!(
bundle bundle
.metadata .metadata
@@ -115,12 +115,16 @@ mod tests {
#[test] #[test]
fn workspace_override_deep_patches_builtin_and_preserves_other_leaves() { fn workspace_override_deep_patches_builtin_and_preserves_other_leaves() {
let baseline = project_prompts_from_workspace_config(&state("{}")).unwrap();
let state = state(r#"{ prompts = { common = { language = "OVERRIDE"; }; }; }"#); let state = state(r#"{ prompts = { common = { language = "OVERRIDE"; }; }; }"#);
let catalog = project_prompts_from_workspace_config(&state).unwrap(); let catalog = project_prompts_from_workspace_config(&state).unwrap();
assert_eq!(catalog.config_revision, 7); assert_eq!(catalog.config_revision, 7);
assert_eq!(catalog.templates["common.language"], "OVERRIDE"); assert_eq!(catalog.templates["common.language"], "OVERRIDE");
assert!(!catalog.templates["common.workspace"].is_empty()); for (key, value) in baseline.templates {
assert!(catalog.templates["default"].contains("common.workspace")); if key != "common.language" {
assert_eq!(catalog.templates.get(&key), Some(&value));
}
}
} }
#[test] #[test]
+26 -3
View File
@@ -12,6 +12,14 @@ pub struct ProjectRecordList<T> {
pub record_authority: String, pub record_authority: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketSummaryPage {
pub items: Vec<TicketSummary>,
pub page: QueryPage,
pub invalid_records: Vec<InvalidProjectRecord>,
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct InvalidProjectRecord { pub struct InvalidProjectRecord {
@@ -33,12 +41,23 @@ pub struct TicketSummary {
pub record_source: String, pub record_source: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TicketListPageRequest {
#[serde(default)]
pub states: Vec<String>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketListResponse { pub struct TicketListResponse {
pub workspace_id: String, pub workspace_id: String,
pub limit: usize, pub limit: usize,
pub items: Vec<TicketSummary>, pub items: Vec<TicketSummary>,
pub page: QueryPage,
pub invalid_records: Vec<InvalidProjectRecord>, pub invalid_records: Vec<InvalidProjectRecord>,
pub record_authority: String, pub record_authority: String,
} }
@@ -239,12 +258,15 @@ pub struct TicketAssignmentSummary {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketMergeRequestSummary { pub struct TicketMergeRequestSummary {
pub merge_request_id: String, pub merge_request_id: String,
pub repository_id: String,
pub state: String, pub state: String,
pub review_status: String, pub review_status: String,
pub selector_from: Option<String>, pub selector_from: Option<String>,
pub selector_to: String, pub selector_to: String,
pub updated_at: String, pub updated_at: String,
pub current_subject_ref: Option<String>,
pub review_subject_ref: Option<String>, pub review_subject_ref: Option<String>,
pub review_requested_at: Option<String>,
pub review_submitted_at: Option<String>, pub review_submitted_at: Option<String>,
pub review_excerpt: Option<String>, pub review_excerpt: Option<String>,
} }
@@ -252,12 +274,13 @@ pub struct TicketMergeRequestSummary {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketEvidenceSummary { pub struct TicketEvidenceSummary {
pub has_implementation_report: bool,
pub implementation_report_after_rescope: bool,
pub has_merge_request: bool, pub has_merge_request: bool,
pub has_current_subject_ref: bool,
pub has_review_request: bool,
pub has_commit: bool, pub has_commit: bool,
pub review_status: Option<String>, pub review_status: Option<String>,
pub approved: bool, pub approved_current_subject: bool,
pub review_after_rescope: bool,
pub unresolved_request_changes: bool, pub unresolved_request_changes: bool,
pub complete_for_integration: bool, pub complete_for_integration: bool,
pub missing: Vec<String>, pub missing: Vec<String>,
+8 -79
View File
@@ -97,38 +97,13 @@ pub struct CommitObservation {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepositoryLookupError { pub enum RepositoryLookupError {
UnknownRepository { UnknownRepository { id: RepositoryId },
id: RepositoryId, UnsupportedProvider { id: RepositoryId, provider: String },
}, MissingDefaultSelector { id: RepositoryId },
UnsupportedProvider { InvalidSelector { id: RepositoryId, selector: String },
id: RepositoryId, CommitNotFound { id: RepositoryId, commit: String },
provider: String, InvalidCommitRelation { id: RepositoryId, detail: String },
}, ProviderFailure { id: RepositoryId, operation: String },
MissingDefaultSelector {
id: RepositoryId,
},
InvalidSelector {
id: RepositoryId,
selector: String,
},
CommitNotFound {
id: RepositoryId,
commit: String,
},
InvalidCommitRelation {
id: RepositoryId,
detail: String,
},
TargetMoved {
id: RepositoryId,
selector: String,
expected: String,
observed: Option<String>,
},
ProviderFailure {
id: RepositoryId,
operation: String,
},
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -306,45 +281,6 @@ impl RepositoryRegistryReader {
} }
} }
pub fn update_merge_target(
&self,
id: &str,
selector: &str,
expected_target: &str,
result_commit: &str,
) -> Result<(), RepositoryLookupError> {
let repository = self.merge_repository(id)?;
let target_ref = normalize_target_branch_selector(id, selector)?;
self.observe_commit(id, result_commit)?;
let status = Command::new("git")
.arg("-C")
.arg(&repository.path)
.args([
"update-ref",
target_ref.as_str(),
result_commit,
expected_target,
])
.status()
.map_err(|_| RepositoryLookupError::ProviderFailure {
id: id.into(),
operation: "guarded target update".into(),
})?;
if status.success() {
return Ok(());
}
let observed = self
.observe_merge_target(id, Some(selector))
.ok()
.map(|target| target.commit);
Err(RepositoryLookupError::TargetMoved {
id: id.into(),
selector: selector.into(),
expected: expected_target.into(),
observed,
})
}
fn merge_repository(&self, id: &str) -> Result<&ConfiguredRepository, RepositoryLookupError> { fn merge_repository(&self, id: &str) -> Result<&ConfiguredRepository, RepositoryLookupError> {
let repository = self let repository = self
.find(id) .find(id)
@@ -755,20 +691,13 @@ mod tests {
vec![base.clone()] vec![base.clone()]
); );
reader.ensure_ancestor("main", &base, &source).unwrap(); reader.ensure_ancestor("main", &base, &source).unwrap();
reader
.update_merge_target("main", "main", &base, &source)
.unwrap();
assert_eq!( assert_eq!(
reader reader
.observe_merge_target("main", Some("refs/heads/main")) .observe_merge_target("main", Some("refs/heads/main"))
.unwrap() .unwrap()
.commit, .commit,
source base
); );
assert!(matches!(
reader.update_merge_target("main", "refs/heads/main", &base, &base),
Err(RepositoryLookupError::TargetMoved { .. })
));
assert!(matches!( assert!(matches!(
reader.ensure_ancestor("main", &source, &base), reader.ensure_ancestor("main", &source, &base),
Err(RepositoryLookupError::InvalidCommitRelation { .. }) Err(RepositoryLookupError::InvalidCommitRelation { .. })
+441 -121
View File
@@ -62,7 +62,7 @@ use crate::auth::{
}; };
use crate::authority::{ use crate::authority::{
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput, MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
SqliteWorkspaceAuthority, TicketAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
}; };
use crate::companion::{ use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
@@ -809,7 +809,11 @@ impl WorkspaceApi {
authority: SqliteWorkspaceAuthority::new( authority: SqliteWorkspaceAuthority::new(
config.database_path.clone(), config.database_path.clone(),
config.workspace_id.clone(), config.workspace_id.clone(),
)?, )?
.with_merge_revision_source(Arc::new(MergeRequestRepositorySource {
workspace_id: config.workspace_id.clone(),
reader: RepositoryRegistryReader::new(config.repositories.clone()),
})),
config, config,
store, store,
runtime, runtime,
@@ -1060,11 +1064,18 @@ impl WorkspaceApi {
&self, &self,
request: &WorkerSpawnRequest, request: &WorkerSpawnRequest,
) -> ApiResult<()> { ) -> ApiResult<()> {
let selected_repository_id = let (selected_repository_id, selected_ref_selector) =
if let Some(working_directory) = request.resolved_working_directory_request.as_ref() { if let Some(working_directory) = request.resolved_working_directory_request.as_ref() {
let repository_id = working_directory.repository.id.as_str(); let repository_id = working_directory.repository.id.as_str();
self.require_workspace_repository(repository_id)?; self.require_workspace_repository(repository_id)?;
Some(repository_id.to_string()) (
Some(repository_id.to_string()),
working_directory
.repository
.selector
.as_deref()
.map(str::to_owned),
)
} else if let Some(claim) = request.resolved_working_directory.as_ref() { } else if let Some(claim) = request.resolved_working_directory.as_ref() {
let workdir = self let workdir = self
.store .store
@@ -1076,20 +1087,48 @@ impl WorkspaceApi {
))) )))
})?; })?;
self.require_workspace_repository(&workdir.repository_id)?; self.require_workspace_repository(&workdir.repository_id)?;
Some(workdir.repository_id) (Some(workdir.repository_id), workdir.creation_selector)
} else { } else {
None (None, None)
}; };
if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent { if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent {
let ticket = self.authority.ticket(ticket_id)?; let ticket = self.authority.ticket(ticket_id)?;
if let Some(repository_id) = ticket.repository_id.as_deref() { // Workdir-less Ticket Workers cannot execute repository implementation.
// Preserve that control-plane launch while still validating any persisted
// target (including its Workspace ownership) when one exists.
if selected_repository_id.is_none() && ticket.repository_id.is_none() {
return Ok(());
}
let repository_id = ticket.repository_id.as_deref().ok_or_else(|| {
ApiError::from(Error::Config(
"Ticket implementation target must be validated and persisted before spawning a Ticket Worker".to_owned(),
))
})?;
let ref_selector = ticket.ref_selector.as_deref().ok_or_else(|| {
ApiError::from(Error::Config(
"Ticket implementation target selector must be validated and persisted before spawning a Ticket Worker".to_owned(),
))
})?;
self.require_workspace_repository(repository_id)?; self.require_workspace_repository(repository_id)?;
self.repository_reader()
.observe_merge_target(repository_id, Some(ref_selector))
.map_err(|error| {
ApiError::from(Error::Config(format!(
"Ticket implementation target is no longer resolvable: {error:?}"
)))
})?;
if selected_repository_id.as_deref() != Some(repository_id) { if selected_repository_id.as_deref() != Some(repository_id) {
return Err(ApiError::from(Error::Config(format!( return Err(ApiError::from(Error::Config(format!(
"Ticket `{ticket_id}` targets repository `{repository_id}`, but the Worker launch does not resolve that repository in this Workspace" "Ticket `{ticket_id}` targets repository `{repository_id}`, but the Worker launch resolves `{}`",
selected_repository_id.as_deref().unwrap_or("none")
)))); ))));
} }
if selected_ref_selector.as_deref() != Some(ref_selector) {
return Err(ApiError::from(Error::Config(format!(
"Ticket `{ticket_id}` targets selector `{ref_selector}`, but the Worker launch resolves `{}`",
selected_ref_selector.as_deref().unwrap_or("none")
))));
} }
} }
Ok(()) Ok(())
@@ -1315,8 +1354,8 @@ pub fn build_router(api: WorkspaceApi) -> Router {
post(scoped_set_ticket_workflow_state), post(scoped_set_ticket_workflow_state),
) )
.route( .route(
"/api/w/{workspace_id}/tickets/{id}/intake-ready", "/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready",
post(scoped_prepare_ticket_intake_ready), post(scoped_mark_ticket_ready),
) )
.route( .route(
"/api/w/{workspace_id}/tickets/{id}/workflow/queue", "/api/w/{workspace_id}/tickets/{id}/workflow/queue",
@@ -1397,6 +1436,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/tickets/{id}/state", "/api/w/{workspace_id}/tickets/{id}/state",
post(scoped_transition_ticket_state), post(scoped_transition_ticket_state),
) )
.route(
"/api/w/{workspace_id}/tickets/{id}/ready",
post(scoped_mark_ticket_ready_from_browser),
)
.route( .route(
"/api/w/{workspace_id}/tickets/{id}/events", "/api/w/{workspace_id}/tickets/{id}/events",
post(scoped_append_ticket_event), post(scoped_append_ticket_event),
@@ -2229,6 +2272,9 @@ struct ObjectiveEditRequest {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct TicketListQuery { struct TicketListQuery {
limit: Option<usize>, limit: Option<usize>,
cursor: Option<String>,
/// Comma-separated workflow states. Repeated lane requests normally pass one state group.
states: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -3107,6 +3153,55 @@ struct BrowserCloseTicketRequest {
resolution: String, resolution: String,
} }
#[derive(Clone)]
struct WorkspaceTicketTargetAuthority {
api: WorkspaceApi,
}
impl ticket::TicketTargetAuthority for WorkspaceTicketTargetAuthority {
fn resolve_target(
&self,
workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> ticket::Result<ticket::ResolvedTicketTarget> {
if workspace_id != self.api.config.workspace_id {
return Err(ticket::TicketError::UnknownTargetRepository(
repository_id.unwrap_or_default().to_owned(),
));
}
let repository_id = repository_id
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(ticket::TicketError::MissingTargetRepository)?;
let repository = self
.api
.store
.get_repository(workspace_id, repository_id)
.map_err(|error| ticket::TicketError::Conflict(error.to_string()))?
.ok_or_else(|| {
ticket::TicketError::UnknownTargetRepository(repository_id.to_owned())
})?;
let selector = ref_selector
.map(str::trim)
.filter(|value| !value.is_empty())
.or(repository.default_ref.as_deref())
.ok_or_else(|| ticket::TicketError::MissingTargetSelector(repository_id.to_owned()))?;
self.api
.repository_reader()
.observe_merge_target(repository_id, Some(selector))
.map_err(|error| ticket::TicketError::InvalidTargetSelector {
repository_id: repository_id.to_owned(),
selector: selector.to_owned(),
reason: format!("{error:?}"),
})?;
Ok(ticket::ResolvedTicketTarget {
repository_id: repository_id.to_owned(),
ref_selector: selector.to_owned(),
})
}
}
fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> { fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root) let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?; .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
@@ -3114,7 +3209,10 @@ fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
api.config.database_path.clone(), api.config.database_path.clone(),
api.config.workspace_id.clone(), api.config.workspace_id.clone(),
)? )?
.with_record_language(config.ticket_record_language())) .with_record_language(config.ticket_record_language())
.with_target_authority(Arc::new(WorkspaceTicketTargetAuthority {
api: api.clone(),
})))
} }
fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult<Json<TicketDetail>> { fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult<Json<TicketDetail>> {
@@ -3208,6 +3306,26 @@ async fn scoped_append_ticket_event(
browser_ticket_detail(&api, &path.id) browser_ticket_detail(&api, &path.id)
} }
async fn scoped_mark_ticket_ready_from_browser(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<TicketMarkReadyRequest>,
) -> ApiResult<Json<TicketDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
browser_ticket_backend(&api)?
.mark_ready(
TicketIdOrSlug::Id(path.id.clone()),
ticket::TicketMarkReady {
operation_key: request.operation_key,
reason: request.reason,
author: Some("web".to_owned()),
intake_summary: None,
},
)
.map_err(Error::from)?;
browser_ticket_detail(&api, &path.id)
}
async fn scoped_queue_ticket( async fn scoped_queue_ticket(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>, AxumPath(path): AxumPath<ScopedRecordPath>,
@@ -3269,7 +3387,10 @@ async fn execute_worker_ticket_rest_operation(
api.config.workspace_id.clone(), api.config.workspace_id.clone(),
) )
.map_err(Error::from)? .map_err(Error::from)?
.with_record_language(config.ticket_record_language()); .with_record_language(config.ticket_record_language())
.with_target_authority(Arc::new(WorkspaceTicketTargetAuthority {
api: api.clone(),
}));
let operation_kind = ticket_mutation_operation_kind(&operation); let operation_kind = ticket_mutation_operation_kind(&operation);
let is_mutation = operation_kind != "read"; let is_mutation = operation_kind != "read";
let target = ticket_mutation_target(&operation).cloned(); let target = ticket_mutation_target(&operation).cloned();
@@ -3421,6 +3542,15 @@ async fn scoped_create_ticket_record(
headers: HeaderMap, headers: HeaderMap,
Json(input): Json<ticket::NewTicket>, Json(input): Json<ticket::NewTicket>,
) -> ApiResult<Json<ticket::TicketRef>> { ) -> ApiResult<Json<ticket::TicketRef>> {
if input
.workflow_state
.is_some_and(|state| state != TicketWorkflowState::Planning)
{
return Err(settings_bad_request(
"ticket_create_state_bypass",
"Ticket creation must start in planning; use guarded workflow operations for later states",
));
}
let result = execute_worker_ticket_rest_operation( let result = execute_worker_ticket_rest_operation(
&api, &api,
&path.workspace_id, &path.workspace_id,
@@ -3534,9 +3664,12 @@ async fn scoped_add_ticket_intake_summary(
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct TicketIntakeReadyRequest { struct TicketMarkReadyRequest {
summary: ticket::TicketIntakeSummary, operation_key: String,
change: TicketStateChange, #[serde(default)]
reason: Option<String>,
#[serde(default)]
intake_summary: Option<ticket::TicketIntakeSummary>,
} }
async fn scoped_set_ticket_state_field( async fn scoped_set_ticket_state_field(
@@ -3578,24 +3711,31 @@ async fn scoped_set_ticket_workflow_state(
ticket_rest_unit(result) ticket_rest_unit(result)
} }
async fn scoped_prepare_ticket_intake_ready( async fn scoped_mark_ticket_ready(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath((workspace_id, id)): AxumPath<(String, String)>, AxumPath((workspace_id, id)): AxumPath<(String, String)>,
headers: HeaderMap, headers: HeaderMap,
Json(request): Json<TicketIntakeReadyRequest>, Json(request): Json<TicketMarkReadyRequest>,
) -> ApiResult<StatusCode> { ) -> ApiResult<Json<ticket::Ticket>> {
let result = execute_worker_ticket_rest_operation( let result = execute_worker_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
TicketBackendOperation::MarkIntakeReady { TicketBackendOperation::MarkReady {
id: TicketIdOrSlug::Query(id), id: TicketIdOrSlug::Query(id),
summary: request.summary, request: ticket::TicketMarkReady {
change: request.change, operation_key: request.operation_key,
reason: request.reason,
author: None,
intake_summary: request.intake_summary,
},
}, },
) )
.await?; .await?;
ticket_rest_unit(result) ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::Ticket(ticket) => Some(ticket),
_ => None,
})
} }
async fn scoped_queue_ticket_record( async fn scoped_queue_ticket_record(
@@ -3721,6 +3861,15 @@ struct MergeRequestRepositorySource {
reader: RepositoryRegistryReader, reader: RepositoryRegistryReader,
} }
impl TicketMergeRevisionSource for MergeRequestRepositorySource {
fn resolve_subject_ref(&self, repository_id: &str, selector: &str) -> Option<String> {
self.reader
.observe_merge_target(repository_id, Some(selector))
.ok()
.map(|target| target.commit)
}
}
impl merge_request::RepositorySource for MergeRequestRepositorySource { impl merge_request::RepositorySource for MergeRequestRepositorySource {
fn repository_belongs_to_workspace( fn repository_belongs_to_workspace(
&self, &self,
@@ -3760,6 +3909,37 @@ fn repository_merge_evidence_error(error: RepositoryLookupError) -> ApiError {
.into() .into()
} }
fn recorded_merge_completion<'a>(
thread: &'a [merge_request::MergeRequestThreadEvent],
operation_id: &str,
) -> Option<&'a merge_request::MergeEvent> {
thread.iter().find_map(|event| match event {
merge_request::MergeRequestThreadEvent::Merge(event)
if event.operation_id == operation_id =>
{
Some(event)
}
_ => None,
})
}
fn require_completed_target_observation(
observed: &str,
target_ref_before: &str,
target_ref_after: &str,
) -> ApiResult<()> {
if observed == target_ref_after {
return Ok(());
}
if observed == target_ref_before {
return Err(Error::InvalidInput(
"target selector is still at target_ref_before; push the verified result from the Orchestrator Workdir before MergeRequestComplete".into(),
)
.into());
}
Err(Error::InvalidInput("target selector moved outside completion evidence".into()).into())
}
async fn scoped_show_merge_request( async fn scoped_show_merge_request(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
@@ -4093,19 +4273,40 @@ async fn scoped_complete_merge_request(
require_workspace_access(&workspace_id, &api)?; require_workspace_access(&workspace_id, &api)?;
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
require_online_workspace_orchestrator_source(&api, &source)?; require_online_workspace_orchestrator_source(&api, &source)?;
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?;
let repositories = api.repository_reader();
if let Some(existing) = recorded_merge_completion(&mr.thread, &input.operation_id) {
let replay = merge_request::CompleteMergeRequest {
ticket_id,
operation_id: input.operation_id,
approval_event_id: input.approval_event_id,
current_subject_ref: existing.approved_source_ref.clone(),
target_ref_before: input.target_ref_before,
target_ref_after: input.target_ref_after,
strategy: input.strategy,
resolution: input.resolution,
auth: merge_request::MergeRequestAuth {
workspace_id,
repository_id: mr.repository_id.clone(),
runtime_id: source.runtime_id,
worker_id: source.worker_id,
assignment_id: String::new(),
},
now: Utc::now(),
};
return store.complete(replay).map(Json).map_err(Into::into);
}
let assignment = api let assignment = api
.store .store
.get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)?
.ok_or_else(|| { .ok_or_else(|| {
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
})?; })?;
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?;
let selector = mr let selector = mr
.selector_from .selector_from
.as_deref() .as_deref()
.ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?;
let repositories = api.repository_reader();
let current_source_ref = repositories let current_source_ref = repositories
.observe_merge_target(&mr.repository_id, Some(selector)) .observe_merge_target(&mr.repository_id, Some(selector))
.map_err(repository_merge_evidence_error)? .map_err(repository_merge_evidence_error)?
@@ -4113,12 +4314,11 @@ async fn scoped_complete_merge_request(
let observed = repositories let observed = repositories
.observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) .observe_merge_target(&mr.repository_id, Some(&mr.selector_to))
.map_err(repository_merge_evidence_error)?; .map_err(repository_merge_evidence_error)?;
if observed.commit != input.target_ref_before && observed.commit != input.target_ref_after { require_completed_target_observation(
return Err(Error::InvalidInput( &observed.commit,
"target selector moved outside completion evidence".into(), &input.target_ref_before,
) &input.target_ref_after,
.into()); )?;
}
let completion = merge_request::CompleteMergeRequest { let completion = merge_request::CompleteMergeRequest {
ticket_id, ticket_id,
operation_id: input.operation_id, operation_id: input.operation_id,
@@ -4138,31 +4338,7 @@ async fn scoped_complete_merge_request(
now: Utc::now(), now: Utc::now(),
}; };
store.validate_completion(&completion)?; store.validate_completion(&completion)?;
let already = observed.commit == input.target_ref_after; store.complete(completion).map(Json).map_err(Into::into)
if !already {
repositories
.update_merge_target(
&mr.repository_id,
&mr.selector_to,
&input.target_ref_before,
&input.target_ref_after,
)
.map_err(repository_merge_evidence_error)?
}
match store.complete(completion) {
Ok(v) => Ok(Json(v)),
Err(e) => {
if !already {
let _ = repositories.update_merge_target(
&mr.repository_id,
&mr.selector_to,
&input.target_ref_after,
&input.target_ref_before,
);
}
Err(e.into())
}
}
} }
fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> {
@@ -4368,7 +4544,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
| TicketBackendOperation::AddIntakeSummary { id, .. } | TicketBackendOperation::AddIntakeSummary { id, .. }
| TicketBackendOperation::SetStateField { id, .. } | TicketBackendOperation::SetStateField { id, .. }
| TicketBackendOperation::SetWorkflowState { id, .. } | TicketBackendOperation::SetWorkflowState { id, .. }
| TicketBackendOperation::MarkIntakeReady { id, .. } | TicketBackendOperation::MarkReady { id, .. }
| TicketBackendOperation::QueueReady { id, .. } | TicketBackendOperation::QueueReady { id, .. }
| TicketBackendOperation::Close { id, .. } | TicketBackendOperation::Close { id, .. }
| TicketBackendOperation::AddTicketRelation { id, .. } | TicketBackendOperation::AddTicketRelation { id, .. }
@@ -4409,11 +4585,11 @@ fn bind_worker_ticket_operation_source(
| TicketBackendOperation::SetStateField { change, .. } | TicketBackendOperation::SetStateField { change, .. }
| TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author), | TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author),
TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author), TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author),
TicketBackendOperation::MarkIntakeReady { TicketBackendOperation::MarkReady { request, .. } => {
summary, change, .. request.author = Some(author.clone());
} => { if let Some(summary) = request.intake_summary.as_mut() {
summary.author = Some(author.clone()); summary.author = Some(author);
change.author = Some(author); }
} }
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author, TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
TicketBackendOperation::AddTicketRelation { relation, .. } => { TicketBackendOperation::AddTicketRelation { relation, .. } => {
@@ -4435,7 +4611,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary", TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary",
TicketBackendOperation::SetStateField { .. } => "set_state_field", TicketBackendOperation::SetStateField { .. } => "set_state_field",
TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state", TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state",
TicketBackendOperation::MarkIntakeReady { .. } => "mark_intake_ready", TicketBackendOperation::MarkReady { .. } => "mark_ready",
TicketBackendOperation::QueueReady { .. } => "queue_ready", TicketBackendOperation::QueueReady { .. } => "queue_ready",
TicketBackendOperation::Close { .. } => "close", TicketBackendOperation::Close { .. } => "close",
TicketBackendOperation::AddTicketRelation { .. } => "add_relation", TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
@@ -8316,17 +8492,35 @@ async fn list_tickets(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
Query(query): Query<TicketListQuery>, Query(query): Query<TicketListQuery>,
) -> ApiResult<Json<crate::records::TicketListResponse>> { ) -> ApiResult<Json<crate::records::TicketListResponse>> {
let requested_limit = query.limit.unwrap_or(api.config.max_records); let limit = query.limit.unwrap_or(30).clamp(1, 100);
let limit = requested_limit.min(1000); let states = query
let ProjectRecordList { .states
.as_deref()
.map(|states| {
states
.split(',')
.filter(|state| !state.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let crate::records::TicketSummaryPage {
items, items,
page,
invalid_records, invalid_records,
record_authority, record_authority,
} = api.authority.list_tickets(limit)?; } = api
.authority
.list_ticket_page(crate::records::TicketListPageRequest {
states,
limit: Some(limit),
cursor: query.cursor,
})?;
Ok(Json(crate::records::TicketListResponse { Ok(Json(crate::records::TicketListResponse {
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
limit, limit,
items, items,
page,
invalid_records, invalid_records,
record_authority, record_authority,
})) }))
@@ -11883,7 +12077,26 @@ impl From<Error> for ApiError {
ticket::TicketError::NotFound(_) => "ticket_not_found", ticket::TicketError::NotFound(_) => "ticket_not_found",
ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous", ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous",
ticket::TicketError::Locked { .. } => "ticket_locked", ticket::TicketError::Locked { .. } => "ticket_locked",
ticket::TicketError::Conflict(_) => "ticket_conflict", ticket::TicketError::Conflict(_)
| ticket::TicketError::StaleWorkflowState { .. }
| ticket::TicketError::InvalidWorkflowTransition { .. }
| ticket::TicketError::BlockingRelations(_)
| ticket::TicketError::OperationFingerprintMismatch { .. } => "ticket_conflict",
ticket::TicketError::MissingTargetRepository => {
"ticket_target_repository_missing"
}
ticket::TicketError::UnknownTargetRepository(_) => {
"ticket_target_repository_unknown"
}
ticket::TicketError::MissingTargetSelector(_) => {
"ticket_target_selector_missing"
}
ticket::TicketError::InvalidTargetSelector { .. } => {
"ticket_target_selector_invalid"
}
ticket::TicketError::TargetAuthorityUnavailable => {
"ticket_target_authority_unavailable"
}
ticket::TicketError::InvalidPathComponent(_) ticket::TicketError::InvalidPathComponent(_)
| ticket::TicketError::PathEscapesRoot { .. } => "invalid_ticket_request", | ticket::TicketError::PathEscapesRoot { .. } => "invalid_ticket_request",
ticket::TicketError::Io { .. } ticket::TicketError::Io { .. }
@@ -12291,7 +12504,7 @@ mod tests {
assert_eq!(builtin.definition.name, "coder-review"); assert_eq!(builtin.definition.name, "coder-review");
assert_eq!(builtin.selector.to_string(), "builtin:coder-review"); assert_eq!(builtin.selector.to_string(), "builtin:coder-review");
assert_eq!(builtin.flow_id, "builtin:coder-review"); assert_eq!(builtin.flow_id, "builtin:coder-review");
assert_eq!(builtin.revision, 2); assert_eq!(builtin.revision, 3);
assert_eq!( assert_eq!(
api.store api.store
.list_flow_sources(&api.config.workspace_id) .list_flow_sources(&api.config.workspace_id)
@@ -12452,6 +12665,54 @@ mod tests {
); );
} }
#[test]
fn recorded_completion_replay_is_identified_before_later_target_observation() {
let event = merge_request::MergeEvent {
event_id: "merge-event".into(),
sequence: 1,
operation_id: "operation".into(),
approval_event_id: "approval".into(),
approved_source_ref: "source".into(),
target_ref_before: "before".into(),
target_ref_after: "after".into(),
strategy: merge_request::MergeStrategy::FastForward,
resolution: merge_request::ConflictResolution::None,
merged_by: merge_request::WorkerIdentity {
runtime_id: "runtime".into(),
worker_id: "orchestrator".into(),
},
created_at: Utc::now(),
};
let thread = vec![merge_request::MergeRequestThreadEvent::Merge(event.clone())];
assert_eq!(
recorded_merge_completion(&thread, "operation"),
Some(&event)
);
assert!(recorded_merge_completion(&thread, "different").is_none());
assert!(require_completed_target_observation("later", "before", "after").is_err());
}
#[test]
fn merge_request_completion_records_only_an_observed_remote_target_update() {
require_completed_target_observation("after", "before", "after").unwrap();
let not_pushed =
require_completed_target_observation("before", "before", "after").unwrap_err();
assert!(matches!(
not_pushed.error,
Error::InvalidInput(ref message)
if message.contains("push the verified result from the Orchestrator Workdir")
));
let moved = require_completed_target_observation("other", "before", "after").unwrap_err();
assert!(matches!(
moved.error,
Error::InvalidInput(ref message)
if message.contains("moved outside completion evidence")
));
}
#[test] #[test]
fn worker_ticket_assignment_projects_coder_intent_and_run_acceptance() { fn worker_ticket_assignment_projects_coder_intent_and_run_acceptance() {
let initial_submit = vec![ let initial_submit = vec![
@@ -13549,7 +13810,7 @@ mod tests {
config.repositories = vec![ConfiguredRepository { config.repositories = vec![ConfiguredRepository {
id: TEST_REPOSITORY_ID.to_string(), id: TEST_REPOSITORY_ID.to_string(),
provider: "git".to_string(), provider: "git".to_string(),
uri: ".".to_string(), uri: workspace_root.display().to_string(),
path: workspace_root, path: workspace_root,
display_name: Some("Test Repository".to_string()), display_name: Some("Test Repository".to_string()),
default_selector: Some("HEAD".to_string()), default_selector: Some("HEAD".to_string()),
@@ -13706,7 +13967,7 @@ mod tests {
fn init_clean_git_workspace(path: &std::path::Path) { fn init_clean_git_workspace(path: &std::path::Path) {
for args in [ for args in [
vec!["init"], vec!["init", "--initial-branch=develop"],
vec!["config", "user.email", "test@example.invalid"], vec!["config", "user.email", "test@example.invalid"],
vec!["config", "user.name", "Yoi Test"], vec!["config", "user.name", "Yoi Test"],
] { ] {
@@ -13738,6 +13999,88 @@ mod tests {
} }
} }
#[tokio::test]
async fn mark_ready_resolves_workspace_target_and_closes_lifecycle_bypasses() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("Validated target");
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
input.ref_selector = Some("develop".to_owned());
let ticket_ref = backend.create(input).unwrap();
let request = ticket::TicketMarkReady {
operation_key: "ready-server-test".to_owned(),
reason: Some("target accepted".to_owned()),
author: Some("test".to_owned()),
intake_summary: None,
};
let ready = backend
.mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request.clone())
.unwrap();
assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready);
assert_eq!(
ready.meta.repository_id.as_deref(),
Some(TEST_REPOSITORY_ID)
);
assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop"));
assert_eq!(
backend
.mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request)
.unwrap()
.events
.iter()
.filter(|event| event.attributes.contains_key("operation_key"))
.count(),
1
);
assert!(matches!(
backend.edit_item(
TicketIdOrSlug::Id(ticket_ref.id.clone()),
ticket::TicketItemEdit {
target: Some(ticket::TicketTargetEdit::Set {
repository_id: TEST_REPOSITORY_ID.to_owned(),
ref_selector: Some("other".to_owned()),
}),
..Default::default()
},
),
Err(ticket::TicketError::Conflict(_))
));
let mut missing = ticket::NewTicket::new("Missing target");
missing.repository_id = Some("unknown".to_owned());
let missing = backend.create(missing).unwrap();
assert!(matches!(
backend.mark_ready(
TicketIdOrSlug::Id(missing.id.clone()),
ticket::TicketMarkReady {
operation_key: "missing-repository".to_owned(),
reason: None,
author: None,
intake_summary: None,
},
),
Err(ticket::TicketError::UnknownTargetRepository(_))
));
assert_eq!(
backend
.show(TicketIdOrSlug::Id(missing.id))
.unwrap()
.meta
.workflow_state,
TicketWorkflowState::Planning
);
assert!(matches!(
backend.set_workflow_state(
TicketIdOrSlug::Id(ticket_ref.id),
TicketStateChange::new("ready", "queued", "bypass", "must use TicketQueue",),
),
Err(ticket::TicketError::InvalidWorkflowTransition { .. })
));
}
#[test] #[test]
fn worker_source_actor_roles_use_canonical_vocabulary() { fn worker_source_actor_roles_use_canonical_vocabulary() {
assert_eq!(worker_source_actor_role(true, false), "coder"); assert_eq!(worker_source_actor_role(true, false), "coder");
@@ -13779,6 +14122,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() { async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let (api, execution) = test_api_with_recording_backend(dir.path()).await; let (api, execution) = test_api_with_recording_backend(dir.path()).await;
let source_worker = api let source_worker = api
.runtime .runtime
@@ -13836,29 +14180,24 @@ mod tests {
let orchestrator = started.worker.unwrap().worker; let orchestrator = started.worker.unwrap().worker;
execution.take_inputs(); execution.take_inputs();
let ticket = browser_ticket_backend(&api) let mut input = ticket::NewTicket::new("Bounded notification");
.unwrap() input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
.create(ticket::NewTicket::new("Bounded notification")) input.ref_selector = Some("develop".to_owned());
.unwrap(); let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
let ticket_id = TicketIdOrSlug::Id(ticket.id.clone()); let ticket_id = TicketIdOrSlug::Id(ticket.id.clone());
let operations = [ let operations = [
TicketBackendOperation::SetWorkflowState { TicketBackendOperation::MarkReady {
id: ticket_id.clone(), id: ticket_id.clone(),
change: TicketStateChange::new( request: ticket::TicketMarkReady {
"planning", operation_key: "notification-ready".to_owned(),
"ready", reason: Some("ready for implementation".to_owned()),
"ready for implementation", author: None,
"test transition", intake_summary: None,
),
}, },
TicketBackendOperation::SetWorkflowState { },
TicketBackendOperation::QueueReady {
id: ticket_id.clone(), id: ticket_id.clone(),
change: TicketStateChange::new( queued_by: "spoofed".to_owned(),
"ready",
"queued",
"queued for implementation",
"test transition",
),
}, },
TicketBackendOperation::SetWorkflowState { TicketBackendOperation::SetWorkflowState {
id: ticket_id.clone(), id: ticket_id.clone(),
@@ -14263,30 +14602,11 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap(); let backend = browser_ticket_backend(&api).unwrap();
let ticket_ref = backend let mut input = ticket::NewTicket::new("Recover queued work");
.create(ticket::NewTicket::new("Recover queued work")) input.workflow_state = Some(TicketWorkflowState::Queued);
.unwrap(); input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
backend input.ref_selector = Some("HEAD".to_owned());
.mark_intake_ready( let ticket_ref = backend.create(input).unwrap();
TicketIdOrSlug::Id(ticket_ref.id.clone()),
ticket::TicketIntakeSummary {
author: Some("intake".to_string()),
body: MarkdownText::new("Ready"),
references: Vec::new(),
},
ticket::TicketStateChange {
from: "planning".to_string(),
to: "ready".to_string(),
reason: "ready".to_string(),
author: Some("intake".to_string()),
body: MarkdownText::new("Ready"),
references: Vec::new(),
},
)
.unwrap();
backend
.queue_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), "browser-user")
.unwrap();
*api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone()); *api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone());
let Json(started) = scoped_start_workspace_orchestrator( let Json(started) = scoped_start_workspace_orchestrator(
@@ -14712,6 +15032,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() { async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let ticket_ref = browser_ticket_backend(&api) let ticket_ref = browser_ticket_backend(&api)
.unwrap() .unwrap()
@@ -14751,7 +15072,7 @@ mod tests {
replace_all: false, replace_all: false,
target: Some(TicketTargetEdit::Set { target: Some(TicketTargetEdit::Set {
repository_id: "main".to_string(), repository_id: "main".to_string(),
ref_selector: Some("feature/api".to_string()), ref_selector: Some("develop".to_string()),
}), }),
author: Some("browser-user".to_string()), author: Some("browser-user".to_string()),
}), }),
@@ -14761,7 +15082,7 @@ mod tests {
assert_eq!(edited.title, "Browser Ticket API edited"); assert_eq!(edited.title, "Browser Ticket API edited");
assert_eq!(edited.body, "Updated from the Browser API."); assert_eq!(edited.body, "Updated from the Browser API.");
assert_eq!(edited.repository_id.as_deref(), Some("main")); assert_eq!(edited.repository_id.as_deref(), Some("main"));
assert_eq!(edited.ref_selector.as_deref(), Some("feature/api")); assert_eq!(edited.ref_selector.as_deref(), Some("develop"));
assert_eq!(edited.assignee, None); assert_eq!(edited.assignee, None);
assert_eq!(edited.relations.outgoing.len(), 1); assert_eq!(edited.relations.outgoing.len(), 1);
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id); assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
@@ -14782,14 +15103,13 @@ mod tests {
event.kind == "comment" && event.body.as_deref() == Some("API comment") event.kind == "comment" && event.body.as_deref() == Some("API comment")
})); }));
let Json(ready) = scoped_transition_ticket_state( let Json(ready) = scoped_mark_ticket_ready_from_browser(
State(api.clone()), State(api.clone()),
AxumPath(path()), AxumPath(path()),
Json(BrowserTransitionTicketStateRequest { Json(TicketMarkReadyRequest {
state: TicketWorkflowState::Ready, operation_key: "browser-ready".to_owned(),
reason: Some("intake complete".to_string()), reason: Some("intake complete".to_owned()),
body: Some("Ready for queue".to_string()), intake_summary: None,
author: Some("browser-user".to_string()),
}), }),
) )
.await .await
+33 -10
View File
@@ -196,6 +196,11 @@ const MIGRATIONS: &[Migration] = &[
name: "remove Worker control delegation authority", name: "remove Worker control delegation authority",
apply: remove_worker_control_delegation_authority, apply: remove_worker_control_delegation_authority,
}, },
Migration {
version: 36,
name: "add Objective query indexes",
apply: add_objective_query_indexes,
},
]; ];
struct Migration { struct Migration {
@@ -5046,6 +5051,24 @@ fn create_worker_control_delegation_operation_authority(conn: &Connection) -> Re
Ok(()) Ok(())
} }
fn add_objective_query_indexes(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE INDEX IF NOT EXISTS objectives_workspace_state_updated
ON objectives(workspace_id, state, updated_at DESC, objective_id);
CREATE INDEX IF NOT EXISTS objectives_workspace_updated
ON objectives(workspace_id, updated_at DESC, objective_id);
CREATE INDEX IF NOT EXISTS objectives_workspace_created
ON objectives(workspace_id, created_at DESC, objective_id);
CREATE INDEX IF NOT EXISTS objectives_workspace_title
ON objectives(workspace_id, title COLLATE NOCASE, objective_id);
CREATE INDEX IF NOT EXISTS objective_ticket_links_workspace_ticket_objective
ON objective_ticket_links(workspace_id, ticket_id, objective_id);
"#,
)?;
Ok(())
}
fn remove_worker_control_delegation_authority(conn: &Connection) -> Result<()> { fn remove_worker_control_delegation_authority(conn: &Connection) -> Result<()> {
let mut statement = let mut statement =
conn.prepare("SELECT workspace_id, grant_id, permissions_json FROM worker_control_grants")?; conn.prepare("SELECT workspace_id, grant_id, permissions_json FROM worker_control_grants")?;
@@ -5799,7 +5822,7 @@ INSERT INTO worker_control_grants (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 35); assert_eq!(current_schema_version(&conn).unwrap(), 36);
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
let (permissions_json, revoked_at): (String, Option<String>) = conn let (permissions_json, revoked_at): (String, Option<String>) = conn
.query_row( .query_row(
@@ -5847,7 +5870,7 @@ INSERT INTO worker_control_grants (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 35); assert_eq!(current_schema_version(&conn).unwrap(), 36);
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
} }
@@ -5880,7 +5903,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 35); assert_eq!(current_schema_version(&conn).unwrap(), 36);
assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_sources").unwrap());
assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap());
assert!(!table_exists(&conn, "flow_instances").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap());
@@ -5947,7 +5970,7 @@ INSERT INTO worker_workdir_attachment_reservations (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 35); assert_eq!(current_schema_version(&conn).unwrap(), 36);
let repositories_sql: String = conn let repositories_sql: String = conn
.query_row( .query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
@@ -6127,7 +6150,7 @@ INSERT INTO workdir_registry (
let db = dir.path().join("control-plane.sqlite"); let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap(); let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 35); assert_eq!(store.schema_version().await.unwrap(), 36);
assert!( assert!(
!store !store
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) .with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
@@ -6144,7 +6167,7 @@ INSERT INTO workdir_registry (
store.upsert_workspace(&record).await.unwrap(); store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 35); assert_eq!(reopened.schema_version().await.unwrap(), 36);
assert_eq!( assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(), reopened.get_workspace("local-dev").await.unwrap(),
Some(record) Some(record)
@@ -6691,7 +6714,7 @@ INSERT INTO workdir_registry (
.unwrap(); .unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 35); assert_eq!(store.schema_version().await.unwrap(), 36);
store store
.with_conn(|conn| { .with_conn(|conn| {
@@ -6880,7 +6903,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn repository_records_round_trip() { async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 35); assert_eq!(store.schema_version().await.unwrap(), 36);
let workspace = WorkspaceRecord { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: None, owner_account_id: None,
@@ -6946,7 +6969,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn memory_authority_records_round_trip_and_close_staging() { async fn memory_authority_records_round_trip_and_close_staging() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 35); assert_eq!(store.schema_version().await.unwrap(), 36);
let workspace = WorkspaceRecord { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: None, owner_account_id: None,
@@ -7337,7 +7360,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn account_and_login_records_round_trip() { async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 35); assert_eq!(store.schema_version().await.unwrap(), 36);
let now = "2026-07-22T00:00:00Z".to_string(); let now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord { let account = AccountRecord {
account_id: "acct-user-alice".to_string(), account_id: "acct-user-alice".to_string(),
+19 -38
View File
@@ -12,8 +12,7 @@ use ticket::config::{
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketSummary, TicketWorkflowState,
TicketWorkflowState,
}; };
const DEFAULT_LIST_LIMIT: usize = 50; const DEFAULT_LIST_LIMIT: usize = 50;
@@ -630,8 +629,16 @@ fn state(
let id = TicketIdOrSlug::Query(options.query.clone()); let id = TicketIdOrSlug::Query(options.query.clone());
let target_state = match options.state { let target_state = match options.state {
StateTarget::Planning => TicketWorkflowState::Planning, StateTarget::Planning => TicketWorkflowState::Planning,
StateTarget::Ready => TicketWorkflowState::Ready, StateTarget::Ready => {
StateTarget::Queued => TicketWorkflowState::Queued, return Err(TicketCliError::new(
"ready requires Workspace repository authority; use the Browser Mark ready action or TicketMarkReady",
));
}
StateTarget::Queued => {
return Err(TicketCliError::new(
"queued is an Orchestrator operation; use TicketQueue after MarkReady succeeds",
));
}
StateTarget::InProgress => TicketWorkflowState::InProgress, StateTarget::InProgress => TicketWorkflowState::InProgress,
StateTarget::Done => { StateTarget::Done => {
return Err(TicketCliError::new( return Err(TicketCliError::new(
@@ -646,21 +653,6 @@ fn state(
}; };
let current = backend.show(id.clone())?; let current = backend.show(id.clone())?;
let ticket_id = current.meta.id.clone(); let ticket_id = current.meta.id.clone();
match target_state {
TicketWorkflowState::Ready => backend.mark_intake_ready(
id,
TicketIntakeSummary::new("Marked ready by `yoi ticket state`."),
ticket::TicketStateChange {
from: current.meta.workflow_state.as_str().to_string(),
to: TicketWorkflowState::Ready.as_str().to_string(),
reason: "cli_state".to_string(),
author: Some("yoi ticket".to_string()),
body: "Marked ready by `yoi ticket state`.\n".into(),
references: Vec::new(),
},
)?,
TicketWorkflowState::Queued => backend.queue_ready(id, "yoi ticket")?,
_ => {
let from = current.meta.workflow_state; let from = current.meta.workflow_state;
let change = ticket::TicketStateChange { let change = ticket::TicketStateChange {
from: from.as_str().to_string(), from: from.as_str().to_string(),
@@ -671,8 +663,6 @@ fn state(
references: Vec::new(), references: Vec::new(),
}; };
backend.set_workflow_state(id, change)?; backend.set_workflow_state(id, change)?;
}
}
Ok(success(format!( Ok(success(format!(
"state\t{}\t{}\n", "state\t{}\t{}\n",
ticket_id, ticket_id,
@@ -1335,23 +1325,14 @@ mod tests {
.contains(&format!("appended\t{}\timplementation_report", ticket_id)) .contains(&format!("appended\t{}\timplementation_report", ticket_id))
); );
let ready = run(&temp, &["state", &ticket_id, "ready"]); let ready_error = parse_ticket_args(&args(&["state", &ticket_id, "ready"]))
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id)); .and_then(|cli| run_in_workspace(cli, temp.path()))
let ready_listed = run(&temp, &["list", "--state", "ready"]); .unwrap_err();
assert!(ready_listed.stdout.contains(&ticket_id)); assert!(ready_error.to_string().contains("TicketMarkReady"));
let queue_error = parse_ticket_args(&args(&["state", &ticket_id, "queued"]))
let queued = run(&temp, &["state", &ticket_id, "queued"]); .and_then(|cli| run_in_workspace(cli, temp.path()))
assert_eq!(queued.stdout, format!("state\t{}\tqueued\n", ticket_id)); .unwrap_err();
let queued_listed = run(&temp, &["list", "--state", "queued"]); assert!(queue_error.to_string().contains("TicketQueue"));
assert!(queued_listed.stdout.contains(&ticket_id));
let inprogress = run(&temp, &["state", &ticket_id, "inprogress"]);
assert_eq!(
inprogress.stdout,
format!("state\t{}\tinprogress\n", ticket_id)
);
let inprogress_listed = run(&temp, &["list", "--state", "inprogress"]);
assert!(inprogress_listed.stdout.contains(&ticket_id));
let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"])) let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"]))
.and_then(|cli| run_in_workspace(cli, temp.path())) .and_then(|cli| run_in_workspace(cli, temp.path()))
+5 -4
View File
@@ -31,14 +31,15 @@ Maintainers can inspect the local `.yoi/tickets/` files directly when debugging
## Ticket tools inside Workers ## Ticket tools inside Workers
Workers with the Ticket built-in feature can use typed Ticket tools: Workers with the Ticket and operation-specific Merge Request built-in features can use typed workflow tools:
- `TicketCreate` - `TicketCreate`
- `QueryTicket` — bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata. - `QueryTicket` — bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata.
- `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence. - `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence.
- `TicketComment` - `TicketComment`
- `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete` - Coder: `MergeRequestShow`, `MergeRequestOpen`
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input. - Reviewer: `MergeRequestShow`, `MergeRequestReview` — available only inside the attested direct-child Reviewer request; grant and subject-ref capability material are not model input.
- Orchestrator: `MergeRequestShow`, `MergeRequestReadinessCheck`, `MergeRequestComplete`
- `TicketClose` - `TicketClose`
- `TicketRelationRecord` - `TicketRelationRecord`
@@ -243,7 +244,7 @@ Implementation normally happens in a child git worktree created by the Orchestra
The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with read-only scope and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval. The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with read-only scope and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval.
The Reviewer records the structured result with `MergeRequestReviewSubmit`. Request changes requires a new immutable revision and a fresh child attempt. `MergeRequestComplete` performs guarded Ticket completion with operation-id dedupe/CAS semantics; Flow transitions are not completion authority. The Reviewer records the structured result with `MergeRequestReview`. Request changes requires a new immutable revision and a fresh child attempt. The Orchestrator uses `MergeRequestReadinessCheck` and then `MergeRequestComplete` for guarded integration with operation-id dedupe/CAS semantics; Flow transitions are not completion authority.
Blockers must be fixed or explicitly escalated before merge-ready submission. Blockers must be fixed or explicitly escalated before merge-ready submission.
+203
View File
@@ -0,0 +1,203 @@
# Test code audit — 2026-08-06
## Conclusion
The repository has a large test corpus and many high-value authority/state tests, but it is not currently a reliable repository-wide quality gate. Test quantity is not outgrowing production code, yet test bodies are becoming larger and are concentrated in a few inline modules. The Rust workspace suite does not currently compile, the tracked repository has no CI workflow that runs it, and part of the Web UI suite checks source text instead of behavior.
AI authorship cannot be measured reliably from Git metadata. All 400 sampled first-parent commits use the same `hare` author identity, so this audit measures the shape and evolution of tests rather than attempting author attribution.
## Method
Baseline: `HEAD = 46767daf`.
Current size uses tracked Rust, TypeScript, TSX, JavaScript, JSX, and Svelte files. LOC means nonblank physical lines.
Test LOC includes:
- complete files under `tests/` or named `*_test.rs`, `*_tests.rs`, `tests.rs`, `*.test.ts`, or `*.spec.ts`;
- inline Rust `#[cfg(test)] mod ... { ... }` regions.
Rust test count is the number of source declarations carrying `#[test]` or `#[tokio::test]`. Web test count is the count actually reported by `deno task test`. Generated doctests and feature-dependent discovered tests are not included because the full Cargo suite currently fails during compilation.
Historical comparisons use first-parent commit distance rather than commit dates. The latest commit timestamps are later than the Worker environment date, so calendar-time rates would be misleading.
## Current quantity
| Measure | Current value |
|---|---:|
| Production LOC | 166,680 |
| Test LOC | 80,455 |
| Test share of code LOC | 32.6% |
| Test LOC / production LOC | 0.483 |
| Rust test declarations | 2,400 |
| Deno tests actually run | 106 |
| Dedicated test-file LOC | 19,253 |
| Inline `cfg(test)` LOC | 61,202 |
| Inline share of test LOC | 76.1% |
| Test-bearing files | 264 / 495 |
| Median test LOC per test-bearing file | 127 |
Language distribution:
| Language | Production LOC | Test LOC |
|---|---:|---:|
| Rust | 146,626 | 76,541 |
| TypeScript | 16,570 | 3,914 |
| TSX | 551 | 0 |
| Svelte | 2,931 | 0 |
| JavaScript | 2 | 0 |
The absence of Svelte test LOC is important: Web tests do not mount Svelte components. The `.test.ts` suite tests reducers, API helpers, WASM behavior, and source text around Svelte files.
### Concentration
The four largest test modules contain 32,879 test LOC, or 40.9% of all test code:
| Test location | Test LOC | Test declarations |
|---|---:|---:|
| `crates/workspace-server/src/server.rs` | 12,334 | 219 |
| `crates/workspace-server/src/store.rs` | 9,091 | 128 |
| `crates/workspace-api/src/lib.rs` | 6,098 | 116 |
| `crates/worker/src/worker.rs` | 5,356 | 102 |
The ten largest locations contain 59.3% of all test LOC. By crate, `workspace-server`, `worker-runtime`, `worker`, and `workspace-api` together contain 57,703 test LOC, or 71.7% of the repository total.
This concentration is partly justified because these crates own authority and lifecycle behavior. It also means fixture changes and internal representation changes can cause broad, difficult-to-diagnose failures.
## Historical trend
| First-parent snapshot | Production LOC | Test LOC | Test share | Rust test declarations |
|---|---:|---:|---:|---:|
| `HEAD` | 166,680 | 80,455 | 32.6% | 2,400 |
| `HEAD~25` | 160,791 | 79,669 | 33.1% | 2,404 |
| `HEAD~50` | 157,560 | 76,568 | 32.7% | 2,402 |
| `HEAD~100` | 155,087 | 75,035 | 32.6% | 2,356 |
| `HEAD~200` | 143,786 | 70,859 | 33.0% | 2,266 |
| `HEAD~400` | 114,352 | 63,702 | 35.8% | 2,189 |
| `HEAD~800` | 71,833 | 44,687 | 38.4% | 1,745 |
| `HEAD~1200` | 47,284 | 36,514 | 43.6% | 1,422 |
Over the latest 400 first-parent commits:
- production LOC grew 45.8%;
- test LOC grew 26.3%;
- Rust test declarations grew 9.6%;
- test share fell from 35.8% to 32.6%;
- 189 of 400 commits, or 47.3%, changed at least one test declaration.
Tests are therefore not growing faster than production code. The concerning trend is different: a coarse test/helper LOC per source test declaration, measured consistently across snapshots, increased from approximately 27.5 at `HEAD~400` to 31.8 now. The suite is becoming heavier per test and more dependent on shared fixture code.
The cleanup immediately before this audit removed 547 nonblank test LOC and 21 Rust test declarations that duplicated prompt/profile/Flow/model resource contents.
## Test-suite health
### Rust workspace
`cargo test --workspace --no-fail-fast` currently fails during compilation before the workspace suite can run.
The immediate failure is stale test code in `crates/llm-engine/tests/parallel_execution_test.rs`:
- tests pass `Vec<ToolOutput>` where production now requires `Vec<Segment>`;
- tests still construct `ToolOutput` with two arguments although the current constructor accepts one;
- a nested vector repeats the same obsolete contract.
This is direct evidence of test drift. A large test corpus is not a quality gate if the aggregate command cannot compile.
### Web workspace
`cd web/workspace && deno task test` succeeds:
- 106 passed;
- 0 failed.
The run is fast, but a meaningful fraction of the Web suite is source inspection. `worker-console.ui.test.ts` and `config-source/editor-state.test.ts` alone contain about 930 LOC and 23 tests that read source files and assert implementation strings. There is no Playwright, Vitest, Testing Library, jsdom, or other component/browser runner in the tracked Web setup.
### Automation and coverage
- No tracked `.github/workflows` or equivalent CI workflow was found.
- `flake.nix` exposes `checks.default = yoi`, which builds the package but does not run Cargo or Deno tests.
- No active `cargo llvm-cov`, tarpaulin, grcov, codecov, or mutation-test configuration was found.
The repository documentation describes tests as required, but the repository itself does not enforce a green aggregate test gate or measure exercised behavior.
## Quality tendencies
### High-value behavior tests
The strongest tests use real SQLite stores, in-process routers, Runtime brokers, WASM artifacts, or lifecycle state and verify externally meaningful authority boundaries. Representative examples include:
- `workspace-server/config_source.rs::invalid_candidate_is_never_persisted`;
- `workspace-server/config_source.rs::stale_expected_revision_is_rejected`;
- `workspace-server/store.rs::schema_v27_rebuild_rejects_cross_workspace_assignment_repository_drift`;
- `workspace-server/store.rs::worker_spawn_operation_retry_allows_same_reserved_workdir`;
- `workspace-server/server.rs::ticket_assignment_spawn_requires_inprogress_before_runtime_side_effects`;
- `workspace-server/server.rs::destructive_worker_remove_rejects_header_spoof_without_source_proof`;
- `worker-runtime/runtime.rs::scoped_runtime_worker_subscription_hides_other_workspaces`;
- `worker-runtime/runtime.rs::restore_does_not_redispatch_spawn_initial_submit`;
- `ticket/sqlite_schema.rs::migration_rejects_constraint_drift_before_marker_update`;
- `web/workspace/test/config-source/wasm-parity.test.ts`;
- `web/workspace/src/lib/workspace/sidebar/worker-subscription.test.ts`.
These tests protect state ordering, idempotency, workspace isolation, migration atomicity, stale-revision rejection, and information disclosure. They make a real contribution to quality.
### Low-value and brittle tests
The main low-value pattern is implementation/source-shape monitoring presented as regression testing:
- `worker-console.ui.test.ts` checks route strings, assignments such as `nextReloadToken += 1`, CSS imports, and Svelte source fragments without mounting the component;
- `config-source/editor-state.test.ts` checks `$state.raw` and `untrack` text rather than demonstrating that the editor avoids an effect loop;
- some schema tests enumerate complete table/column/index/FK shapes rather than testing the migration behavior that depends on them;
- provider fixture tests allow missing usage fields to pass with a warning, weakening semantic validation despite maintaining fixtures.
These tests can block harmless refactors while failing to prove the user-visible or authority behavior they are named after.
### Maintainability and flakiness risks
- 76.1% of test LOC lives inline in production source files.
- Four files own 40.9% of all test LOC.
- Large shared fixtures mix router wiring, fake Runtime behavior, temporary Git repositories, DB setup, and domain assertions.
- 48 explicit sleep calls occur in test regions across 14 Rust files; `worker/tests/controller_test.rs` contains 19 of them.
- Parallel execution tests use elapsed-time thresholds and can depend on scheduler load.
- Some system-prompt tests retain temporary directories with `std::mem::forget`, leaving environment-cleanup risk.
- Runtime tests frequently manipulate internal snapshots and locks directly, which provides reach but creates implementation coupling.
## Coverage gaps
The largest gaps are at cross-component boundaries:
1. Web Console route reuse and shared WebSocket behavior is checked through reducers and source strings, not a mounted component with a fake protocol client.
2. Config editor source monitoring does not demonstrate generation fencing, stale-response rejection, debounced diagnostics, or commit-button state transitions in the UI.
3. Merge Request completion has strong store-level authority tests but limited provider-level Git integration for stale target refs, non-fast-forward/conflict outcomes, and approved source reachability.
4. Runtime, Server, and Browser multiplexer tests are split by layer; a minimal end-to-end subscription lifecycle is missing.
5. LLM provider fixtures do not consistently fail closed on missing usage, finish-reason, streaming-delta, and tool-call semantics.
## Assessment
The test corpus is not merely write-only: a substantial part of it protects difficult authority and lifecycle contracts that would otherwise regress. The concern is nevertheless valid because quantity is being used without a reliable feedback system.
The primary failure is not “too many tests.” It is that the repository lacks these controls:
- an always-green aggregate gate;
- a distinction between behavior tests and source-shape guards;
- a maintenance owner or deletion rule for stale tests;
- flake/runtime monitoring;
- coverage or mutation evidence for critical invariants.
Without those controls, additional AI-generated tests can increase review and maintenance cost without increasing defect detection.
## Recommended policy
1. Restore a green workspace command first, then run it in tracked CI. Do not accept new tests while the aggregate suite is uncompilable unless they repair the baseline.
2. Require every new test to name an independent observable invariant. A test that only repeats prompt/config/source data should be rejected.
3. Prefer tests at the narrowest authoritative boundary: real DB transaction, public resolver, router, broker, reducer, or provider adapter.
4. Replace Web source-inspection tests with component behavior tests using a fake protocol/fetch boundary. Delete source checks when no executable behavior boundary exists yet.
5. Split giant test modules by authority area and share only typed fixture builders. Do not share mutable scenario scripts that hide setup and expected effects.
6. Replace fixed sleeps and elapsed thresholds with barriers, channels, paused time, or explicit event acknowledgements where possible.
7. Track quality metrics, not test-count targets:
- aggregate suite green rate;
- flaky/retry rate;
- critical invariant coverage by domain;
- mutation kill rate for selected authority modules;
- production defects that had or lacked a regression test;
- test LOC and runtime as costs, not goals.
8. Periodically delete tests that no longer protect an independent invariant. Test deletion should be treated as maintenance, not loss of quality.
+8 -8
View File
@@ -5,17 +5,17 @@
states = { states = {
implement = { implement = {
instructions = "Before editing, inspect the assigned Workdir's Git state. A newly delegated Git Workdir normally starts at a detached HEAD. If HEAD is detached, create and switch to a local branch named `work/<ticket-id>-<slug>`, using the canonical Ticket id and a short lowercase kebab-case slug derived from the Ticket title or implementation scope. If the Workdir is already on a suitable work branch after restore, keep it. Never delete, reset, or overwrite an existing branch to resolve a name collision; choose a concise collision-free suffix and report the actual branch. For this assigned Ticket Workdir, you are explicitly authorized to create or switch the local work branch and to use `git add` and `git commit`. Commit coherent, validated implementation slices while working; do not push, merge, force-rewrite a submitted revision, delete branches, or discard pre-existing changes. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence. Open or update the Ticket Merge Request with immutable repository revision evidence. Before requesting independent review, commit all intended changes, confirm that the Workdir is clean, and make the current MR revision authoritative. A Flow transition is never Ticket completion authority."; instructions = "Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/<ticket-id>-<slug>` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. For this assigned Ticket Workdir, you are explicitly authorized to create or switch the local work branch and to use `git add` and `git commit`. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the published source selector resolves to the exact local HEAD. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open or update the Ticket Merge Request with immutable `selector_from` / `selector_to` revision evidence. Do not request review from a dirty Workdir or an unpublished source ref. A Flow transition is never Ticket completion authority.";
transitions = { transitions = {
review = { review = {
target = "review"; target = "review";
condition = "The requested implementation is present on the Ticket work branch, all intended changes are committed, the Workdir is clean, the relevant validation has completed, and there is enough bounded repository and session evidence for an independent Reviewer to evaluate the current head commit."; condition = "The requested implementation is present on the Ticket work branch, all intended changes are committed, the Workdir is clean, the relevant validation has completed, the current Ticket work branch has been published with a normal non-force push, the configured repository provider resolves that published source ref to the exact current HEAD, and the linked Merge Request current revision records that same subject for independent review.";
}; };
}; };
}; };
review = { review = {
instructions = "Spawn one actual direct-child SubWorker with profile builtin:reviewer, read-only scope, and a structured review handoff bound to the current immutable Merge Request revision. The child must commit MergeRequestReviewSubmit; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition."; instructions = "Use the current Ticket Merge Request as review authority. Confirm its immutable source selector resolves to the exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, read-only scope, and a structured review handoff bound to the current immutable Merge Request revision. The trusted spawn layer records `ReviewRequested`; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit MergeRequestReview; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition.";
transitions = { transitions = {
approved = { approved = {
target = "complete"; target = "complete";
@@ -29,27 +29,27 @@
}; };
fix = { fix = {
instructions = "Resolve every open Reviewer finding on the same Ticket work branch, rerun the validation affected by the fixes, commit the corrected implementation as a new revision, and preserve concrete evidence. Do not rewrite the previously reviewed commit or claim approval from the prior request_changes review. When the corrected committed revision is ready for a new independent review, request a Flow transition."; instructions = "Resolve every open Reviewer finding on the same Ticket work branch, rerun the validation affected by the fixes, commit the corrected implementation as a new revision, and preserve concrete evidence. Publish only the updated Ticket work branch with a normal non-force push, verify that the configured repository provider resolves the published source ref to the exact new HEAD, and update the linked Merge Request so its current revision records that same subject. Request review from a fresh read-only Reviewer child so the trusted spawn layer captures the new immutable subject. Do not rewrite the previously reviewed commit, claim approval from the prior request_changes review, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected committed revision is published and ready for a new independent review.";
transitions = { transitions = {
review = { review = {
target = "review"; target = "review";
condition = "Every finding from the latest request_changes review has been addressed with relevant validation evidence, and the corrected implementation is ready for a fresh independent Reviewer attempt."; condition = "Every finding from the latest request_changes review has been addressed with relevant validation evidence, the corrected implementation is committed and clean, the updated Ticket work branch has been published with a normal non-force push, the configured repository provider resolves that published source ref to the exact new HEAD, and the linked Merge Request current revision records that same subject for a fresh independent Reviewer attempt.";
}; };
}; };
}; };
complete = { complete = {
instructions = "Call MergeRequestComplete with a fresh operation_id and the approved current revision. The Server must revalidate current assignment, immutable revision, registered Reviewer attempt, and Ticket inprogress CAS. Only after the authoritative operation returns Ticket state done, request a Flow transition."; instructions = "Verify the authoritative approval still matches the exact current Merge Request subject, keep the reviewed source ref immutable, leave concise implementation and validation evidence on the Ticket when useful, then hand off to the Orchestrator. Do not call MergeRequestComplete, update the target selector, or treat the Flow terminal state as Ticket completion authority. After durable handoff evidence exists, request a Flow transition.";
transitions = { transitions = {
completed = { completed = {
target = "done"; target = "done";
condition = "MergeRequestComplete durably returned done for this exact operation_id and current approved revision. A Flow state or prose report alone is never sufficient."; condition = "The exact approved Merge Request revision and implementation evidence have been durably handed off to the Orchestrator. A Flow state or prose report alone is never Ticket completion authority.";
}; };
}; };
}; };
done = { done = {
instructions = "The guarded Merge Request completion operation committed Ticket state done. Flow terminal state only reflects that durable authority."; instructions = "The approved implementation has been handed off for Orchestrator-owned readiness and integration. Flow terminal state only reflects that handoff.";
terminal = true; terminal = true;
}; };
}; };
+7
View File
@@ -30,6 +30,13 @@ feature = {
worker = { enabled = false; }; worker = { enabled = false; };
objective = { enabled = true; }; objective = { enabled = true; };
ticket = { enabled = true; authoring = true; thread = true; }; ticket = { enabled = true; authoring = true; thread = true; };
merge_request = {
show = false;
open = false;
review = false;
readiness_check = false;
complete = false;
};
}; };
memory = { memory = {
+7
View File
@@ -12,5 +12,12 @@ import "./base.dcdl" // {
flow = { enabled = true; }; flow = { enabled = true; };
worker = { enabled = true; }; worker = { enabled = true; };
ticket = { enabled = true; thread = true; }; ticket = { enabled = true; thread = true; };
merge_request = {
show = true;
open = true;
review = false;
readiness_check = false;
complete = false;
};
}; };
} }
+7
View File
@@ -12,6 +12,13 @@ import "./base.dcdl" // {
worker = { enabled = true; direct_spawn = false; }; worker = { enabled = true; direct_spawn = false; };
manage_workdir = { enabled = true; }; manage_workdir = { enabled = true; };
ticket = { enabled = true; thread = true; workflow = true; }; ticket = { enabled = true; thread = true; workflow = true; };
merge_request = {
show = true;
open = false;
review = false;
readiness_check = true;
complete = true;
};
orchestration = { enabled = true; }; orchestration = { enabled = true; };
}; };
} }
+7
View File
@@ -11,5 +11,12 @@ import "./base.dcdl" // {
sub_worker = { enabled = false; }; sub_worker = { enabled = false; };
worker = { enabled = false; }; worker = { enabled = false; };
ticket = { enabled = true; thread = false; }; ticket = { enabled = true; thread = false; };
merge_request = {
show = true;
open = false;
review = true;
readiness_check = false;
complete = false;
};
}; };
} }
+2
View File
@@ -6,6 +6,7 @@ let
defaultDocument = import "./default.md"; defaultDocument = import "./default.md";
commonLanguage = import "./common/language.md"; commonLanguage = import "./common/language.md";
commonGit = import "./common/git.md"; commonGit = import "./common/git.md";
commonMergeRequest = import "./common/merge-request.md";
commonTickets = import "./common/tickets.md"; commonTickets = import "./common/tickets.md";
commonToolUsage = import "./common/tool-usage.md"; commonToolUsage = import "./common/tool-usage.md";
commonWorkerObservation = import "./common/worker-observation.md"; commonWorkerObservation = import "./common/worker-observation.md";
@@ -36,6 +37,7 @@ in
common = { common = {
git = commonGit.content; git = commonGit.content;
language = commonLanguage.content; language = commonLanguage.content;
merge_request = commonMergeRequest.content;
tickets = commonTickets.content; tickets = commonTickets.content;
tool_usage = commonToolUsage.content; tool_usage = commonToolUsage.content;
worker_observation = commonWorkerObservation.content; worker_observation = commonWorkerObservation.content;
+19
View File
@@ -0,0 +1,19 @@
## Merge Request workflow
Use only the exposed Merge Request operations; their availability expresses this Worker's workflow responsibility, not authorization to bypass Backend validation.
{% if "MergeRequestShow" in tools %}
- Reread the current Merge Request and append-only thread with `MergeRequestShow` before making review or integration decisions.
{% endif %}
{% if "MergeRequestOpen" in tools %}
- Open the Merge Request only after all intended changes are committed and the Workdir is clean. Use immutable source and target selectors; do not infer target authority from a branch name or cwd.
- Before requesting independent review, make the exact current MR revision authoritative.
{% endif %}
{% if "MergeRequestReview" in tools %}
- Review the exact current immutable MR revision independently. Submit the authoritative verdict through `MergeRequestReview`; prose alone is not approval.
{% endif %}
{% if "MergeRequestReadinessCheck" in tools %}
- Use `MergeRequestReadinessCheck` to resolve current refs and authoritative review readiness before integration.
{% endif %}
{% if "MergeRequestComplete" in tools %}
- Complete integration only after readiness confirms approval for the exact current revision and all target/ref guards pass. Merge completion is separate from implementation and review evidence.
{% endif %}
+1 -1
View File
@@ -1,6 +1,6 @@
## Ticket workflow ## Ticket workflow
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and current Merge Request context before routing, review, or closure decisions. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools. Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and current Merge Request context before routing, review, or closure decisions. Current linked-MR evidence is completion authority; `implementation_report` entries are optional historical/audit context and must not be required for integration readiness. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
Read the relevant Ticket before making implementation, routing, review, state, or closure decisions. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work. Read the relevant Ticket before making implementation, routing, review, state, or closure decisions. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work.
+4 -2
View File
@@ -2,8 +2,10 @@ You are the assigned Coder. Implement the requested scope in the provided Workdi
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.
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.
{% include "common.git" %} {% include "common.git" %}
Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected capability authority. Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReview` through its injected capability authority.
A request-changes result requires a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id, the approved `Review` event id, and final target-ref evidence; the Server re-resolves selectors, revalidates assignment, and fences Ticket state side effects. A request-changes result requires a freshly published immutable subject and a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. After the exact current Merge Request subject has authoritative approval, keep that source ref immutable, leave concise implementation evidence on the Ticket when useful, and hand off integration to the Orchestrator. Do not update the target selector. Do not call `MergeRequestComplete`.
+7 -1
View File
@@ -2,10 +2,16 @@ You are the Ticket Orchestrator role.
{% include "common.git" %} {% 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. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify 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, and stop for human authority when merge/closure is not explicitly delegated. 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. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify 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.
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. 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.
Treat the current linked Merge Request as implementation-completion authority. A current provider-resolved source ref, commit/repository evidence, an effective approval for that exact subject, review freshness after the latest substantive Ticket item edit, and no unresolved request-changes are sufficient; do not require an `implementation_report`. Human summaries remain optional audit context. Recheck `ShowTicket` and `MergeRequestReadinessCheck` immediately before guarded integration. Require the Merge Request source selector to remain on the exact reviewed commit; any source movement requires a fresh Reviewer attempt.
Before integration, run `MergeRequestReadinessCheck` and reread the Ticket, current assignment, and exact approved subject. In the Orchestrator Workdir, use the Ticket repository `origin` transport to fetch the current target selector and immutable source selector, verify both against readiness evidence, apply the selected fast-forward or merge strategy, and validate the resulting tree. Push only a result that descends from the observed target, using a guarded non-force push whose expected old target is `target_ref_before`; reject target movement and conflicts rather than rewriting the remote. Verify the remote target now resolves exactly to `target_ref_after`, then call `MergeRequestComplete` with that before/after evidence and the authoritative approval event. Never mutate a Server-side repository path or use local `git update-ref` as integration authority.
If the repository push succeeds but completion recording fails, do not push again or invent a new result. Retry the same completion operation and evidence: while no completion event exists, the Server requires the target to remain at the exact `target_ref_after` before it records `MergeResult`, moves the Ticket to `done`, and closes the current assignment atomically. Once that exact operation is recorded, later target movement does not invalidate an idempotent replay of the recorded result. Before recording, any other observed target is a stale/conflicting completion and must fail closed.
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots. Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
+2 -2
View File
@@ -1,7 +1,7 @@
You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder. You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder.
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, or take over implementation. Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, update a repository ref, or take over implementation.
Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. Your prose response is not review authority. Before finishing, call `MergeRequestReview` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work.
Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary compatibility. Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary compatibility.
@@ -19,6 +19,7 @@ export type TicketListResponse = {
workspace_id: string; workspace_id: string;
limit: number; limit: number;
items: Array<TicketSummary>; items: Array<TicketSummary>;
page: QueryPage;
invalid_records: Array<InvalidProjectRecord>; invalid_records: Array<InvalidProjectRecord>;
record_authority: string; record_authority: string;
}; };
@@ -69,23 +70,27 @@ export type TicketAssignmentSummary = {
export type TicketMergeRequestSummary = { export type TicketMergeRequestSummary = {
merge_request_id: string; merge_request_id: string;
repository_id: string;
state: string; state: string;
review_status: string; review_status: string;
selector_from: string | null; selector_from: string | null;
selector_to: string; selector_to: string;
updated_at: string; updated_at: string;
current_subject_ref: string | null;
review_subject_ref: string | null; review_subject_ref: string | null;
review_requested_at: string | null;
review_submitted_at: string | null; review_submitted_at: string | null;
review_excerpt: string | null; review_excerpt: string | null;
}; };
export type TicketEvidenceSummary = { export type TicketEvidenceSummary = {
has_implementation_report: boolean;
implementation_report_after_rescope: boolean;
has_merge_request: boolean; has_merge_request: boolean;
has_current_subject_ref: boolean;
has_review_request: boolean;
has_commit: boolean; has_commit: boolean;
review_status: string | null; review_status: string | null;
approved: boolean; approved_current_subject: boolean;
review_after_rescope: boolean;
unresolved_request_changes: boolean; unresolved_request_changes: boolean;
complete_for_integration: boolean; complete_for_integration: boolean;
missing: Array<string>; missing: Array<string>;
@@ -219,18 +219,21 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
); );
assert( assert(
ticketsLoad.includes("?limit=1000") && ticketsLoad.includes("?limit=1000") &&
ticketsPage.includes("data.tickets.data?.items") && !ticketsLoad.includes("/tickets/query") &&
ticketsPage.includes("ticketLanes(tickets)") &&
ticketsPage.includes('class="ticket-kanban"') && ticketsPage.includes('class="ticket-kanban"') &&
ticketsPage.includes("lane.tickets"), ticketsPage.includes('class="ticket-lane-cards"') &&
"Tickets list should load every workflow state and render a workspace Kanban board", ticketsPage.includes("lane.tickets.slice(0, lane.visibleCount)") &&
ticketsPage.includes("handleLaneScroll") &&
ticketsPage.includes("revealNextTickets"),
"Tickets list should fetch lightweight summaries once and incrementally reveal each Kanban lane",
); );
assert( assert(
ticketPanelModel.includes('label: "Ready + Planning"') && ticketPanelModel.includes('label: "Ready + Planning"') &&
ticketPanelModel.includes('label: "In progress + Queued"') && ticketPanelModel.includes('label: "In progress + Queued"') &&
ticketPanelModel.includes('label: "Done + Closed"') && ticketPanelModel.includes('label: "Done + Closed"') &&
ticketPanelModel.includes("updatedAt(right) - updatedAt(left)"), ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") &&
"Ticket Kanban should combine related states and sort state priority before recency", ticketPanelModel.includes("nextTicketLaneVisibleCount"),
"Ticket Kanban should combine related states into independent 30-item display windows",
); );
assert( assert(
generatedTicketApi.includes("Generated from yoi-workspace-server") && generatedTicketApi.includes("Generated from yoi-workspace-server") &&
@@ -249,7 +252,9 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
ticketDetailPage.includes('mutate("state", "/state"') && ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes('mutate("queue", "/queue"') && ticketDetailPage.includes('mutate("queue", "/queue"') &&
!ticketDetailPage.includes("/merge-request/merge") && !ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("merged_result_commit") && ticketDetailPage.includes("mergeRequest.selector_from") &&
ticketDetailPage.includes("currentReview?.kind") &&
ticketDetailPage.includes("mergeEvent?.kind") &&
!ticketDetailPage.includes('mutate("review", "/review"') && !ticketDetailPage.includes('mutate("review", "/review"') &&
ticketDetailPage.includes('mutate("close", "/close"') && ticketDetailPage.includes('mutate("close", "/close"') &&
ticketDetailPage.includes("ticketWorkerLaunchHref") && ticketDetailPage.includes("ticketWorkerLaunchHref") &&
@@ -121,18 +121,25 @@
} }
.ticket-kanban { .ticket-kanban {
display: grid; display: grid;
align-items: stretch;
grid-template-columns: repeat(3, minmax(18rem, 1fr)); grid-template-columns: repeat(3, minmax(18rem, 1fr));
gap: var(--space-3); gap: var(--space-3);
overflow-x: auto; overflow-x: auto;
padding-bottom: var(--space-3); padding-bottom: var(--space-3);
} }
.ticket-lane { .ticket-lane {
display: flex;
flex-direction: column;
height: calc(100dvh - 15rem);
min-height: 24rem; min-height: 24rem;
max-height: 48rem;
overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.8rem; border-radius: 0.8rem;
background: color-mix(in srgb, var(--bg-raised) 55%, transparent); background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
} }
.ticket-lane-header { .ticket-lane-header {
flex: 0 0 auto;
justify-content: space-between; justify-content: space-between;
padding: 0.75rem; padding: 0.75rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
@@ -167,9 +174,43 @@
} }
.ticket-lane-cards { .ticket-lane-cards {
display: grid; display: grid;
flex: 1 1 auto;
align-content: start; align-content: start;
gap: 0.55rem; gap: 0.55rem;
min-height: 0;
max-height: min(68vh, 48rem);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.6rem; padding: 0.6rem;
scrollbar-gutter: stable;
}
.ticket-lane-load-status {
margin: 0;
color: var(--text-muted);
padding: 0.5rem;
font-size: 0.7rem;
text-align: center;
}
.ticket-lane-page-state {
display: flex;
justify-content: center;
gap: 0.5rem;
margin: 0;
padding: 0.45rem;
color: var(--text-muted);
font-size: 0.72rem;
text-align: center;
}
.ticket-lane-page-error {
align-items: center;
color: var(--danger);
}
.ticket-lane-page-error button {
border: 1px solid var(--line);
border-radius: 0.35rem;
background: var(--bg-raised);
color: inherit;
padding: 0.2rem 0.45rem;
} }
.ticket-card { .ticket-card {
display: grid; display: grid;
@@ -1,4 +1,6 @@
import { import {
nextTicketLaneVisibleCount,
TICKET_LANE_PAGE_SIZE,
ticketLanes, ticketLanes,
ticketWorkerLaunchHref, ticketWorkerLaunchHref,
ticketWorkerMessage, ticketWorkerMessage,
@@ -9,16 +11,9 @@ import type {
} from "../../generated/ticket-api.ts"; } from "../../generated/ticket-api.ts";
declare const Deno: { declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void; test(name: string, fn: () => void): void;
readTextFile(path: string): Promise<string>;
}; };
function assertIncludes(actual: string, expected: string): void {
if (!actual.includes(expected)) {
throw new Error(`expected source to include ${JSON.stringify(expected)}`);
}
}
function assertEquals<T>(actual: T, expected: T): void { function assertEquals<T>(actual: T, expected: T): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) { if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error( throw new Error(
@@ -79,6 +74,15 @@ Deno.test("ticketLanes combines workflow states and sorts by state then update t
]); ]);
}); });
Deno.test("ticket lane visibility advances in bounded pages of 30", () => {
assertEquals(TICKET_LANE_PAGE_SIZE, 30);
assertEquals(nextTicketLaneVisibleCount(0, 95), 30);
assertEquals(nextTicketLaneVisibleCount(30, 95), 60);
assertEquals(nextTicketLaneVisibleCount(60, 95), 90);
assertEquals(nextTicketLaneVisibleCount(90, 95), 95);
assertEquals(nextTicketLaneVisibleCount(95, 95), 95);
});
Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => { Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => {
const ticket = { const ticket = {
id: "00001KYRRDVH9", id: "00001KYRRDVH9",
@@ -104,20 +108,3 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
"Work on Ticket 00001KYRRDVH9 as its reviewer.", "Work on Ticket 00001KYRRDVH9 as its reviewer.",
); );
}); });
Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestration actions", async () => {
const panelSource = await Deno.readTextFile(
"src/routes/w/[workspaceId]/tickets/+page.svelte",
);
const detailSource = await Deno.readTextFile(
"src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
);
assertIncludes(panelSource, 'workspaceApiPath(data.workspaceId, "/orchestrator")');
assertIncludes(panelSource, '{ method: "POST" }');
assertIncludes(panelSource, "Start Orchestrator");
assertIncludes(panelSource, "orchestrator.data?.online");
assertIncludes(detailSource, "{#if orchestratorOnline}");
assertIncludes(detailSource, "!orchestratorOnline");
assertIncludes(detailSource, "Orchestrator offline");
});
@@ -47,6 +47,15 @@ const LANE_DEFINITIONS = [
}, },
] as const; ] as const;
export const TICKET_LANE_PAGE_SIZE = 30;
export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
export type TicketLaneId = TicketLaneDefinition["id"];
export type TicketCardSummary = Pick<
TicketSummary,
"id" | "title" | "state" | "priority" | "updated_at"
>;
const STATE_SORT_ORDER = new Map<string, number>([ const STATE_SORT_ORDER = new Map<string, number>([
["ready", 0], ["ready", 0],
["planning", 1], ["planning", 1],
@@ -57,19 +66,28 @@ const STATE_SORT_ORDER = new Map<string, number>([
]); ]);
export type TicketLane = { export type TicketLane = {
id: string; id: TicketLaneId;
label: string; label: string;
states: readonly TicketState[]; states: readonly TicketState[];
tickets: TicketSummary[]; tickets: TicketCardSummary[];
}; };
function updatedAt(ticket: TicketSummary): number { export function nextTicketLaneVisibleCount(
current: number,
total: number,
): number {
return Math.min(total, current + TICKET_LANE_PAGE_SIZE);
}
function updatedAt(ticket: TicketCardSummary): number {
if (!ticket.updated_at) return 0; if (!ticket.updated_at) return 0;
const parsed = Date.parse(ticket.updated_at); const parsed = Date.parse(ticket.updated_at);
return Number.isNaN(parsed) ? 0 : parsed; return Number.isNaN(parsed) ? 0 : parsed;
} }
export function sortTickets(tickets: TicketSummary[]): TicketSummary[] { export function sortTickets(
tickets: TicketCardSummary[],
): TicketCardSummary[] {
return [...tickets].sort((left, right) => { return [...tickets].sort((left, right) => {
const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) - const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) -
(STATE_SORT_ORDER.get(right.state) ?? 99); (STATE_SORT_ORDER.get(right.state) ?? 99);
@@ -80,7 +98,7 @@ export function sortTickets(tickets: TicketSummary[]): TicketSummary[] {
}); });
} }
export function ticketLanes(tickets: TicketSummary[]): TicketLane[] { export function ticketLanes(tickets: TicketCardSummary[]): TicketLane[] {
return LANE_DEFINITIONS.map((definition) => ({ return LANE_DEFINITIONS.map((definition) => ({
...definition, ...definition,
tickets: sortTickets( tickets: sortTickets(
@@ -2,30 +2,94 @@
import { untrack } from "svelte"; import { untrack } from "svelte";
import type { ApiResult } from "$lib/workspace/api/http"; import type { ApiResult } from "$lib/workspace/api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type {
QueryPage,
TicketListResponse,
TicketSummary,
} from "$lib/generated/ticket-api";
import { import {
ticketLanes, ticketLanes,
type WorkspaceOrchestratorStatus, type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel"; } from "$lib/workspace/tickets/ticket-panel";
import type { import type { PageData } from "./$types";
TicketListResponse,
TicketSummary,
} from "$lib/workspace/sidebar/types";
const { data } = $props<{ type LaneState = {
data: { states: string[];
workspaceId: string; tickets: TicketSummary[];
tickets: ApiResult<TicketListResponse>; page: QueryPage;
orchestrator: ApiResult<WorkspaceOrchestratorStatus>; loading: boolean;
error: string | null;
}; };
}>();
const initialTickets = untrack(() => data.tickets.data?.items ?? []); let { data }: { data: PageData } = $props();
let tickets = $state<TicketSummary[]>(initialTickets); // svelte-ignore state_referenced_locally
let laneState = $state<Record<string, LaneState>>(
Object.fromEntries(
Object.entries(data.ticketLanes).map(([laneId, lane]) => [
laneId,
{
states: [...lane.states],
tickets: lane.response.items,
page: lane.response.page,
loading: false,
error: null,
},
]),
),
);
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>( let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator), untrack(() => data.orchestrator),
); );
let orchestratorStarting = $state(false); let orchestratorStarting = $state(false);
let lanes = $derived(ticketLanes(tickets)); const tickets = $derived(
Object.values(laneState).flatMap((lane) => lane.tickets),
);
const lanes = $derived(ticketLanes(tickets));
function mergeTickets(
current: TicketSummary[],
incoming: TicketSummary[],
): TicketSummary[] {
const byId = new Map(current.map((ticket) => [ticket.id, ticket]));
for (const ticket of incoming) byId.set(ticket.id, ticket);
return [...byId.values()];
}
async function loadMore(laneId: string): Promise<void> {
const lane = laneState[laneId];
if (!lane || lane.loading || !lane.page.has_more || !lane.page.next_cursor) {
return;
}
lane.loading = true;
lane.error = null;
try {
const search = new URLSearchParams({
limit: "30",
states: lane.states.join(","),
cursor: lane.page.next_cursor,
});
const response = await fetch(
`/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`,
);
if (!response.ok) {
throw new Error(`追加読み込みに失敗しました (${response.status})`);
}
const page = (await response.json()) as TicketListResponse;
lane.tickets = mergeTickets(lane.tickets, page.items);
lane.page = page.page;
} catch (error) {
lane.error = error instanceof Error ? error.message : String(error);
} finally {
lane.loading = false;
}
}
function handleLaneScroll(event: Event, laneId: string): void {
const container = event.currentTarget as HTMLElement;
const remaining =
container.scrollHeight - container.scrollTop - container.clientHeight;
if (remaining <= 96) void loadMore(laneId);
}
async function startOrchestrator() { async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return; if (orchestratorStarting || orchestrator.data?.online) return;
@@ -45,7 +109,9 @@
} }
</script> </script>
<svelte:head><title>Tickets · Yoi</title></svelte:head> <svelte:head>
<title>Tickets · {data.workspaceId}</title>
</svelte:head>
<div class="workspace-page ticket-panel-page"> <div class="workspace-page ticket-panel-page">
<header class="workspace-page-header ticket-panel-header"> <header class="workspace-page-header ticket-panel-header">
@@ -76,7 +142,7 @@
</div> </div>
<div class="ticket-panel-summary" aria-label="Ticket summary"> <div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{tickets.length}</strong> <strong>{tickets.length}</strong>
<span>tickets</span> <span>loaded tickets</span>
</div> </div>
</div> </div>
</header> </header>
@@ -93,6 +159,7 @@
<section class="ticket-kanban" aria-label="Ticket workflow board"> <section class="ticket-kanban" aria-label="Ticket workflow board">
{#each lanes as lane (lane.id)} {#each lanes as lane (lane.id)}
{@const pagination = laneState[lane.id]}
<section class="ticket-lane" data-state={lane.id}> <section class="ticket-lane" data-state={lane.id}>
<header class="ticket-lane-header"> <header class="ticket-lane-header">
<div> <div>
@@ -101,8 +168,11 @@
</div> </div>
<span class="ticket-lane-count">{lane.tickets.length}</span> <span class="ticket-lane-count">{lane.tickets.length}</span>
</header> </header>
<div
<div class="ticket-lane-cards"> class="ticket-lane-cards"
data-lane-id={lane.id}
onscroll={(event) => handleLaneScroll(event, lane.id)}
>
{#each lane.tickets as ticket (ticket.id)} {#each lane.tickets as ticket (ticket.id)}
<a <a
class="ticket-card" class="ticket-card"
@@ -118,6 +188,16 @@
{:else} {:else}
<div class="ticket-lane-empty">No tickets</div> <div class="ticket-lane-empty">No tickets</div>
{/each} {/each}
{#if pagination?.loading}
<p class="ticket-lane-page-state" aria-live="polite">Loading…</p>
{:else if pagination?.error}
<div class="ticket-lane-page-state ticket-lane-page-error" role="alert">
<span>{pagination.error}</span>
<button type="button" onclick={() => loadMore(lane.id)}>Retry</button>
</div>
{:else if pagination && !pagination.page.has_more && lane.tickets.length > 0}
<p class="ticket-lane-page-state">End of lane</p>
{/if}
</div> </div>
</section> </section>
{/each} {/each}
@@ -1,23 +1,56 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { TicketListResponse } from "$lib/generated/ticket-api";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel"; import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => { const LANE_STATES = {
const [tickets, orchestrator] = await Promise.all([ "ready-planning": ["ready", "planning"],
loadJson<TicketListResponse>( "inprogress-queued": ["inprogress", "queued"],
fetch, "done-closed": ["done", "closed"],
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`, } as const;
export type TicketLaneId = keyof typeof LANE_STATES;
export type TicketLanePage = {
states: readonly string[];
response: TicketListResponse;
};
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const [entries, orchestrator] = await Promise.all([
Promise.all(
Object.entries(LANE_STATES).map(async ([laneId, states]) => {
const search = new URLSearchParams({
limit: "30",
states: states.join(","),
});
const response = await fetch(
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
);
if (!response.ok) {
throw new Error(
`failed to load ${laneId} Ticket lane (${response.status})`,
);
}
return [
laneId,
{ states: [...states], response: await response.json() },
] as const;
}),
), ),
loadJson<WorkspaceOrchestratorStatus>( loadJson<WorkspaceOrchestratorStatus>(
fetch, fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"), workspaceApiPath(workspaceId, "/orchestrator"),
), ),
]); ]);
return { return {
workspaceId: params.workspaceId, workspaceId,
tickets, ticketLanes: Object.fromEntries(entries) as unknown as Record<
TicketLaneId,
TicketLanePage
>,
orchestrator, orchestrator,
}; };
}) satisfies PageLoad; };
@@ -14,6 +14,7 @@
import type { ApiResult } from "$lib/workspace/api/http"; import type { ApiResult } from "$lib/workspace/api/http";
import type { import type {
RepositoryListResponse, RepositoryListResponse,
RepositorySummary,
TicketDetail, TicketDetail,
} from "$lib/workspace/sidebar/types"; } from "$lib/workspace/sidebar/types";
@@ -65,7 +66,9 @@
thread: MergeRequestThreadEvent[]; thread: MergeRequestThreadEvent[];
}; };
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done"); const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
state !== "done" && state !== "ready" && state !== "queued"
);
const { data } = $props<{ const { data } = $props<{
data: { data: {
@@ -115,6 +118,27 @@
let resolution = $state(""); let resolution = $state("");
let busy = $state<string | null>(null); let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null); let errorMessage = $state<string | null>(null);
let readyOperationKey = $state<string | null>(null);
const selectedRepository = $derived(
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? 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_id !== null &&
ticket.ref_selector !== null &&
(loadedRepositories?.items ?? []).some((repository: RepositorySummary) =>
repository.id === ticket.repository_id && (repository.diagnostics ?? []).length === 0
),
);
const implementationStartEligible = $derived(
persistedTargetValid && ticket.state !== "planning" && ticket.state !== "closed",
);
const ticketPath = $derived( const ticketPath = $derived(
workspaceApiPath( workspaceApiPath(
@@ -180,6 +204,33 @@
}, "PATCH"); }, "PATCH");
} }
async function markReady() {
if (!targetCandidateValid || busy) return;
if (
ticket.repository_id !== repositoryId ||
(ticket.ref_selector ?? "") !== refSelector.trim()
) {
const saved = await mutate("target", "", {
target: {
action: "set",
repository_id: repositoryId,
ref_selector: refSelector.trim() || null,
},
}, "PATCH");
if (!saved) return;
}
readyOperationKey ??= crypto.randomUUID();
if (
await mutate("ready", "/ready", {
operation_key: readyOperationKey,
reason: transitionReason.trim() || null,
})
) {
readyOperationKey = null;
transitionReason = "";
}
}
async function transition(event: SubmitEvent) { async function transition(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if ( if (
@@ -329,16 +380,19 @@
<p class="ticket-assignment-line"> <p class="ticket-assignment-line">
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong> Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
</p> </p>
{#if orchestratorOnline} {#if orchestratorOnline && implementationStartEligible}
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p> <p>The Orchestrator is online. Start a role-specific Worker with the validated Ticket target below.</p>
<div class="ticket-role-actions"> <div class="ticket-role-actions">
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a> <a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
</div> </div>
{:else} {:else}
<p class="workspace-callout">Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers.</p> <p class="workspace-callout">
{orchestratorOnline
? "Validate and persist the repository target before starting a Ticket Worker."
: "Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers."}
</p>
<div class="ticket-role-actions"> <div class="ticket-role-actions">
<button class="workspace-primary-button" type="button" disabled>Coder</button> <button class="workspace-primary-button" type="button" disabled>Coder</button>
<button class="workspace-secondary-button" type="button" disabled>Reviewer</button>
</div> </div>
{/if} {/if}
</section> </section>
@@ -347,15 +401,15 @@
<header><h2>Repository target</h2></header> <header><h2>Repository target</h2></header>
<form class="ticket-control-form" onsubmit={saveTarget}> <form class="ticket-control-form" onsubmit={saveTarget}>
<label>Repository <label>Repository
<select bind:value={repositoryId}> <select bind:value={repositoryId} disabled={ticket.state !== "planning"}>
<option value="">Not assigned</option> <option value="">Not assigned</option>
{#each loadedRepositories?.items ?? [] as repository} {#each loadedRepositories?.items ?? [] as repository}
<option value={repository.id}>{repository.display_name}</option> <option value={repository.id}>{repository.display_name}</option>
{/each} {/each}
</select> </select>
</label> </label>
<label>Ref selector<input bind:value={refSelector} placeholder="branch, tag, or revision" /></label> <label>Ref selector<input bind:value={refSelector} placeholder={selectedRepository?.default_ref ?? "branch, tag, or revision"} disabled={ticket.state !== "planning"} /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "target"}> <button class="workspace-secondary-button" type="submit" disabled={busy === "target" || ticket.state !== "planning"}>
{busy === "target" ? "Saving…" : "Save target"} {busy === "target" ? "Saving…" : "Save target"}
</button> </button>
</form> </form>
@@ -374,8 +428,15 @@
Apply state Apply state
</button> </button>
</form> </form>
{#if ticket.state === "ready"} {#if ticket.state === "planning"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline} onclick={() => mutate("queue", "/queue", {})}> <button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy !== null || !targetCandidateValid} onclick={markReady}>
{busy === "ready" ? "Marking ready…" : "Mark ready"}
</button>
{#if !targetCandidateValid}
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
{/if}
{:else if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline || !persistedTargetValid} onclick={() => mutate("queue", "/queue", {})}>
{busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"} {busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"}
</button> </button>
{/if} {/if}
+7 -1
View File
@@ -6,7 +6,13 @@ export default defineConfig({
server: { server: {
allowedHosts: ["develop.hareworks.net"], allowedHosts: ["develop.hareworks.net"],
watch: { ignored: ["**/.tmp*", "**/.tmp*/**"] }, watch: {
ignored: [
"**/.tmp*",
"**/.tmp*/**",
"**/vite.config.*.timestamp-*.mjs",
],
},
proxy: { proxy: {
"/api": { "/api": {