Compare commits
33
Commits
c292c01da3
...
f9d328f2db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9d328f2db | ||
|
|
e47eca53a2 | ||
|
|
8d061a8734 | ||
|
|
9c8ab6441c | ||
|
|
dfede080d2 | ||
|
|
cf32d871de | ||
|
|
fe373b5656 | ||
|
|
a44a4bc4f8 | ||
|
|
cb683beef8 | ||
|
|
1d4ffa875a | ||
|
|
297a7ddd9d | ||
|
|
be698c2239 | ||
|
|
e80581d139 | ||
|
|
4e7eaac7d5 | ||
|
|
92f7f3fca3 | ||
|
|
1e10cdecc8 | ||
|
|
ce6b8f65cc | ||
|
|
f60c2d5834 | ||
|
|
795f26fb51 | ||
|
|
8ae930c5fc | ||
|
|
ebe0f93744 | ||
|
|
8cc0aaf8d2 | ||
|
|
86be3a6865 | ||
|
|
5e5ce73fd0 | ||
|
|
01971807cb | ||
|
|
da8313fa1a | ||
|
|
09fd17e38b | ||
|
|
34f8949e85 | ||
|
|
33d3d1a43f | ||
|
|
1814c35701 | ||
|
|
6df5fe5b2a | ||
|
|
7f0d025312 | ||
|
|
9dccf99c50 |
@@ -76,7 +76,7 @@ Intake は以下を行う。
|
|||||||
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
|
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
|
||||||
- `TicketDoctor`: 必要に応じた整合性確認。
|
- `TicketDoctor`: 必要に応じた整合性確認。
|
||||||
|
|
||||||
Intake は `TicketReview`, `TicketWorkflowState`, `TicketClose` を通常使わない。review / state transition / close は Orchestrator または reviewer / maintainer workflow の責務である。
|
Intake は `MergeRequest*`, `TicketWorkflowState`, `TicketClose` を通常使わない。review authority は assigned Coder が起動した read-only direct-child Reviewer の immutable Merge Request attempt に属し、completion / merge / close は各guarded workflowの責務である。
|
||||||
|
|
||||||
Ticket tools が利用できない環境では、勝手に file write で代替しない。ユーザーまたは Orchestrator に「Ticket tools がないため materialize できない」と報告し、必要なら `yoi ticket` を使える人間/親 workflow に戻す。
|
Ticket tools が利用できない環境では、勝手に file write で代替しない。ユーザーまたは Orchestrator に「Ticket tools がないため materialize できない」と報告し、必要なら `yoi ticket` を使える人間/親 workflow に戻す。
|
||||||
|
|
||||||
|
|||||||
@@ -19,23 +19,6 @@ Workerの状態から純粋に再現可能で、且つ揮発性の無い操作
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 実際のセッションを読んでデバッグする
|
|
||||||
|
|
||||||
`~/.yoi/sessions`にすべてのセッションがある。jsonlなので、いい感じにBashで読むこと。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git操作
|
|
||||||
|
|
||||||
明示的に指示されない限り、読み取り以外の操作は控えること。
|
|
||||||
基本はworktree上の一時的なブランチでコミットを重ね、メインブランチに取り込む運用をしている。
|
|
||||||
Orchestrator の cwd が orchestration 用ブランチ/worktree の場合、通常作業では親ブランチの dirty state を気にしない。
|
|
||||||
コミットメッセージは適当に`<prefix>: *簡潔な1行*`で書いている。
|
|
||||||
|
|
||||||
外部の参考プロジェクトは必要に応じてローカルの外部 checkout からReadすること。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 検証
|
## 検証
|
||||||
|
|
||||||
開発中は、変更した契約を証明する最小の target / filter から実行する。
|
開発中は、変更した契約を証明する最小の target / filter から実行する。
|
||||||
@@ -61,7 +44,6 @@ workspace全体、E2E、Nix/Docker buildなどの重い検証は、変更した
|
|||||||
|
|
||||||
Yoi Worker で作業する場合、Ticket の authority・ライフサイクル・操作方法は Yoi
|
Yoi Worker で作業する場合、Ticket の authority・ライフサイクル・操作方法は Yoi
|
||||||
system instructions と、その Worker に提供された typed Ticket tools に従うこと。
|
system instructions と、その Worker に提供された typed Ticket tools に従うこと。
|
||||||
backend や CLI の具体的な手順はこのリポジトリの `AGENTS.md` では重複して定義しない。
|
|
||||||
|
|
||||||
Codex など typed Ticket tools が提供されていないクライアントでは、`yoi ticket`
|
Codex など typed Ticket tools が提供されていないクライアントでは、`yoi ticket`
|
||||||
CLI や保存先の直接操作で Ticket tools を代替しないこと。Ticket の作成・更新は
|
CLI や保存先の直接操作で Ticket tools を代替しないこと。Ticket の作成・更新は
|
||||||
@@ -70,3 +52,8 @@ Yoi Worker に委ねる。
|
|||||||
---
|
---
|
||||||
|
|
||||||
YoiでYoiを開発している際、AI自身のフィードバックを元に改善を回すために `docs/report/`ディレクトリに感じた障壁や改善案等を書き残す形にした。 明確に力不足な点/ツールの問題があった場合や、ユーザーからの指示があった際に作ること。
|
YoiでYoiを開発している際、AI自身のフィードバックを元に改善を回すために `docs/report/`ディレクトリに感じた障壁や改善案等を書き残す形にした。 明確に力不足な点/ツールの問題があった場合や、ユーザーからの指示があった際に作ること。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
絶対に自身が動作しているプロセスを止めないこと。
|
||||||
|
マージ後のドッグフーディング環境の更新は必ずユーザーの操作で行う。
|
||||||
|
|||||||
Generated
+13
@@ -2481,6 +2481,17 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "merge-request"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -6045,6 +6056,7 @@ dependencies = [
|
|||||||
"ring",
|
"ring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serial_test",
|
||||||
"session-store",
|
"session-store",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"tar",
|
"tar",
|
||||||
@@ -6164,6 +6176,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"manifest",
|
"manifest",
|
||||||
"memory",
|
"memory",
|
||||||
|
"merge-request",
|
||||||
"project-record",
|
"project-record",
|
||||||
"protocol",
|
"protocol",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ members = [
|
|||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
"tests/e2e",
|
"tests/e2e",
|
||||||
@@ -50,6 +51,7 @@ default-members = [
|
|||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
]
|
]
|
||||||
@@ -67,6 +69,7 @@ manifest = { path = "crates/manifest" }
|
|||||||
mcp = { path = "crates/mcp" }
|
mcp = { path = "crates/mcp" }
|
||||||
lint-common = { path = "crates/lint-common" }
|
lint-common = { path = "crates/lint-common" }
|
||||||
memory = { path = "crates/memory" }
|
memory = { path = "crates/memory" }
|
||||||
|
merge-request = { path = "crates/merge-request" }
|
||||||
ticket = { path = "crates/ticket" }
|
ticket = { path = "crates/ticket" }
|
||||||
project-record = { path = "crates/project-record" }
|
project-record = { path = "crates/project-record" }
|
||||||
worker = { path = "crates/worker" }
|
worker = { path = "crates/worker" }
|
||||||
|
|||||||
@@ -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: 1,
|
revision: 2,
|
||||||
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: 1,
|
revision: 2,
|
||||||
path: "builtin/flows/coder-review.dcdl",
|
path: "builtin/flows/coder-review.dcdl",
|
||||||
content: CODER_REVIEW_FLOW_SOURCE,
|
content: CODER_REVIEW_FLOW_SOURCE,
|
||||||
}];
|
}];
|
||||||
@@ -61,9 +61,32 @@ mod tests {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
assert_eq!(definition.name, source.slug);
|
assert_eq!(definition.name, source.slug);
|
||||||
assert_eq!(
|
let selected =
|
||||||
builtin_flow_source(source.slug).map(|item| item.content),
|
builtin_flow_source(source.slug).expect("builtin Flow must be selectable");
|
||||||
Some(source.content)
|
assert_eq!(selected.content, source.content);
|
||||||
|
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:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use crate::{
|
|||||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, ScopeConfig,
|
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, ScopeConfig,
|
||||||
SessionConfig, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig,
|
SessionConfig, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig,
|
||||||
ToolPermissionRule, WebConfig, WorkerManifest, WorkerMeta,
|
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
|
||||||
@@ -89,7 +89,7 @@ pub struct FeatureConfigPartial {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub flow: Option<FeatureFlagConfigPartial>,
|
pub flow: Option<FeatureFlagConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub worker: Option<FeatureFlagConfigPartial>,
|
pub worker: Option<WorkerFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub objective: Option<FeatureFlagConfigPartial>,
|
pub objective: Option<FeatureFlagConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -97,6 +97,8 @@ pub struct FeatureConfigPartial {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub orchestration: Option<FeatureFlagConfigPartial>,
|
||||||
|
#[serde(default)]
|
||||||
pub plugins: Option<FeatureFlagConfigPartial>,
|
pub plugins: Option<FeatureFlagConfigPartial>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +115,7 @@ impl FeatureConfigPartial {
|
|||||||
FeatureFlagConfigPartial::merge,
|
FeatureFlagConfigPartial::merge,
|
||||||
),
|
),
|
||||||
flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge),
|
flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge),
|
||||||
worker: merge_option(self.worker, other.worker, FeatureFlagConfigPartial::merge),
|
worker: merge_option(self.worker, other.worker, WorkerFeatureConfigPartial::merge),
|
||||||
objective: merge_option(
|
objective: merge_option(
|
||||||
self.objective,
|
self.objective,
|
||||||
other.objective,
|
other.objective,
|
||||||
@@ -125,6 +127,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),
|
||||||
|
orchestration: merge_option(
|
||||||
|
self.orchestration,
|
||||||
|
other.orchestration,
|
||||||
|
FeatureFlagConfigPartial::merge,
|
||||||
|
),
|
||||||
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +151,32 @@ impl FeatureFlagConfigPartial {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct WorkerFeatureConfigPartial {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub direct_spawn: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerFeatureConfigPartial {
|
||||||
|
fn merge(self, other: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: other.enabled.or(self.enabled),
|
||||||
|
direct_spawn: other.direct_spawn.or(self.direct_spawn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<WorkerFeatureConfigPartial> for WorkerFeatureConfig {
|
||||||
|
fn from(value: WorkerFeatureConfigPartial) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: value.enabled.unwrap_or(false),
|
||||||
|
direct_spawn: value.direct_spawn.unwrap_or(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct MemoryFeatureConfigPartial {
|
pub struct MemoryFeatureConfigPartial {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -168,7 +201,7 @@ pub struct TicketFeatureConfigPartial {
|
|||||||
pub authoring: Option<bool>,
|
pub authoring: Option<bool>,
|
||||||
pub thread: Option<bool>,
|
pub thread: Option<bool>,
|
||||||
pub intake: Option<bool>,
|
pub intake: Option<bool>,
|
||||||
pub orchestration_control: Option<bool>,
|
pub workflow: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketFeatureConfigPartial {
|
impl TicketFeatureConfigPartial {
|
||||||
@@ -178,7 +211,7 @@ impl TicketFeatureConfigPartial {
|
|||||||
authoring: other.authoring.or(self.authoring),
|
authoring: other.authoring.or(self.authoring),
|
||||||
thread: other.thread.or(self.thread),
|
thread: other.thread.or(self.thread),
|
||||||
intake: other.intake.or(self.intake),
|
intake: other.intake.or(self.intake),
|
||||||
orchestration_control: other.orchestration_control.or(self.orchestration_control),
|
workflow: other.workflow.or(self.workflow),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +233,7 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
|||||||
flow: value.flow.map(FeatureFlagConfig::from).unwrap_or_default(),
|
flow: value.flow.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
worker: value
|
worker: value
|
||||||
.worker
|
.worker
|
||||||
.map(FeatureFlagConfig::from)
|
.map(WorkerFeatureConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
objective: value
|
objective: value
|
||||||
.objective
|
.objective
|
||||||
@@ -214,6 +247,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
|||||||
.ticket
|
.ticket
|
||||||
.map(TicketFeatureConfig::from)
|
.map(TicketFeatureConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
orchestration: value
|
||||||
|
.orchestration
|
||||||
|
.map(FeatureFlagConfig::from)
|
||||||
|
.unwrap_or_default(),
|
||||||
plugins: value
|
plugins: value
|
||||||
.plugins
|
.plugins
|
||||||
.map(FeatureFlagConfig::from)
|
.map(FeatureFlagConfig::from)
|
||||||
@@ -238,6 +275,15 @@ impl From<FeatureFlagConfig> for FeatureFlagConfigPartial {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<WorkerFeatureConfig> for WorkerFeatureConfigPartial {
|
||||||
|
fn from(value: WorkerFeatureConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: Some(value.enabled),
|
||||||
|
direct_spawn: Some(value.direct_spawn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig {
|
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig {
|
||||||
fn from(value: MemoryFeatureConfigPartial) -> Self {
|
fn from(value: MemoryFeatureConfigPartial) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -263,7 +309,7 @@ impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
|
|||||||
authoring: value.authoring.unwrap_or_default(),
|
authoring: value.authoring.unwrap_or_default(),
|
||||||
thread: value.thread.unwrap_or_default(),
|
thread: value.thread.unwrap_or_default(),
|
||||||
intake: value.intake.unwrap_or_default(),
|
intake: value.intake.unwrap_or_default(),
|
||||||
orchestration_control: value.orchestration_control.unwrap_or_default(),
|
workflow: value.workflow.unwrap_or_default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +321,7 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
|
|||||||
authoring: Some(value.authoring),
|
authoring: Some(value.authoring),
|
||||||
thread: Some(value.thread),
|
thread: Some(value.thread),
|
||||||
intake: Some(value.intake),
|
intake: Some(value.intake),
|
||||||
orchestration_control: Some(value.orchestration_control),
|
workflow: Some(value.workflow),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,6 +339,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()),
|
||||||
|
orchestration: Some(value.orchestration.into()),
|
||||||
plugins: Some(value.plugins.into()),
|
plugins: Some(value.plugins.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1866,7 +1913,10 @@ enabled = true
|
|||||||
authoring = false
|
authoring = false
|
||||||
thread = false
|
thread = false
|
||||||
intake = false
|
intake = false
|
||||||
orchestration_control = false
|
workflow = false
|
||||||
|
|
||||||
|
[feature.orchestration]
|
||||||
|
enabled = false
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1900,7 +1950,8 @@ orchestration_control = false
|
|||||||
assert!(!manifest.feature.ticket.authoring);
|
assert!(!manifest.feature.ticket.authoring);
|
||||||
assert!(!manifest.feature.ticket.thread);
|
assert!(!manifest.feature.ticket.thread);
|
||||||
assert!(!manifest.feature.ticket.intake);
|
assert!(!manifest.feature.ticket.intake);
|
||||||
assert!(!manifest.feature.ticket.orchestration_control);
|
assert!(!manifest.feature.ticket.workflow);
|
||||||
|
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);
|
||||||
assert!(!manifest.feature.objective.enabled);
|
assert!(!manifest.feature.objective.enabled);
|
||||||
@@ -1921,7 +1972,10 @@ enabled = true
|
|||||||
authoring = false
|
authoring = false
|
||||||
thread = false
|
thread = false
|
||||||
intake = false
|
intake = false
|
||||||
orchestration_control = false
|
workflow = false
|
||||||
|
|
||||||
|
[feature.orchestration]
|
||||||
|
enabled = false
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1929,7 +1983,10 @@ orchestration_control = false
|
|||||||
r#"
|
r#"
|
||||||
[feature.ticket]
|
[feature.ticket]
|
||||||
thread = true
|
thread = true
|
||||||
orchestration_control = true
|
workflow = true
|
||||||
|
|
||||||
|
[feature.orchestration]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
[feature.memory]
|
[feature.memory]
|
||||||
staging = true
|
staging = true
|
||||||
@@ -1977,7 +2034,8 @@ enabled = true
|
|||||||
assert!(!manifest.feature.ticket.authoring);
|
assert!(!manifest.feature.ticket.authoring);
|
||||||
assert!(manifest.feature.ticket.thread);
|
assert!(manifest.feature.ticket.thread);
|
||||||
assert!(!manifest.feature.ticket.intake);
|
assert!(!manifest.feature.ticket.intake);
|
||||||
assert!(manifest.feature.ticket.orchestration_control);
|
assert!(manifest.feature.ticket.workflow);
|
||||||
|
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);
|
||||||
assert!(!manifest.feature.sub_worker.enabled);
|
assert!(!manifest.feature.sub_worker.enabled);
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ pub struct FeatureConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub flow: FeatureFlagConfig,
|
pub flow: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub worker: FeatureFlagConfig,
|
pub worker: WorkerFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub objective: FeatureFlagConfig,
|
pub objective: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -125,6 +125,8 @@ pub struct FeatureConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ticket: TicketFeatureConfig,
|
pub ticket: TicketFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub orchestration: FeatureFlagConfig,
|
||||||
|
#[serde(default)]
|
||||||
pub plugins: FeatureFlagConfig,
|
pub plugins: FeatureFlagConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +139,11 @@ impl Default for FeatureConfig {
|
|||||||
image: FeatureFlagConfig::disabled(),
|
image: FeatureFlagConfig::disabled(),
|
||||||
sub_worker: FeatureFlagConfig::disabled(),
|
sub_worker: FeatureFlagConfig::disabled(),
|
||||||
flow: FeatureFlagConfig::disabled(),
|
flow: FeatureFlagConfig::disabled(),
|
||||||
worker: FeatureFlagConfig::disabled(),
|
worker: WorkerFeatureConfig::disabled(),
|
||||||
objective: FeatureFlagConfig::disabled(),
|
objective: FeatureFlagConfig::disabled(),
|
||||||
manage_workdir: FeatureFlagConfig::disabled(),
|
manage_workdir: FeatureFlagConfig::disabled(),
|
||||||
ticket: TicketFeatureConfig::default(),
|
ticket: TicketFeatureConfig::default(),
|
||||||
|
orchestration: FeatureFlagConfig::disabled(),
|
||||||
plugins: FeatureFlagConfig::disabled(),
|
plugins: FeatureFlagConfig::disabled(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +171,42 @@ impl Default for FeatureFlagConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerFeatureConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Exposes the generic WorkerSpawn tool. Stateful lifecycle services remain
|
||||||
|
/// available to dependent semantic features when this is false.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub direct_spawn: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerFeatureConfig {
|
||||||
|
pub const fn disabled() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
direct_spawn: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn enabled() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
direct_spawn: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WorkerFeatureConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::disabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct MemoryFeatureConfig {
|
pub struct MemoryFeatureConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -210,7 +249,7 @@ pub struct TicketFeatureConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub intake: bool,
|
pub intake: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub orchestration_control: bool,
|
pub workflow: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
||||||
|
|||||||
@@ -1002,16 +1002,19 @@ fn apply_role_profile(
|
|||||||
value["feature"]["image"] = serde_json::json!({ "enabled": true });
|
value["feature"]["image"] = serde_json::json!({ "enabled": true });
|
||||||
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
||||||
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
||||||
value["feature"]["worker"] =
|
value["feature"]["worker"] = serde_json::json!({
|
||||||
serde_json::json!({ "enabled": matches!(slug, "companion" | "orchestrator") });
|
"enabled": matches!(slug, "companion" | "orchestrator"),
|
||||||
|
"direct_spawn": slug != "orchestrator"
|
||||||
|
});
|
||||||
value["feature"]["manage_workdir"] = serde_json::json!({ "enabled": slug == "orchestrator" });
|
value["feature"]["manage_workdir"] = serde_json::json!({ "enabled": slug == "orchestrator" });
|
||||||
|
value["feature"]["orchestration"] = serde_json::json!({ "enabled": slug == "orchestrator" });
|
||||||
let ticket = match slug {
|
let ticket = match slug {
|
||||||
"companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
"companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
||||||
"intake" => {
|
"intake" => {
|
||||||
serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true })
|
serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true })
|
||||||
}
|
}
|
||||||
"orchestrator" => {
|
"orchestrator" => {
|
||||||
serde_json::json!({ "enabled": true, "thread": true, "orchestration_control": true })
|
serde_json::json!({ "enabled": true, "thread": true, "workflow": true })
|
||||||
}
|
}
|
||||||
"coder" => serde_json::json!({ "enabled": true, "thread": true }),
|
"coder" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||||
"reviewer" => serde_json::json!({ "enabled": true, "thread": true }),
|
"reviewer" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||||
@@ -1474,7 +1477,7 @@ mod tests {
|
|||||||
assert!(companion.feature.ticket.thread);
|
assert!(companion.feature.ticket.thread);
|
||||||
assert!(companion.feature.objective.enabled);
|
assert!(companion.feature.objective.enabled);
|
||||||
assert!(!companion.feature.ticket.intake);
|
assert!(!companion.feature.ticket.intake);
|
||||||
assert!(!companion.feature.ticket.orchestration_control);
|
assert!(!companion.feature.orchestration.enabled);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
companion.compaction.as_ref().unwrap().threshold,
|
companion.compaction.as_ref().unwrap().threshold,
|
||||||
Some(240000)
|
Some(240000)
|
||||||
@@ -1503,7 +1506,7 @@ mod tests {
|
|||||||
assert!(intake.feature.objective.enabled);
|
assert!(intake.feature.objective.enabled);
|
||||||
assert!(!intake.feature.manage_workdir.enabled);
|
assert!(!intake.feature.manage_workdir.enabled);
|
||||||
assert!(intake.feature.ticket.intake);
|
assert!(intake.feature.ticket.intake);
|
||||||
assert!(!intake.feature.ticket.orchestration_control);
|
assert!(!intake.feature.orchestration.enabled);
|
||||||
assert!(intake.scope.allow.is_empty());
|
assert!(intake.scope.allow.is_empty());
|
||||||
assert!(intake.delegation_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_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||||
@@ -1514,6 +1517,7 @@ mod tests {
|
|||||||
assert!(orchestrator.feature.task.enabled);
|
assert!(orchestrator.feature.task.enabled);
|
||||||
assert!(!orchestrator.feature.sub_worker.enabled);
|
assert!(!orchestrator.feature.sub_worker.enabled);
|
||||||
assert!(orchestrator.feature.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.enabled);
|
assert!(orchestrator.feature.ticket.enabled);
|
||||||
assert!(!orchestrator.feature.ticket.authoring);
|
assert!(!orchestrator.feature.ticket.authoring);
|
||||||
@@ -1521,7 +1525,8 @@ mod tests {
|
|||||||
assert!(orchestrator.feature.objective.enabled);
|
assert!(orchestrator.feature.objective.enabled);
|
||||||
assert!(orchestrator.feature.manage_workdir.enabled);
|
assert!(orchestrator.feature.manage_workdir.enabled);
|
||||||
assert!(!orchestrator.feature.ticket.intake);
|
assert!(!orchestrator.feature.ticket.intake);
|
||||||
assert!(orchestrator.feature.ticket.orchestration_control);
|
assert!(orchestrator.feature.ticket.workflow);
|
||||||
|
assert!(orchestrator.feature.orchestration.enabled);
|
||||||
assert!(orchestrator.scope.allow.is_empty());
|
assert!(orchestrator.scope.allow.is_empty());
|
||||||
assert!(orchestrator.delegation_scope.allow.is_empty());
|
assert!(orchestrator.delegation_scope.allow.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1548,7 +1553,7 @@ mod tests {
|
|||||||
assert!(coder.feature.objective.enabled);
|
assert!(coder.feature.objective.enabled);
|
||||||
assert!(!coder.feature.manage_workdir.enabled);
|
assert!(!coder.feature.manage_workdir.enabled);
|
||||||
assert!(!coder.feature.ticket.intake);
|
assert!(!coder.feature.ticket.intake);
|
||||||
assert!(!coder.feature.ticket.orchestration_control);
|
assert!(!coder.feature.orchestration.enabled);
|
||||||
let reviewer = resolve("reviewer");
|
let reviewer = resolve("reviewer");
|
||||||
assert!(reviewer.feature.task.enabled);
|
assert!(reviewer.feature.task.enabled);
|
||||||
assert!(!reviewer.feature.sub_worker.enabled);
|
assert!(!reviewer.feature.sub_worker.enabled);
|
||||||
@@ -1561,7 +1566,7 @@ mod tests {
|
|||||||
assert!(reviewer.feature.objective.enabled);
|
assert!(reviewer.feature.objective.enabled);
|
||||||
assert!(!reviewer.feature.manage_workdir.enabled);
|
assert!(!reviewer.feature.manage_workdir.enabled);
|
||||||
assert!(!reviewer.feature.ticket.intake);
|
assert!(!reviewer.feature.ticket.intake);
|
||||||
assert!(!reviewer.feature.ticket.orchestration_control);
|
assert!(!reviewer.feature.orchestration.enabled);
|
||||||
assert!(reviewer.scope.allow.is_empty());
|
assert!(reviewer.scope.allow.is_empty());
|
||||||
assert!(reviewer.delegation_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_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||||
@@ -1574,6 +1579,9 @@ mod tests {
|
|||||||
let prompt = include_str!("../../../resources/prompts/role/orchestrator.md");
|
let prompt = include_str!("../../../resources/prompts/role/orchestrator.md");
|
||||||
|
|
||||||
assert!(prompt.contains("assigned Coder owns its review/fix loop"));
|
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(
|
assert!(prompt.contains(
|
||||||
"Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers"
|
"Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers"
|
||||||
));
|
));
|
||||||
@@ -1725,7 +1733,8 @@ enabled = true
|
|||||||
authoring = false
|
authoring = false
|
||||||
thread = false
|
thread = false
|
||||||
intake = false
|
intake = false
|
||||||
orchestration_control = false
|
[feature.orchestration]
|
||||||
|
enabled = false
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let workspace = tmp.path().join("workspace");
|
let workspace = tmp.path().join("workspace");
|
||||||
@@ -1746,7 +1755,7 @@ orchestration_control = 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!(!resolved.manifest.feature.ticket.orchestration_control);
|
assert!(!resolved.manifest.feature.orchestration.enabled);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.manifest.delegation_scope.allow[0].target,
|
resolved.manifest.delegation_scope.allow[0].target,
|
||||||
workspace
|
workspace
|
||||||
@@ -1833,7 +1842,7 @@ worker_context_max_tokens = 68000
|
|||||||
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!(!resolved.manifest.feature.ticket.orchestration_control);
|
assert!(!resolved.manifest.feature.orchestration.enabled);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.profile.as_ref().unwrap().name.as_deref(),
|
resolved.profile.as_ref().unwrap().name.as_deref(),
|
||||||
Some("companion")
|
Some("companion")
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "merge-request"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rusqlite.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
sha2.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
|||||||
|
use merge_request::*;
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn setup() -> (TempDir, SqliteMergeRequestStore) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("server.db");
|
||||||
|
let conn = Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch(r#"
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id));
|
||||||
|
CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));
|
||||||
|
CREATE TABLE typed_ticket_events(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,event_index INTEGER NOT NULL,kind TEXT NOT NULL,author TEXT,at TEXT,status TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));
|
||||||
|
CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,event_index INTEGER NOT NULL,key TEXT NOT NULL,value TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,event_index,key));
|
||||||
|
CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,assignment_id));
|
||||||
|
CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));
|
||||||
|
"#).unwrap();
|
||||||
|
for ws in ["ws-a", "ws-b"] {
|
||||||
|
conn.execute("INSERT INTO repositories VALUES(?1,'repo')", params![ws])
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO typed_tickets VALUES(?1,'T1','inprogress',1,'t0')",
|
||||||
|
params![ws],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ticket_worker_assignments VALUES(?1,'T1','A1','R1','W1')",
|
||||||
|
params![ws],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ticket_current_worker_assignments VALUES(?1,'T1','A1','R1','W1')",
|
||||||
|
params![ws],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
drop(conn);
|
||||||
|
let store = SqliteMergeRequestStore::open(&path, "ws-a").unwrap();
|
||||||
|
(dir, store)
|
||||||
|
}
|
||||||
|
fn revision(id: &str, ordinal: u64, head: &str) -> MergeRequestRevision {
|
||||||
|
MergeRequestRevision {
|
||||||
|
revision_id: id.into(),
|
||||||
|
ordinal,
|
||||||
|
base_commit: "base".into(),
|
||||||
|
head_commit: head.into(),
|
||||||
|
head_tree: format!("tree-{head}"),
|
||||||
|
diff_digest: format!("sha256:diff-{head}"),
|
||||||
|
changed_paths: vec!["src/lib.rs".into()],
|
||||||
|
summary: format!("revision {id}"),
|
||||||
|
assignment_id: "A1".into(),
|
||||||
|
created_at: format!("t{ordinal}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn open(store: &SqliteMergeRequestStore) {
|
||||||
|
store
|
||||||
|
.open_merge_request(OpenMergeRequest {
|
||||||
|
merge_request_id: "MR1".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
repository_id: "repo".into(),
|
||||||
|
revision: revision("V1", 1, "h1"),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t1".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
fn attempt(store: &SqliteMergeRequestStore, id: &str, revision: &str, token: &str, child: &str) {
|
||||||
|
store
|
||||||
|
.register_reviewer_child_session(RegisterReviewerChildSession {
|
||||||
|
parent_runtime_id: "R1".into(),
|
||||||
|
parent_worker_id: "W1".into(),
|
||||||
|
child_session_id: child.into(),
|
||||||
|
now: "t".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.register_review_attempt(RegisterReviewAttempt {
|
||||||
|
attempt_id: id.into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
revision_id: revision.into(),
|
||||||
|
parent_assignment_id: "A1".into(),
|
||||||
|
parent_runtime_id: "R1".into(),
|
||||||
|
parent_worker_id: "W1".into(),
|
||||||
|
child_session_id: child.into(),
|
||||||
|
capability_token: token.into(),
|
||||||
|
now: "t".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
fn review(
|
||||||
|
store: &SqliteMergeRequestStore,
|
||||||
|
revision: &str,
|
||||||
|
token: &str,
|
||||||
|
decision: ReviewDecision,
|
||||||
|
) -> Result<MergeRequestReview> {
|
||||||
|
store.submit_review(SubmitReview {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
revision_id: revision.into(),
|
||||||
|
capability_token: token.into(),
|
||||||
|
decision,
|
||||||
|
body: "evidence".into(),
|
||||||
|
findings: vec![],
|
||||||
|
now: "tr".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn storage_allows_multiple_merge_requests_for_one_ticket() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
store
|
||||||
|
.open_merge_request(OpenMergeRequest {
|
||||||
|
merge_request_id: "MR2".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
repository_id: "repo".into(),
|
||||||
|
revision: revision("V2", 1, "h2"),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t2".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let conn = Connection::open(store.db_path()).unwrap();
|
||||||
|
let count:i64=conn.query_row("SELECT COUNT(*) FROM merge_request_ticket_relations WHERE workspace_id='ws-a' AND ticket_id='T1'",[],|row|row.get(0)).unwrap();
|
||||||
|
assert_eq!(count, 2);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.show_for_ticket("T1")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.merge_request_id,
|
||||||
|
"MR2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_context_rejects_oversized_revision_evidence() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
let mut oversized = revision("V1", 1, "h1");
|
||||||
|
oversized.changed_paths = (0..=1_000).map(|i| format!("src/{i}.rs")).collect();
|
||||||
|
let result = store.open_merge_request(OpenMergeRequest {
|
||||||
|
merge_request_id: "MR1".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
repository_id: "repo".into(),
|
||||||
|
revision: oversized,
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t".into(),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(MergeRequestError::TooLarge {
|
||||||
|
field: "revision.changed_paths",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_v6_schema_missing_diff_digest_is_archived_before_fresh_v7() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("legacy.db");
|
||||||
|
let conn = Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\
|
||||||
|
INSERT INTO merge_request_schema_migrations(version,name) VALUES(6,'rejected_merge_request_v6');\
|
||||||
|
CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id));\
|
||||||
|
CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));\
|
||||||
|
CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,assignment_id));\
|
||||||
|
CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL,lifecycle_generation INTEGER NOT NULL,current_revision_id TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_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,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id));\
|
||||||
|
CREATE TABLE merge_request_revisions(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,revision_id TEXT NOT NULL,ordinal INTEGER NOT NULL,base_commit TEXT NOT NULL,head_commit TEXT NOT NULL,head_tree TEXT NOT NULL,assignment_id TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,revision_id));",
|
||||||
|
).unwrap();
|
||||||
|
drop(conn);
|
||||||
|
let store = SqliteMergeRequestStore::open(&path, "ws-a").unwrap();
|
||||||
|
assert!(store.show_for_ticket("missing").unwrap().is_none());
|
||||||
|
let conn = Connection::open(&path).unwrap();
|
||||||
|
let archived: i64 = conn.query_row("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='legacy_v6_merge_requests'",[],|row|row.get(0)).unwrap();
|
||||||
|
assert_eq!(archived, 1);
|
||||||
|
for table in [
|
||||||
|
"merge_request_review_attempts",
|
||||||
|
"merge_request_completion_operations",
|
||||||
|
] {
|
||||||
|
let present: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
|
||||||
|
params![table],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(present, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_changes_new_revision_resets_and_exact_completion_replay_converges() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
attempt(&store, "AT1", "V1", "tok1", "child1");
|
||||||
|
review(&store, "V1", "tok1", ReviewDecision::RequestChanges).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.show_for_ticket("T1").unwrap().unwrap().review_status,
|
||||||
|
ReviewStatus::ChangesRequested
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.add_revision(AddRevision {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_current_revision_id: "V1".into(),
|
||||||
|
revision: revision("V2", 2, "h2"),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t2".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.show_for_ticket("T1").unwrap().unwrap().review_status,
|
||||||
|
ReviewStatus::Pending
|
||||||
|
);
|
||||||
|
assert!(review(&store, "V1", "tok1", ReviewDecision::Approve).is_err());
|
||||||
|
attempt(&store, "AT2", "V2", "tok2", "child2");
|
||||||
|
review(&store, "V2", "tok2", ReviewDecision::Approve).unwrap();
|
||||||
|
let input = CompleteMergeRequest {
|
||||||
|
operation_id: "OP1".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V2".into(),
|
||||||
|
assignment_id: "A1".into(),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "tc".into(),
|
||||||
|
};
|
||||||
|
let first = store.complete(input.clone()).unwrap();
|
||||||
|
assert!(!first.replayed);
|
||||||
|
let replay = store.complete(input).unwrap();
|
||||||
|
assert!(replay.replayed);
|
||||||
|
assert!(matches!(
|
||||||
|
store.confirm_merge(MergeConfirmation {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V2".into(),
|
||||||
|
authenticated_account_id: "runtime".into(),
|
||||||
|
actor_kind: "worker".into(),
|
||||||
|
explicit_confirmation: true,
|
||||||
|
now: "tm".into()
|
||||||
|
}),
|
||||||
|
Err(MergeRequestError::MergeConfirmationRequired)
|
||||||
|
));
|
||||||
|
let merged = store
|
||||||
|
.confirm_merge(MergeConfirmation {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V2".into(),
|
||||||
|
authenticated_account_id: "account-1".into(),
|
||||||
|
actor_kind: "user".into(),
|
||||||
|
explicit_confirmation: true,
|
||||||
|
now: "tm".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(merged.state, MergeRequestState::Merged);
|
||||||
|
let conn = Connection::open(store.db_path()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT workflow_state FROM typed_tickets WHERE workspace_id='ws-a' AND ticket_id='T1'",
|
||||||
|
[],
|
||||||
|
|r| r.get::<_, String>(0)
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"done"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id='ws-a' AND ticket_id='T1'",
|
||||||
|
[],
|
||||||
|
|r| r.get::<_, i64>(0)
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spoof_self_approval_replay_and_cross_workspace_are_rejected() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
let mut bad = RegisterReviewAttempt {
|
||||||
|
attempt_id: "bad".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
revision_id: "V1".into(),
|
||||||
|
parent_assignment_id: "A1".into(),
|
||||||
|
parent_runtime_id: "R1".into(),
|
||||||
|
parent_worker_id: "W1".into(),
|
||||||
|
child_session_id: "W1".into(),
|
||||||
|
capability_token: "bad".into(),
|
||||||
|
now: "t".into(),
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
store.register_review_attempt(bad.clone()),
|
||||||
|
Err(MergeRequestError::SelfApproval)
|
||||||
|
));
|
||||||
|
bad.child_session_id = "child".into();
|
||||||
|
assert!(matches!(
|
||||||
|
store.register_review_attempt(bad),
|
||||||
|
Err(MergeRequestError::InvalidReviewer)
|
||||||
|
));
|
||||||
|
attempt(&store, "AT", "V1", "secret", "child");
|
||||||
|
assert!(review(&store, "V1", "spoof", ReviewDecision::Approve).is_err());
|
||||||
|
review(&store, "V1", "secret", ReviewDecision::Approve).unwrap();
|
||||||
|
assert!(review(&store, "V1", "secret", ReviewDecision::Approve).is_err());
|
||||||
|
let other = SqliteMergeRequestStore::open_verified(store.db_path(), "ws-b").unwrap();
|
||||||
|
assert!(other.show_for_ticket("T1").unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reopen_resets_approval_and_merge_requires_authenticated_explicit_user() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
attempt(&store, "AT", "V1", "token", "child");
|
||||||
|
review(&store, "V1", "token", ReviewDecision::Approve).unwrap();
|
||||||
|
store.close("T1", "V1", "tc").unwrap();
|
||||||
|
let reopened = store.reopen("T1", "V1", "tr").unwrap();
|
||||||
|
assert_eq!(reopened.review_status, ReviewStatus::Pending);
|
||||||
|
let denied = store.confirm_merge(MergeConfirmation {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V1".into(),
|
||||||
|
authenticated_account_id: "user".into(),
|
||||||
|
actor_kind: "user".into(),
|
||||||
|
explicit_confirmation: false,
|
||||||
|
now: "tm".into(),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
denied,
|
||||||
|
Err(MergeRequestError::MergeConfirmationRequired)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_exact_completion_replays_commit_one_ticket_side_effect() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
attempt(&store, "AT", "V1", "token", "child");
|
||||||
|
review(&store, "V1", "token", ReviewDecision::Approve).unwrap();
|
||||||
|
let input = CompleteMergeRequest {
|
||||||
|
operation_id: "OP-concurrent".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V1".into(),
|
||||||
|
assignment_id: "A1".into(),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t".into(),
|
||||||
|
};
|
||||||
|
let left_store = store.clone();
|
||||||
|
let left_input = input.clone();
|
||||||
|
let left = std::thread::spawn(move || left_store.complete(left_input));
|
||||||
|
let right_store = store.clone();
|
||||||
|
let right = std::thread::spawn(move || right_store.complete(input));
|
||||||
|
let outcomes = [
|
||||||
|
left.join().unwrap().unwrap(),
|
||||||
|
right.join().unwrap().unwrap(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
outcomes.iter().filter(|outcome| !outcome.replayed).count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outcomes.iter().filter(|outcome| outcome.replayed).count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let conn = Connection::open(store.db_path()).unwrap();
|
||||||
|
let events: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id='ws-a' AND ticket_id='T1'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(events, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_key_mismatch_and_assignment_takeover_are_fenced() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
attempt(&store, "AT", "V1", "token", "child");
|
||||||
|
review(&store, "V1", "token", ReviewDecision::Approve).unwrap();
|
||||||
|
let mut input = CompleteMergeRequest {
|
||||||
|
operation_id: "OP".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V1".into(),
|
||||||
|
assignment_id: "A1".into(),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t".into(),
|
||||||
|
};
|
||||||
|
let conn = Connection::open(store.db_path()).unwrap();
|
||||||
|
conn.execute("UPDATE ticket_current_worker_assignments SET assignment_id='A2',runtime_id='R2',worker_id='W2' WHERE workspace_id='ws-a' AND ticket_id='T1'",[]).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.complete(input.clone()),
|
||||||
|
Err(MergeRequestError::AssignmentMismatch)
|
||||||
|
));
|
||||||
|
conn.execute("UPDATE ticket_current_worker_assignments SET assignment_id='A1',runtime_id='R1',worker_id='W1' WHERE workspace_id='ws-a' AND ticket_id='T1'",[]).unwrap();
|
||||||
|
store.complete(input.clone()).unwrap();
|
||||||
|
input.expected_revision_id = "other".into();
|
||||||
|
assert!(matches!(
|
||||||
|
store.complete(input),
|
||||||
|
Err(MergeRequestError::OperationConflict)
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ pub mod segment_log;
|
|||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod system_item;
|
pub mod system_item;
|
||||||
pub mod worker_metadata;
|
pub mod worker_metadata;
|
||||||
|
pub mod worker_session_store;
|
||||||
|
|
||||||
pub use event_trace::{TraceEntry, TracePayload};
|
pub use event_trace::{TraceEntry, TracePayload};
|
||||||
pub use fs_store::FsStore;
|
pub use fs_store::FsStore;
|
||||||
@@ -54,10 +55,11 @@ pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension,
|
|||||||
pub use store::{Store, StoreError};
|
pub use store::{Store, StoreError};
|
||||||
pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_worker_event};
|
pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_worker_event};
|
||||||
pub use worker_metadata::{
|
pub use worker_metadata::{
|
||||||
CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore,
|
CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerAggregateStore, WorkerMetadata,
|
||||||
WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild, WorkerSpawnedScopeRule, WorkerStoreError,
|
WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild,
|
||||||
validate_worker_name,
|
WorkerSpawnedScopeRule, WorkerStoreError, validate_worker_name,
|
||||||
};
|
};
|
||||||
|
pub use worker_session_store::WorkerSessionStore;
|
||||||
|
|
||||||
/// Session identifier — the fork-tree root. UUID v7 (time-ordered).
|
/// Session identifier — the fork-tree root. UUID v7 (time-ordered).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -319,6 +319,115 @@ pub trait WorkerMetadataStore: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Metadata store for one canonical Worker aggregate.
|
||||||
|
///
|
||||||
|
/// The Worker key is fixed by Runtime authority and metadata lives directly at
|
||||||
|
/// `<worker-aggregate>/metadata.json`. This store cannot address another
|
||||||
|
/// Worker's global metadata root.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WorkerAggregateStore {
|
||||||
|
root: PathBuf,
|
||||||
|
worker_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerAggregateStore {
|
||||||
|
pub fn new(
|
||||||
|
root: impl Into<PathBuf>,
|
||||||
|
worker_name: impl Into<String>,
|
||||||
|
) -> Result<Self, WorkerStoreError> {
|
||||||
|
let root = root.into();
|
||||||
|
let worker_name = worker_name.into();
|
||||||
|
validate_worker_name(&worker_name)?;
|
||||||
|
fs::create_dir_all(&root)?;
|
||||||
|
Ok(Self { root, worker_name })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
|
||||||
|
validate_worker_name(worker_name)?;
|
||||||
|
if worker_name == self.worker_name {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(WorkerStoreError::InvalidWorkerName(format!(
|
||||||
|
"aggregate owns `{}`; requested `{worker_name}`",
|
||||||
|
self.worker_name
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_path(&self) -> PathBuf {
|
||||||
|
self.root.join("metadata.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerMetadataStore for WorkerAggregateStore {
|
||||||
|
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
||||||
|
self.validate_name(&metadata.worker_name)?;
|
||||||
|
let mut content = serde_json::to_vec_pretty(metadata)?;
|
||||||
|
content.push(b'\n');
|
||||||
|
let path = self.metadata_path();
|
||||||
|
let temp = self.root.join(format!(
|
||||||
|
".metadata.json.tmp-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
uuid::Uuid::now_v7()
|
||||||
|
));
|
||||||
|
let result = (|| -> Result<(), WorkerStoreError> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&temp)?;
|
||||||
|
file.write_all(&content)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
drop(file);
|
||||||
|
fs::rename(&temp, &path)?;
|
||||||
|
std::fs::File::open(&self.root)?.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = fs::remove_file(temp);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||||
|
self.validate_name(worker_name)?;
|
||||||
|
let content = match fs::read(self.metadata_path()) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
let metadata: WorkerMetadata = serde_json::from_slice(&content)?;
|
||||||
|
if metadata.worker_name != self.worker_name {
|
||||||
|
return Err(WorkerStoreError::InvalidWorkerName(format!(
|
||||||
|
"aggregate identity mismatch: expected `{}`, found `{}`",
|
||||||
|
self.worker_name, metadata.worker_name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Some(metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
||||||
|
Ok(if self.metadata_path().is_file() {
|
||||||
|
vec![self.worker_name.clone()]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_dir(&self) -> Option<PathBuf> {
|
||||||
|
Some(self.root.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
|
||||||
|
self.validate_name(worker_name)?;
|
||||||
|
match fs::remove_file(self.metadata_path()) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Filesystem-backed Worker metadata store.
|
/// Filesystem-backed Worker metadata store.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct FsWorkerStore {
|
pub struct FsWorkerStore {
|
||||||
@@ -555,6 +664,27 @@ mod tests {
|
|||||||
assert_eq!(restored, metadata);
|
assert_eq!(restored, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_aggregate_store_writes_one_fixed_metadata_identity() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let store = WorkerAggregateStore::new(tmp.path(), "worker-runtime-7").unwrap();
|
||||||
|
let metadata = WorkerMetadata::new("worker-runtime-7", None);
|
||||||
|
store.write(&metadata).unwrap();
|
||||||
|
|
||||||
|
assert!(tmp.path().join("metadata.json").is_file());
|
||||||
|
assert_eq!(store.list_names().unwrap(), vec!["worker-runtime-7"]);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.read_by_name("worker-runtime-7")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.worker_name,
|
||||||
|
"worker-runtime-7"
|
||||||
|
);
|
||||||
|
let error = store.read_by_name("worker-runtime-8").unwrap_err();
|
||||||
|
assert!(error.to_string().contains("aggregate owns"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fs_store_writes_under_worker_state_root_only() {
|
fn fs_store_writes_under_worker_state_root_only() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
//! Filesystem store for the canonical `1 Worker = 1 Session` aggregate.
|
||||||
|
//!
|
||||||
|
//! Layout under one Worker aggregate:
|
||||||
|
//! - `session/session.json` — immutable Session identity
|
||||||
|
//! - `session/segments/<segment_id>.jsonl`
|
||||||
|
//! - `session/segments/<segment_id>.trace.jsonl`
|
||||||
|
//!
|
||||||
|
//! Unlike [`crate::FsStore`], this store cannot enumerate or switch between
|
||||||
|
//! arbitrary Sessions. The first segment materializes the sole Session identity;
|
||||||
|
//! every later operation must use that same ID.
|
||||||
|
|
||||||
|
use crate::event_trace::TraceEntry;
|
||||||
|
use crate::segment_log::LogEntry;
|
||||||
|
use crate::store::{Store, StoreError};
|
||||||
|
use crate::{SegmentId, SessionId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
const SESSION_SCHEMA_VERSION: u32 = 1;
|
||||||
|
const SESSION_FILE: &str = "session.json";
|
||||||
|
const SEGMENTS_DIR: &str = "segments";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WorkerSessionStore {
|
||||||
|
root: PathBuf,
|
||||||
|
session_id: Arc<Mutex<Option<SessionId>>>,
|
||||||
|
append_lock: Arc<Mutex<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct SessionManifest {
|
||||||
|
schema_version: u32,
|
||||||
|
session_id: SessionId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerSessionStore {
|
||||||
|
/// Open the Session store rooted at `<worker-aggregate>/session`.
|
||||||
|
pub fn new(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
|
||||||
|
let root = root.into();
|
||||||
|
fs::create_dir_all(root.join(SEGMENTS_DIR))?;
|
||||||
|
let session_id = match fs::read(root.join(SESSION_FILE)) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
||||||
|
if manifest.schema_version != SESSION_SCHEMA_VERSION {
|
||||||
|
return Err(StoreError::Corrupt {
|
||||||
|
line: 0,
|
||||||
|
message: format!(
|
||||||
|
"unsupported Worker Session schema version {}, expected {}",
|
||||||
|
manifest.schema_version, SESSION_SCHEMA_VERSION
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(manifest.session_id)
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
root,
|
||||||
|
session_id: Arc::new(Mutex::new(session_id)),
|
||||||
|
append_lock: Arc::new(Mutex::new(())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn root_dir(&self) -> &Path {
|
||||||
|
&self.root
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_id(&self) -> Result<Option<SessionId>, StoreError> {
|
||||||
|
self.session_id
|
||||||
|
.lock()
|
||||||
|
.map(|session_id| *session_id)
|
||||||
|
.map_err(|_| std::io::Error::other("Worker Session identity lock was poisoned").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_modified_at(&self) -> Result<Option<SystemTime>, StoreError> {
|
||||||
|
let metadata = match fs::metadata(&self.root) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
let mut latest = Some(metadata.modified()?);
|
||||||
|
for entry in fs::read_dir(self.root.join(SEGMENTS_DIR))? {
|
||||||
|
let modified = entry?.metadata()?.modified()?;
|
||||||
|
if latest.map(|current| modified > current).unwrap_or(true) {
|
||||||
|
latest = Some(modified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(latest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_session(&self, requested: SessionId, materialize: bool) -> Result<(), StoreError> {
|
||||||
|
let mut session_id = self
|
||||||
|
.session_id
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| std::io::Error::other("Worker Session identity lock was poisoned"))?;
|
||||||
|
match *session_id {
|
||||||
|
Some(existing) if existing == requested => Ok(()),
|
||||||
|
Some(existing) => Err(StoreError::Corrupt {
|
||||||
|
line: 0,
|
||||||
|
message: format!(
|
||||||
|
"Worker aggregate owns Session {existing}; cannot attach or switch to Session {requested}"
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
None if !materialize => Err(StoreError::Corrupt {
|
||||||
|
line: 0,
|
||||||
|
message: format!(
|
||||||
|
"Worker aggregate has no materialized Session; requested Session {requested}"
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
None => {
|
||||||
|
let manifest = SessionManifest {
|
||||||
|
schema_version: SESSION_SCHEMA_VERSION,
|
||||||
|
session_id: requested,
|
||||||
|
};
|
||||||
|
atomic_write_json(&self.root.join(SESSION_FILE), &manifest)?;
|
||||||
|
*session_id = Some(requested);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_path(&self, segment_id: SegmentId) -> PathBuf {
|
||||||
|
self.root
|
||||||
|
.join(SEGMENTS_DIR)
|
||||||
|
.join(format!("{segment_id}.jsonl"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trace_path(&self, segment_id: SegmentId) -> PathBuf {
|
||||||
|
self.root
|
||||||
|
.join(SEGMENTS_DIR)
|
||||||
|
.join(format!("{segment_id}.trace.jsonl"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
|
||||||
|
let _guard = self
|
||||||
|
.append_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.append(true)
|
||||||
|
.open(path)?;
|
||||||
|
let committed_len = truncate_uncommitted_tail(&mut file)?;
|
||||||
|
let mut record = Vec::with_capacity(line.len() + 1);
|
||||||
|
record.extend_from_slice(line.as_bytes());
|
||||||
|
record.push(b'\n');
|
||||||
|
if let Err(write_error) = file.write_all(&record) {
|
||||||
|
return match file.set_len(committed_len) {
|
||||||
|
Ok(()) => Err(write_error.into()),
|
||||||
|
Err(rollback_error) => Err(std::io::Error::new(
|
||||||
|
rollback_error.kind(),
|
||||||
|
format!(
|
||||||
|
"session append failed ({write_error}) and rollback failed: {rollback_error}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store for WorkerSessionStore {
|
||||||
|
fn append(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
entry: &LogEntry,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.ensure_session(session_id, true)?;
|
||||||
|
self.append_line(&self.log_path(segment_id), &serde_json::to_string(entry)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_all(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
) -> Result<Vec<LogEntry>, StoreError> {
|
||||||
|
self.ensure_session(session_id, false)?;
|
||||||
|
let path = self.log_path(segment_id);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(StoreError::NotFound(segment_id));
|
||||||
|
}
|
||||||
|
parse_jsonl(&fs::read(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
|
||||||
|
Ok(self.session_id()?.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_segments(&self, session_id: SessionId) -> Result<Vec<SegmentId>, StoreError> {
|
||||||
|
self.ensure_session(session_id, false)?;
|
||||||
|
let mut segments: Vec<SegmentId> = Vec::new();
|
||||||
|
for entry in fs::read_dir(self.root.join(SEGMENTS_DIR))? {
|
||||||
|
let path = entry?.path();
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if name.ends_with(".jsonl")
|
||||||
|
&& !name.ends_with(".trace.jsonl")
|
||||||
|
&& let Ok(segment_id) = name.trim_end_matches(".jsonl").parse()
|
||||||
|
{
|
||||||
|
segments.push(segment_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
segments.sort_by(|left, right| right.cmp(left));
|
||||||
|
Ok(segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_session_of(&self, segment_id: SegmentId) -> Result<Option<SessionId>, StoreError> {
|
||||||
|
let session_id = self.session_id()?;
|
||||||
|
Ok(session_id.filter(|_| self.log_path(segment_id).exists()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_segment(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
entries: &[LogEntry],
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.ensure_session(session_id, true)?;
|
||||||
|
let mut content = Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
serde_json::to_writer(&mut content, entry)?;
|
||||||
|
content.push(b'\n');
|
||||||
|
}
|
||||||
|
atomic_write_bytes(&self.log_path(segment_id), &content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result<bool, StoreError> {
|
||||||
|
self.ensure_session(session_id, false)?;
|
||||||
|
Ok(self.log_path(segment_id).exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_entry_count(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
) -> Result<usize, StoreError> {
|
||||||
|
self.ensure_session(session_id, false)?;
|
||||||
|
let path = self.log_path(segment_id);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(StoreError::NotFound(segment_id));
|
||||||
|
}
|
||||||
|
let content = fs::read(path)?;
|
||||||
|
let complete = complete_jsonl_prefix(&content);
|
||||||
|
let complete = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
|
||||||
|
line: complete[..error.valid_up_to()]
|
||||||
|
.iter()
|
||||||
|
.filter(|byte| **byte == b'\n')
|
||||||
|
.count()
|
||||||
|
+ 1,
|
||||||
|
message: error.to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(complete
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_trace(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
entry: &TraceEntry,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.ensure_session(session_id, true)?;
|
||||||
|
self.append_line(&self.trace_path(segment_id), &serde_json::to_string(entry)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
|
||||||
|
let mut bytes = serde_json::to_vec_pretty(value)?;
|
||||||
|
bytes.push(b'\n');
|
||||||
|
atomic_write_bytes(path, &bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| std::io::Error::other("Worker Session path has no parent"))?;
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
let tmp = path.with_file_name(format!(
|
||||||
|
".{}.tmp-{}-{}",
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("session"),
|
||||||
|
std::process::id(),
|
||||||
|
uuid::Uuid::now_v7()
|
||||||
|
));
|
||||||
|
let result = (|| -> Result<(), StoreError> {
|
||||||
|
let mut file = OpenOptions::new().create_new(true).write(true).open(&tmp)?;
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
drop(file);
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
File::open(parent)?.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = fs::remove_file(&tmp);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_jsonl_prefix(content: &[u8]) -> &[u8] {
|
||||||
|
if content.last() == Some(&b'\n') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
content
|
||||||
|
.iter()
|
||||||
|
.rposition(|byte| *byte == b'\n')
|
||||||
|
.map(|index| &content[..=index])
|
||||||
|
.unwrap_or(&[])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_jsonl<T: serde::de::DeserializeOwned>(content: &[u8]) -> Result<Vec<T>, StoreError> {
|
||||||
|
let complete = complete_jsonl_prefix(content);
|
||||||
|
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
|
||||||
|
line: complete[..error.valid_up_to()]
|
||||||
|
.iter()
|
||||||
|
.filter(|byte| **byte == b'\n')
|
||||||
|
.count()
|
||||||
|
+ 1,
|
||||||
|
message: error.to_string(),
|
||||||
|
})?;
|
||||||
|
content
|
||||||
|
.lines()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, line)| !line.trim().is_empty())
|
||||||
|
.map(|(index, line)| {
|
||||||
|
serde_json::from_str(line).map_err(|error| StoreError::Corrupt {
|
||||||
|
line: index + 1,
|
||||||
|
message: error.to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_uncommitted_tail(file: &mut File) -> std::io::Result<u64> {
|
||||||
|
const SCAN_BYTES: usize = 8 * 1024;
|
||||||
|
let len = file.metadata()?.len();
|
||||||
|
if len == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
file.seek(SeekFrom::End(-1))?;
|
||||||
|
let mut last = [0_u8; 1];
|
||||||
|
file.read_exact(&mut last)?;
|
||||||
|
if last[0] == b'\n' {
|
||||||
|
return Ok(len);
|
||||||
|
}
|
||||||
|
let mut end = len;
|
||||||
|
let mut buffer = [0_u8; SCAN_BYTES];
|
||||||
|
while end > 0 {
|
||||||
|
let start = end.saturating_sub(SCAN_BYTES as u64);
|
||||||
|
let chunk_len = (end - start) as usize;
|
||||||
|
file.seek(SeekFrom::Start(start))?;
|
||||||
|
file.read_exact(&mut buffer[..chunk_len])?;
|
||||||
|
if let Some(index) = buffer[..chunk_len].iter().rposition(|byte| *byte == b'\n') {
|
||||||
|
let committed_len = start + index as u64 + 1;
|
||||||
|
file.set_len(committed_len)?;
|
||||||
|
return Ok(committed_len);
|
||||||
|
}
|
||||||
|
end = start;
|
||||||
|
}
|
||||||
|
file.set_len(0)?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{Store, new_segment_id, new_session_id};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_layout_and_single_session_invariant() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let store = WorkerSessionStore::new(root.path().join("session")).unwrap();
|
||||||
|
let session_id = new_session_id();
|
||||||
|
let segment_id = new_segment_id();
|
||||||
|
store.create_segment(session_id, segment_id, &[]).unwrap();
|
||||||
|
|
||||||
|
assert!(root.path().join("session/session.json").is_file());
|
||||||
|
assert!(
|
||||||
|
root.path()
|
||||||
|
.join(format!("session/segments/{segment_id}.jsonl"))
|
||||||
|
.is_file()
|
||||||
|
);
|
||||||
|
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
|
||||||
|
|
||||||
|
let other = new_session_id();
|
||||||
|
let error = store
|
||||||
|
.create_segment(other, new_segment_id(), &[])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("cannot attach or switch"));
|
||||||
|
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reopen_preserves_session_and_segment_ids() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let session_id = new_session_id();
|
||||||
|
let segment_id = new_segment_id();
|
||||||
|
WorkerSessionStore::new(root.path())
|
||||||
|
.unwrap()
|
||||||
|
.create_segment(session_id, segment_id, &[])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let reopened = WorkerSessionStore::new(root.path()).unwrap();
|
||||||
|
assert_eq!(reopened.session_id().unwrap(), Some(session_id));
|
||||||
|
assert!(reopened.exists(session_id, segment_id).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
+402
-299
@@ -10,6 +10,8 @@ use std::fs::{self, File, OpenOptions};
|
|||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use fs4::fs_std::FileExt;
|
use fs4::fs_std::FileExt;
|
||||||
@@ -20,8 +22,13 @@ use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
mod sqlite_schema;
|
||||||
pub mod tool;
|
pub mod tool;
|
||||||
|
|
||||||
|
pub use sqlite_schema::{
|
||||||
|
LATEST_SQLITE_TICKET_SCHEMA_VERSION, migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema,
|
||||||
|
};
|
||||||
|
|
||||||
const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"];
|
const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"];
|
||||||
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
|
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
|
||||||
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
|
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
|
||||||
@@ -290,7 +297,6 @@ pub enum TicketEventKind {
|
|||||||
Plan,
|
Plan,
|
||||||
Decision,
|
Decision,
|
||||||
ImplementationReport,
|
ImplementationReport,
|
||||||
Review,
|
|
||||||
StateChanged,
|
StateChanged,
|
||||||
IntakeSummary,
|
IntakeSummary,
|
||||||
StatusChanged,
|
StatusChanged,
|
||||||
@@ -306,7 +312,6 @@ impl TicketEventKind {
|
|||||||
Self::Plan => "plan",
|
Self::Plan => "plan",
|
||||||
Self::Decision => "decision",
|
Self::Decision => "decision",
|
||||||
Self::ImplementationReport => "implementation_report",
|
Self::ImplementationReport => "implementation_report",
|
||||||
Self::Review => "review",
|
|
||||||
Self::StateChanged => "state_changed",
|
Self::StateChanged => "state_changed",
|
||||||
Self::IntakeSummary => "intake_summary",
|
Self::IntakeSummary => "intake_summary",
|
||||||
Self::StatusChanged => "status_changed",
|
Self::StatusChanged => "status_changed",
|
||||||
@@ -322,7 +327,6 @@ impl TicketEventKind {
|
|||||||
Self::Plan => "Plan".to_string(),
|
Self::Plan => "Plan".to_string(),
|
||||||
Self::Decision => "Decision".to_string(),
|
Self::Decision => "Decision".to_string(),
|
||||||
Self::ImplementationReport => "Implementation report".to_string(),
|
Self::ImplementationReport => "Implementation report".to_string(),
|
||||||
Self::Review => "Review".to_string(),
|
|
||||||
Self::StateChanged => "State changed".to_string(),
|
Self::StateChanged => "State changed".to_string(),
|
||||||
Self::IntakeSummary => "Intake summary".to_string(),
|
Self::IntakeSummary => "Intake summary".to_string(),
|
||||||
Self::StatusChanged => "Status changed".to_string(),
|
Self::StatusChanged => "Status changed".to_string(),
|
||||||
@@ -340,7 +344,7 @@ impl From<&str> for TicketEventKind {
|
|||||||
"plan" => Self::Plan,
|
"plan" => Self::Plan,
|
||||||
"decision" => Self::Decision,
|
"decision" => Self::Decision,
|
||||||
"implementation_report" => Self::ImplementationReport,
|
"implementation_report" => Self::ImplementationReport,
|
||||||
"review" => Self::Review,
|
"review" => Self::Comment,
|
||||||
"state_changed" => Self::StateChanged,
|
"state_changed" => Self::StateChanged,
|
||||||
"intake_summary" => Self::IntakeSummary,
|
"intake_summary" => Self::IntakeSummary,
|
||||||
"status_changed" => Self::StatusChanged,
|
"status_changed" => Self::StatusChanged,
|
||||||
@@ -350,42 +354,6 @@ impl From<&str> for TicketEventKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TicketReviewResult {
|
|
||||||
Approve,
|
|
||||||
RequestChanges,
|
|
||||||
Other(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TicketReviewResult {
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
Self::Approve => "approve",
|
|
||||||
Self::RequestChanges => "request_changes",
|
|
||||||
Self::Other(value) => value.as_str(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn heading(&self) -> String {
|
|
||||||
match self {
|
|
||||||
Self::Approve => "Review: approve".to_string(),
|
|
||||||
Self::RequestChanges => "Review: request changes".to_string(),
|
|
||||||
Self::Other(value) => format!("Review: {value}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&str> for TicketReviewResult {
|
|
||||||
fn from(value: &str) -> Self {
|
|
||||||
match value {
|
|
||||||
"approve" => Self::Approve,
|
|
||||||
"request_changes" => Self::RequestChanges,
|
|
||||||
other => Self::Other(other.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TicketReference {
|
pub struct TicketReference {
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
@@ -456,31 +424,6 @@ impl TicketIntakeSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct TicketReview {
|
|
||||||
pub result: TicketReviewResult,
|
|
||||||
pub author: Option<String>,
|
|
||||||
pub body: MarkdownText,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TicketReview {
|
|
||||||
pub fn approve(body: impl Into<MarkdownText>) -> Self {
|
|
||||||
Self {
|
|
||||||
result: TicketReviewResult::Approve,
|
|
||||||
author: None,
|
|
||||||
body: body.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn request_changes(body: impl Into<MarkdownText>) -> Self {
|
|
||||||
Self {
|
|
||||||
result: TicketReviewResult::RequestChanges,
|
|
||||||
author: None,
|
|
||||||
body: body.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct NewTicket {
|
pub struct NewTicket {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -1446,6 +1389,21 @@ pub struct TicketSummary {
|
|||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounded SQLite list projection used by Workspace list surfaces.
|
||||||
|
///
|
||||||
|
/// This intentionally contains only summary fields and relation blockers. Full Ticket bodies,
|
||||||
|
/// events, references, and artifacts remain exclusive to [`TicketBackend::show`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SqliteTicketListItem {
|
||||||
|
pub summary: TicketSummary,
|
||||||
|
pub relation_blockers: Vec<TicketRelationBlocker>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct SqliteTicketListProjection {
|
||||||
|
pub items: Vec<SqliteTicketListItem>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TicketInvalidRecord {
|
pub struct TicketInvalidRecord {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -1573,7 +1531,6 @@ pub trait TicketBackend {
|
|||||||
change: TicketStateChange,
|
change: TicketStateChange,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
|
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
|
||||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
|
||||||
fn add_ticket_relation(
|
fn add_ticket_relation(
|
||||||
&self,
|
&self,
|
||||||
@@ -1651,10 +1608,6 @@ pub enum TicketBackendOperation {
|
|||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
queued_by: String,
|
queued_by: String,
|
||||||
},
|
},
|
||||||
Review {
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
review: TicketReview,
|
|
||||||
},
|
|
||||||
Close {
|
Close {
|
||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
resolution: MarkdownText,
|
resolution: MarkdownText,
|
||||||
@@ -1758,10 +1711,6 @@ where
|
|||||||
backend.queue_ready(id, &queued_by)?;
|
backend.queue_ready(id, &queued_by)?;
|
||||||
TicketBackendOperationResult::Unit
|
TicketBackendOperationResult::Unit
|
||||||
}
|
}
|
||||||
TicketBackendOperation::Review { id, review } => {
|
|
||||||
backend.review(id, review)?;
|
|
||||||
TicketBackendOperationResult::Unit
|
|
||||||
}
|
|
||||||
TicketBackendOperation::Close { id, resolution } => {
|
TicketBackendOperation::Close { id, resolution } => {
|
||||||
backend.close(id, resolution)?;
|
backend.close(id, resolution)?;
|
||||||
TicketBackendOperationResult::Unit
|
TicketBackendOperationResult::Unit
|
||||||
@@ -2282,6 +2231,8 @@ pub struct SqliteTicketBackend {
|
|||||||
record_language: Option<String>,
|
record_language: Option<String>,
|
||||||
event_attributes: BTreeMap<String, String>,
|
event_attributes: BTreeMap<String, String>,
|
||||||
mutation_hook: Option<Arc<SqliteTicketMutationHook>>,
|
mutation_hook: Option<Arc<SqliteTicketMutationHook>>,
|
||||||
|
#[cfg(test)]
|
||||||
|
full_ticket_load_count: Arc<AtomicUsize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for SqliteTicketBackend {
|
impl fmt::Debug for SqliteTicketBackend {
|
||||||
@@ -2301,16 +2252,39 @@ impl fmt::Debug for SqliteTicketBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteTicketBackend {
|
impl SqliteTicketBackend {
|
||||||
pub fn new(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
|
fn configured(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
db_path: db_path.into(),
|
db_path: db_path.into(),
|
||||||
workspace_id: workspace_id.into(),
|
workspace_id: workspace_id.into(),
|
||||||
record_language: None,
|
record_language: None,
|
||||||
event_attributes: BTreeMap::new(),
|
event_attributes: BTreeMap::new(),
|
||||||
mutation_hook: None,
|
mutation_hook: None,
|
||||||
|
#[cfg(test)]
|
||||||
|
full_ticket_load_count: Arc::new(AtomicUsize::new(0)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens a standalone Ticket backend, applying all Ticket-owned migrations once.
|
||||||
|
pub fn open(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Result<Self> {
|
||||||
|
let backend = Self::configured(db_path, workspace_id);
|
||||||
|
let connection = backend.connect()?;
|
||||||
|
migrate_sqlite_ticket_schema(&connection)?;
|
||||||
|
Ok(backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects to a database whose Ticket schema was composed by its startup owner.
|
||||||
|
///
|
||||||
|
/// This performs verification only and never creates or alters schema objects.
|
||||||
|
pub fn open_verified(
|
||||||
|
db_path: impl Into<PathBuf>,
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let backend = Self::configured(db_path, workspace_id);
|
||||||
|
let connection = backend.connect()?;
|
||||||
|
verify_sqlite_ticket_schema(&connection)?;
|
||||||
|
Ok(backend)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_event_attributes(mut self, attributes: BTreeMap<String, String>) -> Self {
|
pub fn with_event_attributes(mut self, attributes: BTreeMap<String, String>) -> Self {
|
||||||
self.event_attributes = attributes;
|
self.event_attributes = attributes;
|
||||||
self
|
self
|
||||||
@@ -2336,9 +2310,204 @@ impl SqliteTicketBackend {
|
|||||||
self.record_language.as_deref()
|
self.record_language.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lists a bounded Workspace projection in one verified SQLite read.
|
||||||
|
///
|
||||||
|
/// The summary query applies `updated_at DESC, ticket_id ASC` and `limit` before relation
|
||||||
|
/// enrichment. A second bulk query loads only relations touching those returned Tickets,
|
||||||
|
/// including the blocking Ticket states needed to preserve relation blocker semantics.
|
||||||
|
pub fn list_workspace_projection(&self, limit: usize) -> Result<SqliteTicketListProjection> {
|
||||||
|
self.with_read(|conn| {
|
||||||
|
let summaries = self.list_workspace_summaries(conn, limit)?;
|
||||||
|
if summaries.is_empty() {
|
||||||
|
return Ok(SqliteTicketListProjection::default());
|
||||||
|
}
|
||||||
|
let blockers = self.list_workspace_blockers(conn, &summaries)?;
|
||||||
|
Ok(SqliteTicketListProjection {
|
||||||
|
items: summaries
|
||||||
|
.into_iter()
|
||||||
|
.map(|summary| {
|
||||||
|
let relation_blockers =
|
||||||
|
blockers.get(&summary.id).cloned().unwrap_or_default();
|
||||||
|
SqliteTicketListItem {
|
||||||
|
summary,
|
||||||
|
relation_blockers,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_workspace_summaries(
|
||||||
|
&self,
|
||||||
|
conn: &Connection,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<TicketSummary>> {
|
||||||
|
let limit = i64::try_from(limit).unwrap_or(i64::MAX);
|
||||||
|
let mut statement = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT ticket_id, slug, title, status, kind, priority, readiness,
|
||||||
|
workflow_state, workflow_state_explicit, queued_by, queued_at, updated_at
|
||||||
|
FROM typed_tickets
|
||||||
|
WHERE workspace_id = ?1
|
||||||
|
ORDER BY updated_at DESC, ticket_id ASC
|
||||||
|
LIMIT ?2",
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
let rows = statement
|
||||||
|
.query_map(params![self.workspace_id, limit], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, String>(3)?,
|
||||||
|
row.get::<_, String>(4)?,
|
||||||
|
row.get::<_, String>(5)?,
|
||||||
|
row.get::<_, Option<String>>(6)?,
|
||||||
|
row.get::<_, String>(7)?,
|
||||||
|
row.get::<_, i64>(8)? != 0,
|
||||||
|
row.get::<_, Option<String>>(9)?,
|
||||||
|
row.get::<_, Option<String>>(10)?,
|
||||||
|
row.get::<_, Option<String>>(11)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
let mut summaries = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let (
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
kind,
|
||||||
|
priority,
|
||||||
|
readiness,
|
||||||
|
workflow_state,
|
||||||
|
workflow_state_explicit,
|
||||||
|
queued_by,
|
||||||
|
queued_at,
|
||||||
|
updated_at,
|
||||||
|
) = row.map_err(sqlite_err)?;
|
||||||
|
summaries.push(TicketSummary {
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
status: ExtensibleTicketStatus::from(status.as_str()),
|
||||||
|
kind,
|
||||||
|
priority,
|
||||||
|
labels: Vec::new(),
|
||||||
|
readiness,
|
||||||
|
workflow_state: TicketWorkflowState::parse(&workflow_state)
|
||||||
|
.unwrap_or(TicketWorkflowState::Planning),
|
||||||
|
workflow_state_explicit,
|
||||||
|
queued_by,
|
||||||
|
queued_at,
|
||||||
|
updated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(summaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_workspace_blockers(
|
||||||
|
&self,
|
||||||
|
conn: &Connection,
|
||||||
|
summaries: &[TicketSummary],
|
||||||
|
) -> Result<HashMap<String, Vec<TicketRelationBlocker>>> {
|
||||||
|
let listed_ids = summaries
|
||||||
|
.iter()
|
||||||
|
.map(|summary| summary.id.as_str())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let mut statement = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT relation.ticket_id, relation.kind, relation.target, relation.note,
|
||||||
|
source.workflow_state, target.workflow_state
|
||||||
|
FROM typed_ticket_relations AS relation
|
||||||
|
LEFT JOIN typed_tickets AS source
|
||||||
|
ON source.workspace_id = relation.workspace_id
|
||||||
|
AND source.ticket_id = relation.ticket_id
|
||||||
|
LEFT JOIN typed_tickets AS target
|
||||||
|
ON target.workspace_id = relation.workspace_id
|
||||||
|
AND target.ticket_id = relation.target
|
||||||
|
WHERE relation.workspace_id = ?1
|
||||||
|
AND relation.kind IN ('depends_on', 'blocks')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM json_each(?2) AS listed
|
||||||
|
WHERE listed.value = relation.ticket_id
|
||||||
|
OR listed.value = relation.target
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
let listed_ids_json = serde_json::to_string(
|
||||||
|
&summaries
|
||||||
|
.iter()
|
||||||
|
.map(|summary| summary.id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.map_err(|error| TicketError::Sqlite(error.to_string()))?;
|
||||||
|
let rows = statement
|
||||||
|
.query_map(params![self.workspace_id, listed_ids_json], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, Option<String>>(3)?,
|
||||||
|
row.get::<_, Option<String>>(4)?,
|
||||||
|
row.get::<_, Option<String>>(5)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
let mut blockers = HashMap::<String, Vec<TicketRelationBlocker>>::new();
|
||||||
|
for row in rows {
|
||||||
|
let (source, kind, target, note, source_state, target_state) =
|
||||||
|
row.map_err(sqlite_err)?;
|
||||||
|
let (listed_ticket, blocking_ticket, reason_kind, relation_kind, blocking_state) =
|
||||||
|
match kind.as_str() {
|
||||||
|
"depends_on" if listed_ids.contains(source.as_str()) => (
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
"depends_on",
|
||||||
|
TicketRelationKind::DependsOn,
|
||||||
|
target_state,
|
||||||
|
),
|
||||||
|
"blocks" if listed_ids.contains(target.as_str()) => (
|
||||||
|
target,
|
||||||
|
source,
|
||||||
|
"blocked_by",
|
||||||
|
TicketRelationKind::Blocks,
|
||||||
|
source_state,
|
||||||
|
),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let blocking_state = blocking_state
|
||||||
|
.as_deref()
|
||||||
|
.and_then(TicketWorkflowState::parse)
|
||||||
|
.unwrap_or(TicketWorkflowState::Planning);
|
||||||
|
if ticket_state_resolved(blocking_state) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
blockers
|
||||||
|
.entry(listed_ticket)
|
||||||
|
.or_default()
|
||||||
|
.push(TicketRelationBlocker {
|
||||||
|
blocking_ticket,
|
||||||
|
reason_kind: reason_kind.to_string(),
|
||||||
|
relation_kind,
|
||||||
|
note,
|
||||||
|
blocking_state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for ticket_blockers in blockers.values_mut() {
|
||||||
|
ticket_blockers.sort_by(|a, b| {
|
||||||
|
a.reason_kind
|
||||||
|
.cmp(&b.reason_kind)
|
||||||
|
.then_with(|| a.blocking_ticket.cmp(&b.blocking_ticket))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(blockers)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn import_from_local_backend(&self, local: &LocalTicketBackend) -> Result<()> {
|
pub fn import_from_local_backend(&self, local: &LocalTicketBackend) -> Result<()> {
|
||||||
let conn = self.open_connection()?;
|
let conn = self.open_connection()?;
|
||||||
self.ensure_schema(&conn)?;
|
|
||||||
conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?;
|
conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?;
|
||||||
let result = (|| {
|
let result = (|| {
|
||||||
for summary in local.list(TicketListQuery::all())? {
|
for summary in local.list(TicketListQuery::all())? {
|
||||||
@@ -2351,7 +2520,7 @@ impl SqliteTicketBackend {
|
|||||||
finish_sqlite_transaction(&conn, result)
|
finish_sqlite_transaction(&conn, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_connection(&self) -> Result<Connection> {
|
fn connect(&self) -> Result<Connection> {
|
||||||
if let Some(parent) = self.db_path.parent() {
|
if let Some(parent) = self.db_path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|error| io_err(parent, error))?;
|
fs::create_dir_all(parent).map_err(|error| io_err(parent, error))?;
|
||||||
}
|
}
|
||||||
@@ -2361,115 +2530,20 @@ impl SqliteTicketBackend {
|
|||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_schema(&self, conn: &Connection) -> Result<()> {
|
fn open_connection(&self) -> Result<Connection> {
|
||||||
conn.execute_batch(r#"
|
let connection = self.connect()?;
|
||||||
CREATE TABLE IF NOT EXISTS typed_tickets (
|
verify_sqlite_ticket_schema(&connection)?;
|
||||||
workspace_id TEXT NOT NULL,
|
Ok(connection)
|
||||||
ticket_id TEXT NOT NULL,
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
kind TEXT NOT NULL,
|
|
||||||
priority TEXT NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
created_at TEXT,
|
|
||||||
updated_at TEXT,
|
|
||||||
assignee TEXT,
|
|
||||||
readiness TEXT,
|
|
||||||
workflow_state TEXT NOT NULL,
|
|
||||||
workflow_state_explicit INTEGER NOT NULL,
|
|
||||||
queued_by TEXT,
|
|
||||||
queued_at TEXT,
|
|
||||||
resolution TEXT,
|
|
||||||
repository_id TEXT,
|
|
||||||
ref_selector TEXT,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS typed_ticket_labels (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS typed_ticket_risk_flags (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS typed_ticket_raw_frontmatter (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, key),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS typed_ticket_events (
|
|
||||||
workspace_id TEXT NOT NULL,
|
|
||||||
ticket_id TEXT NOT NULL,
|
|
||||||
event_index INTEGER NOT NULL,
|
|
||||||
kind TEXT NOT NULL,
|
|
||||||
author TEXT,
|
|
||||||
at TEXT,
|
|
||||||
status TEXT,
|
|
||||||
from_state TEXT,
|
|
||||||
to_state TEXT,
|
|
||||||
reason TEXT,
|
|
||||||
state_field TEXT,
|
|
||||||
heading TEXT,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, event_index),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS typed_ticket_event_references (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS typed_ticket_event_attributes (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS typed_ticket_relations (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, kind, target),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS typed_ticket_orchestration_plans (
|
|
||||||
workspace_id TEXT NOT NULL,
|
|
||||||
ticket_id TEXT NOT NULL,
|
|
||||||
record_id TEXT NOT NULL,
|
|
||||||
kind TEXT NOT NULL,
|
|
||||||
related_ticket TEXT,
|
|
||||||
note TEXT,
|
|
||||||
accepted_summary TEXT,
|
|
||||||
accepted_branch TEXT,
|
|
||||||
accepted_worktree TEXT,
|
|
||||||
accepted_role_plan TEXT,
|
|
||||||
author TEXT NOT NULL,
|
|
||||||
at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, record_id),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS typed_ticket_artifacts (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
"#)
|
|
||||||
.map_err(sqlite_err)?;
|
|
||||||
ensure_sqlite_ticket_column(conn, "repository_id", "TEXT")?;
|
|
||||||
ensure_sqlite_ticket_column(conn, "ref_selector", "TEXT")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_write<R>(&self, op: impl FnOnce(&Connection) -> Result<R>) -> Result<R> {
|
fn with_write<R>(&self, op: impl FnOnce(&Connection) -> Result<R>) -> Result<R> {
|
||||||
let conn = self.open_connection()?;
|
let conn = self.open_connection()?;
|
||||||
self.ensure_schema(&conn)?;
|
|
||||||
conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?;
|
conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?;
|
||||||
finish_sqlite_transaction(&conn, op(&conn))
|
finish_sqlite_transaction(&conn, op(&conn))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_read<R>(&self, op: impl FnOnce(&Connection) -> Result<R>) -> Result<R> {
|
fn with_read<R>(&self, op: impl FnOnce(&Connection) -> Result<R>) -> Result<R> {
|
||||||
let conn = self.open_connection()?;
|
let conn = self.open_connection()?;
|
||||||
self.ensure_schema(&conn)?;
|
|
||||||
op(&conn)
|
op(&conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2676,6 +2750,8 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts (
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_ticket(&self, conn: &Connection, ticket_id: &str) -> Result<Ticket> {
|
fn load_ticket(&self, conn: &Connection, ticket_id: &str) -> Result<Ticket> {
|
||||||
|
#[cfg(test)]
|
||||||
|
self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst);
|
||||||
let (mut meta, body, resolution): (TicketMeta, String, Option<String>) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#,
|
let (mut meta, body, resolution): (TicketMeta, String, Option<String>) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#,
|
||||||
params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?;
|
params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?;
|
||||||
meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?;
|
meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?;
|
||||||
@@ -2915,30 +2991,6 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_sqlite_ticket_column(
|
|
||||||
conn: &rusqlite::Connection,
|
|
||||||
name: &str,
|
|
||||||
sql_type: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut statement = conn
|
|
||||||
.prepare("PRAGMA table_info(typed_tickets)")
|
|
||||||
.map_err(sqlite_err)?;
|
|
||||||
let columns = statement
|
|
||||||
.query_map([], |row| row.get::<_, String>(1))
|
|
||||||
.map_err(sqlite_err)?;
|
|
||||||
for column in columns {
|
|
||||||
if column.map_err(sqlite_err)? == name {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
conn.execute(
|
|
||||||
format!("ALTER TABLE typed_tickets ADD COLUMN {name} {sql_type}").as_str(),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(sqlite_err)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish_sqlite_transaction<R>(conn: &Connection, result: Result<R>) -> Result<R> {
|
fn finish_sqlite_transaction<R>(conn: &Connection, result: Result<R>) -> Result<R> {
|
||||||
match result {
|
match result {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
@@ -3295,34 +3347,6 @@ impl TicketBackend for SqliteTicketBackend {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> {
|
|
||||||
self.with_write(|conn| {
|
|
||||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
|
||||||
let at = now_utc();
|
|
||||||
let mut attributes = BTreeMap::new();
|
|
||||||
attributes.insert("result".to_string(), review.result.as_str().to_string());
|
|
||||||
self.insert_event(
|
|
||||||
conn,
|
|
||||||
&ticket_id,
|
|
||||||
&TicketEvent {
|
|
||||||
kind: TicketEventKind::Review,
|
|
||||||
author: Some(review.author.unwrap_or_else(default_author)),
|
|
||||||
at: Some(at.clone()),
|
|
||||||
status: Some(review.result.as_str().to_string()),
|
|
||||||
from: None,
|
|
||||||
to: None,
|
|
||||||
reason: None,
|
|
||||||
state_field: None,
|
|
||||||
heading: Some(review.result.heading()),
|
|
||||||
body: review.body,
|
|
||||||
references: Vec::new(),
|
|
||||||
attributes,
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
self.touch_ticket(conn, &ticket_id, &at)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
|
||||||
self.with_write(|conn| {
|
self.with_write(|conn| {
|
||||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||||
@@ -3898,21 +3922,6 @@ impl TicketBackend for LocalTicketBackend {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> {
|
|
||||||
let _lock = self.acquire_lock()?;
|
|
||||||
let dir = self.find_ticket_dir(&id)?;
|
|
||||||
let author = review.author.unwrap_or_else(default_author);
|
|
||||||
self.append_thread_event(
|
|
||||||
&dir,
|
|
||||||
"review",
|
|
||||||
&review.result.heading(),
|
|
||||||
&author,
|
|
||||||
Some(review.result.as_str()),
|
|
||||||
&[],
|
|
||||||
&review.body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
|
||||||
let _lock = self.acquire_lock()?;
|
let _lock = self.acquire_lock()?;
|
||||||
self.ensure_backend_dirs()?;
|
self.ensure_backend_dirs()?;
|
||||||
@@ -5431,7 +5440,8 @@ fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
|
|||||||
.strip_prefix("<!-- ")
|
.strip_prefix("<!-- ")
|
||||||
.and_then(|v| v.strip_suffix(" -->"))
|
.and_then(|v| v.strip_suffix(" -->"))
|
||||||
{
|
{
|
||||||
let attrs = parse_event_comment(comment);
|
let mut attrs = parse_event_comment(comment);
|
||||||
|
let legacy_review = attrs.get("event").is_some_and(|value| value == "review");
|
||||||
let kind = attrs
|
let kind = attrs
|
||||||
.get("event")
|
.get("event")
|
||||||
.map(|value| TicketEventKind::from(value.as_str()))
|
.map(|value| TicketEventKind::from(value.as_str()))
|
||||||
@@ -5463,11 +5473,22 @@ fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
|
|||||||
while body.ends_with('\n') {
|
while body.ends_with('\n') {
|
||||||
body.pop();
|
body.pop();
|
||||||
}
|
}
|
||||||
|
if legacy_review {
|
||||||
|
heading = Some("Legacy review (non-authoritative)".to_string());
|
||||||
|
attrs.remove("status");
|
||||||
|
attrs.remove("result");
|
||||||
|
attrs.insert("event".to_string(), "comment".to_string());
|
||||||
|
attrs.insert("legacy_event_kind".to_string(), "review".to_string());
|
||||||
|
}
|
||||||
events.push(TicketEvent {
|
events.push(TicketEvent {
|
||||||
kind,
|
kind,
|
||||||
author: attrs.get("author").cloned(),
|
author: attrs.get("author").cloned(),
|
||||||
at: attrs.get("at").cloned(),
|
at: attrs.get("at").cloned(),
|
||||||
status: attrs.get("status").cloned(),
|
status: if legacy_review {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
attrs.get("status").cloned()
|
||||||
|
},
|
||||||
from: attrs.get("from").cloned(),
|
from: attrs.get("from").cloned(),
|
||||||
to: attrs.get("to").cloned(),
|
to: attrs.get("to").cloned(),
|
||||||
reason: attrs.get("reason").cloned(),
|
reason: attrs.get("reason").cloned(),
|
||||||
@@ -6406,28 +6427,11 @@ state: planning
|
|||||||
#[test]
|
#[test]
|
||||||
fn sqlite_backend_persists_and_edits_ticket_target() {
|
fn sqlite_backend_persists_and_edits_ticket_target() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
|
let backend =
|
||||||
|
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
|
||||||
assert_ticket_target_edit_semantics(&backend);
|
assert_ticket_target_edit_semantics(&backend);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sqlite_ticket_target_columns_are_added_to_existing_table() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let conn = rusqlite::Connection::open(tmp.path().join("workspace.db")).unwrap();
|
|
||||||
conn.execute_batch("CREATE TABLE typed_tickets (ticket_id TEXT PRIMARY KEY);")
|
|
||||||
.unwrap();
|
|
||||||
ensure_sqlite_ticket_column(&conn, "repository_id", "TEXT").unwrap();
|
|
||||||
ensure_sqlite_ticket_column(&conn, "ref_selector", "TEXT").unwrap();
|
|
||||||
let mut statement = conn.prepare("PRAGMA table_info(typed_tickets)").unwrap();
|
|
||||||
let columns = statement
|
|
||||||
.query_map([], |row| row.get::<_, String>(1))
|
|
||||||
.unwrap()
|
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()
|
|
||||||
.unwrap();
|
|
||||||
assert!(columns.iter().any(|column| column == "repository_id"));
|
|
||||||
assert!(columns.iter().any(|column| column == "ref_selector"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_backend_edit_item_supports_partial_body_replacement() {
|
fn local_backend_edit_item_supports_partial_body_replacement() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
@@ -6438,7 +6442,8 @@ state: planning
|
|||||||
#[test]
|
#[test]
|
||||||
fn sqlite_backend_edit_item_supports_partial_body_replacement() {
|
fn sqlite_backend_edit_item_supports_partial_body_replacement() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
|
let backend =
|
||||||
|
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
|
||||||
assert_partial_body_replacement_semantics(&backend);
|
assert_partial_body_replacement_semantics(&backend);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6446,7 +6451,7 @@ state: planning
|
|||||||
fn sqlite_mutation_hook_failure_rolls_back_ticket_event() {
|
fn sqlite_mutation_hook_failure_rolls_back_ticket_event() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let db_path = tmp.path().join("workspace.db");
|
let db_path = tmp.path().join("workspace.db");
|
||||||
let backend = SqliteTicketBackend::new(&db_path, "workspace-test");
|
let backend = SqliteTicketBackend::open(&db_path, "workspace-test").unwrap();
|
||||||
let created = backend.create(NewTicket::new("Atomic mutation")).unwrap();
|
let created = backend.create(NewTicket::new("Atomic mutation")).unwrap();
|
||||||
let before = backend
|
let before = backend
|
||||||
.show(TicketIdOrSlug::Id(created.id.clone()))
|
.show(TicketIdOrSlug::Id(created.id.clone()))
|
||||||
@@ -6477,10 +6482,134 @@ state: planning
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_workspace_projection_preserves_order_limit_and_blockers_without_full_loads() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let db_path = tmp.path().join("workspace.db");
|
||||||
|
let backend = SqliteTicketBackend::open(&db_path, "workspace-test").unwrap();
|
||||||
|
let mut blocker_input = NewTicket::new("Blocker");
|
||||||
|
blocker_input.workflow_state = Some(TicketWorkflowState::InProgress);
|
||||||
|
let blocker = backend.create(blocker_input).unwrap();
|
||||||
|
let mut blocked_input = NewTicket::new("Blocked");
|
||||||
|
blocked_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
let blocked = backend.create(blocked_input).unwrap();
|
||||||
|
let mut newest_input = NewTicket::new("Newest");
|
||||||
|
newest_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
let newest = backend.create(newest_input).unwrap();
|
||||||
|
backend
|
||||||
|
.add_ticket_relation(
|
||||||
|
TicketIdOrSlug::Id(blocked.id.clone()),
|
||||||
|
NewTicketRelation {
|
||||||
|
kind: TicketRelationKind::DependsOn,
|
||||||
|
target: blocker.id.clone(),
|
||||||
|
note: Some("wait".to_string()),
|
||||||
|
author: Some("test".to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let connection = Connection::open(&db_path).unwrap();
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"UPDATE typed_tickets SET updated_at = CASE ticket_id
|
||||||
|
WHEN ?2 THEN '2026-08-12T03:00:00Z'
|
||||||
|
WHEN ?3 THEN '2026-08-12T02:00:00Z'
|
||||||
|
ELSE '2026-08-12T01:00:00Z' END
|
||||||
|
WHERE workspace_id = ?1",
|
||||||
|
params!["workspace-test", newest.id, blocked.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO typed_ticket_events
|
||||||
|
(workspace_id, ticket_id, event_index, kind, author, at, body)
|
||||||
|
VALUES (?1, ?2, 99, 'comment', 'test', '2026-08-12T00:00:00Z', ?3)",
|
||||||
|
params!["workspace-test", blocked.id, "full-event-marker"],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO typed_ticket_artifacts
|
||||||
|
(workspace_id, ticket_id, relative_path, content)
|
||||||
|
VALUES (?1, ?2, 'full-artifact-marker', X'01')",
|
||||||
|
params!["workspace-test", blocked.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(connection);
|
||||||
|
|
||||||
|
let full_loads_before = backend.full_ticket_load_count.load(Ordering::SeqCst);
|
||||||
|
let all_projection = backend.list_workspace_projection(3).unwrap();
|
||||||
|
let blocked_with_both_relation_ends_listed = all_projection
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.summary.id == blocked.id)
|
||||||
|
.expect("blocked Ticket is listed");
|
||||||
|
assert_eq!(
|
||||||
|
blocked_with_both_relation_ends_listed
|
||||||
|
.relation_blockers
|
||||||
|
.len(),
|
||||||
|
1,
|
||||||
|
"a relation must not duplicate when both endpoints are listed",
|
||||||
|
);
|
||||||
|
let projection = backend.list_workspace_projection(2).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
backend.full_ticket_load_count.load(Ordering::SeqCst),
|
||||||
|
full_loads_before,
|
||||||
|
"bulk projection must not run a full Ticket load per listed item",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
projection
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.summary.id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![newest.id.as_str(), blocked.id.as_str()]
|
||||||
|
);
|
||||||
|
let blocked_item = &projection.items[1];
|
||||||
|
assert_eq!(blocked_item.relation_blockers.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
blocked_item.relation_blockers[0].blocking_ticket,
|
||||||
|
blocker.id
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
blocked_item.relation_blockers[0].blocking_state,
|
||||||
|
TicketWorkflowState::InProgress
|
||||||
|
);
|
||||||
|
assert_eq!(blocked_item.relation_blockers[0].reason_kind, "depends_on");
|
||||||
|
let debug = format!("{projection:?}");
|
||||||
|
assert!(!debug.contains("full-event-marker"));
|
||||||
|
assert!(!debug.contains("full-artifact-marker"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_workspace_projection_sql_shape_is_constant_for_ticket_count() {
|
||||||
|
let source = include_str!("lib.rs");
|
||||||
|
let start = source
|
||||||
|
.find("pub fn list_workspace_projection")
|
||||||
|
.expect("projection method");
|
||||||
|
let end = source[start..]
|
||||||
|
.find("pub fn import_from_local_backend")
|
||||||
|
.map(|offset| start + offset)
|
||||||
|
.expect("following method");
|
||||||
|
let projection_source = &source[start..end];
|
||||||
|
assert_eq!(projection_source.matches("self.with_read(").count(), 1);
|
||||||
|
assert_eq!(projection_source.matches(".prepare(").count(), 2);
|
||||||
|
let item_loop = projection_source
|
||||||
|
.split("items: summaries")
|
||||||
|
.nth(1)
|
||||||
|
.expect("summary projection loop");
|
||||||
|
assert!(!item_loop.contains("open_connection("));
|
||||||
|
assert!(!item_loop.contains("verify_sqlite_ticket_schema("));
|
||||||
|
assert!(!item_loop.contains("self.load_ticket("));
|
||||||
|
assert!(!projection_source.contains("typed_ticket_events"));
|
||||||
|
assert!(!projection_source.contains("typed_ticket_event_references"));
|
||||||
|
assert!(!projection_source.contains("typed_ticket_artifacts"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sqlite_backend_persists_core_ticket_operations() {
|
fn sqlite_backend_persists_core_ticket_operations() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
|
let backend =
|
||||||
|
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
|
||||||
let created = backend.create(NewTicket::new("SQLite Ticket")).unwrap();
|
let created = backend.create(NewTicket::new("SQLite Ticket")).unwrap();
|
||||||
backend
|
backend
|
||||||
.add_event(
|
.add_event(
|
||||||
@@ -6488,12 +6617,6 @@ state: planning
|
|||||||
NewTicketEvent::new(TicketEventKind::Comment, "Imported into SQLite."),
|
NewTicketEvent::new(TicketEventKind::Comment, "Imported into SQLite."),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
backend
|
|
||||||
.review(
|
|
||||||
TicketIdOrSlug::Id(created.id.clone()),
|
|
||||||
TicketReview::approve("Looks good."),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
backend
|
backend
|
||||||
.close(
|
.close(
|
||||||
TicketIdOrSlug::Id(created.id.clone()),
|
TicketIdOrSlug::Id(created.id.clone()),
|
||||||
@@ -6501,7 +6624,9 @@ state: planning
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let reopened = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
|
let reopened =
|
||||||
|
SqliteTicketBackend::open_verified(tmp.path().join("workspace.db"), "workspace-test")
|
||||||
|
.unwrap();
|
||||||
let list = reopened.list(TicketListQuery::all()).unwrap();
|
let list = reopened.list(TicketListQuery::all()).unwrap();
|
||||||
assert_eq!(list.len(), 1);
|
assert_eq!(list.len(), 1);
|
||||||
assert_eq!(list[0].id, created.id);
|
assert_eq!(list[0].id, created.id);
|
||||||
@@ -6511,13 +6636,6 @@ state: planning
|
|||||||
assert!(ticket.events.iter().any(|event| {
|
assert!(ticket.events.iter().any(|event| {
|
||||||
event.kind == TicketEventKind::Comment && event.body.0.contains("Imported into SQLite")
|
event.kind == TicketEventKind::Comment && event.body.0.contains("Imported into SQLite")
|
||||||
}));
|
}));
|
||||||
assert!(
|
|
||||||
ticket
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.any(|event| event.kind == TicketEventKind::Review
|
|
||||||
&& event.body.0.contains("Looks good"))
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
ticket
|
ticket
|
||||||
.resolution
|
.resolution
|
||||||
@@ -6531,7 +6649,8 @@ state: planning
|
|||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let local = backend(&tmp);
|
let local = backend(&tmp);
|
||||||
let created = local.create(NewTicket::new("Legacy Ticket")).unwrap();
|
let created = local.create(NewTicket::new("Legacy Ticket")).unwrap();
|
||||||
let db = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
|
let db =
|
||||||
|
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
|
||||||
db.import_from_local_backend(&local).unwrap();
|
db.import_from_local_backend(&local).unwrap();
|
||||||
|
|
||||||
let ticket = db.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
|
let ticket = db.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
|
||||||
@@ -6630,7 +6749,7 @@ state: planning
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_event_review_status_and_close_preserve_local_layout() {
|
fn add_event_status_and_close_preserve_local_layout() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = backend(&tmp);
|
let backend = backend(&tmp);
|
||||||
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap();
|
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap();
|
||||||
@@ -6640,12 +6759,6 @@ state: planning
|
|||||||
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
|
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
backend
|
|
||||||
.review(
|
|
||||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
|
||||||
TicketReview::approve("Looks good."),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let mut summary = TicketIntakeSummary::new("Ready for queue.");
|
let mut summary = TicketIntakeSummary::new("Ready for queue.");
|
||||||
summary.author = Some("test".to_string());
|
summary.author = Some("test".to_string());
|
||||||
let mut change = TicketStateChange::new(
|
let mut change = TicketStateChange::new(
|
||||||
@@ -6669,8 +6782,6 @@ state: planning
|
|||||||
let closed_dir = tmp.path().join("tickets").join(&ticket.id);
|
let closed_dir = tmp.path().join("tickets").join(&ticket.id);
|
||||||
assert!(closed_dir.join("resolution.md").exists());
|
assert!(closed_dir.join("resolution.md").exists());
|
||||||
let thread = fs::read_to_string(closed_dir.join("thread.md")).unwrap();
|
let thread = fs::read_to_string(closed_dir.join("thread.md")).unwrap();
|
||||||
assert!(thread.contains("<!-- event: review"));
|
|
||||||
assert!(thread.contains("status: approve"));
|
|
||||||
assert!(thread.contains("<!-- event: close"));
|
assert!(thread.contains("<!-- event: close"));
|
||||||
let report = backend.doctor().unwrap();
|
let report = backend.doctor().unwrap();
|
||||||
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
||||||
@@ -6698,14 +6809,6 @@ state: planning
|
|||||||
));
|
));
|
||||||
assert_eq!(fs::read_to_string(&thread_path).unwrap(), original);
|
assert_eq!(fs::read_to_string(&thread_path).unwrap(), original);
|
||||||
|
|
||||||
let mut review = TicketReview::approve("This must not append either.");
|
|
||||||
review.author = Some("bad-->author".into());
|
|
||||||
assert!(matches!(
|
|
||||||
backend.review(TicketIdOrSlug::Id(ticket.id.clone()), review),
|
|
||||||
Err(TicketError::Conflict(_))
|
|
||||||
));
|
|
||||||
assert_eq!(fs::read_to_string(&thread_path).unwrap(), original);
|
|
||||||
|
|
||||||
let invalid_kind = NewTicketEvent::new(
|
let invalid_kind = NewTicketEvent::new(
|
||||||
TicketEventKind::Other("bad\nevent".into()),
|
TicketEventKind::Other("bad\nevent".into()),
|
||||||
"Invalid event kind.",
|
"Invalid event kind.",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,7 @@ use crate::{
|
|||||||
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, TicketRef, TicketRelation, TicketRelationKind,
|
||||||
TicketRelationView, TicketReview, TicketReviewResult, TicketStateChange, TicketSummary,
|
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author,
|
||||||
TicketWorkflowState, default_author,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||||
@@ -34,7 +33,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
|
|||||||
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
||||||
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
||||||
|
|
||||||
pub const TICKET_BASE_TOOL_NAMES: [&str; 15] = [
|
pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
"TicketList",
|
"TicketList",
|
||||||
@@ -43,7 +42,6 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 15] = [
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketReview",
|
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
@@ -69,7 +67,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
|||||||
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; 18] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
"TicketList",
|
"TicketList",
|
||||||
@@ -78,7 +76,6 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketReview",
|
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
@@ -100,14 +97,13 @@ 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; 12] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketReview",
|
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
@@ -134,8 +130,6 @@ 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 REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be `approve` or \
|
|
||||||
`request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root.";
|
|
||||||
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
|
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
|
||||||
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
|
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
|
||||||
for `state`, and transitions state to `ready`.";
|
for `state`, and transitions state to `ready`.";
|
||||||
@@ -175,7 +169,6 @@ 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,
|
||||||
"TicketReview" => REVIEW_DESCRIPTION,
|
|
||||||
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
||||||
"TicketQueue" => QUEUE_DESCRIPTION,
|
"TicketQueue" => QUEUE_DESCRIPTION,
|
||||||
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||||
@@ -319,10 +312,6 @@ impl TicketBackend for TicketToolBackend {
|
|||||||
self.backend.queue_ready(id, queued_by)
|
self.backend.queue_ready(id, queued_by)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> TicketResult<()> {
|
|
||||||
self.backend.review(id, review)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
||||||
self.backend.close(id, resolution)
|
self.backend.close(id, resolution)
|
||||||
}
|
}
|
||||||
@@ -554,23 +543,6 @@ struct TicketThreadEventParams {
|
|||||||
body: String,
|
body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
enum TicketReviewResultParam {
|
|
||||||
Approve,
|
|
||||||
RequestChanges,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
||||||
struct TicketReviewParams {
|
|
||||||
/// Ticket id.
|
|
||||||
ticket: String,
|
|
||||||
/// Review result: `approve` or `request_changes`.
|
|
||||||
result: TicketReviewResultParam,
|
|
||||||
/// Markdown review body.
|
|
||||||
body: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct TicketIntakeReadyParams {
|
struct TicketIntakeReadyParams {
|
||||||
/// Ticket id.
|
/// Ticket id.
|
||||||
@@ -839,11 +811,6 @@ struct TicketImplementationReportTool {
|
|||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct TicketReviewTool {
|
|
||||||
backend: TicketToolBackend,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TicketIntakeReadyTool {
|
struct TicketIntakeReadyTool {
|
||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
@@ -1117,34 +1084,6 @@ impl_ticket_thread_event_tool!(
|
|||||||
TicketEventKind::ImplementationReport
|
TicketEventKind::ImplementationReport
|
||||||
);
|
);
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for TicketReviewTool {
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
input_json: &str,
|
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let params: TicketReviewParams = parse_input("TicketReview", input_json)?;
|
|
||||||
let result = match params.result {
|
|
||||||
TicketReviewResultParam::Approve => TicketReviewResult::Approve,
|
|
||||||
TicketReviewResultParam::RequestChanges => TicketReviewResult::RequestChanges,
|
|
||||||
};
|
|
||||||
let result_str = result.as_str().to_string();
|
|
||||||
let review = TicketReview {
|
|
||||||
result,
|
|
||||||
author: None,
|
|
||||||
body: MarkdownText::new(params.body),
|
|
||||||
};
|
|
||||||
self.backend
|
|
||||||
.review(TicketIdOrSlug::Query(params.ticket.clone()), review)
|
|
||||||
.map_err(|error| backend_error("TicketReview", error))?;
|
|
||||||
Ok(json_output(
|
|
||||||
format!("Appended {result_str} review to ticket {}", params.ticket),
|
|
||||||
json!({ "ticket": params.ticket, "review": result_str, "ok": true }),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for TicketIntakeReadyTool {
|
impl Tool for TicketIntakeReadyTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
@@ -1731,7 +1670,6 @@ 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))
|
||||||
}
|
}
|
||||||
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
|
|
||||||
"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" => {
|
||||||
@@ -1777,7 +1715,6 @@ 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!(TicketReviewTool);
|
|
||||||
impl_from_backend!(TicketIntakeReadyTool);
|
impl_from_backend!(TicketIntakeReadyTool);
|
||||||
impl_from_backend!(TicketQueueTool);
|
impl_from_backend!(TicketQueueTool);
|
||||||
impl_from_backend!(TicketWorkflowStateTool);
|
impl_from_backend!(TicketWorkflowStateTool);
|
||||||
@@ -1804,7 +1741,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
|||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
backend.clone(),
|
backend.clone(),
|
||||||
),
|
),
|
||||||
tool_definition::<TicketReviewTool>("TicketReview", 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()),
|
||||||
@@ -1880,7 +1816,6 @@ mod tests {
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketReview",
|
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
@@ -2373,12 +2308,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ticket_tools_comment_review_state_and_close_are_doctor_clean() {
|
async fn ticket_tools_report_state_and_close_are_doctor_clean() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let backend = backend(&temp);
|
let backend = backend(&temp);
|
||||||
let created = backend.create(NewTicket::new("Flow Tool")).unwrap();
|
let created = backend.create(NewTicket::new("Flow Tool")).unwrap();
|
||||||
let report = tool_by_name(backend.clone(), "TicketImplementationReport");
|
let report = tool_by_name(backend.clone(), "TicketImplementationReport");
|
||||||
let review = tool_by_name(backend.clone(), "TicketReview");
|
|
||||||
let close = tool_by_name(backend.clone(), "TicketClose");
|
let close = tool_by_name(backend.clone(), "TicketClose");
|
||||||
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
|
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
|
||||||
|
|
||||||
@@ -2393,18 +2327,6 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
review
|
|
||||||
.execute(
|
|
||||||
&json!({
|
|
||||||
"ticket": created.id.clone(),
|
|
||||||
"result": "approve",
|
|
||||||
"body": "Looks good."
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
Default::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
close
|
close
|
||||||
.execute(
|
.execute(
|
||||||
&json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" })
|
&json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" })
|
||||||
@@ -2427,12 +2349,6 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.kind == TicketEventKind::ImplementationReport)
|
.any(|event| event.kind == TicketEventKind::ImplementationReport)
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
closed
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.any(|event| event.kind == TicketEventKind::Review)
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
closed
|
closed
|
||||||
.events
|
.events
|
||||||
@@ -2852,7 +2768,6 @@ mod tests {
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketReview",
|
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
|||||||
@@ -795,13 +795,7 @@ async fn ticket_review_action_does_not_silently_approve() {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("current action is Queue"));
|
assert!(error.to_string().contains("current action is Queue"));
|
||||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
let _ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
||||||
assert!(
|
|
||||||
!ticket
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.any(|event| event.kind == TicketEventKind::Review)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1524,7 +1524,7 @@ fn render_compact(lines: &mut Vec<Line<'static>>, evt: &CompactEvent, width: u16
|
|||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
let elapsed = elapsed_suffix(*elapsed_secs);
|
let elapsed = elapsed_suffix(*elapsed_secs);
|
||||||
(
|
(
|
||||||
format!("[compact] done (new session {short}){elapsed}"),
|
format!("[compact] done (new segment {short}){elapsed}"),
|
||||||
MessageKind::NoticeWarn,
|
MessageKind::NoticeWarn,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ workdir.workspace = true
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
llm-engine.workspace = true
|
llm-engine.workspace = true
|
||||||
|
serial_test = "3.4.0"
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
|
|||||||
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
|
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
|
||||||
const TOKEN_PREFIX: &str = "yoi-cap-v1";
|
const TOKEN_PREFIX: &str = "yoi-cap-v1";
|
||||||
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
|
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
|
||||||
|
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
|
||||||
|
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
|
||||||
|
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
|
||||||
|
pub const WORKER_REMOVE_PERMISSION: &str = "workspace:worker-remove";
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RuntimeAuthError {
|
pub enum RuntimeAuthError {
|
||||||
@@ -41,6 +45,16 @@ pub enum RuntimeAuthError {
|
|||||||
MissingWorkspaceScope,
|
MissingWorkspaceScope,
|
||||||
#[error("capability token is missing required permission `{0}`")]
|
#[error("capability token is missing required permission `{0}`")]
|
||||||
MissingPermission(String),
|
MissingPermission(String),
|
||||||
|
#[error("source proof workspace `{actual}` does not match `{expected}`")]
|
||||||
|
WrongWorkspace { expected: String, actual: String },
|
||||||
|
#[error("source proof Worker `{actual}` does not match `{expected}`")]
|
||||||
|
WrongWorker { expected: String, actual: String },
|
||||||
|
#[error("source proof actor kind is not allowed")]
|
||||||
|
WrongActorKind,
|
||||||
|
#[error("source proof operation is not allowed")]
|
||||||
|
WrongOperation,
|
||||||
|
#[error("source proof mutation target does not match the request")]
|
||||||
|
WrongMutationTarget,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -210,6 +224,201 @@ pub fn verify_capability_token(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WorkerMutationSourceClaims {
|
||||||
|
pub iss: String,
|
||||||
|
pub aud: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub actor_kind: WorkerMutationActorKind,
|
||||||
|
pub operation: WorkerMutationOperation,
|
||||||
|
pub target_runtime_id: String,
|
||||||
|
pub target_worker_id: String,
|
||||||
|
pub permission: String,
|
||||||
|
pub iat: u64,
|
||||||
|
pub exp: u64,
|
||||||
|
pub jti: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkerMutationActorKind {
|
||||||
|
Worker,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkerMutationOperation {
|
||||||
|
WorkerRemove,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RuntimeWorkerMutationSourceSigner {
|
||||||
|
runtime_id: String,
|
||||||
|
private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for RuntimeWorkerMutationSourceSigner {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("RuntimeWorkerMutationSourceSigner")
|
||||||
|
.field("runtime_id", &self.runtime_id)
|
||||||
|
.field("private_key", &"[redacted]")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeWorkerMutationSourceSigner {
|
||||||
|
pub fn from_identity(identity: &RuntimeIdentityMaterial) -> Self {
|
||||||
|
Self {
|
||||||
|
runtime_id: identity.identity_id.clone(),
|
||||||
|
private_key: identity.private_key.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runtime_id(&self) -> &str {
|
||||||
|
&self.runtime_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_worker_remove(
|
||||||
|
&self,
|
||||||
|
audience: impl Into<String>,
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
|
source_worker_id: impl Into<String>,
|
||||||
|
target_runtime_id: impl Into<String>,
|
||||||
|
target_worker_id: impl Into<String>,
|
||||||
|
ttl_seconds: u64,
|
||||||
|
) -> Result<String, RuntimeAuthError> {
|
||||||
|
let issued_at = unix_now_seconds();
|
||||||
|
let claims = WorkerMutationSourceClaims {
|
||||||
|
iss: self.runtime_id.clone(),
|
||||||
|
aud: audience.into(),
|
||||||
|
workspace_id: workspace_id.into(),
|
||||||
|
worker_id: source_worker_id.into(),
|
||||||
|
actor_kind: WorkerMutationActorKind::Worker,
|
||||||
|
operation: WorkerMutationOperation::WorkerRemove,
|
||||||
|
target_runtime_id: target_runtime_id.into(),
|
||||||
|
target_worker_id: target_worker_id.into(),
|
||||||
|
permission: WORKER_REMOVE_PERMISSION.to_string(),
|
||||||
|
iat: issued_at,
|
||||||
|
exp: issued_at.saturating_add(ttl_seconds),
|
||||||
|
jti: new_token_id()?,
|
||||||
|
};
|
||||||
|
self.sign(&claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign(&self, claims: &WorkerMutationSourceClaims) -> Result<String, RuntimeAuthError> {
|
||||||
|
if claims.iss != self.runtime_id {
|
||||||
|
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
|
||||||
|
}
|
||||||
|
let private = decode_private_key(&self.private_key)?;
|
||||||
|
let pair = Ed25519KeyPair::from_pkcs8(&private)
|
||||||
|
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
|
||||||
|
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?);
|
||||||
|
let signing_input = format!("{WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
|
||||||
|
let signature = pair.sign(signing_input.as_bytes());
|
||||||
|
Ok(format!(
|
||||||
|
"{WORKER_MUTATION_SOURCE_PROOF_PREFIX}.{payload}.{}",
|
||||||
|
URL_SAFE_NO_PAD.encode(signature.as_ref())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct WorkerMutationSourceExpectation<'a> {
|
||||||
|
pub runtime_id: &'a str,
|
||||||
|
pub audience: &'a str,
|
||||||
|
pub workspace_id: &'a str,
|
||||||
|
pub worker_id: Option<&'a str>,
|
||||||
|
pub actor_kind: WorkerMutationActorKind,
|
||||||
|
pub operation: WorkerMutationOperation,
|
||||||
|
pub target_runtime_id: &'a str,
|
||||||
|
pub target_worker_id: &'a str,
|
||||||
|
pub permission: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_worker_mutation_source_claims(
|
||||||
|
token: &str,
|
||||||
|
) -> Result<WorkerMutationSourceClaims, RuntimeAuthError> {
|
||||||
|
let (payload, _) = split_worker_mutation_source_proof(token)?;
|
||||||
|
Ok(serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_worker_mutation_source_proof(
|
||||||
|
trusted_runtime_public_key: &str,
|
||||||
|
token: &str,
|
||||||
|
expected: &WorkerMutationSourceExpectation<'_>,
|
||||||
|
now_seconds: u64,
|
||||||
|
) -> Result<WorkerMutationSourceClaims, RuntimeAuthError> {
|
||||||
|
let (payload, signature) = split_worker_mutation_source_proof(token)?;
|
||||||
|
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
|
||||||
|
let claims: WorkerMutationSourceClaims = serde_json::from_slice(&claims_json)?;
|
||||||
|
let public_key = decode_public_key(trusted_runtime_public_key)?;
|
||||||
|
let signing_input = format!("{WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
|
||||||
|
UnparsedPublicKey::new(&ED25519, public_key)
|
||||||
|
.verify(signing_input.as_bytes(), &signature)
|
||||||
|
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
|
||||||
|
|
||||||
|
if claims.iss != expected.runtime_id {
|
||||||
|
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
|
||||||
|
}
|
||||||
|
if claims.aud != expected.audience {
|
||||||
|
return Err(RuntimeAuthError::WrongAudience {
|
||||||
|
expected: expected.audience.to_string(),
|
||||||
|
actual: claims.aud,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if claims.exp <= now_seconds || claims.iat > now_seconds.saturating_add(60) {
|
||||||
|
return Err(RuntimeAuthError::Expired);
|
||||||
|
}
|
||||||
|
if claims.workspace_id != expected.workspace_id {
|
||||||
|
return Err(RuntimeAuthError::WrongWorkspace {
|
||||||
|
expected: expected.workspace_id.to_string(),
|
||||||
|
actual: claims.workspace_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(worker_id) = expected.worker_id {
|
||||||
|
if claims.worker_id != worker_id {
|
||||||
|
return Err(RuntimeAuthError::WrongWorker {
|
||||||
|
expected: worker_id.to_string(),
|
||||||
|
actual: claims.worker_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if claims.actor_kind != expected.actor_kind {
|
||||||
|
return Err(RuntimeAuthError::WrongActorKind);
|
||||||
|
}
|
||||||
|
if claims.operation != expected.operation {
|
||||||
|
return Err(RuntimeAuthError::WrongOperation);
|
||||||
|
}
|
||||||
|
if claims.target_runtime_id != expected.target_runtime_id
|
||||||
|
|| claims.target_worker_id != expected.target_worker_id
|
||||||
|
{
|
||||||
|
return Err(RuntimeAuthError::WrongMutationTarget);
|
||||||
|
}
|
||||||
|
if claims.permission != expected.permission {
|
||||||
|
return Err(RuntimeAuthError::MissingPermission(
|
||||||
|
expected.permission.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if claims.jti.trim().is_empty() {
|
||||||
|
return Err(RuntimeAuthError::InvalidTokenFormat);
|
||||||
|
}
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
|
||||||
|
let mut parts = token.split('.');
|
||||||
|
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||||
|
(Some(prefix), Some(payload), Some(signature), None)
|
||||||
|
if prefix == WORKER_MUTATION_SOURCE_PROOF_PREFIX =>
|
||||||
|
{
|
||||||
|
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
|
||||||
|
}
|
||||||
|
_ => Err(RuntimeAuthError::InvalidTokenFormat),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
|
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
|
||||||
let mut parts = token.split('.');
|
let mut parts = token.split('.');
|
||||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||||
@@ -277,6 +486,112 @@ impl fmt::Display for RuntimeAuthContext {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_mutation_source_proof_binds_all_source_authority_claims() {
|
||||||
|
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||||
|
let signer = RuntimeWorkerMutationSourceSigner::from_identity(&runtime);
|
||||||
|
let claims = WorkerMutationSourceClaims {
|
||||||
|
iss: "runtime-main".to_string(),
|
||||||
|
aud: "server-main".to_string(),
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
worker_id: "worker-7".to_string(),
|
||||||
|
actor_kind: WorkerMutationActorKind::Worker,
|
||||||
|
operation: WorkerMutationOperation::WorkerRemove,
|
||||||
|
target_runtime_id: "runtime-target".to_string(),
|
||||||
|
target_worker_id: "worker-target".to_string(),
|
||||||
|
permission: WORKER_REMOVE_PERMISSION.to_string(),
|
||||||
|
iat: 90,
|
||||||
|
exp: 100,
|
||||||
|
jti: "source-proof-1".to_string(),
|
||||||
|
};
|
||||||
|
let token = signer.sign(&claims).unwrap();
|
||||||
|
let expected = WorkerMutationSourceExpectation {
|
||||||
|
runtime_id: "runtime-main",
|
||||||
|
audience: "server-main",
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
worker_id: Some("worker-7"),
|
||||||
|
actor_kind: WorkerMutationActorKind::Worker,
|
||||||
|
operation: WorkerMutationOperation::WorkerRemove,
|
||||||
|
target_runtime_id: "runtime-target",
|
||||||
|
target_worker_id: "worker-target",
|
||||||
|
permission: WORKER_REMOVE_PERMISSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
verify_worker_mutation_source_proof(&runtime.public_key, &token, &expected, 99)
|
||||||
|
.unwrap(),
|
||||||
|
claims
|
||||||
|
);
|
||||||
|
|
||||||
|
let wrong_worker = WorkerMutationSourceExpectation {
|
||||||
|
worker_id: Some("worker-8"),
|
||||||
|
..expected.clone()
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
verify_worker_mutation_source_proof(&runtime.public_key, &token, &wrong_worker, 99),
|
||||||
|
Err(RuntimeAuthError::WrongWorker { .. })
|
||||||
|
));
|
||||||
|
let wrong_scope = WorkerMutationSourceExpectation {
|
||||||
|
workspace_id: "workspace-b",
|
||||||
|
..expected.clone()
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
verify_worker_mutation_source_proof(&runtime.public_key, &token, &wrong_scope, 99),
|
||||||
|
Err(RuntimeAuthError::WrongWorkspace { .. })
|
||||||
|
));
|
||||||
|
let wrong_audience = WorkerMutationSourceExpectation {
|
||||||
|
audience: "server-other",
|
||||||
|
..expected.clone()
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
verify_worker_mutation_source_proof(&runtime.public_key, &token, &wrong_audience, 99),
|
||||||
|
Err(RuntimeAuthError::WrongAudience { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
verify_worker_mutation_source_proof(&runtime.public_key, &token, &expected, 101),
|
||||||
|
Err(RuntimeAuthError::Expired)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_mutation_source_proof_rejects_spoofed_runtime_signature() {
|
||||||
|
let trusted = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||||
|
let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||||
|
let claims = WorkerMutationSourceClaims {
|
||||||
|
iss: "runtime-main".to_string(),
|
||||||
|
aud: "server-main".to_string(),
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
worker_id: "worker-7".to_string(),
|
||||||
|
actor_kind: WorkerMutationActorKind::Worker,
|
||||||
|
operation: WorkerMutationOperation::WorkerRemove,
|
||||||
|
target_runtime_id: "runtime-target".to_string(),
|
||||||
|
target_worker_id: "worker-target".to_string(),
|
||||||
|
permission: WORKER_REMOVE_PERMISSION.to_string(),
|
||||||
|
iat: 90,
|
||||||
|
exp: 100,
|
||||||
|
jti: "source-proof-2".to_string(),
|
||||||
|
};
|
||||||
|
let token = RuntimeWorkerMutationSourceSigner::from_identity(&spoofed)
|
||||||
|
.sign(&claims)
|
||||||
|
.unwrap();
|
||||||
|
let expected = WorkerMutationSourceExpectation {
|
||||||
|
runtime_id: "runtime-main",
|
||||||
|
audience: "server-main",
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
worker_id: Some("worker-7"),
|
||||||
|
actor_kind: WorkerMutationActorKind::Worker,
|
||||||
|
operation: WorkerMutationOperation::WorkerRemove,
|
||||||
|
target_runtime_id: "runtime-target",
|
||||||
|
target_worker_id: "worker-target",
|
||||||
|
permission: WORKER_REMOVE_PERMISSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
verify_worker_mutation_source_proof(&trusted.public_key, &token, &expected, 99),
|
||||||
|
Err(RuntimeAuthError::InvalidSignature)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capability_token_verifies_signature_audience_expiry_and_permission() {
|
fn capability_token_verifies_signature_audience_expiry_and_permission() {
|
||||||
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
|
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
|
||||||
|
|||||||
@@ -179,8 +179,6 @@ pub struct WorkingDirectoryStatus {
|
|||||||
pub struct WorkspaceApiRef {
|
pub struct WorkspaceApiRef {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub runtime_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for WorkspaceApiRef {
|
impl std::fmt::Debug for WorkspaceApiRef {
|
||||||
@@ -189,7 +187,6 @@ impl std::fmt::Debug for WorkspaceApiRef {
|
|||||||
.debug_struct("WorkspaceApiRef")
|
.debug_struct("WorkspaceApiRef")
|
||||||
.field("workspace_id", &self.workspace_id)
|
.field("workspace_id", &self.workspace_id)
|
||||||
.field("base_url", &self.base_url)
|
.field("base_url", &self.base_url)
|
||||||
.field("runtime_id", &self.runtime_id)
|
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,3 +289,31 @@ pub struct WorkerLifecycleAck {
|
|||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::WorkspaceApiRef;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_api_ref_public_schema_contains_no_source_credentials_or_claim_choices() {
|
||||||
|
let value = serde_json::to_value(WorkspaceApiRef {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
base_url: "https://server.invalid".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let object = value.as_object().unwrap();
|
||||||
|
assert_eq!(object.len(), 2);
|
||||||
|
assert!(object.contains_key("workspace_id"));
|
||||||
|
assert!(object.contains_key("base_url"));
|
||||||
|
for forbidden in [
|
||||||
|
"runtime_id",
|
||||||
|
"worker_id",
|
||||||
|
"permission",
|
||||||
|
"private_key",
|
||||||
|
"bearer_token",
|
||||||
|
"signing_handle",
|
||||||
|
] {
|
||||||
|
assert!(!object.contains_key(forbidden), "unexpected {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -245,7 +245,10 @@ impl fmt::Debug for WorkerExecutionContext {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct WorkerExecutionSpawnRequest {
|
pub struct WorkerExecutionSpawnRequest {
|
||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
|
/// Monotonic execution generation reserved durably before launch.
|
||||||
|
pub run_generation: u64,
|
||||||
pub request: crate::catalog::CreateWorkerRequest,
|
pub request: crate::catalog::CreateWorkerRequest,
|
||||||
|
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
|
||||||
pub context: WorkerExecutionContext,
|
pub context: WorkerExecutionContext,
|
||||||
pub working_directory: Option<WorkingDirectoryBinding>,
|
pub working_directory: Option<WorkingDirectoryBinding>,
|
||||||
pub config_bundle: Option<ConfigBundle>,
|
pub config_bundle: Option<ConfigBundle>,
|
||||||
@@ -255,7 +258,10 @@ pub struct WorkerExecutionSpawnRequest {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct WorkerExecutionRestoreRequest {
|
pub struct WorkerExecutionRestoreRequest {
|
||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
|
/// Monotonic execution generation reserved durably before restore.
|
||||||
|
pub run_generation: u64,
|
||||||
pub request: crate::catalog::CreateWorkerRequest,
|
pub request: crate::catalog::CreateWorkerRequest,
|
||||||
|
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
|
||||||
pub context: WorkerExecutionContext,
|
pub context: WorkerExecutionContext,
|
||||||
pub previous_working_directory: Option<WorkingDirectoryStatus>,
|
pub previous_working_directory: Option<WorkingDirectoryStatus>,
|
||||||
pub working_directory: Option<WorkingDirectoryBinding>,
|
pub working_directory: Option<WorkingDirectoryBinding>,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
const SCHEMA_VERSION: u32 = 1;
|
const SCHEMA_VERSION: u32 = 1;
|
||||||
const RUNTIME_FILE: &str = "runtime.json";
|
const RUNTIME_FILE: &str = "runtime.json";
|
||||||
const WORKERS_DIR: &str = "workers";
|
const WORKERS_DIR: &str = "workers";
|
||||||
const LEGACY_RUNTIMES_DIR: &str = "runtimes";
|
|
||||||
const WORKER_FILE: &str = "worker.json";
|
const WORKER_FILE: &str = "worker.json";
|
||||||
const LEGACY_OBSERVATIONS_FILE: &str = "observations.jsonl";
|
const LEGACY_OBSERVATIONS_FILE: &str = "observations.jsonl";
|
||||||
|
|
||||||
@@ -65,10 +64,6 @@ impl FsRuntimeStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if existed {
|
|
||||||
migrate_legacy_single_runtime_layout(&root)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::create_dir_all(root.join(WORKERS_DIR)).map_err(|source| RuntimeError::StoreIo {
|
fs::create_dir_all(root.join(WORKERS_DIR)).map_err(|source| RuntimeError::StoreIo {
|
||||||
operation: "create runtime store",
|
operation: "create runtime store",
|
||||||
path: root.join(WORKERS_DIR),
|
path: root.join(WORKERS_DIR),
|
||||||
@@ -259,6 +254,8 @@ pub(crate) struct PersistedWorkerRecord {
|
|||||||
pub(crate) worker_ref: WorkerRef,
|
pub(crate) worker_ref: WorkerRef,
|
||||||
pub(crate) worker_id: WorkerId,
|
pub(crate) worker_id: WorkerId,
|
||||||
pub(crate) request: CreateWorkerRequest,
|
pub(crate) request: CreateWorkerRequest,
|
||||||
|
/// Last generation durably reserved for this Worker's execution.
|
||||||
|
pub(crate) run_generation: u64,
|
||||||
pub(crate) workspace_id: Option<String>,
|
pub(crate) workspace_id: Option<String>,
|
||||||
pub(crate) working_directory: Option<WorkingDirectoryStatus>,
|
pub(crate) working_directory: Option<WorkingDirectoryStatus>,
|
||||||
}
|
}
|
||||||
@@ -353,6 +350,8 @@ struct WorkerSnapshot {
|
|||||||
worker_ref: WorkerRef,
|
worker_ref: WorkerRef,
|
||||||
worker_id: WorkerId,
|
worker_id: WorkerId,
|
||||||
request: CreateWorkerRequest,
|
request: CreateWorkerRequest,
|
||||||
|
#[serde(default)]
|
||||||
|
run_generation: u64,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
workspace_id: Option<String>,
|
workspace_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -376,6 +375,7 @@ impl WorkerSnapshot {
|
|||||||
worker_ref: worker.worker_ref.clone(),
|
worker_ref: worker.worker_ref.clone(),
|
||||||
worker_id: worker.worker_id.clone(),
|
worker_id: worker.worker_id.clone(),
|
||||||
request: worker.request.clone(),
|
request: worker.request.clone(),
|
||||||
|
run_generation: worker.run_generation,
|
||||||
workspace_id: worker.workspace_id.clone(),
|
workspace_id: worker.workspace_id.clone(),
|
||||||
working_directory: worker.working_directory.clone(),
|
working_directory: worker.working_directory.clone(),
|
||||||
legacy_execution: None,
|
legacy_execution: None,
|
||||||
@@ -417,6 +417,7 @@ impl WorkerSnapshot {
|
|||||||
worker_ref: self.worker_ref,
|
worker_ref: self.worker_ref,
|
||||||
worker_id: self.worker_id,
|
worker_id: self.worker_id,
|
||||||
request: self.request,
|
request: self.request,
|
||||||
|
run_generation: self.run_generation,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
working_directory: self.working_directory.or_else(|| {
|
working_directory: self.working_directory.or_else(|| {
|
||||||
self.legacy_execution
|
self.legacy_execution
|
||||||
@@ -426,78 +427,6 @@ impl WorkerSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn migrate_legacy_single_runtime_layout(root: &Path) -> Result<(), RuntimeError> {
|
|
||||||
if root.join(RUNTIME_FILE).exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let legacy_root = root.join(LEGACY_RUNTIMES_DIR);
|
|
||||||
if !legacy_root.is_dir() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut candidates = Vec::new();
|
|
||||||
for entry in fs::read_dir(&legacy_root).map_err(|source| RuntimeError::StoreIo {
|
|
||||||
operation: "read legacy runtime store root",
|
|
||||||
path: legacy_root.clone(),
|
|
||||||
source,
|
|
||||||
})? {
|
|
||||||
let entry = entry.map_err(|source| RuntimeError::StoreIo {
|
|
||||||
operation: "read legacy runtime store entry",
|
|
||||||
path: legacy_root.clone(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let path = entry.path();
|
|
||||||
if path.join(RUNTIME_FILE).is_file() {
|
|
||||||
candidates.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if candidates.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if candidates.len() > 1 {
|
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
|
||||||
operation: "migrate legacy runtime store",
|
|
||||||
path: legacy_root,
|
|
||||||
message: "multiple legacy runtime directories exist; choose a concrete fs root"
|
|
||||||
.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let legacy_dir = candidates.remove(0);
|
|
||||||
rename_if_exists(
|
|
||||||
&legacy_dir.join(RUNTIME_FILE),
|
|
||||||
&root.join(RUNTIME_FILE),
|
|
||||||
"migrate legacy runtime snapshot",
|
|
||||||
)?;
|
|
||||||
rename_if_exists(
|
|
||||||
&legacy_dir.join(WORKERS_DIR),
|
|
||||||
&root.join(WORKERS_DIR),
|
|
||||||
"migrate legacy runtime workers",
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rename_if_exists(src: &Path, dst: &Path, operation: &'static str) -> Result<(), RuntimeError> {
|
|
||||||
if !src.exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if dst.exists() {
|
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
|
||||||
operation,
|
|
||||||
path: dst.to_path_buf(),
|
|
||||||
message: format!(
|
|
||||||
"cannot migrate {} because destination already exists",
|
|
||||||
src.display()
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
fs::rename(src, dst).map_err(|source| RuntimeError::StoreIo {
|
|
||||||
operation,
|
|
||||||
path: dst.to_path_buf(),
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_json<T>(path: &Path, operation: &'static str) -> Result<T, RuntimeError>
|
fn read_json<T>(path: &Path, operation: &'static str) -> Result<T, RuntimeError>
|
||||||
where
|
where
|
||||||
T: for<'de> Deserialize<'de>,
|
T: for<'de> Deserialize<'de>,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ use crate::interaction::{WorkerInput, WorkerInteractionAck};
|
|||||||
use crate::management::{RuntimeSummary, WorkerDeleteResult};
|
use crate::management::{RuntimeSummary, WorkerDeleteResult};
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use crate::observation::WorkerObservationCursor;
|
use crate::observation::WorkerObservationCursor;
|
||||||
|
use crate::retention::{
|
||||||
|
WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory,
|
||||||
|
};
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use crate::runtime::RuntimeSubscriptionRecvError;
|
use crate::runtime::RuntimeSubscriptionRecvError;
|
||||||
use crate::{Runtime, RuntimeWorkspaceScope};
|
use crate::{Runtime, RuntimeWorkspaceScope};
|
||||||
@@ -217,6 +220,14 @@ fn runtime_http_router_with_optional_auth(
|
|||||||
"/v1/workers/{worker_id}",
|
"/v1/workers/{worker_id}",
|
||||||
get(get_worker).delete(delete_worker),
|
get(get_worker).delete(delete_worker),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/workers/{worker_id}/retention/inventory",
|
||||||
|
get(worker_retention_inventory),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/workers/{worker_id}/retention/execute",
|
||||||
|
post(execute_worker_retention),
|
||||||
|
)
|
||||||
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
|
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
|
||||||
.route("/v1/workers/{worker_id}/restore", post(restore_worker))
|
.route("/v1/workers/{worker_id}/restore", post(restore_worker))
|
||||||
.route(
|
.route(
|
||||||
@@ -1220,6 +1231,61 @@ fn protocol_error_event(message: impl Into<String>) -> protocol::Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn worker_retention_inventory(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
auth: Option<Extension<RuntimeAuthContext>>,
|
||||||
|
Path(worker_id): Path<String>,
|
||||||
|
) -> RestResult<WorkerRetentionInventory> {
|
||||||
|
let scope = auth_workspace_scope(&state, auth.as_ref())?.ok_or_else(|| {
|
||||||
|
RuntimeHttpRestError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"workspace_scope_required",
|
||||||
|
"Worker retention inventory requires workspace-scoped authorization",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
|
||||||
|
state
|
||||||
|
.runtime
|
||||||
|
.worker_retention_inventory(&scope.workspace_id, &worker_ref)
|
||||||
|
.map(Json)
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_worker_retention(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
auth: Option<Extension<RuntimeAuthContext>>,
|
||||||
|
Path(worker_id): Path<String>,
|
||||||
|
body: Result<Json<WorkerRetentionExecutionRequest>, JsonRejection>,
|
||||||
|
) -> RestResult<WorkerRetentionExecutionResult> {
|
||||||
|
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||||
|
if request.worker_id.to_string() != worker_id {
|
||||||
|
return Err(RuntimeHttpRestError::new(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"worker_id_mismatch",
|
||||||
|
"Retention request worker_id does not match the route",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let scope = auth_workspace_scope(&state, auth.as_ref())?.ok_or_else(|| {
|
||||||
|
RuntimeHttpRestError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"workspace_scope_required",
|
||||||
|
"Worker retention execution requires workspace-scoped authorization",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if request.workspace_id != scope.workspace_id {
|
||||||
|
return Err(RuntimeHttpRestError::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"worker_not_found",
|
||||||
|
"Worker was not found in the authenticated Workspace",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.runtime
|
||||||
|
.execute_worker_retention(&request)
|
||||||
|
.map(Json)
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_worker_input(
|
async fn send_worker_input(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
auth: Option<Extension<RuntimeAuthContext>>,
|
auth: Option<Extension<RuntimeAuthContext>>,
|
||||||
@@ -1473,6 +1539,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
if path.ends_with("/completions") {
|
if path.ends_with("/completions") {
|
||||||
return Some("workers:read");
|
return Some("workers:read");
|
||||||
}
|
}
|
||||||
|
if path.contains("/retention/") {
|
||||||
|
return Some("workers:delete");
|
||||||
|
}
|
||||||
if path.starts_with("/v1/workers/") && *method == Method::DELETE {
|
if path.starts_with("/v1/workers/") && *method == Method::DELETE {
|
||||||
return Some("workers:delete");
|
return Some("workers:delete");
|
||||||
}
|
}
|
||||||
@@ -1753,7 +1822,6 @@ mod tests {
|
|||||||
request.workspace_api = Some(WorkspaceApiRef {
|
request.workspace_api = Some(WorkspaceApiRef {
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
base_url: format!("https://workspace.example/{workspace_id}"),
|
base_url: format!("https://workspace.example/{workspace_id}"),
|
||||||
runtime_id: None,
|
|
||||||
});
|
});
|
||||||
request
|
request
|
||||||
}
|
}
|
||||||
@@ -2067,6 +2135,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retention_routes_require_worker_delete_permission() {
|
||||||
|
assert_eq!(
|
||||||
|
required_runtime_permission(&Method::GET, "/v1/workers/worker-1/retention/inventory",),
|
||||||
|
Some("workers:delete")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
required_runtime_permission(&Method::POST, "/v1/workers/worker-1/retention/execute",),
|
||||||
|
Some("workers:delete")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workdir_routes_require_dedicated_operation_permission() {
|
fn workdir_routes_require_dedicated_operation_permission() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2324,7 +2404,6 @@ mod tests {
|
|||||||
workspace_api: WorkspaceApiRef {
|
workspace_api: WorkspaceApiRef {
|
||||||
workspace_id: "local".to_string(),
|
workspace_id: "local".to_string(),
|
||||||
base_url: "http://127.0.0.1:8787".to_string(),
|
base_url: "http://127.0.0.1:8787".to_string(),
|
||||||
runtime_id: None,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ pub mod management;
|
|||||||
pub mod observation;
|
pub mod observation;
|
||||||
pub mod profile_archive;
|
pub mod profile_archive;
|
||||||
pub mod resource;
|
pub mod resource;
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
pub mod retention;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
pub mod worker_backend;
|
pub mod worker_backend;
|
||||||
|
pub mod worker_source;
|
||||||
pub mod working_directory;
|
pub mod working_directory;
|
||||||
|
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
|
|||||||
@@ -80,9 +80,16 @@ fn run() -> Result<(), ProcessError> {
|
|||||||
|
|
||||||
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||||
let fs_paths = config.resolved_fs_paths();
|
let fs_paths = config.resolved_fs_paths();
|
||||||
|
let runtime_store_dir = match &config.http.store {
|
||||||
|
RuntimeHttpStoreSelection::Memory => fs_paths.runtime_dir.clone(),
|
||||||
|
RuntimeHttpStoreSelection::Fs { root } => root.clone(),
|
||||||
|
_ => fs_paths.runtime_dir.clone(),
|
||||||
|
};
|
||||||
let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root"))
|
let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root"))
|
||||||
.with_store_dir(fs_paths.worker_dir.join("sessions"))
|
.with_runtime_store_dir(runtime_store_dir);
|
||||||
.with_worker_metadata_dir(fs_paths.worker_dir.join("metadata"));
|
if let Some(identity) = read_runtime_auth_file(&runtime_auth_path(config))?.identity {
|
||||||
|
factory = factory.with_remote_worker_mutation_identity(identity);
|
||||||
|
}
|
||||||
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
|
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
|
||||||
factory = factory.with_resource_client(Arc::new(
|
factory = factory.with_resource_client(Arc::new(
|
||||||
worker_runtime::resource::HttpBackendResourceClient::new(
|
worker_runtime::resource::HttpBackendResourceClient::new(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,11 @@ use crate::management::{
|
|||||||
};
|
};
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
|
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
use crate::retention::{
|
||||||
|
FsWorkerRetentionProvider, WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult,
|
||||||
|
WorkerRetentionInventory, WorkerRetentionInventorySnapshot, WorkerRetentionProvider,
|
||||||
|
};
|
||||||
use protocol::subscription::{
|
use protocol::subscription::{
|
||||||
EventSubscriptionSelector, SubscriptionEventPayload, SubscriptionSnapshot,
|
EventSubscriptionSelector, SubscriptionEventPayload, SubscriptionSnapshot,
|
||||||
SubscriptionValidationError, SubscriptionWorkdirId, SubscriptionWorker, SubscriptionWorkerId,
|
SubscriptionValidationError, SubscriptionWorkdirId, SubscriptionWorker, SubscriptionWorkerId,
|
||||||
@@ -532,13 +537,18 @@ impl Runtime {
|
|||||||
status: WorkerStatus::Stopped,
|
status: WorkerStatus::Stopped,
|
||||||
workspace_id: scope.map(|scope| scope.workspace_id.clone()),
|
workspace_id: scope.map(|scope| scope.workspace_id.clone()),
|
||||||
request: request.clone(),
|
request: request.clone(),
|
||||||
|
run_generation: 1,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
execution_handle: None,
|
execution_handle: None,
|
||||||
};
|
};
|
||||||
state.workers.insert(worker_id, record);
|
state.workers.insert(worker_id, record);
|
||||||
|
state.persist_runtime_snapshot()?;
|
||||||
|
state.persist_worker(&worker_ref.worker_id)?;
|
||||||
let spawn_request = WorkerExecutionSpawnRequest {
|
let spawn_request = WorkerExecutionSpawnRequest {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
|
run_generation: 1,
|
||||||
request,
|
request,
|
||||||
|
workspace_scope: scope.cloned(),
|
||||||
context: self.execution_context(worker_ref.clone()),
|
context: self.execution_context(worker_ref.clone()),
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
config_bundle: None,
|
config_bundle: None,
|
||||||
@@ -817,13 +827,10 @@ impl Runtime {
|
|||||||
if let Some(existing) = worker.request.workspace_api.as_ref()
|
if let Some(existing) = worker.request.workspace_api.as_ref()
|
||||||
&& (existing.workspace_id != workspace_api.workspace_id
|
&& (existing.workspace_id != workspace_api.workspace_id
|
||||||
|| existing.base_url.trim_end_matches('/')
|
|| existing.base_url.trim_end_matches('/')
|
||||||
!= workspace_api.base_url.trim_end_matches('/')
|
!= workspace_api.base_url.trim_end_matches('/'))
|
||||||
|| existing.runtime_id.as_ref().is_some_and(|runtime_id| {
|
|
||||||
workspace_api.runtime_id.as_ref() != Some(runtime_id)
|
|
||||||
}))
|
|
||||||
{
|
{
|
||||||
return Err(RuntimeError::InvalidRequest(
|
return Err(RuntimeError::InvalidRequest(
|
||||||
"Workspace API replacement cannot change Worker Workspace identity, Runtime identity, or base URL"
|
"Workspace API replacement cannot change Worker Workspace identity or base URL"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -859,35 +866,53 @@ impl Runtime {
|
|||||||
/// present this is idempotent; otherwise the configured backend is tried.
|
/// present this is idempotent; otherwise the configured backend is tried.
|
||||||
pub fn restore_worker(&self, worker_ref: &WorkerRef) -> Result<WorkerDetail, RuntimeError> {
|
pub fn restore_worker(&self, worker_ref: &WorkerRef) -> Result<WorkerDetail, RuntimeError> {
|
||||||
let (backend, request) = {
|
let (backend, request) = {
|
||||||
let state = self.lock()?;
|
let mut state = self.lock()?;
|
||||||
state.ensure_running()?;
|
state.ensure_running()?;
|
||||||
let worker = state.worker(worker_ref)?;
|
let (worker_request, previous_working_directory, config_bundle, run_generation) = {
|
||||||
if worker.execution_handle.is_some() {
|
let worker = state.worker(worker_ref)?;
|
||||||
return Ok(worker.detail());
|
if worker.execution_handle.is_some() {
|
||||||
}
|
return Ok(worker.detail());
|
||||||
if worker.status == WorkerStatus::Cancelled {
|
}
|
||||||
return Err(RuntimeError::InvalidRequest(format!(
|
if worker.status == WorkerStatus::Cancelled {
|
||||||
"worker {} is cancelled",
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
worker_ref.worker_id
|
"worker {} is cancelled",
|
||||||
)));
|
worker_ref.worker_id
|
||||||
}
|
)));
|
||||||
|
}
|
||||||
|
let config_bundle = worker
|
||||||
|
.request
|
||||||
|
.config_bundle
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|bundle_ref| state.config_bundles.get(&bundle_ref.id))
|
||||||
|
.cloned();
|
||||||
|
(
|
||||||
|
worker.request.clone(),
|
||||||
|
worker.working_directory.clone(),
|
||||||
|
config_bundle,
|
||||||
|
worker.run_generation.saturating_add(1).max(1),
|
||||||
|
)
|
||||||
|
};
|
||||||
let backend = state.execution_backend.clone().ok_or_else(|| {
|
let backend = state.execution_backend.clone().ok_or_else(|| {
|
||||||
RuntimeError::WorkerExecutionUnavailable {
|
RuntimeError::WorkerExecutionUnavailable {
|
||||||
worker_id: worker_ref.worker_id.clone(),
|
worker_id: worker_ref.worker_id.clone(),
|
||||||
message: "runtime has no execution backend".to_string(),
|
message: "runtime has no execution backend".to_string(),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
let config_bundle = worker
|
state.worker_mut(worker_ref)?.run_generation = run_generation;
|
||||||
.request
|
state.persist_worker(&worker_ref.worker_id)?;
|
||||||
.config_bundle
|
let workspace_scope = worker_request.workspace_api.as_ref().and_then(|api| {
|
||||||
.as_ref()
|
state
|
||||||
.and_then(|bundle_ref| state.config_bundles.get(&bundle_ref.id))
|
.workspace_owners
|
||||||
.cloned();
|
.get(&api.workspace_id)
|
||||||
|
.map(|server_id| RuntimeWorkspaceScope::new(&api.workspace_id, server_id))
|
||||||
|
});
|
||||||
let request = WorkerExecutionRestoreRequest {
|
let request = WorkerExecutionRestoreRequest {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
request: worker.request.clone(),
|
run_generation,
|
||||||
|
request: worker_request,
|
||||||
|
workspace_scope,
|
||||||
context: self.execution_context(worker_ref.clone()),
|
context: self.execution_context(worker_ref.clone()),
|
||||||
previous_working_directory: worker.working_directory.clone(),
|
previous_working_directory,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
config_bundle,
|
config_bundle,
|
||||||
};
|
};
|
||||||
@@ -1514,47 +1539,85 @@ impl Runtime {
|
|||||||
struct RestoreCandidate {
|
struct RestoreCandidate {
|
||||||
worker_ref: WorkerRef,
|
worker_ref: WorkerRef,
|
||||||
request: CreateWorkerRequest,
|
request: CreateWorkerRequest,
|
||||||
|
run_generation: u64,
|
||||||
previous_working_directory: Option<CatalogWorkingDirectoryStatus>,
|
previous_working_directory: Option<CatalogWorkingDirectoryStatus>,
|
||||||
config_bundle: Option<ConfigBundle>,
|
config_bundle: Option<ConfigBundle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidates = {
|
let candidates = {
|
||||||
let state = self.lock()?;
|
let mut state = self.lock()?;
|
||||||
if state.execution_backend.is_none() {
|
if state.execution_backend.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
state
|
let worker_ids = state
|
||||||
.workers
|
.workers
|
||||||
.values()
|
.values()
|
||||||
.filter(|worker| worker.execution_handle.is_none())
|
.filter(|worker| worker.execution_handle.is_none())
|
||||||
.map(|worker| {
|
.map(|worker| worker.worker_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut candidates = Vec::with_capacity(worker_ids.len());
|
||||||
|
for worker_id in worker_ids {
|
||||||
|
let (
|
||||||
|
worker_ref,
|
||||||
|
request,
|
||||||
|
previous_working_directory,
|
||||||
|
config_bundle,
|
||||||
|
run_generation,
|
||||||
|
) = {
|
||||||
|
let worker = state
|
||||||
|
.workers
|
||||||
|
.get(&worker_id)
|
||||||
|
.expect("collected Worker exists");
|
||||||
let config_bundle = worker
|
let config_bundle = worker
|
||||||
.request
|
.request
|
||||||
.config_bundle
|
.config_bundle
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|bundle_ref| state.config_bundles.get(&bundle_ref.id))
|
.and_then(|bundle_ref| state.config_bundles.get(&bundle_ref.id))
|
||||||
.cloned();
|
.cloned();
|
||||||
RestoreCandidate {
|
(
|
||||||
worker_ref: worker.worker_ref.clone(),
|
worker.worker_ref.clone(),
|
||||||
request: worker.request.clone(),
|
worker.request.clone(),
|
||||||
previous_working_directory: worker.working_directory.clone(),
|
worker.working_directory.clone(),
|
||||||
config_bundle,
|
config_bundle,
|
||||||
}
|
worker.run_generation.saturating_add(1).max(1),
|
||||||
})
|
)
|
||||||
.collect::<Vec<_>>()
|
};
|
||||||
|
state
|
||||||
|
.workers
|
||||||
|
.get_mut(&worker_id)
|
||||||
|
.expect("collected Worker exists")
|
||||||
|
.run_generation = run_generation;
|
||||||
|
state.persist_worker(&worker_id)?;
|
||||||
|
candidates.push(RestoreCandidate {
|
||||||
|
worker_ref,
|
||||||
|
request,
|
||||||
|
run_generation,
|
||||||
|
previous_working_directory,
|
||||||
|
config_bundle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
candidates
|
||||||
};
|
};
|
||||||
|
|
||||||
for candidate in candidates {
|
for candidate in candidates {
|
||||||
let backend = {
|
let (backend, workspace_scope) = {
|
||||||
let state = self.lock()?;
|
let state = self.lock()?;
|
||||||
state.execution_backend.clone()
|
let workspace_scope = candidate.request.workspace_api.as_ref().and_then(|api| {
|
||||||
|
state
|
||||||
|
.workspace_owners
|
||||||
|
.get(&api.workspace_id)
|
||||||
|
.map(|server_id| RuntimeWorkspaceScope::new(&api.workspace_id, server_id))
|
||||||
|
});
|
||||||
|
(state.execution_backend.clone(), workspace_scope)
|
||||||
};
|
};
|
||||||
let Some(backend) = backend else {
|
let Some(backend) = backend else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let request = WorkerExecutionRestoreRequest {
|
let request = WorkerExecutionRestoreRequest {
|
||||||
worker_ref: candidate.worker_ref.clone(),
|
worker_ref: candidate.worker_ref.clone(),
|
||||||
|
run_generation: candidate.run_generation,
|
||||||
request: candidate.request,
|
request: candidate.request,
|
||||||
|
workspace_scope,
|
||||||
context: self.execution_context(candidate.worker_ref.clone()),
|
context: self.execution_context(candidate.worker_ref.clone()),
|
||||||
previous_working_directory: candidate.previous_working_directory,
|
previous_working_directory: candidate.previous_working_directory,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
@@ -1602,6 +1665,139 @@ impl Runtime {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bind the Backend registry identity once. Retention evidence fails closed
|
||||||
|
/// until the Runtime host supplies this trusted configuration.
|
||||||
|
pub fn bind_runtime_identity(&self, runtime_id: &str) -> Result<(), RuntimeError> {
|
||||||
|
if runtime_id.trim().is_empty() || runtime_id.len() > 160 {
|
||||||
|
return Err(RuntimeError::InvalidRequest(
|
||||||
|
"Runtime identity must be non-empty and bounded".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut state = self.lock()?;
|
||||||
|
match state.runtime_identity.as_deref() {
|
||||||
|
Some(current) if current == runtime_id => Ok(()),
|
||||||
|
Some(_) => Err(RuntimeError::InvalidRequest(
|
||||||
|
"Runtime identity is already bound".to_string(),
|
||||||
|
)),
|
||||||
|
None => {
|
||||||
|
state.runtime_identity = Some(runtime_id.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read canonical aggregate facts needed by a Backend removal plan.
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
pub fn worker_retention_inventory(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
) -> Result<WorkerRetentionInventory, RuntimeError> {
|
||||||
|
let state = self.lock()?;
|
||||||
|
let runtime_id = state.runtime_identity.as_deref().ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest(
|
||||||
|
"Runtime identity is not bound for Worker retention".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let worker = state.worker(worker_ref)?;
|
||||||
|
if worker.workspace_id.as_deref() != Some(workspace_id) {
|
||||||
|
return Err(RuntimeError::WorkerNotFound {
|
||||||
|
worker_id: worker_ref.worker_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let store = state.fs_store().ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest(
|
||||||
|
"Worker retention archive authority requires an fs-backed Runtime".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
FsWorkerRetentionProvider::new(store.runtime_dir()).inventory(
|
||||||
|
workspace_id,
|
||||||
|
runtime_id,
|
||||||
|
worker.worker_id,
|
||||||
|
worker.run_generation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerate host-authoritative Runtime inventory for Backend orphan
|
||||||
|
/// reconciliation. Runtime identity and Workspace scope are derived here,
|
||||||
|
/// not accepted in a diagnostic payload.
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
pub fn list_worker_retention_inventory(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<WorkerRetentionInventorySnapshot, RuntimeError> {
|
||||||
|
let state = self.lock()?;
|
||||||
|
let runtime_id = state.runtime_identity.as_deref().ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest(
|
||||||
|
"Runtime identity is not bound for Worker retention".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let store = state.fs_store().ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest(
|
||||||
|
"Worker retention inventory requires an fs-backed Runtime".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let provider = FsWorkerRetentionProvider::new(store.runtime_dir());
|
||||||
|
provider.snapshot(workspace_id, runtime_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a Backend-resolved retention plan. Only stopped Workers are
|
||||||
|
/// eligible. Provider receipt lookup happens before live lookup so exact
|
||||||
|
/// retries converge after aggregate removal.
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
pub fn execute_worker_retention(
|
||||||
|
&self,
|
||||||
|
request: &WorkerRetentionExecutionRequest,
|
||||||
|
) -> Result<WorkerRetentionExecutionResult, RuntimeError> {
|
||||||
|
let mut state = self.lock()?;
|
||||||
|
let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest(
|
||||||
|
"Runtime identity is not bound for Worker retention".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if request.source_runtime_id != runtime_id {
|
||||||
|
return Err(RuntimeError::InvalidRequest(
|
||||||
|
"Worker retention Runtime identity mismatch".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let store = state.fs_store().ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest(
|
||||||
|
"Worker retention execution requires an fs-backed Runtime".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let provider = FsWorkerRetentionProvider::new(store.runtime_dir());
|
||||||
|
if let Some(completed) = provider.completed_for(request)? {
|
||||||
|
state.workers.remove(&request.worker_id);
|
||||||
|
state.persist_runtime_snapshot()?;
|
||||||
|
return Ok(completed);
|
||||||
|
}
|
||||||
|
let Some(worker) = state.workers.get(&request.worker_id) else {
|
||||||
|
// Recover a pending receipt after a crash between aggregate removal
|
||||||
|
// and final receipt/Runtime catalog commit.
|
||||||
|
return provider.recover_after_source_removal(request);
|
||||||
|
};
|
||||||
|
if worker.workspace_id.as_deref() != Some(request.workspace_id.as_str()) {
|
||||||
|
return Err(RuntimeError::WorkerNotFound {
|
||||||
|
worker_id: request.worker_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if worker.status != WorkerStatus::Stopped {
|
||||||
|
return Err(RuntimeError::InvalidRequest(
|
||||||
|
"Worker retention requires a stopped Worker".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if worker.run_generation != request.expected_run_generation {
|
||||||
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
|
"Worker retention plan expected generation {}, current generation is {}",
|
||||||
|
request.expected_run_generation, worker.run_generation
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let result = provider.execute(request)?;
|
||||||
|
state.workers.remove(&request.worker_id);
|
||||||
|
state.persist_runtime_snapshot()?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
fn lock(&self) -> Result<MutexGuard<'_, RuntimeState>, RuntimeError> {
|
fn lock(&self) -> Result<MutexGuard<'_, RuntimeState>, RuntimeError> {
|
||||||
self.inner.lock().map_err(|_| RuntimeError::StatePoisoned)
|
self.inner.lock().map_err(|_| RuntimeError::StatePoisoned)
|
||||||
}
|
}
|
||||||
@@ -1627,6 +1823,9 @@ struct SubscriptionSink {
|
|||||||
struct RuntimeState {
|
struct RuntimeState {
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
backend: RuntimeBackendKind,
|
backend: RuntimeBackendKind,
|
||||||
|
/// Backend-bound stable identity used for cross-boundary retention evidence.
|
||||||
|
/// It is configured once by the Runtime host and never model input.
|
||||||
|
runtime_identity: Option<String>,
|
||||||
#[cfg_attr(not(feature = "fs-store"), allow(dead_code))]
|
#[cfg_attr(not(feature = "fs-store"), allow(dead_code))]
|
||||||
persistence: RuntimePersistence,
|
persistence: RuntimePersistence,
|
||||||
status: RuntimeStatus,
|
status: RuntimeStatus,
|
||||||
@@ -1655,6 +1854,7 @@ impl RuntimeState {
|
|||||||
Self {
|
Self {
|
||||||
display_name,
|
display_name,
|
||||||
backend: RuntimeBackendKind::Memory,
|
backend: RuntimeBackendKind::Memory,
|
||||||
|
runtime_identity: None,
|
||||||
persistence: RuntimePersistence::Memory,
|
persistence: RuntimePersistence::Memory,
|
||||||
status: RuntimeStatus::Running,
|
status: RuntimeStatus::Running,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
@@ -1683,6 +1883,7 @@ impl RuntimeState {
|
|||||||
Self {
|
Self {
|
||||||
display_name,
|
display_name,
|
||||||
backend: RuntimeBackendKind::FsStore,
|
backend: RuntimeBackendKind::FsStore,
|
||||||
|
runtime_identity: None,
|
||||||
persistence: RuntimePersistence::Fs(store),
|
persistence: RuntimePersistence::Fs(store),
|
||||||
status: RuntimeStatus::Running,
|
status: RuntimeStatus::Running,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
@@ -1723,6 +1924,7 @@ impl RuntimeState {
|
|||||||
status: WorkerStatus::Stopped,
|
status: WorkerStatus::Stopped,
|
||||||
workspace_id: worker.workspace_id,
|
workspace_id: worker.workspace_id,
|
||||||
request: worker.request,
|
request: worker.request,
|
||||||
|
run_generation: worker.run_generation,
|
||||||
working_directory: worker.working_directory,
|
working_directory: worker.working_directory,
|
||||||
execution_handle: None,
|
execution_handle: None,
|
||||||
},
|
},
|
||||||
@@ -1732,6 +1934,7 @@ impl RuntimeState {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
display_name: persisted.display_name,
|
display_name: persisted.display_name,
|
||||||
backend: RuntimeBackendKind::FsStore,
|
backend: RuntimeBackendKind::FsStore,
|
||||||
|
runtime_identity: None,
|
||||||
persistence: RuntimePersistence::Fs(store),
|
persistence: RuntimePersistence::Fs(store),
|
||||||
status: persisted.status,
|
status: persisted.status,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
@@ -2281,6 +2484,7 @@ struct WorkerRecord {
|
|||||||
status: WorkerStatus,
|
status: WorkerStatus,
|
||||||
workspace_id: Option<String>,
|
workspace_id: Option<String>,
|
||||||
request: CreateWorkerRequest,
|
request: CreateWorkerRequest,
|
||||||
|
run_generation: u64,
|
||||||
working_directory: Option<CatalogWorkingDirectoryStatus>,
|
working_directory: Option<CatalogWorkingDirectoryStatus>,
|
||||||
execution_handle: Option<WorkerExecutionHandle>,
|
execution_handle: Option<WorkerExecutionHandle>,
|
||||||
}
|
}
|
||||||
@@ -2324,6 +2528,7 @@ impl WorkerRecord {
|
|||||||
worker_ref: self.worker_ref.clone(),
|
worker_ref: self.worker_ref.clone(),
|
||||||
worker_id: self.worker_id.clone(),
|
worker_id: self.worker_id.clone(),
|
||||||
request: self.request.clone(),
|
request: self.request.clone(),
|
||||||
|
run_generation: self.run_generation,
|
||||||
workspace_id: self.workspace_id.clone(),
|
workspace_id: self.workspace_id.clone(),
|
||||||
working_directory: self.working_directory.clone(),
|
working_directory: self.working_directory.clone(),
|
||||||
}
|
}
|
||||||
@@ -2501,6 +2706,18 @@ mod tests {
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_identity_binding_is_immutable_and_host_owned() {
|
||||||
|
let runtime = Runtime::new_memory();
|
||||||
|
runtime.bind_runtime_identity("runtime-a").unwrap();
|
||||||
|
runtime.bind_runtime_identity("runtime-a").unwrap();
|
||||||
|
assert!(runtime.bind_runtime_identity("runtime-b").is_err());
|
||||||
|
assert_eq!(
|
||||||
|
runtime.lock().unwrap().runtime_identity.as_deref(),
|
||||||
|
Some("runtime-a")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typed_segments_allow_empty_flat_content() {
|
fn typed_segments_allow_empty_flat_content() {
|
||||||
let input = WorkerInput {
|
let input = WorkerInput {
|
||||||
@@ -2585,7 +2802,6 @@ mod tests {
|
|||||||
request.workspace_api = Some(WorkspaceApiRef {
|
request.workspace_api = Some(WorkspaceApiRef {
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
base_url: format!("https://workspace.example/{workspace_id}"),
|
base_url: format!("https://workspace.example/{workspace_id}"),
|
||||||
runtime_id: None,
|
|
||||||
});
|
});
|
||||||
request
|
request
|
||||||
}
|
}
|
||||||
@@ -2627,6 +2843,7 @@ mod tests {
|
|||||||
dispatch_result: Mutex<Option<WorkerExecutionResult>>,
|
dispatch_result: Mutex<Option<WorkerExecutionResult>>,
|
||||||
restore_result: Mutex<Option<WorkerExecutionSpawnResult>>,
|
restore_result: Mutex<Option<WorkerExecutionSpawnResult>>,
|
||||||
restore_count: Mutex<u64>,
|
restore_count: Mutex<u64>,
|
||||||
|
run_generations: Mutex<Vec<u64>>,
|
||||||
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
|
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
|
||||||
dispatched_inputs: Mutex<Vec<WorkerInput>>,
|
dispatched_inputs: Mutex<Vec<WorkerInput>>,
|
||||||
preserve_commit_ack_submission_id: AtomicBool,
|
preserve_commit_ack_submission_id: AtomicBool,
|
||||||
@@ -2670,6 +2887,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
||||||
|
self.run_generations
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(request.run_generation);
|
||||||
self.contexts
|
self.contexts
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -2689,6 +2910,10 @@ mod tests {
|
|||||||
request: WorkerExecutionRestoreRequest,
|
request: WorkerExecutionRestoreRequest,
|
||||||
) -> WorkerExecutionSpawnResult {
|
) -> WorkerExecutionSpawnResult {
|
||||||
*self.restore_count.lock().unwrap() += 1;
|
*self.restore_count.lock().unwrap() += 1;
|
||||||
|
self.run_generations
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(request.run_generation);
|
||||||
if let Some(result) = self.restore_result.lock().unwrap().clone() {
|
if let Some(result) = self.restore_result.lock().unwrap().clone() {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -3025,7 +3250,6 @@ mod tests {
|
|||||||
let replacement = WorkspaceApiRef {
|
let replacement = WorkspaceApiRef {
|
||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
base_url: "https://workspace.example/workspace-a/".to_string(),
|
base_url: "https://workspace.example/workspace-a/".to_string(),
|
||||||
runtime_id: Some("runtime-a".to_string()),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
runtime
|
runtime
|
||||||
@@ -3551,6 +3775,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(*backend.restore_count.lock().unwrap(), 1);
|
assert_eq!(*backend.restore_count.lock().unwrap(), 1);
|
||||||
|
assert_eq!(*backend.run_generations.lock().unwrap(), vec![1, 2]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runtime.worker_detail(&detail.worker_ref).unwrap().status,
|
runtime.worker_detail(&detail.worker_ref).unwrap().status,
|
||||||
WorkerStatus::Idle
|
WorkerStatus::Idle
|
||||||
|
|||||||
@@ -10,10 +10,11 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex, mpsc};
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::auth::RuntimeIdentityMaterial;
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
||||||
WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
@@ -26,13 +27,19 @@ use crate::execution::{
|
|||||||
use crate::identity::WorkerRef;
|
use crate::identity::WorkerRef;
|
||||||
use crate::interaction::{WorkerInput, WorkerInputKind};
|
use crate::interaction::{WorkerInput, WorkerInputKind};
|
||||||
use crate::resource::{BackendResourceClient, ProfileSourceArchiveCache};
|
use crate::resource::{BackendResourceClient, ProfileSourceArchiveCache};
|
||||||
|
use crate::worker_source::{
|
||||||
|
EmbeddedWorkerMutationDispatcher, RuntimeOwnedWorkspaceClient, RuntimeWorkerMutationForwarder,
|
||||||
|
};
|
||||||
use crate::working_directory::{
|
use crate::working_directory::{
|
||||||
WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer,
|
WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use manifest::paths;
|
|
||||||
use protocol::{Event, Method, Segment, WorkerStatus};
|
use protocol::{Event, Method, Segment, WorkerStatus};
|
||||||
use session_store::{CombinedStore, FsStore, FsWorkerStore, LogEntry, collect_state};
|
use session_store::{
|
||||||
|
CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore, collect_state,
|
||||||
|
};
|
||||||
|
#[cfg(test)]
|
||||||
|
use session_store::{FsStore, FsWorkerStore};
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
@@ -46,10 +53,9 @@ use worker::feature::builtin::{
|
|||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
|
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
|
||||||
use worker::{
|
use worker::{
|
||||||
PromptLoader, RuntimeWorkspaceHttpClient, SegmentLogSink,
|
PromptLoader, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker,
|
||||||
WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerController, WorkerError,
|
WorkerController, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority,
|
||||||
WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState, WorkerWorkspaceContext,
|
WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
||||||
WorkspaceClient, WorkspaceId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
||||||
@@ -57,7 +63,6 @@ const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
|
|||||||
// Keep this below the adapter task timeout so a failed acknowledgement task
|
// Keep this below the adapter task timeout so a failed acknowledgement task
|
||||||
// returns a typed execution error instead of leaving the outer waiter to time out.
|
// returns a typed execution error instead of leaving the outer waiter to time out.
|
||||||
const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
|
const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
|
||||||
static NEXT_RUNTIME_ARTIFACT_ROOT: AtomicU64 = AtomicU64::new(1);
|
|
||||||
|
|
||||||
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
|
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
|
||||||
let LogEntry::UserInput { extensions, .. } = entry else {
|
let LogEntry::UserInput { extensions, .. } = entry else {
|
||||||
@@ -69,43 +74,9 @@ fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
enum RuntimeArtifactRoot {
|
|
||||||
Owned(Arc<OwnedRuntimeArtifactRoot>),
|
|
||||||
External(PathBuf),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RuntimeArtifactRoot {
|
|
||||||
fn owned() -> Self {
|
|
||||||
let sequence = NEXT_RUNTIME_ARTIFACT_ROOT.fetch_add(1, Ordering::Relaxed);
|
|
||||||
Self::Owned(Arc::new(OwnedRuntimeArtifactRoot {
|
|
||||||
path: std::env::temp_dir().join(format!(
|
|
||||||
"yoi-worker-runtime-artifacts-{}-{sequence}",
|
|
||||||
std::process::id()
|
|
||||||
)),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(&self) -> &std::path::Path {
|
|
||||||
match self {
|
|
||||||
Self::Owned(root) => &root.path,
|
|
||||||
Self::External(path) => path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct OwnedRuntimeArtifactRoot {
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for OwnedRuntimeArtifactRoot {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_dir_all(&self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RuntimeWorkerController {
|
pub struct RuntimeWorkerController {
|
||||||
pub handle: WorkerHandle,
|
pub handle: WorkerHandle,
|
||||||
|
pub shutdown: Arc<tokio::sync::Mutex<Option<worker::ShutdownReceiver>>>,
|
||||||
pub workspace_client: Arc<dyn WorkspaceClient>,
|
pub workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,11 +215,13 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider {
|
|||||||
pub struct ProfileRuntimeWorkerFactory {
|
pub struct ProfileRuntimeWorkerFactory {
|
||||||
observation_hub: Arc<RuntimeWorkerObservationHub>,
|
observation_hub: Arc<RuntimeWorkerObservationHub>,
|
||||||
profile_base_dir: PathBuf,
|
profile_base_dir: PathBuf,
|
||||||
store_dir: Option<PathBuf>,
|
worker_aggregate_root: Option<PathBuf>,
|
||||||
worker_metadata_dir: Option<PathBuf>,
|
|
||||||
runtime_base_dir: RuntimeArtifactRoot,
|
|
||||||
resource_client: Option<Arc<dyn BackendResourceClient>>,
|
resource_client: Option<Arc<dyn BackendResourceClient>>,
|
||||||
profile_archive_cache: Arc<ProfileSourceArchiveCache>,
|
profile_archive_cache: Arc<ProfileSourceArchiveCache>,
|
||||||
|
runtime_id: Option<String>,
|
||||||
|
worker_mutation_identity: Option<RuntimeIdentityMaterial>,
|
||||||
|
embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
||||||
|
controller_transport: WorkerControllerTransport,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProfileRuntimeWorkerFactory {
|
impl ProfileRuntimeWorkerFactory {
|
||||||
@@ -257,26 +230,52 @@ impl ProfileRuntimeWorkerFactory {
|
|||||||
Self {
|
Self {
|
||||||
observation_hub: Arc::new(RuntimeWorkerObservationHub::default()),
|
observation_hub: Arc::new(RuntimeWorkerObservationHub::default()),
|
||||||
profile_base_dir,
|
profile_base_dir,
|
||||||
store_dir: None,
|
worker_aggregate_root: None,
|
||||||
worker_metadata_dir: None,
|
|
||||||
runtime_base_dir: RuntimeArtifactRoot::owned(),
|
|
||||||
resource_client: None,
|
resource_client: None,
|
||||||
profile_archive_cache: Arc::new(ProfileSourceArchiveCache::default()),
|
profile_archive_cache: Arc::new(ProfileSourceArchiveCache::default()),
|
||||||
|
runtime_id: None,
|
||||||
|
worker_mutation_identity: None,
|
||||||
|
embedded_worker_mutation_dispatcher: None,
|
||||||
|
controller_transport: WorkerControllerTransport::UnixSocket,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_store_dir(mut self, store_dir: impl Into<PathBuf>) -> Self {
|
pub fn with_runtime_id(mut self, runtime_id: impl Into<String>) -> Self {
|
||||||
self.store_dir = Some(store_dir.into());
|
self.runtime_id = Some(runtime_id.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_worker_metadata_dir(mut self, worker_metadata_dir: impl Into<PathBuf>) -> Self {
|
pub fn with_remote_worker_mutation_identity(
|
||||||
self.worker_metadata_dir = Some(worker_metadata_dir.into());
|
mut self,
|
||||||
|
identity: RuntimeIdentityMaterial,
|
||||||
|
) -> Self {
|
||||||
|
self.runtime_id = Some(identity.identity_id.clone());
|
||||||
|
self.worker_mutation_identity = Some(identity);
|
||||||
|
self.embedded_worker_mutation_dispatcher = None;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_runtime_base_dir(mut self, runtime_base_dir: impl Into<PathBuf>) -> Self {
|
pub fn with_embedded_worker_mutation_dispatcher(
|
||||||
self.runtime_base_dir = RuntimeArtifactRoot::External(runtime_base_dir.into());
|
mut self,
|
||||||
|
runtime_id: impl Into<String>,
|
||||||
|
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
|
||||||
|
) -> Self {
|
||||||
|
self.runtime_id = Some(runtime_id.into());
|
||||||
|
self.worker_mutation_identity = None;
|
||||||
|
self.embedded_worker_mutation_dispatcher = Some(dispatcher);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_controller_transport(
|
||||||
|
mut self,
|
||||||
|
controller_transport: WorkerControllerTransport,
|
||||||
|
) -> Self {
|
||||||
|
self.controller_transport = controller_transport;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_runtime_store_dir(mut self, runtime_store_dir: impl Into<PathBuf>) -> Self {
|
||||||
|
self.worker_aggregate_root = Some(runtime_store_dir.into().join("workers"));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,28 +284,16 @@ impl ProfileRuntimeWorkerFactory {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn store_dir(&self) -> Result<PathBuf, String> {
|
fn worker_aggregate_dir(&self, worker_ref: &WorkerRef) -> Result<PathBuf, String> {
|
||||||
self.store_dir
|
self.worker_aggregate_root
|
||||||
.clone()
|
.as_ref()
|
||||||
.or_else(paths::sessions_dir)
|
.map(|root| root.join(worker_ref.worker_id.to_string()))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
"could not resolve sessions directory (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)"
|
"Runtime Worker aggregate root is not configured; global Session/metadata roots are migration-only"
|
||||||
.to_string()
|
.to_string()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_metadata_dir(&self, store_dir: &std::path::Path) -> PathBuf {
|
|
||||||
self.worker_metadata_dir
|
|
||||||
.clone()
|
|
||||||
.or_else(|| paths::data_dir().map(|data_dir| data_dir.join("workers")))
|
|
||||||
.or_else(|| store_dir.parent().map(|parent| parent.join("workers")))
|
|
||||||
.unwrap_or_else(|| PathBuf::from("workers"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime_base_dir(&self) -> Result<PathBuf, String> {
|
|
||||||
Ok(self.runtime_base_dir.path().to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime_worker_name_for_ref(worker_ref: &crate::identity::WorkerRef) -> String {
|
fn runtime_worker_name_for_ref(worker_ref: &crate::identity::WorkerRef) -> String {
|
||||||
format!("worker-runtime-{}", worker_ref.worker_id)
|
format!("worker-runtime-{}", worker_ref.worker_id)
|
||||||
}
|
}
|
||||||
@@ -406,38 +393,59 @@ enum RuntimeWorkspaceBackendRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeWorkspaceBackendRef {
|
impl RuntimeWorkspaceBackendRef {
|
||||||
fn from_worker_request(request: &CreateWorkerRequest) -> Self {
|
fn from_worker_request(request: &CreateWorkerRequest, runtime_id: Option<&str>) -> Self {
|
||||||
if let Some(api) = request.workspace_api.as_ref()
|
if let (Some(api), Some(runtime_id)) = (request.workspace_api.as_ref(), runtime_id) {
|
||||||
&& let Some(runtime_id) = api
|
|
||||||
.runtime_id
|
|
||||||
.as_ref()
|
|
||||||
.filter(|runtime_id| !runtime_id.trim().is_empty())
|
|
||||||
{
|
|
||||||
return Self::Http {
|
return Self::Http {
|
||||||
workspace_id: api.workspace_id.clone(),
|
workspace_id: api.workspace_id.clone(),
|
||||||
base_url: api.base_url.clone(),
|
base_url: api.base_url.clone(),
|
||||||
runtime_id: runtime_id.clone(),
|
runtime_id: runtime_id.to_string(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Self::None
|
Self::None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_context(&self, worker_ref: &WorkerRef) -> WorkerWorkspaceContext {
|
fn worker_context(
|
||||||
|
&self,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>,
|
||||||
|
mutation_identity: Option<&RuntimeIdentityMaterial>,
|
||||||
|
embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
||||||
|
) -> WorkerWorkspaceContext {
|
||||||
match self {
|
match self {
|
||||||
Self::None => WorkerWorkspaceContext::no_workspace(),
|
Self::None => WorkerWorkspaceContext::no_workspace(),
|
||||||
Self::Http {
|
Self::Http {
|
||||||
workspace_id,
|
workspace_id,
|
||||||
base_url,
|
base_url,
|
||||||
runtime_id,
|
runtime_id,
|
||||||
} => WorkerWorkspaceContext::with_client(
|
} => {
|
||||||
WorkspaceId::new(workspace_id.clone()).ok(),
|
let mut client = RuntimeOwnedWorkspaceClient::new(
|
||||||
Arc::new(RuntimeWorkspaceHttpClient::new(
|
|
||||||
workspace_id.clone(),
|
workspace_id.clone(),
|
||||||
base_url.clone(),
|
base_url.clone(),
|
||||||
runtime_id.clone(),
|
runtime_id.clone(),
|
||||||
worker_ref.worker_id.to_string(),
|
worker_ref.worker_id.to_string(),
|
||||||
)),
|
);
|
||||||
),
|
if let (Some(scope), Some(identity)) = (workspace_scope, mutation_identity) {
|
||||||
|
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote(
|
||||||
|
identity,
|
||||||
|
scope.clone(),
|
||||||
|
worker_ref.worker_id.to_string(),
|
||||||
|
base_url.clone(),
|
||||||
|
));
|
||||||
|
} else if let (Some(scope), Some(dispatcher)) =
|
||||||
|
(workspace_scope, embedded_dispatcher)
|
||||||
|
{
|
||||||
|
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::embedded(
|
||||||
|
runtime_id,
|
||||||
|
scope.clone(),
|
||||||
|
worker_ref.worker_id.to_string(),
|
||||||
|
(*dispatcher).clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
WorkerWorkspaceContext::with_client(
|
||||||
|
WorkspaceId::new(workspace_id.clone()).ok(),
|
||||||
|
Arc::new(client),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,13 +544,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or(WorkerFilesystemAuthority::None);
|
.unwrap_or(WorkerFilesystemAuthority::None);
|
||||||
let workspace_backend_ref =
|
let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request(
|
||||||
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
|
&request.request,
|
||||||
let observation_runtime_id = request
|
self.runtime_id.as_deref(),
|
||||||
.request
|
);
|
||||||
.workspace_api
|
let observation_runtime_id = self.runtime_id.clone();
|
||||||
.as_ref()
|
|
||||||
.and_then(|api| api.runtime_id.clone());
|
|
||||||
let observation_workspace_id = request
|
let observation_workspace_id = request
|
||||||
.request
|
.request
|
||||||
.workspace_api
|
.workspace_api
|
||||||
@@ -550,7 +556,12 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
.map(|api| api.workspace_id.clone());
|
.map(|api| api.workspace_id.clone());
|
||||||
let observation_grants = request.request.worker_observation_grants.clone();
|
let observation_grants = request.request.worker_observation_grants.clone();
|
||||||
let observation_enabled = request.request.worker_observation_enabled;
|
let observation_enabled = request.request.worker_observation_enabled;
|
||||||
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
|
let workspace_context = workspace_backend_ref.worker_context(
|
||||||
|
&request.worker_ref,
|
||||||
|
request.workspace_scope.as_ref(),
|
||||||
|
self.worker_mutation_identity.as_ref(),
|
||||||
|
self.embedded_worker_mutation_dispatcher.as_ref(),
|
||||||
|
);
|
||||||
let selector = profile.as_ref();
|
let selector = profile.as_ref();
|
||||||
let archive = self
|
let archive = self
|
||||||
.resolve_profile_source_archive(&request.request.profile_source)
|
.resolve_profile_source_archive(&request.request.profile_source)
|
||||||
@@ -575,20 +586,23 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
};
|
};
|
||||||
let flow_transition_enabled = manifest.feature.flow.enabled;
|
let flow_transition_enabled = manifest.feature.flow.enabled;
|
||||||
|
|
||||||
let store_dir = self.store_dir()?;
|
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
|
||||||
let session_store = FsStore::new(&store_dir).map_err(|err| {
|
let session_dir = worker_aggregate_dir.join("session");
|
||||||
|
let session_store = WorkerSessionStore::new(&session_dir).map_err(|err| {
|
||||||
format!(
|
format!(
|
||||||
"failed to initialize session store at {}: {err}",
|
"failed to initialize canonical Worker Session store at {}: {err}",
|
||||||
store_dir.display()
|
session_dir.display()
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let worker_metadata_dir = self.worker_metadata_dir(&store_dir);
|
|
||||||
let worker_metadata_store = FsWorkerStore::new(&worker_metadata_dir).map_err(|err| {
|
|
||||||
format!(
|
|
||||||
"failed to initialize worker metadata store at {}: {err}",
|
|
||||||
worker_metadata_dir.display()
|
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let worker_metadata_store =
|
||||||
|
WorkerAggregateStore::new(&worker_aggregate_dir, worker_name.clone()).map_err(
|
||||||
|
|err| {
|
||||||
|
format!(
|
||||||
|
"failed to initialize canonical Worker metadata store at {}: {err}",
|
||||||
|
worker_aggregate_dir.display()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
let store = CombinedStore::new(session_store, worker_metadata_store);
|
let store = CombinedStore::new(session_store, worker_metadata_store);
|
||||||
|
|
||||||
let mut worker = Worker::from_manifest_with_context(
|
let mut worker = Worker::from_manifest_with_context(
|
||||||
@@ -642,10 +656,21 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let workspace_client = worker.workspace_client_handle();
|
let workspace_client = worker.workspace_client_handle();
|
||||||
let runtime_base = self.runtime_base_dir()?;
|
let run_dir = worker_aggregate_dir
|
||||||
let (handle, _shutdown_rx) = WorkerController::spawn_runtime_managed(worker, &runtime_base)
|
.join("runs")
|
||||||
.await
|
.join(request.run_generation.to_string());
|
||||||
.map_err(|err| format!("failed to spawn Worker controller: {err}"))?;
|
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed_run_with_transport(
|
||||||
|
worker,
|
||||||
|
&run_dir,
|
||||||
|
self.controller_transport,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"failed to spawn Worker controller in {}: {err}",
|
||||||
|
run_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
if flow_transition_enabled {
|
if flow_transition_enabled {
|
||||||
handle.shared_state.enable_flow_transition();
|
handle.shared_state.enable_flow_transition();
|
||||||
}
|
}
|
||||||
@@ -656,6 +681,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
);
|
);
|
||||||
Ok(RuntimeWorkerController {
|
Ok(RuntimeWorkerController {
|
||||||
handle,
|
handle,
|
||||||
|
shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))),
|
||||||
workspace_client,
|
workspace_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -675,13 +701,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or(WorkerFilesystemAuthority::None);
|
.unwrap_or(WorkerFilesystemAuthority::None);
|
||||||
let workspace_backend_ref =
|
let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request(
|
||||||
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
|
&request.request,
|
||||||
let observation_runtime_id = request
|
self.runtime_id.as_deref(),
|
||||||
.request
|
);
|
||||||
.workspace_api
|
let observation_runtime_id = self.runtime_id.clone();
|
||||||
.as_ref()
|
|
||||||
.and_then(|api| api.runtime_id.clone());
|
|
||||||
let observation_workspace_id = request
|
let observation_workspace_id = request
|
||||||
.request
|
.request
|
||||||
.workspace_api
|
.workspace_api
|
||||||
@@ -689,29 +713,37 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
.map(|api| api.workspace_id.clone());
|
.map(|api| api.workspace_id.clone());
|
||||||
let observation_grants = request.request.worker_observation_grants.clone();
|
let observation_grants = request.request.worker_observation_grants.clone();
|
||||||
let observation_enabled = request.request.worker_observation_enabled;
|
let observation_enabled = request.request.worker_observation_enabled;
|
||||||
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
|
let workspace_context = workspace_backend_ref.worker_context(
|
||||||
|
&request.worker_ref,
|
||||||
|
request.workspace_scope.as_ref(),
|
||||||
|
self.worker_mutation_identity.as_ref(),
|
||||||
|
self.embedded_worker_mutation_dispatcher.as_ref(),
|
||||||
|
);
|
||||||
let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?;
|
let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?;
|
||||||
|
|
||||||
let store_dir = self.store_dir()?;
|
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
|
||||||
let session_store = FsStore::new(&store_dir).map_err(|err| {
|
let session_dir = worker_aggregate_dir.join("session");
|
||||||
|
let session_store = WorkerSessionStore::new(&session_dir).map_err(|err| {
|
||||||
format!(
|
format!(
|
||||||
"failed to initialize session store at {}: {err}",
|
"failed to initialize canonical Worker Session store at {}: {err}",
|
||||||
store_dir.display()
|
session_dir.display()
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let worker_metadata_dir = self.worker_metadata_dir(&store_dir);
|
|
||||||
let worker_metadata_store = FsWorkerStore::new(&worker_metadata_dir).map_err(|err| {
|
|
||||||
format!(
|
|
||||||
"failed to initialize worker metadata store at {}: {err}",
|
|
||||||
worker_metadata_dir.display()
|
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let worker_metadata_store =
|
||||||
|
WorkerAggregateStore::new(&worker_aggregate_dir, worker_name.clone()).map_err(
|
||||||
|
|err| {
|
||||||
|
format!(
|
||||||
|
"failed to initialize canonical Worker metadata store at {}: {err}",
|
||||||
|
worker_aggregate_dir.display()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
let store = CombinedStore::new(session_store, worker_metadata_store);
|
let store = CombinedStore::new(session_store, worker_metadata_store);
|
||||||
|
|
||||||
let mut worker = match Worker::restore_from_worker_metadata_with_context(
|
let mut worker = match Worker::restore_from_worker_metadata_with_context(
|
||||||
&worker_name,
|
&worker_name,
|
||||||
manifest.clone(),
|
manifest.clone(),
|
||||||
store,
|
store.clone(),
|
||||||
loader.clone(),
|
loader.clone(),
|
||||||
workspace_context.clone(),
|
workspace_context.clone(),
|
||||||
filesystem_authority.clone(),
|
filesystem_authority.clone(),
|
||||||
@@ -722,20 +754,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
Err(WorkerError::WorkerMetadataPending { .. })
|
Err(WorkerError::WorkerMetadataPending { .. })
|
||||||
if request.request.initial_input.is_none() =>
|
if request.request.initial_input.is_none() =>
|
||||||
{
|
{
|
||||||
let session_store = FsStore::new(&store_dir).map_err(|err| {
|
|
||||||
format!(
|
|
||||||
"failed to initialize session store at {}: {err}",
|
|
||||||
store_dir.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let worker_metadata_store =
|
|
||||||
FsWorkerStore::new(&worker_metadata_dir).map_err(|err| {
|
|
||||||
format!(
|
|
||||||
"failed to initialize worker metadata store at {}: {err}",
|
|
||||||
worker_metadata_dir.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let store = CombinedStore::new(session_store, worker_metadata_store);
|
|
||||||
Worker::restore_pending_from_worker_metadata_with_context(
|
Worker::restore_pending_from_worker_metadata_with_context(
|
||||||
&worker_name,
|
&worker_name,
|
||||||
manifest.clone(),
|
manifest.clone(),
|
||||||
@@ -792,10 +810,21 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let workspace_client = worker.workspace_client_handle();
|
let workspace_client = worker.workspace_client_handle();
|
||||||
let runtime_base = self.runtime_base_dir()?;
|
let run_dir = worker_aggregate_dir
|
||||||
let (handle, _shutdown_rx) = WorkerController::spawn_runtime_managed(worker, &runtime_base)
|
.join("runs")
|
||||||
.await
|
.join(request.run_generation.to_string());
|
||||||
.map_err(|err| format!("failed to spawn restored Worker controller: {err}"))?;
|
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed_run_with_transport(
|
||||||
|
worker,
|
||||||
|
&run_dir,
|
||||||
|
self.controller_transport,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"failed to spawn restored Worker controller in {}: {err}",
|
||||||
|
run_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
if flow_transition_enabled {
|
if flow_transition_enabled {
|
||||||
handle.shared_state.enable_flow_transition();
|
handle.shared_state.enable_flow_transition();
|
||||||
}
|
}
|
||||||
@@ -806,6 +835,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
);
|
);
|
||||||
Ok(RuntimeWorkerController {
|
Ok(RuntimeWorkerController {
|
||||||
handle,
|
handle,
|
||||||
|
shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))),
|
||||||
workspace_client,
|
workspace_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -813,6 +843,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
|
|
||||||
struct RuntimeWorkerExecution {
|
struct RuntimeWorkerExecution {
|
||||||
handle: WorkerHandle,
|
handle: WorkerHandle,
|
||||||
|
shutdown: Arc<tokio::sync::Mutex<Option<worker::ShutdownReceiver>>>,
|
||||||
busy: Arc<AtomicBool>,
|
busy: Arc<AtomicBool>,
|
||||||
workspace_client: Option<Arc<dyn WorkspaceClient>>,
|
workspace_client: Option<Arc<dyn WorkspaceClient>>,
|
||||||
}
|
}
|
||||||
@@ -828,7 +859,10 @@ pub struct WorkerRuntimeExecutionBackend<F = ProfileRuntimeWorkerFactory> {
|
|||||||
|
|
||||||
impl WorkerRuntimeExecutionBackend<ProfileRuntimeWorkerFactory> {
|
impl WorkerRuntimeExecutionBackend<ProfileRuntimeWorkerFactory> {
|
||||||
pub fn from_workspace(workspace_root: impl Into<PathBuf>) -> Result<Self, String> {
|
pub fn from_workspace(workspace_root: impl Into<PathBuf>) -> Result<Self, String> {
|
||||||
Self::new(ProfileRuntimeWorkerFactory::new(workspace_root))
|
let workspace_root = workspace_root.into();
|
||||||
|
let factory = ProfileRuntimeWorkerFactory::new(&workspace_root)
|
||||||
|
.with_runtime_store_dir(workspace_root.join(".yoi/runtime-store"));
|
||||||
|
Self::new(factory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1094,6 +1128,7 @@ where
|
|||||||
worker_ref: crate::identity::WorkerRef,
|
worker_ref: crate::identity::WorkerRef,
|
||||||
bridge_context: crate::execution::WorkerExecutionContext,
|
bridge_context: crate::execution::WorkerExecutionContext,
|
||||||
handle: WorkerHandle,
|
handle: WorkerHandle,
|
||||||
|
shutdown: Arc<tokio::sync::Mutex<Option<worker::ShutdownReceiver>>>,
|
||||||
working_directory: Option<WorkingDirectoryBinding>,
|
working_directory: Option<WorkingDirectoryBinding>,
|
||||||
workspace_client: Option<Arc<dyn WorkspaceClient>>,
|
workspace_client: Option<Arc<dyn WorkspaceClient>>,
|
||||||
) -> WorkerExecutionSpawnResult {
|
) -> WorkerExecutionSpawnResult {
|
||||||
@@ -1157,6 +1192,7 @@ where
|
|||||||
worker_ref.clone(),
|
worker_ref.clone(),
|
||||||
RuntimeWorkerExecution {
|
RuntimeWorkerExecution {
|
||||||
handle,
|
handle,
|
||||||
|
shutdown,
|
||||||
busy,
|
busy,
|
||||||
workspace_client,
|
workspace_client,
|
||||||
},
|
},
|
||||||
@@ -1393,6 +1429,7 @@ where
|
|||||||
worker_ref,
|
worker_ref,
|
||||||
bridge_context,
|
bridge_context,
|
||||||
controller.handle,
|
controller.handle,
|
||||||
|
controller.shutdown,
|
||||||
working_directory,
|
working_directory,
|
||||||
Some(controller.workspace_client),
|
Some(controller.workspace_client),
|
||||||
)
|
)
|
||||||
@@ -1489,6 +1526,7 @@ where
|
|||||||
worker_ref,
|
worker_ref,
|
||||||
bridge_context,
|
bridge_context,
|
||||||
controller.handle,
|
controller.handle,
|
||||||
|
controller.shutdown,
|
||||||
working_directory,
|
working_directory,
|
||||||
Some(controller.workspace_client),
|
Some(controller.workspace_client),
|
||||||
)
|
)
|
||||||
@@ -1698,12 +1736,28 @@ where
|
|||||||
"execution handle does not reference a live Worker",
|
"execution handle does not reference a live Worker",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
self.send_method(
|
let shutdown = execution.shutdown.clone();
|
||||||
|
let result = self.send_method(
|
||||||
WorkerExecutionOperation::Stop,
|
WorkerExecutionOperation::Stop,
|
||||||
execution.handle,
|
execution.handle,
|
||||||
Method::Shutdown,
|
Method::Shutdown,
|
||||||
WorkerExecutionRunState::Stopped,
|
WorkerExecutionRunState::Stopped,
|
||||||
)
|
);
|
||||||
|
if result.outcome != crate::execution::WorkerExecutionOutcome::Accepted {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
match self.run_on_adapter_runtime(async move {
|
||||||
|
let receiver = shutdown.lock().await.take();
|
||||||
|
if let Some(receiver) = receiver {
|
||||||
|
receiver
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Worker shutdown completion channel closed".to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}) {
|
||||||
|
Ok(()) => result,
|
||||||
|
Err(message) => WorkerExecutionResult::errored(WorkerExecutionOperation::Stop, message),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||||
@@ -1782,6 +1836,40 @@ mod tests {
|
|||||||
use manifest::{Scope, WorkerManifest};
|
use manifest::{Scope, WorkerManifest};
|
||||||
use session_store::{LogEntry, WorkerMetadataStore};
|
use session_store::{LogEntry, WorkerMetadataStore};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restart_restore_reconstructs_runtime_owned_worker_mutation_client() {
|
||||||
|
let identity = RuntimeIdentityMaterial::generate("runtime-source").unwrap();
|
||||||
|
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(17));
|
||||||
|
let backend = RuntimeWorkspaceBackendRef::Http {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
base_url: "https://server.invalid".to_string(),
|
||||||
|
runtime_id: "runtime-source".to_string(),
|
||||||
|
};
|
||||||
|
let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main");
|
||||||
|
|
||||||
|
let before_restart =
|
||||||
|
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None);
|
||||||
|
let adapter = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap();
|
||||||
|
let (after_restore_kind, after_restore_workspace_id) = adapter
|
||||||
|
.run_on_adapter_runtime(async move {
|
||||||
|
let after_restore =
|
||||||
|
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None);
|
||||||
|
let client = after_restore.client_handle();
|
||||||
|
Ok((
|
||||||
|
client.kind().to_string(),
|
||||||
|
client.workspace_id().map(str::to_string),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.expect("restore must reconstruct its Workspace client inside the adapter Runtime");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
before_restart.client_handle().kind(),
|
||||||
|
"runtime-owned-workspace-client"
|
||||||
|
);
|
||||||
|
assert_eq!(after_restore_kind, "runtime-owned-workspace-client");
|
||||||
|
assert_eq!(after_restore_workspace_id.as_deref(), Some("workspace-a"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn notify_run_state_allows_running_worker_inbox_delivery() {
|
fn notify_run_state_allows_running_worker_inbox_delivery() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1904,9 +1992,16 @@ mod tests {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|binding| binding.root().to_path_buf())
|
.map(|binding| binding.root().to_path_buf())
|
||||||
.unwrap_or_else(|| self.cwd.clone());
|
.unwrap_or_else(|| self.cwd.clone());
|
||||||
let workspace_backend_ref =
|
let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request(
|
||||||
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
|
&request.request,
|
||||||
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
|
Some("runtime-test"),
|
||||||
|
);
|
||||||
|
let workspace_context = workspace_backend_ref.worker_context(
|
||||||
|
&request.worker_ref,
|
||||||
|
request.workspace_scope.as_ref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
let workspace_client = workspace_context.client_handle();
|
let workspace_client = workspace_context.client_handle();
|
||||||
self.observed_workspace_clients.lock().unwrap().push((
|
self.observed_workspace_clients.lock().unwrap().push((
|
||||||
workspace_client.kind().to_string(),
|
workspace_client.kind().to_string(),
|
||||||
@@ -1924,12 +2019,13 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
let (handle, _shutdown_rx) =
|
let (handle, shutdown_rx) =
|
||||||
WorkerController::spawn_runtime_managed(worker, &self.runtime_base)
|
WorkerController::spawn_runtime_managed(worker, &self.runtime_base)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
Ok(RuntimeWorkerController {
|
Ok(RuntimeWorkerController {
|
||||||
handle,
|
handle,
|
||||||
|
shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))),
|
||||||
workspace_client,
|
workspace_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1939,7 +2035,9 @@ mod tests {
|
|||||||
) -> Result<RuntimeWorkerController, String> {
|
) -> Result<RuntimeWorkerController, String> {
|
||||||
let request = WorkerExecutionSpawnRequest {
|
let request = WorkerExecutionSpawnRequest {
|
||||||
worker_ref: request.worker_ref,
|
worker_ref: request.worker_ref,
|
||||||
|
run_generation: request.run_generation,
|
||||||
request: request.request,
|
request: request.request,
|
||||||
|
workspace_scope: request.workspace_scope,
|
||||||
context: request.context,
|
context: request.context,
|
||||||
working_directory: request.working_directory,
|
working_directory: request.working_directory,
|
||||||
config_bundle: request.config_bundle,
|
config_bundle: request.config_bundle,
|
||||||
@@ -1999,14 +2097,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sample_profile_archive() -> crate::profile_archive::ProfileSourceArchive {
|
fn sample_profile_archive() -> crate::profile_archive::ProfileSourceArchive {
|
||||||
let entrypoints =
|
let entrypoints = BTreeMap::from([
|
||||||
BTreeMap::from([("default".to_string(), "profiles/default.dcdl".to_string())]);
|
("default".to_string(), "profiles/default.dcdl".to_string()),
|
||||||
|
(
|
||||||
|
"builtin:default".to_string(),
|
||||||
|
"profiles/default.dcdl".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"builtin:companion".to_string(),
|
||||||
|
"profiles/default.dcdl".to_string(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
let sources = BTreeMap::from([(
|
let sources = BTreeMap::from([(
|
||||||
"profiles/default.dcdl".to_string(),
|
"profiles/default.dcdl".to_string(),
|
||||||
r#"{
|
r#"{
|
||||||
slug = "default";
|
slug = "default";
|
||||||
description = "Default";
|
description = "Default";
|
||||||
scope = "workspace_read";
|
scope = "workspace_read";
|
||||||
|
model = {
|
||||||
|
scheme = "anthropic";
|
||||||
|
model_id = "test-model";
|
||||||
|
auth = { kind = "none"; };
|
||||||
|
};
|
||||||
|
engine = { max_tokens = 100; };
|
||||||
}"#
|
}"#
|
||||||
.to_string(),
|
.to_string(),
|
||||||
)]);
|
)]);
|
||||||
@@ -2210,7 +2323,9 @@ mod tests {
|
|||||||
let worker_ref = crate::identity::WorkerRef::new(crate::identity::WorkerId::new(1));
|
let worker_ref = crate::identity::WorkerRef::new(crate::identity::WorkerId::new(1));
|
||||||
let request = WorkerExecutionSpawnRequest {
|
let request = WorkerExecutionSpawnRequest {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
|
run_generation: 1,
|
||||||
request: create_request("1"),
|
request: create_request("1"),
|
||||||
|
workspace_scope: None,
|
||||||
context: test_execution_context(worker_ref),
|
context: test_execution_context(worker_ref),
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
config_bundle: None,
|
config_bundle: None,
|
||||||
@@ -2261,11 +2376,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(worker_allocation)]
|
||||||
async fn restore_pending_worker_uses_saved_manifest_snapshot() {
|
async fn restore_pending_worker_uses_saved_manifest_snapshot() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let store_dir = root.path().join("sessions");
|
let runtime_store_dir = root.path().join("runtime");
|
||||||
let worker_metadata_dir = root.path().join("workers");
|
|
||||||
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(1));
|
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(1));
|
||||||
|
let worker_aggregate_dir = runtime_store_dir.join("workers/1");
|
||||||
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
|
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
|
||||||
let session_id = session_store::new_session_id();
|
let session_id = session_store::new_session_id();
|
||||||
let manifest = manifest::WorkerManifest::from_toml(&format!(
|
let manifest = manifest::WorkerManifest::from_toml(&format!(
|
||||||
@@ -2294,7 +2410,7 @@ mod tests {
|
|||||||
root.path().display(),
|
root.path().display(),
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
FsWorkerStore::new(&worker_metadata_dir)
|
WorkerAggregateStore::new(&worker_aggregate_dir, &worker_name)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.set_active(
|
.set_active(
|
||||||
&worker_name,
|
&worker_name,
|
||||||
@@ -2309,14 +2425,19 @@ mod tests {
|
|||||||
request.workspace_api = Some(crate::catalog::WorkspaceApiRef {
|
request.workspace_api = Some(crate::catalog::WorkspaceApiRef {
|
||||||
workspace_id: "workspace-restore".to_string(),
|
workspace_id: "workspace-restore".to_string(),
|
||||||
base_url: "http://workspace.invalid".to_string(),
|
base_url: "http://workspace.invalid".to_string(),
|
||||||
runtime_id: Some("runtime-restore".to_string()),
|
|
||||||
});
|
});
|
||||||
|
let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap();
|
||||||
let controller = ProfileRuntimeWorkerFactory::new(root.path())
|
let controller = ProfileRuntimeWorkerFactory::new(root.path())
|
||||||
.with_store_dir(&store_dir)
|
.with_remote_worker_mutation_identity(identity)
|
||||||
.with_worker_metadata_dir(&worker_metadata_dir)
|
.with_runtime_store_dir(&runtime_store_dir)
|
||||||
.restore_controller(WorkerExecutionRestoreRequest {
|
.restore_controller(WorkerExecutionRestoreRequest {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
|
run_generation: 1,
|
||||||
request,
|
request,
|
||||||
|
workspace_scope: Some(crate::runtime::RuntimeWorkspaceScope::new(
|
||||||
|
"workspace-restore",
|
||||||
|
"server-main",
|
||||||
|
)),
|
||||||
context: test_execution_context(worker_ref),
|
context: test_execution_context(worker_ref),
|
||||||
previous_working_directory: None,
|
previous_working_directory: None,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
@@ -2325,8 +2446,197 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("pending restore should use the saved manifest snapshot");
|
.expect("pending restore should use the saved manifest snapshot");
|
||||||
assert!(controller.handle.shared_state.flow_transition_enabled());
|
assert!(controller.handle.shared_state.flow_transition_enabled());
|
||||||
|
let run_dir = runtime_store_dir.join("workers/1/runs/1");
|
||||||
|
assert!(run_dir.join("worker.sock").exists());
|
||||||
|
assert!(run_dir.join("worker.out.log").is_file());
|
||||||
|
assert!(run_dir.join("worker.err.log").is_file());
|
||||||
|
assert!(run_dir.join("artifacts").is_dir());
|
||||||
|
assert!(run_dir.join("spawned").is_dir());
|
||||||
|
|
||||||
|
let shutdown = controller.shutdown.clone();
|
||||||
controller.handle.send(Method::Shutdown).await.unwrap();
|
controller.handle.send(Method::Shutdown).await.unwrap();
|
||||||
|
if let Some(receiver) = shutdown.lock().await.take() {
|
||||||
|
receiver.await.unwrap();
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
run_dir.is_dir(),
|
||||||
|
"run evidence remains until a separate retention policy disposes it"
|
||||||
|
);
|
||||||
|
assert!(!run_dir.join("worker.sock").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(worker_allocation)]
|
||||||
|
async fn in_process_restore_does_not_bind_unix_socket_under_overlong_store_path() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let long_component = "embedded-workspace-store-segment".repeat(4);
|
||||||
|
let runtime_store_dir = root.path().join(long_component);
|
||||||
|
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(1));
|
||||||
|
let worker_aggregate_dir = runtime_store_dir.join("workers/1");
|
||||||
|
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
|
||||||
|
let session_id = session_store::new_session_id();
|
||||||
|
let manifest = manifest::WorkerManifest::from_toml(&format!(
|
||||||
|
r#"
|
||||||
|
[worker]
|
||||||
|
name = "{}"
|
||||||
|
pwd = "{}"
|
||||||
|
|
||||||
|
[model]
|
||||||
|
scheme = "anthropic"
|
||||||
|
model_id = "test-model"
|
||||||
|
auth = {{ kind = "none" }}
|
||||||
|
|
||||||
|
[engine]
|
||||||
|
max_tokens = 100
|
||||||
|
|
||||||
|
[[scope.allow]]
|
||||||
|
target = "{}"
|
||||||
|
permission = "write"
|
||||||
|
"#,
|
||||||
|
worker_name,
|
||||||
|
root.path().display(),
|
||||||
|
root.path().display(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
WorkerAggregateStore::new(&worker_aggregate_dir, &worker_name)
|
||||||
|
.unwrap()
|
||||||
|
.set_active(
|
||||||
|
&worker_name,
|
||||||
|
Some(session_store::WorkerActiveSegmentRef::pending_segment(
|
||||||
|
session_id,
|
||||||
|
)),
|
||||||
|
Some(serde_json::to_value(&manifest).unwrap()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let run_dir = runtime_store_dir.join("workers/1/runs/2");
|
||||||
|
let socket_path = run_dir.join("worker.sock");
|
||||||
|
assert!(
|
||||||
|
socket_path.as_os_str().as_encoded_bytes().len() > 107,
|
||||||
|
"test path must exceed Linux sockaddr_un.sun_path capacity: {}",
|
||||||
|
socket_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let controller = ProfileRuntimeWorkerFactory::new(root.path())
|
||||||
|
.with_runtime_store_dir(&runtime_store_dir)
|
||||||
|
.with_controller_transport(WorkerControllerTransport::InProcess)
|
||||||
|
.restore_controller(WorkerExecutionRestoreRequest {
|
||||||
|
worker_ref: worker_ref.clone(),
|
||||||
|
run_generation: 2,
|
||||||
|
request: create_request("embedded restore"),
|
||||||
|
workspace_scope: None,
|
||||||
|
context: test_execution_context(worker_ref),
|
||||||
|
previous_working_directory: None,
|
||||||
|
working_directory: None,
|
||||||
|
config_bundle: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("in-process restore must not bind the overlong Unix socket path");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
controller.handle.shared_state.get_status(),
|
||||||
|
WorkerStatus::Idle
|
||||||
|
);
|
||||||
|
assert!(!socket_path.exists());
|
||||||
|
assert!(run_dir.join("worker.out.log").is_file());
|
||||||
|
assert!(run_dir.join("worker.err.log").is_file());
|
||||||
|
controller.handle.send(Method::Shutdown).await.unwrap();
|
||||||
|
if let Some(receiver) = controller.shutdown.lock().await.take() {
|
||||||
|
receiver.await.unwrap();
|
||||||
|
}
|
||||||
|
assert!(!socket_path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial(worker_allocation)]
|
||||||
|
fn in_process_runtime_reopens_persisted_worker_without_overlong_unix_socket() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let long_component = "embedded-workspace-store-segment".repeat(4);
|
||||||
|
let runtime_store_dir = root.path().join(long_component);
|
||||||
|
let runtime_options = crate::fs_store::FsRuntimeStoreOptions {
|
||||||
|
root: runtime_store_dir.clone(),
|
||||||
|
display_name: Some("embedded".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let backend = Arc::new(
|
||||||
|
WorkerRuntimeExecutionBackend::new(
|
||||||
|
ProfileRuntimeWorkerFactory::new(root.path())
|
||||||
|
.with_runtime_store_dir(&runtime_store_dir)
|
||||||
|
.with_controller_transport(WorkerControllerTransport::InProcess),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let runtime = EmbeddedRuntime::with_fs_store_and_execution_backend(
|
||||||
|
runtime_options.clone(),
|
||||||
|
backend.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||||
|
let mut request = create_request("embedded singleton");
|
||||||
|
request.profile = ProfileSelector::Builtin("default".to_string());
|
||||||
|
let worker = runtime.create_worker(request).unwrap();
|
||||||
|
let first_run_socket = runtime_store_dir.join("workers/1/runs/1/worker.sock");
|
||||||
|
assert!(
|
||||||
|
first_run_socket.as_os_str().as_encoded_bytes().len() > 107,
|
||||||
|
"test path must exceed Linux sockaddr_un.sun_path capacity: {}",
|
||||||
|
first_run_socket.display()
|
||||||
|
);
|
||||||
|
assert!(!first_run_socket.exists());
|
||||||
|
|
||||||
|
let (handle, shutdown) = {
|
||||||
|
let workers = backend.workers.lock().unwrap();
|
||||||
|
let execution = workers.get(&worker.worker_ref).unwrap();
|
||||||
|
(execution.handle.clone(), execution.shutdown.clone())
|
||||||
|
};
|
||||||
|
backend
|
||||||
|
.run_on_adapter_runtime(async move {
|
||||||
|
handle
|
||||||
|
.send(Method::Shutdown)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if let Some(receiver) = shutdown.lock().await.take() {
|
||||||
|
receiver.await.map_err(|error| error.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
drop(runtime);
|
||||||
|
drop(backend);
|
||||||
|
|
||||||
|
let restored_backend = Arc::new(
|
||||||
|
WorkerRuntimeExecutionBackend::new(
|
||||||
|
ProfileRuntimeWorkerFactory::new(root.path())
|
||||||
|
.with_runtime_store_dir(&runtime_store_dir)
|
||||||
|
.with_controller_transport(WorkerControllerTransport::InProcess),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let restored = EmbeddedRuntime::with_fs_store_and_execution_backend(
|
||||||
|
runtime_options,
|
||||||
|
restored_backend.clone(),
|
||||||
|
)
|
||||||
|
.expect("persisted in-process Worker must restore without binding its run path");
|
||||||
|
let restored_worker = restored.worker_detail(&worker.worker_ref).unwrap();
|
||||||
|
let diagnostics = restored.diagnostics().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
restored_worker.status,
|
||||||
|
crate::catalog::WorkerStatus::Idle,
|
||||||
|
"restore diagnostics: {diagnostics:#?}"
|
||||||
|
);
|
||||||
|
assert!(!diagnostics.iter().any(|diagnostic| {
|
||||||
|
diagnostic.code == "worker_execution_restore_failed"
|
||||||
|
&& diagnostic.worker_ref.as_ref() == Some(&worker.worker_ref)
|
||||||
|
}));
|
||||||
|
let restored_run = runtime_store_dir.join("workers/1/runs/2");
|
||||||
|
assert!(!restored_run.join("worker.sock").exists());
|
||||||
|
assert!(restored_run.join("worker.out.log").is_file());
|
||||||
|
assert!(restored_run.join("worker.err.log").is_file());
|
||||||
|
|
||||||
|
restored
|
||||||
|
.stop_worker(&worker.worker_ref, Some("test cleanup".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
drop(restored);
|
||||||
|
drop(restored_backend);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2428,7 +2738,6 @@ mod tests {
|
|||||||
request.workspace_api = Some(crate::catalog::WorkspaceApiRef {
|
request.workspace_api = Some(crate::catalog::WorkspaceApiRef {
|
||||||
workspace_id: "ws-test".to_string(),
|
workspace_id: "ws-test".to_string(),
|
||||||
base_url: "http://127.0.0.1:3999".to_string(),
|
base_url: "http://127.0.0.1:3999".to_string(),
|
||||||
runtime_id: Some("runtime-test".to_string()),
|
|
||||||
});
|
});
|
||||||
let detail = runtime.create_worker(request).unwrap();
|
let detail = runtime.create_worker(request).unwrap();
|
||||||
|
|
||||||
@@ -2461,7 +2770,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
observed_workspace_clients.lock().unwrap().as_slice(),
|
observed_workspace_clients.lock().unwrap().as_slice(),
|
||||||
&[(
|
&[(
|
||||||
"runtime-http-proxy".to_string(),
|
"runtime-owned-workspace-client".to_string(),
|
||||||
Some("ws-test".to_string()),
|
Some("ws-test".to_string()),
|
||||||
true,
|
true,
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -0,0 +1,694 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use worker::{
|
||||||
|
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||||
|
WorkspaceResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::auth::{
|
||||||
|
RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner,
|
||||||
|
WORKER_REMOVE_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation,
|
||||||
|
WorkerMutationSourceClaims, new_token_id,
|
||||||
|
};
|
||||||
|
use crate::runtime::RuntimeWorkspaceScope;
|
||||||
|
|
||||||
|
pub const DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS: u64 = 60;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RuntimeOwnedWorkerMutationProof {
|
||||||
|
Remote(String),
|
||||||
|
InProcess(InProcessWorkerMutationProof),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct InProcessWorkerMutationProof {
|
||||||
|
claims: WorkerMutationSourceClaims,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InProcessWorkerMutationProof {
|
||||||
|
pub fn claims(&self) -> &WorkerMutationSourceClaims {
|
||||||
|
&self.claims
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_claims(self) -> WorkerMutationSourceClaims {
|
||||||
|
self.claims
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RuntimeWorkerMutationSourceAuthority {
|
||||||
|
mode: RuntimeWorkerMutationSourceMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum RuntimeWorkerMutationSourceMode {
|
||||||
|
Remote {
|
||||||
|
signer: RuntimeWorkerMutationSourceSigner,
|
||||||
|
},
|
||||||
|
Embedded {
|
||||||
|
runtime_id: String,
|
||||||
|
audience: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeWorkerMutationSourceAuthority {
|
||||||
|
pub fn remote(identity: &RuntimeIdentityMaterial) -> Self {
|
||||||
|
Self {
|
||||||
|
mode: RuntimeWorkerMutationSourceMode::Remote {
|
||||||
|
signer: RuntimeWorkerMutationSourceSigner::from_identity(identity),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn embedded(runtime_id: impl Into<String>, workspace_id: impl AsRef<str>) -> Self {
|
||||||
|
Self {
|
||||||
|
mode: RuntimeWorkerMutationSourceMode::Embedded {
|
||||||
|
runtime_id: runtime_id.into(),
|
||||||
|
audience: format!("embedded:{}", workspace_id.as_ref()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_worker_remove(
|
||||||
|
&self,
|
||||||
|
scope: &RuntimeWorkspaceScope,
|
||||||
|
source_worker_id: &str,
|
||||||
|
target_runtime_id: &str,
|
||||||
|
target_worker_id: &str,
|
||||||
|
) -> Result<RuntimeOwnedWorkerMutationProof, RuntimeAuthError> {
|
||||||
|
match &self.mode {
|
||||||
|
RuntimeWorkerMutationSourceMode::Remote { signer } => {
|
||||||
|
let token = signer.issue_worker_remove(
|
||||||
|
&scope.server_id,
|
||||||
|
&scope.workspace_id,
|
||||||
|
source_worker_id,
|
||||||
|
target_runtime_id,
|
||||||
|
target_worker_id,
|
||||||
|
DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS,
|
||||||
|
)?;
|
||||||
|
Ok(RuntimeOwnedWorkerMutationProof::Remote(token))
|
||||||
|
}
|
||||||
|
RuntimeWorkerMutationSourceMode::Embedded {
|
||||||
|
runtime_id,
|
||||||
|
audience,
|
||||||
|
} => {
|
||||||
|
let issued_at = unix_now_seconds();
|
||||||
|
Ok(RuntimeOwnedWorkerMutationProof::InProcess(
|
||||||
|
InProcessWorkerMutationProof {
|
||||||
|
claims: WorkerMutationSourceClaims {
|
||||||
|
iss: runtime_id.clone(),
|
||||||
|
aud: audience.clone(),
|
||||||
|
workspace_id: scope.workspace_id.clone(),
|
||||||
|
worker_id: source_worker_id.to_string(),
|
||||||
|
actor_kind: WorkerMutationActorKind::Worker,
|
||||||
|
operation: WorkerMutationOperation::WorkerRemove,
|
||||||
|
target_runtime_id: target_runtime_id.to_string(),
|
||||||
|
target_worker_id: target_worker_id.to_string(),
|
||||||
|
permission: WORKER_REMOVE_PERMISSION.to_string(),
|
||||||
|
iat: issued_at,
|
||||||
|
exp: issued_at
|
||||||
|
.saturating_add(DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS),
|
||||||
|
jti: new_token_id()?,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
|
||||||
|
fn execute_worker_remove(
|
||||||
|
&self,
|
||||||
|
proof: InProcessWorkerMutationProof,
|
||||||
|
target_runtime_id: &str,
|
||||||
|
target_worker_id: &str,
|
||||||
|
expected_worker_revision: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum RuntimeWorkerMutationTransport {
|
||||||
|
Remote {
|
||||||
|
base_url: String,
|
||||||
|
},
|
||||||
|
Embedded {
|
||||||
|
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RuntimeWorkerMutationForwarder {
|
||||||
|
authority: RuntimeWorkerMutationSourceAuthority,
|
||||||
|
scope: RuntimeWorkspaceScope,
|
||||||
|
source_worker_id: String,
|
||||||
|
transport: RuntimeWorkerMutationTransport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeWorkerMutationForwarder {
|
||||||
|
pub fn remote(
|
||||||
|
identity: &RuntimeIdentityMaterial,
|
||||||
|
scope: RuntimeWorkspaceScope,
|
||||||
|
source_worker_id: impl Into<String>,
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
|
||||||
|
scope,
|
||||||
|
source_worker_id: source_worker_id.into(),
|
||||||
|
transport: RuntimeWorkerMutationTransport::Remote {
|
||||||
|
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn embedded(
|
||||||
|
runtime_id: impl Into<String>,
|
||||||
|
scope: RuntimeWorkspaceScope,
|
||||||
|
source_worker_id: impl Into<String>,
|
||||||
|
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
|
||||||
|
) -> Self {
|
||||||
|
let runtime_id = runtime_id.into();
|
||||||
|
Self {
|
||||||
|
authority: RuntimeWorkerMutationSourceAuthority::embedded(
|
||||||
|
&runtime_id,
|
||||||
|
&scope.workspace_id,
|
||||||
|
),
|
||||||
|
scope,
|
||||||
|
source_worker_id: source_worker_id.into(),
|
||||||
|
transport: RuntimeWorkerMutationTransport::Embedded { dispatcher },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_worker_remove(
|
||||||
|
&self,
|
||||||
|
target_runtime_id: &str,
|
||||||
|
target_worker_id: &str,
|
||||||
|
expected_worker_revision: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||||
|
let proof = self.authority.issue_worker_remove(
|
||||||
|
&self.scope,
|
||||||
|
&self.source_worker_id,
|
||||||
|
target_runtime_id,
|
||||||
|
target_worker_id,
|
||||||
|
)?;
|
||||||
|
match (&self.transport, proof) {
|
||||||
|
(
|
||||||
|
RuntimeWorkerMutationTransport::Remote { base_url },
|
||||||
|
RuntimeOwnedWorkerMutationProof::Remote(token),
|
||||||
|
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
|
||||||
|
base_url: base_url.clone(),
|
||||||
|
workspace_id: self.scope.workspace_id.clone(),
|
||||||
|
token,
|
||||||
|
target_runtime_id: target_runtime_id.to_string(),
|
||||||
|
target_worker_id: target_worker_id.to_string(),
|
||||||
|
expected_worker_revision: expected_worker_revision.to_string(),
|
||||||
|
reason: reason.to_string(),
|
||||||
|
}),
|
||||||
|
(
|
||||||
|
RuntimeWorkerMutationTransport::Embedded { dispatcher },
|
||||||
|
RuntimeOwnedWorkerMutationProof::InProcess(claims),
|
||||||
|
) => dispatcher.execute_worker_remove(
|
||||||
|
claims,
|
||||||
|
target_runtime_id,
|
||||||
|
target_worker_id,
|
||||||
|
expected_worker_revision,
|
||||||
|
reason,
|
||||||
|
),
|
||||||
|
_ => Err(RuntimeWorkerMutationForwardError::AuthorityTransportMismatch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RemoteWorkerRemoveHttpRequest {
|
||||||
|
base_url: String,
|
||||||
|
workspace_id: String,
|
||||||
|
token: String,
|
||||||
|
target_runtime_id: String,
|
||||||
|
target_worker_id: String,
|
||||||
|
expected_worker_revision: String,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_remote_worker_remove_http(
|
||||||
|
request: RemoteWorkerRemoveHttpRequest,
|
||||||
|
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||||
|
if tokio::runtime::Handle::try_current().is_ok() {
|
||||||
|
return std::thread::Builder::new()
|
||||||
|
.name("yoi-worker-mutation-http".to_string())
|
||||||
|
.spawn(move || execute_remote_worker_remove_http_blocking(request))
|
||||||
|
.map_err(|error| {
|
||||||
|
RuntimeWorkerMutationForwardError::Transport(format!(
|
||||||
|
"failed to start Worker mutation HTTP thread: {error}"
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.join()
|
||||||
|
.map_err(|_| {
|
||||||
|
RuntimeWorkerMutationForwardError::Transport(
|
||||||
|
"Worker mutation HTTP thread panicked".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute_remote_worker_remove_http_blocking(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_remote_worker_remove_http_blocking(
|
||||||
|
request: RemoteWorkerRemoveHttpRequest,
|
||||||
|
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/w/{}/workers/remove",
|
||||||
|
request.base_url, request.workspace_id
|
||||||
|
);
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"target_runtime_id": request.target_runtime_id,
|
||||||
|
"target_worker_id": request.target_worker_id,
|
||||||
|
"expected_worker_revision": request.expected_worker_revision,
|
||||||
|
"reason": request.reason,
|
||||||
|
});
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(url)
|
||||||
|
.header(
|
||||||
|
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
|
||||||
|
request.token,
|
||||||
|
)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||||
|
Ok(WorkspaceResponse { status, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RuntimeOwnedWorkspaceClient {
|
||||||
|
workspace_id: String,
|
||||||
|
base_url: String,
|
||||||
|
runtime_id: String,
|
||||||
|
worker_id: String,
|
||||||
|
worker_remove: Option<RuntimeWorkerMutationForwarder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeOwnedWorkspaceClient {
|
||||||
|
pub fn new(
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
runtime_id: impl Into<String>,
|
||||||
|
worker_id: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
workspace_id: workspace_id.into(),
|
||||||
|
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||||
|
runtime_id: runtime_id.into(),
|
||||||
|
worker_id: worker_id.into(),
|
||||||
|
worker_remove: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_worker_remove(mut self, worker_remove: RuntimeWorkerMutationForwarder) -> Self {
|
||||||
|
self.worker_remove = Some(worker_remove);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("RuntimeOwnedWorkspaceClient")
|
||||||
|
.field("workspace_id", &self.workspace_id)
|
||||||
|
.field("base_url", &self.base_url)
|
||||||
|
.field("source", &"Runtime-owned")
|
||||||
|
.field(
|
||||||
|
"worker_remove",
|
||||||
|
&self.worker_remove.as_ref().map(|_| "enabled"),
|
||||||
|
)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||||
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
|
Some(&self.workspace_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> &str {
|
||||||
|
"runtime-owned-workspace-client"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
let base_url = self.base_url.clone();
|
||||||
|
let runtime_id = self.runtime_id.clone();
|
||||||
|
let worker_id = self.worker_id.clone();
|
||||||
|
if tokio::runtime::Handle::try_current().is_ok() {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
execute_runtime_owned_workspace_http(&base_url, &runtime_id, &worker_id, request)
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.map_err(|_| {
|
||||||
|
WorkspaceClientError::Request("workspace request thread panicked".to_string())
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
execute_runtime_owned_workspace_http(
|
||||||
|
&self.base_url,
|
||||||
|
&self.runtime_id,
|
||||||
|
&self.worker_id,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_worker_remove(
|
||||||
|
&self,
|
||||||
|
target_runtime_id: &str,
|
||||||
|
target_worker_id: &str,
|
||||||
|
expected_worker_revision: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
self.worker_remove
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
WorkspaceClientError::Unavailable(
|
||||||
|
"Runtime-owned WorkerRemove forwarding is unavailable".to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.execute_worker_remove(
|
||||||
|
target_runtime_id,
|
||||||
|
target_worker_id,
|
||||||
|
expected_worker_revision,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_runtime_owned_workspace_http(
|
||||||
|
base_url: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
request: WorkspaceRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
if !request.path.starts_with('/') || request.path.starts_with("//") {
|
||||||
|
return Err(WorkspaceClientError::InvalidPath(request.path));
|
||||||
|
}
|
||||||
|
let url = format!("{base_url}{}", request.path);
|
||||||
|
let method = match request.method {
|
||||||
|
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
||||||
|
WorkspaceRequestMethod::Post => reqwest::Method::POST,
|
||||||
|
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
|
||||||
|
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
|
||||||
|
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
|
||||||
|
};
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let mut request_builder = client
|
||||||
|
.request(method, url)
|
||||||
|
.header("x-yoi-runtime-id", runtime_id)
|
||||||
|
.header("x-yoi-worker-id", worker_id);
|
||||||
|
if let Some(body) = request.body {
|
||||||
|
request_builder = request_builder
|
||||||
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
let response = request_builder
|
||||||
|
.send()
|
||||||
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||||
|
Ok(WorkspaceResponse { status, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum RuntimeWorkerMutationForwardError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Auth(#[from] RuntimeAuthError),
|
||||||
|
#[error("Worker mutation forwarding transport failed: {0}")]
|
||||||
|
Transport(String),
|
||||||
|
#[error("Worker mutation source authority does not match its forwarding transport")]
|
||||||
|
AuthorityTransportMismatch,
|
||||||
|
#[error("embedded Worker mutation dispatcher failed: {0}")]
|
||||||
|
Embedded(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now_seconds() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::auth::{
|
||||||
|
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims,
|
||||||
|
verify_worker_mutation_source_proof,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_workspace_forwarding_stamps_legacy_source_only_inside_runtime() {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let received = Arc::new(Mutex::new(String::new()));
|
||||||
|
let received_for_server = received.clone();
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut bytes = [0_u8; 4096];
|
||||||
|
let count = stream.read(&mut bytes).unwrap();
|
||||||
|
*received_for_server.lock().unwrap() =
|
||||||
|
String::from_utf8_lossy(&bytes[..count]).into_owned();
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}")
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = RuntimeOwnedWorkspaceClient::new(
|
||||||
|
"workspace-a",
|
||||||
|
format!("http://{address}"),
|
||||||
|
"runtime-a",
|
||||||
|
"worker-a",
|
||||||
|
);
|
||||||
|
let response = client
|
||||||
|
.execute(WorkspaceRequest::get("/api/w/workspace-a/tickets/search"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status, 200);
|
||||||
|
server.join().unwrap();
|
||||||
|
let request = received.lock().unwrap().to_ascii_lowercase();
|
||||||
|
assert!(request.contains("x-yoi-runtime-id: runtime-a"));
|
||||||
|
assert!(request.contains("x-yoi-worker-id: worker-a"));
|
||||||
|
assert!(!request.contains("authorization:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_authority_stamps_and_signs_worker_remove_without_caller_claim_choices() {
|
||||||
|
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||||
|
let authority = RuntimeWorkerMutationSourceAuthority::remote(&identity);
|
||||||
|
let scope = RuntimeWorkspaceScope {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
server_id: "server-a".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let RuntimeOwnedWorkerMutationProof::Remote(token) = authority
|
||||||
|
.issue_worker_remove(&scope, "worker-source", "runtime-b", "worker-target")
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("remote authority must produce a signed proof");
|
||||||
|
};
|
||||||
|
let claims = decode_worker_mutation_source_claims(&token).unwrap();
|
||||||
|
let expected = WorkerMutationSourceExpectation {
|
||||||
|
runtime_id: "runtime-a",
|
||||||
|
audience: "server-a",
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
worker_id: Some("worker-source"),
|
||||||
|
actor_kind: WorkerMutationActorKind::Worker,
|
||||||
|
operation: WorkerMutationOperation::WorkerRemove,
|
||||||
|
target_runtime_id: "runtime-b",
|
||||||
|
target_worker_id: "worker-target",
|
||||||
|
permission: WORKER_REMOVE_PERMISSION,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
verify_worker_mutation_source_proof(
|
||||||
|
&identity.public_key,
|
||||||
|
&token,
|
||||||
|
&expected,
|
||||||
|
claims.iat
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
claims
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn remote_forwarder_is_safe_in_async_runtime_and_stamps_signed_proof() {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let received = Arc::new(Mutex::new(String::new()));
|
||||||
|
let received_for_server = received.clone();
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut bytes = [0_u8; 8192];
|
||||||
|
let count = stream.read(&mut bytes).unwrap();
|
||||||
|
*received_for_server.lock().unwrap() =
|
||||||
|
String::from_utf8_lossy(&bytes[..count]).into_owned();
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||||
|
let scope = RuntimeWorkspaceScope::new("workspace-a", "server-a");
|
||||||
|
let forwarder = RuntimeWorkerMutationForwarder::remote(
|
||||||
|
&identity,
|
||||||
|
scope,
|
||||||
|
"worker-source",
|
||||||
|
format!("http://{address}"),
|
||||||
|
);
|
||||||
|
let response = forwarder
|
||||||
|
.execute_worker_remove(
|
||||||
|
"runtime-target",
|
||||||
|
"worker-target",
|
||||||
|
"revision-7",
|
||||||
|
"retire obsolete Worker",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status, 204);
|
||||||
|
server.join().unwrap();
|
||||||
|
|
||||||
|
let request = received.lock().unwrap().clone();
|
||||||
|
assert!(request.starts_with("POST /api/w/workspace-a/workers/remove HTTP/1.1"));
|
||||||
|
assert!(request.contains("\"target_runtime_id\":\"runtime-target\""));
|
||||||
|
assert!(request.contains("\"target_worker_id\":\"worker-target\""));
|
||||||
|
assert!(request.contains("\"expected_worker_revision\":\"revision-7\""));
|
||||||
|
assert!(request.contains("\"reason\":\"retire obsolete Worker\""));
|
||||||
|
let token = request
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| {
|
||||||
|
line.split_once(':').and_then(|(name, value)| {
|
||||||
|
name.eq_ignore_ascii_case(crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER)
|
||||||
|
.then(|| value.trim())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.expect("proof header");
|
||||||
|
let claims = decode_worker_mutation_source_claims(token).unwrap();
|
||||||
|
let expected = WorkerMutationSourceExpectation {
|
||||||
|
runtime_id: "runtime-a",
|
||||||
|
audience: "server-a",
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
worker_id: Some("worker-source"),
|
||||||
|
actor_kind: WorkerMutationActorKind::Worker,
|
||||||
|
operation: WorkerMutationOperation::WorkerRemove,
|
||||||
|
target_runtime_id: "runtime-target",
|
||||||
|
target_worker_id: "worker-target",
|
||||||
|
permission: WORKER_REMOVE_PERMISSION,
|
||||||
|
};
|
||||||
|
verify_worker_mutation_source_proof(&identity.public_key, token, &expected, claims.iat)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_forwarder_delivers_in_process_proof_with_the_request() {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingDispatcher {
|
||||||
|
seen: Mutex<Option<(WorkerMutationSourceClaims, String, String, String, String)>>,
|
||||||
|
}
|
||||||
|
impl EmbeddedWorkerMutationDispatcher for RecordingDispatcher {
|
||||||
|
fn execute_worker_remove(
|
||||||
|
&self,
|
||||||
|
proof: InProcessWorkerMutationProof,
|
||||||
|
target_runtime_id: &str,
|
||||||
|
target_worker_id: &str,
|
||||||
|
expected_worker_revision: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||||
|
*self.seen.lock().unwrap() = Some((
|
||||||
|
proof.into_claims(),
|
||||||
|
target_runtime_id.to_string(),
|
||||||
|
target_worker_id.to_string(),
|
||||||
|
expected_worker_revision.to_string(),
|
||||||
|
reason.to_string(),
|
||||||
|
));
|
||||||
|
Ok(WorkspaceResponse {
|
||||||
|
status: 202,
|
||||||
|
body: "accepted".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dispatcher = Arc::new(RecordingDispatcher::default());
|
||||||
|
let scope = RuntimeWorkspaceScope::new("workspace-a", "server-a");
|
||||||
|
let forwarder = RuntimeWorkerMutationForwarder::embedded(
|
||||||
|
"runtime-embedded",
|
||||||
|
scope,
|
||||||
|
"worker-source",
|
||||||
|
dispatcher.clone(),
|
||||||
|
);
|
||||||
|
let response = forwarder
|
||||||
|
.execute_worker_remove(
|
||||||
|
"runtime-target",
|
||||||
|
"worker-target",
|
||||||
|
"revision-7",
|
||||||
|
"retire obsolete Worker",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status, 202);
|
||||||
|
let (claims, target_runtime_id, target_worker_id, expected_revision, reason) =
|
||||||
|
dispatcher.seen.lock().unwrap().take().unwrap();
|
||||||
|
assert_eq!(claims.iss, "runtime-embedded");
|
||||||
|
assert_eq!(claims.worker_id, "worker-source");
|
||||||
|
assert_eq!(claims.target_runtime_id, "runtime-target");
|
||||||
|
assert_eq!(claims.target_worker_id, "worker-target");
|
||||||
|
assert_eq!(target_runtime_id, "runtime-target");
|
||||||
|
assert_eq!(target_worker_id, "worker-target");
|
||||||
|
assert_eq!(expected_revision, "revision-7");
|
||||||
|
assert_eq!(reason, "retire obsolete Worker");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_authority_uses_the_same_claim_contract_without_a_credential() {
|
||||||
|
let authority =
|
||||||
|
RuntimeWorkerMutationSourceAuthority::embedded("runtime-embedded", "workspace-a");
|
||||||
|
let scope = RuntimeWorkspaceScope {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
server_id: "server-unused-for-embedded".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let RuntimeOwnedWorkerMutationProof::InProcess(proof) = authority
|
||||||
|
.issue_worker_remove(&scope, "worker-source", "runtime-b", "worker-target")
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("embedded authority must produce an in-process proof");
|
||||||
|
};
|
||||||
|
let claims = proof.claims();
|
||||||
|
assert_eq!(claims.iss, "runtime-embedded");
|
||||||
|
assert_eq!(claims.aud, "embedded:workspace-a");
|
||||||
|
assert_eq!(claims.workspace_id, "workspace-a");
|
||||||
|
assert_eq!(claims.worker_id, "worker-source");
|
||||||
|
assert_eq!(claims.operation, WorkerMutationOperation::WorkerRemove);
|
||||||
|
assert_eq!(claims.target_runtime_id, "runtime-b");
|
||||||
|
assert_eq!(claims.target_worker_id, "worker-target");
|
||||||
|
assert_eq!(claims.permission, WORKER_REMOVE_PERMISSION);
|
||||||
|
assert!(!claims.jti.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
-14
@@ -200,6 +200,18 @@ fn should_auto_run_notification(status: WorkerStatus, auto_run: bool) -> bool {
|
|||||||
|
|
||||||
pub type ShutdownReceiver = oneshot::Receiver<()>;
|
pub type ShutdownReceiver = oneshot::Receiver<()>;
|
||||||
|
|
||||||
|
/// Client transport exposed by a Worker controller.
|
||||||
|
///
|
||||||
|
/// Process-hosted Workers use a Unix socket for external attach clients. Runtimes
|
||||||
|
/// that retain the returned [`WorkerHandle`] in the same process can disable that
|
||||||
|
/// redundant listener and drive the controller directly through its channels.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum WorkerControllerTransport {
|
||||||
|
#[default]
|
||||||
|
UnixSocket,
|
||||||
|
InProcess,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct WorkerController;
|
pub struct WorkerController;
|
||||||
|
|
||||||
impl WorkerController {
|
impl WorkerController {
|
||||||
@@ -211,7 +223,14 @@ impl WorkerController {
|
|||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
Self::spawn_inner(worker, runtime_base, false).await
|
Self::spawn_inner(
|
||||||
|
worker,
|
||||||
|
runtime_base,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
WorkerControllerTransport::UnixSocket,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a Worker owned by `worker-runtime`.
|
/// Spawn a Worker owned by `worker-runtime`.
|
||||||
@@ -227,20 +246,70 @@ impl WorkerController {
|
|||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
Self::spawn_inner(worker, runtime_base, true).await
|
Self::spawn_inner(
|
||||||
|
worker,
|
||||||
|
runtime_base,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
WorkerControllerTransport::UnixSocket,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn into an exact persistent `runs/<generation>` directory.
|
||||||
|
pub async fn spawn_runtime_managed_run<C, St>(
|
||||||
|
worker: Worker<C, St>,
|
||||||
|
run_dir: &Path,
|
||||||
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
|
where
|
||||||
|
C: LlmClient + Clone + 'static,
|
||||||
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self::spawn_runtime_managed_run_with_transport(
|
||||||
|
worker,
|
||||||
|
run_dir,
|
||||||
|
WorkerControllerTransport::UnixSocket,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn into an exact persistent `runs/<generation>` directory using the
|
||||||
|
/// requested client transport.
|
||||||
|
pub async fn spawn_runtime_managed_run_with_transport<C, St>(
|
||||||
|
worker: Worker<C, St>,
|
||||||
|
run_dir: &Path,
|
||||||
|
transport: WorkerControllerTransport,
|
||||||
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
|
where
|
||||||
|
C: LlmClient + Clone + 'static,
|
||||||
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let parent = run_dir
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| std::io::Error::other("run path has no parent"))?;
|
||||||
|
Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn_inner<C, St>(
|
async fn spawn_inner<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
runtime_managed: bool,
|
runtime_managed: bool,
|
||||||
|
runtime_run: Option<&Path>,
|
||||||
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let session = worker.workdir_session().cloned();
|
let session = worker.workdir_session().cloned();
|
||||||
let result = Self::spawn_initialized(worker, runtime_base, runtime_managed).await;
|
let result = Self::spawn_initialized(
|
||||||
|
worker,
|
||||||
|
runtime_base,
|
||||||
|
runtime_managed,
|
||||||
|
runtime_run,
|
||||||
|
transport,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
if result.is_err()
|
if result.is_err()
|
||||||
&& let Some(session) = session
|
&& let Some(session) = session
|
||||||
&& let Err(error) = session.close().await
|
&& let Err(error) = session.close().await
|
||||||
@@ -254,6 +323,8 @@ impl WorkerController {
|
|||||||
mut worker: Worker<C, St>,
|
mut worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
runtime_managed: bool,
|
runtime_managed: bool,
|
||||||
|
runtime_run: Option<&Path>,
|
||||||
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
@@ -269,11 +340,12 @@ impl WorkerController {
|
|||||||
let in_flight = InFlightEvents::new(event_tx.clone());
|
let in_flight = InFlightEvents::new(event_tx.clone());
|
||||||
worker.attach_in_flight_events(in_flight.clone());
|
worker.attach_in_flight_events(in_flight.clone());
|
||||||
|
|
||||||
// Runtime directory is created before tool registration because
|
// Runtime directory is created before tool registration because it owns
|
||||||
// the spawn-tool factories need its socket path, and before the
|
// bounded tool artifacts, and before initial status/history writes consume
|
||||||
// initial status/history writes consume the greeting we build
|
// the greeting we build after registration is complete.
|
||||||
// after registration is complete.
|
let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run {
|
||||||
let runtime_dir = Arc::new(if runtime_managed {
|
RuntimeDir::create_worker_run(run_dir).await?
|
||||||
|
} else if runtime_managed {
|
||||||
RuntimeDir::create_transient(runtime_base, &worker.manifest().worker.name).await?
|
RuntimeDir::create_transient(runtime_base, &worker.manifest().worker.name).await?
|
||||||
} else {
|
} else {
|
||||||
RuntimeDir::create(runtime_base, &worker.manifest().worker.name).await?
|
RuntimeDir::create(runtime_base, &worker.manifest().worker.name).await?
|
||||||
@@ -391,7 +463,10 @@ impl WorkerController {
|
|||||||
sink: worker.sink(),
|
sink: worker.sink(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let socket_server = SocketServer::start(&handle).await?;
|
let socket_server = match transport {
|
||||||
|
WorkerControllerTransport::UnixSocket => Some(SocketServer::start(&handle).await?),
|
||||||
|
WorkerControllerTransport::InProcess => None,
|
||||||
|
};
|
||||||
|
|
||||||
// === 5. controller_loop ===
|
// === 5. controller_loop ===
|
||||||
// Clone cancel sender and notification buffer before moving worker
|
// Clone cancel sender and notification buffer before moving worker
|
||||||
@@ -673,7 +748,7 @@ where
|
|||||||
authoring: feature_config.ticket.authoring,
|
authoring: feature_config.ticket.authoring,
|
||||||
thread: feature_config.ticket.thread,
|
thread: feature_config.ticket.thread,
|
||||||
intake: feature_config.ticket.intake,
|
intake: feature_config.ticket.intake,
|
||||||
orchestration_control: feature_config.ticket.orchestration_control,
|
workflow: feature_config.ticket.workflow,
|
||||||
};
|
};
|
||||||
// Ticket tools are typed operations over the current workspace Ticket backend.
|
// Ticket tools are typed operations over the current workspace Ticket backend.
|
||||||
// Workspace access must be authority-bound to the Backend Workspace API; the
|
// Workspace access must be authority-bound to the Backend Workspace API; the
|
||||||
@@ -725,9 +800,16 @@ where
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
feature_registry.add_module(
|
feature_registry.add_module(
|
||||||
crate::feature::builtin::manage_worker::manage_worker_feature(workspace_client),
|
crate::feature::builtin::manage_worker::manage_worker_feature(
|
||||||
|
workspace_client,
|
||||||
|
feature_config.worker.direct_spawn,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if feature_config.orchestration.enabled {
|
||||||
|
feature_registry
|
||||||
|
.add_module(crate::feature::builtin::orchestration::orchestration_feature());
|
||||||
|
}
|
||||||
for module in crate::feature::plugin::plugin_tool_features_if_enabled(
|
for module in crate::feature::plugin::plugin_tool_features_if_enabled(
|
||||||
feature_config.plugins.enabled,
|
feature_config.plugins.enabled,
|
||||||
&worker.manifest().plugins,
|
&worker.manifest().plugins,
|
||||||
@@ -862,7 +944,16 @@ where
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _feature_install_report = worker.install_features(feature_registry);
|
let feature_install_report = worker.install_features(feature_registry);
|
||||||
|
if feature_install_report.has_errors() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
format!(
|
||||||
|
"Worker feature installation failed: {}",
|
||||||
|
feature_install_report.error_message()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
if let Some(tracker) = tracker {
|
if let Some(tracker) = tracker {
|
||||||
worker.attach_tracker(tracker);
|
worker.attach_tracker(tracker);
|
||||||
}
|
}
|
||||||
@@ -888,13 +979,14 @@ async fn controller_loop<C, St>(
|
|||||||
spawner_name: String,
|
spawner_name: String,
|
||||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||||
shutdown_tx: oneshot::Sender<()>,
|
shutdown_tx: oneshot::Sender<()>,
|
||||||
socket_server: SocketServer,
|
socket_server: Option<SocketServer>,
|
||||||
shutdown_after_idle: ShutdownAfterIdleRequest,
|
shutdown_after_idle: ShutdownAfterIdleRequest,
|
||||||
) where
|
) where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + 'static,
|
St: Store + WorkerMetadataStore + Clone + 'static,
|
||||||
{
|
{
|
||||||
// Hold socket server alive for the lifetime of the controller task.
|
// Hold an optional external attach server alive for the controller lifetime.
|
||||||
|
// In-process runtimes retain and drive the WorkerHandle directly.
|
||||||
let _socket_server = socket_server;
|
let _socket_server = socket_server;
|
||||||
|
|
||||||
let discovery_runtime_base = runtime_dir
|
let discovery_runtime_base = runtime_dir
|
||||||
@@ -1298,6 +1390,11 @@ async fn controller_loop<C, St>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drop(_socket_server);
|
||||||
|
if let Err(error) = runtime_dir.close_socket().await {
|
||||||
|
tracing::warn!(%error, "Worker runtime socket cleanup failed");
|
||||||
|
}
|
||||||
|
|
||||||
// Background memory jobs own extract/consolidate workers after a
|
// Background memory jobs own extract/consolidate workers after a
|
||||||
// turn completes. Join them before closing the Workdir session so no
|
// turn completes. Join them before closing the Workdir session so no
|
||||||
// Worker-owned task can outlive its operation attachment.
|
// Worker-owned task can outlive its operation attachment.
|
||||||
|
|||||||
+235
-49
@@ -11,6 +11,7 @@
|
|||||||
//! [`crate::hook::HookRegistryBuilder`], and provider output is represented as
|
//! [`crate::hook::HookRegistryBuilder`], and provider output is represented as
|
||||||
//! ordinary feature reports/diagnostics instead of a separate authority layer.
|
//! ordinary feature reports/diagnostics instead of a separate authority layer.
|
||||||
|
|
||||||
|
use std::any::{Any, type_name};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -497,8 +498,8 @@ impl ServiceRequirement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Contribution service registry skeleton used during feature installation.
|
/// Typed concrete services installed by stateful Feature modules.
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct FeatureServiceRegistry {
|
pub struct FeatureServiceRegistry {
|
||||||
providers: HashMap<ServiceId, FeatureServiceProvider>,
|
providers: HashMap<ServiceId, FeatureServiceProvider>,
|
||||||
}
|
}
|
||||||
@@ -512,11 +513,32 @@ impl FeatureServiceRegistry {
|
|||||||
self.providers.contains_key(id)
|
self.providers.contains_key(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_provider(
|
pub fn service<T>(&self, id: &ServiceId) -> Result<Arc<T>, FeatureInstallError>
|
||||||
|
where
|
||||||
|
T: ?Sized + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let provider = self.providers.get(id).ok_or_else(|| {
|
||||||
|
FeatureInstallError::InvalidDescriptor(format!(
|
||||||
|
"feature service provider is unavailable: {id}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
provider.service::<T>().ok_or_else(|| {
|
||||||
|
FeatureInstallError::InvalidDescriptor(format!(
|
||||||
|
"feature service {id} has incompatible concrete type; requested {}",
|
||||||
|
type_name::<T>()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_provider<T>(
|
||||||
&mut self,
|
&mut self,
|
||||||
feature_id: FeatureId,
|
feature_id: FeatureId,
|
||||||
declaration: ServiceDeclaration,
|
declaration: ServiceDeclaration,
|
||||||
) -> Result<(), FeatureInstallError> {
|
instance: Arc<T>,
|
||||||
|
) -> Result<(), FeatureInstallError>
|
||||||
|
where
|
||||||
|
T: ?Sized + Send + Sync + 'static,
|
||||||
|
{
|
||||||
if let Some(existing) = self.providers.get(&declaration.id) {
|
if let Some(existing) = self.providers.get(&declaration.id) {
|
||||||
return Err(FeatureInstallError::DuplicateService {
|
return Err(FeatureInstallError::DuplicateService {
|
||||||
service: declaration.id.to_string(),
|
service: declaration.id.to_string(),
|
||||||
@@ -529,19 +551,38 @@ impl FeatureServiceRegistry {
|
|||||||
FeatureServiceProvider {
|
FeatureServiceProvider {
|
||||||
feature_id,
|
feature_id,
|
||||||
declaration,
|
declaration,
|
||||||
|
instance: Arc::new(instance),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider metadata for one service declaration.
|
/// Provider metadata and concrete instance for one service declaration.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct FeatureServiceProvider {
|
pub struct FeatureServiceProvider {
|
||||||
pub feature_id: FeatureId,
|
pub feature_id: FeatureId,
|
||||||
pub declaration: ServiceDeclaration,
|
pub declaration: ServiceDeclaration,
|
||||||
|
instance: Arc<dyn Any + Send + Sync>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FeatureServiceProvider {
|
||||||
|
fn service<T>(&self) -> Option<Arc<T>>
|
||||||
|
where
|
||||||
|
T: ?Sized + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
self.instance.downcast_ref::<Arc<T>>().map(Arc::clone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for FeatureServiceProvider {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.feature_id == other.feature_id && self.declaration == other.declaration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for FeatureServiceProvider {}
|
||||||
|
|
||||||
/// Feature descriptor advertised before installation.
|
/// Feature descriptor advertised before installation.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct FeatureDescriptor {
|
pub struct FeatureDescriptor {
|
||||||
@@ -742,6 +783,7 @@ struct FeatureContributionDeclarations {
|
|||||||
instructions: HashSet<FeatureInstructionId>,
|
instructions: HashSet<FeatureInstructionId>,
|
||||||
background_tasks: HashSet<String>,
|
background_tasks: HashSet<String>,
|
||||||
provided_services: HashSet<(ServiceId, String)>,
|
provided_services: HashSet<(ServiceId, String)>,
|
||||||
|
required_services: HashSet<ServiceId>,
|
||||||
protocol_providers: HashSet<ProviderId>,
|
protocol_providers: HashSet<ProviderId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,6 +815,11 @@ impl FeatureContributionDeclarations {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|service| (service.id.clone(), service.version.clone()))
|
.map(|service| (service.id.clone(), service.version.clone()))
|
||||||
.collect(),
|
.collect(),
|
||||||
|
required_services: descriptor
|
||||||
|
.requires_services
|
||||||
|
.iter()
|
||||||
|
.map(|service| service.id.clone())
|
||||||
|
.collect(),
|
||||||
protocol_providers: descriptor
|
protocol_providers: descriptor
|
||||||
.protocol_providers
|
.protocol_providers
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1099,7 +1146,7 @@ impl BackgroundTaskRegistrar<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service registrar for descriptor/report-only provider metadata.
|
/// Service registrar for concrete typed Feature state.
|
||||||
pub struct FeatureServiceRegistrar<'a> {
|
pub struct FeatureServiceRegistrar<'a> {
|
||||||
feature_id: &'a FeatureId,
|
feature_id: &'a FeatureId,
|
||||||
declarations: &'a FeatureContributionDeclarations,
|
declarations: &'a FeatureContributionDeclarations,
|
||||||
@@ -1108,7 +1155,14 @@ pub struct FeatureServiceRegistrar<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureServiceRegistrar<'_> {
|
impl FeatureServiceRegistrar<'_> {
|
||||||
pub fn provide(&mut self, declaration: ServiceDeclaration) -> Result<(), FeatureInstallError> {
|
pub fn provide<T>(
|
||||||
|
&mut self,
|
||||||
|
declaration: ServiceDeclaration,
|
||||||
|
instance: Arc<T>,
|
||||||
|
) -> Result<(), FeatureInstallError>
|
||||||
|
where
|
||||||
|
T: ?Sized + Send + Sync + 'static,
|
||||||
|
{
|
||||||
if !self.declarations.contains_provided_service(&declaration) {
|
if !self.declarations.contains_provided_service(&declaration) {
|
||||||
return Err(reject_undeclared_contribution(
|
return Err(reject_undeclared_contribution(
|
||||||
self.feature_id,
|
self.feature_id,
|
||||||
@@ -1117,19 +1171,28 @@ impl FeatureServiceRegistrar<'_> {
|
|||||||
declaration.id.to_string(),
|
declaration.id.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if self
|
self.service_registry.register_provider(
|
||||||
.report
|
self.feature_id.clone(),
|
||||||
.provided_services
|
declaration.clone(),
|
||||||
.iter()
|
instance,
|
||||||
.any(|service| service.id == declaration.id && service.version == declaration.version)
|
)?;
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
self.service_registry
|
|
||||||
.register_provider(self.feature_id.clone(), declaration.clone())?;
|
|
||||||
self.report.provided_services.push(declaration);
|
self.report.provided_services.push(declaration);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn require<T>(&self, service: &ServiceId) -> Result<Arc<T>, FeatureInstallError>
|
||||||
|
where
|
||||||
|
T: ?Sized + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
if !self.declarations.required_services.contains(service) {
|
||||||
|
return Err(FeatureInstallError::UndeclaredContribution {
|
||||||
|
kind: FeatureContributionKind::Service,
|
||||||
|
name: service.to_string(),
|
||||||
|
feature: self.feature_id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.service_registry.service(service)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registrar for startup-discovered protocol-backed provider contributions.
|
/// Registrar for startup-discovered protocol-backed provider contributions.
|
||||||
@@ -1248,8 +1311,11 @@ impl ProtocolProviderRegistrar<'_> {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|provided| provided.id == service.id && provided.version == service.version)
|
.any(|provided| provided.id == service.id && provided.version == service.version)
|
||||||
{
|
{
|
||||||
self.service_registry
|
self.service_registry.register_provider(
|
||||||
.register_provider(self.feature_id.clone(), service.clone())?;
|
self.feature_id.clone(),
|
||||||
|
service.clone(),
|
||||||
|
Arc::new(()),
|
||||||
|
)?;
|
||||||
self.report.provided_services.push(service);
|
self.report.provided_services.push(service);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1360,13 +1426,36 @@ impl FeatureInstallContext<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Aggregate install output for a registry installation.
|
/// Aggregate install output for a registry installation.
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct FeatureRegistryInstallReport {
|
pub struct FeatureRegistryInstallReport {
|
||||||
pub reports: Vec<FeatureInstallReport>,
|
pub reports: Vec<FeatureInstallReport>,
|
||||||
pub services: FeatureServiceRegistry,
|
pub services: FeatureServiceRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureRegistryInstallReport {
|
impl FeatureRegistryInstallReport {
|
||||||
|
pub fn has_errors(&self) -> bool {
|
||||||
|
self.reports.iter().any(|report| {
|
||||||
|
report
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error_message(&self) -> String {
|
||||||
|
self.reports
|
||||||
|
.iter()
|
||||||
|
.flat_map(|report| {
|
||||||
|
report
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||||
|
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn installed_tool_names(&self) -> Vec<String> {
|
pub fn installed_tool_names(&self) -> Vec<String> {
|
||||||
self.reports
|
self.reports
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1453,7 +1542,9 @@ impl FeatureRegistryBuilder {
|
|||||||
hook_builder,
|
hook_builder,
|
||||||
registered_tool_names,
|
registered_tool_names,
|
||||||
);
|
);
|
||||||
worker.register_tools(pending_tools);
|
if !report.has_errors() {
|
||||||
|
worker.register_tools(pending_tools);
|
||||||
|
}
|
||||||
report
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1481,7 +1572,32 @@ impl FeatureRegistryBuilder {
|
|||||||
let mut reports = Vec::with_capacity(self.modules.len());
|
let mut reports = Vec::with_capacity(self.modules.len());
|
||||||
let mut seen_features = HashSet::new();
|
let mut seen_features = HashSet::new();
|
||||||
|
|
||||||
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
|
let mut pending_modules: Vec<_> = self.modules.into_iter().zip(descriptors).collect();
|
||||||
|
let mut ordered_modules = Vec::with_capacity(pending_modules.len());
|
||||||
|
let mut declared_services = HashSet::new();
|
||||||
|
while !pending_modules.is_empty() {
|
||||||
|
let next = pending_modules
|
||||||
|
.iter()
|
||||||
|
.position(|(_, descriptor)| {
|
||||||
|
descriptor
|
||||||
|
.requires_services
|
||||||
|
.iter()
|
||||||
|
.filter(|requirement| requirement.required)
|
||||||
|
.all(|requirement| declared_services.contains(&requirement.id))
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
let entry = pending_modules.remove(next);
|
||||||
|
declared_services.extend(
|
||||||
|
entry
|
||||||
|
.1
|
||||||
|
.provides_services
|
||||||
|
.iter()
|
||||||
|
.map(|service| service.id.clone()),
|
||||||
|
);
|
||||||
|
ordered_modules.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (module, descriptor) in ordered_modules {
|
||||||
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
||||||
let mut report = FeatureInstallReport::new(&descriptor);
|
let mut report = FeatureInstallReport::new(&descriptor);
|
||||||
|
|
||||||
@@ -1538,22 +1654,6 @@ impl FeatureRegistryBuilder {
|
|||||||
report.declared_background_tasks.push(background_task);
|
report.declared_background_tasks.push(background_task);
|
||||||
}
|
}
|
||||||
|
|
||||||
for service in descriptor.provides_services.iter().cloned() {
|
|
||||||
match service_registry.register_provider(descriptor.id.clone(), service.clone()) {
|
|
||||||
Ok(()) => report.provided_services.push(service),
|
|
||||||
Err(error) => {
|
|
||||||
report
|
|
||||||
.diagnostics
|
|
||||||
.push(FeatureDiagnostic::error(error.to_string()));
|
|
||||||
report.mark_skipped(
|
|
||||||
FeatureContributionKind::Service,
|
|
||||||
service.id.to_string(),
|
|
||||||
error.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let install_result = {
|
let install_result = {
|
||||||
let mut context = FeatureInstallContext {
|
let mut context = FeatureInstallContext {
|
||||||
feature_id: &descriptor.id,
|
feature_id: &descriptor.id,
|
||||||
@@ -2096,8 +2196,11 @@ mod tests {
|
|||||||
|
|
||||||
fn install(
|
fn install(
|
||||||
&self,
|
&self,
|
||||||
_context: &mut FeatureInstallContext<'_>,
|
context: &mut FeatureInstallContext<'_>,
|
||||||
) -> Result<(), FeatureInstallError> {
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
for service in self.descriptor.provides_services.iter().cloned() {
|
||||||
|
context.services().provide(service, Arc::new(()))?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2171,11 +2274,10 @@ mod tests {
|
|||||||
&self,
|
&self,
|
||||||
context: &mut FeatureInstallContext<'_>,
|
context: &mut FeatureInstallContext<'_>,
|
||||||
) -> Result<(), FeatureInstallError> {
|
) -> Result<(), FeatureInstallError> {
|
||||||
context.services().provide(ServiceDeclaration::new(
|
context.services().provide(
|
||||||
self.service.clone(),
|
ServiceDeclaration::new(self.service.clone(), "1", "runtime service provider"),
|
||||||
"1",
|
Arc::new(()),
|
||||||
"runtime service provider",
|
)
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2311,16 +2413,26 @@ mod tests {
|
|||||||
report.reports[1].resolved_service_requirements[0].id,
|
report.reports[1].resolved_service_requirements[0].id,
|
||||||
service
|
service
|
||||||
);
|
);
|
||||||
assert!(!report.reports[2].installed);
|
let missing_report = report
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.find(|feature| feature.feature_id == FeatureId::builtin("missing"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!missing_report.installed);
|
||||||
assert!(
|
assert!(
|
||||||
report.reports[2]
|
missing_report
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
.any(|diagnostic| diagnostic.message.contains("required service requirement"))
|
.any(|diagnostic| diagnostic.message.contains("required service requirement"))
|
||||||
);
|
);
|
||||||
assert!(report.reports[3].installed);
|
let optional_report = report
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.find(|feature| feature.feature_id == FeatureId::builtin("optional"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(optional_report.installed);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report.reports[3].skipped[0].kind,
|
optional_report.skipped[0].kind,
|
||||||
FeatureContributionKind::Service
|
FeatureContributionKind::Service
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2364,6 +2476,80 @@ mod tests {
|
|||||||
assert!(report.reports[0].skipped.is_empty());
|
assert!(report.reports[0].skipped.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_registry_keeps_typed_concrete_instance() {
|
||||||
|
trait CounterService: Send + Sync {
|
||||||
|
fn value(&self) -> usize;
|
||||||
|
}
|
||||||
|
struct Counter(usize);
|
||||||
|
impl CounterService for Counter {
|
||||||
|
fn value(&self) -> usize {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut registry = FeatureServiceRegistry::default();
|
||||||
|
let service_id = ServiceId::builtin("typed-counter");
|
||||||
|
let service: Arc<dyn CounterService> = Arc::new(Counter(7));
|
||||||
|
registry
|
||||||
|
.register_provider(
|
||||||
|
FeatureId::builtin("provider"),
|
||||||
|
ServiceDeclaration::new(service_id.clone(), "1", "typed counter"),
|
||||||
|
service,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.service::<dyn CounterService>(&service_id)
|
||||||
|
.unwrap()
|
||||||
|
.value(),
|
||||||
|
7
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_dependencies_install_in_provider_order() {
|
||||||
|
let service = ServiceId::builtin("ordered-service");
|
||||||
|
let provider = FeatureDescriptor::builtin("provider", "Provider").with_provided_service(
|
||||||
|
ServiceDeclaration::new(service.clone(), "1", "ordered service"),
|
||||||
|
);
|
||||||
|
let consumer = FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
|
||||||
|
ServiceRequirement::required(service, "consumer depends on provider"),
|
||||||
|
);
|
||||||
|
let mut hook_builder = HookRegistryBuilder::default();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
let report = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(ServiceFeature {
|
||||||
|
descriptor: consumer,
|
||||||
|
})
|
||||||
|
.with_module(ServiceFeature {
|
||||||
|
descriptor: provider,
|
||||||
|
})
|
||||||
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||||
|
assert!(!report.has_errors(), "{}", report.error_message());
|
||||||
|
assert_eq!(report.reports[0].feature_id, FeatureId::builtin("provider"));
|
||||||
|
assert_eq!(report.reports[1].feature_id, FeatureId::builtin("consumer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_required_service_is_fatal() {
|
||||||
|
let descriptor = FeatureDescriptor::builtin("consumer", "Consumer")
|
||||||
|
.with_service_requirement(ServiceRequirement::required(
|
||||||
|
ServiceId::builtin("missing-service"),
|
||||||
|
"must fail closed",
|
||||||
|
));
|
||||||
|
let mut hook_builder = HookRegistryBuilder::default();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
let report = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(ServiceFeature { descriptor })
|
||||||
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||||
|
assert!(report.has_errors());
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.error_message()
|
||||||
|
.contains("required service requirement")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_internal_task_feature_descriptor_has_exact_tools_hooks() {
|
fn builtin_internal_task_feature_descriptor_has_exact_tools_hooks() {
|
||||||
let descriptor = builtin::task_tools_feature().descriptor();
|
let descriptor = builtin::task_tools_feature().descriptor();
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ pub mod manage_workdir;
|
|||||||
pub mod manage_worker;
|
pub mod manage_worker;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod memory_extract;
|
pub mod memory_extract;
|
||||||
|
pub mod merge_request;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
|
pub mod orchestration;
|
||||||
pub mod session_explore;
|
pub mod session_explore;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod ticket;
|
pub mod ticket;
|
||||||
|
|||||||
@@ -12,34 +12,116 @@ use serde::{Deserialize, Serialize};
|
|||||||
use protocol::Segment;
|
use protocol::Segment;
|
||||||
|
|
||||||
use crate::feature::{
|
use crate::feature::{
|
||||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||||
ToolDeclaration,
|
ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
|
||||||
|
};
|
||||||
|
use crate::worker::{
|
||||||
|
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||||
|
WorkspaceResponse,
|
||||||
};
|
};
|
||||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
|
||||||
|
|
||||||
const FEATURE_ID: &str = "worker";
|
const FEATURE_ID: &str = "worker";
|
||||||
const FEATURE_NAME: &str = "Worker";
|
const FEATURE_NAME: &str = "Worker";
|
||||||
const FEATURE_DESCRIPTION: &str =
|
const FEATURE_DESCRIPTION: &str =
|
||||||
"Workspace-authority tools for managing Workdir-bound Backend/Runtime Worker sessions.";
|
"Workspace-authority tools for managing Workdir-bound Backend/Runtime Worker sessions.";
|
||||||
|
pub const WORKER_LIFECYCLE_SERVICE_ID: &str = "worker.lifecycle";
|
||||||
|
const WORKER_LIFECYCLE_SERVICE_VERSION: &str = "1";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait WorkerLifecycleService: Send + Sync {
|
||||||
|
async fn spawn(
|
||||||
|
&self,
|
||||||
|
request: WorkerLifecycleSpawnRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WorkerLifecycleSpawnRequest {
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub working_directory_id: String,
|
||||||
|
pub relative_cwd: Option<String>,
|
||||||
|
pub profile: String,
|
||||||
|
pub ticket_id: Option<String>,
|
||||||
|
pub operation_id: Option<String>,
|
||||||
|
pub display_name: String,
|
||||||
|
pub initial_submit: Vec<Segment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkspaceWorkerLifecycleService {
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
workspace_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WorkerLifecycleService for WorkspaceWorkerLifecycleService {
|
||||||
|
async fn spawn(
|
||||||
|
&self,
|
||||||
|
request: WorkerLifecycleSpawnRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
let ticket_assignment = match (request.ticket_id, request.operation_id) {
|
||||||
|
(Some(ticket_id), Some(operation_id)) => Some(WorkerSpawnTicketAssignmentRequest {
|
||||||
|
ticket_id,
|
||||||
|
operation_id,
|
||||||
|
}),
|
||||||
|
(None, None) => None,
|
||||||
|
_ => {
|
||||||
|
return Err(WorkspaceClientError::Request(
|
||||||
|
"ticket_id and operation_id must be provided together".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let body = WorkerSpawnRequest {
|
||||||
|
runtime_id: request.runtime_id,
|
||||||
|
display_name: request.display_name,
|
||||||
|
profile: request.profile,
|
||||||
|
ticket_assignment,
|
||||||
|
initial_submit: request.initial_submit,
|
||||||
|
working_directory: WorkerWorkingDirectorySelection {
|
||||||
|
working_directory_id: request.working_directory_id,
|
||||||
|
relative_cwd: request.relative_cwd,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.client.execute(WorkspaceRequest::json(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("/api/w/{}/workers", self.workspace_id),
|
||||||
|
serde_json::to_string(&body)
|
||||||
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ManageWorkerFeature {
|
pub struct ManageWorkerFeature {
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
direct_spawn: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn manage_worker_feature(client: Arc<dyn WorkspaceClient>) -> ManageWorkerFeature {
|
pub fn manage_worker_feature(
|
||||||
ManageWorkerFeature { client }
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
direct_spawn: bool,
|
||||||
|
) -> ManageWorkerFeature {
|
||||||
|
ManageWorkerFeature {
|
||||||
|
client,
|
||||||
|
direct_spawn,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureModule for ManageWorkerFeature {
|
impl FeatureModule for ManageWorkerFeature {
|
||||||
fn descriptor(&self) -> FeatureDescriptor {
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||||
.with_description(FEATURE_DESCRIPTION);
|
.with_description(FEATURE_DESCRIPTION)
|
||||||
for operation in WorkerOperation::ALL {
|
.with_provided_service(ServiceDeclaration::new(
|
||||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||||
operation.tool_name(),
|
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||||
operation.description(),
|
"Workspace-authoritative Worker lifecycle operations",
|
||||||
));
|
));
|
||||||
|
for operation in WorkerOperation::ALL {
|
||||||
|
if operation != WorkerOperation::Spawn || self.direct_spawn {
|
||||||
|
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||||
|
operation.tool_name(),
|
||||||
|
operation.description(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
descriptor
|
descriptor
|
||||||
}
|
}
|
||||||
@@ -55,7 +137,23 @@ impl FeatureModule for ManageWorkerFeature {
|
|||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let lifecycle: Arc<dyn WorkerLifecycleService> =
|
||||||
|
Arc::new(WorkspaceWorkerLifecycleService {
|
||||||
|
client: self.client.clone(),
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
});
|
||||||
|
context.services().provide(
|
||||||
|
ServiceDeclaration::new(
|
||||||
|
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||||
|
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||||
|
"Workspace-authoritative Worker lifecycle operations",
|
||||||
|
),
|
||||||
|
lifecycle,
|
||||||
|
)?;
|
||||||
for operation in WorkerOperation::ALL {
|
for operation in WorkerOperation::ALL {
|
||||||
|
if operation == WorkerOperation::Spawn && !self.direct_spawn {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let definition = match operation {
|
let definition = match operation {
|
||||||
WorkerOperation::List => definition::<WorkerListInput>(
|
WorkerOperation::List => definition::<WorkerListInput>(
|
||||||
operation,
|
operation,
|
||||||
@@ -77,6 +175,11 @@ impl FeatureModule for ManageWorkerFeature {
|
|||||||
self.client.clone(),
|
self.client.clone(),
|
||||||
workspace_id.clone(),
|
workspace_id.clone(),
|
||||||
),
|
),
|
||||||
|
WorkerOperation::Remove => definition::<WorkerRemoveInput>(
|
||||||
|
operation,
|
||||||
|
self.client.clone(),
|
||||||
|
workspace_id.clone(),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
context
|
context
|
||||||
.tools()
|
.tools()
|
||||||
@@ -96,7 +199,8 @@ struct WorkerSpawnInput {
|
|||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
working_directory_id: String,
|
working_directory_id: String,
|
||||||
profile: String,
|
profile: String,
|
||||||
/// Optional queued Ticket to assign atomically to the new Coder Worker.
|
/// Optional inprogress Ticket already accepted by the Orchestrator. Set
|
||||||
|
/// this with one Flow segment to assign the new Coder atomically.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
ticket_id: Option<String>,
|
ticket_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -149,22 +253,38 @@ struct WorkerStopInput {
|
|||||||
reason: Option<String>,
|
reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct WorkerRemoveInput {
|
||||||
|
runtime_id: String,
|
||||||
|
worker_id: String,
|
||||||
|
expected_worker_revision: String,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
struct WorkspaceWorkerTool {
|
struct WorkspaceWorkerTool {
|
||||||
operation: WorkerOperation,
|
operation: WorkerOperation,
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum WorkerOperation {
|
enum WorkerOperation {
|
||||||
List,
|
List,
|
||||||
Spawn,
|
Spawn,
|
||||||
Stop,
|
Stop,
|
||||||
Restore,
|
Restore,
|
||||||
|
Remove,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerOperation {
|
impl WorkerOperation {
|
||||||
const ALL: [Self; 4] = [Self::List, Self::Spawn, Self::Stop, Self::Restore];
|
const ALL: [Self; 5] = [
|
||||||
|
Self::List,
|
||||||
|
Self::Spawn,
|
||||||
|
Self::Stop,
|
||||||
|
Self::Restore,
|
||||||
|
Self::Remove,
|
||||||
|
];
|
||||||
|
|
||||||
fn tool_name(self) -> &'static str {
|
fn tool_name(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -172,6 +292,7 @@ impl WorkerOperation {
|
|||||||
Self::Spawn => "WorkerSpawn",
|
Self::Spawn => "WorkerSpawn",
|
||||||
Self::Stop => "WorkerStop",
|
Self::Stop => "WorkerStop",
|
||||||
Self::Restore => "WorkerRestore",
|
Self::Restore => "WorkerRestore",
|
||||||
|
Self::Remove => "WorkerRemove",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,12 +302,15 @@ impl WorkerOperation {
|
|||||||
"List Backend/Runtime Worker sessions in the current Workspace. SubWorkers are excluded."
|
"List Backend/Runtime Worker sessions in the current Workspace. SubWorkers are excluded."
|
||||||
}
|
}
|
||||||
Self::Spawn => {
|
Self::Spawn => {
|
||||||
"Spawn a Backend/Runtime Worker session in an existing Workspace Workdir. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. Set `ticket_id` with a Flow segment in `initial_submit` to atomically assign a queued Ticket to the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
|
"Spawn a Backend/Runtime Worker session in an existing Workspace Workdir. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
|
||||||
}
|
}
|
||||||
Self::Stop => "Stop a Backend/Runtime Worker session in the current Workspace.",
|
Self::Stop => "Stop a Backend/Runtime Worker session in the current Workspace.",
|
||||||
Self::Restore => {
|
Self::Restore => {
|
||||||
"Restore a stopped Backend/Runtime Worker session in the current Workspace."
|
"Restore a stopped Backend/Runtime Worker session in the current Workspace."
|
||||||
}
|
}
|
||||||
|
Self::Remove => {
|
||||||
|
"Remove an eligible stopped, unassigned, non-internal Worker. Supply the current Worker revision and a bounded reason; Backend validation and retention are authoritative."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,96 +322,129 @@ impl Tool for WorkspaceWorkerTool {
|
|||||||
input_json: &str,
|
input_json: &str,
|
||||||
ctx: ToolExecutionContext,
|
ctx: ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let request = match self.operation {
|
let response = match self.operation {
|
||||||
WorkerOperation::List => {
|
WorkerOperation::Remove => {
|
||||||
parse::<WorkerListInput>(input_json, "WorkerList")?;
|
let input = parse::<WorkerRemoveInput>(input_json, "WorkerRemove")?;
|
||||||
WorkspaceRequest::get(format!("/api/w/{}/workers", self.workspace_id))
|
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
|
||||||
|
let worker_id = authority_id(&input.worker_id, "worker_id")?;
|
||||||
|
let expected_worker_revision =
|
||||||
|
non_empty(input.expected_worker_revision, "expected_worker_revision")?;
|
||||||
|
let reason = non_empty(input.reason, "reason")?;
|
||||||
|
if reason.len() > 512 {
|
||||||
|
return Err(ToolError::ExecutionFailed(
|
||||||
|
"reason must contain at most 512 bytes".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.client
|
||||||
|
.execute_worker_remove(
|
||||||
|
&runtime_id,
|
||||||
|
&worker_id,
|
||||||
|
&expected_worker_revision,
|
||||||
|
&reason,
|
||||||
|
)
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
|
||||||
}
|
}
|
||||||
WorkerOperation::Spawn => {
|
operation => {
|
||||||
let input = parse::<WorkerSpawnInput>(input_json, "WorkerSpawn")?;
|
let request = match operation {
|
||||||
let ticket_assignment = input
|
WorkerOperation::List => {
|
||||||
.ticket_id
|
parse::<WorkerListInput>(input_json, "WorkerList")?;
|
||||||
.map(|ticket_id| {
|
WorkspaceRequest::get(format!("/api/w/{}/workers", self.workspace_id))
|
||||||
let ticket_id = authority_id(&ticket_id, "ticket_id")?;
|
}
|
||||||
let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?;
|
WorkerOperation::Spawn => {
|
||||||
Ok::<_, ToolError>(WorkerSpawnTicketAssignmentRequest {
|
let input = parse::<WorkerSpawnInput>(input_json, "WorkerSpawn")?;
|
||||||
operation_id: format!("worker-spawn:{ticket_id}:{call_id}"),
|
let ticket_id = input
|
||||||
ticket_id,
|
.ticket_id
|
||||||
})
|
.map(|ticket_id| authority_id(&ticket_id, "ticket_id"))
|
||||||
})
|
.transpose()?;
|
||||||
.transpose()?;
|
let operation_id = ticket_id
|
||||||
let request = WorkerSpawnRequest {
|
.as_ref()
|
||||||
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
|
.map(|ticket_id| {
|
||||||
display_name: input
|
let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?;
|
||||||
.display_name
|
Ok::<_, ToolError>(format!("worker-spawn:{ticket_id}:{call_id}"))
|
||||||
.filter(|value| !value.trim().is_empty())
|
})
|
||||||
.unwrap_or_else(|| "Workspace Worker".to_string()),
|
.transpose()?;
|
||||||
profile: non_empty(input.profile, "profile")?,
|
let lifecycle = WorkspaceWorkerLifecycleService {
|
||||||
ticket_assignment,
|
client: self.client.clone(),
|
||||||
initial_submit: input.initial_submit,
|
workspace_id: self.workspace_id.clone(),
|
||||||
working_directory: WorkerWorkingDirectorySelection {
|
};
|
||||||
working_directory_id: authority_id(
|
let response = lifecycle
|
||||||
&input.working_directory_id,
|
.spawn(WorkerLifecycleSpawnRequest {
|
||||||
"working_directory_id",
|
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
|
||||||
)?,
|
working_directory_id: authority_id(
|
||||||
relative_cwd: input
|
&input.working_directory_id,
|
||||||
.relative_cwd
|
"working_directory_id",
|
||||||
.map(|value| validate_relative_cwd(&value))
|
)?,
|
||||||
.transpose()?,
|
relative_cwd: input
|
||||||
},
|
.relative_cwd
|
||||||
|
.map(|value| validate_relative_cwd(&value))
|
||||||
|
.transpose()?,
|
||||||
|
profile: non_empty(input.profile, "profile")?,
|
||||||
|
ticket_id,
|
||||||
|
operation_id,
|
||||||
|
display_name: input
|
||||||
|
.display_name
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "Workspace Worker".to_string()),
|
||||||
|
initial_submit: input.initial_submit,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
|
return tool_output(self.operation, response);
|
||||||
|
}
|
||||||
|
WorkerOperation::Stop => {
|
||||||
|
let input = parse::<WorkerStopInput>(input_json, "WorkerStop")?;
|
||||||
|
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
|
||||||
|
let worker_id = authority_id(&input.worker_id, "worker_id")?;
|
||||||
|
WorkspaceRequest::json(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{}/runtimes/{runtime_id}/workers/{worker_id}/stop",
|
||||||
|
self.workspace_id
|
||||||
|
),
|
||||||
|
serde_json::json!({ "reason": input.reason }).to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
WorkerOperation::Restore => {
|
||||||
|
let input = parse::<WorkerTargetInput>(input_json, "WorkerRestore")?;
|
||||||
|
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
|
||||||
|
let worker_id = authority_id(&input.worker_id, "worker_id")?;
|
||||||
|
WorkspaceRequest::json(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{}/runtimes/{runtime_id}/workers/{worker_id}/restore",
|
||||||
|
self.workspace_id
|
||||||
|
),
|
||||||
|
"{}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
WorkerOperation::Remove => unreachable!("handled above"),
|
||||||
};
|
};
|
||||||
WorkspaceRequest::json(
|
self.client
|
||||||
WorkspaceRequestMethod::Post,
|
.execute(request)
|
||||||
format!("/api/w/{}/workers", self.workspace_id),
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
|
||||||
serde_json::to_string(&request)
|
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
WorkerOperation::Stop => {
|
|
||||||
let input = parse::<WorkerStopInput>(input_json, "WorkerStop")?;
|
|
||||||
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
|
|
||||||
let worker_id = authority_id(&input.worker_id, "worker_id")?;
|
|
||||||
WorkspaceRequest::json(
|
|
||||||
WorkspaceRequestMethod::Post,
|
|
||||||
format!(
|
|
||||||
"/api/w/{}/runtimes/{runtime_id}/workers/{worker_id}/stop",
|
|
||||||
self.workspace_id
|
|
||||||
),
|
|
||||||
serde_json::json!({ "reason": input.reason }).to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
WorkerOperation::Restore => {
|
|
||||||
let input = parse::<WorkerTargetInput>(input_json, "WorkerRestore")?;
|
|
||||||
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
|
|
||||||
let worker_id = authority_id(&input.worker_id, "worker_id")?;
|
|
||||||
WorkspaceRequest::json(
|
|
||||||
WorkspaceRequestMethod::Post,
|
|
||||||
format!(
|
|
||||||
"/api/w/{}/runtimes/{runtime_id}/workers/{worker_id}/restore",
|
|
||||||
self.workspace_id
|
|
||||||
),
|
|
||||||
"{}",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let response = self
|
tool_output(self.operation, response)
|
||||||
.client
|
|
||||||
.execute(request)
|
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
|
||||||
if !response.is_success() {
|
|
||||||
return Err(ToolError::ExecutionFailed(format!(
|
|
||||||
"Workspace Worker operation returned HTTP {}: {}",
|
|
||||||
response.status, response.body
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(ToolOutput {
|
|
||||||
summary: format!("{} completed", self.operation.tool_name()),
|
|
||||||
content: Some(response.body),
|
|
||||||
attachments: Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_output(
|
||||||
|
operation: WorkerOperation,
|
||||||
|
response: WorkspaceResponse,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
if !response.is_success() {
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"Workspace Worker operation returned HTTP {}: {}",
|
||||||
|
response.status, response.body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(ToolOutput {
|
||||||
|
summary: format!("{} completed", operation.tool_name()),
|
||||||
|
content: Some(response.body),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn definition<I: JsonSchema + 'static>(
|
fn definition<I: JsonSchema + 'static>(
|
||||||
operation: WorkerOperation,
|
operation: WorkerOperation,
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
@@ -356,6 +513,7 @@ mod tests {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct RecordingWorkspaceClient {
|
struct RecordingWorkspaceClient {
|
||||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||||
|
removals: Mutex<Vec<(String, String, String, String)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceClient for RecordingWorkspaceClient {
|
impl WorkspaceClient for RecordingWorkspaceClient {
|
||||||
@@ -381,6 +539,25 @@ mod tests {
|
|||||||
body: "{}".to_string(),
|
body: "{}".to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn execute_worker_remove(
|
||||||
|
&self,
|
||||||
|
target_runtime_id: &str,
|
||||||
|
target_worker_id: &str,
|
||||||
|
expected_worker_revision: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
self.removals.lock().unwrap().push((
|
||||||
|
target_runtime_id.to_string(),
|
||||||
|
target_worker_id.to_string(),
|
||||||
|
expected_worker_revision.to_string(),
|
||||||
|
reason.to_string(),
|
||||||
|
));
|
||||||
|
Ok(WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: r#"{"removed":true}"#.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -429,11 +606,34 @@ mod tests {
|
|||||||
assert!(body.get("initial_text").is_none());
|
assert!(body.get("initial_text").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_service_can_remain_enabled_without_direct_spawn_surface() {
|
||||||
|
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
let descriptor = manage_worker_feature(client, false).descriptor();
|
||||||
|
let tools: Vec<_> = descriptor
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| tool.name.as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(!tools.contains(&"WorkerSpawn"));
|
||||||
|
assert!(tools.contains(&"WorkerList"));
|
||||||
|
assert_eq!(
|
||||||
|
descriptor.provides_services[0].id,
|
||||||
|
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_tool_family_is_distinct_from_sub_worker_tools() {
|
fn worker_tool_family_is_distinct_from_sub_worker_tools() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
WorkerOperation::ALL.map(WorkerOperation::tool_name),
|
WorkerOperation::ALL.map(WorkerOperation::tool_name),
|
||||||
["WorkerList", "WorkerSpawn", "WorkerStop", "WorkerRestore"]
|
[
|
||||||
|
"WorkerList",
|
||||||
|
"WorkerSpawn",
|
||||||
|
"WorkerStop",
|
||||||
|
"WorkerRestore",
|
||||||
|
"WorkerRemove",
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +681,92 @@ mod tests {
|
|||||||
assert!(value.get("initial_text").is_none());
|
assert!(value.get("initial_text").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_remove_forwards_only_target_revision_and_bounded_reason() {
|
||||||
|
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
let tool = WorkspaceWorkerTool {
|
||||||
|
operation: WorkerOperation::Remove,
|
||||||
|
client: client.clone(),
|
||||||
|
workspace_id: "workspace%2Ftest".to_string(),
|
||||||
|
};
|
||||||
|
tool.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"runtime_id": "runtime-1",
|
||||||
|
"worker_id": "worker-7",
|
||||||
|
"expected_worker_revision": "2026-08-11T20:00:00Z",
|
||||||
|
"reason": " retire completed Worker "
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
ToolExecutionContext::new("call-remove", "batch-remove", 0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.removals.lock().unwrap().as_slice(),
|
||||||
|
[(
|
||||||
|
"runtime-1".to_string(),
|
||||||
|
"worker-7".to_string(),
|
||||||
|
"2026-08-11T20:00:00Z".to_string(),
|
||||||
|
"retire completed Worker".to_string(),
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
|
||||||
|
let schema = serde_json::to_value(schemars::schema_for!(WorkerRemoveInput))
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
for field in [
|
||||||
|
"runtime_id",
|
||||||
|
"worker_id",
|
||||||
|
"expected_worker_revision",
|
||||||
|
"reason",
|
||||||
|
] {
|
||||||
|
assert!(schema.contains(field));
|
||||||
|
}
|
||||||
|
for forbidden in ["proof", "actor", "workspace_id", "policy", "plan", "stage"] {
|
||||||
|
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_remove_rejects_empty_oversized_and_unknown_authority_input() {
|
||||||
|
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
let tool = WorkspaceWorkerTool {
|
||||||
|
operation: WorkerOperation::Remove,
|
||||||
|
client: client.clone(),
|
||||||
|
workspace_id: "workspace%2Ftest".to_string(),
|
||||||
|
};
|
||||||
|
for reason in [" ".to_string(), "x".repeat(513)] {
|
||||||
|
let _error = tool
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"runtime_id": "runtime-1",
|
||||||
|
"worker_id": "worker-7",
|
||||||
|
"expected_worker_revision": "revision-1",
|
||||||
|
"reason": reason,
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
ToolExecutionContext::new("call-invalid", "batch-remove", 0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
}
|
||||||
|
let _error = tool
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"runtime_id": "runtime-1",
|
||||||
|
"worker_id": "worker-7",
|
||||||
|
"expected_worker_revision": "revision-1",
|
||||||
|
"reason": "retire",
|
||||||
|
"source_proof": "caller-controlled"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
ToolExecutionContext::new("call-spoof", "batch-remove", 0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(client.removals.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_inputs_reject_paths_and_parent_traversal() {
|
fn worker_inputs_reject_paths_and_parent_traversal() {
|
||||||
assert!(authority_id("https://runtime.example", "runtime_id").is_err());
|
assert!(authority_id("https://runtime.example", "runtime_id").is_err());
|
||||||
|
|||||||
@@ -346,11 +346,9 @@ mod tests {
|
|||||||
use llm_engine::tool::ToolDefinition;
|
use llm_engine::tool::ToolDefinition;
|
||||||
|
|
||||||
fn test_client() -> Arc<dyn WorkspaceClient> {
|
fn test_client() -> Arc<dyn WorkspaceClient> {
|
||||||
Arc::new(crate::worker::RuntimeWorkspaceHttpClient::new(
|
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||||
"workspace",
|
"workspace",
|
||||||
"http://backend",
|
"http://backend",
|
||||||
"test-runtime",
|
|
||||||
"test-worker",
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
use crate::feature::ToolDefinition;
|
||||||
|
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[
|
||||||
|
"MergeRequestShow",
|
||||||
|
"MergeRequestReadinessCheck",
|
||||||
|
"MergeRequestOpen",
|
||||||
|
"MergeRequestAddRevision",
|
||||||
|
"MergeRequestComplete",
|
||||||
|
];
|
||||||
|
pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit";
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Kind {
|
||||||
|
Show,
|
||||||
|
Readiness,
|
||||||
|
Open,
|
||||||
|
AddRevision,
|
||||||
|
Complete,
|
||||||
|
Review,
|
||||||
|
}
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct MergeRequestTool {
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
kind: Kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct ShowInput {
|
||||||
|
ticket: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct OpenInput {
|
||||||
|
ticket: String,
|
||||||
|
repository_id: String,
|
||||||
|
revision_id: String,
|
||||||
|
base_commit: String,
|
||||||
|
head_commit: String,
|
||||||
|
head_tree: String,
|
||||||
|
diff_digest: String,
|
||||||
|
#[serde(default)]
|
||||||
|
changed_paths: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
summary: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct AddRevisionInput {
|
||||||
|
ticket: String,
|
||||||
|
expected_current_revision_id: String,
|
||||||
|
revision_id: String,
|
||||||
|
base_commit: String,
|
||||||
|
head_commit: String,
|
||||||
|
head_tree: String,
|
||||||
|
diff_digest: String,
|
||||||
|
#[serde(default)]
|
||||||
|
changed_paths: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
summary: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct CompleteInput {
|
||||||
|
ticket: String,
|
||||||
|
operation_id: String,
|
||||||
|
expected_revision_id: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct ReviewInput {
|
||||||
|
decision: ReviewDecisionInput,
|
||||||
|
#[serde(default)]
|
||||||
|
body: String,
|
||||||
|
#[serde(default)]
|
||||||
|
findings: Vec<ReviewFindingInput>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum ReviewDecisionInput {
|
||||||
|
Approve,
|
||||||
|
RequestChanges,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct ReviewFindingInput {
|
||||||
|
severity: String,
|
||||||
|
#[serde(default)]
|
||||||
|
code: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
line: Option<u64>,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind {
|
||||||
|
fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Show => "MergeRequestShow",
|
||||||
|
Self::Readiness => "MergeRequestReadinessCheck",
|
||||||
|
Self::Open => "MergeRequestOpen",
|
||||||
|
Self::AddRevision => "MergeRequestAddRevision",
|
||||||
|
Self::Complete => "MergeRequestComplete",
|
||||||
|
Self::Review => "MergeRequestReviewSubmit",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn description(self) -> &'static str {
|
||||||
|
description(self.name()).unwrap_or("Merge Request operation.")
|
||||||
|
}
|
||||||
|
fn schema(self) -> serde_json::Value {
|
||||||
|
match self {
|
||||||
|
Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)),
|
||||||
|
Self::Open => json!(schemars::schema_for!(OpenInput)),
|
||||||
|
Self::AddRevision => json!(schemars::schema_for!(AddRevisionInput)),
|
||||||
|
Self::Complete => json!(schemars::schema_for!(CompleteInput)),
|
||||||
|
Self::Review => json!(schemars::schema_for!(ReviewInput)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for MergeRequestTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input: &str,
|
||||||
|
_context: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
|
||||||
|
})?;
|
||||||
|
let (method, path, body) = match self.kind {
|
||||||
|
Kind::Show => {
|
||||||
|
let v: ShowInput = parse(input)?;
|
||||||
|
nonempty(&v.ticket)?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Kind::Readiness => {
|
||||||
|
let v: ShowInput = parse(input)?;
|
||||||
|
nonempty(&v.ticket)?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!(
|
||||||
|
"/api/w/{workspace_id}/tickets/{}/merge-request/readiness",
|
||||||
|
v.ticket
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Kind::Open => {
|
||||||
|
let v: OpenInput = parse(input)?;
|
||||||
|
nonempty(&v.ticket)?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket),
|
||||||
|
Some(
|
||||||
|
json!({"repository_id":v.repository_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"head_tree":v.head_tree,"diff_digest":v.diff_digest,"changed_paths":v.changed_paths,"summary":v.summary}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Kind::AddRevision => {
|
||||||
|
let v: AddRevisionInput = parse(input)?;
|
||||||
|
nonempty(&v.ticket)?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{workspace_id}/tickets/{}/merge-request/revisions",
|
||||||
|
v.ticket
|
||||||
|
),
|
||||||
|
Some(
|
||||||
|
json!({"expected_current_revision_id":v.expected_current_revision_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"head_tree":v.head_tree,"diff_digest":v.diff_digest,"changed_paths":v.changed_paths,"summary":v.summary}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Kind::Complete => {
|
||||||
|
let v: CompleteInput = parse(input)?;
|
||||||
|
nonempty(&v.ticket)?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{workspace_id}/tickets/{}/merge-request/complete",
|
||||||
|
v.ticket
|
||||||
|
),
|
||||||
|
Some(
|
||||||
|
json!({"operation_id":v.operation_id,"expected_revision_id":v.expected_revision_id}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Kind::Review => {
|
||||||
|
let v: ReviewInput = parse(input)?;
|
||||||
|
let context = self.client.reviewer_attempt_context().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed(
|
||||||
|
"MergeRequestReviewSubmit is available only to an attested Reviewer child"
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{workspace_id}/tickets/{}/merge-request/reviews",
|
||||||
|
context.ticket_id
|
||||||
|
),
|
||||||
|
Some(
|
||||||
|
json!({"decision":match v.decision{ReviewDecisionInput::Approve=>"approve",ReviewDecisionInput::RequestChanges=>"request_changes"},"body":v.body,"findings":v.findings.into_iter().map(|f|json!({"severity":f.severity,"code":f.code,"path":f.path,"line":f.line,"body":f.body})).collect::<Vec<_>>() }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let request = match body {
|
||||||
|
Some(body) => WorkspaceRequest::json(method, path, body.to_string()),
|
||||||
|
None => WorkspaceRequest::get(path),
|
||||||
|
};
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.execute(request)
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
|
if !response.is_success() {
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"Merge Request API returned HTTP {}: {}",
|
||||||
|
response.status, response.body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(ToolOutput {
|
||||||
|
summary: self.kind.name().to_string(),
|
||||||
|
content: Some(response.body),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn parse<T: serde::de::DeserializeOwned>(value: &str) -> Result<T, ToolError> {
|
||||||
|
serde_json::from_str(value).map_err(|e| ToolError::InvalidArgument(e.to_string()))
|
||||||
|
}
|
||||||
|
fn nonempty(value: &str) -> Result<(), ToolError> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
Err(ToolError::InvalidArgument(
|
||||||
|
"ticket must not be empty".into(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn definition(client: Arc<dyn WorkspaceClient>, kind: Kind) -> ToolDefinition {
|
||||||
|
Arc::new(move || {
|
||||||
|
let meta = ToolMeta::new(kind.name())
|
||||||
|
.description(kind.description())
|
||||||
|
.input_schema(kind.schema());
|
||||||
|
let tool: Arc<dyn Tool> = Arc::new(MergeRequestTool {
|
||||||
|
client: client.clone(),
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
(meta, tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn common_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
|
||||||
|
vec![
|
||||||
|
definition(client.clone(), Kind::Show),
|
||||||
|
definition(client.clone(), Kind::Readiness),
|
||||||
|
definition(client.clone(), Kind::Open),
|
||||||
|
definition(client.clone(), Kind::AddRevision),
|
||||||
|
definition(client, Kind::Complete),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
pub fn reviewer_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
|
||||||
|
if client.reviewer_attempt_context().is_some() {
|
||||||
|
vec![
|
||||||
|
definition(client.clone(), Kind::Show),
|
||||||
|
definition(client, Kind::Review),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn description(name: &str) -> Option<&'static str> {
|
||||||
|
match name {
|
||||||
|
"MergeRequestShow" => Some(
|
||||||
|
"Read the authoritative Merge Request, immutable current revision, and structured review status.",
|
||||||
|
),
|
||||||
|
"MergeRequestReadinessCheck" => {
|
||||||
|
Some("Check derived merge readiness for the current immutable revision.")
|
||||||
|
}
|
||||||
|
"MergeRequestOpen" => {
|
||||||
|
Some("Open an immutable Merge Request revision for the current assigned Coder.")
|
||||||
|
}
|
||||||
|
"MergeRequestAddRevision" => {
|
||||||
|
Some("Append an immutable revision; prior approval cannot carry to the new revision.")
|
||||||
|
}
|
||||||
|
"MergeRequestComplete" => {
|
||||||
|
Some("CAS-complete an approved revision with operation-id replay and crash fencing.")
|
||||||
|
}
|
||||||
|
"MergeRequestReviewSubmit" => Some(
|
||||||
|
"Submit the attested direct-child Reviewer result bound to its immutable revision.",
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -628,12 +628,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn workspace_http_objective_tools_include_objective_crud_tools() {
|
fn workspace_http_objective_tools_include_objective_crud_tools() {
|
||||||
let names = tool_names(workspace_http_objective_tools(Arc::new(
|
let names = tool_names(workspace_http_objective_tools(Arc::new(
|
||||||
crate::worker::RuntimeWorkspaceHttpClient::new(
|
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
|
||||||
"workspace",
|
|
||||||
"http://backend",
|
|
||||||
"test-runtime",
|
|
||||||
"test-worker",
|
|
||||||
),
|
|
||||||
)));
|
)));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
//! Semantic Ticket orchestration tools backed by Feature Services.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use llm_engine::tool::{
|
||||||
|
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
|
||||||
|
};
|
||||||
|
use protocol::Segment;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::manage_worker::{
|
||||||
|
WORKER_LIFECYCLE_SERVICE_ID, WorkerLifecycleService, WorkerLifecycleSpawnRequest,
|
||||||
|
};
|
||||||
|
use super::ticket::{TICKET_SERVICE_ID, TicketService};
|
||||||
|
use crate::feature::{
|
||||||
|
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ServiceId,
|
||||||
|
ServiceRequirement, ToolContribution, ToolDeclaration,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_ID: &str = "orchestration";
|
||||||
|
const TOOL_NAME: &str = "SpawnTicketCoder";
|
||||||
|
const CODER_PROFILE: &str = "builtin:coder";
|
||||||
|
const CODER_FLOW: &str = "builtin:coder-review";
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct OrchestrationFeature;
|
||||||
|
|
||||||
|
pub fn orchestration_feature() -> OrchestrationFeature {
|
||||||
|
OrchestrationFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeatureModule for OrchestrationFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
FeatureDescriptor::builtin(FEATURE_ID, "Orchestration")
|
||||||
|
.with_description("Semantic Ticket orchestration operations.")
|
||||||
|
.with_service_requirement(ServiceRequirement::required(
|
||||||
|
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||||
|
"SpawnTicketCoder requires current typed Ticket authority",
|
||||||
|
))
|
||||||
|
.with_service_requirement(ServiceRequirement::required(
|
||||||
|
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||||
|
"SpawnTicketCoder requires Workspace Worker lifecycle authority",
|
||||||
|
))
|
||||||
|
.with_tool(ToolDeclaration::new(
|
||||||
|
TOOL_NAME,
|
||||||
|
"Spawn and atomically assign a Coder Worker for an inprogress Ticket. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
|
let ticket_service = context
|
||||||
|
.services()
|
||||||
|
.require::<dyn TicketService>(&ServiceId::builtin(TICKET_SERVICE_ID))?;
|
||||||
|
let worker_service =
|
||||||
|
context
|
||||||
|
.services()
|
||||||
|
.require::<dyn WorkerLifecycleService>(&ServiceId::builtin(
|
||||||
|
WORKER_LIFECYCLE_SERVICE_ID,
|
||||||
|
))?;
|
||||||
|
context.tools().register(ToolContribution::new(
|
||||||
|
TOOL_NAME,
|
||||||
|
definition(ticket_service, worker_service),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct SpawnTicketCoderInput {
|
||||||
|
ticket_id: String,
|
||||||
|
runtime_id: String,
|
||||||
|
working_directory_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
relative_cwd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SpawnTicketCoderTool {
|
||||||
|
ticket_service: Arc<dyn TicketService>,
|
||||||
|
worker_service: Arc<dyn WorkerLifecycleService>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for SpawnTicketCoderTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input_json: &str,
|
||||||
|
ctx: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
|
||||||
|
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
|
||||||
|
})?;
|
||||||
|
let ticket_id = authority_id(input.ticket_id, "ticket_id")?;
|
||||||
|
let workflow_state = self
|
||||||
|
.ticket_service
|
||||||
|
.workflow_state(&ticket_id)
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
|
if workflow_state != ticket::TicketWorkflowState::InProgress {
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"Ticket {ticket_id} must be inprogress before spawning its Coder; current state is {}",
|
||||||
|
workflow_state.as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let call_id = non_empty(ctx.call_id, "tool call_id")?;
|
||||||
|
let relative_cwd = input.relative_cwd.map(validate_relative_cwd).transpose()?;
|
||||||
|
let response = self
|
||||||
|
.worker_service
|
||||||
|
.spawn(WorkerLifecycleSpawnRequest {
|
||||||
|
runtime_id: authority_id(input.runtime_id, "runtime_id")?,
|
||||||
|
working_directory_id: authority_id(
|
||||||
|
input.working_directory_id,
|
||||||
|
"working_directory_id",
|
||||||
|
)?,
|
||||||
|
relative_cwd,
|
||||||
|
profile: CODER_PROFILE.to_string(),
|
||||||
|
ticket_id: Some(ticket_id.clone()),
|
||||||
|
operation_id: Some(format!("spawn-ticket-coder:{ticket_id}:{call_id}")),
|
||||||
|
display_name: format!("Coder · {ticket_id}"),
|
||||||
|
initial_submit: vec![
|
||||||
|
Segment::Flow {
|
||||||
|
selector: CODER_FLOW.to_string(),
|
||||||
|
},
|
||||||
|
Segment::text(format!("Implement Ticket {ticket_id}.")),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
|
if !response.is_success() {
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"Workspace Worker operation returned HTTP {}: {}",
|
||||||
|
response.status, response.body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(ToolOutput {
|
||||||
|
summary: format!("Spawned Coder for Ticket {ticket_id}"),
|
||||||
|
content: Some(response.body),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn definition(
|
||||||
|
ticket_service: Arc<dyn TicketService>,
|
||||||
|
worker_service: Arc<dyn WorkerLifecycleService>,
|
||||||
|
) -> ToolDefinition {
|
||||||
|
Arc::new(move || {
|
||||||
|
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketCoderInput))
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
|
let meta = ToolMeta::new(TOOL_NAME)
|
||||||
|
.description("Spawn and atomically assign a policy-configured Coder for a Ticket.")
|
||||||
|
.input_schema(schema);
|
||||||
|
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketCoderTool {
|
||||||
|
ticket_service: ticket_service.clone(),
|
||||||
|
worker_service: worker_service.clone(),
|
||||||
|
});
|
||||||
|
(meta, tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authority_id(value: String, field: &str) -> Result<String, ToolError> {
|
||||||
|
let value = non_empty(value, field)?;
|
||||||
|
if value.contains('/') || value.contains('?') || value.contains('#') {
|
||||||
|
return Err(ToolError::InvalidArgument(format!(
|
||||||
|
"{field} must be an authority id, not a path or URL"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(value: String, field: &str) -> Result<String, ToolError> {
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(ToolError::InvalidArgument(format!(
|
||||||
|
"{field} must not be empty"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_relative_cwd(value: String) -> Result<String, ToolError> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty()
|
||||||
|
|| value.starts_with('/')
|
||||||
|
|| value.split('/').any(|part| matches!(part, "" | "." | ".."))
|
||||||
|
{
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"relative_cwd must be a normalized relative path inside the Workdir".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use ticket::{TicketError, TicketWorkflowState};
|
||||||
|
|
||||||
|
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingTicketService;
|
||||||
|
|
||||||
|
impl TicketService for RecordingTicketService {
|
||||||
|
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
||||||
|
Ok(TicketWorkflowState::InProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixedTicketService(TicketWorkflowState);
|
||||||
|
|
||||||
|
impl TicketService for FixedTicketService {
|
||||||
|
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
||||||
|
Ok(self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingService {
|
||||||
|
requests: Mutex<Vec<WorkerLifecycleSpawnRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WorkerLifecycleService for RecordingService {
|
||||||
|
async fn spawn(
|
||||||
|
&self,
|
||||||
|
request: WorkerLifecycleSpawnRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
self.requests.lock().unwrap().push(request);
|
||||||
|
Ok(WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: r#"{"worker_id":"42"}"#.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_ticket_coder_fixes_profile_flow_assignment_and_message() {
|
||||||
|
let service = Arc::new(RecordingService::default());
|
||||||
|
let tool = SpawnTicketCoderTool {
|
||||||
|
ticket_service: Arc::new(RecordingTicketService),
|
||||||
|
worker_service: service.clone(),
|
||||||
|
};
|
||||||
|
tool.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"ticket_id": "00001KZXN51C7",
|
||||||
|
"runtime_id": "runtime-1",
|
||||||
|
"working_directory_id": "workdir-1"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
ToolExecutionContext::new("call-7", "batch-1", 0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let requests = service.requests.lock().unwrap();
|
||||||
|
let request = &requests[0];
|
||||||
|
assert_eq!(request.profile, CODER_PROFILE);
|
||||||
|
assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7"));
|
||||||
|
assert_eq!(
|
||||||
|
request.operation_id.as_deref(),
|
||||||
|
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
|
||||||
|
);
|
||||||
|
assert_eq!(request.display_name, "Coder · 00001KZXN51C7");
|
||||||
|
assert_eq!(
|
||||||
|
request.initial_submit,
|
||||||
|
vec![
|
||||||
|
Segment::Flow {
|
||||||
|
selector: CODER_FLOW.to_string()
|
||||||
|
},
|
||||||
|
Segment::text("Implement Ticket 00001KZXN51C7.")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_ticket_coder_rejects_ticket_before_worker_side_effect() {
|
||||||
|
let worker_service = Arc::new(RecordingService::default());
|
||||||
|
let tool = SpawnTicketCoderTool {
|
||||||
|
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
|
||||||
|
worker_service: worker_service.clone(),
|
||||||
|
};
|
||||||
|
let error = tool
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"ticket_id": "00001KZXN51C7",
|
||||||
|
"runtime_id": "runtime-1",
|
||||||
|
"working_directory_id": "workdir-1"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
ToolExecutionContext::new("call-queued", "batch-1", 0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("must be inprogress"));
|
||||||
|
assert!(worker_service.requests.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orchestration_descriptor_requires_ticket_and_worker_services() {
|
||||||
|
let descriptor = orchestration_feature().descriptor();
|
||||||
|
let required: Vec<_> = descriptor
|
||||||
|
.requires_services
|
||||||
|
.iter()
|
||||||
|
.map(|requirement| requirement.id.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
required,
|
||||||
|
vec![
|
||||||
|
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||||
|
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_surface_does_not_expose_profile_flow_or_assignment_controls() {
|
||||||
|
let schema = serde_json::to_string(&schemars::schema_for!(SpawnTicketCoderInput)).unwrap();
|
||||||
|
for field in [
|
||||||
|
"ticket_id",
|
||||||
|
"runtime_id",
|
||||||
|
"working_directory_id",
|
||||||
|
"relative_cwd",
|
||||||
|
] {
|
||||||
|
assert!(schema.contains(field));
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"profile",
|
||||||
|
"selector",
|
||||||
|
"operation_id",
|
||||||
|
"display_name",
|
||||||
|
"initial_submit",
|
||||||
|
] {
|
||||||
|
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,16 +14,17 @@ use ticket::{
|
|||||||
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
||||||
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
|
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
|
||||||
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
||||||
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview,
|
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketStateChange,
|
||||||
TicketStateChange, TicketSummary,
|
TicketSummary, TicketWorkflowState,
|
||||||
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
||||||
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,
|
||||||
FeatureModule, ToolContribution, ToolDeclaration,
|
FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
|
||||||
};
|
};
|
||||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||||
|
|
||||||
@@ -33,6 +34,24 @@ const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over
|
|||||||
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
||||||
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
|
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
|
||||||
const TICKET_WORKFLOW_PROMPT_REF: &str = "$yoi/common/tickets";
|
const TICKET_WORKFLOW_PROMPT_REF: &str = "$yoi/common/tickets";
|
||||||
|
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||||
|
const TICKET_SERVICE_VERSION: &str = "1";
|
||||||
|
|
||||||
|
pub trait TicketService: Send + Sync {
|
||||||
|
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BackendTicketService {
|
||||||
|
backend: TicketToolBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TicketService for BackendTicketService {
|
||||||
|
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
||||||
|
self.backend
|
||||||
|
.show(ticket_id.into())
|
||||||
|
.map(|ticket| ticket.meta.workflow_state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn ticket_workflow_instruction() -> FeatureInstructionDeclaration {
|
fn ticket_workflow_instruction() -> FeatureInstructionDeclaration {
|
||||||
FeatureInstructionDeclaration::new(
|
FeatureInstructionDeclaration::new(
|
||||||
@@ -48,7 +67,7 @@ pub struct TicketFeatureAccess {
|
|||||||
pub authoring: bool,
|
pub authoring: bool,
|
||||||
pub thread: bool,
|
pub thread: bool,
|
||||||
pub intake: bool,
|
pub intake: bool,
|
||||||
pub orchestration_control: bool,
|
pub workflow: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketFeatureAccess {
|
impl TicketFeatureAccess {
|
||||||
@@ -57,7 +76,7 @@ impl TicketFeatureAccess {
|
|||||||
authoring: false,
|
authoring: false,
|
||||||
thread: false,
|
thread: false,
|
||||||
intake: false,
|
intake: false,
|
||||||
orchestration_control: false,
|
workflow: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +85,7 @@ impl TicketFeatureAccess {
|
|||||||
authoring: true,
|
authoring: true,
|
||||||
thread: true,
|
thread: true,
|
||||||
intake: false,
|
intake: false,
|
||||||
orchestration_control: false,
|
workflow: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,16 +94,16 @@ impl TicketFeatureAccess {
|
|||||||
authoring: true,
|
authoring: true,
|
||||||
thread: true,
|
thread: true,
|
||||||
intake: true,
|
intake: true,
|
||||||
orchestration_control: false,
|
workflow: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn orchestration_control() -> Self {
|
pub const fn workflow() -> Self {
|
||||||
Self {
|
Self {
|
||||||
authoring: false,
|
authoring: false,
|
||||||
thread: true,
|
thread: true,
|
||||||
intake: false,
|
intake: false,
|
||||||
orchestration_control: true,
|
workflow: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,16 +112,16 @@ impl TicketFeatureAccess {
|
|||||||
authoring: false,
|
authoring: false,
|
||||||
thread: true,
|
thread: true,
|
||||||
intake: false,
|
intake: false,
|
||||||
orchestration_control: false,
|
workflow: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn review() -> Self {
|
pub const fn review() -> Self {
|
||||||
Self {
|
Self {
|
||||||
authoring: false,
|
authoring: false,
|
||||||
thread: true,
|
thread: false,
|
||||||
intake: false,
|
intake: false,
|
||||||
orchestration_control: false,
|
workflow: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,8 +138,7 @@ impl TicketFeatureAccess {
|
|||||||
|| (self.authoring && AUTHORING_TOOL_NAMES.contains(&name))
|
|| (self.authoring && AUTHORING_TOOL_NAMES.contains(&name))
|
||||||
|| (self.thread && THREAD_TOOL_NAMES.contains(&name))
|
|| (self.thread && THREAD_TOOL_NAMES.contains(&name))
|
||||||
|| (self.intake && INTAKE_TOOL_NAMES.contains(&name))
|
|| (self.intake && INTAKE_TOOL_NAMES.contains(&name))
|
||||||
|| (self.orchestration_control
|
|| (self.workflow && WORKFLOW_ADDITIONAL_TOOL_NAMES.contains(&name))
|
||||||
&& ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES.contains(&name))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +159,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
|
|||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
];
|
];
|
||||||
|
|
||||||
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment", "TicketReview"];
|
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
|
||||||
|
|
||||||
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
|
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
|
||||||
|
|
||||||
@@ -152,7 +170,6 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
|||||||
"TicketList",
|
"TicketList",
|
||||||
"TicketShow",
|
"TicketShow",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketReview",
|
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketDependencyCheck",
|
"TicketDependencyCheck",
|
||||||
@@ -163,11 +180,10 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
|
const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
||||||
"TicketList",
|
"TicketList",
|
||||||
"TicketShow",
|
"TicketShow",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketReview",
|
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketDependencyCheck",
|
"TicketDependencyCheck",
|
||||||
@@ -178,7 +194,7 @@ const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
|
|||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
];
|
];
|
||||||
|
|
||||||
const ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
@@ -332,7 +348,12 @@ impl FeatureModule for TicketFeature {
|
|||||||
fn descriptor(&self) -> FeatureDescriptor {
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||||
.with_description(FEATURE_DESCRIPTION)
|
.with_description(FEATURE_DESCRIPTION)
|
||||||
.with_instruction(ticket_workflow_instruction());
|
.with_instruction(ticket_workflow_instruction())
|
||||||
|
.with_provided_service(ServiceDeclaration::new(
|
||||||
|
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||||
|
TICKET_SERVICE_VERSION,
|
||||||
|
"Current typed Ticket authority",
|
||||||
|
));
|
||||||
let enabled_tool_names = self.enabled_tool_names();
|
let enabled_tool_names = self.enabled_tool_names();
|
||||||
for name in enabled_tool_names {
|
for name in enabled_tool_names {
|
||||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||||
@@ -340,6 +361,22 @@ 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_attempt_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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +392,17 @@ impl FeatureModule for TicketFeature {
|
|||||||
let Some(backend) = self.tool_backend(context) else {
|
let Some(backend) = self.tool_backend(context) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService {
|
||||||
|
backend: backend.clone(),
|
||||||
|
});
|
||||||
|
context.services().provide(
|
||||||
|
ServiceDeclaration::new(
|
||||||
|
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||||
|
TICKET_SERVICE_VERSION,
|
||||||
|
"Current typed Ticket authority",
|
||||||
|
),
|
||||||
|
ticket_service,
|
||||||
|
)?;
|
||||||
context
|
context
|
||||||
.instructions()
|
.instructions()
|
||||||
.register(FeatureInstructionContribution::new(
|
.register(FeatureInstructionContribution::new(
|
||||||
@@ -373,6 +421,17 @@ 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_attempt_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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,14 +670,6 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
|
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
TicketBackendOperation::Review { id, review } => Self::request_unit(
|
|
||||||
client,
|
|
||||||
WorkspaceRequestMethod::Post,
|
|
||||||
format!("{base}/{}/workflow/review", Self::ticket_path(&id)),
|
|
||||||
Some(serde_json::to_value(review).map_err(|error| {
|
|
||||||
TicketError::Conflict(format!("serialize Ticket review: {error}"))
|
|
||||||
})?),
|
|
||||||
),
|
|
||||||
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
|
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
|
||||||
client,
|
client,
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
@@ -844,15 +895,6 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> TicketResult<()> {
|
|
||||||
match self.invoke(TicketBackendOperation::Review { id, review })? {
|
|
||||||
TicketBackendOperationResult::Unit => Ok(()),
|
|
||||||
other => Err(TicketError::Conflict(format!(
|
|
||||||
"unexpected ticket backend response: {other:?}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
||||||
match self.invoke(TicketBackendOperation::Close { id, resolution })? {
|
match self.invoke(TicketBackendOperation::Close { id, resolution })? {
|
||||||
TicketBackendOperationResult::Unit => Ok(()),
|
TicketBackendOperationResult::Unit => Ok(()),
|
||||||
@@ -1009,24 +1051,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn orchestration_control_descriptor_declares_orchestration_tools() {
|
fn workflow_descriptor_declares_workflow_tools() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let feature = ticket_tools_feature_with_access(
|
let feature =
|
||||||
temp.path(),
|
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
|
||||||
TicketFeatureAccess::orchestration_control(),
|
|
||||||
);
|
|
||||||
let descriptor = feature.descriptor();
|
let descriptor = feature.descriptor();
|
||||||
assert_eq!(
|
assert_eq!(feature.access(), TicketFeatureAccess::workflow());
|
||||||
feature.access(),
|
|
||||||
TicketFeatureAccess::orchestration_control()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
descriptor
|
descriptor
|
||||||
.tools
|
.tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tool| tool.name.as_str())
|
.map(|tool| tool.name.as_str())
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
ORCHESTRATION_CONTROL_TOOL_NAMES
|
WORKFLOW_TOOL_NAMES
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,10 +1086,8 @@ mod tests {
|
|||||||
assert!(workspace_tools.contains(&"TicketQueue"));
|
assert!(workspace_tools.contains(&"TicketQueue"));
|
||||||
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
|
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
|
||||||
|
|
||||||
let orchestration = ticket_tools_feature_with_access(
|
let orchestration =
|
||||||
temp.path(),
|
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
|
||||||
TicketFeatureAccess::orchestration_control(),
|
|
||||||
);
|
|
||||||
let orchestration_descriptor = orchestration.descriptor();
|
let orchestration_descriptor = orchestration.descriptor();
|
||||||
let orchestration_tools = orchestration_descriptor
|
let orchestration_tools = orchestration_descriptor
|
||||||
.tools
|
.tools
|
||||||
@@ -1075,7 +1110,6 @@ mod tests {
|
|||||||
.map(|tool| tool.name.as_str())
|
.map(|tool| tool.name.as_str())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert!(work_report_tools.contains(&"TicketComment"));
|
assert!(work_report_tools.contains(&"TicketComment"));
|
||||||
assert!(work_report_tools.contains(&"TicketReview"));
|
|
||||||
assert!(!work_report_tools.contains(&"TicketWorkflowState"));
|
assert!(!work_report_tools.contains(&"TicketWorkflowState"));
|
||||||
|
|
||||||
let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::review());
|
let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::review());
|
||||||
@@ -1085,7 +1119,6 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|tool| tool.name.as_str())
|
.map(|tool| tool.name.as_str())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert!(review_tools.contains(&"TicketReview"));
|
|
||||||
assert!(!review_tools.contains(&"TicketWorkflowState"));
|
assert!(!review_tools.contains(&"TicketWorkflowState"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1369,12 +1402,7 @@ provider = "github"
|
|||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn workspace_http_backend_invoke_is_safe_inside_async_context() {
|
async fn workspace_http_backend_invoke_is_safe_inside_async_context() {
|
||||||
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
|
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
|
||||||
crate::worker::RuntimeWorkspaceHttpClient::new(
|
crate::worker::TestWorkspaceHttpClient::new("workspace-a", "not-a-url"),
|
||||||
"workspace-a",
|
|
||||||
"not-a-url",
|
|
||||||
"test-runtime",
|
|
||||||
"test-worker",
|
|
||||||
),
|
|
||||||
));
|
));
|
||||||
|
|
||||||
let error = backend
|
let error = backend
|
||||||
@@ -1405,11 +1433,9 @@ provider = "github"
|
|||||||
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
let client = Arc::new(crate::worker::RuntimeWorkspaceHttpClient::new(
|
let client = Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||||
"workspace-a",
|
"workspace-a",
|
||||||
format!("http://{address}"),
|
format!("http://{address}"),
|
||||||
"test-runtime",
|
|
||||||
"worker-a",
|
|
||||||
));
|
));
|
||||||
let backend = WorkspaceHttpTicketBackend::new(client);
|
let backend = WorkspaceHttpTicketBackend::new(client);
|
||||||
|
|
||||||
@@ -1450,12 +1476,7 @@ provider = "github"
|
|||||||
});
|
});
|
||||||
|
|
||||||
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
|
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
|
||||||
crate::worker::RuntimeWorkspaceHttpClient::new(
|
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
|
||||||
"workspace-a",
|
|
||||||
base_url,
|
|
||||||
"test-runtime",
|
|
||||||
"test-worker",
|
|
||||||
),
|
|
||||||
));
|
));
|
||||||
let created = backend.create(NewTicket::new("HTTP ticket")).unwrap();
|
let created = backend.create(NewTicket::new("HTTP ticket")).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -724,11 +724,14 @@ impl FeatureModule for PluginToolFeature {
|
|||||||
if instance.is_none() {
|
if instance.is_none() {
|
||||||
instance = Some(self.ensure_instance()?);
|
instance = Some(self.ensure_instance()?);
|
||||||
}
|
}
|
||||||
context.services().provide(ServiceDeclaration::new(
|
context.services().provide(
|
||||||
plugin_service_id(&self.record, &service.name),
|
ServiceDeclaration::new(
|
||||||
self.record.manifest.version.clone(),
|
plugin_service_id(&self.record, &service.name),
|
||||||
service.description.clone(),
|
self.record.manifest.version.clone(),
|
||||||
))?;
|
service.description.clone(),
|
||||||
|
),
|
||||||
|
Arc::new(instance.as_ref().expect("instance initialized").clone()),
|
||||||
|
)?;
|
||||||
exposed += 1;
|
exposed += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ pub(crate) struct InternalWorkerSessionHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InternalWorkerSessionHandle {
|
impl InternalWorkerSessionHandle {
|
||||||
|
pub(crate) fn session_id_string(&self) -> String {
|
||||||
|
self.session_id.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn status(&self) -> InternalWorkerSessionStatus {
|
pub(crate) fn status(&self) -> InternalWorkerSessionStatus {
|
||||||
InternalWorkerSessionStatus::decode(self.status.load(std::sync::atomic::Ordering::Acquire))
|
InternalWorkerSessionStatus::decode(self.status.load(std::sync::atomic::Ordering::Acquire))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ mod permission;
|
|||||||
mod worker;
|
mod worker;
|
||||||
|
|
||||||
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||||
pub use controller::{ShutdownReceiver, WorkerController, WorkerHandle};
|
pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle};
|
||||||
pub use hook::{Hook, HookEventKind, HookRegistryBuilder};
|
pub use hook::{Hook, HookEventKind, HookRegistryBuilder};
|
||||||
pub use ipc::alerter::Alerter;
|
pub use ipc::alerter::Alerter;
|
||||||
pub use ipc::server::SocketServer;
|
pub use ipc::server::SocketServer;
|
||||||
@@ -40,9 +40,9 @@ pub use runtime::dir::RuntimeDir;
|
|||||||
pub use segment_log_sink::SegmentLogSink;
|
pub use segment_log_sink::SegmentLogSink;
|
||||||
pub use shared_state::WorkerSharedState;
|
pub use shared_state::WorkerSharedState;
|
||||||
pub use worker::{
|
pub use worker::{
|
||||||
LocalWorkingDirectory, RuntimeWorkspaceHttpClient, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN,
|
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
|
||||||
Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext,
|
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
|
||||||
WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspaceRequest,
|
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||||
WorkspaceRequestMethod, WorkspaceResponse, apply_worker_manifest, marker_workspace_client,
|
WorkspaceResponse, apply_worker_manifest, marker_workspace_client,
|
||||||
unavailable_workspace_client,
|
unavailable_workspace_client,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -746,6 +746,21 @@ compact_system = "PREFIX\n{% include \"$yoi/internal/compact_system\" %}"
|
|||||||
assert!(rendered.contains("bypass user/Ticket authorization"));
|
assert!(rendered.contains("bypass user/Ticket authorization"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orchestrator_role_prompt_fences_worker_remove_authority() {
|
||||||
|
let source = include_str!("../../../../resources/prompts/role/orchestrator.md");
|
||||||
|
assert!(source.contains("Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder"));
|
||||||
|
assert!(source.contains("exact current `updated_at` value"));
|
||||||
|
assert!(source.contains("must have no current Ticket assignment"));
|
||||||
|
assert!(source.contains("pending notification, Reviewer handoff, legal hold, or pin"));
|
||||||
|
assert!(source.contains("After removal, reread the Worker catalog and attachment state"));
|
||||||
|
assert!(source.contains("attachment-close, and attachment-release conflicts"));
|
||||||
|
assert!(source.contains("preserves the Workdir materialization"));
|
||||||
|
assert!(!source.contains("source proof"));
|
||||||
|
assert!(!source.contains("provider handle"));
|
||||||
|
assert!(!source.contains("retention plan"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sub_worker_spawn_tool_description_renders_profile_block() {
|
fn sub_worker_spawn_tool_description_renders_profile_block() {
|
||||||
let cat = PromptCatalog::builtins_only().unwrap();
|
let cat = PromptCatalog::builtins_only().unwrap();
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ pub struct SpawnedWorkerRecord {
|
|||||||
pub struct RuntimeDir {
|
pub struct RuntimeDir {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
write_legacy_snapshots: bool,
|
write_legacy_snapshots: bool,
|
||||||
|
preserve_on_drop: bool,
|
||||||
|
socket_file_name: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeDir {
|
impl RuntimeDir {
|
||||||
@@ -56,6 +58,8 @@ impl RuntimeDir {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
path,
|
path,
|
||||||
write_legacy_snapshots: true,
|
write_legacy_snapshots: true,
|
||||||
|
preserve_on_drop: false,
|
||||||
|
socket_file_name: "sock",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +73,36 @@ impl RuntimeDir {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
path,
|
path,
|
||||||
write_legacy_snapshots: false,
|
write_legacy_snapshots: false,
|
||||||
|
preserve_on_drop: false,
|
||||||
|
socket_file_name: "sock",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an exact, persistent generation-scoped Worker run directory.
|
||||||
|
/// Existing directories are rejected so stale artifacts cannot be reused.
|
||||||
|
pub async fn create_worker_run(path: &Path) -> Result<Self, io::Error> {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| io::Error::other("run path has no parent"))?;
|
||||||
|
fs::create_dir_all(parent).await?;
|
||||||
|
fs::create_dir(path).await?;
|
||||||
|
fs::create_dir(path.join("artifacts")).await?;
|
||||||
|
fs::create_dir(path.join("spawned")).await?;
|
||||||
|
for log in ["worker.out.log", "worker.err.log"] {
|
||||||
|
let file = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(path.join(log))
|
||||||
|
.await?;
|
||||||
|
file.sync_all().await?;
|
||||||
|
}
|
||||||
|
std::fs::File::open(path)?.sync_all()?;
|
||||||
|
std::fs::File::open(parent)?.sync_all()?;
|
||||||
|
Ok(Self {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
write_legacy_snapshots: false,
|
||||||
|
preserve_on_drop: true,
|
||||||
|
socket_file_name: "worker.sock",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,13 +150,24 @@ impl RuntimeDir {
|
|||||||
/// that only know the worker name (e.g. the TUI's attach flow)
|
/// that only know the worker name (e.g. the TUI's attach flow)
|
||||||
/// predict the same path via [`manifest::paths::worker_socket_path`].
|
/// predict the same path via [`manifest::paths::worker_socket_path`].
|
||||||
pub fn socket_path(&self) -> PathBuf {
|
pub fn socket_path(&self) -> PathBuf {
|
||||||
self.path.join("sock")
|
self.path.join(self.socket_file_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn close_socket(&self) -> Result<(), io::Error> {
|
||||||
|
match fs::remove_file(self.socket_path()).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for RuntimeDir {
|
impl Drop for RuntimeDir {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = std::fs::remove_dir_all(&self.path);
|
let _ = std::fs::remove_file(self.socket_path());
|
||||||
|
if !self.preserve_on_drop {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,15 +120,15 @@ pub fn adopt_allocation(
|
|||||||
/// The Worker's in-memory `segment_id` can change underneath the
|
/// The Worker's in-memory `segment_id` can change underneath the
|
||||||
/// allocation in two normal places:
|
/// allocation in two normal places:
|
||||||
///
|
///
|
||||||
/// - `Worker::compact` mints a fresh session and swaps it in.
|
/// - `Worker::compact` mints a fresh Segment in the same Session.
|
||||||
/// - `session_store::ensure_head_or_fork` auto-forks when another
|
/// - `session_store::ensure_head_or_fork` auto-forks within that Session when another
|
||||||
/// writer has advanced the store head behind our back.
|
/// writer has advanced the store head behind our back.
|
||||||
///
|
///
|
||||||
/// Both paths must call this so subsequent [`lookup_segment`] queries
|
/// Both paths must call this so subsequent [`lookup_segment`] queries
|
||||||
/// find the live session id, not the old one. Without this update a
|
/// find the live Segment id, not the old one. Without this update a
|
||||||
/// concurrent `restore_from_manifest(new_id)` would see "no live
|
/// concurrent `restore_from_manifest(new_id)` would see "no live
|
||||||
/// writer" and proceed to register a competing allocation on the
|
/// writer" and proceed to register a competing allocation on the
|
||||||
/// session this Worker just moved into.
|
/// Segment lineage this Worker just moved into.
|
||||||
///
|
///
|
||||||
/// The lock is opened once and the allocation is rewritten inside the
|
/// The lock is opened once and the allocation is rewritten inside the
|
||||||
/// guard, so the segment_id collision check is atomic with the
|
/// guard, so the segment_id collision check is atomic with the
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ mod tests {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert_eq!(runtime_header.as_deref(), Some("runtime-test"));
|
assert_eq!(runtime_header, None);
|
||||||
assert_eq!(worker_header.as_deref(), Some("test-worker"));
|
assert_eq!(worker_header, None);
|
||||||
assert_eq!(authorization, None);
|
assert_eq!(authorization, None);
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"authority": "workspace-backend-skills-v0",
|
"authority": "workspace-backend-skills-v0",
|
||||||
@@ -236,12 +236,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = crate::worker::RuntimeWorkspaceHttpClient::new(
|
let client = crate::worker::TestWorkspaceHttpClient::new("ws-1", format!("http://{addr}"));
|
||||||
"ws-1",
|
|
||||||
format!("http://{addr}"),
|
|
||||||
"runtime-test",
|
|
||||||
"test-worker",
|
|
||||||
);
|
|
||||||
let catalog = (&client as &dyn WorkspaceClient).list_skills().unwrap();
|
let catalog = (&client as &dyn WorkspaceClient).list_skills().unwrap();
|
||||||
assert_eq!(catalog.entries[0].name, "triage-errors");
|
assert_eq!(catalog.entries[0].name, "triage-errors");
|
||||||
assert_eq!(catalog.entries[0].provenance.id, "workspace:triage-errors");
|
assert_eq!(catalog.entries[0].provenance.id, "workspace:triage-errors");
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ use crate::internal_worker::{
|
|||||||
};
|
};
|
||||||
use crate::prompt::catalog::PromptCatalog;
|
use crate::prompt::catalog::PromptCatalog;
|
||||||
use crate::spawn::registry::SpawnedWorkerRegistry;
|
use crate::spawn::registry::SpawnedWorkerRegistry;
|
||||||
use crate::worker::{Worker, WorkerFilesystemAuthority};
|
use crate::worker::{
|
||||||
|
ReviewerAttemptContext, ReviewerChildWorkspaceClient, Worker, WorkerFilesystemAuthority,
|
||||||
|
WorkspaceRequest, WorkspaceRequestMethod,
|
||||||
|
};
|
||||||
use protocol::Method;
|
use protocol::Method;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
@@ -55,6 +58,16 @@ struct SubWorkerSpawnInput {
|
|||||||
/// spawner's explicit delegation authority; direct tool scope alone is not
|
/// spawner's explicit delegation authority; direct tool scope alone is not
|
||||||
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
|
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
|
||||||
scope: Vec<ScopeRuleInput>,
|
scope: Vec<ScopeRuleInput>,
|
||||||
|
/// Binds an actual read-only builtin Reviewer child to an immutable Merge Request revision.
|
||||||
|
/// Review attempt identity and capability material are generated by the trusted spawn layer.
|
||||||
|
#[serde(default)]
|
||||||
|
review: Option<ReviewerHandoffInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct ReviewerHandoffInput {
|
||||||
|
ticket_id: String,
|
||||||
|
revision_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
@@ -320,6 +333,32 @@ impl SubWorkerSpawnTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolError> {
|
||||||
|
let Some(review) = &input.review else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if review.ticket_id.trim().is_empty() || review.revision_id.trim().is_empty() {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"reviewer handoff requires non-empty ticket_id and revision_id".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if input.profile.as_deref() != Some("builtin:reviewer") {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"reviewer handoff requires the explicit effective profile builtin:reviewer".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if input
|
||||||
|
.scope
|
||||||
|
.iter()
|
||||||
|
.any(|rule| matches!(rule.permission, PermissionInput::Write))
|
||||||
|
{
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"Merge Request Reviewer SubWorkers must have read-only delegated scope".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for SubWorkerSpawnTool {
|
impl Tool for SubWorkerSpawnTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
@@ -340,6 +379,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
input.name
|
input.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
validate_reviewer_handoff(&input)?;
|
||||||
let name_reservation = self
|
let name_reservation = self
|
||||||
.registry
|
.registry
|
||||||
.reserve_internal_name(input.name.clone())
|
.reserve_internal_name(input.name.clone())
|
||||||
@@ -378,6 +418,48 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
|
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
|
||||||
})?;
|
})?;
|
||||||
|
let reviewer_attempt = input.review.as_ref().map(|review| {
|
||||||
|
(
|
||||||
|
review.ticket_id.clone(),
|
||||||
|
review.revision_id.clone(),
|
||||||
|
uuid::Uuid::now_v7().to_string(),
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
uuid::Uuid::now_v7().simple(),
|
||||||
|
uuid::Uuid::now_v7().simple()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let child_workspace_context =
|
||||||
|
if let Some((ticket_id, revision_id, _, capability_token)) = &reviewer_attempt {
|
||||||
|
let workspace_id =
|
||||||
|
self.workspace_context
|
||||||
|
.workspace_id()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::InvalidArgument(
|
||||||
|
"reviewer handoff requires Workspace identity".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let parent_client = self.workspace_context.client_handle();
|
||||||
|
if !parent_client.is_available() {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"reviewer handoff requires Workspace API authority".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let child_client: Arc<dyn crate::worker::WorkspaceClient> =
|
||||||
|
Arc::new(ReviewerChildWorkspaceClient::new(
|
||||||
|
parent_client.clone(),
|
||||||
|
ReviewerAttemptContext {
|
||||||
|
ticket_id: ticket_id.clone(),
|
||||||
|
revision_id: revision_id.clone(),
|
||||||
|
},
|
||||||
|
capability_token.clone(),
|
||||||
|
));
|
||||||
|
crate::worker::WorkerWorkspaceContext::with_client(Some(workspace_id), child_client)
|
||||||
|
} else {
|
||||||
|
self.workspace_context.clone()
|
||||||
|
};
|
||||||
let store = EphemeralSessionStore::default();
|
let store = EphemeralSessionStore::default();
|
||||||
let filesystem_authority =
|
let filesystem_authority =
|
||||||
WorkerFilesystemAuthority::local(self.workspace_root.clone(), child_cwd.clone());
|
WorkerFilesystemAuthority::local(self.workspace_root.clone(), child_cwd.clone());
|
||||||
@@ -385,7 +467,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
child_manifest,
|
child_manifest,
|
||||||
store.clone(),
|
store.clone(),
|
||||||
self.prompt_loader.clone(),
|
self.prompt_loader.clone(),
|
||||||
self.workspace_context.clone(),
|
child_workspace_context,
|
||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
self.internal_client_override
|
self.internal_client_override
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -465,6 +547,66 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some((ticket_id, revision_id, attempt_id, capability_token)) = &reviewer_attempt {
|
||||||
|
let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("reviewer attempt lost Workspace identity".to_string())
|
||||||
|
})?;
|
||||||
|
let child_session_id = session.session_id_string();
|
||||||
|
let child_registration = WorkspaceRequest::json(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{}/internal/reviewer-child-sessions",
|
||||||
|
workspace_id.as_str()
|
||||||
|
),
|
||||||
|
serde_json::json!({"child_session_id": child_session_id}).to_string(),
|
||||||
|
);
|
||||||
|
let child_response = self
|
||||||
|
.workspace_context
|
||||||
|
.client()
|
||||||
|
.execute(child_registration)
|
||||||
|
.map_err(|error| {
|
||||||
|
ToolError::ExecutionFailed(format!(
|
||||||
|
"register Runtime-owned Reviewer child session: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !child_response.is_success() {
|
||||||
|
let _ = session.stop().await;
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"register Runtime-owned Reviewer child session failed with status {}: {}",
|
||||||
|
child_response.status, child_response.body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"attempt_id": attempt_id,
|
||||||
|
"revision_id": revision_id,
|
||||||
|
"child_session_id": child_session_id,
|
||||||
|
"capability_token": capability_token,
|
||||||
|
});
|
||||||
|
let request = WorkspaceRequest::json(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{}/tickets/{}/merge-request/review-attempts",
|
||||||
|
workspace_id.as_str(),
|
||||||
|
ticket_id
|
||||||
|
),
|
||||||
|
body.to_string(),
|
||||||
|
);
|
||||||
|
let response = self
|
||||||
|
.workspace_context
|
||||||
|
.client()
|
||||||
|
.execute(request)
|
||||||
|
.map_err(|error| {
|
||||||
|
ToolError::ExecutionFailed(format!("register reviewer attempt: {error}"))
|
||||||
|
})?;
|
||||||
|
if !response.is_success() {
|
||||||
|
let _ = session.stop().await;
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"register reviewer attempt failed with status {}: {}",
|
||||||
|
response.status, response.body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new(
|
let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new(
|
||||||
input.name.clone(),
|
input.name.clone(),
|
||||||
scope_allow,
|
scope_allow,
|
||||||
@@ -899,6 +1041,31 @@ mod tests {
|
|||||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
|
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reviewer_handoff_requires_explicit_builtin_profile_and_read_only_scope() {
|
||||||
|
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
|
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||||
|
"scope":[{"target":"/tmp/work","permission":"read"}],
|
||||||
|
"review":{"ticket_id":"T1","revision_id":"V1"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(validate_reviewer_handoff(&valid).is_ok());
|
||||||
|
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
|
"name":"reviewer","task":"review","profile":"builtin:coder",
|
||||||
|
"scope":[{"target":"/tmp/work","permission":"read"}],
|
||||||
|
"review":{"ticket_id":"T1","revision_id":"V1"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
|
||||||
|
let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
|
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||||
|
"scope":[{"target":"/tmp/work","permission":"write"}],
|
||||||
|
"review":{"ticket_id":"T1","revision_id":"V1"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(validate_reviewer_handoff(&writable).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
|
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
|
||||||
ScopeRule {
|
ScopeRule {
|
||||||
target: path.to_path_buf(),
|
target: path.to_path_buf(),
|
||||||
|
|||||||
+163
-115
@@ -223,55 +223,135 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
|||||||
fn is_available(&self) -> bool;
|
fn is_available(&self) -> bool;
|
||||||
fn execute(&self, request: WorkspaceRequest)
|
fn execute(&self, request: WorkspaceRequest)
|
||||||
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||||
}
|
|
||||||
|
|
||||||
/// HTTP forwarding client created by Runtime for one concrete Worker execution.
|
/// Executes the destructive WorkerRemove operation through Runtime-owned source proof.
|
||||||
///
|
/// Target identity is operation data; source identity and permission are never caller inputs.
|
||||||
/// The upstream endpoint and source headers are private implementation details;
|
fn execute_worker_remove(
|
||||||
/// model-visible tools can only submit [`WorkspaceRequest`] values through the
|
&self,
|
||||||
/// [`WorkspaceClient`] trait.
|
_target_runtime_id: &str,
|
||||||
pub struct RuntimeWorkspaceHttpClient {
|
_target_worker_id: &str,
|
||||||
workspace_id: String,
|
_expected_worker_revision: &str,
|
||||||
base_url: String,
|
_reason: &str,
|
||||||
runtime_id: String,
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
worker_id: String,
|
Err(WorkspaceClientError::Unavailable(
|
||||||
}
|
"Runtime-owned WorkerRemove forwarding is unavailable".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for RuntimeWorkspaceHttpClient {
|
/// Trusted review-attempt context is injected by the Internal SubWorker spawn layer.
|
||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
/// It is never accepted from a model-visible tool argument.
|
||||||
formatter
|
fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> {
|
||||||
.debug_struct("RuntimeWorkspaceHttpClient")
|
None
|
||||||
.field("workspace_id", &self.workspace_id)
|
|
||||||
.field("base_url", &self.base_url)
|
|
||||||
.field("runtime_id", &self.runtime_id)
|
|
||||||
.field("worker_id", &self.worker_id)
|
|
||||||
.finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeWorkspaceHttpClient {
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReviewerAttemptContext {
|
||||||
|
pub ticket_id: String,
|
||||||
|
pub revision_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ReviewerChildWorkspaceClient {
|
||||||
|
inner: Arc<dyn WorkspaceClient>,
|
||||||
|
context: ReviewerAttemptContext,
|
||||||
|
capability_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReviewerChildWorkspaceClient {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
workspace_id: impl Into<String>,
|
inner: Arc<dyn WorkspaceClient>,
|
||||||
base_url: impl Into<String>,
|
context: ReviewerAttemptContext,
|
||||||
runtime_id: impl Into<String>,
|
capability_token: String,
|
||||||
worker_id: impl Into<String>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
workspace_id: workspace_id.into(),
|
inner,
|
||||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
context,
|
||||||
runtime_id: runtime_id.into(),
|
capability_token,
|
||||||
worker_id: worker_id.into(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceClient for RuntimeWorkspaceHttpClient {
|
impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
||||||
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
|
self.inner.workspace_id()
|
||||||
|
}
|
||||||
|
fn kind(&self) -> &str {
|
||||||
|
"runtime-reviewer-child"
|
||||||
|
}
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
self.inner.is_available()
|
||||||
|
}
|
||||||
|
fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> {
|
||||||
|
Some(&self.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
mut request: WorkspaceRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
let expected_path = format!(
|
||||||
|
"/api/w/{}/tickets/{}/merge-request/reviews",
|
||||||
|
self.workspace_id().unwrap_or_default(),
|
||||||
|
self.context.ticket_id
|
||||||
|
);
|
||||||
|
if request.method == WorkspaceRequestMethod::Post && request.path == expected_path {
|
||||||
|
let body = request.body.take().ok_or_else(|| {
|
||||||
|
WorkspaceClientError::Request("review submission requires a JSON body".to_string())
|
||||||
|
})?;
|
||||||
|
let mut value: serde_json::Value = serde_json::from_str(&body)
|
||||||
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||||
|
let object = value.as_object_mut().ok_or_else(|| {
|
||||||
|
WorkspaceClientError::Request(
|
||||||
|
"review submission body must be an object".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
object.insert(
|
||||||
|
"revision_id".to_string(),
|
||||||
|
serde_json::Value::String(self.context.revision_id.clone()),
|
||||||
|
);
|
||||||
|
object.insert(
|
||||||
|
"capability_token".to_string(),
|
||||||
|
serde_json::Value::String(self.capability_token.clone()),
|
||||||
|
);
|
||||||
|
request.body = Some(
|
||||||
|
serde_json::to_string(&value)
|
||||||
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
|
||||||
|
);
|
||||||
|
} else if request.method != WorkspaceRequestMethod::Get {
|
||||||
|
return Err(WorkspaceClientError::Unavailable(
|
||||||
|
"Reviewer child Workspace authority is read-only except for its one attested Merge Request review submission".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.inner.execute(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct TestWorkspaceHttpClient {
|
||||||
|
workspace_id: String,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl TestWorkspaceHttpClient {
|
||||||
|
pub(crate) fn new(workspace_id: impl Into<String>, base_url: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
workspace_id: workspace_id.into(),
|
||||||
|
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl WorkspaceClient for TestWorkspaceHttpClient {
|
||||||
fn workspace_id(&self) -> Option<&str> {
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
Some(&self.workspace_id)
|
Some(&self.workspace_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kind(&self) -> &str {
|
fn kind(&self) -> &str {
|
||||||
"runtime-http-proxy"
|
"test-http"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_available(&self) -> bool {
|
fn is_available(&self) -> bool {
|
||||||
@@ -283,32 +363,28 @@ impl WorkspaceClient for RuntimeWorkspaceHttpClient {
|
|||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
let base_url = self.base_url.clone();
|
let base_url = self.base_url.clone();
|
||||||
let runtime_id = self.runtime_id.clone();
|
|
||||||
let worker_id = self.worker_id.clone();
|
|
||||||
if tokio::runtime::Handle::try_current().is_ok() {
|
if tokio::runtime::Handle::try_current().is_ok() {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || execute_test_workspace_http(&base_url, request))
|
||||||
execute_runtime_workspace_http(&base_url, &runtime_id, &worker_id, request)
|
.join()
|
||||||
})
|
.map_err(|_| {
|
||||||
.join()
|
WorkspaceClientError::Request(
|
||||||
.map_err(|_| {
|
"test workspace request thread panicked".to_string(),
|
||||||
WorkspaceClientError::Request("workspace request thread panicked".to_string())
|
)
|
||||||
})?
|
})?
|
||||||
} else {
|
} else {
|
||||||
execute_runtime_workspace_http(&base_url, &runtime_id, &worker_id, request)
|
execute_test_workspace_http(&base_url, request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_runtime_workspace_http(
|
#[cfg(test)]
|
||||||
|
fn execute_test_workspace_http(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
runtime_id: &str,
|
|
||||||
worker_id: &str,
|
|
||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
if !request.path.starts_with('/') || request.path.starts_with("//") {
|
if !request.path.starts_with('/') || request.path.starts_with("//") {
|
||||||
return Err(WorkspaceClientError::InvalidPath(request.path));
|
return Err(WorkspaceClientError::InvalidPath(request.path));
|
||||||
}
|
}
|
||||||
let url = format!("{base_url}{}", request.path);
|
|
||||||
let method = match request.method {
|
let method = match request.method {
|
||||||
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
||||||
WorkspaceRequestMethod::Post => reqwest::Method::POST,
|
WorkspaceRequestMethod::Post => reqwest::Method::POST,
|
||||||
@@ -317,16 +393,11 @@ fn execute_runtime_workspace_http(
|
|||||||
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
|
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
|
||||||
};
|
};
|
||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let mut request_builder = client
|
let mut builder = client.request(method, format!("{base_url}{}", request.path));
|
||||||
.request(method, url)
|
|
||||||
.header("x-yoi-runtime-id", runtime_id)
|
|
||||||
.header("x-yoi-worker-id", worker_id);
|
|
||||||
if let Some(body) = request.body {
|
if let Some(body) = request.body {
|
||||||
request_builder = request_builder
|
builder = builder.body(body);
|
||||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
|
||||||
.body(body);
|
|
||||||
}
|
}
|
||||||
let response = request_builder
|
let response = builder
|
||||||
.send()
|
.send()
|
||||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
@@ -365,6 +436,36 @@ impl WorkspaceClient for MarkerWorkspaceClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod reviewer_client_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reviewer_child_client_denies_non_review_workspace_mutations() {
|
||||||
|
let inner: Arc<dyn WorkspaceClient> = Arc::new(MarkerWorkspaceClient {
|
||||||
|
workspace_id: Some("ws".to_string()),
|
||||||
|
kind: "marker".to_string(),
|
||||||
|
available: true,
|
||||||
|
reason: "forwarded".to_string(),
|
||||||
|
});
|
||||||
|
let client = ReviewerChildWorkspaceClient::new(
|
||||||
|
inner,
|
||||||
|
ReviewerAttemptContext {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
revision_id: "V1".into(),
|
||||||
|
},
|
||||||
|
"secret".into(),
|
||||||
|
);
|
||||||
|
let request = WorkspaceRequest::json(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
"/api/w/ws/tickets/T1/comments",
|
||||||
|
"{}".to_string(),
|
||||||
|
);
|
||||||
|
let error = client.execute(request).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("read-only"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn unavailable_workspace_client(
|
pub fn unavailable_workspace_client(
|
||||||
workspace_id: Option<&WorkspaceId>,
|
workspace_id: Option<&WorkspaceId>,
|
||||||
reason: impl Into<String>,
|
reason: impl Into<String>,
|
||||||
@@ -2949,13 +3050,13 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
|
|
||||||
/// Compact the current session by summarising history via a
|
/// Compact the current session by summarising history via a
|
||||||
/// disposable Engine, then replacing history with
|
/// disposable Engine, then replacing history with
|
||||||
/// `[summary, ...recent_turns]` and creating a new session.
|
/// `[summary, ...recent_turns]` in a new Segment of the same Session.
|
||||||
///
|
///
|
||||||
/// The summary Engine uses:
|
/// The summary Engine uses:
|
||||||
/// - `compaction.model` from the manifest if configured, or
|
/// - `compaction.model` from the manifest if configured, or
|
||||||
/// - a clone of the main LlmClient via `clone_boxed()`.
|
/// - a clone of the main LlmClient via `clone_boxed()`.
|
||||||
///
|
///
|
||||||
/// Returns the new session ID.
|
/// Returns the new Segment ID. The Worker keeps its Session ID.
|
||||||
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SegmentId, WorkerError> {
|
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SegmentId, WorkerError> {
|
||||||
use crate::compact::worker::{
|
use crate::compact::worker::{
|
||||||
CompactWorkerContext, CompactWorkerInterceptor, add_reference_tool,
|
CompactWorkerContext, CompactWorkerInterceptor, add_reference_tool,
|
||||||
@@ -6389,7 +6490,10 @@ mod build_summary_prompt_tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.expect("flow runtime state lock") = Some(state.clone());
|
.expect("flow runtime state lock") = Some(state.clone());
|
||||||
|
|
||||||
assert!(matches!(segments[0], Segment::Text { .. }));
|
assert_eq!(
|
||||||
|
segments[0],
|
||||||
|
Segment::text("Implement the Ticket and request review.")
|
||||||
|
);
|
||||||
assert_eq!(segments[1], Segment::text("Implement Ticket 00001"));
|
assert_eq!(segments[1], Segment::text("Implement Ticket 00001"));
|
||||||
assert_eq!(state.instance.definition_revision, 3);
|
assert_eq!(state.instance.definition_revision, 3);
|
||||||
assert_eq!(state.instance.current_state.as_str(), "implement");
|
assert_eq!(state.instance.current_state.as_str(), "implement");
|
||||||
@@ -6841,11 +6945,9 @@ mod build_summary_prompt_tests {
|
|||||||
});
|
});
|
||||||
WorkerWorkspaceContext::with_client(
|
WorkerWorkspaceContext::with_client(
|
||||||
Some(WorkspaceId::new("test-memory").unwrap()),
|
Some(WorkspaceId::new("test-memory").unwrap()),
|
||||||
Arc::new(RuntimeWorkspaceHttpClient::new(
|
Arc::new(TestWorkspaceHttpClient::new(
|
||||||
"test-memory",
|
"test-memory",
|
||||||
format!("http://{addr}"),
|
format!("http://{addr}"),
|
||||||
"test-runtime",
|
|
||||||
"test-worker",
|
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -6978,11 +7080,9 @@ mod build_summary_prompt_tests {
|
|||||||
store,
|
store,
|
||||||
WorkerWorkspaceContext::with_client(
|
WorkerWorkspaceContext::with_client(
|
||||||
Some(WorkspaceId::new("ws-skill").unwrap()),
|
Some(WorkspaceId::new("ws-skill").unwrap()),
|
||||||
Arc::new(RuntimeWorkspaceHttpClient::new(
|
Arc::new(TestWorkspaceHttpClient::new(
|
||||||
"ws-skill",
|
"ws-skill",
|
||||||
format!("http://{addr}"),
|
format!("http://{addr}"),
|
||||||
"test-runtime",
|
|
||||||
"test-worker",
|
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
authority,
|
authority,
|
||||||
@@ -7024,58 +7124,6 @@ mod build_summary_prompt_tests {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn runtime_workspace_client_sends_runtime_worker_identity_without_bearer() {
|
|
||||||
use std::io::{BufRead, BufReader, Write};
|
|
||||||
use std::net::TcpListener;
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
let server = std::thread::spawn(move || {
|
|
||||||
let (mut stream, _) = listener.accept().unwrap();
|
|
||||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
||||||
let mut first_line = String::new();
|
|
||||||
reader.read_line(&mut first_line).unwrap();
|
|
||||||
assert!(first_line.contains("/api/w/workspace-a/tickets/search"));
|
|
||||||
let mut runtime_id = String::new();
|
|
||||||
let mut worker_id = String::new();
|
|
||||||
let mut authorization = String::new();
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
reader.read_line(&mut line).unwrap();
|
|
||||||
if let Some(value) = line.strip_prefix("x-yoi-runtime-id: ") {
|
|
||||||
runtime_id = value.trim().to_string();
|
|
||||||
}
|
|
||||||
if let Some(value) = line.strip_prefix("x-yoi-worker-id: ") {
|
|
||||||
worker_id = value.trim().to_string();
|
|
||||||
}
|
|
||||||
if let Some(value) = line.strip_prefix("authorization: ") {
|
|
||||||
authorization = value.trim().to_string();
|
|
||||||
}
|
|
||||||
if line == "\r\n" || line.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert_eq!(runtime_id, "runtime-a");
|
|
||||||
assert_eq!(worker_id, "worker-a");
|
|
||||||
assert!(authorization.is_empty());
|
|
||||||
stream
|
|
||||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}")
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
let client = RuntimeWorkspaceHttpClient::new(
|
|
||||||
"workspace-a",
|
|
||||||
format!("http://{address}"),
|
|
||||||
"runtime-a",
|
|
||||||
"worker-a",
|
|
||||||
);
|
|
||||||
let response = client
|
|
||||||
.execute(WorkspaceRequest::get("/api/w/workspace-a/tickets/search"))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status, 200);
|
|
||||||
server.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cancelled_internal_extract_does_not_commit_pointer_or_completed_audit() {
|
async fn cancelled_internal_extract_does_not_commit_pointer_or_completed_audit() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -421,7 +421,11 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
|||||||
// Drain run events so only compact events remain in `rx`.
|
// Drain run events so only compact events remain in `rx`.
|
||||||
let _ = drain(&mut rx);
|
let _ = drain(&mut rx);
|
||||||
|
|
||||||
|
let session_before = worker.session_id();
|
||||||
|
let segment_before = worker.segment_id();
|
||||||
worker.try_pre_run_compact().await;
|
worker.try_pre_run_compact().await;
|
||||||
|
assert_eq!(worker.session_id(), session_before);
|
||||||
|
assert_ne!(worker.segment_id(), segment_before);
|
||||||
|
|
||||||
let events = drain(&mut rx);
|
let events = drain(&mut rx);
|
||||||
let kinds: Vec<&str> = events
|
let kinds: Vec<&str> = events
|
||||||
@@ -442,7 +446,7 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
|||||||
"unexpected CompactFailed in {kinds:?}"
|
"unexpected CompactFailed in {kinds:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// CompactDone carries the new session id.
|
// CompactDone carries the new Segment ID; the Session ID is unchanged.
|
||||||
let new_id_in_event = events.iter().find_map(|e| match e {
|
let new_id_in_event = events.iter().find_map(|e| match e {
|
||||||
Event::CompactDone { new_segment_id } => Some(*new_segment_id),
|
Event::CompactDone { new_segment_id } => Some(*new_segment_id),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -583,11 +587,11 @@ async fn compact_resets_extract_pointer_so_extract_can_fire_again() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Compact runs. Without the fix the in-memory pointer would still
|
// Compact runs. Without the fix the in-memory pointer would still
|
||||||
// reference the old session's history_len.
|
// reference the old Segment's history_len.
|
||||||
worker.try_pre_run_compact().await;
|
worker.try_pre_run_compact().await;
|
||||||
assert!(
|
assert!(
|
||||||
worker.extract_pointer().is_none(),
|
worker.extract_pointer().is_none(),
|
||||||
"extract_pointer must be reset to None after compact (matches cold-restore on the new session)"
|
"extract_pointer must be reset to None after compact (matches cold-restore on the new Segment)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ sha2.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
ticket.workspace = true
|
ticket.workspace = true
|
||||||
memory.workspace = true
|
memory.workspace = true
|
||||||
|
merge-request.workspace = true
|
||||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
worker.workspace = true
|
worker.workspace = true
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use chrono::Utc;
|
|||||||
use project_record::{allocate_record_id, unix_epoch_millis_now};
|
use project_record::{allocate_record_id, unix_epoch_millis_now};
|
||||||
|
|
||||||
use ticket::{
|
use ticket::{
|
||||||
SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery,
|
SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkspaceActionPriority,
|
||||||
TicketWorkspaceActionPriority, project_ticket_workspace_item,
|
project_ticket_workspace_item,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::records::{
|
use crate::records::{
|
||||||
@@ -131,7 +131,7 @@ impl SqliteWorkspaceAuthority {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
store: SqliteWorkspaceStore::open(&database_path)?,
|
store: SqliteWorkspaceStore::open(&database_path)?,
|
||||||
ticket_backend: SqliteTicketBackend::new(database_path, workspace_id),
|
ticket_backend: SqliteTicketBackend::open_verified(database_path, workspace_id)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,31 +207,27 @@ impl SqliteWorkspaceAuthority {
|
|||||||
|
|
||||||
impl TicketAuthority for SqliteWorkspaceAuthority {
|
impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||||
fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
|
fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
|
||||||
let mut items = Vec::new();
|
let projection = self.ticket_backend.list_workspace_projection(limit)?;
|
||||||
for item in self.ticket_backend.list(TicketListQuery::all())? {
|
let items = projection
|
||||||
let ticket = self
|
.items
|
||||||
.ticket_backend
|
.into_iter()
|
||||||
.show(TicketIdOrSlug::Id(item.id.clone()))?;
|
.map(|item| {
|
||||||
let projection = project_ticket_workspace_item(&item, &ticket.relations.blockers, None);
|
let projection =
|
||||||
items.push(TicketSummary {
|
project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
|
||||||
id: item.id,
|
TicketSummary {
|
||||||
title: item.title,
|
id: item.summary.id,
|
||||||
state: item.workflow_state.as_str().to_string(),
|
title: item.summary.title,
|
||||||
priority: item.priority,
|
state: item.summary.workflow_state.as_str().to_string(),
|
||||||
updated_at: item.updated_at,
|
priority: item.summary.priority,
|
||||||
queued_by: item.queued_by,
|
updated_at: item.summary.updated_at,
|
||||||
queued_at: item.queued_at,
|
queued_by: item.summary.queued_by,
|
||||||
workspace_action_priority: workspace_action_priority_name(projection.priority)
|
queued_at: item.summary.queued_at,
|
||||||
.to_string(),
|
workspace_action_priority: workspace_action_priority_name(projection.priority)
|
||||||
record_source: "sqlite_yoi_ticket".to_string(),
|
.to_string(),
|
||||||
});
|
record_source: "sqlite_yoi_ticket".to_string(),
|
||||||
}
|
}
|
||||||
items.sort_by(|a, b| {
|
})
|
||||||
b.updated_at
|
.collect();
|
||||||
.cmp(&a.updated_at)
|
|
||||||
.then_with(|| a.id.cmp(&b.id))
|
|
||||||
});
|
|
||||||
items.truncate(limit);
|
|
||||||
Ok(ProjectRecordList {
|
Ok(ProjectRecordList {
|
||||||
items,
|
items,
|
||||||
invalid_records: Vec::new(),
|
invalid_records: Vec::new(),
|
||||||
@@ -725,7 +721,8 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
||||||
let db_path = dir.path().join("workspace.db");
|
let db_path = dir.path().join("workspace.db");
|
||||||
SqliteTicketBackend::new(&db_path, "workspace-test")
|
SqliteTicketBackend::open(&db_path, "workspace-test")
|
||||||
|
.unwrap()
|
||||||
.import_from_local_backend(&ticket::LocalTicketBackend::new(
|
.import_from_local_backend(&ticket::LocalTicketBackend::new(
|
||||||
dir.path().join(".yoi/tickets"),
|
dir.path().join(".yoi/tickets"),
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -451,6 +451,11 @@ impl ResolvedWorkspaceBackendConfig {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||||
|
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_listen(mut self, listen: SocketAddr) -> Self {
|
pub fn with_listen(mut self, listen: SocketAddr) -> Self {
|
||||||
self.listen = listen;
|
self.listen = listen;
|
||||||
self
|
self
|
||||||
@@ -579,6 +584,24 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_base_url_is_explicit_and_normalized() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let listen = "127.0.0.1:48787".parse().unwrap();
|
||||||
|
let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path())
|
||||||
|
.unwrap()
|
||||||
|
.resolve(dir.path(), identity())
|
||||||
|
.unwrap()
|
||||||
|
.with_listen(listen)
|
||||||
|
.with_backend_base_url("http://127.0.0.1:48787/");
|
||||||
|
|
||||||
|
assert_eq!(resolved.listen, listen);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.server.backend_base_url.as_deref(),
|
||||||
|
Some("http://127.0.0.1:48787")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_fields() {
|
fn rejects_unknown_fields() {
|
||||||
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test")
|
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test")
|
||||||
|
|||||||
@@ -54,8 +54,11 @@ use worker_runtime::interaction::{
|
|||||||
};
|
};
|
||||||
use worker_runtime::management::{RuntimeOptions as EmbeddedRuntimeOptions, RuntimeStatus};
|
use worker_runtime::management::{RuntimeOptions as EmbeddedRuntimeOptions, RuntimeStatus};
|
||||||
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
||||||
|
use worker_runtime::retention::{
|
||||||
|
WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory,
|
||||||
|
};
|
||||||
|
|
||||||
const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
|
pub(crate) const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
|
||||||
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
|
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
|
||||||
const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host";
|
const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host";
|
||||||
const MAX_DIAGNOSTICS: usize = 16;
|
const MAX_DIAGNOSTICS: usize = 16;
|
||||||
@@ -856,6 +859,25 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_retention_inventory(
|
||||||
|
&self,
|
||||||
|
worker_id: &str,
|
||||||
|
) -> Result<WorkerRetentionInventory, String> {
|
||||||
|
Err(format!(
|
||||||
|
"runtime does not implement retention inventory for '{worker_id}'"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_worker_retention(
|
||||||
|
&self,
|
||||||
|
request: WorkerRetentionExecutionRequest,
|
||||||
|
) -> Result<WorkerRetentionExecutionResult, String> {
|
||||||
|
Err(format!(
|
||||||
|
"runtime does not implement retention execution for '{}'",
|
||||||
|
request.worker_id
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
_worker_id: &str,
|
_worker_id: &str,
|
||||||
@@ -1399,6 +1421,44 @@ impl RuntimeRegistry {
|
|||||||
Ok(runtime.delete_worker(worker_id))
|
Ok(runtime.delete_worker(worker_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn worker_retention_inventory(
|
||||||
|
&self,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
) -> Result<WorkerRetentionInventory, RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", &worker.runtime_id)?;
|
||||||
|
validate_backend_identifier("worker_id", &worker.worker_id)?;
|
||||||
|
self.runtime(&worker.runtime_id)?
|
||||||
|
.worker_retention_inventory(&worker.worker_id)
|
||||||
|
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: worker.runtime_id.clone(),
|
||||||
|
code: "worker_retention_inventory_failed".to_string(),
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_worker_retention(
|
||||||
|
&self,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
request: WorkerRetentionExecutionRequest,
|
||||||
|
) -> Result<WorkerRetentionExecutionResult, RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", &worker.runtime_id)?;
|
||||||
|
validate_backend_identifier("worker_id", &worker.worker_id)?;
|
||||||
|
if request.worker_id.to_string() != worker.worker_id {
|
||||||
|
return Err(RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: worker.runtime_id.clone(),
|
||||||
|
code: "worker_id_mismatch".to_string(),
|
||||||
|
message: "retention request worker_id does not match target".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.runtime(&worker.runtime_id)?
|
||||||
|
.execute_worker_retention(request)
|
||||||
|
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: worker.runtime_id.clone(),
|
||||||
|
code: "worker_retention_execution_failed".to_string(),
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn observation_source(
|
pub fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
@@ -1438,6 +1498,7 @@ impl RuntimeRegistry {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct EmbeddedWorkerRuntime {
|
pub struct EmbeddedWorkerRuntime {
|
||||||
|
workspace_id: String,
|
||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
host_id: String,
|
host_id: String,
|
||||||
runtime: worker_runtime::Runtime,
|
runtime: worker_runtime::Runtime,
|
||||||
@@ -1497,9 +1558,13 @@ impl EmbeddedWorkerRuntime {
|
|||||||
|
|
||||||
pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self {
|
pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self {
|
||||||
let workspace_id = workspace_id.as_ref().to_string();
|
let workspace_id = workspace_id.as_ref().to_string();
|
||||||
|
runtime
|
||||||
|
.bind_runtime_identity(EMBEDDED_RUNTIME_ID)
|
||||||
|
.expect("fresh embedded Runtime must accept its Backend-owned identity");
|
||||||
Self {
|
Self {
|
||||||
runtime_id: EMBEDDED_RUNTIME_ID.to_string(),
|
|
||||||
host_id: host_id_for_embedded_workspace(&workspace_id),
|
host_id: host_id_for_embedded_workspace(&workspace_id),
|
||||||
|
workspace_id,
|
||||||
|
runtime_id: EMBEDDED_RUNTIME_ID.to_string(),
|
||||||
runtime,
|
runtime,
|
||||||
execution_enabled: false,
|
execution_enabled: false,
|
||||||
resource_broker: BackendResourceBroker::default(),
|
resource_broker: BackendResourceBroker::default(),
|
||||||
@@ -2103,6 +2168,30 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_retention_inventory(
|
||||||
|
&self,
|
||||||
|
worker_id: &str,
|
||||||
|
) -> Result<WorkerRetentionInventory, String> {
|
||||||
|
let worker_ref = self
|
||||||
|
.worker_ref(worker_id)
|
||||||
|
.ok_or_else(|| format!("invalid embedded Worker id '{worker_id}'"))?;
|
||||||
|
self.runtime
|
||||||
|
.worker_retention_inventory(&self.workspace_id, &worker_ref)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_worker_retention(
|
||||||
|
&self,
|
||||||
|
request: WorkerRetentionExecutionRequest,
|
||||||
|
) -> Result<WorkerRetentionExecutionResult, String> {
|
||||||
|
if request.workspace_id != self.workspace_id {
|
||||||
|
return Err("retention request Workspace does not match embedded Runtime".to_string());
|
||||||
|
}
|
||||||
|
self.runtime
|
||||||
|
.execute_worker_retention(&request)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
@@ -3125,6 +3214,28 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_retention_inventory(
|
||||||
|
&self,
|
||||||
|
worker_id: &str,
|
||||||
|
) -> Result<WorkerRetentionInventory, String> {
|
||||||
|
self.get_json::<WorkerRetentionInventory>(&format!(
|
||||||
|
"/v1/workers/{worker_id}/retention/inventory"
|
||||||
|
))
|
||||||
|
.map_err(|diagnostic| diagnostic.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_worker_retention(
|
||||||
|
&self,
|
||||||
|
request: WorkerRetentionExecutionRequest,
|
||||||
|
) -> Result<WorkerRetentionExecutionResult, String> {
|
||||||
|
let worker_id = request.worker_id.to_string();
|
||||||
|
self.post_json::<_, WorkerRetentionExecutionResult>(
|
||||||
|
&format!("/v1/workers/{worker_id}/retention/execute"),
|
||||||
|
&request,
|
||||||
|
)
|
||||||
|
.map_err(|diagnostic| diagnostic.message)
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
@@ -4036,7 +4147,6 @@ mod tests {
|
|||||||
WorkspaceApiRef {
|
WorkspaceApiRef {
|
||||||
workspace_id: "workspace-test".to_string(),
|
workspace_id: "workspace-test".to_string(),
|
||||||
base_url: "http://127.0.0.1:8787".to_string(),
|
base_url: "http://127.0.0.1:8787".to_string(),
|
||||||
runtime_id: Some("runtime-test".to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ pub mod records;
|
|||||||
pub use records::ticket_api_typescript;
|
pub use records::ticket_api_typescript;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod resource_broker;
|
pub mod resource_broker;
|
||||||
|
pub mod retention;
|
||||||
pub mod runtime_subscription;
|
pub mod runtime_subscription;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
pub mod worker_source;
|
||||||
mod workspace_subscription;
|
mod workspace_subscription;
|
||||||
|
|
||||||
pub use authority::{
|
pub use authority::{
|
||||||
@@ -55,6 +57,8 @@ pub enum Error {
|
|||||||
Sqlite(#[from] rusqlite::Error),
|
Sqlite(#[from] rusqlite::Error),
|
||||||
#[error("ticket error: {0}")]
|
#[error("ticket error: {0}")]
|
||||||
Ticket(#[from] ticket::TicketError),
|
Ticket(#[from] ticket::TicketError),
|
||||||
|
#[error("merge request error: {0}")]
|
||||||
|
MergeRequest(#[from] merge_request::MergeRequestError),
|
||||||
#[error("yaml error: {0}")]
|
#[error("yaml error: {0}")]
|
||||||
Yaml(#[from] serde_yaml::Error),
|
Yaml(#[from] serde_yaml::Error),
|
||||||
#[error("invalid input: {0}")]
|
#[error("invalid input: {0}")]
|
||||||
@@ -88,6 +92,14 @@ pub enum Error {
|
|||||||
},
|
},
|
||||||
#[error("unknown local repository `{0}`")]
|
#[error("unknown local repository `{0}`")]
|
||||||
UnknownRepository(String),
|
UnknownRepository(String),
|
||||||
|
#[error(
|
||||||
|
"merge confirmation requires an authenticated Browser session; API tokens and Worker actors are not accepted"
|
||||||
|
)]
|
||||||
|
BrowserMergeConfirmationRequired,
|
||||||
|
#[error(
|
||||||
|
"Merge Request reopen requires an authenticated Browser session and explicit confirmation"
|
||||||
|
)]
|
||||||
|
BrowserReopenConfirmationRequired,
|
||||||
#[error("workspace id does not match this Workspace backend")]
|
#[error("workspace id does not match this Workspace backend")]
|
||||||
WorkspaceIdMismatch,
|
WorkspaceIdMismatch,
|
||||||
#[error("Ticket assignment conflict: {0}")]
|
#[error("Ticket assignment conflict: {0}")]
|
||||||
|
|||||||
@@ -571,11 +571,15 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
let listener = TcpListener::bind(resolved.listen).await?;
|
let listener = TcpListener::bind(resolved.listen).await?;
|
||||||
|
let local_addr = listener.local_addr()?;
|
||||||
|
if resolved.server.backend_base_url.is_none() {
|
||||||
|
resolved = resolved.with_backend_base_url(format!("http://{local_addr}"));
|
||||||
|
}
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"yoi-server: serving workspace `{}` from server DB `{}` on http://{}",
|
"yoi-server: serving workspace `{}` from server DB `{}` on http://{}",
|
||||||
workspace.workspace_id,
|
workspace.workspace_id,
|
||||||
database_path.display(),
|
database_path.display(),
|
||||||
listener.local_addr()?
|
local_addr
|
||||||
);
|
);
|
||||||
serve(resolved.server, store, listener).await?;
|
serve(resolved.server, store, listener).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+2383
-189
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
|||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use worker_runtime::auth::{
|
||||||
|
WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims,
|
||||||
|
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims,
|
||||||
|
verify_worker_mutation_source_proof,
|
||||||
|
};
|
||||||
|
use worker_runtime::worker_source::InProcessWorkerMutationProof;
|
||||||
|
|
||||||
|
use crate::hosts::RemoteRuntimeConfig;
|
||||||
|
use crate::server::WorkspaceApi;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum PresentedWorkerMutationSourceProof<'a> {
|
||||||
|
Remote(&'a str),
|
||||||
|
InProcess(InProcessWorkerMutationProof),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn presented_worker_remove_source<'a>(
|
||||||
|
headers: &'a HeaderMap,
|
||||||
|
in_process: Option<InProcessWorkerMutationProof>,
|
||||||
|
) -> Result<PresentedWorkerMutationSourceProof<'a>, WorkerMutationSourceProofError> {
|
||||||
|
if let Some(claims) = in_process {
|
||||||
|
return Ok(PresentedWorkerMutationSourceProof::InProcess(claims));
|
||||||
|
}
|
||||||
|
headers
|
||||||
|
.get(worker_runtime::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(PresentedWorkerMutationSourceProof::Remote)
|
||||||
|
.ok_or(WorkerMutationSourceProofError::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct VerifiedWorkerMutationSource {
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub actor_kind: WorkerMutationActorKind,
|
||||||
|
pub permission: String,
|
||||||
|
pub jti: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum WorkerMutationSourceProofError {
|
||||||
|
#[error("Worker mutation source proof is required")]
|
||||||
|
Missing,
|
||||||
|
#[error("Worker mutation source proof is invalid")]
|
||||||
|
Invalid,
|
||||||
|
#[error("Worker mutation source proof is not authorized for this Server")]
|
||||||
|
WrongAudience,
|
||||||
|
#[error("Worker mutation source proof is not authorized for this Workspace")]
|
||||||
|
WrongWorkspace,
|
||||||
|
#[error("Worker mutation source proof actor is not allowed")]
|
||||||
|
WrongActor,
|
||||||
|
#[error("Worker mutation source proof lacks `{0}` permission")]
|
||||||
|
MissingPermission(String),
|
||||||
|
#[error("Worker mutation source proof is expired")]
|
||||||
|
Expired,
|
||||||
|
#[error("Runtime trust is missing or revoked")]
|
||||||
|
RevokedRuntimeTrust,
|
||||||
|
#[error("Worker mutation source proof was already consumed")]
|
||||||
|
Replay,
|
||||||
|
#[error("source Worker is not a current member of this Workspace Runtime catalog")]
|
||||||
|
WorkerCatalogMembership,
|
||||||
|
#[error("source proof authority failed: {0}")]
|
||||||
|
Authority(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_worker_remove_source(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
proof: PresentedWorkerMutationSourceProof<'_>,
|
||||||
|
target_runtime_id: &str,
|
||||||
|
target_worker_id: &str,
|
||||||
|
) -> Result<VerifiedWorkerMutationSource, WorkerMutationSourceProofError> {
|
||||||
|
verify_worker_remove_source_with(
|
||||||
|
&api.config,
|
||||||
|
&api.store,
|
||||||
|
proof,
|
||||||
|
target_runtime_id,
|
||||||
|
target_worker_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_worker_remove_source_with(
|
||||||
|
config: &crate::server::ServerConfig,
|
||||||
|
store: &std::sync::Arc<dyn crate::store::ControlPlaneStore>,
|
||||||
|
proof: PresentedWorkerMutationSourceProof<'_>,
|
||||||
|
target_runtime_id: &str,
|
||||||
|
target_worker_id: &str,
|
||||||
|
) -> Result<VerifiedWorkerMutationSource, WorkerMutationSourceProofError> {
|
||||||
|
let required_permission = worker_runtime::auth::WORKER_REMOVE_PERMISSION;
|
||||||
|
let now = unix_now_seconds();
|
||||||
|
let claims = match proof {
|
||||||
|
PresentedWorkerMutationSourceProof::Remote(token) => {
|
||||||
|
let unverified = decode_worker_mutation_source_claims(token)
|
||||||
|
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
|
||||||
|
let audience = remote_audience(config, &unverified.iss)?;
|
||||||
|
let trusted = store
|
||||||
|
.get_trusted_runtime(&unverified.iss)
|
||||||
|
.await
|
||||||
|
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
|
||||||
|
.filter(|record| record.revoked_at.is_none())
|
||||||
|
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
|
||||||
|
let expected = WorkerMutationSourceExpectation {
|
||||||
|
runtime_id: &unverified.iss,
|
||||||
|
audience,
|
||||||
|
workspace_id: &config.workspace_id,
|
||||||
|
worker_id: None,
|
||||||
|
actor_kind: WorkerMutationActorKind::Worker,
|
||||||
|
operation: WorkerMutationOperation::WorkerRemove,
|
||||||
|
target_runtime_id,
|
||||||
|
target_worker_id,
|
||||||
|
permission: required_permission,
|
||||||
|
};
|
||||||
|
verify_worker_mutation_source_proof(&trusted.public_key, token, &expected, now)
|
||||||
|
.map_err(map_auth_error)?
|
||||||
|
}
|
||||||
|
PresentedWorkerMutationSourceProof::InProcess(proof) => {
|
||||||
|
let claims = proof.into_claims();
|
||||||
|
if config
|
||||||
|
.remote_runtime_sources
|
||||||
|
.iter()
|
||||||
|
.any(|runtime| runtime.runtime_id == claims.iss)
|
||||||
|
{
|
||||||
|
return Err(WorkerMutationSourceProofError::Invalid);
|
||||||
|
}
|
||||||
|
validate_in_process_claims(
|
||||||
|
&claims,
|
||||||
|
&format!("embedded:{}", config.workspace_id),
|
||||||
|
&config.workspace_id,
|
||||||
|
target_runtime_id,
|
||||||
|
target_worker_id,
|
||||||
|
required_permission,
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
|
claims
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let worker = worker_runtime::identity::RuntimeWorkerRef {
|
||||||
|
runtime_id: claims.iss.clone(),
|
||||||
|
worker_id: claims.worker_id.clone(),
|
||||||
|
};
|
||||||
|
let member = store
|
||||||
|
.get_worker_registry(&config.workspace_id, &worker)
|
||||||
|
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
|
||||||
|
if member.is_none() {
|
||||||
|
return Err(WorkerMutationSourceProofError::WorkerCatalogMembership);
|
||||||
|
}
|
||||||
|
|
||||||
|
let consumed_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
let consumed = store
|
||||||
|
.consume_worker_mutation_source_jti(&claims.iss, &claims.jti, claims.exp, now, &consumed_at)
|
||||||
|
.await
|
||||||
|
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
|
||||||
|
if !consumed {
|
||||||
|
return Err(WorkerMutationSourceProofError::Replay);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(VerifiedWorkerMutationSource {
|
||||||
|
runtime_id: claims.iss,
|
||||||
|
worker_id: claims.worker_id,
|
||||||
|
actor_kind: claims.actor_kind,
|
||||||
|
permission: claims.permission,
|
||||||
|
jti: claims.jti,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) trait VerifiedWorkerRemoveExecutor: Send + Sync {
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
source: VerifiedWorkerMutationSource,
|
||||||
|
target_runtime_id: &str,
|
||||||
|
target_worker_id: &str,
|
||||||
|
expected_worker_revision: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<worker::WorkspaceResponse, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct EmbeddedServerWorkerMutationDispatcher {
|
||||||
|
config: crate::server::ServerConfig,
|
||||||
|
store: Arc<dyn crate::store::ControlPlaneStore>,
|
||||||
|
executor: Arc<OnceLock<Arc<dyn VerifiedWorkerRemoveExecutor>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmbeddedServerWorkerMutationDispatcher {
|
||||||
|
pub(crate) fn new(
|
||||||
|
config: crate::server::ServerConfig,
|
||||||
|
store: Arc<dyn crate::store::ControlPlaneStore>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
executor: Arc::new(OnceLock::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install_executor(
|
||||||
|
&self,
|
||||||
|
executor: Arc<dyn VerifiedWorkerRemoveExecutor>,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
self.executor
|
||||||
|
.set(executor)
|
||||||
|
.map_err(|_| "WorkerRemove executor is already installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
|
||||||
|
for EmbeddedServerWorkerMutationDispatcher
|
||||||
|
{
|
||||||
|
fn execute_worker_remove(
|
||||||
|
&self,
|
||||||
|
proof: InProcessWorkerMutationProof,
|
||||||
|
target_runtime_id: &str,
|
||||||
|
target_worker_id: &str,
|
||||||
|
expected_worker_revision: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<
|
||||||
|
worker::WorkspaceResponse,
|
||||||
|
worker_runtime::worker_source::RuntimeWorkerMutationForwardError,
|
||||||
|
> {
|
||||||
|
let source = futures::executor::block_on(verify_worker_remove_source_with(
|
||||||
|
&self.config,
|
||||||
|
&self.store,
|
||||||
|
PresentedWorkerMutationSourceProof::InProcess(proof),
|
||||||
|
target_runtime_id,
|
||||||
|
target_worker_id,
|
||||||
|
))
|
||||||
|
.map_err(|error| {
|
||||||
|
worker_runtime::worker_source::RuntimeWorkerMutationForwardError::Embedded(
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let executor = self.executor.get().ok_or_else(|| {
|
||||||
|
worker_runtime::worker_source::RuntimeWorkerMutationForwardError::Embedded(
|
||||||
|
"WorkerRemove executor is unavailable".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
executor
|
||||||
|
.execute(
|
||||||
|
source,
|
||||||
|
target_runtime_id,
|
||||||
|
target_worker_id,
|
||||||
|
expected_worker_revision,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
.map_err(worker_runtime::worker_source::RuntimeWorkerMutationForwardError::Embedded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_audience<'a>(
|
||||||
|
config: &'a crate::server::ServerConfig,
|
||||||
|
runtime_id: &str,
|
||||||
|
) -> Result<&'a str, WorkerMutationSourceProofError> {
|
||||||
|
config
|
||||||
|
.remote_runtime_sources
|
||||||
|
.iter()
|
||||||
|
.find(|runtime| runtime.runtime_id == runtime_id)
|
||||||
|
.and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref())
|
||||||
|
.map(|auth| auth.server_id.as_str())
|
||||||
|
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_in_process_claims(
|
||||||
|
claims: &WorkerMutationSourceClaims,
|
||||||
|
audience: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
target_runtime_id: &str,
|
||||||
|
target_worker_id: &str,
|
||||||
|
permission: &str,
|
||||||
|
now: u64,
|
||||||
|
) -> Result<(), WorkerMutationSourceProofError> {
|
||||||
|
if claims.aud != audience {
|
||||||
|
return Err(WorkerMutationSourceProofError::WrongAudience);
|
||||||
|
}
|
||||||
|
if claims.workspace_id != workspace_id {
|
||||||
|
return Err(WorkerMutationSourceProofError::WrongWorkspace);
|
||||||
|
}
|
||||||
|
if claims.actor_kind != WorkerMutationActorKind::Worker {
|
||||||
|
return Err(WorkerMutationSourceProofError::WrongActor);
|
||||||
|
}
|
||||||
|
if claims.operation != WorkerMutationOperation::WorkerRemove
|
||||||
|
|| claims.target_runtime_id != target_runtime_id
|
||||||
|
|| claims.target_worker_id != target_worker_id
|
||||||
|
{
|
||||||
|
return Err(WorkerMutationSourceProofError::Invalid);
|
||||||
|
}
|
||||||
|
if claims.permission != permission {
|
||||||
|
return Err(WorkerMutationSourceProofError::MissingPermission(
|
||||||
|
permission.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if claims.exp <= now || claims.iat > now.saturating_add(60) || claims.jti.trim().is_empty() {
|
||||||
|
return Err(WorkerMutationSourceProofError::Expired);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_auth_error(error: worker_runtime::auth::RuntimeAuthError) -> WorkerMutationSourceProofError {
|
||||||
|
use worker_runtime::auth::RuntimeAuthError;
|
||||||
|
match error {
|
||||||
|
RuntimeAuthError::WrongAudience { .. } => WorkerMutationSourceProofError::WrongAudience,
|
||||||
|
RuntimeAuthError::WrongWorkspace { .. } => WorkerMutationSourceProofError::WrongWorkspace,
|
||||||
|
RuntimeAuthError::WrongActorKind => WorkerMutationSourceProofError::WrongActor,
|
||||||
|
RuntimeAuthError::WrongOperation | RuntimeAuthError::WrongMutationTarget => {
|
||||||
|
WorkerMutationSourceProofError::Invalid
|
||||||
|
}
|
||||||
|
RuntimeAuthError::MissingPermission(permission) => {
|
||||||
|
WorkerMutationSourceProofError::MissingPermission(permission)
|
||||||
|
}
|
||||||
|
RuntimeAuthError::Expired => WorkerMutationSourceProofError::Expired,
|
||||||
|
_ => WorkerMutationSourceProofError::Invalid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now_seconds() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
+15
-127
@@ -12,8 +12,8 @@ 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, TicketReview,
|
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketSummary,
|
||||||
TicketReviewResult, TicketSummary, TicketWorkflowState,
|
TicketWorkflowState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||||
@@ -35,7 +35,6 @@ pub enum TicketCommand {
|
|||||||
List(ListOptions),
|
List(ListOptions),
|
||||||
Show { query: String },
|
Show { query: String },
|
||||||
Comment(CommentOptions),
|
Comment(CommentOptions),
|
||||||
Review(ReviewOptions),
|
|
||||||
State(StateOptions),
|
State(StateOptions),
|
||||||
Close(CloseOptions),
|
Close(CloseOptions),
|
||||||
Relation(RelationOptions),
|
Relation(RelationOptions),
|
||||||
@@ -67,13 +66,6 @@ pub struct CommentOptions {
|
|||||||
pub body: BodySource,
|
pub body: BodySource,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ReviewOptions {
|
|
||||||
pub query: String,
|
|
||||||
pub result: TicketReviewResult,
|
|
||||||
pub body: BodySource,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum StateTarget {
|
pub enum StateTarget {
|
||||||
Planning,
|
Planning,
|
||||||
@@ -194,7 +186,6 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
|
|||||||
query: parse_one_positional("show", &args[1..])?,
|
query: parse_one_positional("show", &args[1..])?,
|
||||||
},
|
},
|
||||||
"comment" => TicketCommand::Comment(parse_comment(&args[1..])?),
|
"comment" => TicketCommand::Comment(parse_comment(&args[1..])?),
|
||||||
"review" => TicketCommand::Review(parse_review(&args[1..])?),
|
|
||||||
"state" => TicketCommand::State(parse_state(&args[1..])?),
|
"state" => TicketCommand::State(parse_state(&args[1..])?),
|
||||||
"close" => TicketCommand::Close(parse_close(&args[1..])?),
|
"close" => TicketCommand::Close(parse_close(&args[1..])?),
|
||||||
"relation" => TicketCommand::Relation(parse_relation(&args[1..])?),
|
"relation" => TicketCommand::Relation(parse_relation(&args[1..])?),
|
||||||
@@ -249,7 +240,6 @@ fn run_command(
|
|||||||
TicketCommand::List(options) => list(backend.as_ref(), options),
|
TicketCommand::List(options) => list(backend.as_ref(), options),
|
||||||
TicketCommand::Show { query } => show(backend.as_ref(), query),
|
TicketCommand::Show { query } => show(backend.as_ref(), query),
|
||||||
TicketCommand::Comment(options) => comment(backend.as_ref(), options),
|
TicketCommand::Comment(options) => comment(backend.as_ref(), options),
|
||||||
TicketCommand::Review(options) => review(backend.as_ref(), options),
|
|
||||||
TicketCommand::State(options) => state(backend.as_ref(), options),
|
TicketCommand::State(options) => state(backend.as_ref(), options),
|
||||||
TicketCommand::Close(options) => close(backend.as_ref(), options),
|
TicketCommand::Close(options) => close(backend.as_ref(), options),
|
||||||
TicketCommand::Relation(options) => relation(backend.as_ref(), options),
|
TicketCommand::Relation(options) => relation(backend.as_ref(), options),
|
||||||
@@ -370,7 +360,7 @@ fn backend_for_workspace(workspace: &Path) -> Result<Box<dyn TicketBackend>, Tic
|
|||||||
let workspace_id = workspace_id_for_workspace(workspace)?;
|
let workspace_id = workspace_id_for_workspace(workspace)?;
|
||||||
let db_path = server_database_path(workspace)?;
|
let db_path = server_database_path(workspace)?;
|
||||||
Ok(Box::new(
|
Ok(Box::new(
|
||||||
SqliteTicketBackend::new(db_path, workspace_id)
|
SqliteTicketBackend::open(db_path, workspace_id)?
|
||||||
.with_record_language(config.ticket_record_language()),
|
.with_record_language(config.ticket_record_language()),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -381,7 +371,7 @@ fn import_local(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
|||||||
.with_record_language(config.ticket_record_language());
|
.with_record_language(config.ticket_record_language());
|
||||||
let workspace_id = workspace_id_for_workspace(workspace)?;
|
let workspace_id = workspace_id_for_workspace(workspace)?;
|
||||||
let db_path = server_database_path(workspace)?;
|
let db_path = server_database_path(workspace)?;
|
||||||
let sqlite = SqliteTicketBackend::new(db_path.clone(), workspace_id)
|
let sqlite = SqliteTicketBackend::open(db_path.clone(), workspace_id)?
|
||||||
.with_record_language(config.ticket_record_language());
|
.with_record_language(config.ticket_record_language());
|
||||||
sqlite.import_from_local_backend(&local)?;
|
sqlite.import_from_local_backend(&local)?;
|
||||||
Ok(success(format!(
|
Ok(success(format!(
|
||||||
@@ -633,23 +623,6 @@ fn comment(
|
|||||||
Ok(success(format!("appended\t{}\t{}\n", options.query, role)))
|
Ok(success(format!("appended\t{}\t{}\n", options.query, role)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn review(
|
|
||||||
backend: &dyn TicketBackend,
|
|
||||||
options: ReviewOptions,
|
|
||||||
) -> Result<TicketCliOutput, TicketCliError> {
|
|
||||||
let result = options.result.as_str().to_string();
|
|
||||||
let review = TicketReview {
|
|
||||||
result: options.result,
|
|
||||||
author: Some(default_author()),
|
|
||||||
body: MarkdownText::new(read_body_source(&options.body)?),
|
|
||||||
};
|
|
||||||
backend.review(TicketIdOrSlug::Query(options.query.clone()), review)?;
|
|
||||||
Ok(success(format!(
|
|
||||||
"reviewed\t{}\t{}\n",
|
|
||||||
options.query, result
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state(
|
fn state(
|
||||||
backend: &dyn TicketBackend,
|
backend: &dyn TicketBackend,
|
||||||
options: StateOptions,
|
options: StateOptions,
|
||||||
@@ -660,7 +633,11 @@ fn state(
|
|||||||
StateTarget::Ready => TicketWorkflowState::Ready,
|
StateTarget::Ready => TicketWorkflowState::Ready,
|
||||||
StateTarget::Queued => TicketWorkflowState::Queued,
|
StateTarget::Queued => TicketWorkflowState::Queued,
|
||||||
StateTarget::InProgress => TicketWorkflowState::InProgress,
|
StateTarget::InProgress => TicketWorkflowState::InProgress,
|
||||||
StateTarget::Done => TicketWorkflowState::Done,
|
StateTarget::Done => {
|
||||||
|
return Err(TicketCliError::new(
|
||||||
|
"done is guarded by MergeRequestComplete with an approved immutable revision and operation_id",
|
||||||
|
));
|
||||||
|
}
|
||||||
StateTarget::Closed => {
|
StateTarget::Closed => {
|
||||||
return Err(TicketCliError::new(
|
return Err(TicketCliError::new(
|
||||||
"yoi ticket state <ticket> closed cannot write resolution.md; use `yoi ticket close <ticket> --resolution <text>` instead",
|
"yoi ticket state <ticket> closed cannot write resolution.md; use `yoi ticket close <ticket> --resolution <text>` instead",
|
||||||
@@ -965,64 +942,6 @@ fn parse_comment(args: &[String]) -> Result<CommentOptions, TicketCliError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_review(args: &[String]) -> Result<ReviewOptions, TicketCliError> {
|
|
||||||
if args.is_empty() || args[0].starts_with('-') {
|
|
||||||
return Err(TicketCliError::new("review requires <id>"));
|
|
||||||
}
|
|
||||||
let query = args[0].clone();
|
|
||||||
let mut approve = false;
|
|
||||||
let mut request_changes = false;
|
|
||||||
let mut file = None;
|
|
||||||
let mut message = None;
|
|
||||||
let mut i = 1;
|
|
||||||
while i < args.len() {
|
|
||||||
match args[i].as_str() {
|
|
||||||
"--approve" => {
|
|
||||||
approve = true;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
"--request-changes" => {
|
|
||||||
request_changes = true;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
_ => match option_with_value(args, &mut i)? {
|
|
||||||
Some(("--file", value)) => file = Some(PathBuf::from(value)),
|
|
||||||
Some(("--message", value)) => message = Some(value),
|
|
||||||
Some((name, _)) => {
|
|
||||||
return Err(TicketCliError::new(format!(
|
|
||||||
"unknown review argument: {name}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
return Err(TicketCliError::new(format!(
|
|
||||||
"unknown review argument: {}",
|
|
||||||
args[i]
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let result = match (approve, request_changes) {
|
|
||||||
(true, false) => TicketReviewResult::Approve,
|
|
||||||
(false, true) => TicketReviewResult::RequestChanges,
|
|
||||||
(false, false) => {
|
|
||||||
return Err(TicketCliError::new(
|
|
||||||
"review requires exactly one of --approve or --request-changes",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
(true, true) => {
|
|
||||||
return Err(TicketCliError::new(
|
|
||||||
"review accepts exactly one of --approve or --request-changes",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(ReviewOptions {
|
|
||||||
query,
|
|
||||||
result,
|
|
||||||
body: exactly_one_body("review", file, message)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_state(args: &[String]) -> Result<StateOptions, TicketCliError> {
|
fn parse_state(args: &[String]) -> Result<StateOptions, TicketCliError> {
|
||||||
if args.len() != 2 {
|
if args.len() != 2 {
|
||||||
return Err(TicketCliError::new(
|
return Err(TicketCliError::new(
|
||||||
@@ -1244,7 +1163,7 @@ fn default_author() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn help_text() -> &'static str {
|
fn help_text() -> &'static str {
|
||||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
|
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1375,7 +1294,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_cli_create_list_show_comment_review_state_close_and_doctor() {
|
fn ticket_cli_create_list_show_comment_state_close_and_doctor() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
let created = run(&temp, &["create", "--title", "CLI Created"]);
|
let created = run(&temp, &["create", "--title", "CLI Created"]);
|
||||||
@@ -1416,22 +1335,6 @@ mod tests {
|
|||||||
.contains(&format!("appended\t{}\timplementation_report", ticket_id))
|
.contains(&format!("appended\t{}\timplementation_report", ticket_id))
|
||||||
);
|
);
|
||||||
|
|
||||||
let reviewed = run(
|
|
||||||
&temp,
|
|
||||||
&[
|
|
||||||
"review",
|
|
||||||
&ticket_id,
|
|
||||||
"--approve",
|
|
||||||
"--message",
|
|
||||||
"Looks good.",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
reviewed
|
|
||||||
.stdout
|
|
||||||
.contains(&format!("reviewed\t{}\tapprove", ticket_id))
|
|
||||||
);
|
|
||||||
|
|
||||||
let ready = run(&temp, &["state", &ticket_id, "ready"]);
|
let ready = run(&temp, &["state", &ticket_id, "ready"]);
|
||||||
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id));
|
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id));
|
||||||
let ready_listed = run(&temp, &["list", "--state", "ready"]);
|
let ready_listed = run(&temp, &["list", "--state", "ready"]);
|
||||||
@@ -1450,10 +1353,10 @@ mod tests {
|
|||||||
let inprogress_listed = run(&temp, &["list", "--state", "inprogress"]);
|
let inprogress_listed = run(&temp, &["list", "--state", "inprogress"]);
|
||||||
assert!(inprogress_listed.stdout.contains(&ticket_id));
|
assert!(inprogress_listed.stdout.contains(&ticket_id));
|
||||||
|
|
||||||
let done = run(&temp, &["state", &ticket_id, "done"]);
|
let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"]))
|
||||||
assert_eq!(done.stdout, format!("state\t{}\tdone\n", ticket_id));
|
.and_then(|cli| run_in_workspace(cli, temp.path()))
|
||||||
let done_listed = run(&temp, &["list", "--state", "done"]);
|
.unwrap_err();
|
||||||
assert!(done_listed.stdout.contains(&ticket_id));
|
assert!(done_error.to_string().contains("MergeRequestComplete"));
|
||||||
|
|
||||||
let closed = run(
|
let closed = run(
|
||||||
&temp,
|
&temp,
|
||||||
@@ -1469,7 +1372,6 @@ mod tests {
|
|||||||
assert!(final_show.stdout.contains("State: closed"));
|
assert!(final_show.stdout.contains("State: closed"));
|
||||||
assert!(final_show.stdout.contains("Done via yoi ticket."));
|
assert!(final_show.stdout.contains("Done via yoi ticket."));
|
||||||
assert!(final_show.stdout.contains("implementation_report"));
|
assert!(final_show.stdout.contains("implementation_report"));
|
||||||
assert!(final_show.stdout.contains("review"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1594,20 +1496,6 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("exactly one"));
|
assert!(err.to_string().contains("exactly one"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ticket_cli_rejects_ambiguous_review_result() {
|
|
||||||
let err = parse_ticket_args(&args(&[
|
|
||||||
"review",
|
|
||||||
"ticket",
|
|
||||||
"--approve",
|
|
||||||
"--request-changes",
|
|
||||||
"--message",
|
|
||||||
"body",
|
|
||||||
]))
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(err.to_string().contains("exactly one"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_cli_state_closed_requires_close_command() {
|
fn ticket_cli_state_closed_requires_close_command() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -1,45 +1,85 @@
|
|||||||
# Worker, session, and state authority
|
# Worker aggregate, Session, and run authority
|
||||||
|
|
||||||
Yoi separates replayable history from current Worker identity because they answer different questions.
|
A Runtime-managed Worker is the canonical durable aggregate. One Worker owns exactly one Session for its lifetime; compaction and forks create Segments inside that Session rather than replacing the Session identity.
|
||||||
|
|
||||||
A session log answers: "what happened and what can be replayed?" Worker metadata answers: "what does this Worker name currently refer to?" Live sockets and registries answer only: "what seems reachable right now?"
|
This identity rule separates durable conversation history from execution attempts:
|
||||||
|
|
||||||
## Session logs
|
- **Worker ID** identifies the Runtime catalog aggregate and remains stable across stop/restore.
|
||||||
|
- **Session ID** identifies that Worker's sole replayable history and remains stable across compaction.
|
||||||
|
- **Segment ID** identifies a branch or compacted history projection inside the Session.
|
||||||
|
- **run generation** identifies one process/controller execution attempt and increases before every spawn or restore.
|
||||||
|
|
||||||
Session JSONL is the durable replay record. It contains committed user inputs, assistant items, tool results, system/runtime events that must explain later behavior, segment boundaries, and persisted effective snapshots needed to understand a run.
|
## Canonical Runtime layout
|
||||||
|
|
||||||
The session log should be append-oriented and schema drift should be compile-visible. Compatibility shims that silently reinterpret old plural/current entries make future readers less safe.
|
Filesystem Runtime stores materialize one aggregate under `workers/<worker_id>/`:
|
||||||
|
|
||||||
Session logs do not own current Worker-name state. A historical session can be replayable without being the active session for a Worker name.
|
```text
|
||||||
|
workers/<worker_id>/
|
||||||
|
worker.json
|
||||||
|
metadata.json
|
||||||
|
session/
|
||||||
|
session.json
|
||||||
|
segments/
|
||||||
|
<segment_id>.jsonl
|
||||||
|
<segment_id>.trace.jsonl
|
||||||
|
runs/
|
||||||
|
<generation>/
|
||||||
|
worker.sock
|
||||||
|
worker.out.log
|
||||||
|
worker.err.log
|
||||||
|
artifacts/
|
||||||
|
spawned/
|
||||||
|
```
|
||||||
|
|
||||||
## Worker metadata
|
`worker.json` is Runtime catalog authority: Workspace attribution, create/restore request, execution binding, and the last durably reserved run generation. `metadata.json` is the current Worker projection used to restore active/pending Segment pointers, resolved manifest state, delegation metadata, and child/peer visibility. It is not a second transcript.
|
||||||
|
|
||||||
Worker metadata is the current-state layer keyed by Worker name. It records active/pending session pointers, resolved manifest snapshots, current delegation metadata, spawned-child visibility, and restoration information.
|
`session/session.json` fixes the single Session ID for the aggregate. The Worker-specific Session store rejects attempts to address another Session ID. Session JSONL is the append-oriented replay authority for committed user inputs, assistant items, tool results, system/runtime events, Segment lineage, and effective snapshots required to explain later behavior.
|
||||||
|
|
||||||
This avoids reconstructing current Worker state by scanning every session log. It also gives `--worker <name>`, TUI resume, `ListWorkers`, and `RestoreWorker` a single current authority.
|
The normal execution path resolves all three stores from the trusted `WorkerRef`. It does not fall back to process-global Session or Worker-metadata roots. Those roots are legacy migration inputs only.
|
||||||
|
|
||||||
Worker metadata should stay thin. It is not a second transcript, and it should not duplicate model conversation content.
|
## Segment lifecycle
|
||||||
|
|
||||||
## Live runtime hints
|
A new Worker materializes its Session when the initial Segment is created. The Session ID then remains stable.
|
||||||
|
|
||||||
Sockets, process registries, and runtime files are liveness hints. They are useful for attach, status probing, and fast discovery, but they are not final proof that work completed or that a Worker's state changed durably.
|
Compaction writes a new Segment in the same Session with `compacted_from` lineage. Forking likewise writes a sibling Segment in the same Session with `forked_from` lineage. Allocation, UI, and event surfaces therefore report the new **Segment ID**, never a "new Session". A different Session requires a different Worker aggregate.
|
||||||
|
|
||||||
A reachable pending Worker should be visible even if durable logs have not materialized yet. Missing restore labels should degrade labels and diagnostics, not hide a live attachable Worker.
|
This keeps existing Worker IDs, Session IDs, Segment references, observation entry references, and UI routes meaningful across compaction and restore.
|
||||||
|
|
||||||
## Spawned children and delegation
|
## Run lifecycle
|
||||||
|
|
||||||
Parent-visible children are sourced from Worker metadata, not from a transient runtime mirror. Restoring a parent should reconstruct reachable children where possible and keep stopped-but-restorable children visible when metadata supports it.
|
Run generations are monotonic per Worker. Runtime durably reserves and persists the next generation before invoking the execution backend:
|
||||||
|
|
||||||
Delegated write scope is a capability loan. Stopping, shutting down, or pruning a child must reclaim the parent's effective write permissions while preserving explicit base denies.
|
- initial spawn reserves generation `1`;
|
||||||
|
- explicit restore reserves the next generation;
|
||||||
|
- startup restoration after a process crash reserves the next generation before reconnecting providers or observation state.
|
||||||
|
|
||||||
## Peer Workers
|
A generation directory is created with `create_new` semantics. An existing directory is a collision and is never reused as a new execution. This makes a crash between reservation and controller startup recoverable: startup consumes another generation instead of treating stale socket/log state as live authority.
|
||||||
|
|
||||||
Peer visibility is also Worker metadata, but it is distinct from spawned-child delegation. A TUI user can run `:peer <worker-name>` while attached to an idle Worker to register reciprocal peer metadata with another existing Worker. This is a metadata-level registration, not live target-controller consent.
|
Stopping a Worker waits for controller shutdown completion and removes `worker.sock`. The generation directory and diagnostic files remain evidence. How old generations are retained, archived, or purged is a separate policy; aggregate creation and migration do not invent that disposition.
|
||||||
|
|
||||||
A peer relationship only makes the Workers mutually visible through `ListWorkers` with visibility source `peer`. It does not grant filesystem scope, create a child output cursor, make either Worker the other's parent, or imply child completion notifications. Peer messages use `SendToPeerWorker`, which delivers a labeled notification into the target Worker's normal durable notification/history path. `SendToPeerWorker` requires the peer to be live and fails clearly for non-live peers rather than auto-restoring them.
|
Live sockets and provider sessions are execution hints, not durable identity. Restore reconstructs Workspace client attribution, observation registration, and Workdir/provider bindings from Runtime/Backend authority while keeping the Worker and Session identities unchanged.
|
||||||
|
|
||||||
## Notifications are not authority
|
## Legacy migration
|
||||||
|
|
||||||
Worker completion notifications are UX hints. Before treating delegated work as complete, inspect queryable evidence: child output, session/log state, worktree status, diffs, and validation output.
|
Startup migration recognizes the versioned `worker-aggregate-v1` format and treats legacy process-global metadata/Session directories as read-only sources.
|
||||||
|
|
||||||
This is why orchestration code should expose state-aware operations such as `ListWorkers` and `RestoreWorker`, rather than letting a background alert decide workflow state by itself.
|
The migration is serialized by an OS file lock and writes a versioned manifest with per-Worker checkpoints and diagnostics. For each unambiguous catalog Worker it:
|
||||||
|
|
||||||
|
1. validates Worker metadata identity and the referenced Session;
|
||||||
|
2. parses Segment and trace JSONL, tolerating only the existing crash-truncated final-line rule;
|
||||||
|
3. stages `session.json` plus all Segment files under the target Worker aggregate;
|
||||||
|
4. fsyncs files and directories, atomically renames the staged Session directory, and fsyncs its parent;
|
||||||
|
5. atomically writes and fsyncs `metadata.json`;
|
||||||
|
6. atomically updates the migration checkpoint.
|
||||||
|
|
||||||
|
Reruns validate exact Session identity, the complete source/target filename set, and file bytes before accepting an existing target. Mixed old/new stores and a crash between Session rename, metadata copy, and checkpoint update therefore converge without replacing divergent data.
|
||||||
|
|
||||||
|
Migration fails closed on target collisions, corrupt complete JSONL records, or ambiguous ownership. If multiple legacy metadata sources reference one Session—including metadata with no catalog Worker—the Session is not assigned to either aggregate. The manifest records the shared reference. Legacy metadata or Session directories with no catalog-backed owner are also recorded as orphans and left untouched; they do not become normal authority and are not silently deleted.
|
||||||
|
|
||||||
|
Legacy sources remain available for audit and recovery after a successful copy. Archive/retention disposition and Orchestrator-driven Worker removal are intentionally outside this migration contract.
|
||||||
|
|
||||||
|
## Child, peer, and notification state
|
||||||
|
|
||||||
|
Parent-visible children and peer registrations are current Worker metadata, distinct from Session history and run liveness. Restoring a parent reconstructs reachable children where possible and retains stopped-but-restorable visibility when metadata supports it. Delegated write scope is a capability loan; stopping or pruning a child must reclaim the parent's effective permissions.
|
||||||
|
|
||||||
|
Peer registration does not grant filesystem authority, imply parent ownership, or make notifications completion proof. Notifications remain UX hints committed through the normal Worker history path. Completion decisions must reread durable Ticket, repository, review, and test evidence rather than relying on a socket event or final assistant message.
|
||||||
|
|||||||
@@ -2,6 +2,38 @@
|
|||||||
|
|
||||||
This repository is developed with Yoi itself. Dogfooding is valuable because it exposes orchestration, memory, TUI, and workflow problems under real use.
|
This repository is developed with Yoi itself. Dogfooding is valuable because it exposes orchestration, memory, TUI, and workflow problems under real use.
|
||||||
|
|
||||||
|
## Pre-restart gate
|
||||||
|
|
||||||
|
Never use the live dogfood Server or Runtime as the first startup test for a new
|
||||||
|
binary. A dogfood restart is allowed only after this sequence succeeds:
|
||||||
|
|
||||||
|
1. Build the production entrypoints:
|
||||||
|
`cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
|
||||||
|
2. Run the focused and dependent tests for the changed contracts, followed by
|
||||||
|
`cargo fmt --all -- --check` and `git diff --check HEAD`.
|
||||||
|
3. Run `scripts/isolated-startup-smoke.sh` from an external shell/process.
|
||||||
|
4. Inspect any failed run's retained `/tmp/yoi-isolated-startup-smoke.*` logs;
|
||||||
|
do not restart dogfood until the cause is fixed and the smoke passes.
|
||||||
|
5. Have an external supervisor or operator restart Server and Runtime. A Worker
|
||||||
|
hosted by the target Runtime must never terminate its own Runtime.
|
||||||
|
6. Verify post-restart readiness through the Workspace Runtime projection and a
|
||||||
|
real restored Worker operation before treating the environment as healthy.
|
||||||
|
|
||||||
|
The smoke harness runs the normal `yoi-server` and `yoi-runtime` binaries using
|
||||||
|
separate `HOME`, `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, temporary Git repository,
|
||||||
|
Server database, Runtime fs store, identity/trust material, and non-dogfood
|
||||||
|
ports. It fails if either port is already occupied, if state escapes the
|
||||||
|
temporary root, if a process exits unexpectedly, if Runtime readiness is not
|
||||||
|
visible through Server, or if startup logs contain a panic, migration collision,
|
||||||
|
or Worker execution restore failure. It also proves that a listening Server
|
||||||
|
without its configured Runtime is not readiness and restarts the isolated
|
||||||
|
Runtime once to exercise persistence reopen.
|
||||||
|
|
||||||
|
Override `YOI_SMOKE_SERVER_BIN`, `YOI_SMOKE_RUNTIME_BIN`,
|
||||||
|
`YOI_SMOKE_SERVER_PORT`, or `YOI_SMOKE_RUNTIME_PORT` only when a separate build
|
||||||
|
or port is intentionally under test. Set `YOI_SMOKE_KEEP=1` to retain successful
|
||||||
|
artifacts. Failed artifacts are retained automatically.
|
||||||
|
|
||||||
## What to record
|
## What to record
|
||||||
|
|
||||||
When a tool limitation, workflow obstacle, or model-facing policy problem blocks work, record it under `docs/report/` or a work item artifact. Do not turn every minor annoyance into a maintained design doc.
|
When a tool limitation, workflow obstacle, or model-facing policy problem blocks work, record it under `docs/report/` or a work item artifact. Do not turn every minor annoyance into a maintained design doc.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Use the highest-level interface that matches the work:
|
|||||||
|
|
||||||
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions.
|
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions.
|
||||||
- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links.
|
- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links.
|
||||||
- Inside Workers, use typed Ticket tools to create, inspect, comment, review, and close Tickets.
|
- Inside Workers, use typed Ticket tools for Ticket records and typed Merge Request tools for immutable implementation/review/completion evidence.
|
||||||
- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates.
|
- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates.
|
||||||
|
|
||||||
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through `yoi panel`, Ticket tools, or `yoi ticket ...`.
|
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through `yoi panel`, Ticket tools, or `yoi ticket ...`.
|
||||||
@@ -37,8 +37,8 @@ Workers with the Ticket built-in feature can use typed Ticket tools:
|
|||||||
- `TicketList` — lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority.
|
- `TicketList` — lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority.
|
||||||
- `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds.
|
- `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds.
|
||||||
- `TicketComment`
|
- `TicketComment`
|
||||||
- `TicketReview`
|
- `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete`
|
||||||
- `TicketWorkflowState`
|
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input.
|
||||||
- `TicketClose`
|
- `TicketClose`
|
||||||
- `TicketRelationRecord`
|
- `TicketRelationRecord`
|
||||||
- `TicketRelationQuery`
|
- `TicketRelationQuery`
|
||||||
@@ -52,7 +52,7 @@ Use them when a Worker needs to materialize or update project records:
|
|||||||
|
|
||||||
- Intake creates a new Ticket after user agreement.
|
- Intake creates a new Ticket after user agreement.
|
||||||
- Orchestrator records routing decisions and intent packets.
|
- Orchestrator records routing decisions and intent packets.
|
||||||
- Reviewer records approve/request-changes review results.
|
- Reviewer commits an approve/request-changes result against one immutable Merge Request revision.
|
||||||
- Maintainer closes a Ticket with a resolution when merge/validation/cleanup evidence is complete.
|
- Maintainer closes a Ticket with a resolution when merge/validation/cleanup evidence is complete.
|
||||||
|
|
||||||
Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable.
|
Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable.
|
||||||
@@ -241,9 +241,9 @@ Implementation normally happens in a child git worktree created by the Orchestra
|
|||||||
|
|
||||||
### 5. Review
|
### 5. Review
|
||||||
|
|
||||||
Reviewer Workers should be sibling Workers, not children of coder Workers. They should read the Ticket, intent packet, diff, implementation report, and validation evidence.
|
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.
|
||||||
|
|
||||||
Review results should be recorded with the `TicketReview` tool. Maintainers working directly with the local backend can use the `yoi ticket` CLI documented later.
|
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.
|
||||||
|
|
||||||
Blockers must be fixed or explicitly escalated before merge-ready submission.
|
Blockers must be fixed or explicitly escalated before merge-ready submission.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# Ticket検索・close監査に必要な履歴visibilityが不足している
|
||||||
|
|
||||||
|
Tracking Ticket: `00001KZRNHB35`
|
||||||
|
|
||||||
|
## 発生日
|
||||||
|
|
||||||
|
2026-08-06
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
active Ticketから「Implementation reportやapprove、commit evidenceはあるがcloseされていないTicket」を監査した際、model-facing Ticket toolだけでは候補抽出とscope履歴の照合が難しかった。
|
||||||
|
|
||||||
|
この不足により、Workerが複数Ticketの履歴を目視で突き合わせ、`00001KZ46DP6K`のImplementation reportを別のWorkdir delete要件と誤対応させた。最終的にはfull thread、current source、commit `e6a2da54`を再確認して訂正したが、Ticket bodyの誤編集と誤った監査commentを発生させた。
|
||||||
|
|
||||||
|
誤判定自体はWorkerの確認不足である。一方、現在のtool surfaceはこの種の事故を防ぐための検索・version history・typed evidence projectionを提供していない。
|
||||||
|
|
||||||
|
## 観測した障壁
|
||||||
|
|
||||||
|
### `TicketList`から候補を抽出できない
|
||||||
|
|
||||||
|
今回のmodel-facing結果では、`TicketList`が次のようなcount summaryだけを返す場合があった。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Listed 23 ticket(s) for state active
|
||||||
|
```
|
||||||
|
|
||||||
|
個々のTicket id、title、state、updated_atを取得できないため、既知のIDを使って`TicketShow`を全件呼ぶ必要がある。pagination offsetやevent/evidence filterもない。
|
||||||
|
|
||||||
|
必要だった検索条件は以下。
|
||||||
|
|
||||||
|
- activeかつImplementation reportあり
|
||||||
|
- activeかつapproveあり
|
||||||
|
- activeかつcommit evidenceあり
|
||||||
|
- `done`だが未close
|
||||||
|
- Implementation report後にitem body/titleが変更された
|
||||||
|
- unresolved `request_changes`あり/なし
|
||||||
|
- commit hashまたは本文文字列を含むTicket
|
||||||
|
|
||||||
|
### `TicketShow`のprojectionが安定しない
|
||||||
|
|
||||||
|
同じ`TicketShow`でも、state一行だけになる場合と、body/thread/relationsを含むfull JSON projectionになる場合があった。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ticket 00001KZ46DP6K state planning
|
||||||
|
```
|
||||||
|
|
||||||
|
`body_max_bytes`と`event_limit`を大きくしても常にfull projectionになるとは限らず、Workerは「eventがない」のか「表示が省略された」のかを区別できない。
|
||||||
|
|
||||||
|
### Implementation reportがtyped eventではない
|
||||||
|
|
||||||
|
Implementation reportとcommit evidenceは通常のMarkdown commentとして保存されている。
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Implementation report
|
||||||
|
...
|
||||||
|
commit `e6a2da54`
|
||||||
|
```
|
||||||
|
|
||||||
|
そのため、backendは以下を構造化して検索できない。
|
||||||
|
|
||||||
|
- implementation reportの存在
|
||||||
|
- repository id
|
||||||
|
- base/head commit
|
||||||
|
- test evidence
|
||||||
|
- dirty state
|
||||||
|
- assignment id
|
||||||
|
- report対象revision
|
||||||
|
|
||||||
|
Markdown headingや文言に依存した監査になる。
|
||||||
|
|
||||||
|
### item editのversion historyを復元できない
|
||||||
|
|
||||||
|
threadの`item_edit` eventは、変更fieldとreplacement countだけを返す。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ticket item updated: title, body. Body replacement applied to 10 occurrence(s).
|
||||||
|
```
|
||||||
|
|
||||||
|
編集前title/body、編集後snapshot、item revision idを取得できない。Implementation reportがどのitem revisionを対象にしたかも記録されない。
|
||||||
|
|
||||||
|
このため、「実装完了後にTicketが別scopeへrescopeされた」のか、「Implementation reportの読み違い」なのかをthreadだけから確実に判定できない。
|
||||||
|
|
||||||
|
### lifecycle監査queryがない
|
||||||
|
|
||||||
|
`TicketDoctor`はschema/consistency diagnosticsには有効だが、次のsemantic lifecycle driftを検出しない。
|
||||||
|
|
||||||
|
- Implementation report + approve + reachable commitがあるのにplanning/inprogress
|
||||||
|
- state `done`だが未close
|
||||||
|
- close後にunresolved request_changesが追加された
|
||||||
|
- Implementation report後のmaterial rescope
|
||||||
|
- report commitがcurrent repositoryで解決不能
|
||||||
|
- MR current revisionとreview対象revisionが不一致
|
||||||
|
|
||||||
|
### relation削除operationがない
|
||||||
|
|
||||||
|
`TicketRelationRecord`と`TicketRelationQuery`はあるが、誤登録やstale relationを削除するtyped operationがない。storage直接編集はauthority bypassになるため実施できない。
|
||||||
|
|
||||||
|
## 影響
|
||||||
|
|
||||||
|
- active Ticket全件へのN+1 `TicketShow`が必要になる。
|
||||||
|
- known IDを持たないWorkerは候補自体を列挙できない。
|
||||||
|
- Markdown commentの目視照合で別Ticketのevidenceを混同しやすい。
|
||||||
|
- rescope後のcurrent bodyだけを見て過去Implementation reportを誤評価する。
|
||||||
|
- close漏れ監査が高コストで、Ticket state driftが蓄積する。
|
||||||
|
- 今回は誤ったTicket body editと監査commentを追加し、後から訂正eventを積むことになった。
|
||||||
|
|
||||||
|
## 提案
|
||||||
|
|
||||||
|
### read toolを`Query` / `Show`へ整理する
|
||||||
|
|
||||||
|
既存のselection-only `TicketList`へ検索責務を積み増すのではなく、read surfaceを次の責務へ整理する。
|
||||||
|
|
||||||
|
- `QueryTicket`: Ticket workflowのselection、全文検索、structured filter、attention候補
|
||||||
|
- `ShowTicket`: 一件のcurrent item、version refs、bounded thread、resolution、artifacts
|
||||||
|
- `QueryObjective`: Objectiveのselection、全文検索、state/linked-Ticket filter
|
||||||
|
- `ShowObjective`: 一件のcurrent bodyとlink projection
|
||||||
|
|
||||||
|
`QueryTicket`は次のoptional queryを受け取る。
|
||||||
|
|
||||||
|
```text
|
||||||
|
QueryTicket {
|
||||||
|
query?: string,
|
||||||
|
states?: [...],
|
||||||
|
event_kinds?: [...],
|
||||||
|
evidence?: implementation_report | commit | approved,
|
||||||
|
review_status?: none | approved | request_changes | unresolved_changes,
|
||||||
|
attention?: done_not_closed
|
||||||
|
| implementation_report_not_closed
|
||||||
|
| report_after_rescope
|
||||||
|
| unresolved_review
|
||||||
|
| missing_commit
|
||||||
|
| blocked
|
||||||
|
| unblocked,
|
||||||
|
related_ticket_id?: TicketId,
|
||||||
|
relation_kind?: RelationKind,
|
||||||
|
linked_objective_id?: ObjectiveId,
|
||||||
|
updated_before?: timestamp,
|
||||||
|
updated_after?: timestamp,
|
||||||
|
limit,
|
||||||
|
cursor?,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`attention`は自動mutationを行わず、候補と根拠だけを返す。Worker/Userが`ShowTicket`、review、repository authorityを再読してclose判断する。
|
||||||
|
|
||||||
|
resultは常にbounded summaryを返す。
|
||||||
|
|
||||||
|
- Ticket id/title/state/readiness
|
||||||
|
- matched field/event kind
|
||||||
|
- bounded snippet
|
||||||
|
- event id/sequence
|
||||||
|
- updated_at
|
||||||
|
- unresolved blocker/review count
|
||||||
|
- `next_cursor` / `truncated`
|
||||||
|
|
||||||
|
`QueryObjective`も同じ共通query infrastructureを使い、`query`、`states`、`linked_ticket_id`、`updated_*`、cursor paginationを提供する。domain固有filterとresult型は混ぜない。
|
||||||
|
|
||||||
|
### 横断検索toolは追加しない
|
||||||
|
|
||||||
|
同じqueryを`QueryTicket`と`QueryObjective`へそれぞれ実行すれば十分である。異なるauthorityとresult型を`WorkspaceSearch`へ混ぜるとsurfaceとprojectionが増えるため、横断toolは追加しない。
|
||||||
|
|
||||||
|
### typed Implementation report
|
||||||
|
|
||||||
|
Markdown bodyに加えて、最低限次をtyped attributesとして保存する。
|
||||||
|
|
||||||
|
- `assignment_id`
|
||||||
|
- `repository_id`
|
||||||
|
- `base_commit`
|
||||||
|
- `head_commit`
|
||||||
|
- `merge_request_id` / `revision_id`
|
||||||
|
- validation evidence refs
|
||||||
|
- dirty/untracked state
|
||||||
|
- source Runtime/Worker identity
|
||||||
|
|
||||||
|
reportは作成時のTicket item revisionを参照する。`QueryTicket.evidence`と`attention`はMarkdown headingをparseせず、このtyped authorityをqueryする。
|
||||||
|
|
||||||
|
### retrievable item revisions
|
||||||
|
|
||||||
|
`ShowTicket`のoptional revision selectorまたはbounded version projectionでitem historyを取得できるようにする。専用tool追加は、同じprojectionではsize/authorityを分離できない場合だけ検討する。
|
||||||
|
|
||||||
|
```text
|
||||||
|
TicketItemRevision {
|
||||||
|
revision_id,
|
||||||
|
title,
|
||||||
|
body_digest,
|
||||||
|
body or bounded diff,
|
||||||
|
edited_at,
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation report/review/close resolutionから対象item revisionを参照する。
|
||||||
|
|
||||||
|
### stable tool projection
|
||||||
|
|
||||||
|
`QueryTicket`、`ShowTicket`、`QueryObjective`、`ShowObjective`は、同じparameterなら常に同じshapeのbounded JSONを返す。省略時は明示的な`truncated`、`returned`、`next_cursor`を返し、「entryなし」と「projection省略」を区別する。
|
||||||
|
|
||||||
|
### write commandは副作用単位で明示する
|
||||||
|
|
||||||
|
relationのadd/remove、Queue、review、Close、item editなどはauthorization、precondition、idempotency、notification、compensation、audit/result型が異なるため、generic `MutateTicket`や`MutateObjective`へ統合しない。明示commandを維持し、model-facing tool数はprofile・role・Flow別catalog projectionで抑える。
|
||||||
|
|
||||||
|
Implementation reportはtyped Ticket thread eventとして扱い、item revision参照とbounded query結果から監査可能にする。
|
||||||
|
|
||||||
|
## 推奨順序
|
||||||
|
|
||||||
|
1. `QueryTicket`を追加し、全文検索・state・updated time・thread kind・resolution・artifact・relation filterとcursorを入れる
|
||||||
|
2. `ShowTicket`へitem/threadの安定したversion referenceを加える
|
||||||
|
3. `QueryObjective`を同じquery/cursor infrastructureで追加する
|
||||||
|
4. `ShowObjective`へlink projectionを加える
|
||||||
|
5. typed Implementation reportとprofile/role/Flow別catalog projectionを追加する
|
||||||
|
6. WebUIに検索・filter・pagination・implementation-report表示を追加する
|
||||||
|
|
||||||
|
## 期待する監査手順
|
||||||
|
|
||||||
|
1. `QueryTicket(states=active, attention=implementation_report_not_closed)`で候補抽出。
|
||||||
|
2. candidateごとに`ShowTicket`でcurrent item revision、report対象revision、latest reviewを取得。
|
||||||
|
3. typed commit/MR evidenceをrepository authorityで検証。
|
||||||
|
4. unresolved `request_changes`とdependencyを確認。
|
||||||
|
5. User/Orchestratorが明示的にcloseする。
|
||||||
|
|
||||||
|
この順序なら、全TicketのMarkdown threadを目視で横断せず、scopeの異なるevidenceを誤対応させずにclose漏れを監査できる。横断検索toolやgeneric mutation toolを増やさず、roleごとのcatalog projectionでsurfaceを限定できる。
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Flow verifier uses a stale committed-session capture
|
||||||
|
|
||||||
|
Date: 2026-08-11
|
||||||
|
Ticket: `00001KZPQW4GJ`
|
||||||
|
Flow instance: `019ff243-d4df-71f2-beb3-cbc360f58c34`
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
`RequestFlowTransition` repeatedly evaluated the `implement -> review` condition against a parent-session capture that ended immediately after branch creation and Ticket/plan reads. It did not observe later committed session entries containing implementation work, post-commit validation, clean-tree checks, commits, or independent Reviewer approval.
|
||||||
|
|
||||||
|
The verifier therefore returned `indeterminate` even though the Workdir contained the implementation and the current Worker session had already recorded the required evidence.
|
||||||
|
|
||||||
|
## Repository-visible evidence
|
||||||
|
|
||||||
|
Named branch and commits:
|
||||||
|
|
||||||
|
- `work/00001KZPQW4GJ-worker-remove-v3`
|
||||||
|
- `8ae930c5fc81acb2c60de15add07e016a1552edd` — `worker: add guarded WorkerRemove lifecycle`
|
||||||
|
- `f60c2d583485572697f7ec42d8cf3c8015e7c179` — `worker: resume failed removal operation`
|
||||||
|
|
||||||
|
Post-commit validation:
|
||||||
|
|
||||||
|
- `cargo test -p worker --lib`: 520 passed.
|
||||||
|
- `cargo test -p worker-runtime --lib`: 127 passed.
|
||||||
|
- `cargo test -p yoi-workspace-server --lib retention::tests`: 12 passed.
|
||||||
|
- `cargo test -p yoi-workspace-server --lib worker_remove`: 5 passed.
|
||||||
|
- `cargo test -p yoi-workspace-server --lib stale_policy_and_failed_retry_restore_fence`: passed.
|
||||||
|
- `cargo check -p yoi-workspace-server -p worker-runtime -p worker`: passed.
|
||||||
|
- `cargo fmt --all -- --check`: passed.
|
||||||
|
- `git diff --check`: passed.
|
||||||
|
|
||||||
|
Independent read-only Reviewer verdict:
|
||||||
|
|
||||||
|
> APPROVE — I found no blocker/high security or correctness issue in commits `8ae930c5` + `f60c2d58`.
|
||||||
|
|
||||||
|
The Reviewer explicitly confirmed the constrained four-field tool input, proof-only destructive boundary, exact Runtime-result Worker revision binding, successful recovery after registry purge, and failed-operation re-entry through the authoritative prepare/executing fence.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
A correct, tested, independently approved implementation cannot advance from `implement` to `review` because the Flow verifier does not see newly committed Worker history. Repeating validation or review inside the same live session does not repair the verifier input.
|
||||||
|
|
||||||
|
## Suggested fix
|
||||||
|
|
||||||
|
Before evaluating a transition, refresh the verifier's session capture from the latest committed Worker history revision and include stable references to:
|
||||||
|
|
||||||
|
- the current branch and commit,
|
||||||
|
- bounded validation command results,
|
||||||
|
- current clean-tree evidence,
|
||||||
|
- independent Reviewer verdicts,
|
||||||
|
- current Ticket review/evidence events.
|
||||||
|
|
||||||
|
The refreshed evidence must be committed to Worker history before verifier context construction, following the project context-injection invariant. Do not use an unrecorded transient reminder or mutate earlier history.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Dogfood restart self-termination and Runtime Worker restore panic
|
||||||
|
|
||||||
|
Date: 2026-08-12
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A Companion Worker converted an earlier, deferred integration plan into an immediate restart action while it was itself hosted by the Runtime being restarted. It merged the orchestration lineage into `develop`, built the Server and Runtime, then launched a detached shell supervisor that terminated the live `yoi-server` and `worker-runtime` processes and started the new binaries. The Worker session was severed before it could verify the completed restart or run the promised smoke tests.
|
||||||
|
|
||||||
|
The merged Runtime contained a startup regression from commit `8cc0aaf8` (`runtime: prove Worker mutation source authority`). Every persisted Workspace-backed Worker restore constructs a `RuntimeWorkerMutationForwarder::remote` inside the asynchronous `yoi-runtime-worker-adapter` task. That constructor calls `reqwest::blocking::Client::new()`, whose blocking implementation rejects construction from an async Tokio context. The result is one panic per Worker restore.
|
||||||
|
|
||||||
|
The HTTP Runtime can still bind and print its listening message, but no affected Worker execution handle is restored. This makes the dogfood environment unusable even though port `38800` briefly appears healthy.
|
||||||
|
|
||||||
|
## Incident timeline
|
||||||
|
|
||||||
|
Authoritative session:
|
||||||
|
|
||||||
|
`~/.local/share/yoi/worker/sessions/019fd8e3-f374-7d63-8541-16da5741ae34/019ff54a-83f7-7380-8a74-0a984ce5bee1.jsonl`
|
||||||
|
|
||||||
|
- 18:30:08: the Worker completed merge commit `297a7ddd` (`merge: integrate orchestration merge request lifecycle`) on `develop`. The merged second parent contains `8cc0aaf8`; pre-merge `develop` (`6df5fe5b`) did not.
|
||||||
|
- 18:33:29: the new `target/debug/yoi-runtime` binary was built.
|
||||||
|
- 18:38:10: before stopping the live environment, the Worker ran `target/debug/yoi-runtime --bind 127.0.0.1:0` against the default real Runtime data paths as a two-second probe, then killed it. The probe did not use an isolated store.
|
||||||
|
- 18:38:54: the Worker launched detached supervisor PID `1787015`. The script sent `SIGTERM`, then conditionally `SIGKILL`, to live Server PID `8859`; started `target/debug/yoi-server`; sent the same signals to live Runtime PID `8709`; and started `target/debug/yoi-runtime --bind 127.0.0.1:38800`.
|
||||||
|
- Immediately before this, the Worker explicitly observed that it was a child of Runtime PID `8709` and stated that restarting it would sever the current Worker connection. It proceeded without an immediate confirmation turn.
|
||||||
|
- 18:38:58: `target/restart-logs/restart.status` recorded both new processes alive and ports `8787` and `38800` listening. The detached supervisor stopped at this shallow liveness check.
|
||||||
|
- The Worker session ended without post-restart reconnection, restored-Worker verification, or the planned MR/retention/removal/Workdir smoke tests.
|
||||||
|
- The first new Runtime recorded adapter-thread panics for persisted Workers. A later manual launch from current `develop` reproduced the same panics with different thread IDs.
|
||||||
|
|
||||||
|
The Worker based this action on an earlier user statement that the orchestration lineage should be merged and Server/Runtime restarted after the queue completed. It was not a direct restart request in the incident turn. Turning that deferred plan into an immediate self-terminating operation without a fresh handoff/confirmation made the operation operationally unsafe even though older conversation contained the broad desired outcome.
|
||||||
|
|
||||||
|
## Code path
|
||||||
|
|
||||||
|
1. `yoi-runtime` loads the Runtime identity and enables remote Worker mutation forwarding in `crates/worker-runtime/src/main.rs`.
|
||||||
|
2. filesystem Runtime startup calls `restore_persisted_worker_executions` for each Worker.
|
||||||
|
3. `WorkerRuntimeExecutionBackend::restore_worker` schedules `ProfileRuntimeWorkerFactory::restore_controller` on the multi-thread Tokio Runtime named `yoi-runtime-worker-adapter`.
|
||||||
|
4. `restore_controller` constructs the Workspace context.
|
||||||
|
5. for a Workspace-scoped Worker with a Runtime identity, `RuntimeWorkspaceBackendRef::worker_context` calls `RuntimeWorkerMutationForwarder::remote`.
|
||||||
|
6. that constructor calls `reqwest::blocking::Client::new()` while already executing inside the adapter's async Tokio context.
|
||||||
|
7. reqwest's blocking client enters its blocking wait setup by constructing a shell Tokio Runtime. Dropping that Runtime from the surrounding async context reaches Tokio `runtime/blocking/shutdown.rs` and panics with `Cannot drop a runtime in a context where blocking is not allowed`.
|
||||||
|
8. `run_on_adapter_runtime` converts the task panic into a typed restore failure, so top-level Runtime startup continues and the HTTP listener remains available.
|
||||||
|
|
||||||
|
Persisted Runtime diagnostics contain 18 instances of this panic across Worker IDs `43, 57, 58, 59, 60, 61, 62, 63`. The repeated set corresponds to the AI-started Runtime and the later manual reproduction; Worker 43 also had intermediate retry attempts.
|
||||||
|
|
||||||
|
## Why existing validation missed it
|
||||||
|
|
||||||
|
The source-authority commit added `restart_restore_reconstructs_runtime_owned_worker_mutation_client`, but that is a synchronous unit test. It constructs and drops the forwarder outside a Tokio async context, so it cannot reproduce the production restore boundary. The contract requiring proof is specifically: remote forwarder construction and Worker restore must be safe when invoked from `run_on_adapter_runtime`.
|
||||||
|
|
||||||
|
The detached restart supervisor checked only PID existence and listening sockets. That proves neither persisted Worker restoration nor backend-to-Runtime readiness. Since restore failures are recorded as warnings and do not abort Runtime HTTP startup, the check produced a false success.
|
||||||
|
|
||||||
|
## Required improvements
|
||||||
|
|
||||||
|
- Do not store or construct a `reqwest::blocking::Client` on an async Runtime path. Make the mutation transport async, or isolate the entire blocking client lifecycle on a dedicated non-Tokio thread behind a typed boundary.
|
||||||
|
- Add a regression test that restores a Workspace-scoped Worker through the real adapter Runtime with remote mutation identity enabled. It must fail on any task panic and assert a connected execution handle.
|
||||||
|
- Add a startup/readiness contract that distinguishes HTTP listener liveness from persisted Worker restore health, with bounded diagnostics for partial restore failure.
|
||||||
|
- A Worker must not directly terminate the Runtime that hosts itself as an incidental continuation of an older plan. Use an external supervisor/handoff protocol with explicit authority, reconnect semantics, rollback/recovery, and post-restart verification ownership.
|
||||||
|
- Never run a probe Runtime against the live default persistent store. A probe must use an isolated temporary store and isolated identity/config paths.
|
||||||
|
- Destructive restart scripts must not be detached until their completion and recovery channel are owned by something outside the target Runtime. PID/port checks alone are insufficient.
|
||||||
|
|
||||||
|
## Direct startup regression resolution
|
||||||
|
|
||||||
|
The direct Worker restore panic was fixed in the same diagnosis work:
|
||||||
|
|
||||||
|
- `RuntimeWorkerMutationTransport::Remote` no longer constructs or retains a `reqwest::blocking::Client`.
|
||||||
|
- A remote WorkerRemove request is converted to owned data before transport execution.
|
||||||
|
- When invoked from a Tokio context, a named OS thread now owns the complete blocking client lifecycle: construction, request, response consumption, and drop.
|
||||||
|
- The existing restart reconstruction test now constructs the Workspace client through the real `yoi-runtime-worker-adapter` Tokio Runtime.
|
||||||
|
- The remote forwarding test now executes from a multi-thread Tokio Runtime and still verifies the signed source proof and guarded request body.
|
||||||
|
- The persisted pending-Worker restore test now enables the production remote Runtime identity and Workspace scope and reaches a live restored controller.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- three focused async adapter, forwarding, and restore tests passed
|
||||||
|
- `cargo test -p worker-runtime --lib` — 119 passed after removing the eight obsolete aggregate-migration tests
|
||||||
|
- `cargo check -p worker-runtime --all-targets -p yoi-workspace-server` — passed; one pre-existing `PasskeyLoginCompleteResponse` dead-code warning remains
|
||||||
|
- `cargo fmt --all -- --check` — passed
|
||||||
|
- `git diff --check HEAD` — passed
|
||||||
|
|
||||||
|
## Embedded Worker aggregate migration collision
|
||||||
|
|
||||||
|
The next Server startup failed before constructing the embedded Runtime:
|
||||||
|
|
||||||
|
`failed to migrate embedded Runtime Worker aggregates: ... workers/3/metadata.json: Worker metadata collision`
|
||||||
|
|
||||||
|
This was not corrupt Worker data. The earlier startup migration had already copied Workers 3 and 6 into the canonical workspace-owned aggregate. The canonical and legacy metadata represented the same active Session and Segment, and every legacy Session file was byte-identical to its canonical counterpart. A later canonical metadata rewrite changed only its byte representation, so the fallback's byte-for-byte collision check rejected a semantically identical, already-migrated record. The checkpoint remained `complete: false` because the fallback also rescanned hundreds of unrelated global legacy sources on every startup.
|
||||||
|
|
||||||
|
The resolution deliberately does not add another compatibility branch:
|
||||||
|
|
||||||
|
- removed embedded Server startup migration from global Worker metadata and Session roots
|
||||||
|
- removed standalone Runtime startup migration from the same global roots
|
||||||
|
- removed the older automatic `root/runtimes/<id>` store-layout migration
|
||||||
|
- removed the now-unused migration API, implementation, checkpoint logic, and dedicated tests
|
||||||
|
- retained only the canonical workspace-owned aggregates for Workers 3 and 6
|
||||||
|
- moved the duplicate global metadata, duplicate global Sessions, incomplete checkpoint, and migration lock into a recoverable backup
|
||||||
|
|
||||||
|
The one-off migration backup is:
|
||||||
|
|
||||||
|
`/home/hare/.local/share/yoi/migration-backups/2026-08-12-embedded-worker-aggregate-v1-0197a949`
|
||||||
|
|
||||||
|
It contains a complete 43 MiB pre-change copy of the canonical embedded Runtime store plus the retired legacy sources and migration markers. No Server or Runtime process was restarted as part of the repair.
|
||||||
|
|
||||||
|
Additional validation:
|
||||||
|
|
||||||
|
- canonical and legacy Worker 3/6 Session file sets and bytes matched before retirement
|
||||||
|
- all JSON files in the retained canonical embedded Runtime store parsed successfully
|
||||||
|
- focused embedded Runtime fs-store restore test passed
|
||||||
|
- full `yoi-workspace-server` library suite reached 197/201; four unrelated remote-Runtime test fixtures failed because their unauthenticated test servers returned `AuthRequired`
|
||||||
|
|
||||||
|
## Post-restart resolution
|
||||||
|
|
||||||
|
After the fixes passed the isolated startup gate, the user restarted the dogfood
|
||||||
|
Server and Runtime externally. Post-restart checks confirmed:
|
||||||
|
|
||||||
|
- Server and remote Runtime both project `running` with no diagnostics
|
||||||
|
- persisted Workers are visible after Runtime restart
|
||||||
|
- `WorkerList` decodes occupied Workdirs without the previous DTO failure
|
||||||
|
- current Worker `arcadia/43` completed a Workdir `stat(".")` operation with
|
||||||
|
`200 OK`
|
||||||
|
- the running Server and Runtime binaries match the rebuilt executable files
|
||||||
|
|
||||||
|
The reusable regression gate is `scripts/isolated-startup-smoke.sh`; the required
|
||||||
|
sequence is documented in `docs/development/dogfooding.md`.
|
||||||
|
|
||||||
|
### Follow-up: embedded singleton restore failure
|
||||||
|
|
||||||
|
The external restart recovered the remote Runtime Workers, but it did not recover
|
||||||
|
the two persisted embedded singleton executions. Workspace Memory Consolidation
|
||||||
|
(`embedded-worker-runtime/3`) and Workspace Orchestrator
|
||||||
|
(`embedded-worker-runtime/6`) are projected as `stopped` without a user stop.
|
||||||
|
The embedded Runtime diagnostics record `worker_execution_restore_failed` for
|
||||||
|
both with `path must be shorter than SUN_LEN`. Their persisted records still
|
||||||
|
contain execution bindings and session files, so this is a failed automatic
|
||||||
|
restore after Server restart, not a graceful terminal stop. Follow-up Ticket:
|
||||||
|
`00001KZVFQPSK`.
|
||||||
|
|
||||||
|
The isolated pre-dogfood gate originally covered remote Runtime persistence reopen
|
||||||
|
but not persisted embedded singleton restoration; that missing scenario allowed
|
||||||
|
this failure through even after the remote smoke passed.
|
||||||
|
|
||||||
|
### Embedded IPC resolution
|
||||||
|
|
||||||
|
The embedded Runtime retained every controller's `WorkerHandle` in the Server
|
||||||
|
process, but `WorkerController` still unconditionally bound `worker.sock` below
|
||||||
|
its persistent `runs/<generation>` directory. The workspace-owned embedded store
|
||||||
|
path plus Worker/run components exceeded Linux `sockaddr_un.sun_path`, so restore
|
||||||
|
failed before the in-process handle could be registered.
|
||||||
|
|
||||||
|
The controller now has an explicit transport policy. Standalone and remote
|
||||||
|
Runtime factories keep the default Unix-socket transport for external attach
|
||||||
|
clients; the Server-owned embedded factory selects `InProcess` and drives the
|
||||||
|
same controller exclusively through `WorkerHandle` channels. Persistent run
|
||||||
|
logs and artifact directories remain unchanged, but embedded spawn and restore
|
||||||
|
no longer create a socket file.
|
||||||
|
|
||||||
|
Regression coverage now includes both direct restore and complete fs-store
|
||||||
|
reopen under a path longer than `SUN_LEN`, with assertions that the Worker
|
||||||
|
returns to `idle`, no `worker_execution_restore_failed` diagnostic exists, and
|
||||||
|
neither run generation contains `worker.sock`. The existing isolated
|
||||||
|
production-binary smoke remains the remote Runtime restart gate; embedding the
|
||||||
|
Server-owned Runtime into that script requires a separate clean embedded spawn
|
||||||
|
fixture because the normal product singleton lifecycle is not a generic smoke
|
||||||
|
fixture.
|
||||||
|
|
||||||
|
Memory Consolidation also has an earlier independent session failure:
|
||||||
|
`memory tools require Backend Workspace API authority and an authenticated
|
||||||
|
workspace id`. That authority problem is not the cause of the `SUN_LEN` restore
|
||||||
|
failure and should remain separate.
|
||||||
|
|
||||||
|
## Current state observed during diagnosis
|
||||||
|
|
||||||
|
- `yoi-server` is not running. Legacy `target/debug/worker-runtime` PID `1820761` remains listening on `127.0.0.1:38800`; it uses the separate standalone Runtime catalog containing Workers 43, 57, 58, 59, 60, 61, 62, and 63.
|
||||||
|
- `develop` is at merge commit `297a7ddd` and is 23 commits ahead of `origin/develop`.
|
||||||
|
- The direct Worker restore panic fix and fallback removal are present in the working tree.
|
||||||
|
- The embedded Runtime store was repaired by the one-off migration above. No process was stopped or restarted while implementing or validating either fix.
|
||||||
@@ -5,21 +5,21 @@
|
|||||||
|
|
||||||
states = {
|
states = {
|
||||||
implement = {
|
implement = {
|
||||||
instructions = "Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record the concrete repository/test evidence. When the implementation is ready for independent review, request a Flow transition.";
|
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.";
|
||||||
transitions = {
|
transitions = {
|
||||||
review = {
|
review = {
|
||||||
target = "review";
|
target = "review";
|
||||||
condition = "The requested implementation is present, the relevant validation has completed, and there is enough bounded repository and session evidence for an independent Reviewer to evaluate the change.";
|
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.";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
review = {
|
review = {
|
||||||
instructions = "Spawn one independent Reviewer SubWorker with bounded Ticket, repository, diff, and validation context. Read its committed review through worker observation. Do not review your own implementation or treat a prose status as approval. After the Reviewer returns a typed approval or concrete requested changes, request a Flow transition.";
|
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.";
|
||||||
transitions = {
|
transitions = {
|
||||||
approved = {
|
approved = {
|
||||||
target = "done";
|
target = "complete";
|
||||||
condition = "The latest independent Reviewer attempt for the current implementation completed and approved it, with no later unresolved request_changes finding.";
|
condition = "The authoritative Merge Request current revision has a structured approve result from its registered direct-child builtin:reviewer attempt, with no later unresolved request_changes finding. The Flow transition itself does not complete the Ticket.";
|
||||||
};
|
};
|
||||||
changes_requested = {
|
changes_requested = {
|
||||||
target = "fix";
|
target = "fix";
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
fix = {
|
fix = {
|
||||||
instructions = "Resolve every open Reviewer finding, rerun the validation affected by the fixes, and preserve concrete evidence. Do not claim approval from the prior request_changes review. When the corrected implementation 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. 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.";
|
||||||
transitions = {
|
transitions = {
|
||||||
review = {
|
review = {
|
||||||
target = "review";
|
target = "review";
|
||||||
@@ -38,8 +38,18 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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.";
|
||||||
|
transitions = {
|
||||||
|
completed = {
|
||||||
|
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.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
done = {
|
done = {
|
||||||
instructions = "The Coder implementation and independent review loop is complete.";
|
instructions = "The guarded Merge Request completion operation committed Ticket state done. Flow terminal state only reflects that durable authority.";
|
||||||
terminal = true;
|
terminal = true;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import "./base.dcdl" // {
|
|||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = false; };
|
sub_worker = { enabled = false; };
|
||||||
worker = { enabled = true; };
|
worker = { enabled = true; direct_spawn = false; };
|
||||||
manage_workdir = { enabled = true; };
|
manage_workdir = { enabled = true; };
|
||||||
ticket = { enabled = true; thread = true; orchestration_control = true; };
|
ticket = { enabled = true; thread = true; workflow = true; };
|
||||||
|
orchestration = { enabled = true; };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ import "./base.dcdl" // {
|
|||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = false; };
|
sub_worker = { enabled = false; };
|
||||||
worker = { enabled = false; };
|
worker = { enabled = false; };
|
||||||
ticket = { enabled = true; thread = true; };
|
ticket = { enabled = true; thread = false; };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
You are the Ticket Coder role.
|
You are the assigned Coder. Implement the requested scope in the provided Workdir and keep durable evidence on the Ticket and its Merge Request.
|
||||||
|
|
||||||
Keep role behavior here and treat the first committed user message as concrete Ticket/action context only. Implement only within the delegated worktree/branch and authority scope. Treat the Ticket, intent packet, binding decisions/invariants, implementation latitude, validation expectations, and report expectations as the contract.
|
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose.
|
||||||
|
|
||||||
Choose local implementation tactics within that contract. Escalate to the Orchestrator instead of expanding scope when design, permission, dependency, prompt-boundary, or Ticket-boundary questions appear. Do not merge, push, close Tickets, delete worktrees, or create generated memory/local/runtime/log/lock/cache/socket/secret-like `.yoi` state.
|
Before review, open or append an immutable Merge Request revision containing the exact base/head/tree and changed-path evidence. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and include the structured `review` handoff with the Ticket id and current MR revision id. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected attempt authority.
|
||||||
|
|
||||||
Keep the repository operational throughout the work unless the Ticket explicitly permits a bounded incomplete state. Report the implementation and proportionate validation through the available typed Ticket tools; do not edit Ticket storage directly.
|
A request-changes result requires a new immutable revision and a fresh Reviewer child attempt. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id and the currently approved revision; the Server revalidates assignment and fences Ticket state side effects.
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
You are the Ticket Orchestrator role.
|
You are the Ticket Orchestrator role.
|
||||||
|
|
||||||
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, 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, and stop for human authority when merge/closure is not explicitly delegated.
|
||||||
|
|
||||||
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 current-revision durable review evidence is missing, indeterminate, 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 current-revision durable review evidence is missing, indeterminate, 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker with `WorkerShow`; pass the exact current `updated_at` value as `expected_worker_revision` with a concise reason. After removal, reread the Worker catalog and attachment state. Treat revision, assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures: do not guess policy or retry with stale input. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
You are the Ticket Reviewer role.
|
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 concrete Ticket/action context only. Review the implementation against the Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Prefer read-only inspection and focused validation; do not merge, close, clean up worktrees, or take over implementation unless explicitly asked.
|
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the immutable current Merge Request revision 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.
|
||||||
|
|
||||||
Report clear approve/request-changes evidence with risks, validation performed, and any unresolved requirement or design-boundary concern. When a workflow is invoked, follow that workflow as the procedural authority for reviewer handoff and report shape.
|
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. Attempt identity and revision identity are injected by your child Workspace client and are not model inputs. If the authoritative revision changed, submission must fail 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 behavior or 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.
|
||||||
|
|||||||
Executable
+301
@@ -0,0 +1,301 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Starts production Server/Runtime binaries against disposable state and ports.
|
||||||
|
# This script must never read or write the caller's Yoi data/config directories.
|
||||||
|
|
||||||
|
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
server_bin=${YOI_SMOKE_SERVER_BIN:-"$repo_root/target/debug/yoi-server"}
|
||||||
|
runtime_bin=${YOI_SMOKE_RUNTIME_BIN:-"$repo_root/target/debug/yoi-runtime"}
|
||||||
|
server_port=${YOI_SMOKE_SERVER_PORT:-48787}
|
||||||
|
runtime_port=${YOI_SMOKE_RUNTIME_PORT:-48800}
|
||||||
|
keep=${YOI_SMOKE_KEEP:-0}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'isolated-startup-smoke: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for command in curl git node ss; do
|
||||||
|
command -v "$command" >/dev/null || fail "required command is unavailable: $command"
|
||||||
|
done
|
||||||
|
[[ -x "$server_bin" ]] || fail "Server binary is not executable: $server_bin"
|
||||||
|
[[ -x "$runtime_bin" ]] || fail "Runtime binary is not executable: $runtime_bin"
|
||||||
|
[[ "$server_port" =~ ^[0-9]+$ ]] || fail "invalid Server port: $server_port"
|
||||||
|
[[ "$runtime_port" =~ ^[0-9]+$ ]] || fail "invalid Runtime port: $runtime_port"
|
||||||
|
[[ "$server_port" != "$runtime_port" ]] || fail "Server and Runtime ports must differ"
|
||||||
|
|
||||||
|
port_is_listening() {
|
||||||
|
local port=$1
|
||||||
|
ss -H -ltn "sport = :$port" | grep -q .
|
||||||
|
}
|
||||||
|
|
||||||
|
port_is_listening "$server_port" && fail "Server smoke port is already in use: $server_port"
|
||||||
|
port_is_listening "$runtime_port" && fail "Runtime smoke port is already in use: $runtime_port"
|
||||||
|
|
||||||
|
root=$(mktemp -d "${TMPDIR:-/tmp}/yoi-isolated-startup-smoke.XXXXXX")
|
||||||
|
server_pid=
|
||||||
|
runtime_pid=
|
||||||
|
|
||||||
|
stop_pid() {
|
||||||
|
local pid=${1:-}
|
||||||
|
[[ -n "$pid" ]] || return 0
|
||||||
|
kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill -KILL "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
wait "$pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
stop_pid "$runtime_pid"
|
||||||
|
stop_pid "$server_pid"
|
||||||
|
if [[ "$keep" == 1 ]]; then
|
||||||
|
printf 'isolated-startup-smoke: kept artifacts at %s\n' "$root" >&2
|
||||||
|
elif [[ $status -eq 0 ]]; then
|
||||||
|
rm -rf "$root"
|
||||||
|
else
|
||||||
|
printf 'isolated-startup-smoke: failed; artifacts kept at %s\n' "$root" >&2
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
mkdir -p "$root/home" "$root/data" "$root/config" "$root/repository" "$root/logs"
|
||||||
|
export HOME="$root/home"
|
||||||
|
export XDG_DATA_HOME="$root/data"
|
||||||
|
export XDG_CONFIG_HOME="$root/config"
|
||||||
|
unset YOI_DATA_DIR YOI_CONFIG_HOME
|
||||||
|
|
||||||
|
# Fail closed if isolation variables no longer point below the disposable root.
|
||||||
|
case "$HOME:$XDG_DATA_HOME:$XDG_CONFIG_HOME" in
|
||||||
|
"$root"/*:"$root"/*:"$root"/*) ;;
|
||||||
|
*) fail "HOME/XDG isolation guard failed" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
server_url="http://127.0.0.1:$server_port"
|
||||||
|
runtime_url="http://127.0.0.1:$runtime_port"
|
||||||
|
server_id=isolated-smoke-server
|
||||||
|
runtime_id=isolated-smoke-runtime
|
||||||
|
|
||||||
|
git -C "$root/repository" init -q
|
||||||
|
git -C "$root/repository" config user.email smoke@example.invalid
|
||||||
|
git -C "$root/repository" config user.name 'Yoi isolated smoke'
|
||||||
|
printf '# isolated smoke\n' >"$root/repository/README.md"
|
||||||
|
git -C "$root/repository" add README.md
|
||||||
|
git -C "$root/repository" commit -qm 'test: initialize isolated smoke repository'
|
||||||
|
|
||||||
|
"$server_bin" identity init --server-id "$server_id" >"$root/logs/server-identity-init.log" 2>&1
|
||||||
|
"$runtime_bin" identity init --runtime-id "$runtime_id" >"$root/logs/runtime-identity-init.log" 2>&1
|
||||||
|
server_identity=$("$server_bin" identity show --json)
|
||||||
|
runtime_identity=$("$runtime_bin" identity show --json)
|
||||||
|
server_key=$(printf '%s' "$server_identity" | node -e 'const fs=require("fs"); process.stdout.write(JSON.parse(fs.readFileSync(0,"utf8")).public_key)')
|
||||||
|
runtime_key=$(printf '%s' "$runtime_identity" | node -e 'const fs=require("fs"); process.stdout.write(JSON.parse(fs.readFileSync(0,"utf8")).public_key)')
|
||||||
|
|
||||||
|
"$server_bin" trust-runtime add \
|
||||||
|
--runtime-id "$runtime_id" \
|
||||||
|
--public-key "$runtime_key" \
|
||||||
|
--base-url "$runtime_url" >"$root/logs/server-trust-runtime.log" 2>&1
|
||||||
|
"$runtime_bin" trust-server add \
|
||||||
|
--server-id "$server_id" \
|
||||||
|
--public-key "$server_key" >"$root/logs/runtime-trust-server.log" 2>&1
|
||||||
|
|
||||||
|
"$server_bin" init --workspace "$root/repository" >"$root/logs/server-init.log" 2>&1
|
||||||
|
workspace_id=$(sed -n 's/^workspace_id = "\([^"]*\)"/\1/p' "$root/repository/.yoi/workspace.toml")
|
||||||
|
[[ -n "$workspace_id" ]] || fail "workspace init did not write workspace_id"
|
||||||
|
# Runtime Git materialization requires a clean source repository. Commit both
|
||||||
|
# local bootstrap markers inside this disposable repository.
|
||||||
|
git -C "$root/repository" add .yoi/workspace.toml .yoi/workspace-backend.local.toml
|
||||||
|
git -C "$root/repository" commit -qm 'test: record isolated Yoi workspace markers'
|
||||||
|
|
||||||
|
runtime_store="$XDG_DATA_HOME/yoi/runtime"
|
||||||
|
mkdir -p "$runtime_store/workers"
|
||||||
|
cat >"$runtime_store/runtime.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"display_name": "isolated startup smoke",
|
||||||
|
"backend": "fs_store",
|
||||||
|
"status": "running",
|
||||||
|
"next_worker_sequence": 1,
|
||||||
|
"next_diagnostic_id": 1,
|
||||||
|
"config_bundles": {},
|
||||||
|
"workspace_owners": {},
|
||||||
|
"diagnostics": []
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
start_server() {
|
||||||
|
: >"$root/logs/server.log"
|
||||||
|
"$server_bin" serve --listen "127.0.0.1:$server_port" >"$root/logs/server.log" 2>&1 &
|
||||||
|
server_pid=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
start_runtime() {
|
||||||
|
: >"$root/logs/runtime.log"
|
||||||
|
"$runtime_bin" --bind "127.0.0.1:$runtime_port" >"$root/logs/runtime.log" 2>&1 &
|
||||||
|
runtime_pid=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_listener() {
|
||||||
|
local pid=$1
|
||||||
|
local port=$2
|
||||||
|
local name=$3
|
||||||
|
for _ in $(seq 1 150); do
|
||||||
|
kill -0 "$pid" 2>/dev/null || fail "$name exited before listening; inspect $root/logs"
|
||||||
|
port_is_listening "$port" && return 0
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
fail "$name did not listen on port $port within 15 seconds"
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime_projection() {
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
"$server_url/api/w/$workspace_id/runtimes"
|
||||||
|
}
|
||||||
|
|
||||||
|
projection_is_ready() {
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const runtimeId = process.argv[1];
|
||||||
|
const body = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||||
|
const runtime = body.items.find((item) => item.runtime_id === runtimeId);
|
||||||
|
if (!runtime || runtime.status !== "running") process.exit(1);
|
||||||
|
if (!runtime.capabilities?.can_list_workers) process.exit(1);
|
||||||
|
if ((runtime.diagnostics ?? []).length !== 0) process.exit(1);
|
||||||
|
if ((body.diagnostics ?? []).length !== 0) process.exit(1);
|
||||||
|
' "$runtime_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_projection_state() {
|
||||||
|
local expected=$1
|
||||||
|
local body=
|
||||||
|
for _ in $(seq 1 150); do
|
||||||
|
kill -0 "$server_pid" 2>/dev/null || fail "Server exited during readiness check"
|
||||||
|
body=$(runtime_projection 2>/dev/null || true)
|
||||||
|
if [[ -n "$body" ]]; then
|
||||||
|
if printf '%s' "$body" | projection_is_ready 2>/dev/null; then
|
||||||
|
[[ "$expected" == ready ]] && return 0
|
||||||
|
else
|
||||||
|
[[ "$expected" == not-ready ]] && return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
printf '%s\n' "$body" >"$root/logs/last-runtime-projection.json"
|
||||||
|
fail "Runtime projection did not become $expected within 15 seconds"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_clean_logs() {
|
||||||
|
if grep -Eiq 'panicked at|thread .* panicked|UNIQUE constraint failed|worker_execution_restore_failed' \
|
||||||
|
"$root/logs/server.log" "$root/logs/runtime.log"; then
|
||||||
|
fail "panic, migration collision, or restore failure found in startup logs"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
start_server
|
||||||
|
wait_for_listener "$server_pid" "$server_port" Server
|
||||||
|
|
||||||
|
# Negative control: a listening Server is not readiness. The configured remote
|
||||||
|
# Runtime must be rejected while it is absent.
|
||||||
|
wait_for_projection_state not-ready
|
||||||
|
|
||||||
|
start_runtime
|
||||||
|
wait_for_listener "$runtime_pid" "$runtime_port" Runtime
|
||||||
|
wait_for_projection_state ready
|
||||||
|
assert_clean_logs
|
||||||
|
|
||||||
|
# Listener/catalog readiness is insufficient. Materialize a real Workdir and
|
||||||
|
# require the normal Server -> Runtime Worker spawn path to create a persisted
|
||||||
|
# Worker with an execution handle. This catches adapter panics that startup
|
||||||
|
# alone cannot observe.
|
||||||
|
repositories=$(curl --fail --silent --show-error \
|
||||||
|
"$server_url/api/w/$workspace_id/repositories")
|
||||||
|
repository_id=$(printf '%s' "$repositories" | node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const body = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||||
|
if (body.items.length !== 1) process.exit(1);
|
||||||
|
process.stdout.write(body.items[0].id);
|
||||||
|
')
|
||||||
|
workdir_response=$(curl --fail --silent --show-error \
|
||||||
|
--request POST \
|
||||||
|
--header 'content-type: application/json' \
|
||||||
|
--data "{\"runtime_id\":\"$runtime_id\",\"repository_id\":\"$repository_id\"}" \
|
||||||
|
"$server_url/api/w/$workspace_id/runtimes/$runtime_id/working-directories") || \
|
||||||
|
fail "isolated Workdir materialization failed"
|
||||||
|
working_directory_id=$(printf '%s' "$workdir_response" | node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const body = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||||
|
if (body.item?.status !== "active" || body.item?.cleanliness !== "clean") process.exit(1);
|
||||||
|
process.stdout.write(body.item.working_directory_id);
|
||||||
|
')
|
||||||
|
|
||||||
|
cat >"$root/worker-create.json" <<JSON
|
||||||
|
{
|
||||||
|
"runtime_id": "$runtime_id",
|
||||||
|
"display_name": "isolated restore smoke",
|
||||||
|
"profile": "builtin:companion",
|
||||||
|
"initial_submit": [],
|
||||||
|
"working_directory": {
|
||||||
|
"working_directory_id": "$working_directory_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
worker_response=$(curl --fail --silent --show-error \
|
||||||
|
--request POST \
|
||||||
|
--header 'content-type: application/json' \
|
||||||
|
--data @"$root/worker-create.json" \
|
||||||
|
"$server_url/api/w/$workspace_id/workers") || \
|
||||||
|
fail "isolated Worker spawn failed; listener readiness is not sufficient"
|
||||||
|
worker_id=$(printf '%s' "$worker_response" | node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const body = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||||
|
if (body.runtime_id !== process.argv[1] || !body.worker_id) process.exit(1);
|
||||||
|
process.stdout.write(String(body.worker_id));
|
||||||
|
' "$runtime_id")
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const record = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||||
|
const expectedBase = process.argv[2];
|
||||||
|
const profileUrl = record.request?.profile_source?.location?.url;
|
||||||
|
const workspaceUrl = record.request?.workspace_api?.base_url;
|
||||||
|
if (!profileUrl?.startsWith(`${expectedBase}/`)) {
|
||||||
|
console.error(`profile callback escaped isolated Server: ${profileUrl}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (workspaceUrl !== expectedBase) {
|
||||||
|
console.error(`Workspace API escaped isolated Server: ${workspaceUrl}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
' "$runtime_store/workers/$worker_id/worker.json" "$server_url" || \
|
||||||
|
fail "persisted Worker callback URLs are not isolated"
|
||||||
|
assert_clean_logs
|
||||||
|
|
||||||
|
# Exercise persistence reopen with a real persisted Worker and require the
|
||||||
|
# Server projection to recover. The Worker record must remain addressable after
|
||||||
|
# Runtime restart; restore failures and adapter panics are rejected by log scan.
|
||||||
|
stop_pid "$runtime_pid"
|
||||||
|
runtime_pid=
|
||||||
|
wait_for_projection_state not-ready
|
||||||
|
start_runtime
|
||||||
|
wait_for_listener "$runtime_pid" "$runtime_port" Runtime
|
||||||
|
wait_for_projection_state ready
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
"$server_url/api/w/$workspace_id/runtimes/$runtime_id/workers/$worker_id" \
|
||||||
|
>"$root/logs/restored-worker.json" || fail "persisted Worker is unavailable after Runtime restart"
|
||||||
|
assert_clean_logs
|
||||||
|
|
||||||
|
# Prove that this run used only disposable state paths.
|
||||||
|
grep -Fq "$root/data/yoi/server/server.db" "$root/logs/server.log" || \
|
||||||
|
fail "Server log does not identify the isolated database"
|
||||||
|
if grep -Fq '/home/hare/.local/share/yoi' "$root/logs/server.log" "$root/logs/runtime.log"; then
|
||||||
|
fail "startup logs reference a non-isolated Yoi data path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'isolated-startup-smoke: PASS (workspace=%s, server=%s, runtime=%s)\n' \
|
||||||
|
"$workspace_id" "$server_url" "$runtime_url"
|
||||||
@@ -238,7 +238,9 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
ticketDetailLoad.includes("/repositories") &&
|
ticketDetailLoad.includes("/repositories") &&
|
||||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||||
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
||||||
ticketDetailPage.includes('mutate("review", "/review"') &&
|
ticketDetailPage.includes("/merge-request/merge") &&
|
||||||
|
ticketDetailPage.includes("explicit_confirmation: true") &&
|
||||||
|
!ticketDetailPage.includes('mutate("review", "/review"') &&
|
||||||
ticketDetailPage.includes('mutate("close", "/close"') &&
|
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||||
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
||||||
ticketDetailPage.includes("ticket.relations.outgoing"),
|
ticketDetailPage.includes("ticket.relations.outgoing"),
|
||||||
|
|||||||
@@ -17,6 +17,16 @@
|
|||||||
TicketDetail,
|
TicketDetail,
|
||||||
} from "$lib/workspace/sidebar/types";
|
} from "$lib/workspace/sidebar/types";
|
||||||
|
|
||||||
|
type MergeRequestDetail = {
|
||||||
|
state: "draft" | "open" | "closed" | "merged";
|
||||||
|
review_status: "pending" | "approved" | "changes_requested";
|
||||||
|
current_revision: { revision_id: string; head_commit: string; head_tree: string; diff_digest: string; changed_paths: string[]; summary: string };
|
||||||
|
current_review?: { decision: string; body: string; reviewer_effective_profile: string } | null;
|
||||||
|
merged_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done");
|
||||||
|
|
||||||
const { data } = $props<{
|
const { data } = $props<{
|
||||||
data: {
|
data: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -24,6 +34,7 @@
|
|||||||
ticket: ApiResult<TicketDetail>;
|
ticket: ApiResult<TicketDetail>;
|
||||||
repositories: ApiResult<RepositoryListResponse>;
|
repositories: ApiResult<RepositoryListResponse>;
|
||||||
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
||||||
|
mergeRequest: ApiResult<MergeRequestDetail | null>;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -34,6 +45,7 @@
|
|||||||
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
|
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
|
||||||
|
|
||||||
let ticket = $state<TicketDetail>(loadedTicket);
|
let ticket = $state<TicketDetail>(loadedTicket);
|
||||||
|
let mergeRequest = $state<MergeRequestDetail | null>(initialData.mergeRequest.data ?? null);
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let editTitle = $state(loadedTicket.title);
|
let editTitle = $state(loadedTicket.title);
|
||||||
let editBody = $state(loadedTicket.body);
|
let editBody = $state(loadedTicket.body);
|
||||||
@@ -43,8 +55,7 @@
|
|||||||
let transitionReason = $state("");
|
let transitionReason = $state("");
|
||||||
let threadRole = $state("comment");
|
let threadRole = $state("comment");
|
||||||
let threadBody = $state("");
|
let threadBody = $state("");
|
||||||
let reviewResult = $state("approve");
|
let confirmMerge = $state(false);
|
||||||
let reviewBody = $state("");
|
|
||||||
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);
|
||||||
@@ -134,15 +145,27 @@
|
|||||||
) threadBody = "";
|
) threadBody = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function review(event: SubmitEvent) {
|
async function mergeConfirmedRevision() {
|
||||||
event.preventDefault();
|
if (!mergeRequest || !confirmMerge || busy) return;
|
||||||
if (!reviewBody.trim()) return;
|
busy = "merge";
|
||||||
if (
|
errorMessage = null;
|
||||||
await mutate("review", "/review", {
|
try {
|
||||||
result: reviewResult,
|
mergeRequest = await workspaceApiJsonWithBody<MergeRequestDetail>(
|
||||||
body: reviewBody.trim(),
|
`${ticketPath}/merge-request/merge`,
|
||||||
})
|
{
|
||||||
) reviewBody = "";
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision_id: mergeRequest.current_revision.revision_id,
|
||||||
|
explicit_confirmation: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
confirmMerge = false;
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
busy = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeTicket(event: SubmitEvent) {
|
async function closeTicket(event: SubmitEvent) {
|
||||||
@@ -277,7 +300,6 @@
|
|||||||
<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 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>
|
||||||
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "reviewer")}>Reviewer</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">Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers.</p>
|
||||||
@@ -311,7 +333,7 @@
|
|||||||
<form class="ticket-control-form" onsubmit={transition}>
|
<form class="ticket-control-form" onsubmit={transition}>
|
||||||
<label>State
|
<label>State
|
||||||
<select bind:value={nextState}>
|
<select bind:value={nextState}>
|
||||||
{#each TICKET_STATES as state}<option value={state}>{state}</option>{/each}
|
{#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Reason<input bind:value={transitionReason} placeholder="Optional decision context" /></label>
|
<label>Reason<input bind:value={transitionReason} placeholder="Optional decision context" /></label>
|
||||||
@@ -340,17 +362,27 @@
|
|||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="ticket-control-card">
|
<section class="ticket-control-card">
|
||||||
<summary>Record review</summary>
|
<header><h2>Merge Request</h2></header>
|
||||||
<form class="ticket-control-form" onsubmit={review}>
|
{#if data.mergeRequest.error}
|
||||||
<label>Result<select bind:value={reviewResult}>
|
<p class="workspace-callout is-error">{data.mergeRequest.error}</p>
|
||||||
<option value="approve">Approve</option>
|
{:else if mergeRequest}
|
||||||
<option value="request_changes">Request changes</option>
|
<p><strong>{mergeRequest.state}</strong> · {mergeRequest.review_status}</p>
|
||||||
</select></label>
|
<p><code>{mergeRequest.current_revision.revision_id}</code></p>
|
||||||
<label>Review body<textarea bind:value={reviewBody} rows="5" required></textarea></label>
|
<p>Head <code>{mergeRequest.current_revision.head_commit}</code></p>
|
||||||
<button class="workspace-secondary-button" type="submit" disabled={busy === "review" || !reviewBody.trim()}>Record review</button>
|
{#if mergeRequest.current_revision.summary}<p>{mergeRequest.current_revision.summary}</p>{/if}
|
||||||
</form>
|
{#if mergeRequest.current_review}
|
||||||
</details>
|
<p><strong>{mergeRequest.current_review.decision}</strong> by {mergeRequest.current_review.reviewer_effective_profile}</p>
|
||||||
|
{#if mergeRequest.current_review.body}<RichMarkdown text={mergeRequest.current_review.body} />{/if}
|
||||||
|
{/if}
|
||||||
|
{#if mergeRequest.state === "open" && mergeRequest.review_status === "approved"}
|
||||||
|
<label><input type="checkbox" bind:checked={confirmMerge} /> Explicitly confirm merge of this revision</label>
|
||||||
|
<button class="workspace-primary-button" type="button" disabled={!confirmMerge || busy !== null} onclick={mergeConfirmedRevision}>Confirm merge</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if ticket.state !== "closed"}
|
{#if ticket.state !== "closed"}
|
||||||
<details class="ticket-control-card ticket-close-card">
|
<details class="ticket-control-card ticket-close-card">
|
||||||
|
|||||||
@@ -1,35 +1,26 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||||
import type {
|
import type { RepositoryListResponse, TicketDetail } from "$lib/workspace/sidebar/types";
|
||||||
RepositoryListResponse,
|
|
||||||
TicketDetail,
|
|
||||||
} from "$lib/workspace/sidebar/types";
|
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load = (async ({ fetch, params }) => {
|
async function loadOptionalJson<T>(fetcher: typeof fetch, path: string): Promise<{ data: T | null; error: string | null }> {
|
||||||
const [ticket, repositories, orchestrator] = await Promise.all([
|
try {
|
||||||
loadJson<TicketDetail>(
|
const response = await fetcher(path);
|
||||||
fetch,
|
if (response.status === 404) return { data: null, error: null };
|
||||||
workspaceApiPath(
|
if (!response.ok) return { data: null, error: await response.text() || `HTTP ${response.status}` };
|
||||||
params.workspaceId,
|
return { data: await response.json() as T, error: null };
|
||||||
`/tickets/${encodeURIComponent(params.ticketId)}`,
|
} catch (error) {
|
||||||
),
|
return { data: null, error: error instanceof Error ? error.message : String(error) };
|
||||||
),
|
}
|
||||||
loadJson<RepositoryListResponse>(
|
}
|
||||||
fetch,
|
|
||||||
workspaceApiPath(params.workspaceId, "/repositories"),
|
|
||||||
),
|
|
||||||
loadJson<WorkspaceOrchestratorStatus>(
|
|
||||||
fetch,
|
|
||||||
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
export const load = (async ({ fetch, params }) => {
|
||||||
workspaceId: params.workspaceId,
|
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(params.ticketId)}`);
|
||||||
ticketId: params.ticketId,
|
const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([
|
||||||
ticket,
|
loadJson<TicketDetail>(fetch, ticketPath),
|
||||||
repositories,
|
loadJson<RepositoryListResponse>(fetch, workspaceApiPath(params.workspaceId, "/repositories")),
|
||||||
orchestrator,
|
loadJson<WorkspaceOrchestratorStatus>(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")),
|
||||||
};
|
loadOptionalJson<Record<string, unknown>>(fetch, `${ticketPath}/merge-request`),
|
||||||
|
]);
|
||||||
|
return { workspaceId: params.workspaceId, ticketId: params.ticketId, ticket, repositories, orchestrator, mergeRequest };
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
|
|||||||
Reference in New Issue
Block a user