Compare commits
2 Commits
2e0cd3d161
...
06d4adf198
| Author | SHA1 | Date | |
|---|---|---|---|
| 06d4adf198 | |||
| 2572dde691 |
|
|
@ -3,7 +3,6 @@
|
||||||
## このシステムに置ける設計要旨
|
## このシステムに置ける設計要旨
|
||||||
|
|
||||||
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。
|
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。
|
||||||
- E2E(実プロセスをスポーンさせてのテスト)は未設計。
|
|
||||||
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
||||||
|
|
||||||
### LLM コンテキストの加工原則
|
### LLM コンテキストの加工原則
|
||||||
|
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -5850,6 +5850,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"manifest",
|
"manifest",
|
||||||
|
"memory",
|
||||||
"project-record",
|
"project-record",
|
||||||
"protocol",
|
"protocol",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,13 @@ pub enum ScopeError {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Scope {
|
impl Scope {
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
allow: Vec::new(),
|
||||||
|
deny: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a [`Scope`] from a declarative [`ScopeConfig`].
|
/// Build a [`Scope`] from a declarative [`ScopeConfig`].
|
||||||
///
|
///
|
||||||
/// Every `target` in `config` must already be absolute — per-layer
|
/// Every `target` in `config` must already be absolute — per-layer
|
||||||
|
|
|
||||||
469
crates/memory/src/backend.rs
Normal file
469
crates/memory/src/backend.rs
Normal file
|
|
@ -0,0 +1,469 @@
|
||||||
|
//! Workspace-backend operations for Memory tools.
|
||||||
|
//!
|
||||||
|
//! These types are the typed HTTP boundary used by runtime workers that have
|
||||||
|
//! Workspace authority but no direct local filesystem authority. The local
|
||||||
|
//! executor intentionally lives in the memory crate so the workspace backend and
|
||||||
|
//! future non-HTTP hosts share validation and path semantics.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::extract::{ExtractedPayload, write_staging};
|
||||||
|
use crate::schema::SourceRef;
|
||||||
|
use crate::tool::MemoryToolKind;
|
||||||
|
use crate::workspace::WorkspaceLayout;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "operation", rename_all = "snake_case")]
|
||||||
|
pub enum MemoryBackendOperation {
|
||||||
|
Query(MemoryQueryOperation),
|
||||||
|
Read(MemoryReadOperation),
|
||||||
|
Write(MemoryWriteOperation),
|
||||||
|
Edit(MemoryEditOperation),
|
||||||
|
Delete(MemoryDeleteOperation),
|
||||||
|
StageExtracted(MemoryStageExtractedOperation),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "status", rename_all = "snake_case")]
|
||||||
|
pub enum MemoryBackendHttpResponse {
|
||||||
|
Ok {
|
||||||
|
result: MemoryBackendOperationResult,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum MemoryBackendOperationResult {
|
||||||
|
ToolOutput(MemoryToolOutput),
|
||||||
|
StagingWritten(MemoryStagingWriteOutput),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryToolOutput {
|
||||||
|
pub summary: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryQueryOperation {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub query: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryReadOperation {
|
||||||
|
pub kind: MemoryToolKind,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub slug: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub offset: Option<usize>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryWriteOperation {
|
||||||
|
pub kind: MemoryToolKind,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryEditOperation {
|
||||||
|
pub kind: MemoryToolKind,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub old_string: String,
|
||||||
|
pub new_string: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub replace_all: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryDeleteOperation {
|
||||||
|
pub kind: MemoryToolKind,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub slug: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryStageExtractedOperation {
|
||||||
|
pub source: SourceRef,
|
||||||
|
pub payload: ExtractedPayload,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryStagingWriteOutput {
|
||||||
|
pub staging_count: usize,
|
||||||
|
pub staging_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_memory_backend_operation(
|
||||||
|
layout: &WorkspaceLayout,
|
||||||
|
operation: MemoryBackendOperation,
|
||||||
|
) -> io::Result<MemoryBackendOperationResult> {
|
||||||
|
match operation {
|
||||||
|
MemoryBackendOperation::Query(operation) => {
|
||||||
|
execute_query(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||||
|
}
|
||||||
|
MemoryBackendOperation::Read(operation) => {
|
||||||
|
execute_read(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||||
|
}
|
||||||
|
MemoryBackendOperation::Write(operation) => {
|
||||||
|
execute_write(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||||
|
}
|
||||||
|
MemoryBackendOperation::Edit(operation) => {
|
||||||
|
execute_edit(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||||
|
}
|
||||||
|
MemoryBackendOperation::Delete(operation) => {
|
||||||
|
execute_delete(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||||
|
}
|
||||||
|
MemoryBackendOperation::StageExtracted(operation) => {
|
||||||
|
let written = write_staging(layout, operation.source, operation.payload)?;
|
||||||
|
Ok(MemoryBackendOperationResult::StagingWritten(
|
||||||
|
MemoryStagingWriteOutput {
|
||||||
|
staging_count: written.len(),
|
||||||
|
staging_ids: written.iter().map(|item| item.id.to_string()).collect(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_query(
|
||||||
|
layout: &WorkspaceLayout,
|
||||||
|
operation: MemoryQueryOperation,
|
||||||
|
) -> io::Result<MemoryToolOutput> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for kind in [
|
||||||
|
MemoryToolKind::Summary,
|
||||||
|
MemoryToolKind::Decision,
|
||||||
|
MemoryToolKind::Request,
|
||||||
|
] {
|
||||||
|
entries.extend(query_kind(layout, kind, operation.query.as_deref())?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
let suffix = operation
|
||||||
|
.query
|
||||||
|
.as_deref()
|
||||||
|
.map(|query| format!(" matching {query:?}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
return Ok(MemoryToolOutput {
|
||||||
|
summary: format!("No memory records found{suffix}"),
|
||||||
|
content: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = entries.join("\n\n");
|
||||||
|
Ok(tool_output_from_string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_kind(
|
||||||
|
layout: &WorkspaceLayout,
|
||||||
|
kind: MemoryToolKind,
|
||||||
|
query: Option<&str>,
|
||||||
|
) -> io::Result<Vec<String>> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
match kind {
|
||||||
|
MemoryToolKind::Summary => {
|
||||||
|
let path = memory_path(layout, kind, None)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(entries);
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
if matches_query(&content, query) {
|
||||||
|
entries.push(format!("kind=summary\n{}", excerpt(&content, query)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MemoryToolKind::Decision | MemoryToolKind::Request => {
|
||||||
|
let dir = record_dir(layout, kind);
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(entries);
|
||||||
|
}
|
||||||
|
let mut files: Vec<PathBuf> = fs::read_dir(&dir)?
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
|
||||||
|
.collect();
|
||||||
|
files.sort();
|
||||||
|
for path in files {
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
if matches_query(&content, query) {
|
||||||
|
let slug = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
entries.push(format!(
|
||||||
|
"kind={} slug={}\n{}",
|
||||||
|
kind.as_str(),
|
||||||
|
slug,
|
||||||
|
excerpt(&content, query)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_read(
|
||||||
|
layout: &WorkspaceLayout,
|
||||||
|
operation: MemoryReadOperation,
|
||||||
|
) -> io::Result<MemoryToolOutput> {
|
||||||
|
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
let offset = operation.offset.unwrap_or(0);
|
||||||
|
let limit = operation.limit.unwrap_or(2000);
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
let selected = lines
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip(offset)
|
||||||
|
.take(limit)
|
||||||
|
.map(|(index, line)| format!("{:>6}\t{}", index + 1, line))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let total = lines.len();
|
||||||
|
Ok(MemoryToolOutput {
|
||||||
|
summary: format!(
|
||||||
|
"Read {} lines from {}{}",
|
||||||
|
selected.lines().count(),
|
||||||
|
operation.kind.as_str(),
|
||||||
|
operation
|
||||||
|
.slug
|
||||||
|
.as_deref()
|
||||||
|
.map(|slug| format!("/{slug}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
),
|
||||||
|
content: Some(format!(
|
||||||
|
"total_lines={total} offset={offset} limit={limit}\n{selected}"
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_write(
|
||||||
|
layout: &WorkspaceLayout,
|
||||||
|
operation: MemoryWriteOperation,
|
||||||
|
) -> io::Result<MemoryToolOutput> {
|
||||||
|
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
||||||
|
validate_slug_rules(operation.kind, operation.slug.as_deref())?;
|
||||||
|
validate_memory_content(
|
||||||
|
operation.kind,
|
||||||
|
operation.slug.as_deref(),
|
||||||
|
&operation.content,
|
||||||
|
)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(&path, operation.content)?;
|
||||||
|
Ok(MemoryToolOutput {
|
||||||
|
summary: format!("Wrote {} record", operation.kind.as_str()),
|
||||||
|
content: Some(path.display().to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_edit(
|
||||||
|
layout: &WorkspaceLayout,
|
||||||
|
operation: MemoryEditOperation,
|
||||||
|
) -> io::Result<MemoryToolOutput> {
|
||||||
|
validate_slug_rules(operation.kind, operation.slug.as_deref())?;
|
||||||
|
if operation.old_string == operation.new_string {
|
||||||
|
return Err(invalid_input("old_string and new_string must differ"));
|
||||||
|
}
|
||||||
|
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
||||||
|
let original = fs::read_to_string(&path)?;
|
||||||
|
let count = original.matches(&operation.old_string).count();
|
||||||
|
if count == 0 {
|
||||||
|
return Err(invalid_input("old_string was not found"));
|
||||||
|
}
|
||||||
|
if count > 1 && !operation.replace_all {
|
||||||
|
return Err(invalid_input(
|
||||||
|
"old_string matched more than once; set replace_all=true to replace every occurrence",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let updated = if operation.replace_all {
|
||||||
|
original.replace(&operation.old_string, &operation.new_string)
|
||||||
|
} else {
|
||||||
|
original.replacen(&operation.old_string, &operation.new_string, 1)
|
||||||
|
};
|
||||||
|
validate_memory_content(operation.kind, operation.slug.as_deref(), &updated)?;
|
||||||
|
fs::write(&path, updated)?;
|
||||||
|
Ok(MemoryToolOutput {
|
||||||
|
summary: format!(
|
||||||
|
"Edited {} occurrence(s)",
|
||||||
|
if operation.replace_all { count } else { 1 }
|
||||||
|
),
|
||||||
|
content: Some(path.display().to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_delete(
|
||||||
|
layout: &WorkspaceLayout,
|
||||||
|
operation: MemoryDeleteOperation,
|
||||||
|
) -> io::Result<MemoryToolOutput> {
|
||||||
|
validate_slug_rules(operation.kind, operation.slug.as_deref())?;
|
||||||
|
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
||||||
|
fs::remove_file(&path)?;
|
||||||
|
Ok(MemoryToolOutput {
|
||||||
|
summary: format!("Deleted {} record", operation.kind.as_str()),
|
||||||
|
content: Some(path.display().to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn memory_path(
|
||||||
|
layout: &WorkspaceLayout,
|
||||||
|
kind: MemoryToolKind,
|
||||||
|
slug: Option<&str>,
|
||||||
|
) -> io::Result<PathBuf> {
|
||||||
|
match kind {
|
||||||
|
MemoryToolKind::Summary => {
|
||||||
|
if slug.is_some() {
|
||||||
|
return Err(invalid_input("summary records do not accept slug"));
|
||||||
|
}
|
||||||
|
Ok(layout.memory_dir().join("summary.md"))
|
||||||
|
}
|
||||||
|
MemoryToolKind::Decision => {
|
||||||
|
Ok(record_dir(layout, kind).join(format!("{}.md", required_slug(slug)?)))
|
||||||
|
}
|
||||||
|
MemoryToolKind::Request => {
|
||||||
|
Ok(record_dir(layout, kind).join(format!("{}.md", required_slug(slug)?)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_dir(layout: &WorkspaceLayout, kind: MemoryToolKind) -> PathBuf {
|
||||||
|
match kind {
|
||||||
|
MemoryToolKind::Summary => layout.memory_dir(),
|
||||||
|
MemoryToolKind::Decision => layout.memory_dir().join("decisions"),
|
||||||
|
MemoryToolKind::Request => layout.memory_dir().join("requests"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_slug(slug: Option<&str>) -> io::Result<&str> {
|
||||||
|
slug.filter(|value| !value.trim().is_empty())
|
||||||
|
.ok_or_else(|| invalid_input("slug is required for this memory kind"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_slug_rules(kind: MemoryToolKind, slug: Option<&str>) -> io::Result<()> {
|
||||||
|
match kind {
|
||||||
|
MemoryToolKind::Summary => {
|
||||||
|
if slug.is_some() {
|
||||||
|
return Err(invalid_input("summary records do not accept slug"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MemoryToolKind::Decision | MemoryToolKind::Request => {
|
||||||
|
validate_slug(required_slug(slug)?)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_slug(slug: &str) -> io::Result<()> {
|
||||||
|
if slug.is_empty()
|
||||||
|
|| slug.contains('/')
|
||||||
|
|| slug.contains('\\')
|
||||||
|
|| slug == "."
|
||||||
|
|| slug == ".."
|
||||||
|
|| slug.starts_with('.')
|
||||||
|
|| !slug
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
||||||
|
{
|
||||||
|
return Err(invalid_input(
|
||||||
|
"slug must use lowercase ASCII letters, digits, and '-' only",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_memory_content(
|
||||||
|
kind: MemoryToolKind,
|
||||||
|
slug: Option<&str>,
|
||||||
|
content: &str,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return Err(invalid_input("content must not be empty"));
|
||||||
|
}
|
||||||
|
match kind {
|
||||||
|
MemoryToolKind::Summary => {
|
||||||
|
if !content.trim_start().starts_with("# Memory summary") {
|
||||||
|
return Err(invalid_input(
|
||||||
|
"summary content must start with '# Memory summary'",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MemoryToolKind::Decision | MemoryToolKind::Request => {
|
||||||
|
validate_slug_rules(kind, slug)?;
|
||||||
|
if !content.trim_start().starts_with("---") {
|
||||||
|
return Err(invalid_input(
|
||||||
|
"memory record content must start with YAML frontmatter",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_query(content: &str, query: Option<&str>) -> bool {
|
||||||
|
match query.map(str::trim).filter(|query| !query.is_empty()) {
|
||||||
|
Some(query) => content.to_lowercase().contains(&query.to_lowercase()),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn excerpt(content: &str, query: Option<&str>) -> String {
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else {
|
||||||
|
return lines
|
||||||
|
.iter()
|
||||||
|
.take(20)
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
};
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let hit = lines
|
||||||
|
.iter()
|
||||||
|
.position(|line| line.to_lowercase().contains(&query_lower))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let start = hit.saturating_sub(2);
|
||||||
|
let end = (hit + 3).min(lines.len());
|
||||||
|
lines[start..end].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_output_from_string(value: String) -> MemoryToolOutput {
|
||||||
|
const SUMMARY_THRESHOLD: usize = 200;
|
||||||
|
if value.len() <= SUMMARY_THRESHOLD {
|
||||||
|
MemoryToolOutput {
|
||||||
|
summary: value,
|
||||||
|
content: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let lines = value.lines().count();
|
||||||
|
let first_line: String = value
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.chars()
|
||||||
|
.take(80)
|
||||||
|
.collect();
|
||||||
|
MemoryToolOutput {
|
||||||
|
summary: format!("{lines} lines | {first_line}…"),
|
||||||
|
content: Some(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_input(message: impl Into<String>) -> io::Error {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidInput, message.into())
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
//! Worker is responsible for denying it at the Scope level when memory is enabled.
|
//! Worker is responsible for denying it at the Scope level when memory is enabled.
|
||||||
|
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
pub mod backend;
|
||||||
pub mod consolidate;
|
pub mod consolidate;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod extract;
|
pub mod extract;
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ pub use write::write_tool;
|
||||||
|
|
||||||
/// Kinds the memory tools accept as input. `Workflow` is intentionally
|
/// Kinds the memory tools accept as input. `Workflow` is intentionally
|
||||||
/// excluded — workflows are sub-Engine context, not agent-editable.
|
/// excluded — workflows are sub-Engine context, not agent-editable.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
|
#[derive(
|
||||||
|
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize, schemars::JsonSchema,
|
||||||
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum MemoryToolKind {
|
pub enum MemoryToolKind {
|
||||||
Summary,
|
Summary,
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||||
) -> Result<WorkerHandle, String> {
|
) -> Result<WorkerHandle, String> {
|
||||||
let worker_name = Self::runtime_worker_name(&request);
|
let worker_name = Self::runtime_worker_name(&request);
|
||||||
let profile = self.runtime_profile(&request);
|
let profile = self.runtime_profile(&request);
|
||||||
|
let has_local_filesystem = request.working_directory.is_some();
|
||||||
let worker_root = request
|
let worker_root = request
|
||||||
.working_directory
|
.working_directory
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -385,17 +386,24 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||||
let archive = self
|
let archive = self
|
||||||
.resolve_profile_source_archive(&request.request.profile_source)
|
.resolve_profile_source_archive(&request.request.profile_source)
|
||||||
.await?;
|
.await?;
|
||||||
let (mut manifest, loader) = {
|
let (manifest, loader) = {
|
||||||
let manifest = archive
|
let manifest = archive
|
||||||
.resolve_profile(selector, &worker_root, &worker_name)
|
.resolve_profile(selector, &worker_root, &worker_name)
|
||||||
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
|
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
|
||||||
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest(
|
if has_local_filesystem {
|
||||||
manifest,
|
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest(
|
||||||
&worker_root,
|
manifest,
|
||||||
&worker_name,
|
&worker_root,
|
||||||
)?
|
&worker_name,
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest_without_filesystem(
|
||||||
|
manifest,
|
||||||
|
&worker_root,
|
||||||
|
&worker_name,
|
||||||
|
)?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
manifest.worker.name = worker_name;
|
|
||||||
|
|
||||||
let store_dir = self.store_dir()?;
|
let store_dir = self.store_dir()?;
|
||||||
let session_store = FsStore::new(&store_dir).map_err(|err| {
|
let session_store = FsStore::new(&store_dir).map_err(|err| {
|
||||||
|
|
|
||||||
|
|
@ -726,34 +726,46 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
let workspace_client = worker.workspace_client().clone();
|
||||||
let worker = worker.engine_mut();
|
let worker = worker.engine_mut();
|
||||||
|
|
||||||
// Memory tools require both explicit feature exposure and memory storage
|
// Memory tools require explicit feature exposure. Storage access may be
|
||||||
// configuration. This keeps resident-memory config separate from the
|
// provided by local filesystem authority or the path-free Workspace HTTP API.
|
||||||
// model-visible Memory* tool surface.
|
|
||||||
if feature_config.memory.enabled {
|
if feature_config.memory.enabled {
|
||||||
let mem = memory_config.as_ref().ok_or_else(|| {
|
if let Some(workspace_root) = local_workspace_root.as_ref() {
|
||||||
std::io::Error::new(
|
let mem = memory_config.as_ref().ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"[feature.memory].enabled = true requires a [memory] configuration section",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let layout = memory::WorkspaceLayout::resolve(mem, workspace_root);
|
||||||
|
let query_cfg = memory::tool::QueryConfig::from(mem);
|
||||||
|
worker.register_tool(memory::tool::read_tool_with_usage(
|
||||||
|
layout.clone(),
|
||||||
|
session_id_for_usage,
|
||||||
|
));
|
||||||
|
worker.register_tool(memory::tool::write_tool(layout.clone()));
|
||||||
|
worker.register_tool(memory::tool::edit_tool(layout.clone()));
|
||||||
|
worker.register_tool(memory::tool::delete_tool(layout.clone()));
|
||||||
|
worker.register_tool(memory::tool::memory_query_tool(layout, query_cfg));
|
||||||
|
} else if let WorkspaceClient::Http {
|
||||||
|
workspace_id,
|
||||||
|
base_url,
|
||||||
|
} = workspace_client
|
||||||
|
{
|
||||||
|
for definition in crate::feature::builtin::memory::workspace_http_memory_tools(
|
||||||
|
workspace_id,
|
||||||
|
base_url,
|
||||||
|
) {
|
||||||
|
worker.register_tool(definition);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidInput,
|
std::io::ErrorKind::InvalidInput,
|
||||||
"[feature.memory].enabled = true requires a [memory] configuration section",
|
"memory tools require Workspace HTTP API or local Worker filesystem authority",
|
||||||
)
|
));
|
||||||
})?;
|
}
|
||||||
let workspace_root = local_workspace_root.as_ref().ok_or_else(|| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
"memory tools require local Worker filesystem authority",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let layout = memory::WorkspaceLayout::resolve(mem, workspace_root);
|
|
||||||
let query_cfg = memory::tool::QueryConfig::from(mem);
|
|
||||||
worker.register_tool(memory::tool::read_tool_with_usage(
|
|
||||||
layout.clone(),
|
|
||||||
session_id_for_usage,
|
|
||||||
));
|
|
||||||
worker.register_tool(memory::tool::write_tool(layout.clone()));
|
|
||||||
worker.register_tool(memory::tool::edit_tool(layout.clone()));
|
|
||||||
worker.register_tool(memory::tool::delete_tool(layout.clone()));
|
|
||||||
worker.register_tool(memory::tool::memory_query_tool(layout, query_cfg));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker-orchestration tools (SpawnWorker + the four comm tools) share
|
// Worker-orchestration tools (SpawnWorker + the four comm tools) share
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,20 @@ pub fn resolve_runtime_profile_manifest_from_manifest(
|
||||||
Ok((manifest, PromptLoader::builtins_only()))
|
Ok((manifest, PromptLoader::builtins_only()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem(
|
||||||
|
mut manifest: WorkerManifest,
|
||||||
|
workspace_root: &Path,
|
||||||
|
worker_name: &str,
|
||||||
|
) -> Result<(WorkerManifest, PromptLoader), String> {
|
||||||
|
if manifest.worker.name.is_empty() {
|
||||||
|
manifest.worker.name = worker_name.to_string();
|
||||||
|
}
|
||||||
|
manifest.scope = ScopeConfig::default();
|
||||||
|
manifest.delegation_scope = ScopeConfig::default();
|
||||||
|
apply_plugin_resolution_plan(&mut manifest, workspace_root);
|
||||||
|
Ok((manifest, PromptLoader::builtins_only()))
|
||||||
|
}
|
||||||
|
|
||||||
fn load_single_manifest(
|
fn load_single_manifest(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
explicit_worker_name: Option<&str>,
|
explicit_worker_name: Option<&str>,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
//! same descriptor-approved registry path used by feature modules. They are not
|
//! same descriptor-approved registry path used by feature modules. They are not
|
||||||
//! an external plugin-loading surface.
|
//! an external plugin-loading surface.
|
||||||
|
|
||||||
|
pub mod memory;
|
||||||
pub mod session_explore;
|
pub mod session_explore;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod ticket;
|
pub mod ticket;
|
||||||
|
|
|
||||||
261
crates/worker/src/feature/builtin/memory.rs
Normal file
261
crates/worker/src/feature/builtin/memory.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
//! Workspace-HTTP backed Memory tools.
|
||||||
|
//!
|
||||||
|
//! Runtime workers may have Workspace authority without direct local filesystem
|
||||||
|
//! authority. In that case model-visible Memory tools must go through the
|
||||||
|
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use llm_engine::tool::{
|
||||||
|
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
|
||||||
|
};
|
||||||
|
use memory::backend::{
|
||||||
|
MemoryBackendHttpResponse, MemoryBackendOperation, MemoryBackendOperationResult,
|
||||||
|
MemoryDeleteOperation, MemoryEditOperation, MemoryQueryOperation, MemoryReadOperation,
|
||||||
|
MemoryToolOutput, MemoryWriteOperation,
|
||||||
|
};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct WorkspaceHttpMemoryBackend {
|
||||||
|
workspace_id: String,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceHttpMemoryBackend {
|
||||||
|
pub fn new(workspace_id: impl Into<String>, base_url: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
workspace_id: workspace_id.into(),
|
||||||
|
base_url: base_url.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(&self, operation: MemoryBackendOperation) -> Result<ToolOutput, ToolError> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/w/{}/memory/backend",
|
||||||
|
self.base_url.trim_end_matches('/'),
|
||||||
|
self.workspace_id
|
||||||
|
);
|
||||||
|
let response = reqwest::blocking::Client::new()
|
||||||
|
.post(url)
|
||||||
|
.json(&operation)
|
||||||
|
.send()
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"workspace memory backend returned HTTP {status}: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let response: MemoryBackendHttpResponse = serde_json::from_str(&body).map_err(|error| {
|
||||||
|
ToolError::ExecutionFailed(format!("decode memory backend response: {error}"))
|
||||||
|
})?;
|
||||||
|
match response {
|
||||||
|
MemoryBackendHttpResponse::Ok {
|
||||||
|
result: MemoryBackendOperationResult::ToolOutput(output),
|
||||||
|
} => Ok(tool_output(output)),
|
||||||
|
MemoryBackendHttpResponse::Ok { result } => Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"unexpected memory backend result for model-visible tool: {result:?}"
|
||||||
|
))),
|
||||||
|
MemoryBackendHttpResponse::Error { message } => {
|
||||||
|
Err(ToolError::ExecutionFailed(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_http_memory_tools(
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
) -> Vec<ToolDefinition> {
|
||||||
|
let backend = WorkspaceHttpMemoryBackend::new(workspace_id, base_url);
|
||||||
|
vec![
|
||||||
|
memory_tool(
|
||||||
|
"MemoryRead",
|
||||||
|
READ_DESCRIPTION,
|
||||||
|
read_schema(),
|
||||||
|
backend.clone(),
|
||||||
|
|input| {
|
||||||
|
Ok(MemoryBackendOperation::Read(parse_input::<
|
||||||
|
MemoryReadOperation,
|
||||||
|
>(input)?))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
memory_tool(
|
||||||
|
"MemoryWrite",
|
||||||
|
WRITE_DESCRIPTION,
|
||||||
|
write_schema(),
|
||||||
|
backend.clone(),
|
||||||
|
|input| {
|
||||||
|
Ok(MemoryBackendOperation::Write(parse_input::<
|
||||||
|
MemoryWriteOperation,
|
||||||
|
>(input)?))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
memory_tool(
|
||||||
|
"MemoryEdit",
|
||||||
|
EDIT_DESCRIPTION,
|
||||||
|
edit_schema(),
|
||||||
|
backend.clone(),
|
||||||
|
|input| {
|
||||||
|
Ok(MemoryBackendOperation::Edit(parse_input::<
|
||||||
|
MemoryEditOperation,
|
||||||
|
>(input)?))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
memory_tool(
|
||||||
|
"MemoryDelete",
|
||||||
|
DELETE_DESCRIPTION,
|
||||||
|
delete_schema(),
|
||||||
|
backend.clone(),
|
||||||
|
|input| {
|
||||||
|
Ok(MemoryBackendOperation::Delete(parse_input::<
|
||||||
|
MemoryDeleteOperation,
|
||||||
|
>(input)?))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
memory_tool(
|
||||||
|
"MemoryQuery",
|
||||||
|
QUERY_DESCRIPTION,
|
||||||
|
query_schema(),
|
||||||
|
backend,
|
||||||
|
|input| {
|
||||||
|
Ok(MemoryBackendOperation::Query(parse_input::<
|
||||||
|
MemoryQueryOperation,
|
||||||
|
>(input)?))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationBuilder = fn(&str) -> Result<MemoryBackendOperation, ToolError>;
|
||||||
|
|
||||||
|
fn memory_tool(
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
schema: serde_json::Value,
|
||||||
|
backend: WorkspaceHttpMemoryBackend,
|
||||||
|
build: OperationBuilder,
|
||||||
|
) -> ToolDefinition {
|
||||||
|
Arc::new(move || {
|
||||||
|
(
|
||||||
|
ToolMeta::new(name)
|
||||||
|
.description(description)
|
||||||
|
.input_schema(schema.clone()),
|
||||||
|
Arc::new(WorkspaceHttpMemoryTool {
|
||||||
|
backend: backend.clone(),
|
||||||
|
build,
|
||||||
|
}) as Arc<dyn Tool>,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct WorkspaceHttpMemoryTool {
|
||||||
|
backend: WorkspaceHttpMemoryBackend,
|
||||||
|
build: OperationBuilder,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for WorkspaceHttpMemoryTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input_json: &str,
|
||||||
|
_ctx: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let operation = (self.build)(input_json)?;
|
||||||
|
self.backend.execute(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_input<T: DeserializeOwned>(input: &str) -> Result<T, ToolError> {
|
||||||
|
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_output(output: MemoryToolOutput) -> ToolOutput {
|
||||||
|
ToolOutput {
|
||||||
|
summary: output.summary,
|
||||||
|
content: output.content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const READ_DESCRIPTION: &str = "Read a durable memory record through Workspace authority.";
|
||||||
|
const WRITE_DESCRIPTION: &str =
|
||||||
|
"Create or overwrite a durable memory record through Workspace authority.";
|
||||||
|
const EDIT_DESCRIPTION: &str =
|
||||||
|
"Replace text in a durable memory record through Workspace authority.";
|
||||||
|
const DELETE_DESCRIPTION: &str = "Delete a durable memory record through Workspace authority.";
|
||||||
|
const QUERY_DESCRIPTION: &str = "Query durable memory records through Workspace authority.";
|
||||||
|
|
||||||
|
fn kind_schema() -> serde_json::Value {
|
||||||
|
json!({"type":"string","enum":["summary","decision","request"]})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_schema() -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type":"object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required":["kind"],
|
||||||
|
"properties":{
|
||||||
|
"kind": kind_schema(),
|
||||||
|
"slug":{"type":["string","null"]},
|
||||||
|
"offset":{"type":["integer","null"],"minimum":0},
|
||||||
|
"limit":{"type":["integer","null"],"minimum":0}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_schema() -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type":"object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required":["kind","content"],
|
||||||
|
"properties":{
|
||||||
|
"kind": kind_schema(),
|
||||||
|
"slug":{"type":["string","null"]},
|
||||||
|
"content":{"type":"string"}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_schema() -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type":"object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required":["kind","old_string","new_string"],
|
||||||
|
"properties":{
|
||||||
|
"kind": kind_schema(),
|
||||||
|
"slug":{"type":["string","null"]},
|
||||||
|
"old_string":{"type":"string"},
|
||||||
|
"new_string":{"type":"string"},
|
||||||
|
"replace_all":{"type":"boolean","default":false}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_schema() -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type":"object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required":["kind"],
|
||||||
|
"properties":{
|
||||||
|
"kind": kind_schema(),
|
||||||
|
"slug":{"type":["string","null"]}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_schema() -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type":"object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties":{
|
||||||
|
"query":{"type":["string","null"]}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -4971,7 +4971,11 @@ fn prepare_worker_common_with_context(
|
||||||
let layout = memory::WorkspaceLayout::resolve(mem, &local.root);
|
let layout = memory::WorkspaceLayout::resolve(mem, &local.root);
|
||||||
scope_config.deny.extend(memory::deny_write_rules(&layout));
|
scope_config.deny.extend(memory::deny_write_rules(&layout));
|
||||||
}
|
}
|
||||||
let scope = Scope::from_config(&scope_config).map_err(WorkerError::Scope)?;
|
let scope = if scope_config.allow.is_empty() && filesystem_authority.as_local().is_none() {
|
||||||
|
Scope::empty()
|
||||||
|
} else {
|
||||||
|
Scope::from_config(&scope_config).map_err(WorkerError::Scope)?
|
||||||
|
};
|
||||||
prepare_worker_common_from_scope(
|
prepare_worker_common_from_scope(
|
||||||
manifest,
|
manifest,
|
||||||
loader,
|
loader,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ serde_yaml.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
ticket.workspace = true
|
ticket.workspace = true
|
||||||
|
memory.workspace = true
|
||||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
|
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
worker.workspace = true
|
worker.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1449,64 +1449,51 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||||
|
|
||||||
fn create_working_directory(
|
fn create_working_directory(
|
||||||
&self,
|
&self,
|
||||||
request: WorkingDirectoryRequest,
|
_request: WorkingDirectoryRequest,
|
||||||
) -> RuntimeWorkingDirectoryResult {
|
) -> RuntimeWorkingDirectoryResult {
|
||||||
match self.runtime.create_working_directory(request) {
|
RuntimeWorkingDirectoryResult {
|
||||||
Ok(working_directory) => RuntimeWorkingDirectoryResult {
|
state: WorkerOperationState::Rejected,
|
||||||
state: WorkerOperationState::Accepted,
|
working_directory: None,
|
||||||
working_directory: Some(working_directory),
|
diagnostics: vec![embedded_workdir_unsupported_diagnostic()],
|
||||||
diagnostics: Vec::new(),
|
|
||||||
},
|
|
||||||
Err(err) => RuntimeWorkingDirectoryResult {
|
|
||||||
state: WorkerOperationState::Rejected,
|
|
||||||
working_directory: None,
|
|
||||||
diagnostics: vec![embedded_runtime_diagnostic(&err)],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||||
match self.runtime.list_working_directories() {
|
RuntimeList::new(Vec::new(), Vec::new())
|
||||||
Ok(items) => RuntimeList::new(items, Vec::new()),
|
|
||||||
Err(err) => RuntimeList::new(Vec::new(), vec![embedded_runtime_diagnostic(&err)]),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn working_directory(&self, working_directory_id: &str) -> RuntimeWorkingDirectoryResult {
|
fn working_directory(&self, _working_directory_id: &str) -> RuntimeWorkingDirectoryResult {
|
||||||
match self.runtime.working_directory(working_directory_id) {
|
RuntimeWorkingDirectoryResult {
|
||||||
Ok(working_directory) => RuntimeWorkingDirectoryResult {
|
state: WorkerOperationState::Rejected,
|
||||||
state: WorkerOperationState::Accepted,
|
working_directory: None,
|
||||||
working_directory: Some(working_directory),
|
diagnostics: vec![embedded_workdir_unsupported_diagnostic()],
|
||||||
diagnostics: Vec::new(),
|
|
||||||
},
|
|
||||||
Err(err) => RuntimeWorkingDirectoryResult {
|
|
||||||
state: WorkerOperationState::Rejected,
|
|
||||||
working_directory: None,
|
|
||||||
diagnostics: vec![embedded_runtime_diagnostic(&err)],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cleanup_working_directory(
|
fn cleanup_working_directory(
|
||||||
&self,
|
&self,
|
||||||
working_directory_id: &str,
|
_working_directory_id: &str,
|
||||||
) -> RuntimeWorkingDirectoryResult {
|
) -> RuntimeWorkingDirectoryResult {
|
||||||
match self.runtime.cleanup_working_directory(working_directory_id) {
|
RuntimeWorkingDirectoryResult {
|
||||||
Ok(working_directory) => RuntimeWorkingDirectoryResult {
|
state: WorkerOperationState::Rejected,
|
||||||
state: WorkerOperationState::Accepted,
|
working_directory: None,
|
||||||
working_directory: Some(working_directory),
|
diagnostics: vec![embedded_workdir_unsupported_diagnostic()],
|
||||||
diagnostics: Vec::new(),
|
|
||||||
},
|
|
||||||
Err(err) => RuntimeWorkingDirectoryResult {
|
|
||||||
state: WorkerOperationState::Rejected,
|
|
||||||
working_directory: None,
|
|
||||||
diagnostics: vec![embedded_runtime_diagnostic(&err)],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult {
|
fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult {
|
||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
|
if request.resolved_working_directory_request.is_some()
|
||||||
|
|| request.resolved_working_directory.is_some()
|
||||||
|
{
|
||||||
|
diagnostics.push(embedded_workdir_unsupported_diagnostic());
|
||||||
|
return WorkerSpawnResult {
|
||||||
|
state: WorkerOperationState::Rejected,
|
||||||
|
worker: None,
|
||||||
|
acceptance_evidence: Vec::new(),
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
request.acceptance,
|
request.acceptance,
|
||||||
WorkerSpawnAcceptanceRequirement::SocketReady
|
WorkerSpawnAcceptanceRequirement::SocketReady
|
||||||
|
|
@ -2647,12 +2634,15 @@ fn embedded_spawn_execution_failure_diagnostic(
|
||||||
worker_runtime::execution::WorkerExecutionOutcome::Unsupported => "unsupported",
|
worker_runtime::execution::WorkerExecutionOutcome::Unsupported => "unsupported",
|
||||||
worker_runtime::execution::WorkerExecutionOutcome::Errored => "errored",
|
worker_runtime::execution::WorkerExecutionOutcome::Errored => "errored",
|
||||||
};
|
};
|
||||||
|
let detail = result
|
||||||
|
.message
|
||||||
|
.as_deref()
|
||||||
|
.map(|message| format!(": {message}"))
|
||||||
|
.unwrap_or_default();
|
||||||
Some(diagnostic(
|
Some(diagnostic(
|
||||||
format!("embedded_worker_execution_spawn_{status}"),
|
format!("embedded_worker_execution_spawn_{status}"),
|
||||||
severity,
|
severity,
|
||||||
format!(
|
format!("Embedded Worker execution spawn was {status} during setup{detail}"),
|
||||||
"Embedded Worker execution spawn was {status} during setup; check runtime configuration"
|
|
||||||
),
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3006,6 +2996,14 @@ fn remote_lifecycle_rejected(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn embedded_workdir_unsupported_diagnostic() -> RuntimeDiagnostic {
|
||||||
|
diagnostic(
|
||||||
|
"embedded_worker_workdir_unsupported",
|
||||||
|
DiagnosticSeverity::Error,
|
||||||
|
"Embedded Runtime is no-workdir only; choose a non-embedded Runtime for workspace-file Workers".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
|
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
|
||||||
match error {
|
match error {
|
||||||
EmbeddedRuntimeError::RuntimeStopped => diagnostic(
|
EmbeddedRuntimeError::RuntimeStopped => diagnostic(
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ use axum::routing::{delete, get, post, put};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
use memory::backend::{
|
||||||
|
MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
|
|
@ -21,7 +24,6 @@ use ticket::{
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
||||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||||
use worker_runtime::working_directory::LocalGitWorktreeMaterializer;
|
|
||||||
|
|
||||||
use crate::companion::{
|
use crate::companion::{
|
||||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||||
|
|
@ -193,10 +195,7 @@ impl WorkspaceApi {
|
||||||
crate::Error::Store(format!(
|
crate::Error::Store(format!(
|
||||||
"failed to initialize embedded Worker backend: {err}"
|
"failed to initialize embedded Worker backend: {err}"
|
||||||
))
|
))
|
||||||
})?
|
})?;
|
||||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
|
||||||
config.embedded_runtime_store_root.clone(),
|
|
||||||
));
|
|
||||||
Self::new_with_execution_backend_and_broker(
|
Self::new_with_execution_backend_and_broker(
|
||||||
config,
|
config,
|
||||||
store,
|
store,
|
||||||
|
|
@ -330,6 +329,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||||
"/api/w/{workspace_id}/tickets/backend",
|
"/api/w/{workspace_id}/tickets/backend",
|
||||||
post(scoped_ticket_backend_operation),
|
post(scoped_ticket_backend_operation),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/memory/backend",
|
||||||
|
post(scoped_memory_backend_operation),
|
||||||
|
)
|
||||||
.route("/api/tickets/{id}", get(get_ticket))
|
.route("/api/tickets/{id}", get(get_ticket))
|
||||||
.route("/api/w/{workspace_id}/skills", get(scoped_list_skills))
|
.route("/api/w/{workspace_id}/skills", get(scoped_list_skills))
|
||||||
.route("/api/w/{workspace_id}/skills/lint", get(scoped_lint_skills))
|
.route("/api/w/{workspace_id}/skills/lint", get(scoped_lint_skills))
|
||||||
|
|
@ -1275,6 +1278,23 @@ async fn scoped_ticket_backend_operation(
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_memory_backend_operation(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Json(operation): Json<MemoryBackendOperation>,
|
||||||
|
) -> ApiResult<Json<MemoryBackendHttpResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let memory_config = manifest::MemoryConfig::default();
|
||||||
|
let layout = memory::WorkspaceLayout::resolve(&memory_config, &api.config.workspace_root);
|
||||||
|
let response = match execute_memory_backend_operation(&layout, operation) {
|
||||||
|
Ok(result) => MemoryBackendHttpResponse::Ok { result },
|
||||||
|
Err(error) => MemoryBackendHttpResponse::Error {
|
||||||
|
message: sanitize_backend_error(&error.to_string()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_list_skills(
|
async fn scoped_list_skills(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
|
@ -1501,10 +1521,21 @@ async fn scoped_list_working_directories(
|
||||||
async fn scoped_create_working_directory(
|
async fn scoped_create_working_directory(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
|
Json(_request): Json<BrowserWorkingDirectoryCreateRequest>,
|
||||||
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
|
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
create_working_directory_for_runtime(api, request)
|
Err(ApiError::with_diagnostics(
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: "workspace-backend".to_string(),
|
||||||
|
code: "backend_workdir_create_unsupported".to_string(),
|
||||||
|
message: "Working directory creation must be scoped to a concrete Runtime".to_string(),
|
||||||
|
},
|
||||||
|
vec![RuntimeDiagnostic {
|
||||||
|
code: "backend_workdir_create_unsupported".to_string(),
|
||||||
|
severity: DiagnosticSeverity::Error,
|
||||||
|
message: "Use runtime-scoped Worker creation or a runtime-scoped working-directory API; the backend does not own workdir lifecycle.".to_string(),
|
||||||
|
}],
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_working_directory_detail(
|
async fn scoped_working_directory_detail(
|
||||||
|
|
@ -3128,6 +3159,27 @@ struct RuntimeConfigBundleAvailabilityQuery {
|
||||||
digest: String,
|
digest: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reject_workdir_for_embedded_runtime(
|
||||||
|
runtime_id: &str,
|
||||||
|
has_workdir: bool,
|
||||||
|
) -> std::result::Result<(), ApiError> {
|
||||||
|
if runtime_id != EMBEDDED_WORKER_RUNTIME_ID || !has_workdir {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(ApiError::with_diagnostics(
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code: "embedded_worker_workdir_unsupported".to_string(),
|
||||||
|
message: "The embedded Runtime does not accept working directories".to_string(),
|
||||||
|
},
|
||||||
|
vec![RuntimeDiagnostic {
|
||||||
|
code: "embedded_worker_workdir_unsupported".to_string(),
|
||||||
|
severity: DiagnosticSeverity::Error,
|
||||||
|
message: "Choose a non-embedded Runtime for workspace-file Workers; embedded Workers are no-workdir Workspace-API workers.".to_string(),
|
||||||
|
}],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn reject_no_workdir_for_non_embedded_runtime(
|
fn reject_no_workdir_for_non_embedded_runtime(
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
) -> std::result::Result<(), ApiError> {
|
) -> std::result::Result<(), ApiError> {
|
||||||
|
|
@ -3155,6 +3207,10 @@ async fn create_runtime_worker(
|
||||||
AxumPath(runtime_id): AxumPath<String>,
|
AxumPath(runtime_id): AxumPath<String>,
|
||||||
Json(mut request): Json<WorkerSpawnRequest>,
|
Json(mut request): Json<WorkerSpawnRequest>,
|
||||||
) -> ApiResult<Json<WorkerSpawnResult>> {
|
) -> ApiResult<Json<WorkerSpawnResult>> {
|
||||||
|
reject_workdir_for_embedded_runtime(
|
||||||
|
&runtime_id,
|
||||||
|
request.working_directory_request.is_some() || request.resolved_working_directory.is_some(),
|
||||||
|
)?;
|
||||||
if request.working_directory_request.is_none() && request.resolved_working_directory.is_none() {
|
if request.working_directory_request.is_none() && request.resolved_working_directory.is_none() {
|
||||||
reject_no_workdir_for_non_embedded_runtime(&runtime_id)?;
|
reject_no_workdir_for_non_embedded_runtime(&runtime_id)?;
|
||||||
}
|
}
|
||||||
|
|
@ -6031,89 +6087,6 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_observed_workdir(api: &WorkspaceApi) -> String {
|
|
||||||
let Json(response) = create_working_directory_for_runtime(
|
|
||||||
api.clone(),
|
|
||||||
BrowserWorkingDirectoryCreateRequest {
|
|
||||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
|
||||||
repository_id: TEST_REPOSITORY_ID.to_string(),
|
|
||||||
selector: Some("HEAD".to_string()),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|err| panic!("create observed workdir: {}", err.error));
|
|
||||||
response.item.working_directory_id
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn observed_workdir_reports_verified_cleanliness_for_cleanup() {
|
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
|
||||||
init_clean_git_workspace(workspace.path());
|
|
||||||
let api = test_api(workspace.path()).await;
|
|
||||||
let workdir_id = create_observed_workdir(&api);
|
|
||||||
sync_runtime_workdir_observations(&api, EMBEDDED_WORKER_RUNTIME_ID)
|
|
||||||
.unwrap_or_else(|err| panic!("sync runtime workdir observations: {}", err.error));
|
|
||||||
|
|
||||||
let stored = api
|
|
||||||
.store
|
|
||||||
.get_workdir_registry(&api.config.workspace_id, workdir_id.as_str())
|
|
||||||
.unwrap()
|
|
||||||
.expect("workdir registry row");
|
|
||||||
assert_eq!(stored.materialization_status, "present");
|
|
||||||
assert_eq!(stored.cleanliness, "clean");
|
|
||||||
|
|
||||||
let plan = build_runtime_cleanup_plan(&api, EMBEDDED_WORKER_RUNTIME_ID)
|
|
||||||
.unwrap_or_else(|err| panic!("cleanup plan: {}", err.error));
|
|
||||||
let candidate = plan
|
|
||||||
.workdirs
|
|
||||||
.iter()
|
|
||||||
.find(|candidate| candidate.workdir_id == workdir_id)
|
|
||||||
.expect("cleanup candidate");
|
|
||||||
assert_eq!(candidate.cleanliness, CleanupWorkdirCleanliness::Clean);
|
|
||||||
assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup);
|
|
||||||
assert!(candidate.reason.contains("clean"));
|
|
||||||
|
|
||||||
let target_id = candidate.target_id.clone();
|
|
||||||
let request = ExecuteRuntimeCleanupRequest {
|
|
||||||
expected_plan_revision: plan.revision,
|
|
||||||
expected_plan_digest: plan.digest,
|
|
||||||
worker_target_ids: Vec::new(),
|
|
||||||
workdir_target_ids: vec![target_id],
|
|
||||||
confirm_dirty_discard_target_ids: Vec::new(),
|
|
||||||
};
|
|
||||||
let response = execute_runtime_cleanup(&api, EMBEDDED_WORKER_RUNTIME_ID, request)
|
|
||||||
.unwrap_or_else(|err| panic!("cleanup execution: {}", err.error));
|
|
||||||
assert_eq!(
|
|
||||||
response.results[0].action,
|
|
||||||
CleanupTargetKind::WorkdirCleanCleanup
|
|
||||||
);
|
|
||||||
assert_eq!(response.results[0].status, "deleted");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn stale_clean_registry_row_is_downgraded_by_real_observation_before_cleanup() {
|
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
|
||||||
init_clean_git_workspace(workspace.path());
|
|
||||||
let api = test_api(workspace.path()).await;
|
|
||||||
let workdir_id = create_observed_workdir(&api);
|
|
||||||
let mut stale_clean = api
|
|
||||||
.store
|
|
||||||
.get_workdir_registry(&api.config.workspace_id, workdir_id.as_str())
|
|
||||||
.unwrap()
|
|
||||||
.expect("workdir registry row");
|
|
||||||
stale_clean.cleanliness = "clean".to_string();
|
|
||||||
api.store.upsert_workdir_registry(&stale_clean).unwrap();
|
|
||||||
|
|
||||||
let plan = build_runtime_cleanup_plan(&api, EMBEDDED_WORKER_RUNTIME_ID)
|
|
||||||
.unwrap_or_else(|err| panic!("cleanup plan: {}", err.error));
|
|
||||||
let candidate = plan
|
|
||||||
.workdirs
|
|
||||||
.iter()
|
|
||||||
.find(|candidate| candidate.workdir_id == workdir_id)
|
|
||||||
.expect("cleanup candidate");
|
|
||||||
assert_eq!(candidate.cleanliness, CleanupWorkdirCleanliness::Clean);
|
|
||||||
assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn synthetic_verified_clean_workdir_can_still_use_clean_cleanup_path() {
|
async fn synthetic_verified_clean_workdir_can_still_use_clean_cleanup_path() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -6478,71 +6451,33 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn browser_working_directory_create_list_detail_and_cleanup_are_path_safe() {
|
async fn browser_working_directory_create_is_rejected_as_backend_lifecycle() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
init_clean_git_workspace(dir.path());
|
init_clean_git_workspace(dir.path());
|
||||||
let app = test_app(dir.path()).await;
|
let app = test_app(dir.path()).await;
|
||||||
let workspace_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories");
|
let workspace_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories");
|
||||||
|
|
||||||
let created = post_json(
|
let response = request_json(
|
||||||
app.clone(),
|
|
||||||
&workspace_path,
|
|
||||||
serde_json::json!({
|
|
||||||
"repository_id": TEST_REPOSITORY_ID,
|
|
||||||
"selector": "HEAD",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let working_directory_id = created["item"]["working_directory_id"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
assert_eq!(created["item"]["repository_id"], TEST_REPOSITORY_ID);
|
|
||||||
assert_eq!(created["item"]["requested_selector"], "HEAD");
|
|
||||||
assert_eq!(created["item"]["status"], "active");
|
|
||||||
let projected = serde_json::to_string(&created).unwrap();
|
|
||||||
assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
|
|
||||||
|
|
||||||
let list = get_json(app.clone(), &workspace_path).await;
|
|
||||||
assert_eq!(list["items"].as_array().unwrap().len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
list["items"][0]["working_directory_id"],
|
|
||||||
working_directory_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let detail_path = format!("{workspace_path}/{working_directory_id}");
|
|
||||||
let detail = get_json(app.clone(), &detail_path).await;
|
|
||||||
assert_eq!(detail["item"]["working_directory_id"], working_directory_id);
|
|
||||||
|
|
||||||
let cleanup_plan_path = format!(
|
|
||||||
"/api/w/{TEST_WORKSPACE_ID}/runtimes/{EMBEDDED_WORKER_RUNTIME_ID}/cleanup-plan"
|
|
||||||
);
|
|
||||||
let plan = get_json(app.clone(), &cleanup_plan_path).await;
|
|
||||||
let target_id = plan["workdirs"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.find(|candidate| candidate["workdir_id"] == working_directory_id)
|
|
||||||
.and_then(|candidate| candidate["target_id"].as_str())
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
let removed = request_json(
|
|
||||||
app,
|
app,
|
||||||
"POST",
|
"POST",
|
||||||
&format!(
|
&workspace_path,
|
||||||
"/api/w/{TEST_WORKSPACE_ID}/runtimes/{EMBEDDED_WORKER_RUNTIME_ID}/cleanup-executions"
|
|
||||||
),
|
|
||||||
Some(serde_json::json!({
|
Some(serde_json::json!({
|
||||||
"expected_plan_revision": plan["revision"],
|
"repository_id": TEST_REPOSITORY_ID,
|
||||||
"expected_plan_digest": plan["digest"],
|
"selector": "HEAD",
|
||||||
"worker_target_ids": [],
|
|
||||||
"workdir_target_ids": [target_id],
|
|
||||||
"confirm_dirty_discard_target_ids": [target_id]
|
|
||||||
})),
|
})),
|
||||||
StatusCode::OK,
|
StatusCode::BAD_REQUEST,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(removed["results"][0]["status"], "deleted");
|
assert!(
|
||||||
|
response["diagnostics"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|diagnostic| diagnostic["code"] == "backend_workdir_create_unsupported"),
|
||||||
|
"expected backend lifecycle diagnostic, got {response}"
|
||||||
|
);
|
||||||
|
let projected = serde_json::to_string(&response).unwrap();
|
||||||
|
assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -6550,24 +6485,14 @@ mod tests {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
init_clean_git_workspace(dir.path());
|
init_clean_git_workspace(dir.path());
|
||||||
let app = test_app(dir.path()).await;
|
let app = test_app(dir.path()).await;
|
||||||
let working_directories_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories");
|
let working_directory_id = "test-workdir";
|
||||||
let created = post_json(
|
|
||||||
app.clone(),
|
|
||||||
&working_directories_path,
|
|
||||||
serde_json::json!({
|
|
||||||
"repository_id": TEST_REPOSITORY_ID,
|
|
||||||
"selector": "HEAD",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let working_directory_id = created["item"]["working_directory_id"].as_str().unwrap();
|
|
||||||
|
|
||||||
let response = request_json(
|
let response = request_json(
|
||||||
app,
|
app,
|
||||||
"POST",
|
"POST",
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
|
||||||
Some(serde_json::json!({
|
Some(serde_json::json!({
|
||||||
"runtime_id": EMBEDDED_WORKER_RUNTIME_ID,
|
"runtime_id": "remote-runtime",
|
||||||
"display_name": "Coding Worker",
|
"display_name": "Coding Worker",
|
||||||
"profile": "builtin:coder",
|
"profile": "builtin:coder",
|
||||||
"initial_text": "",
|
"initial_text": "",
|
||||||
|
|
@ -6987,7 +6912,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn runtime_worker_spawn_accepts_safe_repository_selector() {
|
async fn runtime_worker_spawn_rejects_embedded_workdir_request() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let app = test_app(dir.path()).await;
|
let app = test_app(dir.path()).await;
|
||||||
let response = request_json(
|
let response = request_json(
|
||||||
|
|
@ -7009,9 +6934,17 @@ mod tests {
|
||||||
"selector": "HEAD"
|
"selector": "HEAD"
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
StatusCode::OK,
|
StatusCode::BAD_REQUEST,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
assert!(
|
||||||
|
response["diagnostics"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|diagnostic| diagnostic["code"] == "embedded_worker_workdir_unsupported"),
|
||||||
|
"expected embedded workdir diagnostic, got {response}"
|
||||||
|
);
|
||||||
let projected = serde_json::to_string(&response).unwrap();
|
let projected = serde_json::to_string(&response).unwrap();
|
||||||
assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
|
assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-Xlaq8/cyMGk3m4z6BNLtSgoRnWqVVnGaLyzgBlr70rk=";
|
cargoHash = "sha256-MUEySmRu5Te8+GCwlt3qk7jxcumYIMvnuvj64o2XuDw=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user