Compare commits
16
Commits
5cc78d63c6
...
925100fb82
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925100fb82 | ||
|
|
a664e72488 | ||
|
|
21317123a4 | ||
|
|
409245cb52 | ||
|
|
1aeb6fdb35 | ||
|
|
816fa96e07 | ||
|
|
8fbe4218c6 | ||
|
|
00c8df0fc9 | ||
|
|
c52c7ead19 | ||
|
|
d1e8a827c2 | ||
|
|
12d96fb03d | ||
|
|
171a191873 | ||
|
|
47dabd8793 | ||
|
|
2bb661f1cf | ||
|
|
04e296a4ef | ||
|
|
4bba227af5 |
Generated
+2
@@ -4402,10 +4402,12 @@ dependencies = [
|
|||||||
"agen",
|
"agen",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
"protocol",
|
"protocol",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -192,6 +192,32 @@ impl BackendApiClient {
|
|||||||
format!("Bearer {}", self.access_token.0)
|
format!("Bearer {}", self.access_token.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn require_success(
|
||||||
|
&self,
|
||||||
|
response: reqwest::Response,
|
||||||
|
) -> Result<reqwest::Response, BackendApiClientError> {
|
||||||
|
let status = response.status();
|
||||||
|
match status {
|
||||||
|
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
|
||||||
|
self.check_status(status)?;
|
||||||
|
}
|
||||||
|
status if !status.is_success() => {
|
||||||
|
let detail = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|body| backend_error_detail(&body));
|
||||||
|
return Err(BackendApiClientError::BackendResponse {
|
||||||
|
origin: self.origin.clone(),
|
||||||
|
status: status.as_u16(),
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> {
|
pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> {
|
||||||
match status {
|
match status {
|
||||||
StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized {
|
StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized {
|
||||||
@@ -235,6 +261,18 @@ fn redirect_policy(origin: BackendOrigin) -> redirect::Policy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BackendErrorBody {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_error_detail(body: &[u8]) -> Option<String> {
|
||||||
|
serde_json::from_slice::<BackendErrorBody>(body)
|
||||||
|
.ok()
|
||||||
|
.map(|body| body.message)
|
||||||
|
.filter(|message| !message.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BackendApiClientError {
|
pub enum BackendApiClientError {
|
||||||
InvalidBackendOrigin(String),
|
InvalidBackendOrigin(String),
|
||||||
@@ -266,6 +304,11 @@ pub enum BackendApiClientError {
|
|||||||
origin: BackendOrigin,
|
origin: BackendOrigin,
|
||||||
status: u16,
|
status: u16,
|
||||||
},
|
},
|
||||||
|
BackendResponse {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
status: u16,
|
||||||
|
detail: Option<String>,
|
||||||
|
},
|
||||||
Io {
|
Io {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
source: std::io::Error,
|
source: std::io::Error,
|
||||||
@@ -312,6 +355,17 @@ impl fmt::Display for BackendApiClientError {
|
|||||||
Self::BackendStatus { origin, status } => {
|
Self::BackendStatus { origin, status } => {
|
||||||
write!(f, "Backend {origin} returned HTTP {status}")
|
write!(f, "Backend {origin} returned HTTP {status}")
|
||||||
}
|
}
|
||||||
|
Self::BackendResponse {
|
||||||
|
origin,
|
||||||
|
status,
|
||||||
|
detail,
|
||||||
|
} => {
|
||||||
|
write!(f, "Backend {origin} returned HTTP {status}")?;
|
||||||
|
if let Some(detail) = detail {
|
||||||
|
write!(f, ": {detail}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Self::Io { path, source } => {
|
Self::Io { path, source } => {
|
||||||
write!(f, "failed to access {}: {source}", path.display())
|
write!(f, "failed to access {}: {source}", path.display())
|
||||||
}
|
}
|
||||||
@@ -584,6 +638,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_error_detail_preserves_public_server_message() {
|
||||||
|
let detail = backend_error_detail(
|
||||||
|
br#"{"error":"Bad Request","message":"working_directory_runtime_mismatch: Working directory is owned by a different Runtime","diagnostics":[{"code":"working_directory_runtime_mismatch"}]}"#,
|
||||||
|
);
|
||||||
|
let error = BackendApiClientError::BackendResponse {
|
||||||
|
origin: BackendOrigin::parse("http://127.0.0.1:8787").unwrap(),
|
||||||
|
status: 400,
|
||||||
|
detail,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"Backend http://127.0.0.1:8787 returned HTTP 400: working_directory_runtime_mismatch: Working directory is owned by a different Runtime"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_origin_rejects_unsafe_authority_changes() {
|
fn backend_origin_rejects_unsafe_authority_changes() {
|
||||||
for invalid in [
|
for invalid in [
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ pub async fn restore_backend_worker(
|
|||||||
.json(&serde_json::json!({}))
|
.json(&serde_json::json!({}))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
api.check_status(response.status())?;
|
let response = api.require_success(response).await?;
|
||||||
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
|
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,20 +101,24 @@ pub fn complete_current(
|
|||||||
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
|
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
|
||||||
let result = session_environment(snapshot.clone())
|
let result = session_environment(snapshot.clone())
|
||||||
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
|
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
|
||||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?
|
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
|
||||||
.map(|result| WasmCompletionResult {
|
let result = result
|
||||||
from: result.from,
|
.map(|result| {
|
||||||
items: result
|
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
|
||||||
.items
|
from: utf8_to_utf16_offset(&source, result.from)?,
|
||||||
.into_iter()
|
items: result
|
||||||
.map(|item| WasmCompletionItem {
|
.items
|
||||||
label: item.label,
|
.into_iter()
|
||||||
kind: format!("{:?}", item.kind).to_lowercase(),
|
.map(|item| WasmCompletionItem {
|
||||||
detail: item.detail,
|
label: item.label,
|
||||||
priority: item.priority,
|
kind: format!("{:?}", item.kind).to_lowercase(),
|
||||||
})
|
detail: item.detail,
|
||||||
.collect(),
|
priority: item.priority,
|
||||||
});
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
encode(result)
|
encode(result)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -177,6 +181,16 @@ fn utf16_to_utf8_offset(source: &str, utf16_offset: usize) -> Result<usize, JsVa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn utf8_to_utf16_offset(source: &str, utf8_offset: usize) -> Result<usize, JsValue> {
|
||||||
|
if utf8_offset > source.len() {
|
||||||
|
return Err(JsValue::from_str("UTF-8 offset is outside the source"));
|
||||||
|
}
|
||||||
|
if !source.is_char_boundary(utf8_offset) {
|
||||||
|
return Err(JsValue::from_str("UTF-8 offset splits a character"));
|
||||||
|
}
|
||||||
|
Ok(source[..utf8_offset].encode_utf16().count())
|
||||||
|
}
|
||||||
|
|
||||||
fn decode<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
|
fn decode<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
|
||||||
from_value(value).map_err(|error| JsValue::from_str(&error.to_string()))
|
from_value(value).map_err(|error| JsValue::from_str(&error.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1203,6 +1203,9 @@ impl SnapshotEnvironment {
|
|||||||
{
|
{
|
||||||
let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}.");
|
let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}.");
|
||||||
member_source.push_str(&context.schema_path.join("."));
|
member_source.push_str(&context.schema_path.join("."));
|
||||||
|
if !context.schema_path.is_empty() && context.from == utf8_byte_offset {
|
||||||
|
member_source.push('.');
|
||||||
|
}
|
||||||
let mut completion = LanguageService::new(self).complete(
|
let mut completion = LanguageService::new(self).complete(
|
||||||
entrypoint.as_str(),
|
entrypoint.as_str(),
|
||||||
&member_source,
|
&member_source,
|
||||||
@@ -1961,6 +1964,31 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|item| item.label == "default_profile")
|
.any(|item| item.label == "default_profile")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let blank_nested_source = "{ profile = { } } as WorkspaceConfigSchema";
|
||||||
|
let blank_nested_cursor = blank_nested_source.find("{ }").unwrap() + 2;
|
||||||
|
let blank_nested = environment
|
||||||
|
.complete_config(
|
||||||
|
&path("main.dcdl"),
|
||||||
|
blank_nested_source,
|
||||||
|
blank_nested_cursor,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(blank_nested.from, blank_nested_cursor);
|
||||||
|
assert!(
|
||||||
|
blank_nested
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.label == "default_profile")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!blank_nested
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.label == "profile")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -193,6 +193,64 @@ impl WorkerEvent {
|
|||||||
/// variants — emits an alert and inserts a `[unknown input segment]`
|
/// variants — emits an alert and inserts a `[unknown input segment]`
|
||||||
/// placeholder into the LLM context so neither user nor LLM is blind to
|
/// placeholder into the LLM context so neither user nor LLM is blind to
|
||||||
/// the dropped intent.
|
/// the dropped intent.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PasteArtifactMediaType {
|
||||||
|
TextPlainUtf8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PasteArtifactAvailability {
|
||||||
|
Available,
|
||||||
|
Unavailable,
|
||||||
|
IntegrityFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PasteArtifactMediaType {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::TextPlainUtf8 => "text/plain; charset=utf-8",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PasteArtifactAvailability {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Available => "available",
|
||||||
|
Self::Unavailable => "unavailable",
|
||||||
|
Self::IntegrityFailed => "integrity_failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session-owned reference to a large pasted-input artifact.
|
||||||
|
///
|
||||||
|
/// The reference contains only bounded integrity and provenance metadata. The
|
||||||
|
/// artifact body remains in session storage and is available to the model only
|
||||||
|
/// through the scoped paste-artifact tools installed by Worker.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
pub struct PasteArtifactRef {
|
||||||
|
pub artifact_id: String,
|
||||||
|
pub created_at_ms: u64,
|
||||||
|
pub media_type: PasteArtifactMediaType,
|
||||||
|
/// Availability observed when this immutable reference was committed.
|
||||||
|
/// Reads revalidate storage and integrity rather than trusting this field.
|
||||||
|
pub availability: PasteArtifactAvailability,
|
||||||
|
pub byte_len: u64,
|
||||||
|
pub char_count: u64,
|
||||||
|
pub line_count: u64,
|
||||||
|
pub sha256: String,
|
||||||
|
pub source_entry_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
@@ -210,6 +268,10 @@ pub enum Segment {
|
|||||||
lines: u32,
|
lines: u32,
|
||||||
content: String,
|
content: String,
|
||||||
},
|
},
|
||||||
|
/// Internal reference produced when Worker stores a large `Paste` before
|
||||||
|
/// committing input. Clients may receive this in history/event projections;
|
||||||
|
/// the body is intentionally absent.
|
||||||
|
PasteArtifact { artifact: PasteArtifactRef },
|
||||||
/// `@<path>` file-system reference. Worker resolves readable files to
|
/// `@<path>` file-system reference. Worker resolves readable files to
|
||||||
/// `[File: <path>]` attachments and readable normal directories to shallow
|
/// `[File: <path>]` attachments and readable normal directories to shallow
|
||||||
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
|
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
|
||||||
@@ -250,6 +312,21 @@ impl Segment {
|
|||||||
match seg {
|
match seg {
|
||||||
Segment::Text { content } => out.push_str(content),
|
Segment::Text { content } => out.push_str(content),
|
||||||
Segment::Paste { content, .. } => out.push_str(content),
|
Segment::Paste { content, .. } => out.push_str(content),
|
||||||
|
Segment::PasteArtifact { artifact } => {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"[Large paste stored as artifact {}: {} bytes, {} chars, {} lines, {}, {}, created at {} ms, sha256 {}; use SearchInputArtifact and ReadInputArtifact to inspect it]",
|
||||||
|
artifact.artifact_id,
|
||||||
|
artifact.byte_len,
|
||||||
|
artifact.char_count,
|
||||||
|
artifact.line_count,
|
||||||
|
artifact.media_type.as_str(),
|
||||||
|
artifact.availability.as_str(),
|
||||||
|
artifact.created_at_ms,
|
||||||
|
artifact.sha256
|
||||||
|
);
|
||||||
|
}
|
||||||
Segment::FileRef { path } => {
|
Segment::FileRef { path } => {
|
||||||
out.push('@');
|
out.push('@');
|
||||||
out.push_str(path);
|
out.push_str(path);
|
||||||
@@ -1202,6 +1279,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paste_artifact_segment_roundtrips_without_body() {
|
||||||
|
let artifact = PasteArtifactRef {
|
||||||
|
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b2".to_string(),
|
||||||
|
created_at_ms: 1_700_000_000_000,
|
||||||
|
media_type: PasteArtifactMediaType::TextPlainUtf8,
|
||||||
|
availability: PasteArtifactAvailability::Available,
|
||||||
|
byte_len: 65_536,
|
||||||
|
char_count: 65_530,
|
||||||
|
line_count: 200,
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
source_entry_id: "entry-1".to_string(),
|
||||||
|
};
|
||||||
|
let segment = Segment::PasteArtifact {
|
||||||
|
artifact: artifact.clone(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&segment).unwrap();
|
||||||
|
assert!(!json.contains("pasted body"));
|
||||||
|
assert_eq!(serde_json::from_str::<Segment>(&json).unwrap(), segment);
|
||||||
|
let projected = Segment::flatten_to_text(&[segment]);
|
||||||
|
assert!(projected.contains(&artifact.artifact_id));
|
||||||
|
assert!(projected.contains("SearchInputArtifact"));
|
||||||
|
assert!(projected.contains("ReadInputArtifact"));
|
||||||
|
assert!(!projected.contains("pasted body"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn method_run_flow_segment_roundtrip() {
|
fn method_run_flow_segment_roundtrip() {
|
||||||
let method = Method::Run {
|
let method = Method::Run {
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ use crate::{
|
|||||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
|
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
|
||||||
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||||
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
|
||||||
RunResult, ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
PasteArtifactRef, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
||||||
|
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
||||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||||
ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
|
ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
|
||||||
subscription::{
|
subscription::{
|
||||||
@@ -78,6 +79,9 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<Greeting>(&cfg, &mut output);
|
push_decl::<Greeting>(&cfg, &mut output);
|
||||||
push_decl::<Alert>(&cfg, &mut output);
|
push_decl::<Alert>(&cfg, &mut output);
|
||||||
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
||||||
|
push_decl::<PasteArtifactMediaType>(&cfg, &mut output);
|
||||||
|
push_decl::<PasteArtifactAvailability>(&cfg, &mut output);
|
||||||
|
push_decl::<PasteArtifactRef>(&cfg, &mut output);
|
||||||
push_decl::<Segment>(&cfg, &mut output);
|
push_decl::<Segment>(&cfg, &mut output);
|
||||||
push_decl::<WorkerEvent>(&cfg, &mut output);
|
push_decl::<WorkerEvent>(&cfg, &mut output);
|
||||||
push_decl::<SubscriptionRequestId>(&cfg, &mut output);
|
push_decl::<SubscriptionRequestId>(&cfg, &mut output);
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
agen = { workspace = true }
|
agen = { workspace = true }
|
||||||
|
fs4.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
sha2.workspace = true
|
||||||
uuid = { workspace = true, features = ["v7", "serde"] }
|
uuid = { workspace = true, features = ["v7", "serde"] }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
//! enumerable by the picker.
|
//! enumerable by the picker.
|
||||||
|
|
||||||
use crate::event_trace::TraceEntry;
|
use crate::event_trace::TraceEntry;
|
||||||
|
use crate::paste_artifact::{read_from_dir, write_to_dir};
|
||||||
use crate::segment_log::LogEntry;
|
use crate::segment_log::LogEntry;
|
||||||
use crate::store::{Store, StoreError};
|
use crate::store::{Store, StoreError};
|
||||||
use crate::{SegmentId, SessionId};
|
use crate::{PasteArtifactLimits, SegmentId, SessionId};
|
||||||
|
use protocol::PasteArtifactRef;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{Read, Seek, SeekFrom, Write};
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -109,6 +111,16 @@ impl FsStore {
|
|||||||
.join(format!("{segment_id}.trace.jsonl"))
|
.join(format!("{segment_id}.trace.jsonl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn paste_artifact_dir(&self, session_id: SessionId) -> PathBuf {
|
||||||
|
self.session_dir(session_id).join("artifacts").join("paste")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn paste_artifact_path(&self, session_id: SessionId, artifact_id: &str) -> PathBuf {
|
||||||
|
self.paste_artifact_dir(session_id)
|
||||||
|
.join(format!("{artifact_id}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
|
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
|
||||||
let _guard = self
|
let _guard = self
|
||||||
.append_lock
|
.append_lock
|
||||||
@@ -350,6 +362,33 @@ impl Store for FsStore {
|
|||||||
Ok(complete.lines().filter(|l| !l.trim().is_empty()).count())
|
Ok(complete.lines().filter(|l| !l.trim().is_empty()).count())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_paste_artifact(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
source_entry_id: &str,
|
||||||
|
content: &str,
|
||||||
|
limits: PasteArtifactLimits,
|
||||||
|
) -> Result<PasteArtifactRef, StoreError> {
|
||||||
|
let _guard = self
|
||||||
|
.append_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||||
|
write_to_dir(
|
||||||
|
&self.paste_artifact_dir(session_id),
|
||||||
|
source_entry_id,
|
||||||
|
content,
|
||||||
|
limits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_paste_artifact(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
artifact_id: &str,
|
||||||
|
) -> Result<(PasteArtifactRef, String), StoreError> {
|
||||||
|
read_from_dir(&self.paste_artifact_dir(session_id), artifact_id)
|
||||||
|
}
|
||||||
|
|
||||||
fn append_trace(
|
fn append_trace(
|
||||||
&self,
|
&self,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
@@ -398,4 +437,146 @@ mod tests {
|
|||||||
store.create_segment(session_id, segment_id, &[]).unwrap();
|
store.create_segment(session_id, segment_id, &[]).unwrap();
|
||||||
assert!(store.session_modified_at(session_id).unwrap().is_some());
|
assert!(store.session_modified_at(session_id).unwrap().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paste_artifacts_are_atomic_integrity_checked_and_session_scoped() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let store = FsStore::new(tmp.path()).unwrap();
|
||||||
|
let owner = new_session_id();
|
||||||
|
let other = new_session_id();
|
||||||
|
let content = "αβγ\nsecond line\n";
|
||||||
|
let reference = store
|
||||||
|
.write_paste_artifact(owner, "entry-1", content, PasteArtifactLimits::default())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(reference.byte_len, content.len() as u64);
|
||||||
|
assert!(reference.created_at_ms > 0);
|
||||||
|
assert_eq!(
|
||||||
|
reference.media_type,
|
||||||
|
protocol::PasteArtifactMediaType::TextPlainUtf8
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reference.availability,
|
||||||
|
protocol::PasteArtifactAvailability::Available
|
||||||
|
);
|
||||||
|
assert_eq!(reference.char_count, content.chars().count() as u64);
|
||||||
|
assert_eq!(reference.source_entry_id, "entry-1");
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.read_paste_artifact(owner, &reference.artifact_id)
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
store.read_paste_artifact(other, &reference.artifact_id),
|
||||||
|
Err(StoreError::PasteArtifactNotFound(_))
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
self::fs::read_dir(store.paste_artifact_dir(owner))
|
||||||
|
.unwrap()
|
||||||
|
.all(|entry| !entry
|
||||||
|
.unwrap()
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.ends_with(".tmp"))
|
||||||
|
);
|
||||||
|
let very_large = "z".repeat(1024 * 1024);
|
||||||
|
let very_large_ref = store
|
||||||
|
.write_paste_artifact(
|
||||||
|
owner,
|
||||||
|
"entry-2",
|
||||||
|
&very_large,
|
||||||
|
PasteArtifactLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.read_paste_artifact(owner, &very_large_ref.artifact_id)
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
very_large
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_paste_writes_atomically_enforce_aggregate_caps() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let session_id = new_session_id();
|
||||||
|
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
|
||||||
|
let limits = PasteArtifactLimits {
|
||||||
|
max_artifact_bytes: 4,
|
||||||
|
max_session_bytes: 8,
|
||||||
|
max_session_artifacts: 1,
|
||||||
|
};
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for entry_id in ["entry-1", "entry-2"] {
|
||||||
|
let root = tmp.path().to_path_buf();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
handles.push(std::thread::spawn(move || {
|
||||||
|
let store = FsStore::new(root).unwrap();
|
||||||
|
barrier.wait();
|
||||||
|
store.write_paste_artifact(session_id, entry_id, "1234", limits)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
barrier.wait();
|
||||||
|
let results = handles
|
||||||
|
.into_iter()
|
||||||
|
.map(|handle| handle.join().unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
results
|
||||||
|
.iter()
|
||||||
|
.filter(|result| matches!(result, Err(StoreError::PasteArtifactLimit(_))))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_dir(
|
||||||
|
FsStore::new(tmp.path())
|
||||||
|
.unwrap()
|
||||||
|
.paste_artifact_dir(session_id)
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(
|
||||||
|
|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("json")
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paste_artifact_limits_and_corruption_fail_closed() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let store = FsStore::new(tmp.path()).unwrap();
|
||||||
|
let session_id = new_session_id();
|
||||||
|
let limits = PasteArtifactLimits {
|
||||||
|
max_artifact_bytes: 5,
|
||||||
|
max_session_bytes: 8,
|
||||||
|
max_session_artifacts: 2,
|
||||||
|
};
|
||||||
|
let first = store
|
||||||
|
.write_paste_artifact(session_id, "entry-1", "1234", limits)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.write_paste_artifact(session_id, "entry-2", "56789", limits),
|
||||||
|
Err(StoreError::PasteArtifactLimit(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.write_paste_artifact(session_id, "entry-2", "5678", limits),
|
||||||
|
Ok(_)
|
||||||
|
));
|
||||||
|
std::fs::write(
|
||||||
|
store.paste_artifact_path(session_id, &first.artifact_id),
|
||||||
|
b"{}",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.read_paste_artifact(session_id, &first.artifact_id),
|
||||||
|
Err(StoreError::Serde(_)) | Err(StoreError::PasteArtifactIntegrity(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub mod fs_store;
|
|||||||
pub mod history;
|
pub mod history;
|
||||||
mod legacy_session_log;
|
mod legacy_session_log;
|
||||||
pub mod logged_item;
|
pub mod logged_item;
|
||||||
|
mod paste_artifact;
|
||||||
pub mod public_snapshot;
|
pub mod public_snapshot;
|
||||||
pub mod segment;
|
pub mod segment;
|
||||||
pub mod segment_log;
|
pub mod segment_log;
|
||||||
@@ -53,6 +54,7 @@ pub use history::{
|
|||||||
LoggedWorkerSubject,
|
LoggedWorkerSubject,
|
||||||
};
|
};
|
||||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||||
|
pub use paste_artifact::PasteArtifactLimits;
|
||||||
pub use segment::{
|
pub use segment::{
|
||||||
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
||||||
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
|
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
//! Session-owned storage for large pasted-input artifacts.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use fs4::fs_std::FileExt;
|
||||||
|
use protocol::{PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::StoreError;
|
||||||
|
|
||||||
|
/// Bounded storage policy applied before a large paste becomes durable input.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct PasteArtifactLimits {
|
||||||
|
pub max_artifact_bytes: u64,
|
||||||
|
pub max_session_bytes: u64,
|
||||||
|
pub max_session_artifacts: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PasteArtifactLimits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_artifact_bytes: 8 * 1024 * 1024,
|
||||||
|
max_session_bytes: 64 * 1024 * 1024,
|
||||||
|
max_session_artifacts: 1_024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integrity-bearing on-disk record. The body and metadata are committed in one
|
||||||
|
/// atomic file replacement so readers never observe a half-written artifact.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct StoredPasteArtifact {
|
||||||
|
pub reference: PasteArtifactRef,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn write_to_dir(
|
||||||
|
artifact_dir: &Path,
|
||||||
|
source_entry_id: &str,
|
||||||
|
content: &str,
|
||||||
|
limits: PasteArtifactLimits,
|
||||||
|
) -> Result<PasteArtifactRef, StoreError> {
|
||||||
|
let byte_len = content.len() as u64;
|
||||||
|
if byte_len > limits.max_artifact_bytes {
|
||||||
|
return Err(StoreError::PasteArtifactLimit(format!(
|
||||||
|
"artifact has {byte_len} bytes; maximum is {}",
|
||||||
|
limits.max_artifact_bytes
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
fs::create_dir_all(artifact_dir)?;
|
||||||
|
let aggregate_lock = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(artifact_dir.join(".aggregate.lock"))?;
|
||||||
|
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||||
|
let mut aggregate = 0_u64;
|
||||||
|
let mut artifact_count = 0_u64;
|
||||||
|
for entry in fs::read_dir(artifact_dir)? {
|
||||||
|
let path = entry?.path();
|
||||||
|
if path.extension().and_then(|value| value.to_str()) != Some("json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stored: StoredPasteArtifact = serde_json::from_slice(&fs::read(&path)?)?;
|
||||||
|
verify(&stored, &stored.reference.artifact_id)?;
|
||||||
|
artifact_count = artifact_count.checked_add(1).ok_or_else(|| {
|
||||||
|
StoreError::PasteArtifactLimit("session artifact count overflow".to_string())
|
||||||
|
})?;
|
||||||
|
aggregate = aggregate
|
||||||
|
.checked_add(stored.reference.byte_len)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let projected = aggregate.checked_add(byte_len).ok_or_else(|| {
|
||||||
|
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
||||||
|
})?;
|
||||||
|
if projected > limits.max_session_bytes {
|
||||||
|
return Err(StoreError::PasteArtifactLimit(format!(
|
||||||
|
"session artifacts would use {projected} bytes; maximum is {}",
|
||||||
|
limits.max_session_bytes
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if artifact_count >= limits.max_session_artifacts {
|
||||||
|
return Err(StoreError::PasteArtifactLimit(format!(
|
||||||
|
"session already has {artifact_count} artifacts; maximum is {}",
|
||||||
|
limits.max_session_artifacts
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let artifact_id = uuid::Uuid::now_v7().to_string();
|
||||||
|
let created_at_ms = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|error| StoreError::PasteArtifactIntegrity(error.to_string()))?
|
||||||
|
.as_millis() as u64;
|
||||||
|
let reference = PasteArtifactRef {
|
||||||
|
artifact_id: artifact_id.clone(),
|
||||||
|
created_at_ms,
|
||||||
|
media_type: PasteArtifactMediaType::TextPlainUtf8,
|
||||||
|
availability: PasteArtifactAvailability::Available,
|
||||||
|
byte_len,
|
||||||
|
char_count: content.chars().count() as u64,
|
||||||
|
line_count: line_count(content),
|
||||||
|
sha256: sha256_hex(content),
|
||||||
|
source_entry_id: source_entry_id.to_string(),
|
||||||
|
};
|
||||||
|
let bytes = serde_json::to_vec(&StoredPasteArtifact {
|
||||||
|
reference: reference.clone(),
|
||||||
|
content: content.to_string(),
|
||||||
|
})?;
|
||||||
|
let target = artifact_dir.join(format!("{artifact_id}.json"));
|
||||||
|
let temporary = artifact_dir.join(format!(".{artifact_id}.tmp"));
|
||||||
|
let mut file = fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&temporary)?;
|
||||||
|
if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
|
||||||
|
let _ = fs::remove_file(&temporary);
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
if let Err(error) = fs::rename(&temporary, &target) {
|
||||||
|
let _ = fs::remove_file(&temporary);
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
if let Ok(directory) = fs::File::open(artifact_dir) {
|
||||||
|
directory.sync_all()?;
|
||||||
|
}
|
||||||
|
Ok(reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_from_dir(
|
||||||
|
artifact_dir: &Path,
|
||||||
|
artifact_id: &str,
|
||||||
|
) -> Result<(PasteArtifactRef, String), StoreError> {
|
||||||
|
let parsed = uuid::Uuid::parse_str(artifact_id)
|
||||||
|
.map_err(|_| StoreError::PasteArtifactNotFound(artifact_id.to_string()))?;
|
||||||
|
if parsed.to_string() != artifact_id {
|
||||||
|
return Err(StoreError::PasteArtifactNotFound(artifact_id.to_string()));
|
||||||
|
}
|
||||||
|
let path = artifact_dir.join(format!("{artifact_id}.json"));
|
||||||
|
let bytes = match fs::read(path) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Err(StoreError::PasteArtifactNotFound(artifact_id.to_string()));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
let stored: StoredPasteArtifact = serde_json::from_slice(&bytes)?;
|
||||||
|
verify(&stored, artifact_id)?;
|
||||||
|
Ok((stored.reference, stored.content))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify(stored: &StoredPasteArtifact, artifact_id: &str) -> Result<(), StoreError> {
|
||||||
|
let actual_digest = sha256_hex(&stored.content);
|
||||||
|
if stored.reference.artifact_id != artifact_id
|
||||||
|
|| stored.reference.created_at_ms == 0
|
||||||
|
|| stored.reference.media_type != PasteArtifactMediaType::TextPlainUtf8
|
||||||
|
|| stored.reference.availability != PasteArtifactAvailability::Available
|
||||||
|
|| stored.reference.byte_len != stored.content.len() as u64
|
||||||
|
|| stored.reference.char_count != stored.content.chars().count() as u64
|
||||||
|
|| stored.reference.line_count != line_count(&stored.content)
|
||||||
|
|| stored.reference.sha256 != actual_digest
|
||||||
|
{
|
||||||
|
return Err(StoreError::PasteArtifactIntegrity(artifact_id.to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(content: &str) -> String {
|
||||||
|
Sha256::digest(content.as_bytes())
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_count(content: &str) -> u64 {
|
||||||
|
if content.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
content.lines().count().max(1) as u64
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
|
|
||||||
use crate::event_trace::TraceEntry;
|
use crate::event_trace::TraceEntry;
|
||||||
use crate::segment_log::LogEntry;
|
use crate::segment_log::LogEntry;
|
||||||
use crate::{SegmentId, SessionId};
|
use crate::{PasteArtifactLimits, SegmentId, SessionId};
|
||||||
|
use protocol::PasteArtifactRef;
|
||||||
|
|
||||||
/// Errors from the persistence store.
|
/// Errors from the persistence store.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -29,6 +30,18 @@ pub enum StoreError {
|
|||||||
|
|
||||||
#[error("log corrupted at line {line}: {message}")]
|
#[error("log corrupted at line {line}: {message}")]
|
||||||
Corrupt { line: usize, message: String },
|
Corrupt { line: usize, message: String },
|
||||||
|
|
||||||
|
#[error("paste artifact storage is unavailable")]
|
||||||
|
PasteArtifactUnsupported,
|
||||||
|
|
||||||
|
#[error("paste artifact not found: {0}")]
|
||||||
|
PasteArtifactNotFound(String),
|
||||||
|
|
||||||
|
#[error("paste artifact integrity check failed: {0}")]
|
||||||
|
PasteArtifactIntegrity(String),
|
||||||
|
|
||||||
|
#[error("paste artifact size limit exceeded: {0}")]
|
||||||
|
PasteArtifactLimit(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync persistence backend for segment logs.
|
/// Sync persistence backend for segment logs.
|
||||||
@@ -117,6 +130,26 @@ pub trait Store: Send + Sync {
|
|||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
) -> Result<usize, StoreError>;
|
) -> Result<usize, StoreError>;
|
||||||
|
|
||||||
|
/// Store a large paste before its reference is committed to history.
|
||||||
|
fn write_paste_artifact(
|
||||||
|
&self,
|
||||||
|
_session_id: SessionId,
|
||||||
|
_source_entry_id: &str,
|
||||||
|
_content: &str,
|
||||||
|
_limits: PasteArtifactLimits,
|
||||||
|
) -> Result<PasteArtifactRef, StoreError> {
|
||||||
|
Err(StoreError::PasteArtifactUnsupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and verify one artifact owned by `session_id`.
|
||||||
|
fn read_paste_artifact(
|
||||||
|
&self,
|
||||||
|
_session_id: SessionId,
|
||||||
|
_artifact_id: &str,
|
||||||
|
) -> Result<(PasteArtifactRef, String), StoreError> {
|
||||||
|
Err(StoreError::PasteArtifactUnsupported)
|
||||||
|
}
|
||||||
|
|
||||||
/// Append a trace entry to the debug event trace file.
|
/// Append a trace entry to the debug event trace file.
|
||||||
fn append_trace(
|
fn append_trace(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -608,6 +608,24 @@ where
|
|||||||
) -> Result<usize, crate::StoreError> {
|
) -> Result<usize, crate::StoreError> {
|
||||||
self.session_store.read_entry_count(session_id, segment_id)
|
self.session_store.read_entry_count(session_id, segment_id)
|
||||||
}
|
}
|
||||||
|
fn write_paste_artifact(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
source_entry_id: &str,
|
||||||
|
content: &str,
|
||||||
|
limits: crate::PasteArtifactLimits,
|
||||||
|
) -> Result<protocol::PasteArtifactRef, crate::StoreError> {
|
||||||
|
self.session_store
|
||||||
|
.write_paste_artifact(session_id, source_entry_id, content, limits)
|
||||||
|
}
|
||||||
|
fn read_paste_artifact(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
artifact_id: &str,
|
||||||
|
) -> Result<(protocol::PasteArtifactRef, String), crate::StoreError> {
|
||||||
|
self.session_store
|
||||||
|
.read_paste_artifact(session_id, artifact_id)
|
||||||
|
}
|
||||||
fn append_trace(
|
fn append_trace(
|
||||||
&self,
|
&self,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
//! every later operation must use that same ID.
|
//! every later operation must use that same ID.
|
||||||
|
|
||||||
use crate::event_trace::TraceEntry;
|
use crate::event_trace::TraceEntry;
|
||||||
|
use crate::paste_artifact::{read_from_dir, write_to_dir};
|
||||||
use crate::segment_log::LogEntry;
|
use crate::segment_log::LogEntry;
|
||||||
use crate::store::{Store, StoreError};
|
use crate::store::{Store, StoreError};
|
||||||
use crate::{SegmentId, SessionId};
|
use crate::{PasteArtifactLimits, SegmentId, SessionId};
|
||||||
|
use protocol::PasteArtifactRef;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::{Read, Seek, SeekFrom, Write};
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
@@ -25,6 +27,7 @@ const PREVIOUS_SESSION_SCHEMA_VERSION: u32 = 2;
|
|||||||
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
|
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
|
||||||
const SESSION_FILE: &str = "session.json";
|
const SESSION_FILE: &str = "session.json";
|
||||||
const SEGMENTS_DIR: &str = "segments";
|
const SEGMENTS_DIR: &str = "segments";
|
||||||
|
const PASTE_ARTIFACTS_DIR: &str = "artifacts/paste";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WorkerSessionStore {
|
pub struct WorkerSessionStore {
|
||||||
@@ -317,6 +320,35 @@ impl Store for WorkerSessionStore {
|
|||||||
.count())
|
.count())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_paste_artifact(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
source_entry_id: &str,
|
||||||
|
content: &str,
|
||||||
|
limits: PasteArtifactLimits,
|
||||||
|
) -> Result<PasteArtifactRef, StoreError> {
|
||||||
|
self.ensure_session(session_id, true)?;
|
||||||
|
let _guard = self
|
||||||
|
.append_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
|
||||||
|
write_to_dir(
|
||||||
|
&self.root.join(PASTE_ARTIFACTS_DIR),
|
||||||
|
source_entry_id,
|
||||||
|
content,
|
||||||
|
limits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_paste_artifact(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
artifact_id: &str,
|
||||||
|
) -> Result<(PasteArtifactRef, String), StoreError> {
|
||||||
|
self.ensure_session(session_id, false)?;
|
||||||
|
read_from_dir(&self.root.join(PASTE_ARTIFACTS_DIR), artifact_id)
|
||||||
|
}
|
||||||
|
|
||||||
fn append_trace(
|
fn append_trace(
|
||||||
&self,
|
&self,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
@@ -601,6 +633,45 @@ mod tests {
|
|||||||
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
|
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_session_store_keeps_paste_artifacts_inside_retention_root() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let store = WorkerSessionStore::new(root.path().join("session")).unwrap();
|
||||||
|
let session_id = new_session_id();
|
||||||
|
store
|
||||||
|
.create_segment(session_id, new_segment_id(), &[])
|
||||||
|
.unwrap();
|
||||||
|
let content = "large paste body\n終端\n";
|
||||||
|
let reference = store
|
||||||
|
.write_paste_artifact(
|
||||||
|
session_id,
|
||||||
|
"entry-1",
|
||||||
|
content,
|
||||||
|
PasteArtifactLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
root.path()
|
||||||
|
.join(format!(
|
||||||
|
"session/{PASTE_ARTIFACTS_DIR}/{}.json",
|
||||||
|
reference.artifact_id
|
||||||
|
))
|
||||||
|
.is_file()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.read_paste_artifact(session_id, &reference.artifact_id)
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
store.read_paste_artifact(new_session_id(), &reference.artifact_id),
|
||||||
|
Err(StoreError::Corrupt { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_v1_logs_are_rewritten_and_promoted_to_v3() {
|
fn schema_v1_logs_are_rewritten_and_promoted_to_v3() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
+381
-74
@@ -15,6 +15,64 @@ use ratatui::style::{Color, Style};
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use unicode_width::UnicodeWidthChar;
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
|
pub const MAX_PLAIN_TEXT_PASTE_CHARS: usize = 50;
|
||||||
|
pub const MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES: usize = 3;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct PasteMeasurement {
|
||||||
|
pub chars: usize,
|
||||||
|
pub logical_lines: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PasteMeasurement {
|
||||||
|
pub fn presentation(self) -> PastePresentation {
|
||||||
|
if self.chars <= MAX_PLAIN_TEXT_PASTE_CHARS
|
||||||
|
&& self.logical_lines <= MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||||
|
{
|
||||||
|
PastePresentation::Text
|
||||||
|
} else {
|
||||||
|
PastePresentation::Chip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PastePresentation {
|
||||||
|
Text,
|
||||||
|
Chip,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn measure_paste(content: &str) -> PasteMeasurement {
|
||||||
|
PasteMeasurement {
|
||||||
|
chars: content.chars().count(),
|
||||||
|
logical_lines: logical_line_count(content),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty content has zero logical lines. Otherwise LF, lone CR, and CRLF each
|
||||||
|
/// advance one line; a CRLF pair is one break rather than two.
|
||||||
|
pub fn logical_line_count(content: &str) -> usize {
|
||||||
|
if content.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = 1;
|
||||||
|
let mut chars = content.chars().peekable();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
match ch {
|
||||||
|
'\r' => {
|
||||||
|
if chars.peek() == Some(&'\n') {
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
lines += 1;
|
||||||
|
}
|
||||||
|
'\n' => lines += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PasteRef {
|
pub struct PasteRef {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@@ -61,6 +119,7 @@ impl FlowRefAtom {
|
|||||||
pub enum Atom {
|
pub enum Atom {
|
||||||
Char(char),
|
Char(char),
|
||||||
Paste(PasteRef),
|
Paste(PasteRef),
|
||||||
|
PasteArtifact(protocol::PasteArtifactRef),
|
||||||
FileRef(FileRefAtom),
|
FileRef(FileRefAtom),
|
||||||
FlowRef(FlowRefAtom),
|
FlowRef(FlowRefAtom),
|
||||||
}
|
}
|
||||||
@@ -72,6 +131,18 @@ impl Atom {
|
|||||||
match self {
|
match self {
|
||||||
Atom::Char(_) => None,
|
Atom::Char(_) => None,
|
||||||
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
|
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
|
||||||
|
Atom::PasteArtifact(artifact) => Some((
|
||||||
|
Style::default().fg(Color::Magenta),
|
||||||
|
format!(
|
||||||
|
"[Paste artifact {} | {} chars, {} lines, {}, {}, created {} ms]",
|
||||||
|
artifact.artifact_id,
|
||||||
|
artifact.char_count,
|
||||||
|
artifact.line_count,
|
||||||
|
artifact.media_type.as_str(),
|
||||||
|
artifact.availability.as_str(),
|
||||||
|
artifact.created_at_ms
|
||||||
|
),
|
||||||
|
)),
|
||||||
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
|
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
|
||||||
Atom::FlowRef(r) => Some((Style::default().fg(Color::Yellow), r.label())),
|
Atom::FlowRef(r) => Some((Style::default().fg(Color::Yellow), r.label())),
|
||||||
}
|
}
|
||||||
@@ -102,7 +173,9 @@ enum WordKind {
|
|||||||
fn atom_class(atom: &Atom) -> AtomClass {
|
fn atom_class(atom: &Atom) -> AtomClass {
|
||||||
match atom {
|
match atom {
|
||||||
Atom::Char(c) => char_class(*c),
|
Atom::Char(c) => char_class(*c),
|
||||||
Atom::Paste(_) | Atom::FileRef(_) | Atom::FlowRef(_) => AtomClass::Chip,
|
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
|
||||||
|
AtomClass::Chip
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +263,9 @@ impl InputBuffer {
|
|||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
protocol::Segment::PasteArtifact { artifact } => {
|
||||||
|
self.atoms.push(Atom::PasteArtifact(artifact.clone()));
|
||||||
|
}
|
||||||
protocol::Segment::FileRef { path } => {
|
protocol::Segment::FileRef { path } => {
|
||||||
self.atoms
|
self.atoms
|
||||||
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
|
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
|
||||||
@@ -225,6 +301,13 @@ impl InputBuffer {
|
|||||||
match atom {
|
match atom {
|
||||||
Atom::Char(c) => text.push(*c),
|
Atom::Char(c) => text.push(*c),
|
||||||
Atom::Paste(paste) => text.push_str(&paste.content),
|
Atom::Paste(paste) => text.push_str(&paste.content),
|
||||||
|
Atom::PasteArtifact(artifact) => {
|
||||||
|
text.push_str(&protocol::Segment::flatten_to_text(&[
|
||||||
|
protocol::Segment::PasteArtifact {
|
||||||
|
artifact: artifact.clone(),
|
||||||
|
},
|
||||||
|
]))
|
||||||
|
}
|
||||||
Atom::FileRef(file) => text.push_str(&file.path),
|
Atom::FileRef(file) => text.push_str(&file.path),
|
||||||
Atom::FlowRef(flow) => text.push_str(&flow.selector),
|
Atom::FlowRef(flow) => text.push_str(&flow.selector),
|
||||||
}
|
}
|
||||||
@@ -237,16 +320,20 @@ impl InputBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_paste(&mut self, content: String) {
|
pub fn insert_paste(&mut self, content: String) {
|
||||||
|
let measurement = measure_paste(&content);
|
||||||
|
if measurement.presentation() == PastePresentation::Text {
|
||||||
|
self.insert_str(&content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let id = self.next_paste_id;
|
let id = self.next_paste_id;
|
||||||
self.next_paste_id = self.next_paste_id.wrapping_add(1);
|
self.next_paste_id = self.next_paste_id.wrapping_add(1);
|
||||||
let chars = content.chars().count();
|
|
||||||
let lines = content.lines().count().max(1);
|
|
||||||
self.atoms.insert(
|
self.atoms.insert(
|
||||||
self.cursor,
|
self.cursor,
|
||||||
Atom::Paste(PasteRef {
|
Atom::Paste(PasteRef {
|
||||||
id,
|
id,
|
||||||
chars,
|
chars: measurement.chars,
|
||||||
lines,
|
lines: measurement.logical_lines,
|
||||||
content,
|
content,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -395,80 +482,78 @@ impl InputBuffer {
|
|||||||
self.cursor = 0;
|
self.cursor = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_home(&mut self) {
|
fn logical_line_ranges(&self) -> Vec<(usize, usize)> {
|
||||||
while self.cursor > 0 {
|
let mut ranges = Vec::new();
|
||||||
if matches!(self.atoms[self.cursor - 1], Atom::Char('\n')) {
|
let mut start = 0;
|
||||||
break;
|
let mut index = 0;
|
||||||
}
|
while index < self.atoms.len() {
|
||||||
self.cursor -= 1;
|
let break_len = match self.atoms[index] {
|
||||||
|
Atom::Char('\r') => {
|
||||||
|
if matches!(self.atoms.get(index + 1), Some(Atom::Char('\n'))) {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Atom::Char('\n') => 1,
|
||||||
|
_ => {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ranges.push((start, index));
|
||||||
|
index += break_len;
|
||||||
|
start = index;
|
||||||
}
|
}
|
||||||
|
ranges.push((start, self.atoms.len()));
|
||||||
|
ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logical_line_and_col(&self) -> (Vec<(usize, usize)>, usize, usize) {
|
||||||
|
let ranges = self.logical_line_ranges();
|
||||||
|
for (line, &(start, end)) in ranges.iter().enumerate() {
|
||||||
|
if self.cursor <= end {
|
||||||
|
return (ranges, line, self.cursor.saturating_sub(start));
|
||||||
|
}
|
||||||
|
if let Some(&(next_start, _)) = ranges.get(line + 1)
|
||||||
|
&& self.cursor < next_start
|
||||||
|
{
|
||||||
|
return (ranges, line + 1, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let line = ranges.len().saturating_sub(1);
|
||||||
|
let col = self.cursor.saturating_sub(ranges[line].0);
|
||||||
|
(ranges, line, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_home(&mut self) {
|
||||||
|
let (ranges, line, _) = self.logical_line_and_col();
|
||||||
|
self.cursor = ranges[line].0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_end(&mut self) {
|
pub fn move_end(&mut self) {
|
||||||
while self.cursor < self.atoms.len() {
|
let (ranges, line, _) = self.logical_line_and_col();
|
||||||
if matches!(self.atoms[self.cursor], Atom::Char('\n')) {
|
self.cursor = ranges[line].1;
|
||||||
break;
|
|
||||||
}
|
|
||||||
self.cursor += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move one logical line up, preserving column (atom count from
|
/// Move one logical line up, preserving column (atom count from
|
||||||
/// current line start). No-op if already on the first line.
|
/// current line start). No-op if already on the first line.
|
||||||
pub fn move_up(&mut self) {
|
pub fn move_up(&mut self) {
|
||||||
let (line_start, col) = self.line_start_and_col();
|
let (ranges, line, col) = self.logical_line_and_col();
|
||||||
if line_start == 0 {
|
if line == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// `atoms[line_start - 1]` is the '\n' that opens the current
|
let (start, end) = ranges[line - 1];
|
||||||
// line; find the previous line's start.
|
self.cursor = start + col.min(end - start);
|
||||||
let prev_end = line_start - 1;
|
|
||||||
let mut prev_start = 0;
|
|
||||||
for i in (0..prev_end).rev() {
|
|
||||||
if matches!(self.atoms[i], Atom::Char('\n')) {
|
|
||||||
prev_start = i + 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let prev_len = prev_end - prev_start;
|
|
||||||
self.cursor = prev_start + col.min(prev_len);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move one logical line down, preserving column.
|
/// Move one logical line down, preserving column.
|
||||||
pub fn move_down(&mut self) {
|
pub fn move_down(&mut self) {
|
||||||
let (line_start, col) = self.line_start_and_col();
|
let (ranges, line, col) = self.logical_line_and_col();
|
||||||
// End of current line.
|
let Some(&(start, end)) = ranges.get(line + 1) else {
|
||||||
let mut cur_end = self.atoms.len();
|
return;
|
||||||
for i in line_start..self.atoms.len() {
|
};
|
||||||
if matches!(self.atoms[i], Atom::Char('\n')) {
|
self.cursor = start + col.min(end - start);
|
||||||
cur_end = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cur_end == self.atoms.len() {
|
|
||||||
return; // no next line
|
|
||||||
}
|
|
||||||
let next_start = cur_end + 1;
|
|
||||||
let mut next_end = self.atoms.len();
|
|
||||||
for i in next_start..self.atoms.len() {
|
|
||||||
if matches!(self.atoms[i], Atom::Char('\n')) {
|
|
||||||
next_end = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let next_len = next_end - next_start;
|
|
||||||
self.cursor = next_start + col.min(next_len);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn line_start_and_col(&self) -> (usize, usize) {
|
|
||||||
let mut start = 0;
|
|
||||||
for i in (0..self.cursor).rev() {
|
|
||||||
if matches!(self.atoms[i], Atom::Char('\n')) {
|
|
||||||
start = i + 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(start, self.cursor - start)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
|
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
|
||||||
@@ -497,6 +582,12 @@ impl InputBuffer {
|
|||||||
content: p.content.clone(),
|
content: p.content.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Atom::PasteArtifact(artifact) => {
|
||||||
|
flush_text(&mut buf, &mut out);
|
||||||
|
out.push(protocol::Segment::PasteArtifact {
|
||||||
|
artifact: artifact.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
Atom::FileRef(r) => {
|
Atom::FileRef(r) => {
|
||||||
flush_text(&mut buf, &mut out);
|
flush_text(&mut buf, &mut out);
|
||||||
out.push(protocol::Segment::FileRef {
|
out.push(protocol::Segment::FileRef {
|
||||||
@@ -535,6 +626,7 @@ impl InputBuffer {
|
|||||||
let mut cursor_row: u16 = 0;
|
let mut cursor_row: u16 = 0;
|
||||||
let mut cursor_col: u16 = 0;
|
let mut cursor_col: u16 = 0;
|
||||||
let mut cursor_set = false;
|
let mut cursor_set = false;
|
||||||
|
let mut previous_was_cr = false;
|
||||||
|
|
||||||
// Record cursor once, at the point right before `atom` would be
|
// Record cursor once, at the point right before `atom` would be
|
||||||
// placed — accounting for a wrap that the atom itself will cause.
|
// placed — accounting for a wrap that the atom itself will cause.
|
||||||
@@ -558,7 +650,7 @@ impl InputBuffer {
|
|||||||
for (i, atom) in self.atoms.iter().enumerate() {
|
for (i, atom) in self.atoms.iter().enumerate() {
|
||||||
if !cursor_set && i == self.cursor {
|
if !cursor_set && i == self.cursor {
|
||||||
let leading = match atom {
|
let leading = match atom {
|
||||||
Atom::Char('\n') => 0,
|
Atom::Char('\n' | '\r') => 0,
|
||||||
Atom::Char(c) => UnicodeWidthChar::width(*c).unwrap_or(0),
|
Atom::Char(c) => UnicodeWidthChar::width(*c).unwrap_or(0),
|
||||||
other => other
|
other => other
|
||||||
.chip()
|
.chip()
|
||||||
@@ -573,6 +665,21 @@ impl InputBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match atom {
|
match atom {
|
||||||
|
Atom::Char('\r') => {
|
||||||
|
flush_pending(
|
||||||
|
&mut pending,
|
||||||
|
&mut pending_width,
|
||||||
|
pending_style,
|
||||||
|
&mut rows,
|
||||||
|
&mut row_width,
|
||||||
|
);
|
||||||
|
rows.push(Vec::new());
|
||||||
|
row_width = 0;
|
||||||
|
previous_was_cr = true;
|
||||||
|
}
|
||||||
|
Atom::Char('\n') if previous_was_cr => {
|
||||||
|
previous_was_cr = false;
|
||||||
|
}
|
||||||
Atom::Char('\n') => {
|
Atom::Char('\n') => {
|
||||||
flush_pending(
|
flush_pending(
|
||||||
&mut pending,
|
&mut pending,
|
||||||
@@ -583,8 +690,10 @@ impl InputBuffer {
|
|||||||
);
|
);
|
||||||
rows.push(Vec::new());
|
rows.push(Vec::new());
|
||||||
row_width = 0;
|
row_width = 0;
|
||||||
|
previous_was_cr = false;
|
||||||
}
|
}
|
||||||
Atom::Char(c) => {
|
Atom::Char(c) => {
|
||||||
|
previous_was_cr = false;
|
||||||
let cw = UnicodeWidthChar::width(*c).unwrap_or(0);
|
let cw = UnicodeWidthChar::width(*c).unwrap_or(0);
|
||||||
if pending_style != text_style && !pending.is_empty() {
|
if pending_style != text_style && !pending.is_empty() {
|
||||||
flush_pending(
|
flush_pending(
|
||||||
@@ -608,6 +717,7 @@ impl InputBuffer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
|
previous_was_cr = false;
|
||||||
let (chip_style, label) = other.chip().expect("non-char atom has a chip");
|
let (chip_style, label) = other.chip().expect("non-char atom has a chip");
|
||||||
if pending_style != chip_style && !pending.is_empty() {
|
if pending_style != chip_style && !pending.is_empty() {
|
||||||
flush_pending(
|
flush_pending(
|
||||||
@@ -848,6 +958,161 @@ mod render_viewport_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod paste_policy_tests {
|
||||||
|
use super::*;
|
||||||
|
use protocol::Segment;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Fixture {
|
||||||
|
max_plain_text_chars: usize,
|
||||||
|
max_plain_text_logical_lines: usize,
|
||||||
|
cases: Vec<FixtureCase>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FixtureCase {
|
||||||
|
name: String,
|
||||||
|
parts: Vec<FixturePart>,
|
||||||
|
char_count: usize,
|
||||||
|
logical_line_count: usize,
|
||||||
|
presentation: FixturePresentation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FixturePart {
|
||||||
|
value: String,
|
||||||
|
repeat: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
enum FixturePresentation {
|
||||||
|
Text,
|
||||||
|
Chip,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> Fixture {
|
||||||
|
serde_json::from_str(include_str!(
|
||||||
|
"../../../tests/fixtures/composer-paste-policy.json"
|
||||||
|
))
|
||||||
|
.expect("shared composer paste policy fixture must be valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_content(case: &FixtureCase) -> String {
|
||||||
|
case.parts
|
||||||
|
.iter()
|
||||||
|
.map(|part| part.value.repeat(part.repeat))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tui_follows_shared_paste_presentation_contract() {
|
||||||
|
let fixture = fixture();
|
||||||
|
assert_eq!(fixture.max_plain_text_chars, MAX_PLAIN_TEXT_PASTE_CHARS);
|
||||||
|
assert_eq!(
|
||||||
|
fixture.max_plain_text_logical_lines,
|
||||||
|
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||||
|
);
|
||||||
|
|
||||||
|
for case in fixture.cases {
|
||||||
|
let content = fixture_content(&case);
|
||||||
|
let measurement = measure_paste(&content);
|
||||||
|
let expected_presentation = match case.presentation {
|
||||||
|
FixturePresentation::Text => PastePresentation::Text,
|
||||||
|
FixturePresentation::Chip => PastePresentation::Chip,
|
||||||
|
};
|
||||||
|
assert_eq!(measurement.chars, case.char_count, "{} chars", case.name);
|
||||||
|
assert_eq!(
|
||||||
|
measurement.logical_lines, case.logical_line_count,
|
||||||
|
"{} logical lines",
|
||||||
|
case.name
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
measurement.presentation(),
|
||||||
|
expected_presentation,
|
||||||
|
"{} presentation",
|
||||||
|
case.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_paste_is_editable_text_at_the_cursor() {
|
||||||
|
let mut buffer = InputBuffer::new();
|
||||||
|
buffer.insert_str("ac");
|
||||||
|
buffer.move_left();
|
||||||
|
buffer.insert_paste("b".to_owned());
|
||||||
|
|
||||||
|
assert_eq!(buffer.plain_text(), "abc");
|
||||||
|
assert!(
|
||||||
|
buffer
|
||||||
|
.atoms
|
||||||
|
.iter()
|
||||||
|
.all(|atom| matches!(atom, Atom::Char(_)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
buffer.submit_segments(),
|
||||||
|
vec![Segment::text("abc".to_owned())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_multiline_paste_preserves_original_line_endings_as_text() {
|
||||||
|
let content = "ab\r\ncd\ref";
|
||||||
|
let mut buffer = InputBuffer::new();
|
||||||
|
buffer.insert_paste(content.to_owned());
|
||||||
|
|
||||||
|
assert_eq!(buffer.plain_text(), content);
|
||||||
|
assert!(
|
||||||
|
buffer
|
||||||
|
.atoms
|
||||||
|
.iter()
|
||||||
|
.all(|atom| matches!(atom, Atom::Char(_)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
buffer.submit_segments(),
|
||||||
|
vec![Segment::text(content.to_owned())]
|
||||||
|
);
|
||||||
|
|
||||||
|
let rendered: Vec<String> = buffer
|
||||||
|
.render(80)
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| {
|
||||||
|
line.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.content.as_ref())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(rendered, vec!["ab", "cd", "ef"]);
|
||||||
|
|
||||||
|
buffer.move_up();
|
||||||
|
assert_eq!(buffer.cursor, 6);
|
||||||
|
buffer.move_up();
|
||||||
|
assert_eq!(buffer.cursor, 2);
|
||||||
|
buffer.move_down();
|
||||||
|
assert_eq!(buffer.cursor, 6);
|
||||||
|
buffer.move_home();
|
||||||
|
assert_eq!(buffer.cursor, 4);
|
||||||
|
buffer.move_end();
|
||||||
|
assert_eq!(buffer.cursor, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_paste_is_a_noop() {
|
||||||
|
let mut buffer = InputBuffer::new();
|
||||||
|
buffer.insert_str("unchanged");
|
||||||
|
let paste_id = buffer.next_paste_id;
|
||||||
|
buffer.insert_paste(String::new());
|
||||||
|
|
||||||
|
assert_eq!(buffer.plain_text(), "unchanged");
|
||||||
|
assert_eq!(buffer.next_paste_id, paste_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod submit_segments_tests {
|
mod submit_segments_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -873,7 +1138,8 @@ mod submit_segments_tests {
|
|||||||
for c in "see ".chars() {
|
for c in "see ".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
buf.insert_paste("line1\nline2".into());
|
let pasted = "line1\nline2\nline3\nline4";
|
||||||
|
buf.insert_paste(pasted.into());
|
||||||
for c in " end".chars() {
|
for c in " end".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
@@ -890,9 +1156,9 @@ mod submit_segments_tests {
|
|||||||
content,
|
content,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(content, "line1\nline2");
|
assert_eq!(content, pasted);
|
||||||
assert_eq!(*chars, "line1\nline2".chars().count() as u32);
|
assert_eq!(*chars, pasted.chars().count() as u32);
|
||||||
assert_eq!(*lines, 2);
|
assert_eq!(*lines, 4);
|
||||||
}
|
}
|
||||||
other => panic!("expected Paste, got {other:?}"),
|
other => panic!("expected Paste, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -902,6 +1168,45 @@ mod submit_segments_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restored_direct_paste_remains_a_typed_segment_without_reclassification() {
|
||||||
|
let original = Segment::Paste {
|
||||||
|
id: 7,
|
||||||
|
chars: 1,
|
||||||
|
lines: 1,
|
||||||
|
content: "x".to_owned(),
|
||||||
|
};
|
||||||
|
let mut buf = InputBuffer::new();
|
||||||
|
buf.replace_with_segments(std::slice::from_ref(&original));
|
||||||
|
|
||||||
|
assert_eq!(buf.submit_segments(), vec![original]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restored_paste_artifact_remains_a_typed_segment() {
|
||||||
|
let artifact = protocol::PasteArtifactRef {
|
||||||
|
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b2".to_string(),
|
||||||
|
created_at_ms: 1_700_000_000_000,
|
||||||
|
media_type: protocol::PasteArtifactMediaType::TextPlainUtf8,
|
||||||
|
availability: protocol::PasteArtifactAvailability::Available,
|
||||||
|
byte_len: 65_536,
|
||||||
|
char_count: 65_530,
|
||||||
|
line_count: 200,
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
source_entry_id: "entry-1".to_string(),
|
||||||
|
};
|
||||||
|
let original = Segment::PasteArtifact {
|
||||||
|
artifact: artifact.clone(),
|
||||||
|
};
|
||||||
|
let mut buf = InputBuffer::new();
|
||||||
|
buf.replace_with_segments(std::slice::from_ref(&original));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
buf.submit_segments(),
|
||||||
|
vec![Segment::PasteArtifact { artifact }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_buffer_yields_empty_segments() {
|
fn empty_buffer_yields_empty_segments() {
|
||||||
let buf = InputBuffer::new();
|
let buf = InputBuffer::new();
|
||||||
@@ -911,7 +1216,7 @@ mod submit_segments_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn leading_paste_does_not_emit_empty_text() {
|
fn leading_paste_does_not_emit_empty_text() {
|
||||||
let mut buf = InputBuffer::new();
|
let mut buf = InputBuffer::new();
|
||||||
buf.insert_paste("X".into());
|
buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||||
let segs = buf.submit_segments();
|
let segs = buf.submit_segments();
|
||||||
assert_eq!(segs.len(), 1);
|
assert_eq!(segs.len(), 1);
|
||||||
assert!(matches!(segs[0], Segment::Paste { .. }));
|
assert!(matches!(segs[0], Segment::Paste { .. }));
|
||||||
@@ -1011,7 +1316,7 @@ mod completion_prefix_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn trigger_after_chip_atom() {
|
fn trigger_after_chip_atom() {
|
||||||
let mut buf = InputBuffer::new();
|
let mut buf = InputBuffer::new();
|
||||||
buf.insert_paste("X".into());
|
buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||||
for c in "@sr".chars() {
|
for c in "@sr".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
@@ -1120,7 +1425,7 @@ mod word_motion_tests {
|
|||||||
for c in "foo ".chars() {
|
for c in "foo ".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
buf.insert_paste("anything".into());
|
buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||||
for c in " bar".chars() {
|
for c in " bar".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
@@ -1219,7 +1524,9 @@ mod word_motion_tests {
|
|||||||
for a in &buf.atoms {
|
for a in &buf.atoms {
|
||||||
match a {
|
match a {
|
||||||
Atom::Char(c) => out.push(*c),
|
Atom::Char(c) => out.push(*c),
|
||||||
Atom::Paste(_) | Atom::FileRef(_) | Atom::FlowRef(_) => out.push_str("<P>"),
|
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
|
||||||
|
out.push_str("<P>")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -1277,7 +1584,7 @@ mod word_motion_tests {
|
|||||||
for c in "foo ".chars() {
|
for c in "foo ".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
buf.insert_paste("anything".into());
|
buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||||
for c in " bar".chars() {
|
for c in " bar".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ pub(crate) enum StandalonePickerError {
|
|||||||
#[error("standalone Worker state is unavailable: {0}")]
|
#[error("standalone Worker state is unavailable: {0}")]
|
||||||
StateStore(#[source] standalone::StandaloneStoreError),
|
StateStore(#[source] standalone::StandaloneStoreError),
|
||||||
#[error(
|
#[error(
|
||||||
"no standalone Workers found for this cwd; use `yoi --local --resume --all` to include all cwd identities"
|
"no standalone Workers found for this cwd; use `yoi --local resume --all` to include all cwd identities"
|
||||||
)]
|
)]
|
||||||
NoWorkers { include_all: bool },
|
NoWorkers { include_all: bool },
|
||||||
#[error("standalone Worker picker I/O failed: {0}")]
|
#[error("standalone Worker picker I/O failed: {0}")]
|
||||||
|
|||||||
@@ -1296,6 +1296,18 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
|
|||||||
Style::default().fg(Color::Magenta),
|
Style::default().fg(Color::Magenta),
|
||||||
format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"),
|
format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"),
|
||||||
),
|
),
|
||||||
|
Segment::PasteArtifact { artifact } => (
|
||||||
|
Style::default().fg(Color::Magenta),
|
||||||
|
format!(
|
||||||
|
"[Paste artifact {} | {} chars, {} lines, {}, {}, created {} ms]",
|
||||||
|
artifact.artifact_id,
|
||||||
|
artifact.char_count,
|
||||||
|
artifact.line_count,
|
||||||
|
artifact.media_type.as_str(),
|
||||||
|
artifact.availability.as_str(),
|
||||||
|
artifact.created_at_ms
|
||||||
|
),
|
||||||
|
),
|
||||||
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
|
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
|
||||||
Segment::Flow { selector } => (
|
Segment::Flow { selector } => (
|
||||||
Style::default().fg(Color::Yellow),
|
Style::default().fg(Color::Yellow),
|
||||||
@@ -1314,6 +1326,15 @@ fn segment_display_text(seg: &Segment) -> String {
|
|||||||
Segment::Paste {
|
Segment::Paste {
|
||||||
id, chars, lines, ..
|
id, chars, lines, ..
|
||||||
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
|
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
|
||||||
|
Segment::PasteArtifact { artifact } => format!(
|
||||||
|
"[Paste artifact {} | {} chars, {} lines, {}, {}, created {} ms]",
|
||||||
|
artifact.artifact_id,
|
||||||
|
artifact.char_count,
|
||||||
|
artifact.line_count,
|
||||||
|
artifact.media_type.as_str(),
|
||||||
|
artifact.availability.as_str(),
|
||||||
|
artifact.created_at_ms
|
||||||
|
),
|
||||||
Segment::FileRef { path } => format!("@{path}"),
|
Segment::FileRef { path } => format!("@{path}"),
|
||||||
Segment::Flow { selector } => format!("[Flow: {selector}]"),
|
Segment::Flow { selector } => format!("[Flow: {selector}]"),
|
||||||
Segment::Unknown => "[unknown segment]".to_owned(),
|
Segment::Unknown => "[unknown segment]".to_owned(),
|
||||||
|
|||||||
@@ -898,6 +898,20 @@ where
|
|||||||
crate::spawn::tool::ParentNotificationTarget::Buffer(worker.notify_buffer_handle())
|
crate::spawn::tool::ParentNotificationTarget::Buffer(worker.notify_buffer_handle())
|
||||||
});
|
});
|
||||||
let prompts = worker.prompts().clone();
|
let prompts = worker.prompts().clone();
|
||||||
|
let paste_store = worker.store().clone();
|
||||||
|
let paste_session_id = worker.session_id();
|
||||||
|
worker
|
||||||
|
.engine_mut()
|
||||||
|
.register_tool(crate::paste_artifact_tool::search_input_artifact_tool(
|
||||||
|
paste_store.clone(),
|
||||||
|
paste_session_id,
|
||||||
|
));
|
||||||
|
worker
|
||||||
|
.engine_mut()
|
||||||
|
.register_tool(crate::paste_artifact_tool::read_input_artifact_tool(
|
||||||
|
paste_store,
|
||||||
|
paste_session_id,
|
||||||
|
));
|
||||||
// Resolve the existing Worker–Workdir binding into the domain provider.
|
// Resolve the existing Worker–Workdir binding into the domain provider.
|
||||||
// Tools only consume the provider handle; they do not own its root, cwd,
|
// Tools only consume the provider handle; they do not own its root, cwd,
|
||||||
// scope, or lifecycle. No-workdir Workers expose no local tools.
|
// scope, or lifecycle. No-workdir Workers expose no local tools.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod hook;
|
|||||||
pub(crate) mod in_flight;
|
pub(crate) mod in_flight;
|
||||||
pub mod ipc;
|
pub mod ipc;
|
||||||
pub mod model_client;
|
pub mod model_client;
|
||||||
|
mod paste_artifact_tool;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod runtime_command;
|
pub mod runtime_command;
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
//! Bounded model-facing access to session-owned large paste artifacts.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use session_store::{SessionId, Store, StoreError};
|
||||||
|
|
||||||
|
const MAX_QUERY_BYTES: usize = 256;
|
||||||
|
const DEFAULT_SEARCH_RESULTS: usize = 20;
|
||||||
|
const MAX_SEARCH_RESULTS: usize = 100;
|
||||||
|
const MAX_SNIPPET_CHARS: usize = 300;
|
||||||
|
const DEFAULT_READ_BYTES: usize = 8 * 1024;
|
||||||
|
const MAX_READ_BYTES: usize = 16 * 1024;
|
||||||
|
|
||||||
|
const SEARCH_DESCRIPTION: &str = "Search one large pasted-input artifact owned by the current Worker. Returns bounded matching line snippets; never returns the whole artifact.";
|
||||||
|
const READ_DESCRIPTION: &str = "Read a bounded UTF-8 byte range from one large pasted-input artifact owned by the current Worker. Use next_offset for repeated calls instead of requesting the whole artifact.";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ArtifactAccess<St: Store + Clone> {
|
||||||
|
store: St,
|
||||||
|
session_id: SessionId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct SearchInputArtifactInput {
|
||||||
|
/// Opaque artifact id from a large-paste history reference.
|
||||||
|
artifact_id: String,
|
||||||
|
/// Literal case-sensitive text to find.
|
||||||
|
query: String,
|
||||||
|
/// Maximum matching lines to return (1..=100).
|
||||||
|
max_results: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct SearchInputArtifactOutput {
|
||||||
|
artifact_id: String,
|
||||||
|
matches: Vec<SearchMatch>,
|
||||||
|
truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct SearchMatch {
|
||||||
|
line: u64,
|
||||||
|
byte_offset: u64,
|
||||||
|
snippet: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchInputArtifactTool<St: Store + Clone> {
|
||||||
|
access: ArtifactAccess<St>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<St> Tool for SearchInputArtifactTool<St>
|
||||||
|
where
|
||||||
|
St: Store + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input_json: &str,
|
||||||
|
_context: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let input: SearchInputArtifactInput =
|
||||||
|
serde_json::from_str(input_json).map_err(|error| {
|
||||||
|
ToolError::InvalidArgument(format!("invalid SearchInputArtifact input: {error}"))
|
||||||
|
})?;
|
||||||
|
if input.query.is_empty() || input.query.len() > MAX_QUERY_BYTES {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"query must contain 1..=256 UTF-8 bytes".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let max_results = input
|
||||||
|
.max_results
|
||||||
|
.unwrap_or(DEFAULT_SEARCH_RESULTS)
|
||||||
|
.clamp(1, MAX_SEARCH_RESULTS);
|
||||||
|
let (_, content) = self
|
||||||
|
.access
|
||||||
|
.store
|
||||||
|
.read_paste_artifact(self.access.session_id, &input.artifact_id)
|
||||||
|
.map_err(tool_store_error)?;
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
let mut truncated = false;
|
||||||
|
let mut byte_offset = 0_u64;
|
||||||
|
for (index, raw_line) in content.split_inclusive('\n').enumerate() {
|
||||||
|
let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
|
||||||
|
if line.contains(&input.query) {
|
||||||
|
if matches.len() == max_results {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
matches.push(SearchMatch {
|
||||||
|
line: index as u64 + 1,
|
||||||
|
byte_offset,
|
||||||
|
snippet: truncate_chars(line, MAX_SNIPPET_CHARS),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
byte_offset += raw_line.len() as u64;
|
||||||
|
}
|
||||||
|
json_output(
|
||||||
|
format!("Found {} matching pasted-input line(s).", matches.len()),
|
||||||
|
&SearchInputArtifactOutput {
|
||||||
|
artifact_id: input.artifact_id,
|
||||||
|
matches,
|
||||||
|
truncated,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct ReadInputArtifactInput {
|
||||||
|
/// Opaque artifact id from a large-paste history reference.
|
||||||
|
artifact_id: String,
|
||||||
|
/// UTF-8 byte offset to start reading. Defaults to 0 and must be a character boundary.
|
||||||
|
offset: Option<u64>,
|
||||||
|
/// Maximum UTF-8 bytes to return (4..=16384). Defaults to 8192.
|
||||||
|
max_bytes: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ReadInputArtifactOutput {
|
||||||
|
artifact_id: String,
|
||||||
|
offset: u64,
|
||||||
|
content: String,
|
||||||
|
next_offset: Option<u64>,
|
||||||
|
truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReadInputArtifactTool<St: Store + Clone> {
|
||||||
|
access: ArtifactAccess<St>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<St> Tool for ReadInputArtifactTool<St>
|
||||||
|
where
|
||||||
|
St: Store + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input_json: &str,
|
||||||
|
_context: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let input: ReadInputArtifactInput = serde_json::from_str(input_json).map_err(|error| {
|
||||||
|
ToolError::InvalidArgument(format!("invalid ReadInputArtifact input: {error}"))
|
||||||
|
})?;
|
||||||
|
let offset = input.offset.unwrap_or(0);
|
||||||
|
let max_bytes = input
|
||||||
|
.max_bytes
|
||||||
|
.unwrap_or(DEFAULT_READ_BYTES)
|
||||||
|
.clamp(4, MAX_READ_BYTES);
|
||||||
|
let (_, content) = self
|
||||||
|
.access
|
||||||
|
.store
|
||||||
|
.read_paste_artifact(self.access.session_id, &input.artifact_id)
|
||||||
|
.map_err(tool_store_error)?;
|
||||||
|
let offset = usize::try_from(offset).map_err(|_| {
|
||||||
|
ToolError::InvalidArgument("offset exceeds the artifact size".to_string())
|
||||||
|
})?;
|
||||||
|
if offset > content.len() || !content.is_char_boundary(offset) {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"offset must be a UTF-8 character boundary within the artifact".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut end = offset.saturating_add(max_bytes).min(content.len());
|
||||||
|
while end > offset && !content.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
let output = content[offset..end].to_string();
|
||||||
|
let next_offset = (end < content.len()).then_some(end as u64);
|
||||||
|
let truncated = next_offset.is_some();
|
||||||
|
|
||||||
|
json_output(
|
||||||
|
format!("Read {} pasted-input byte(s).", output.len()),
|
||||||
|
&ReadInputArtifactOutput {
|
||||||
|
artifact_id: input.artifact_id,
|
||||||
|
offset: offset as u64,
|
||||||
|
content: output,
|
||||||
|
next_offset,
|
||||||
|
truncated,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn search_input_artifact_tool<St>(store: St, session_id: SessionId) -> ToolDefinition
|
||||||
|
where
|
||||||
|
St: Store + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Arc::new(move || {
|
||||||
|
let schema = serde_json::to_value(schemars::schema_for!(SearchInputArtifactInput))
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
|
let meta = ToolMeta::new("SearchInputArtifact")
|
||||||
|
.description(SEARCH_DESCRIPTION)
|
||||||
|
.input_schema(schema);
|
||||||
|
let tool: Arc<dyn Tool> = Arc::new(SearchInputArtifactTool {
|
||||||
|
access: ArtifactAccess {
|
||||||
|
store: store.clone(),
|
||||||
|
session_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(meta, tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_input_artifact_tool<St>(store: St, session_id: SessionId) -> ToolDefinition
|
||||||
|
where
|
||||||
|
St: Store + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Arc::new(move || {
|
||||||
|
let schema = serde_json::to_value(schemars::schema_for!(ReadInputArtifactInput))
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
|
let meta = ToolMeta::new("ReadInputArtifact")
|
||||||
|
.description(READ_DESCRIPTION)
|
||||||
|
.input_schema(schema);
|
||||||
|
let tool: Arc<dyn Tool> = Arc::new(ReadInputArtifactTool {
|
||||||
|
access: ArtifactAccess {
|
||||||
|
store: store.clone(),
|
||||||
|
session_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(meta, tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_output(summary: String, value: &impl Serialize) -> Result<ToolOutput, ToolError> {
|
||||||
|
let content = serde_json::to_string(value)
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
|
Ok(ToolOutput {
|
||||||
|
summary,
|
||||||
|
content: Some(content),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_store_error(error: StoreError) -> ToolError {
|
||||||
|
let message = match error {
|
||||||
|
StoreError::PasteArtifactNotFound(_) => "paste artifact not found",
|
||||||
|
StoreError::PasteArtifactIntegrity(_) | StoreError::Corrupt { .. } => {
|
||||||
|
"paste artifact failed its integrity check"
|
||||||
|
}
|
||||||
|
StoreError::PasteArtifactUnsupported => "paste artifact storage is unavailable",
|
||||||
|
_ => "paste artifact is unavailable",
|
||||||
|
};
|
||||||
|
ToolError::ExecutionFailed(message.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_chars(value: &str, limit: usize) -> String {
|
||||||
|
let mut chars = value.chars();
|
||||||
|
let truncated = chars.by_ref().take(limit).collect::<String>();
|
||||||
|
if chars.next().is_some() {
|
||||||
|
format!("{truncated}…")
|
||||||
|
} else {
|
||||||
|
truncated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use agen::tool::ToolExecutionContext;
|
||||||
|
use session_store::{FsStore, PasteArtifactLimits, Store, new_session_id};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_and_read_are_bounded_and_owner_scoped() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let store = FsStore::new(temp.path()).unwrap();
|
||||||
|
let owner = new_session_id();
|
||||||
|
let other = new_session_id();
|
||||||
|
let content = (0..700)
|
||||||
|
.map(|index| format!("line {index}: needle {}", "x".repeat(80)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let artifact = store
|
||||||
|
.write_paste_artifact(owner, "entry-1", &content, PasteArtifactLimits::default())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let search = SearchInputArtifactTool {
|
||||||
|
access: ArtifactAccess {
|
||||||
|
store: store.clone(),
|
||||||
|
session_id: owner,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let searched = search
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"artifact_id": artifact.artifact_id,
|
||||||
|
"query": "needle",
|
||||||
|
"max_results": 3
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
ToolExecutionContext::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let searched: serde_json::Value =
|
||||||
|
serde_json::from_str(searched.content.as_deref().unwrap()).unwrap();
|
||||||
|
assert_eq!(searched["matches"].as_array().unwrap().len(), 3);
|
||||||
|
assert_eq!(searched["truncated"], true);
|
||||||
|
|
||||||
|
let read = ReadInputArtifactTool {
|
||||||
|
access: ArtifactAccess {
|
||||||
|
store: store.clone(),
|
||||||
|
session_id: owner,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let read_output = read
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"artifact_id": artifact.artifact_id,
|
||||||
|
"offset": 2,
|
||||||
|
"max_bytes": 999999
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
ToolExecutionContext::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let read_output: serde_json::Value =
|
||||||
|
serde_json::from_str(read_output.content.as_deref().unwrap()).unwrap();
|
||||||
|
assert!(read_output["content"].as_str().unwrap().len() <= MAX_READ_BYTES);
|
||||||
|
assert_eq!(read_output["truncated"], true);
|
||||||
|
assert!(read_output["next_offset"].as_u64().is_some());
|
||||||
|
|
||||||
|
let foreign = ReadInputArtifactTool {
|
||||||
|
access: ArtifactAccess {
|
||||||
|
store,
|
||||||
|
session_id: other,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let error = foreign
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"artifact_id": artifact.artifact_id,
|
||||||
|
"offset": 0,
|
||||||
|
"max_bytes": 1
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
ToolExecutionContext::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("paste artifact not found"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ pub(crate) fn metadata(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn history_entry(
|
pub(crate) fn history_entry(
|
||||||
item: Item,
|
item: Item,
|
||||||
origin: WorkerHistoryProvenance,
|
origin: WorkerHistoryProvenance,
|
||||||
@@ -69,6 +70,21 @@ pub(crate) fn history_entry(
|
|||||||
HistoryEntry::new(item, metadata(origin, None))
|
HistoryEntry::new(item, metadata(origin, None))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn history_entry_with_id(
|
||||||
|
item: Item,
|
||||||
|
entry_id: SessionHistoryEntryId,
|
||||||
|
origin: WorkerHistoryProvenance,
|
||||||
|
) -> HistoryEntry<SessionHistoryMetadata> {
|
||||||
|
HistoryEntry::new(
|
||||||
|
item,
|
||||||
|
SessionHistoryMetadata {
|
||||||
|
entry_id,
|
||||||
|
origin,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn to_logged_history_entry(
|
pub(crate) fn to_logged_history_entry(
|
||||||
entry: &HistoryEntry<SessionHistoryMetadata>,
|
entry: &HistoryEntry<SessionHistoryMetadata>,
|
||||||
) -> LoggedHistoryEntry {
|
) -> LoggedHistoryEntry {
|
||||||
|
|||||||
+218
-12
@@ -15,8 +15,8 @@ use agen::{
|
|||||||
};
|
};
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use session_store::{
|
use session_store::{
|
||||||
LogEntry, PromptRenderProvenance, SegmentId, SessionExtension, SessionId, Store, StoreError,
|
LogEntry, PasteArtifactLimits, PromptRenderProvenance, SegmentId, SessionExtension, SessionId,
|
||||||
SystemItem, segment_log,
|
Store, StoreError, SystemItem, segment_log,
|
||||||
};
|
};
|
||||||
use session_store::{
|
use session_store::{
|
||||||
WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerReclaimedChild,
|
WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerReclaimedChild,
|
||||||
@@ -25,10 +25,12 @@ use session_store::{
|
|||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::segment_log_sink::SegmentLogSink;
|
use crate::segment_log_sink::SegmentLogSink;
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::session_history::history_entry;
|
||||||
use crate::session_history::{
|
use crate::session_history::{
|
||||||
SessionHistoryDerivation, SessionHistoryMetadata, WorkerHistoryProvenance, history_entry,
|
SessionHistoryDerivation, SessionHistoryEntryId, SessionHistoryMetadata,
|
||||||
metadata as new_history_metadata, restore_history_entries, to_logged_history_entry,
|
WorkerHistoryProvenance, history_entry_with_id, metadata as new_history_metadata,
|
||||||
worker_subject,
|
restore_history_entries, to_logged_history_entry, worker_subject,
|
||||||
};
|
};
|
||||||
|
|
||||||
use manifest::{
|
use manifest::{
|
||||||
@@ -58,6 +60,7 @@ use crate::internal_worker::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const COMPACTION_EXTENSION_DOMAIN: &str = "yoi.compaction";
|
const COMPACTION_EXTENSION_DOMAIN: &str = "yoi.compaction";
|
||||||
|
const LARGE_PASTE_INLINE_MAX_BYTES: usize = 32 * 1024;
|
||||||
const WORKER_ORCHESTRATION_INSTRUCTION_ID: &str = "worker.orchestration";
|
const WORKER_ORCHESTRATION_INSTRUCTION_ID: &str = "worker.orchestration";
|
||||||
const WORKER_ORCHESTRATION_PROMPT_REF: &str = "common.worker_orchestration";
|
const WORKER_ORCHESTRATION_PROMPT_REF: &str = "common.worker_orchestration";
|
||||||
|
|
||||||
@@ -2797,7 +2800,15 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
St: Clone + 'static,
|
St: Clone + 'static,
|
||||||
F: FnOnce(),
|
F: FnOnce(),
|
||||||
{
|
{
|
||||||
let (input, pending_flow_state, flow_projection) = self.prepare_flow_input(input)?;
|
let (mut input, pending_flow_state, flow_projection) = self.prepare_flow_input(input)?;
|
||||||
|
let projected_entry_ids = if flow_projection.is_some() {
|
||||||
|
(0..input.len())
|
||||||
|
.map(|_| SessionHistoryEntryId::new())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
vec![SessionHistoryEntryId::new()]
|
||||||
|
};
|
||||||
|
self.materialize_large_pastes(&mut input, &projected_entry_ids, flow_projection.is_some())?;
|
||||||
if let Some(state) = pending_flow_state.as_ref() {
|
if let Some(state) = pending_flow_state.as_ref() {
|
||||||
let payload = serde_json::to_value(state).map_err(|error| {
|
let payload = serde_json::to_value(state).map_err(|error| {
|
||||||
WorkerError::FlowInput(format!("serialize Flow runtime state: {error}"))
|
WorkerError::FlowInput(format!("serialize Flow runtime state: {error}"))
|
||||||
@@ -2830,7 +2841,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
trigger: protocol::InvokeKind::UserSend,
|
trigger: protocol::InvokeKind::UserSend,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let projected_input = self.projected_input_history(&input, flow_projection.as_ref());
|
let projected_input =
|
||||||
|
self.projected_input_history(&input, flow_projection.as_ref(), &projected_entry_ids);
|
||||||
|
|
||||||
// Persist original typed segments together with the exact ordered
|
// Persist original typed segments together with the exact ordered
|
||||||
// model-visible item+origin projection before any entry becomes live.
|
// model-visible item+origin projection before any entry becomes live.
|
||||||
@@ -3064,17 +3076,61 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn materialize_large_pastes(
|
||||||
|
&self,
|
||||||
|
input: &mut [Segment],
|
||||||
|
projected_entry_ids: &[SessionHistoryEntryId],
|
||||||
|
one_entry_per_segment: bool,
|
||||||
|
) -> Result<(), WorkerError> {
|
||||||
|
for (index, segment) in input.iter_mut().enumerate() {
|
||||||
|
if let Segment::PasteArtifact { artifact } = segment {
|
||||||
|
let (stored, _) = self
|
||||||
|
.store
|
||||||
|
.read_paste_artifact(self.session_id(), &artifact.artifact_id)?;
|
||||||
|
if &stored != artifact {
|
||||||
|
return Err(WorkerError::Store(StoreError::PasteArtifactIntegrity(
|
||||||
|
artifact.artifact_id.clone(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Segment::Paste { content, .. } = segment else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if content.len() <= LARGE_PASTE_INLINE_MAX_BYTES {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry_index = if one_entry_per_segment { index } else { 0 };
|
||||||
|
let source_entry_id = projected_entry_ids
|
||||||
|
.get(entry_index)
|
||||||
|
.expect("projected input id exists for every paste")
|
||||||
|
.0
|
||||||
|
.as_str();
|
||||||
|
let artifact = self.store.write_paste_artifact(
|
||||||
|
self.session_id(),
|
||||||
|
source_entry_id,
|
||||||
|
content,
|
||||||
|
PasteArtifactLimits::default(),
|
||||||
|
)?;
|
||||||
|
*segment = Segment::PasteArtifact { artifact };
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn projected_input_history(
|
fn projected_input_history(
|
||||||
&self,
|
&self,
|
||||||
input: &[Segment],
|
input: &[Segment],
|
||||||
flow_projection: Option<&PreparedFlowProjection>,
|
flow_projection: Option<&PreparedFlowProjection>,
|
||||||
|
entry_ids: &[SessionHistoryEntryId],
|
||||||
) -> Vec<HistoryEntry<SessionHistoryMetadata>> {
|
) -> Vec<HistoryEntry<SessionHistoryMetadata>> {
|
||||||
if let Some(flow) = flow_projection {
|
if let Some(flow) = flow_projection {
|
||||||
return input
|
return input
|
||||||
.iter()
|
.iter()
|
||||||
.map(|segment| match segment {
|
.zip(entry_ids)
|
||||||
Segment::Flow { .. } => history_entry(
|
.map(|(segment, entry_id)| match segment {
|
||||||
|
Segment::Flow { .. } => history_entry_with_id(
|
||||||
Item::user_message(flow.instructions.clone()),
|
Item::user_message(flow.instructions.clone()),
|
||||||
|
entry_id.clone(),
|
||||||
WorkerHistoryProvenance::FlowInstruction {
|
WorkerHistoryProvenance::FlowInstruction {
|
||||||
selector: flow.selector.clone(),
|
selector: flow.selector.clone(),
|
||||||
definition_id: flow.definition_id.clone(),
|
definition_id: flow.definition_id.clone(),
|
||||||
@@ -3083,8 +3139,9 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
state_id: flow.state_id.clone(),
|
state_id: flow.state_id.clone(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
other => history_entry(
|
other => history_entry_with_id(
|
||||||
Item::user_message(Segment::flatten_to_text(std::slice::from_ref(other))),
|
Item::user_message(Segment::flatten_to_text(std::slice::from_ref(other))),
|
||||||
|
entry_id.clone(),
|
||||||
// Current public submit transport does not carry a
|
// Current public submit transport does not carry a
|
||||||
// trusted account/Worker subject envelope. Fail closed
|
// trusted account/Worker subject envelope. Fail closed
|
||||||
// instead of promoting role=user to HumanInput.
|
// instead of promoting role=user to HumanInput.
|
||||||
@@ -3094,8 +3151,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
vec![history_entry(
|
vec![history_entry_with_id(
|
||||||
Item::user_message(Segment::flatten_to_text(input)),
|
Item::user_message(Segment::flatten_to_text(input)),
|
||||||
|
entry_ids
|
||||||
|
.first()
|
||||||
|
.expect("projected Worker input always has one entry id")
|
||||||
|
.clone(),
|
||||||
WorkerHistoryProvenance::LegacyUnknown,
|
WorkerHistoryProvenance::LegacyUnknown,
|
||||||
)]
|
)]
|
||||||
}
|
}
|
||||||
@@ -6405,6 +6466,11 @@ fn preview_segments(segments: &[Segment]) -> String {
|
|||||||
match segment {
|
match segment {
|
||||||
Segment::Text { content } => preview.push_str(content.trim()),
|
Segment::Text { content } => preview.push_str(content.trim()),
|
||||||
Segment::Paste { content, .. } => preview.push_str(content.trim()),
|
Segment::Paste { content, .. } => preview.push_str(content.trim()),
|
||||||
|
Segment::PasteArtifact { artifact } => {
|
||||||
|
preview.push_str("[Large paste artifact: ");
|
||||||
|
preview.push_str(&artifact.artifact_id);
|
||||||
|
preview.push(']');
|
||||||
|
}
|
||||||
Segment::FileRef { path } => {
|
Segment::FileRef { path } => {
|
||||||
preview.push('@');
|
preview.push('@');
|
||||||
preview.push_str(path);
|
preview.push_str(path);
|
||||||
@@ -7909,7 +7975,9 @@ mod build_summary_prompt_tests {
|
|||||||
FLOW_RUNTIME_EXTENSION_DOMAIN,
|
FLOW_RUNTIME_EXTENSION_DOMAIN,
|
||||||
serde_json::to_value(&state).unwrap(),
|
serde_json::to_value(&state).unwrap(),
|
||||||
);
|
);
|
||||||
let projected = worker.projected_input_history(&segments, projection.as_ref());
|
let projected_ids = vec![SessionHistoryEntryId::new(), SessionHistoryEntryId::new()];
|
||||||
|
let projected =
|
||||||
|
worker.projected_input_history(&segments, projection.as_ref(), &projected_ids);
|
||||||
worker
|
worker
|
||||||
.commit_entry(LogEntry::AnnotatedUserInput {
|
.commit_entry(LogEntry::AnnotatedUserInput {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
@@ -7981,6 +8049,144 @@ mod build_summary_prompt_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn large_paste_is_stored_before_compact_history_is_committed() {
|
||||||
|
let (_dir, worker) = rewind_test_worker().await;
|
||||||
|
let exact = "x".repeat(LARGE_PASTE_INLINE_MAX_BYTES);
|
||||||
|
let exact_ids = vec![SessionHistoryEntryId::new()];
|
||||||
|
let mut exact_input = vec![Segment::Paste {
|
||||||
|
id: 1,
|
||||||
|
chars: exact.len() as u32,
|
||||||
|
lines: 1,
|
||||||
|
content: exact.clone(),
|
||||||
|
}];
|
||||||
|
worker
|
||||||
|
.materialize_large_pastes(&mut exact_input, &exact_ids, false)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(&exact_input[0], Segment::Paste { content, .. } if content == &exact));
|
||||||
|
let mut empty_input = vec![Segment::Paste {
|
||||||
|
id: 0,
|
||||||
|
chars: 0,
|
||||||
|
lines: 0,
|
||||||
|
content: String::new(),
|
||||||
|
}];
|
||||||
|
worker
|
||||||
|
.materialize_large_pastes(&mut empty_input, &exact_ids, false)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
&empty_input[0],
|
||||||
|
Segment::Paste { content, .. } if content.is_empty()
|
||||||
|
));
|
||||||
|
|
||||||
|
let body = format!("{}\n終端\n", "多".repeat(12_000));
|
||||||
|
let entry_id = SessionHistoryEntryId::new();
|
||||||
|
let mut input = vec![Segment::Paste {
|
||||||
|
id: 2,
|
||||||
|
chars: body.chars().count() as u32,
|
||||||
|
lines: 3,
|
||||||
|
content: body.clone(),
|
||||||
|
}];
|
||||||
|
worker
|
||||||
|
.materialize_large_pastes(&mut input, std::slice::from_ref(&entry_id), false)
|
||||||
|
.unwrap();
|
||||||
|
let artifact = match &input[0] {
|
||||||
|
Segment::PasteArtifact { artifact } => artifact.clone(),
|
||||||
|
other => panic!("expected stored paste reference, got {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(artifact.source_entry_id, entry_id.0);
|
||||||
|
assert_eq!(artifact.byte_len, body.len() as u64);
|
||||||
|
assert_eq!(artifact.char_count, body.chars().count() as u64);
|
||||||
|
assert_eq!(
|
||||||
|
worker
|
||||||
|
.store
|
||||||
|
.read_paste_artifact(worker.session_id(), &artifact.artifact_id)
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
worker
|
||||||
|
.materialize_large_pastes(&mut input, &[SessionHistoryEntryId::new()], false)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
&input[0],
|
||||||
|
Segment::PasteArtifact { artifact: retained }
|
||||||
|
if retained.source_entry_id == artifact.source_entry_id
|
||||||
|
));
|
||||||
|
|
||||||
|
let history = worker.projected_input_history(&input, None, &[entry_id]);
|
||||||
|
assert!(!history[0].item.as_text().unwrap().contains("終端"));
|
||||||
|
append_test_entry(
|
||||||
|
&worker,
|
||||||
|
LogEntry::Invoke {
|
||||||
|
ts: segment_log::now_millis(),
|
||||||
|
trigger: protocol::InvokeKind::UserSend,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
worker
|
||||||
|
.commit_entry(LogEntry::AnnotatedUserInput {
|
||||||
|
ts: segment_log::now_millis(),
|
||||||
|
segments: input.clone(),
|
||||||
|
extensions: Vec::new(),
|
||||||
|
history: history.iter().map(to_logged_history_entry).collect(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let location = worker.segment_state.location();
|
||||||
|
let entries = worker
|
||||||
|
.store
|
||||||
|
.read_all(location.session_id, location.segment_id)
|
||||||
|
.unwrap();
|
||||||
|
let persisted = serde_json::to_string(&entries).unwrap();
|
||||||
|
assert!(!persisted.contains("終端"));
|
||||||
|
let state = session_store::collect_state(&entries);
|
||||||
|
assert!(matches!(
|
||||||
|
&state.user_segments[0][0],
|
||||||
|
Segment::PasteArtifact { artifact: restored }
|
||||||
|
if restored.artifact_id == artifact.artifact_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn large_paste_storage_failure_commits_no_input() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let store = session_store::FsStore::new(temp.path()).unwrap();
|
||||||
|
let mut worker = Worker::new(
|
||||||
|
minimal_manifest(),
|
||||||
|
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient),
|
||||||
|
store.clone(),
|
||||||
|
WorkerWorkspaceContext::unavailable(None, "test unavailable"),
|
||||||
|
WorkerFilesystemAuthority::None,
|
||||||
|
Scope::empty(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
worker.ensure_segment_head().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
temp.path()
|
||||||
|
.join(worker.session_id().to_string())
|
||||||
|
.join("artifacts"),
|
||||||
|
"block artifact directory creation",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = worker
|
||||||
|
.run(vec![Segment::Paste {
|
||||||
|
id: 1,
|
||||||
|
chars: (LARGE_PASTE_INLINE_MAX_BYTES + 1) as u32,
|
||||||
|
lines: 1,
|
||||||
|
content: "x".repeat(LARGE_PASTE_INLINE_MAX_BYTES + 1),
|
||||||
|
}])
|
||||||
|
.await;
|
||||||
|
assert!(matches!(result, Err(WorkerError::Store(StoreError::Io(_)))));
|
||||||
|
let location = worker.segment_state.location();
|
||||||
|
let entries = store
|
||||||
|
.read_all(location.session_id, location.segment_id)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| matches!(entry, LogEntry::AnnotatedUserInput { .. }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async fn rewind_test_worker() -> (
|
async fn rewind_test_worker() -> (
|
||||||
tempfile::TempDir,
|
tempfile::TempDir,
|
||||||
Worker<NoopClient, session_store::FsStore>,
|
Worker<NoopClient, session_store::FsStore>,
|
||||||
|
|||||||
@@ -613,7 +613,19 @@ async fn feature_flags_default_to_core_tool_surface_only() {
|
|||||||
|
|
||||||
let request = wait_for_captured_request(&client_for_assert).await;
|
let request = wait_for_captured_request(&client_for_assert).await;
|
||||||
let names = request_tool_names(&request);
|
let names = request_tool_names(&request);
|
||||||
assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
|
assert_eq!(
|
||||||
|
names,
|
||||||
|
vec![
|
||||||
|
"Bash",
|
||||||
|
"Edit",
|
||||||
|
"Glob",
|
||||||
|
"Grep",
|
||||||
|
"Read",
|
||||||
|
"ReadInputArtifact",
|
||||||
|
"SearchInputArtifact",
|
||||||
|
"Write",
|
||||||
|
]
|
||||||
|
);
|
||||||
assert!(!names.iter().any(|name| name == "TaskCreate"));
|
assert!(!names.iter().any(|name| name == "TaskCreate"));
|
||||||
assert!(!names.iter().any(|name| name == "WebSearch"));
|
assert!(!names.iter().any(|name| name == "WebSearch"));
|
||||||
assert!(!names.iter().any(|name| name == "SubWorkerSpawn"));
|
assert!(!names.iter().any(|name| name == "SubWorkerSpawn"));
|
||||||
|
|||||||
@@ -1909,16 +1909,18 @@ impl WorkspaceApi {
|
|||||||
.list_worker_workdir_links(&self.config.workspace_id, worker)?
|
.list_worker_workdir_links(&self.config.workspace_id, worker)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|link| link.unlinked_at.is_none())
|
.find(|link| link.unlinked_at.is_none())
|
||||||
&& let Some(access) = repository_access_request_for_workdir(
|
{
|
||||||
|
let workdir_runtime_id = registered_workdir_runtime_id(self, &link.workdir_id)?;
|
||||||
|
if let Some(access) = repository_access_request_for_workdir(
|
||||||
self,
|
self,
|
||||||
&worker.runtime_id,
|
&workdir_runtime_id,
|
||||||
&link.workdir_id,
|
&link.workdir_id,
|
||||||
&format!("worker-restore:{}", WorkerId::now_v7()),
|
&format!("worker-restore:{}", WorkerId::now_v7()),
|
||||||
)?
|
)? {
|
||||||
{
|
self.runtime
|
||||||
self.runtime
|
.authorize_working_directory_repository_access(&workdir_runtime_id, access)
|
||||||
.authorize_working_directory_repository_access(&worker.runtime_id, access)
|
.map_err(RuntimeRegistryError::into_error)?;
|
||||||
.map_err(RuntimeRegistryError::into_error)?;
|
}
|
||||||
}
|
}
|
||||||
let binding = self
|
let binding = self
|
||||||
.runtime
|
.runtime
|
||||||
@@ -25010,7 +25012,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let app = build_inner_router(api);
|
let app = build_inner_router(api.clone());
|
||||||
|
|
||||||
let runtimes = get_json(app.clone(), "/api/runtimes").await;
|
let runtimes = get_json(app.clone(), "/api/runtimes").await;
|
||||||
let embedded_summary = runtimes["items"]
|
let embedded_summary = runtimes["items"]
|
||||||
@@ -25065,6 +25067,37 @@ mod tests {
|
|||||||
"embedded_worker_runtime"
|
"embedded_worker_runtime"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let workdir_id = "external-workdir";
|
||||||
|
api.store
|
||||||
|
.upsert_workdir_registry(&WorkdirRegistryRecord {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
workdir_id: workdir_id.to_string(),
|
||||||
|
runtime_id: "external-workdir-runtime".to_string(),
|
||||||
|
repository_id: "main".to_string(),
|
||||||
|
creation_selector: None,
|
||||||
|
creation_ref: None,
|
||||||
|
creation_tree: None,
|
||||||
|
current_selector: None,
|
||||||
|
current_ref: None,
|
||||||
|
current_tree: None,
|
||||||
|
observed_at_epoch_seconds: None,
|
||||||
|
materialization_status: "present".to_string(),
|
||||||
|
cleanliness: "clean".to_string(),
|
||||||
|
created_at: "1".to_string(),
|
||||||
|
updated_at: "1".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
api.store
|
||||||
|
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
worker: RuntimeWorkerRef::new("embedded-worker-runtime", &worker_id),
|
||||||
|
workdir_id: workdir_id.to_string(),
|
||||||
|
role: "attachment".to_string(),
|
||||||
|
linked_at: "2".to_string(),
|
||||||
|
unlinked_at: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let worker = get_json(
|
let worker = get_json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}"),
|
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}"),
|
||||||
|
|||||||
+15
-76
@@ -571,20 +571,10 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
|||||||
let mut socket_override = None;
|
let mut socket_override = None;
|
||||||
let mut runtime_id = None;
|
let mut runtime_id = None;
|
||||||
let mut worker_id = None;
|
let mut worker_id = None;
|
||||||
let mut standalone_resume = false;
|
|
||||||
let mut standalone_all = false;
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
let arg = &args[i];
|
let arg = &args[i];
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
"--resume" => {
|
|
||||||
standalone_resume = true;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
"--all" => {
|
|
||||||
standalone_all = true;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
"--worker" => {
|
"--worker" => {
|
||||||
let value = args
|
let value = args
|
||||||
.get(i + 1)
|
.get(i + 1)
|
||||||
@@ -766,29 +756,6 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
|||||||
&workspace_root,
|
&workspace_root,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if standalone_all && !standalone_resume {
|
|
||||||
return Err(ParseError("--all requires --resume".to_string()));
|
|
||||||
}
|
|
||||||
if standalone_resume {
|
|
||||||
if target.kind() != TargetKind::Standalone {
|
|
||||||
return Err(ParseError(
|
|
||||||
"--resume is a Standalone option and requires --local".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if worker_name.is_some()
|
|
||||||
|| profile.is_some()
|
|
||||||
|| session.is_some()
|
|
||||||
|| socket_override.is_some()
|
|
||||||
|| runtime_id.is_some()
|
|
||||||
|| worker_id.is_some()
|
|
||||||
{
|
|
||||||
return Err(ParseError(
|
|
||||||
"--local --resume cannot be combined with Worker, profile, session, socket, or Runtime selectors"
|
|
||||||
.to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if target.kind() == TargetKind::Standalone {
|
if target.kind() == TargetKind::Standalone {
|
||||||
if runtime_id.is_some() || worker_id.is_some() {
|
if runtime_id.is_some() || worker_id.is_some() {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
@@ -798,7 +765,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
|||||||
}
|
}
|
||||||
if session.is_some() {
|
if session.is_some() {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
"--local does not accept legacy --session; use --local --resume for Standalone Worker restore"
|
"--local does not accept legacy --session; use `yoi --local resume` for Standalone Worker restore"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -834,16 +801,12 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
|||||||
|
|
||||||
if target.kind() == TargetKind::Standalone && (session.is_some() || socket_override.is_some()) {
|
if target.kind() == TargetKind::Standalone && (session.is_some() || socket_override.is_some()) {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
"Standalone does not accept legacy Worker session or socket selectors; use --resume for the standalone Worker store"
|
"Standalone does not accept legacy Worker session or socket selectors; use `yoi --local resume` for the standalone Worker store"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mode = if standalone_resume {
|
let mode = if target.kind() == TargetKind::Standalone {
|
||||||
LaunchMode::StandaloneResume {
|
|
||||||
include_all: standalone_all,
|
|
||||||
}
|
|
||||||
} else if target.kind() == TargetKind::Standalone {
|
|
||||||
LaunchMode::Spawn {
|
LaunchMode::Spawn {
|
||||||
worker_name,
|
worker_name,
|
||||||
profile,
|
profile,
|
||||||
@@ -951,7 +914,7 @@ fn parse_workers_args<R: CliConnectionResolver + ?Sized>(
|
|||||||
)?;
|
)?;
|
||||||
if target.kind() != TargetKind::Backend {
|
if target.kind() != TargetKind::Backend {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
"yoi workers requires a Backend connection target; use yoi --local --resume for Standalone Workers"
|
"yoi workers requires a Backend connection target; use yoi --local resume for Standalone Workers"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1741,7 +1704,6 @@ const TOP_LEVEL_HELP: &str = r#"yoi
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
yoi [TARGET]
|
yoi [TARGET]
|
||||||
yoi --local --resume [--all]
|
|
||||||
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
|
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
|
||||||
yoi [TARGET] resume [--all] [--runtime-id <ID>]
|
yoi [TARGET] resume [--all] [--runtime-id <ID>]
|
||||||
yoi --backend <URL> [--workspace-id <ID>] panel
|
yoi --backend <URL> [--workspace-id <ID>] panel
|
||||||
@@ -1750,8 +1712,6 @@ Usage:
|
|||||||
|
|
||||||
Target selection:
|
Target selection:
|
||||||
--local Use the client-owned one-process Standalone host
|
--local Use the client-owned one-process Standalone host
|
||||||
--resume With --local, restore from the Standalone Worker store
|
|
||||||
--all With Standalone restore, include Workers from every cwd identity
|
|
||||||
--backend <URL> Use a Workspace Backend explicitly
|
--backend <URL> Use a Workspace Backend explicitly
|
||||||
--workspace-id <ID> Scope Backend routes to a Workspace id
|
--workspace-id <ID> Scope Backend routes to a Workspace id
|
||||||
|
|
||||||
@@ -1801,7 +1761,7 @@ Usage:
|
|||||||
|
|
||||||
Authority:
|
Authority:
|
||||||
Lists Workers from the selected Backend Workspace. Standalone Workers are restored with
|
Lists Workers from the selected Backend Workspace. Standalone Workers are restored with
|
||||||
`yoi --local --resume` and are not part of the Workspace Worker catalog.
|
`yoi --local resume` and are not part of the Workspace Worker catalog.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--backend <URL> Use this Workspace Backend
|
--backend <URL> Use this Workspace Backend
|
||||||
@@ -2212,35 +2172,14 @@ backend = "shared"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parser_local_resume_uses_standalone_picker_scope() {
|
fn parser_rejects_removed_top_level_resume_flags() {
|
||||||
let mode = parse_args_from(["--local", "--resume"]).unwrap();
|
for (args, expected) in [
|
||||||
let Mode::Tui { target, mode, .. } = mode else {
|
(vec!["--resume"], "unknown argument: --resume"),
|
||||||
panic!("expected TUI mode")
|
(vec!["--local", "--resume"], "unknown argument: --resume"),
|
||||||
};
|
(vec!["--all"], "unknown argument: --all"),
|
||||||
assert_eq!(target.kind(), TargetKind::Standalone);
|
] {
|
||||||
assert!(matches!(
|
assert_eq!(parse_args_from(args).unwrap_err().to_string(), expected);
|
||||||
mode,
|
}
|
||||||
LaunchMode::StandaloneResume { include_all: false }
|
|
||||||
));
|
|
||||||
let intent = target.standalone_worker_list(false).unwrap();
|
|
||||||
assert!(intent.state_dir.ends_with("client/standalone/workers"));
|
|
||||||
assert!(!intent.include_all);
|
|
||||||
|
|
||||||
let mode = parse_args_from(["--local", "--resume", "--all"]).unwrap();
|
|
||||||
let Mode::Tui { mode, .. } = mode else {
|
|
||||||
panic!("expected TUI mode")
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
mode,
|
|
||||||
LaunchMode::StandaloneResume { include_all: true }
|
|
||||||
));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
parse_args_from(["--local", "--all"])
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string(),
|
|
||||||
"--all requires --resume"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2264,7 +2203,7 @@ backend = "shared"
|
|||||||
let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err();
|
let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
err.0,
|
err.0,
|
||||||
"--local does not accept legacy --session; use --local --resume for Standalone Worker restore"
|
"--local does not accept legacy --session; use `yoi --local resume` for Standalone Worker restore"
|
||||||
);
|
);
|
||||||
|
|
||||||
let socket_args = [
|
let socket_args = [
|
||||||
@@ -2897,7 +2836,7 @@ backend = "shared"
|
|||||||
other => panic!("expected WorkersHelp mode, got {other:?}"),
|
other => panic!("expected WorkersHelp mode, got {other:?}"),
|
||||||
}
|
}
|
||||||
assert!(WORKERS_HELP.contains("selected Backend Workspace"));
|
assert!(WORKERS_HELP.contains("selected Backend Workspace"));
|
||||||
assert!(WORKERS_HELP.contains("--local --resume"));
|
assert!(WORKERS_HELP.contains("--local resume"));
|
||||||
assert!(!WORKERS_HELP.contains("[--local|--backend"));
|
assert!(!WORKERS_HELP.contains("[--local|--backend"));
|
||||||
assert!(!WORKERS_HELP.contains("local Worker records"));
|
assert!(!WORKERS_HELP.contains("local Worker records"));
|
||||||
}
|
}
|
||||||
|
|||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
{
|
||||||
|
"max_plain_text_chars": 50,
|
||||||
|
"max_plain_text_logical_lines": 3,
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "empty",
|
||||||
|
"parts": [],
|
||||||
|
"char_count": 0,
|
||||||
|
"logical_line_count": 0,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ascii_50",
|
||||||
|
"parts": [{ "value": "a", "repeat": 50 }],
|
||||||
|
"char_count": 50,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ascii_51",
|
||||||
|
"parts": [{ "value": "a", "repeat": 51 }],
|
||||||
|
"char_count": 51,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "three_lines_50_scalars",
|
||||||
|
"parts": [
|
||||||
|
{ "value": "a\nb\n", "repeat": 1 },
|
||||||
|
{ "value": "x", "repeat": 46 }
|
||||||
|
],
|
||||||
|
"char_count": 50,
|
||||||
|
"logical_line_count": 3,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "four_lines_50_scalars",
|
||||||
|
"parts": [
|
||||||
|
{ "value": "a\nb\nc\n", "repeat": 1 },
|
||||||
|
{ "value": "x", "repeat": 44 }
|
||||||
|
],
|
||||||
|
"char_count": 50,
|
||||||
|
"logical_line_count": 4,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "four_lines_51_scalars",
|
||||||
|
"parts": [
|
||||||
|
{ "value": "a\nb\nc\n", "repeat": 1 },
|
||||||
|
{ "value": "x", "repeat": 45 }
|
||||||
|
],
|
||||||
|
"char_count": 51,
|
||||||
|
"logical_line_count": 4,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "two_lines_51_scalars",
|
||||||
|
"parts": [
|
||||||
|
{ "value": "a\n", "repeat": 1 },
|
||||||
|
{ "value": "x", "repeat": 49 }
|
||||||
|
],
|
||||||
|
"char_count": 51,
|
||||||
|
"logical_line_count": 2,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "four_lines_10_scalars",
|
||||||
|
"parts": [
|
||||||
|
{ "value": "a\nb\nc\n", "repeat": 1 },
|
||||||
|
{ "value": "x", "repeat": 4 }
|
||||||
|
],
|
||||||
|
"char_count": 10,
|
||||||
|
"logical_line_count": 4,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "japanese",
|
||||||
|
"parts": [{ "value": "日本語の貼り付け", "repeat": 1 }],
|
||||||
|
"char_count": 8,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "combining_marks_50_scalars",
|
||||||
|
"parts": [{ "value": "é", "repeat": 25 }],
|
||||||
|
"char_count": 50,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unicode_scalar_50",
|
||||||
|
"parts": [{ "value": "🦀", "repeat": 50 }],
|
||||||
|
"char_count": 50,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unicode_scalar_51",
|
||||||
|
"parts": [{ "value": "🦀", "repeat": 51 }],
|
||||||
|
"char_count": 51,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "three_lf_lines",
|
||||||
|
"parts": [{ "value": "alpha\nbeta\ngamma", "repeat": 1 }],
|
||||||
|
"char_count": 16,
|
||||||
|
"logical_line_count": 3,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "four_lf_lines",
|
||||||
|
"parts": [{ "value": "a\nb\nc\nd", "repeat": 1 }],
|
||||||
|
"char_count": 7,
|
||||||
|
"logical_line_count": 4,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "three_crlf_lines",
|
||||||
|
"parts": [{ "value": "a\r\nb\r\nc", "repeat": 1 }],
|
||||||
|
"char_count": 7,
|
||||||
|
"logical_line_count": 3,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mixed_line_endings",
|
||||||
|
"parts": [{ "value": "a\r\nb\rc\nd", "repeat": 1 }],
|
||||||
|
"char_count": 8,
|
||||||
|
"logical_line_count": 4,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trailing_newline",
|
||||||
|
"parts": [{ "value": "a\n", "repeat": 1 }],
|
||||||
|
"char_count": 2,
|
||||||
|
"logical_line_count": 2,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "whitespace_rich_50",
|
||||||
|
"parts": [{ "value": " \t", "repeat": 25 }],
|
||||||
|
"char_count": 50,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
@@ -16,9 +16,11 @@
|
|||||||
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
|
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||||
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0",
|
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0",
|
||||||
|
"@codemirror/commands": "npm:@codemirror/commands@6.9.0",
|
||||||
"@codemirror/language": "npm:@codemirror/language@6.12.4",
|
"@codemirror/language": "npm:@codemirror/language@6.12.4",
|
||||||
"@codemirror/state": "npm:@codemirror/state@6.7.1",
|
"@codemirror/state": "npm:@codemirror/state@6.7.1",
|
||||||
"@codemirror/view": "npm:@codemirror/view@6.43.8",
|
"@codemirror/view": "npm:@codemirror/view@6.43.8",
|
||||||
|
"@lezer/common": "npm:@lezer/common@1.5.2",
|
||||||
"@lezer/highlight": "npm:@lezer/highlight@1.2.3",
|
"@lezer/highlight": "npm:@lezer/highlight@1.2.3",
|
||||||
"decodal-codemirror": "npm:decodal-codemirror@0.3.0",
|
"decodal-codemirror": "npm:decodal-codemirror@0.3.0",
|
||||||
"clsx": "npm:clsx@2.1.1",
|
"clsx": "npm:clsx@2.1.1",
|
||||||
|
|||||||
Generated
+13
@@ -4,10 +4,12 @@
|
|||||||
"jsr:@std/assert@*": "1.0.19",
|
"jsr:@std/assert@*": "1.0.19",
|
||||||
"jsr:@std/internal@^1.0.12": "1.0.14",
|
"jsr:@std/internal@^1.0.12": "1.0.14",
|
||||||
"npm:@codemirror/autocomplete@6.20.0": "6.20.0",
|
"npm:@codemirror/autocomplete@6.20.0": "6.20.0",
|
||||||
|
"npm:@codemirror/commands@6.9.0": "6.9.0",
|
||||||
"npm:@codemirror/language@6.12.4": "6.12.4",
|
"npm:@codemirror/language@6.12.4": "6.12.4",
|
||||||
"npm:@codemirror/state@6.7.1": "6.7.1",
|
"npm:@codemirror/state@6.7.1": "6.7.1",
|
||||||
"npm:@codemirror/view@6.43.8": "6.43.8",
|
"npm:@codemirror/view@6.43.8": "6.43.8",
|
||||||
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
|
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
|
||||||
|
"npm:@lezer/common@1.5.2": "1.5.2",
|
||||||
"npm:@lezer/highlight@1.2.3": "1.2.3",
|
"npm:@lezer/highlight@1.2.3": "1.2.3",
|
||||||
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
|
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
|
||||||
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
||||||
@@ -47,6 +49,15 @@
|
|||||||
"@lezer/common"
|
"@lezer/common"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@codemirror/commands@6.9.0": {
|
||||||
|
"integrity": "sha512-454TVgjhO6cMufsyyGN70rGIfJxJEjcqjBG2x2Y03Y/+Fm99d3O/Kv1QDYWuG6hvxsgmjXmBuATikIIYvERX+w==",
|
||||||
|
"dependencies": [
|
||||||
|
"@codemirror/language",
|
||||||
|
"@codemirror/state",
|
||||||
|
"@codemirror/view",
|
||||||
|
"@lezer/common"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@codemirror/language@6.12.4": {
|
"@codemirror/language@6.12.4": {
|
||||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -1012,9 +1023,11 @@
|
|||||||
"workspace": {
|
"workspace": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"npm:@codemirror/autocomplete@6.20.0",
|
"npm:@codemirror/autocomplete@6.20.0",
|
||||||
|
"npm:@codemirror/commands@6.9.0",
|
||||||
"npm:@codemirror/language@6.12.4",
|
"npm:@codemirror/language@6.12.4",
|
||||||
"npm:@codemirror/state@6.7.1",
|
"npm:@codemirror/state@6.7.1",
|
||||||
"npm:@codemirror/view@6.43.8",
|
"npm:@codemirror/view@6.43.8",
|
||||||
|
"npm:@lezer/common@1.5.2",
|
||||||
"npm:@lezer/highlight@1.2.3",
|
"npm:@lezer/highlight@1.2.3",
|
||||||
"npm:@sveltejs/adapter-static@3.0.9",
|
"npm:@sveltejs/adapter-static@3.0.9",
|
||||||
"npm:@sveltejs/kit@2.49.4",
|
"npm:@sveltejs/kit@2.49.4",
|
||||||
|
|||||||
@@ -133,7 +133,18 @@ message: string,
|
|||||||
*/
|
*/
|
||||||
timestamp_ms: number, };
|
timestamp_ms: number, };
|
||||||
|
|
||||||
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "file_ref", path: string, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
|
export type PasteArtifactMediaType = "text_plain_utf8";
|
||||||
|
|
||||||
|
export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed";
|
||||||
|
|
||||||
|
export type PasteArtifactRef = { artifact_id: string, created_at_ms: number, media_type: PasteArtifactMediaType,
|
||||||
|
/**
|
||||||
|
* Availability observed when this immutable reference was committed.
|
||||||
|
* Reads revalidate storage and integrity rather than trusting this field.
|
||||||
|
*/
|
||||||
|
availability: PasteArtifactAvailability, byte_len: number, char_count: number, line_count: number, sha256: string, source_entry_id: string, };
|
||||||
|
|
||||||
|
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "paste_artifact", artifact: PasteArtifactRef, } | { "kind": "file_ref", path: string, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
|
||||||
|
|
||||||
export type WorkerEvent = { "kind": "turn_ended", worker_name: string, } | { "kind": "errored", worker_name: string, message: string, } | { "kind": "shut_down", worker_name: string, } | { "kind": "scope_sub_delegated",
|
export type WorkerEvent = { "kind": "turn_ended", worker_name: string, } | { "kind": "errored", worker_name: string, message: string, } | { "kind": "shut_down", worker_name: string, } | { "kind": "scope_sub_delegated",
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -328,10 +328,12 @@
|
|||||||
</header>
|
</header>
|
||||||
<DecodalSourceEditor
|
<DecodalSourceEditor
|
||||||
value={source}
|
value={source}
|
||||||
readonly={!selectedPath || busy}
|
readonly={!selectedPath || busy || !analysisReady}
|
||||||
fixedSchemaWrapper={mainSelected}
|
fixedSchemaWrapper={mainSelected}
|
||||||
onChange={(value) => source = value}
|
onChange={(value) => source = value}
|
||||||
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
|
onComplete={(value, offset, explicit) => analysisReady && toolchain
|
||||||
|
? toolchain.complete(selectedPath, value, offset, explicit)
|
||||||
|
: Promise.resolve(null)}
|
||||||
/>
|
/>
|
||||||
<p class="config-source-status" aria-live="polite">{status}</p>
|
<p class="config-source-status" aria-live="polite">{status}</p>
|
||||||
{#if conflict}
|
{#if conflict}
|
||||||
|
|||||||
@@ -12,13 +12,18 @@ type ConfigSourceCompletionItem = {
|
|||||||
priority: number;
|
priority: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function shouldStartCompletionAfterTyping(
|
||||||
|
insertedText: string,
|
||||||
|
): boolean {
|
||||||
|
return /\S/u.test(insertedText);
|
||||||
|
}
|
||||||
|
|
||||||
export function toCodeMirrorCompletion(
|
export function toCodeMirrorCompletion(
|
||||||
source: string,
|
|
||||||
result: ConfigSourceCompletionResult | null,
|
result: ConfigSourceCompletionResult | null,
|
||||||
): CompletionResult | null {
|
): CompletionResult | null {
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
return {
|
return {
|
||||||
from: utf8ByteOffsetToUtf16(source, result.from),
|
from: result.from,
|
||||||
options: result.items.map((item) => ({
|
options: result.items.map((item) => ({
|
||||||
label: item.label,
|
label: item.label,
|
||||||
type: item.kind,
|
type: item.kind,
|
||||||
@@ -27,31 +32,3 @@ export function toCodeMirrorCompletion(
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function utf8ByteOffsetToUtf16(source: string, byteOffset: number): number {
|
|
||||||
if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) {
|
|
||||||
throw new RangeError(
|
|
||||||
"completion byte offset must be a non-negative integer",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = 0;
|
|
||||||
let utf16 = 0;
|
|
||||||
for (const character of source) {
|
|
||||||
if (bytes === byteOffset) return utf16;
|
|
||||||
const codePoint = character.codePointAt(0)!;
|
|
||||||
bytes += codePoint <= 0x7f
|
|
||||||
? 1
|
|
||||||
: codePoint <= 0x7ff
|
|
||||||
? 2
|
|
||||||
: codePoint <= 0xffff
|
|
||||||
? 3
|
|
||||||
: 4;
|
|
||||||
utf16 += character.length;
|
|
||||||
if (bytes > byteOffset) {
|
|
||||||
throw new RangeError("completion byte offset splits a UTF-8 code point");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bytes === byteOffset) return utf16;
|
|
||||||
throw new RangeError("completion byte offset is outside the source");
|
|
||||||
}
|
|
||||||
|
|||||||
Binary file not shown.
@@ -79,7 +79,7 @@ export class ConfigSourceToolchain {
|
|||||||
utf16Offset,
|
utf16Offset,
|
||||||
explicit,
|
explicit,
|
||||||
});
|
});
|
||||||
return toCodeMirrorCompletion(source, result);
|
return toCodeMirrorCompletion(result);
|
||||||
}
|
}
|
||||||
format(source: string): Promise<string> {
|
format(source: string): Promise<string> {
|
||||||
return this.#request({ kind: "format", source });
|
return this.#request({ kind: "format", source });
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const CODEMIRROR_VITE_DEDUPE = [
|
||||||
|
"@codemirror/autocomplete",
|
||||||
|
"@codemirror/language",
|
||||||
|
"@codemirror/state",
|
||||||
|
"@codemirror/view",
|
||||||
|
"@lezer/common",
|
||||||
|
];
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Compartment,
|
||||||
|
EditorSelection,
|
||||||
|
EditorState,
|
||||||
|
Prec,
|
||||||
|
StateEffect,
|
||||||
|
StateField,
|
||||||
|
} from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
keymap,
|
||||||
|
WidgetType,
|
||||||
|
type DecorationSet,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import {
|
||||||
|
defaultKeymap,
|
||||||
|
history,
|
||||||
|
historyKeymap,
|
||||||
|
invertedEffects,
|
||||||
|
isolateHistory,
|
||||||
|
} from "@codemirror/commands";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import type { Segment } from "$lib/generated/protocol.ts";
|
||||||
|
import {
|
||||||
|
measureComposerPaste,
|
||||||
|
type ComposerPasteMeasurement,
|
||||||
|
} from "$lib/workspace/console/composer-paste.ts";
|
||||||
|
import {
|
||||||
|
composerDeletionRange,
|
||||||
|
composerPasteAtoms,
|
||||||
|
composerPasteToken,
|
||||||
|
pasteChipLabel,
|
||||||
|
snapshotComposerDraft,
|
||||||
|
type ComposerDraftSnapshot,
|
||||||
|
type ComposerPaste,
|
||||||
|
type ComposerTextPaste,
|
||||||
|
} from "$lib/workspace/console/composer-draft.ts";
|
||||||
|
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
ariaKeyShortcuts?: string;
|
||||||
|
onchange?: (snapshot: ComposerDraftSnapshot) => void;
|
||||||
|
onkeydown?: (event: KeyboardEvent) => void;
|
||||||
|
onsubmit?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel = "Message",
|
||||||
|
ariaKeyShortcuts = "Meta+Enter Control+Enter",
|
||||||
|
onchange,
|
||||||
|
onkeydown,
|
||||||
|
onsubmit,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let mountElement: HTMLDivElement;
|
||||||
|
let view: EditorView | null = null;
|
||||||
|
let nextPasteId = 1;
|
||||||
|
let nextPasteKey = 1;
|
||||||
|
const editable = new Compartment();
|
||||||
|
|
||||||
|
const registerPaste = StateEffect.define<{ key: number; paste: ComposerPaste }>();
|
||||||
|
const pasteRegistry = StateField.define<ReadonlyMap<number, ComposerPaste>>({
|
||||||
|
create: () => new Map(),
|
||||||
|
update(registry, transaction) {
|
||||||
|
const additions = transaction.effects.filter((effect) => effect.is(registerPaste));
|
||||||
|
if (additions.length === 0) return registry;
|
||||||
|
const next = new Map(registry);
|
||||||
|
for (const addition of additions) {
|
||||||
|
next.set(addition.value.key, addition.value.paste);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerTextPaste = StateEffect.define<ComposerTextPaste>();
|
||||||
|
const restoreTextPastes = StateEffect.define<readonly ComposerTextPaste[]>();
|
||||||
|
const textPasteState = StateField.define<readonly ComposerTextPaste[]>({
|
||||||
|
create: () => [],
|
||||||
|
update(textPastes, transaction) {
|
||||||
|
const restored = transaction.effects.find((effect) =>
|
||||||
|
effect.is(restoreTextPastes)
|
||||||
|
);
|
||||||
|
if (restored) return restored.value;
|
||||||
|
const retained: ComposerTextPaste[] = [];
|
||||||
|
for (const paste of textPastes) {
|
||||||
|
let touched = false;
|
||||||
|
transaction.changes.iterChangedRanges((from, to) => {
|
||||||
|
const replacesContent = from < paste.to && to > paste.from;
|
||||||
|
const insertsInside = from === to && from > paste.from && from < paste.to;
|
||||||
|
if (replacesContent || insertsInside) touched = true;
|
||||||
|
});
|
||||||
|
if (touched) continue;
|
||||||
|
retained.push({
|
||||||
|
...paste,
|
||||||
|
from: transaction.changes.mapPos(paste.from, 1),
|
||||||
|
to: transaction.changes.mapPos(paste.to, -1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const effect of transaction.effects) {
|
||||||
|
if (effect.is(registerTextPaste)) retained.push(effect.value);
|
||||||
|
}
|
||||||
|
return retained;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
class PasteChipWidget extends WidgetType {
|
||||||
|
readonly paste: ComposerPaste;
|
||||||
|
|
||||||
|
constructor(paste: ComposerPaste) {
|
||||||
|
super();
|
||||||
|
this.paste = paste;
|
||||||
|
}
|
||||||
|
|
||||||
|
override eq(other: PasteChipWidget): boolean {
|
||||||
|
return other.paste.id === this.paste.id &&
|
||||||
|
other.paste.content === this.paste.content &&
|
||||||
|
other.paste.chars === this.paste.chars &&
|
||||||
|
other.paste.lines === this.paste.lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
override toDOM(): HTMLElement {
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
const label = pasteChipLabel(this.paste);
|
||||||
|
chip.className = "composer-paste-chip";
|
||||||
|
chip.textContent = label;
|
||||||
|
chip.title = label;
|
||||||
|
chip.setAttribute("role", "note");
|
||||||
|
chip.setAttribute("aria-label", label);
|
||||||
|
return chip;
|
||||||
|
}
|
||||||
|
|
||||||
|
override ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteDecorations(state: EditorState): DecorationSet {
|
||||||
|
const registry = state.field(pasteRegistry);
|
||||||
|
return Decoration.set(
|
||||||
|
composerPasteAtoms(state.doc.toString(), registry).map((paste) =>
|
||||||
|
Decoration.replace({
|
||||||
|
widget: new PasteChipWidget(paste),
|
||||||
|
inclusive: false,
|
||||||
|
}).range(paste.from, paste.to)
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pasteChips = [
|
||||||
|
pasteRegistry,
|
||||||
|
textPasteState,
|
||||||
|
invertedEffects.of((transaction) =>
|
||||||
|
transaction.docChanged
|
||||||
|
? [restoreTextPastes.of(transaction.startState.field(textPasteState))]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
EditorView.decorations.of((currentView) => pasteDecorations(currentView.state)),
|
||||||
|
EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)),
|
||||||
|
];
|
||||||
|
|
||||||
|
function currentSnapshot(state = view?.state): ComposerDraftSnapshot {
|
||||||
|
if (!state) {
|
||||||
|
return { document: "", content: "", segments: [], pastes: [], textPastes: [] };
|
||||||
|
}
|
||||||
|
return snapshotComposerDraft(
|
||||||
|
state.doc.toString(),
|
||||||
|
state.field(pasteRegistry),
|
||||||
|
state.field(textPasteState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitChange(): void {
|
||||||
|
onchange?.(currentSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertPasteChip(content: string, measurement: ComposerPasteMeasurement): void {
|
||||||
|
if (!view) return;
|
||||||
|
const selection = view.state.selection.main;
|
||||||
|
const key = nextPasteKey++;
|
||||||
|
const paste: ComposerPaste = {
|
||||||
|
id: nextPasteId++,
|
||||||
|
content,
|
||||||
|
chars: measurement.charCount,
|
||||||
|
lines: measurement.logicalLineCount,
|
||||||
|
};
|
||||||
|
const token = composerPasteToken(key);
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: selection.from, to: selection.to, insert: token },
|
||||||
|
selection: EditorSelection.cursor(selection.from + token.length),
|
||||||
|
effects: registerPaste.of({ key, paste }),
|
||||||
|
annotations: isolateHistory.of("full"),
|
||||||
|
userEvent: "input.paste",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTextPaste(content: string): void {
|
||||||
|
if (!view) return;
|
||||||
|
const selection = view.state.selection.main;
|
||||||
|
const rendered = view.state.toText(content).toString();
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: selection.from, to: selection.to, insert: rendered },
|
||||||
|
selection: EditorSelection.cursor(selection.from + rendered.length),
|
||||||
|
effects: registerTextPaste.of({
|
||||||
|
from: selection.from,
|
||||||
|
to: selection.from + rendered.length,
|
||||||
|
rendered,
|
||||||
|
content,
|
||||||
|
}),
|
||||||
|
annotations: isolateHistory.of("full"),
|
||||||
|
userEvent: "input.paste",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePasteEvent(event: ClipboardEvent): boolean {
|
||||||
|
if (disabled || view?.state.readOnly) return false;
|
||||||
|
const content = event.clipboardData?.getData("text/plain");
|
||||||
|
if (!content) return false;
|
||||||
|
const measurement = measureComposerPaste(content);
|
||||||
|
event.preventDefault();
|
||||||
|
if (measurement.presentation === "chip") {
|
||||||
|
insertPasteChip(content, measurement);
|
||||||
|
} else {
|
||||||
|
insertTextPaste(content);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedClipboardContent(state: EditorState): string | null {
|
||||||
|
const selection = state.selection.main;
|
||||||
|
if (selection.empty) return null;
|
||||||
|
const document = state.doc.sliceString(selection.from, selection.to);
|
||||||
|
const registry = state.field(pasteRegistry);
|
||||||
|
const selectedRegistry = new Map<number, ComposerPaste>();
|
||||||
|
for (const atom of composerPasteAtoms(state.doc.toString(), registry)) {
|
||||||
|
if (atom.from >= selection.from && atom.to <= selection.to) {
|
||||||
|
selectedRegistry.set(atom.key, atom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const selectedTextPastes = state.field(textPasteState)
|
||||||
|
.filter((paste) => paste.from >= selection.from && paste.to <= selection.to)
|
||||||
|
.map((paste) => ({
|
||||||
|
...paste,
|
||||||
|
from: paste.from - selection.from,
|
||||||
|
to: paste.to - selection.from,
|
||||||
|
}));
|
||||||
|
return snapshotComposerDraft(document, selectedRegistry, selectedTextPastes).content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAdjacentPasteFromView(
|
||||||
|
currentView: EditorView,
|
||||||
|
direction: "backward" | "forward",
|
||||||
|
): boolean {
|
||||||
|
if (currentView.state.readOnly) return false;
|
||||||
|
const selection = currentView.state.selection.main;
|
||||||
|
const pastes = composerPasteAtoms(
|
||||||
|
currentView.state.doc.toString(),
|
||||||
|
currentView.state.field(pasteRegistry),
|
||||||
|
);
|
||||||
|
const deletion = composerDeletionRange(selection, pastes, direction);
|
||||||
|
if (!deletion) return false;
|
||||||
|
currentView.dispatch({
|
||||||
|
changes: deletion,
|
||||||
|
selection: EditorSelection.cursor(deletion.from),
|
||||||
|
annotations: isolateHistory.of("full"),
|
||||||
|
userEvent: "delete",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
view = new EditorView({
|
||||||
|
parent: mountElement,
|
||||||
|
state: EditorState.create({
|
||||||
|
extensions: [
|
||||||
|
history(),
|
||||||
|
Prec.highest(keymap.of([
|
||||||
|
{
|
||||||
|
key: "Mod-z",
|
||||||
|
run: (currentView) => currentView.state.readOnly,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Mod-Shift-z",
|
||||||
|
run: (currentView) => currentView.state.readOnly,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Mod-y",
|
||||||
|
run: (currentView) => currentView.state.readOnly,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Backspace",
|
||||||
|
run: (currentView) =>
|
||||||
|
deleteAdjacentPasteFromView(currentView, "backward"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Delete",
|
||||||
|
run: (currentView) =>
|
||||||
|
deleteAdjacentPasteFromView(currentView, "forward"),
|
||||||
|
},
|
||||||
|
])),
|
||||||
|
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||||
|
pasteChips,
|
||||||
|
editable.of([
|
||||||
|
EditorView.editable.of(!disabled),
|
||||||
|
EditorState.readOnly.of(disabled),
|
||||||
|
]),
|
||||||
|
EditorState.allowMultipleSelections.of(false),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
EditorView.contentAttributes.of({
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
"aria-keyshortcuts": ariaKeyShortcuts,
|
||||||
|
"aria-multiline": "true",
|
||||||
|
role: "textbox",
|
||||||
|
spellcheck: "true",
|
||||||
|
}),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
|
||||||
|
emitChange();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Prec.high(EditorView.domEventHandlers({
|
||||||
|
paste(event) {
|
||||||
|
return handlePasteEvent(event);
|
||||||
|
},
|
||||||
|
copy(event, currentView) {
|
||||||
|
const content = selectedClipboardContent(currentView.state);
|
||||||
|
if (content === null || !event.clipboardData) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
event.clipboardData.setData("text/plain", content);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
cut(event, currentView) {
|
||||||
|
if (disabled) return false;
|
||||||
|
const content = selectedClipboardContent(currentView.state);
|
||||||
|
if (content === null || !event.clipboardData) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
event.clipboardData.setData("text/plain", content);
|
||||||
|
const selection = currentView.state.selection.main;
|
||||||
|
currentView.dispatch({
|
||||||
|
changes: { from: selection.from, to: selection.to },
|
||||||
|
selection: EditorSelection.cursor(selection.from),
|
||||||
|
userEvent: "delete.cut",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
keydown(event) {
|
||||||
|
onkeydown?.(event);
|
||||||
|
if (event.defaultPrevented) return true;
|
||||||
|
if (
|
||||||
|
shouldSubmitChatKey(event, {
|
||||||
|
mode: "mod-enter",
|
||||||
|
modKey: "auto",
|
||||||
|
enabled: !disabled,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
onsubmit?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
EditorView.theme({
|
||||||
|
"&": { backgroundColor: "transparent" },
|
||||||
|
".cm-scroller": { fontFamily: "inherit" },
|
||||||
|
".cm-content": { caretColor: "var(--text-strong)" },
|
||||||
|
"&.cm-focused": { outline: "none" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
emitChange();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
view?.destroy();
|
||||||
|
view = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const isDisabled = disabled;
|
||||||
|
view?.dispatch({
|
||||||
|
effects: editable.reconfigure([
|
||||||
|
EditorView.editable.of(!isDisabled),
|
||||||
|
EditorState.readOnly.of(isDisabled),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export function snapshot(): ComposerDraftSnapshot {
|
||||||
|
return currentSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focus(): void {
|
||||||
|
view?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function containsTarget(target: EventTarget | null): boolean {
|
||||||
|
return target instanceof Node && Boolean(view?.dom.contains(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cursor(): number {
|
||||||
|
return view?.state.selection.main.head ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceRange(from: number, to: number, content: string): void {
|
||||||
|
if (!view || view.state.readOnly) return;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from, to, insert: content },
|
||||||
|
selection: EditorSelection.cursor(from + content.length),
|
||||||
|
userEvent: "input.complete",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clear(): void {
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: "" },
|
||||||
|
selection: EditorSelection.cursor(0),
|
||||||
|
userEvent: "input",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreSegments(
|
||||||
|
segments: readonly Segment[],
|
||||||
|
preserveExactText = false,
|
||||||
|
): void {
|
||||||
|
if (!view) return;
|
||||||
|
let document = "";
|
||||||
|
const pasteEffects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
|
||||||
|
const textEffects: StateEffect<ComposerTextPaste>[] = [];
|
||||||
|
let highestPasteId = nextPasteId - 1;
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment.kind === "text") {
|
||||||
|
const rendered = view.state.toText(segment.content).toString();
|
||||||
|
const from = document.length;
|
||||||
|
document += rendered;
|
||||||
|
if (preserveExactText) {
|
||||||
|
textEffects.push(registerTextPaste.of({
|
||||||
|
from,
|
||||||
|
to: from + rendered.length,
|
||||||
|
rendered,
|
||||||
|
content: segment.content,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (segment.kind === "paste") {
|
||||||
|
const key = nextPasteKey++;
|
||||||
|
const paste: ComposerPaste = {
|
||||||
|
id: segment.id,
|
||||||
|
content: segment.content,
|
||||||
|
chars: segment.chars,
|
||||||
|
lines: segment.lines,
|
||||||
|
};
|
||||||
|
highestPasteId = Math.max(highestPasteId, paste.id);
|
||||||
|
document += composerPasteToken(key);
|
||||||
|
pasteEffects.push(registerPaste.of({ key, paste }));
|
||||||
|
} else if (segment.kind === "file_ref") {
|
||||||
|
document += `@${segment.path}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextPasteId = highestPasteId + 1;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: document },
|
||||||
|
selection: EditorSelection.cursor(document.length),
|
||||||
|
effects: [...pasteEffects, ...textEffects],
|
||||||
|
userEvent: "input.restore",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="composer-input" class:disabled bind:this={mountElement}></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.composer-input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-input.disabled {
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-input :global(.cm-editor) {
|
||||||
|
min-height: 5.35rem;
|
||||||
|
max-height: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-input :global(.cm-scroller) {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-input :global(.cm-content) {
|
||||||
|
min-height: 5.35rem;
|
||||||
|
padding: 0.55rem 3.4rem 3rem 0.65rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-input :global(.cm-line) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-input :global(.composer-paste-chip) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: min(26rem, 70vw);
|
||||||
|
margin: 0 0.15rem;
|
||||||
|
padding: 0.08rem 0.42rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--bg-subtle));
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: baseline;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -80,6 +80,85 @@ export function buildComposerRequest(value: string): ComposerCommandResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComposerSegmentsRequestOptions {
|
||||||
|
preserveExactText?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildComposerSegmentsRequest(
|
||||||
|
sourceSegments: readonly Segment[],
|
||||||
|
options: ComposerSegmentsRequestOptions = {},
|
||||||
|
): ComposerCommandResult {
|
||||||
|
const hasPaste = sourceSegments.some((segment) => segment.kind === "paste");
|
||||||
|
if (!hasPaste) {
|
||||||
|
const content = sourceSegments.map(segmentContent).join("");
|
||||||
|
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
|
||||||
|
return buildComposerRequest(content);
|
||||||
|
}
|
||||||
|
if (!content.trim()) {
|
||||||
|
return { ok: false, message: "Input is empty." };
|
||||||
|
}
|
||||||
|
const segments = coalesceTextSegments(
|
||||||
|
sourceSegments.flatMap((segment) =>
|
||||||
|
segment.kind === "text"
|
||||||
|
? parseSigilSegments(segment.content)
|
||||||
|
: [segment]
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
request: { kind: "user", content, segments },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = sourceSegments.map(segmentContent).join("");
|
||||||
|
if (!content.trim()) {
|
||||||
|
return { ok: false, message: "Input is empty." };
|
||||||
|
}
|
||||||
|
const leadingText = sourceSegments[0]?.kind === "text"
|
||||||
|
? sourceSegments[0].content
|
||||||
|
: "";
|
||||||
|
if (leadingText.trimStart().startsWith(":")) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
"Commands cannot include a paste chip. Remove the chip or send it as a message.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: Segment[] = [];
|
||||||
|
for (const segment of sourceSegments) {
|
||||||
|
if (segment.kind === "text") {
|
||||||
|
segments.push(...parseSigilSegments(segment.content));
|
||||||
|
} else {
|
||||||
|
segments.push(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
request: {
|
||||||
|
kind: "user",
|
||||||
|
content,
|
||||||
|
segments: coalesceTextSegments(segments),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentContent(segment: Segment): string {
|
||||||
|
switch (segment.kind) {
|
||||||
|
case "text":
|
||||||
|
case "paste":
|
||||||
|
return segment.content;
|
||||||
|
case "file_ref":
|
||||||
|
return `@${segment.path}`;
|
||||||
|
case "flow":
|
||||||
|
return segment.selector;
|
||||||
|
case "paste_artifact":
|
||||||
|
return "";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildColonCommand(commandLine: string): ComposerCommandResult {
|
function buildColonCommand(commandLine: string): ComposerCommandResult {
|
||||||
const [name = "", ...argv] = commandLine.trim().split(/\s+/).filter(Boolean);
|
const [name = "", ...argv] = commandLine.trim().split(/\s+/).filter(Boolean);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import type { Segment } from "$lib/generated/protocol.ts";
|
||||||
|
import {
|
||||||
|
composerDeletionRange,
|
||||||
|
type ComposerPaste,
|
||||||
|
composerPasteToken,
|
||||||
|
pasteChipLabel,
|
||||||
|
snapshotComposerDraft,
|
||||||
|
} from "$lib/workspace/console/composer-draft.ts";
|
||||||
|
import { buildComposerSegmentsRequest } from "$lib/workspace/console/composer-command.ts";
|
||||||
|
import { measureComposerPaste } from "$lib/workspace/console/composer-paste.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assert(
|
||||||
|
condition: unknown,
|
||||||
|
message = "assertion failed",
|
||||||
|
): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
const actualJson = JSON.stringify(actual);
|
||||||
|
const expectedJson = JSON.stringify(expected);
|
||||||
|
if (actualJson !== expectedJson) {
|
||||||
|
throw new Error(`expected ${expectedJson}, received ${actualJson}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paste(id: number, content: string): ComposerPaste {
|
||||||
|
const measurement = measureComposerPaste(content);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
chars: measurement.charCount,
|
||||||
|
lines: measurement.logicalLineCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("composer draft preserves mixed Text and Paste order exactly", () => {
|
||||||
|
const unicodeCrlf = "🙂界\r\nsecond\r\n";
|
||||||
|
const trailingNewline = `${"x".repeat(51)}\n`;
|
||||||
|
const registry = new Map<number, ComposerPaste>([
|
||||||
|
[11, paste(1, unicodeCrlf)],
|
||||||
|
[12, paste(2, trailingNewline)],
|
||||||
|
]);
|
||||||
|
const document = `before ${composerPasteToken(11)} middle ${
|
||||||
|
composerPasteToken(12)
|
||||||
|
} after`;
|
||||||
|
|
||||||
|
const snapshot = snapshotComposerDraft(document, registry);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
snapshot.content,
|
||||||
|
`before ${unicodeCrlf} middle ${trailingNewline} after`,
|
||||||
|
);
|
||||||
|
assertEquals(snapshot.segments, [
|
||||||
|
{ kind: "text", content: "before " },
|
||||||
|
{
|
||||||
|
kind: "paste",
|
||||||
|
id: 1,
|
||||||
|
content: unicodeCrlf,
|
||||||
|
chars: 12,
|
||||||
|
lines: 3,
|
||||||
|
},
|
||||||
|
{ kind: "text", content: " middle " },
|
||||||
|
{
|
||||||
|
kind: "paste",
|
||||||
|
id: 2,
|
||||||
|
content: trailingNewline,
|
||||||
|
chars: 52,
|
||||||
|
lines: 2,
|
||||||
|
},
|
||||||
|
{ kind: "text", content: " after" },
|
||||||
|
]);
|
||||||
|
assertEquals(snapshot.pastes.map((entry) => entry.key), [11, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("selection deletion covers mixed Text and every selected paste chip", () => {
|
||||||
|
const pastes = [
|
||||||
|
{ ...paste(1, "first"), key: 10, from: 2, to: 5 },
|
||||||
|
{ ...paste(2, "second"), key: 11, from: 8, to: 11 },
|
||||||
|
];
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
composerDeletionRange({ from: 1, to: 12, head: 12 }, pastes, "backward"),
|
||||||
|
{ from: 1, to: 12 },
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
composerDeletionRange({ from: 2, to: 11, head: 2 }, pastes, "forward"),
|
||||||
|
{ from: 2, to: 11 },
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
composerDeletionRange({ from: 5, to: 5, head: 5 }, pastes, "backward"),
|
||||||
|
{ from: 2, to: 5 },
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
composerDeletionRange({ from: 8, to: 8, head: 8 }, pastes, "forward"),
|
||||||
|
{ from: 8, to: 11 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("composer paste chip label is compact and accessible", () => {
|
||||||
|
assertEquals(
|
||||||
|
pasteChipLabel({ id: 4, content: "payload", chars: 7, lines: 1 }),
|
||||||
|
"Clipboard #4 · 7 chars · 1 line",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("typed composer restoration retains Paste ids and metadata", () => {
|
||||||
|
const original: Segment[] = [
|
||||||
|
{ kind: "text", content: "prefix\n" },
|
||||||
|
{
|
||||||
|
kind: "paste",
|
||||||
|
id: 9,
|
||||||
|
content: "alpha\r\nbeta\r\n",
|
||||||
|
chars: 13,
|
||||||
|
lines: 3,
|
||||||
|
},
|
||||||
|
{ kind: "text", content: "\nsuffix" },
|
||||||
|
];
|
||||||
|
const registry = new Map<number, ComposerPaste>([
|
||||||
|
[31, original[1] as Extract<Segment, { kind: "paste" }>],
|
||||||
|
]);
|
||||||
|
const restored = snapshotComposerDraft(
|
||||||
|
`prefix\n${composerPasteToken(31)}\nsuffix`,
|
||||||
|
registry,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(restored.segments, original);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("mixed composer request preserves Paste and parsed file-ref boundaries", () => {
|
||||||
|
const segments: Segment[] = [
|
||||||
|
{ kind: "text", content: "inspect @src/main.rs then " },
|
||||||
|
{
|
||||||
|
kind: "paste",
|
||||||
|
id: 2,
|
||||||
|
content: "a\r\nb\r\n",
|
||||||
|
chars: 6,
|
||||||
|
lines: 3,
|
||||||
|
},
|
||||||
|
{ kind: "text", content: " exactly" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildComposerSegmentsRequest(segments);
|
||||||
|
assert(result.ok);
|
||||||
|
assertEquals(result.request, {
|
||||||
|
kind: "user",
|
||||||
|
content: "inspect @src/main.rs then a\r\nb\r\n exactly",
|
||||||
|
segments: [
|
||||||
|
{ kind: "text", content: "inspect " },
|
||||||
|
{ kind: "file_ref", path: "src/main.rs" },
|
||||||
|
{ kind: "text", content: " then " },
|
||||||
|
segments[1],
|
||||||
|
segments[2],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("short-paste Text preserves CRLF, trailing newline, and surrounding whitespace", () => {
|
||||||
|
const original = " short\r\npaste\r\n ";
|
||||||
|
const rendered = " short\npaste\n ";
|
||||||
|
const snapshot = snapshotComposerDraft(rendered, new Map(), [{
|
||||||
|
from: 0,
|
||||||
|
to: rendered.length,
|
||||||
|
rendered,
|
||||||
|
content: original,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assertEquals(snapshot.content, original);
|
||||||
|
assertEquals(snapshot.segments, [{ kind: "text", content: original }]);
|
||||||
|
assertEquals(snapshot.textPastes.length, 1);
|
||||||
|
|
||||||
|
const result = buildComposerSegmentsRequest(snapshot.segments, {
|
||||||
|
preserveExactText: snapshot.textPastes.length > 0,
|
||||||
|
});
|
||||||
|
assert(result.ok);
|
||||||
|
assertEquals(result.request, {
|
||||||
|
kind: "user",
|
||||||
|
content: original,
|
||||||
|
segments: [{ kind: "text", content: original }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("edited short-paste provenance falls back to visible Text", () => {
|
||||||
|
const snapshot = snapshotComposerDraft("changed", new Map(), [{
|
||||||
|
from: 0,
|
||||||
|
to: 5,
|
||||||
|
rendered: "short",
|
||||||
|
content: "short\r\n",
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assertEquals(snapshot.content, "changed");
|
||||||
|
assertEquals(snapshot.segments, [{ kind: "text", content: "changed" }]);
|
||||||
|
assertEquals(snapshot.textPastes, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Paste content beginning with a colon remains opaque user input", () => {
|
||||||
|
const directPaste: Segment = {
|
||||||
|
kind: "paste",
|
||||||
|
id: 3,
|
||||||
|
content: ":not-a-command\r\n",
|
||||||
|
chars: 16,
|
||||||
|
lines: 2,
|
||||||
|
};
|
||||||
|
const direct = buildComposerSegmentsRequest([directPaste]);
|
||||||
|
assert(direct.ok);
|
||||||
|
assertEquals(direct.request, {
|
||||||
|
kind: "user",
|
||||||
|
content: ":not-a-command\r\n",
|
||||||
|
segments: [directPaste],
|
||||||
|
});
|
||||||
|
|
||||||
|
const afterWhitespace = buildComposerSegmentsRequest([
|
||||||
|
{ kind: "text", content: " " },
|
||||||
|
directPaste,
|
||||||
|
]);
|
||||||
|
assert(afterWhitespace.ok);
|
||||||
|
assert(afterWhitespace.request);
|
||||||
|
assertEquals(afterWhitespace.request.kind, "user");
|
||||||
|
assertEquals(afterWhitespace.request.content, " :not-a-command\r\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("plain short-paste Text retains the existing composer request path", () => {
|
||||||
|
const result = buildComposerSegmentsRequest([
|
||||||
|
{ kind: "text", content: " short\r\npaste\r\n " },
|
||||||
|
]);
|
||||||
|
assert(result.ok);
|
||||||
|
assertEquals(result.request, {
|
||||||
|
kind: "user",
|
||||||
|
content: "short\r\npaste",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import type { Segment } from "$lib/generated/protocol.ts";
|
||||||
|
|
||||||
|
const PASTE_TOKEN_PREFIX = "\uFFF9";
|
||||||
|
const PASTE_TOKEN_SUFFIX = "\uFFFB";
|
||||||
|
const PASTE_TOKEN_PATTERN = /\uFFF9(\d+)\uFFFB/g;
|
||||||
|
|
||||||
|
export interface ComposerPaste {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
chars: number;
|
||||||
|
lines: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerPasteAtom extends ComposerPaste {
|
||||||
|
key: number;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerTextPaste {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
rendered: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerSelection {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
head: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composerDeletionRange(
|
||||||
|
selection: ComposerSelection,
|
||||||
|
pastes: readonly ComposerPasteAtom[],
|
||||||
|
direction: "backward" | "forward",
|
||||||
|
): { from: number; to: number } | null {
|
||||||
|
if (selection.from !== selection.to) {
|
||||||
|
return { from: selection.from, to: selection.to };
|
||||||
|
}
|
||||||
|
const paste = direction === "backward"
|
||||||
|
? pastes.find((candidate) => candidate.to === selection.head)
|
||||||
|
: pastes.find((candidate) => candidate.from === selection.head);
|
||||||
|
return paste ? { from: paste.from, to: paste.to } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerDraftSnapshot {
|
||||||
|
document: string;
|
||||||
|
content: string;
|
||||||
|
segments: Segment[];
|
||||||
|
pastes: ComposerPasteAtom[];
|
||||||
|
textPastes: ComposerTextPaste[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composerPasteToken(key: number): string {
|
||||||
|
return `${PASTE_TOKEN_PREFIX}${key}${PASTE_TOKEN_SUFFIX}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composerPasteAtoms(
|
||||||
|
document: string,
|
||||||
|
registry: ReadonlyMap<number, ComposerPaste>,
|
||||||
|
): ComposerPasteAtom[] {
|
||||||
|
const atoms: ComposerPasteAtom[] = [];
|
||||||
|
for (const match of document.matchAll(PASTE_TOKEN_PATTERN)) {
|
||||||
|
const key = Number(match[1]);
|
||||||
|
const paste = registry.get(key);
|
||||||
|
if (!paste || match.index === undefined) continue;
|
||||||
|
atoms.push({
|
||||||
|
...paste,
|
||||||
|
key,
|
||||||
|
from: match.index,
|
||||||
|
to: match.index + match[0].length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return atoms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTextSegment(segments: Segment[], content: string): void {
|
||||||
|
if (content.length === 0) return;
|
||||||
|
const previous = segments.at(-1);
|
||||||
|
if (previous?.kind === "text") {
|
||||||
|
previous.content += content;
|
||||||
|
} else {
|
||||||
|
segments.push({ kind: "text", content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotComposerDraft(
|
||||||
|
document: string,
|
||||||
|
registry: ReadonlyMap<number, ComposerPaste>,
|
||||||
|
candidateTextPastes: readonly ComposerTextPaste[] = [],
|
||||||
|
): ComposerDraftSnapshot {
|
||||||
|
const pastes = composerPasteAtoms(document, registry);
|
||||||
|
const textPastes = candidateTextPastes
|
||||||
|
.filter((paste) =>
|
||||||
|
paste.from >= 0 &&
|
||||||
|
paste.to <= document.length &&
|
||||||
|
document.slice(paste.from, paste.to) === paste.rendered
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.from - right.from);
|
||||||
|
const events = [
|
||||||
|
...pastes.map((paste) => ({
|
||||||
|
kind: "paste" as const,
|
||||||
|
from: paste.from,
|
||||||
|
to: paste.to,
|
||||||
|
paste,
|
||||||
|
})),
|
||||||
|
...textPastes.map((paste) => ({
|
||||||
|
kind: "text_paste" as const,
|
||||||
|
from: paste.from,
|
||||||
|
to: paste.to,
|
||||||
|
paste,
|
||||||
|
})),
|
||||||
|
].sort((left, right) => left.from - right.from);
|
||||||
|
const segments: Segment[] = [];
|
||||||
|
let content = "";
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.from < cursor) continue;
|
||||||
|
const text = document.slice(cursor, event.from);
|
||||||
|
appendTextSegment(segments, text);
|
||||||
|
content += text;
|
||||||
|
|
||||||
|
if (event.kind === "paste") {
|
||||||
|
segments.push({
|
||||||
|
kind: "paste",
|
||||||
|
id: event.paste.id,
|
||||||
|
content: event.paste.content,
|
||||||
|
chars: event.paste.chars,
|
||||||
|
lines: event.paste.lines,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
appendTextSegment(segments, event.paste.content);
|
||||||
|
}
|
||||||
|
content += event.paste.content;
|
||||||
|
cursor = event.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trailingText = document.slice(cursor);
|
||||||
|
appendTextSegment(segments, trailingText);
|
||||||
|
content += trailingText;
|
||||||
|
|
||||||
|
return { document, content, segments, pastes, textPastes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pasteChipLabel(paste: ComposerPaste): string {
|
||||||
|
const chars = paste.chars === 1 ? "char" : "chars";
|
||||||
|
const lines = paste.lines === 1 ? "line" : "lines";
|
||||||
|
return `Clipboard #${paste.id} · ${paste.chars} ${chars} · ${paste.lines} ${lines}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
export const MAX_PLAIN_TEXT_PASTE_CHARS = 50;
|
||||||
|
export const MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES = 3;
|
||||||
|
|
||||||
|
export type ComposerPastePresentation = "text" | "chip";
|
||||||
|
|
||||||
|
export interface ComposerPasteMeasurement {
|
||||||
|
charCount: number;
|
||||||
|
logicalLineCount: number;
|
||||||
|
presentation: ComposerPastePresentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerPasteEvent {
|
||||||
|
clipboardData: { getData(format: string): string } | null;
|
||||||
|
preventDefault(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count user-visible Unicode scalar values rather than UTF-16 code units.
|
||||||
|
* JavaScript string iteration combines a valid surrogate pair into one value.
|
||||||
|
*/
|
||||||
|
export function unicodeScalarCount(content: string): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const _value of content) count += 1;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty content has zero logical lines. Otherwise each LF, lone CR, or CRLF
|
||||||
|
* advances one line; CRLF is one break rather than two.
|
||||||
|
*/
|
||||||
|
export function logicalLineCount(content: string): number {
|
||||||
|
if (content.length === 0) return 0;
|
||||||
|
|
||||||
|
let count = 1;
|
||||||
|
for (let index = 0; index < content.length; index += 1) {
|
||||||
|
const codeUnit = content.charCodeAt(index);
|
||||||
|
if (codeUnit === 0x0d) {
|
||||||
|
if (content.charCodeAt(index + 1) === 0x0a) index += 1;
|
||||||
|
count += 1;
|
||||||
|
} else if (codeUnit === 0x0a) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measureComposerPaste(
|
||||||
|
content: string,
|
||||||
|
): ComposerPasteMeasurement {
|
||||||
|
const charCount = unicodeScalarCount(content);
|
||||||
|
const logicalLineCountValue = logicalLineCount(content);
|
||||||
|
const presentation = charCount <= MAX_PLAIN_TEXT_PASTE_CHARS &&
|
||||||
|
logicalLineCountValue <= MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||||
|
? "text"
|
||||||
|
: "chip";
|
||||||
|
|
||||||
|
return {
|
||||||
|
charCount,
|
||||||
|
logicalLineCount: logicalLineCountValue,
|
||||||
|
presentation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route a Browser paste without disrupting native short-text editing.
|
||||||
|
*
|
||||||
|
* Returning false means the caller must leave the event untouched, preserving
|
||||||
|
* the Browser's cursor, selection replacement, and undo behavior. A chip paste
|
||||||
|
* is consumed exactly once and handed to the compact-paste implementation.
|
||||||
|
*/
|
||||||
|
export function handleComposerPaste(
|
||||||
|
event: ComposerPasteEvent,
|
||||||
|
insertCompactPaste: (
|
||||||
|
content: string,
|
||||||
|
measurement: ComposerPasteMeasurement,
|
||||||
|
) => void,
|
||||||
|
): boolean {
|
||||||
|
if (!event.clipboardData) return false;
|
||||||
|
|
||||||
|
const content = event.clipboardData.getData("text/plain");
|
||||||
|
const measurement = measureComposerPaste(content);
|
||||||
|
if (measurement.presentation === "text") return false;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
insertCompactPaste(content, measurement);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -137,6 +137,30 @@ function snapshotEvent(cwd: string, entries: unknown[] = []): Event {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Deno.test("large paste segments project compact artifact metadata", () => {
|
||||||
|
const body = "secret pasted body";
|
||||||
|
const text = segmentsToText([{
|
||||||
|
kind: "paste_artifact",
|
||||||
|
artifact: {
|
||||||
|
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b2",
|
||||||
|
created_at_ms: 1_700_000_000_000,
|
||||||
|
media_type: "text_plain_utf8",
|
||||||
|
availability: "available",
|
||||||
|
byte_len: 65536,
|
||||||
|
char_count: 65530,
|
||||||
|
line_count: 200,
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
source_entry_id: "entry-1",
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
assert(text.includes("019ca7c8-57b6-7f05-8edf-524147aba7b2"), "artifact id is visible");
|
||||||
|
assert(text.includes("65536 bytes"), "bounded size metadata is visible");
|
||||||
|
assert(text.includes("text_plain_utf8"), "media type is visible");
|
||||||
|
assert(text.includes("available"), "availability is visible");
|
||||||
|
assert(text.includes("1700000000000"), "creation time is visible");
|
||||||
|
assert(!text.includes(body), "artifact body is not projected");
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("console routing projects live errors but not completion replies", () => {
|
Deno.test("console routing projects live errors but not completion replies", () => {
|
||||||
const errorEvent = {
|
const errorEvent = {
|
||||||
event: "error",
|
event: "error",
|
||||||
|
|||||||
@@ -1069,6 +1069,8 @@ export function segmentsToText(segments: Segment[]): string {
|
|||||||
case "paste":
|
case "paste":
|
||||||
return segment.content ||
|
return segment.content ||
|
||||||
`[paste ${segment.id}: ${segment.chars} chars / ${segment.lines} lines]`;
|
`[paste ${segment.id}: ${segment.chars} chars / ${segment.lines} lines]`;
|
||||||
|
case "paste_artifact":
|
||||||
|
return `[Large paste artifact ${segment.artifact.artifact_id}: ${segment.artifact.byte_len} bytes, ${segment.artifact.media_type}, ${segment.artifact.availability}, created ${segment.artifact.created_at_ms} ms, sha256 ${segment.artifact.sha256}]`;
|
||||||
case "file_ref":
|
case "file_ref":
|
||||||
return `@file ${segment.path}`;
|
return `@file ${segment.path}`;
|
||||||
case "unknown":
|
case "unknown":
|
||||||
|
|||||||
@@ -554,18 +554,22 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Worker Console composer fits to content without manual resize", async () => {
|
Deno.test("Worker Console composer keeps a compact bounded chip editor", async () => {
|
||||||
const consolePage = await Deno.readTextFile(
|
const consolePage = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const composerInput = await Deno.readTextFile(
|
||||||
|
new URL("./ComposerInput.svelte", import.meta.url),
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") &&
|
consolePage.includes("<ComposerInput") &&
|
||||||
consolePage.includes('<div class="composer-input-shell">') &&
|
consolePage.includes('<div class="composer-input-shell">') &&
|
||||||
!consolePage.includes("handleComposerShellClick") &&
|
!consolePage.includes("handleComposerShellClick") &&
|
||||||
consolePage.includes("bind:this={composerTextareaElement}") &&
|
consolePage.includes("bind:this={composerInputElement}") &&
|
||||||
|
consolePage.includes("onchange={handleComposerChange}") &&
|
||||||
consolePage.includes(
|
consolePage.includes(
|
||||||
'event.key === "PageUp" || event.key === "PageDown"',
|
'event.key === "PageUp" || event.key === "PageDown"',
|
||||||
) &&
|
) &&
|
||||||
@@ -577,10 +581,46 @@ Deno.test("Worker Console composer fits to content without manual resize", async
|
|||||||
consolePage.includes("pointer-events: auto") &&
|
consolePage.includes("pointer-events: auto") &&
|
||||||
consolePage.includes('class="composer-send-icon"') &&
|
consolePage.includes('class="composer-send-icon"') &&
|
||||||
consolePage.includes('d="M8 6L12 2L16 6"') &&
|
consolePage.includes('d="M8 6L12 2L16 6"') &&
|
||||||
consolePage.includes(".console-composer textarea") &&
|
composerInput.includes("max-height: 10rem") &&
|
||||||
consolePage.includes("resize: none") &&
|
composerInput.includes("EditorView.lineWrapping") &&
|
||||||
consolePage.includes("overflow-y: hidden"),
|
composerInput.includes("overflow-y: auto"),
|
||||||
"Console composer should autosize to content, cap at ten rows, wrap input and icon send button, and disable manual resize",
|
"Console composer should use the bounded chip-capable editor with wrapping, page scrolling, and the icon send button",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker Console paste chips preserve typed draft and target authority", async () => {
|
||||||
|
const consolePage = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const composerInput = await Deno.readTextFile(
|
||||||
|
new URL("./ComposerInput.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
composerInput.includes('measurement.presentation === "chip"') &&
|
||||||
|
composerInput.includes("registerTextPaste") &&
|
||||||
|
composerInput.includes("EditorView.atomicRanges") &&
|
||||||
|
composerInput.includes('key: "Backspace"') &&
|
||||||
|
composerInput.includes('key: "Delete"') &&
|
||||||
|
composerInput.includes(
|
||||||
|
"composerDeletionRange(selection, pastes, direction)",
|
||||||
|
) &&
|
||||||
|
composerInput.includes("EditorState.readOnly.of(isDisabled)") &&
|
||||||
|
composerInput.includes('key: "Mod-z"') &&
|
||||||
|
composerInput.includes("if (!view || view.state.readOnly) return") &&
|
||||||
|
composerInput.includes("if (currentView.state.readOnly) return false") &&
|
||||||
|
consolePage.includes("activeComposerTargetKey !== targetKey") &&
|
||||||
|
consolePage.includes("if (!composerEditable) return") &&
|
||||||
|
composerInput.includes('chip.setAttribute("aria-label", label)') &&
|
||||||
|
composerInput.includes("preserveExactText = false") &&
|
||||||
|
consolePage.includes("buildComposerSegmentsRequest(value.segments, {") &&
|
||||||
|
consolePage.includes("preserveExactText: value.textPastes.length > 0") &&
|
||||||
|
consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
|
||||||
|
consolePage.includes("switchComposerTarget(target)") &&
|
||||||
|
consolePage.includes('sendControl({ method: "cancel" }, "Stop")'),
|
||||||
|
"Paste chips should use shared threshold classification, atomic keyboard behavior, accessible labels, typed restore, and per-Worker draft authority",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -742,7 +782,7 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
|
|||||||
'const composerEditable = $derived(protocolState === "open" && !sending);',
|
'const composerEditable = $derived(protocolState === "open" && !sending);',
|
||||||
) &&
|
) &&
|
||||||
consolePage.includes('sendControl({ method: "cancel" }, "Stop")') &&
|
consolePage.includes('sendControl({ method: "cancel" }, "Stop")') &&
|
||||||
consolePage.includes("enabled: canSubmitDraft") &&
|
consolePage.includes("onsubmit={handleComposerSubmit}") &&
|
||||||
consolePage.includes("disabled={!composerEditable}") &&
|
consolePage.includes("disabled={!composerEditable}") &&
|
||||||
consolePage.includes("class:stop={workerRunning}") &&
|
consolePage.includes("class:stop={workerRunning}") &&
|
||||||
consolePage.includes('"Stop Worker"') &&
|
consolePage.includes('"Stop Worker"') &&
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
import {
|
||||||
|
autocompletion,
|
||||||
|
closeCompletion,
|
||||||
|
completionKeymap,
|
||||||
|
completionStatus,
|
||||||
|
startCompletion,
|
||||||
|
type CompletionContext,
|
||||||
|
type CompletionResult,
|
||||||
|
} from '@codemirror/autocomplete';
|
||||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
import { Compartment, EditorState } from '@codemirror/state';
|
import { Compartment, EditorState } from '@codemirror/state';
|
||||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
|
||||||
import { tags } from '@lezer/highlight';
|
import { tags } from '@lezer/highlight';
|
||||||
import { decodal } from 'decodal-codemirror';
|
import { decodal } from 'decodal-codemirror';
|
||||||
|
import { shouldStartCompletionAfterTyping } from '$lib/workspace/config-source/completion.ts';
|
||||||
import {
|
import {
|
||||||
fixedSchemaWrapperExtension,
|
fixedSchemaWrapperExtension,
|
||||||
moveSelectionIntoFixedWrapper,
|
moveSelectionIntoFixedWrapper,
|
||||||
@@ -69,6 +78,39 @@
|
|||||||
'.cm-tooltip-autocomplete > ul > li[aria-selected]': { background: 'var(--interactive-selected)', color: 'var(--text-strong)' },
|
'.cm-tooltip-autocomplete > ul > li[aria-selected]': { background: 'var(--interactive-selected)', color: 'var(--text-strong)' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function scheduleCompletion(editor: EditorView) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (
|
||||||
|
editor.hasFocus &&
|
||||||
|
!editor.state.facet(EditorState.readOnly) &&
|
||||||
|
completionStatus(editor.state) === null
|
||||||
|
) {
|
||||||
|
startCompletion(editor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function typedText(update: ViewUpdate): string | null {
|
||||||
|
let foundTyping = false;
|
||||||
|
let insertedText = '';
|
||||||
|
for (const transaction of update.transactions) {
|
||||||
|
if (!transaction.isUserEvent('input.type')) continue;
|
||||||
|
foundTyping = true;
|
||||||
|
transaction.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
|
||||||
|
insertedText += inserted.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return foundTyping ? insertedText : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissCompletion(editor: EditorView) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (completionStatus(editor.state) !== null) closeCompletion(editor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const completionKeymapWithoutEnter = completionKeymap.filter((binding) => binding.key !== 'Enter');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!host || untrack(() => view)) return;
|
if (!host || untrack(() => view)) return;
|
||||||
const initialValue = untrack(() => value);
|
const initialValue = untrack(() => value);
|
||||||
@@ -86,11 +128,17 @@
|
|||||||
highlightActiveLine(),
|
highlightActiveLine(),
|
||||||
decodal({ highlight: false }),
|
decodal({ highlight: false }),
|
||||||
syntaxHighlighting(syntaxTheme),
|
syntaxHighlighting(syntaxTheme),
|
||||||
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => {
|
autocompletion({
|
||||||
const doc = context.state.doc.toString();
|
activateOnTyping: false,
|
||||||
return await handleComplete(doc, context.pos, context.explicit);
|
override: [
|
||||||
}] })] : []),
|
async (context: CompletionContext) => {
|
||||||
keymap.of([]),
|
if (!handleComplete) return null;
|
||||||
|
const doc = context.state.doc.toString();
|
||||||
|
return await handleComplete(doc, context.pos, context.explicit);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
keymap.of(completionKeymapWithoutEnter),
|
||||||
fixedSchemaWrapperCompartment.of(
|
fixedSchemaWrapperCompartment.of(
|
||||||
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
||||||
),
|
),
|
||||||
@@ -99,7 +147,17 @@
|
|||||||
EditorView.editable.of(!initialReadonly),
|
EditorView.editable.of(!initialReadonly),
|
||||||
]),
|
]),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) handleChange(update.state.doc.toString());
|
if (update.docChanged) {
|
||||||
|
handleChange(update.state.doc.toString());
|
||||||
|
const insertedText = typedText(update);
|
||||||
|
if (insertedText !== null) {
|
||||||
|
if (shouldStartCompletionAfterTyping(insertedText)) {
|
||||||
|
scheduleCompletion(update.view);
|
||||||
|
} else {
|
||||||
|
dismissCompletion(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
theme,
|
theme,
|
||||||
],
|
],
|
||||||
|
|||||||
+115
-61
@@ -1,22 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick, untrack } from "svelte";
|
import { tick, untrack, type SvelteComponent } from "svelte";
|
||||||
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
|
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
|
||||||
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
|
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
|
||||||
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
|
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
|
||||||
import { chatSubmit } from "$lib/workspace/console/chat-submit";
|
import ComposerInput from "$lib/workspace/console/ComposerInput.svelte";
|
||||||
|
import type { ComposerDraftSnapshot } from "$lib/workspace/console/composer-draft";
|
||||||
import {
|
import {
|
||||||
buildComposerRequest,
|
buildComposerSegmentsRequest,
|
||||||
type WorkerConsoleInputRequest,
|
type WorkerConsoleInputRequest,
|
||||||
} from "$lib/workspace/console/composer-command";
|
} from "$lib/workspace/console/composer-command";
|
||||||
import {
|
import {
|
||||||
applyCompletion,
|
|
||||||
completionTokenAt,
|
completionTokenAt,
|
||||||
localCommandCompletions,
|
localCommandCompletions,
|
||||||
type ComposerCompletionEntry,
|
type ComposerCompletionEntry,
|
||||||
type ComposerCompletionToken,
|
type ComposerCompletionToken,
|
||||||
} from "$lib/workspace/console/composer-completion";
|
} from "$lib/workspace/console/composer-completion";
|
||||||
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
|
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
|
||||||
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
|
|
||||||
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
|
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
|
||||||
import {
|
import {
|
||||||
consoleWorkerViews,
|
consoleWorkerViews,
|
||||||
@@ -103,7 +102,33 @@
|
|||||||
untrack(() => data.worker?.state ?? null),
|
untrack(() => data.worker?.state ?? null),
|
||||||
);
|
);
|
||||||
let workerError = $state<string | null>(untrack(() => data.workerError));
|
let workerError = $state<string | null>(untrack(() => data.workerError));
|
||||||
let draft = $state("");
|
type ComposerInputHandle = {
|
||||||
|
snapshot(): ComposerDraftSnapshot;
|
||||||
|
focus(): void;
|
||||||
|
containsTarget(target: EventTarget | null): boolean;
|
||||||
|
cursor(): number;
|
||||||
|
replaceRange(from: number, to: number, content: string): void;
|
||||||
|
clear(): void;
|
||||||
|
restoreSegments(
|
||||||
|
segments: readonly Segment[],
|
||||||
|
preserveExactText?: boolean,
|
||||||
|
): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComposerDraftCache = {
|
||||||
|
segments: Segment[];
|
||||||
|
preserveExactText: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_DRAFT: ComposerDraftSnapshot = {
|
||||||
|
document: "",
|
||||||
|
content: "",
|
||||||
|
segments: [],
|
||||||
|
pastes: [],
|
||||||
|
textPastes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
|
||||||
let completionEntries = $state<ComposerCompletionEntry[]>([]);
|
let completionEntries = $state<ComposerCompletionEntry[]>([]);
|
||||||
let completionToken = $state<ComposerCompletionToken | null>(null);
|
let completionToken = $state<ComposerCompletionToken | null>(null);
|
||||||
let completionBusy = $state(false);
|
let completionBusy = $state(false);
|
||||||
@@ -130,7 +155,13 @@
|
|||||||
let timelineOpen = $state(false);
|
let timelineOpen = $state(false);
|
||||||
let consoleViewMode = $state<ConsoleViewMode>("overview");
|
let consoleViewMode = $state<ConsoleViewMode>("overview");
|
||||||
let consoleBodyElement: HTMLElement | null = null;
|
let consoleBodyElement: HTMLElement | null = null;
|
||||||
let composerTextareaElement: HTMLTextAreaElement | null = null;
|
let composerInputElement = $state<
|
||||||
|
(SvelteComponent & ComposerInputHandle) | null
|
||||||
|
>(null);
|
||||||
|
const composerDrafts = new Map<string, ComposerDraftCache>();
|
||||||
|
let activeComposerTargetKey = untrack(
|
||||||
|
() => `${workspaceId}:${runtimeId}:${workerId}`,
|
||||||
|
);
|
||||||
let timelineRailDragCleanup: (() => void) | null = null;
|
let timelineRailDragCleanup: (() => void) | null = null;
|
||||||
let autoFollowConsole = $state(true);
|
let autoFollowConsole = $state(true);
|
||||||
let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 });
|
let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 });
|
||||||
@@ -196,7 +227,7 @@
|
|||||||
const inputReady = $derived(workerState === "idle");
|
const inputReady = $derived(workerState === "idle");
|
||||||
const composerEditable = $derived(protocolState === "open" && !sending);
|
const composerEditable = $derived(protocolState === "open" && !sending);
|
||||||
const canSubmitDraft = $derived(inputReady && composerEditable);
|
const canSubmitDraft = $derived(inputReady && composerEditable);
|
||||||
const canSend = $derived(canSubmitDraft && draft.trim().length > 0);
|
const canSend = $derived(canSubmitDraft && draft.content.trim().length > 0);
|
||||||
const canStopFromComposer = $derived(workerRunning && composerEditable);
|
const canStopFromComposer = $derived(workerRunning && composerEditable);
|
||||||
const composerSubmitDisabled = $derived(
|
const composerSubmitDisabled = $derived(
|
||||||
workerRunning ? !canStopFromComposer : !canSend,
|
workerRunning ? !canStopFromComposer : !canSend,
|
||||||
@@ -321,14 +352,14 @@
|
|||||||
scheduleObservationFlush();
|
scheduleObservationFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyComposerCompletion(event: KeyboardEvent) {
|
async function applyComposerCompletion() {
|
||||||
const target = event.currentTarget;
|
if (!composerEditable || !composerInputElement) return;
|
||||||
if (!(target instanceof HTMLTextAreaElement)) {
|
const input = composerInputElement;
|
||||||
return;
|
const targetKey = activeComposerTargetKey;
|
||||||
}
|
const document = draft.document;
|
||||||
const token = completionTokenAt(
|
const token = completionTokenAt(
|
||||||
draft,
|
document,
|
||||||
target.selectionStart ?? draft.length,
|
input.cursor(),
|
||||||
);
|
);
|
||||||
completionToken = token;
|
completionToken = token;
|
||||||
completionError = null;
|
completionError = null;
|
||||||
@@ -340,15 +371,24 @@
|
|||||||
completionBusy = true;
|
completionBusy = true;
|
||||||
try {
|
try {
|
||||||
const entries = await resolveCompletionEntries(token);
|
const entries = await resolveCompletionEntries(token);
|
||||||
|
if (
|
||||||
|
!composerEditable ||
|
||||||
|
composerInputElement !== input ||
|
||||||
|
activeComposerTargetKey !== targetKey ||
|
||||||
|
draft.document !== document
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
completionEntries = entries;
|
completionEntries = entries;
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
completionError = `No completions for ${token.sigil}${token.prefix}`;
|
completionError = `No completions for ${token.sigil}${token.prefix}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const applied = applyCompletion(draft, token, entries[0]);
|
input.replaceRange(
|
||||||
draft = applied.value;
|
token.start,
|
||||||
await tick();
|
token.end,
|
||||||
target.setSelectionRange(applied.cursor, applied.cursor);
|
`${entries[0].value} `,
|
||||||
|
);
|
||||||
composerNotice =
|
composerNotice =
|
||||||
entries.length > 1
|
entries.length > 1
|
||||||
? `Completed ${token.sigil}${entries[0].value}; ${entries.length - 1} more candidate(s)`
|
? `Completed ${token.sigil}${entries[0].value}; ${entries.length - 1} more candidate(s)`
|
||||||
@@ -404,7 +444,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void applyComposerCompletion(event);
|
if (!composerEditable) return;
|
||||||
|
void applyComposerCompletion();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollConsoleByPage(direction: 1 | -1) {
|
function scrollConsoleByPage(direction: 1 | -1) {
|
||||||
@@ -465,13 +506,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleWorkerControlShortcut(event: KeyboardEvent) {
|
function handleWorkerControlShortcut(event: KeyboardEvent) {
|
||||||
const composerFocused = event.target === composerTextareaElement;
|
const composerFocused = composerInputElement?.containsTarget(event.target) ?? false;
|
||||||
const command = resolveWorkerControlShortcut(event, {
|
const command = resolveWorkerControlShortcut(event, {
|
||||||
protocolOpen: protocolState === "open",
|
protocolOpen: protocolState === "open",
|
||||||
running: workerRunning,
|
running: workerRunning,
|
||||||
paused: workerPaused,
|
paused: workerPaused,
|
||||||
composerFocused,
|
composerFocused,
|
||||||
draftBlank: draft.trim().length === 0,
|
draftBlank: draft.content.trim().length === 0,
|
||||||
editableTarget: isEditableTarget(event.target) && !composerFocused,
|
editableTarget: isEditableTarget(event.target) && !composerFocused,
|
||||||
hasSelection: targetHasSelection(event.target),
|
hasSelection: targetHasSelection(event.target),
|
||||||
});
|
});
|
||||||
@@ -529,16 +570,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleComposerSubmit(value = draft) {
|
function cachedComposerDraft(snapshot: ComposerDraftSnapshot): ComposerDraftCache {
|
||||||
|
return {
|
||||||
|
segments: [...snapshot.segments],
|
||||||
|
preserveExactText: snapshot.textPastes.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComposerChange(snapshot: ComposerDraftSnapshot) {
|
||||||
|
draft = snapshot;
|
||||||
|
composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchComposerTarget(target: ConsoleTarget) {
|
||||||
|
const nextKey = `${target.workspaceId}:${target.runtimeId}:${target.workerId}`;
|
||||||
|
if (nextKey === activeComposerTargetKey) return;
|
||||||
|
if (composerInputElement) {
|
||||||
|
composerDrafts.set(
|
||||||
|
activeComposerTargetKey,
|
||||||
|
cachedComposerDraft(composerInputElement.snapshot()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
activeComposerTargetKey = nextKey;
|
||||||
|
const restored = composerDrafts.get(nextKey) ?? {
|
||||||
|
segments: [],
|
||||||
|
preserveExactText: false,
|
||||||
|
};
|
||||||
|
void tick().then(() => {
|
||||||
|
if (activeComposerTargetKey !== nextKey) return;
|
||||||
|
composerInputElement?.restoreSegments(
|
||||||
|
restored.segments,
|
||||||
|
restored.preserveExactText,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComposerSubmit() {
|
||||||
if (workerRunning) {
|
if (workerRunning) {
|
||||||
sendControl({ method: "cancel" }, "Stop");
|
sendControl({ method: "cancel" }, "Stop");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void submitDraft(value);
|
void submitDraft(composerInputElement?.snapshot() ?? draft);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitDraft(value = draft) {
|
async function submitDraft(value: ComposerDraftSnapshot) {
|
||||||
const command = buildComposerRequest(value);
|
const command = buildComposerSegmentsRequest(value.segments, {
|
||||||
|
preserveExactText: value.textPastes.length > 0,
|
||||||
|
});
|
||||||
if (!command.ok) {
|
if (!command.ok) {
|
||||||
composerNotice = null;
|
composerNotice = null;
|
||||||
sendError = command.message;
|
sendError = command.message;
|
||||||
@@ -546,7 +624,7 @@
|
|||||||
}
|
}
|
||||||
composerNotice = command.notice ?? null;
|
composerNotice = command.notice ?? null;
|
||||||
if (!command.request) {
|
if (!command.request) {
|
||||||
draft = "";
|
composerInputElement?.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sending || !inputReady) {
|
if (sending || !inputReady) {
|
||||||
@@ -558,7 +636,7 @@
|
|||||||
try {
|
try {
|
||||||
const method = composerRequestToProtocolMethod(command.request);
|
const method = composerRequestToProtocolMethod(command.request);
|
||||||
sendProtocolMethod(method);
|
sendProtocolMethod(method);
|
||||||
draft = "";
|
composerInputElement?.clear();
|
||||||
if (method.method === "run" || method.method === "notify") {
|
if (method.method === "run" || method.method === "notify") {
|
||||||
liveWorkerState = "running";
|
liveWorkerState = "running";
|
||||||
}
|
}
|
||||||
@@ -1242,6 +1320,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const target = consoleTarget;
|
const target = consoleTarget;
|
||||||
|
switchComposerTarget(target);
|
||||||
const targetWorker = data.worker;
|
const targetWorker = data.worker;
|
||||||
const targetWorkerError = data.workerError;
|
const targetWorkerError = data.workerError;
|
||||||
workerViewSelectionGeneration += 1;
|
workerViewSelectionGeneration += 1;
|
||||||
@@ -1521,19 +1600,15 @@
|
|||||||
|
|
||||||
<form class="console-composer" onsubmit={sendMessage}>
|
<form class="console-composer" onsubmit={sendMessage}>
|
||||||
<div class="composer-input-shell">
|
<div class="composer-input-shell">
|
||||||
<textarea
|
<ComposerInput
|
||||||
id="worker-console-message"
|
bind:this={composerInputElement}
|
||||||
aria-label="Console input"
|
ariaLabel="Console input"
|
||||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
ariaKeyShortcuts="Meta+Enter Control+Enter"
|
||||||
bind:this={composerTextareaElement}
|
disabled={!composerEditable}
|
||||||
bind:value={draft}
|
onchange={handleComposerChange}
|
||||||
use:chatSubmit={{
|
|
||||||
enabled: canSubmitDraft,
|
|
||||||
onSubmit: (value) => handleComposerSubmit(value),
|
|
||||||
}}
|
|
||||||
use:fitTextarea={{ value: draft, maxRows: 10 }}
|
|
||||||
onkeydown={handleComposerKeydown}
|
onkeydown={handleComposerKeydown}
|
||||||
disabled={!composerEditable}></textarea>
|
onsubmit={handleComposerSubmit}
|
||||||
|
/>
|
||||||
<div class="composer-input-footer">
|
<div class="composer-input-footer">
|
||||||
<div class="composer-footer-slot">
|
<div class="composer-footer-slot">
|
||||||
{#if completionBusy || completionError || completionEntries.length > 0}
|
{#if completionBusy || completionError || completionEntries.length > 0}
|
||||||
@@ -1875,27 +1950,6 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-composer textarea {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 5.35rem;
|
|
||||||
resize: none;
|
|
||||||
overflow-y: hidden;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0.55rem 3.4rem 3rem 0.65rem;
|
|
||||||
font: inherit;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--text-strong);
|
|
||||||
outline: none;
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-composer textarea:disabled {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-send-button {
|
.composer-send-button {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: 2.35rem;
|
width: 2.35rem;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import denoConfig from "../../deno.json" with { type: "json" };
|
||||||
|
import { CODEMIRROR_VITE_DEDUPE } from "../../src/lib/workspace/config-source/vite-dedupe.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => Promise<void> | void): void;
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
readTextFile(path: URL): Promise<string>;
|
readTextFile(path: URL): Promise<string>;
|
||||||
@@ -7,6 +10,30 @@ function assert(condition: unknown, message: string): asserts condition {
|
|||||||
if (!condition) throw new Error(message);
|
if (!condition) throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Deno.test("Vite deduplicates CodeMirror stateful packages", () => {
|
||||||
|
for (
|
||||||
|
const packageName of [
|
||||||
|
"@codemirror/autocomplete",
|
||||||
|
"@codemirror/language",
|
||||||
|
"@codemirror/state",
|
||||||
|
"@codemirror/view",
|
||||||
|
"@lezer/common",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(
|
||||||
|
CODEMIRROR_VITE_DEDUPE.includes(packageName),
|
||||||
|
`Vite must deduplicate ${packageName}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const packageName of CODEMIRROR_VITE_DEDUPE) {
|
||||||
|
assert(
|
||||||
|
packageName in (denoConfig.imports ?? {}),
|
||||||
|
`${packageName} must be a direct dependency so Vite can deduplicate it`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => {
|
Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => {
|
||||||
const source = await Deno.readTextFile(
|
const source = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
@@ -72,6 +99,17 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () =
|
|||||||
!source.includes("--border-subtle"),
|
!source.includes("--border-subtle"),
|
||||||
"CodeMirror theme must use workspace tokens that actually exist",
|
"CodeMirror theme must use workspace tokens that actually exist",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("keymap.of(completionKeymapWithoutEnter)") &&
|
||||||
|
source.includes("binding.key !== 'Enter'") &&
|
||||||
|
source.includes("activateOnTyping: false") &&
|
||||||
|
source.includes("shouldStartCompletionAfterTyping(insertedText)") &&
|
||||||
|
source.includes("startCompletion(editor)") &&
|
||||||
|
!source.includes("EditorView.domEventHandlers") &&
|
||||||
|
!source.includes("update.selectionSet") &&
|
||||||
|
source.includes("completionStatus(editor.state) === null"),
|
||||||
|
"completion should start only after non-whitespace typing, without using focus, cursor movement, Space, or Enter",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
source.includes("fixedSchemaWrapperCompartment.reconfigure") &&
|
source.includes("fixedSchemaWrapperCompartment.reconfigure") &&
|
||||||
source.includes("fixedSchemaWrapperExtension()") &&
|
source.includes("fixedSchemaWrapperExtension()") &&
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ declare const Deno: {
|
|||||||
readTextFile(path: URL): Promise<string>;
|
readTextFile(path: URL): Promise<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
import { toCodeMirrorCompletion } from "../../src/lib/workspace/config-source/completion.ts";
|
import {
|
||||||
|
shouldStartCompletionAfterTyping,
|
||||||
|
toCodeMirrorCompletion,
|
||||||
|
} from "../../src/lib/workspace/config-source/completion.ts";
|
||||||
import { jsonWorkerMessage } from "../../src/lib/workspace/config-source/toolchain-message.ts";
|
import { jsonWorkerMessage } from "../../src/lib/workspace/config-source/toolchain-message.ts";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): asserts condition {
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
@@ -67,10 +70,28 @@ Deno.test("toolchain converts reactive-like proxies to plain Worker messages", a
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirror", () => {
|
Deno.test("completion starts only after non-whitespace typing", () => {
|
||||||
const source = "let 名 = tru";
|
assert(
|
||||||
const result = toCodeMirrorCompletion(source, {
|
!shouldStartCompletionAfterTyping(" "),
|
||||||
from: new TextEncoder().encode("let 名 = ").length,
|
"Space should not start completion",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!shouldStartCompletionAfterTyping("\n"),
|
||||||
|
"Enter should not start completion",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!shouldStartCompletionAfterTyping("\t"),
|
||||||
|
"other whitespace should not start completion",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
shouldStartCompletionAfterTyping("p"),
|
||||||
|
"non-whitespace typing should start completion",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("toolchain preserves WASM UTF-16 completion ranges for CodeMirror", () => {
|
||||||
|
const result = toCodeMirrorCompletion({
|
||||||
|
from: "let 名 = ".length,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "true",
|
label: "true",
|
||||||
@@ -84,7 +105,7 @@ Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirro
|
|||||||
assert(result !== null, "WASM completion should produce a CodeMirror result");
|
assert(result !== null, "WASM completion should produce a CodeMirror result");
|
||||||
assert(
|
assert(
|
||||||
result.from === "let 名 = ".length,
|
result.from === "let 名 = ".length,
|
||||||
"byte offsets should become UTF-16 offsets",
|
"WASM UTF-16 offsets should be preserved for CodeMirror",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
result.options.length === 1,
|
result.options.length === 1,
|
||||||
|
|||||||
@@ -299,6 +299,45 @@ Deno.test("generated WASM returns completion items for the editor adapter", () =
|
|||||||
assertEquals(result.items[0].kind, "file");
|
assertEquals(result.items[0].kind, "file");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("generated WASM completes blank nested schema positions after Unicode", () => {
|
||||||
|
const source =
|
||||||
|
'{ description = "日本語"; profile = { }\n} as WorkspaceConfigSchema';
|
||||||
|
const cursor = source.indexOf("{ }") + 2;
|
||||||
|
set_snapshot({
|
||||||
|
...snapshot,
|
||||||
|
entries: {
|
||||||
|
...snapshot.entries,
|
||||||
|
"workspace.dcdl": {
|
||||||
|
...snapshot.entries["workspace.dcdl"],
|
||||||
|
content: source,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
set_schema_bundle({
|
||||||
|
contributions: [],
|
||||||
|
source: "{ profile = { default_profile = String; }; prompts = {}; }",
|
||||||
|
fingerprint: "sha256:test-schema",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = complete_current(
|
||||||
|
"workspace.dcdl",
|
||||||
|
source,
|
||||||
|
cursor,
|
||||||
|
true,
|
||||||
|
) as {
|
||||||
|
from: number;
|
||||||
|
items: Array<{ label: string; kind: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
assertEquals(result.from, cursor);
|
||||||
|
assertEquals(
|
||||||
|
result.items.some((item) => item.label === "default_profile"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(result.items.some((item) => item.label === "profile"), false);
|
||||||
|
assertEquals(result.items.some((item) => item.label === "prompts"), false);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () => {
|
Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () => {
|
||||||
const bareSource = "{ pro }";
|
const bareSource = "{ pro }";
|
||||||
const source = "{ pro } as WorkspaceConfigSchema";
|
const source = "{ pro } as WorkspaceConfigSchema";
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import fixtureJson from "../../../tests/fixtures/composer-paste-policy.json" with {
|
||||||
|
type: "json",
|
||||||
|
};
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ComposerPasteMeasurement,
|
||||||
|
handleComposerPaste,
|
||||||
|
MAX_PLAIN_TEXT_PASTE_CHARS,
|
||||||
|
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES,
|
||||||
|
measureComposerPaste,
|
||||||
|
} from "../src/lib/workspace/console/composer-paste.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertEquals(
|
||||||
|
actual: unknown,
|
||||||
|
expected: unknown,
|
||||||
|
message?: string,
|
||||||
|
): void {
|
||||||
|
const actualJson = JSON.stringify(actual);
|
||||||
|
const expectedJson = JSON.stringify(expected);
|
||||||
|
if (actualJson !== expectedJson) {
|
||||||
|
throw new Error(
|
||||||
|
`${
|
||||||
|
message ? `${message}: ` : ""
|
||||||
|
}expected ${expectedJson}, got ${actualJson}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FixturePart {
|
||||||
|
value: string;
|
||||||
|
repeat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FixtureCase {
|
||||||
|
name: string;
|
||||||
|
parts: FixturePart[];
|
||||||
|
char_count: number;
|
||||||
|
logical_line_count: number;
|
||||||
|
presentation: "text" | "chip";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PastePolicyFixture {
|
||||||
|
max_plain_text_chars: number;
|
||||||
|
max_plain_text_logical_lines: number;
|
||||||
|
cases: FixtureCase[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixture = fixtureJson as PastePolicyFixture;
|
||||||
|
|
||||||
|
function fixtureContent(testCase: FixtureCase): string {
|
||||||
|
return testCase.parts.map(({ value, repeat }) => value.repeat(repeat)).join(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Browser composer follows the shared paste presentation contract", () => {
|
||||||
|
assertEquals(MAX_PLAIN_TEXT_PASTE_CHARS, fixture.max_plain_text_chars);
|
||||||
|
assertEquals(
|
||||||
|
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES,
|
||||||
|
fixture.max_plain_text_logical_lines,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const testCase of fixture.cases) {
|
||||||
|
assertEquals(
|
||||||
|
measureComposerPaste(fixtureContent(testCase)),
|
||||||
|
{
|
||||||
|
charCount: testCase.char_count,
|
||||||
|
logicalLineCount: testCase.logical_line_count,
|
||||||
|
presentation: testCase.presentation,
|
||||||
|
},
|
||||||
|
testCase.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("short paste remains a native Browser edit", () => {
|
||||||
|
let prevented = false;
|
||||||
|
let inserted = 0;
|
||||||
|
const handled = handleComposerPaste(
|
||||||
|
{
|
||||||
|
clipboardData: { getData: () => "replace the selection" },
|
||||||
|
preventDefault: () => {
|
||||||
|
prevented = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
inserted += 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(handled, false);
|
||||||
|
assertEquals(prevented, false);
|
||||||
|
assertEquals(inserted, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("chip paste is prevented and routed exactly once", () => {
|
||||||
|
const content = "🦀".repeat(51);
|
||||||
|
let prevented = 0;
|
||||||
|
const inserted: Array<[string, ComposerPasteMeasurement]> = [];
|
||||||
|
const handled = handleComposerPaste(
|
||||||
|
{
|
||||||
|
clipboardData: { getData: () => content },
|
||||||
|
preventDefault: () => {
|
||||||
|
prevented += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(paste, measurement) => inserted.push([paste, measurement]),
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(handled, true);
|
||||||
|
assertEquals(prevented, 1);
|
||||||
|
assertEquals(inserted, [[content, {
|
||||||
|
charCount: 51,
|
||||||
|
logicalLineCount: 1,
|
||||||
|
presentation: "chip",
|
||||||
|
}]]);
|
||||||
|
});
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import { CODEMIRROR_VITE_DEDUPE } from "./src/lib/workspace/config-source/vite-dedupe";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
dedupe: CODEMIRROR_VITE_DEDUPE,
|
||||||
|
},
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
Reference in New Issue
Block a user