fix: restore console input and attachment workflows

Restore durable busy Submit queues and TUI Enter delivery, expose Web Queue beside Notify, and separate the Composer toolbar from its editor without persistent success notices.

Support pasted image attachments and restore canonical Worker Session uploaded-file operations, including retention, binding, cleanup and authenticated upload context.

Validated Worker, Protocol and Session Store suites, 278 TUI tests, 59 Web tests and Svelte check. Runtime suite could not complete due to external Workdir transport timeouts.
This commit is contained in:
2026-09-30 05:16:57 +09:00
parent 677c7cfed9
commit 2006b390b4
20 changed files with 1478 additions and 339 deletions
+106
View File
@@ -810,6 +810,112 @@ where
self.session_store
.read_paste_artifact(session_id, artifact_id)
}
fn write_uploaded_file(
&self,
session_id: SessionId,
file_name: &str,
media_type: &str,
content: &[u8],
limits: crate::UploadedFileLimits,
) -> Result<protocol::UploadedFileRef, crate::StoreError> {
self.session_store
.write_uploaded_file(session_id, file_name, media_type, content, limits)
}
fn write_uploaded_file_with_context(
&self,
session_id: SessionId,
file_name: &str,
media_type: &str,
content: &[u8],
context: &crate::UploadedFileUploadContext,
limits: crate::UploadedFileLimits,
) -> Result<protocol::UploadedFileRef, crate::StoreError> {
self.session_store.write_uploaded_file_with_context(
session_id, file_name, media_type, content, context, limits,
)
}
fn read_uploaded_file(
&self,
session_id: SessionId,
reference: &protocol::UploadedFileRef,
) -> Result<Vec<u8>, crate::StoreError> {
self.session_store.read_uploaded_file(session_id, reference)
}
fn read_uploaded_file_by_id(
&self,
session_id: SessionId,
artifact_id: &str,
) -> Result<(protocol::UploadedFileRef, Vec<u8>), crate::StoreError> {
self.session_store
.read_uploaded_file_by_id(session_id, artifact_id)
}
fn bind_uploaded_file(
&self,
session_id: SessionId,
reference: &protocol::UploadedFileRef,
source_entry_id: &str,
) -> Result<protocol::UploadedFileRef, crate::StoreError> {
self.session_store
.bind_uploaded_file(session_id, reference, source_entry_id)
}
fn pin_uploaded_file(
&self,
session_id: SessionId,
reference: &protocol::UploadedFileRef,
owner_id: &str,
) -> Result<(), crate::StoreError> {
self.session_store
.pin_uploaded_file(session_id, reference, owner_id)
}
fn release_uploaded_file_pin(
&self,
session_id: SessionId,
artifact_id: &str,
owner_id: &str,
) -> Result<(), crate::StoreError> {
self.session_store
.release_uploaded_file_pin(session_id, artifact_id, owner_id)
}
fn finalize_uploaded_file_binding(
&self,
session_id: SessionId,
artifact_id: &str,
source_entry_id: &str,
) -> Result<(), crate::StoreError> {
self.session_store
.finalize_uploaded_file_binding(session_id, artifact_id, source_entry_id)
}
fn reconcile_uploaded_file_pins(
&self,
session_id: SessionId,
live_owner_ids: &[String],
) -> Result<u64, crate::StoreError> {
self.session_store
.reconcile_uploaded_file_pins(session_id, live_owner_ids)
}
fn delete_uploaded_file(
&self,
session_id: SessionId,
artifact_id: &str,
) -> Result<bool, crate::StoreError> {
self.session_store
.delete_uploaded_file(session_id, artifact_id)
}
fn delete_uncommitted_uploaded_files(
&self,
session_id: SessionId,
) -> Result<u64, crate::StoreError> {
self.session_store
.delete_uncommitted_uploaded_files(session_id)
}
fn copy_committed_uploaded_files(
&self,
source_session_id: SessionId,
target_session_id: SessionId,
) -> Result<u64, crate::StoreError> {
self.session_store
.copy_committed_uploaded_files(source_session_id, target_session_id)
}
fn append_trace(
&self,
session_id: SessionId,
@@ -13,8 +13,16 @@ use crate::event_trace::TraceEntry;
use crate::paste_artifact::{read_from_dir, write_to_dir};
use crate::segment_log::LogEntry;
use crate::store::{Store, StoreError};
use crate::{PasteArtifactLimits, SegmentId, SessionId};
use protocol::PasteArtifactRef;
use crate::uploaded_file::{
bind_uploaded_file, clear_uploaded_file_binding, delete_uncommitted_uploaded_files,
delete_uploaded_file, finalize_uploaded_file_binding, list_uploaded_file_refs,
pin_uploaded_file, read_uploaded_file, read_uploaded_file_by_id, reconcile_uploaded_file_pins,
release_uploaded_file_pin, uploaded_file_has_pending_owner, write_uploaded_file,
};
use crate::{
PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext,
};
use protocol::{PasteArtifactRef, UploadedFileRef};
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
@@ -207,6 +215,40 @@ impl WorkerSessionStore {
.join(format!("{segment_id}.trace.jsonl"))
}
fn uploaded_file_is_referenced(
&self,
session_id: SessionId,
artifact_id: &str,
) -> Result<bool, StoreError> {
fn segments_contain(segments: &[protocol::Segment], artifact_id: &str) -> bool {
segments.iter().any(|segment| {
matches!(
segment,
protocol::Segment::UploadedFile { file }
if file.artifact_id == artifact_id
)
})
}
for segment_id in self.list_segments(session_id)? {
for entry in self.read_all(session_id, segment_id)? {
let referenced = match entry {
LogEntry::AnnotatedUserInput { segments, .. } => {
segments_contain(&segments, artifact_id)
}
LogEntry::InputSegmentsCheckpoint { user_segments, .. } => user_segments
.iter()
.any(|segments| segments_contain(segments, artifact_id)),
_ => false,
};
if referenced {
return Ok(true);
}
}
}
Ok(false)
}
fn append_log_entry(&self, path: &Path, entry: &LogEntry) -> Result<(), StoreError> {
let _guard = self
.append_lock
@@ -402,6 +444,200 @@ impl Store for WorkerSessionStore {
read_from_dir(&self.root.join(PASTE_ARTIFACTS_DIR), artifact_id)
}
fn write_uploaded_file(
&self,
session_id: SessionId,
file_name: &str,
media_type: &str,
content: &[u8],
limits: UploadedFileLimits,
) -> Result<UploadedFileRef, 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_uploaded_file(
&self.root.join(PASTE_ARTIFACTS_DIR),
file_name,
media_type,
content,
None,
limits,
)
}
fn write_uploaded_file_with_context(
&self,
session_id: SessionId,
file_name: &str,
media_type: &str,
content: &[u8],
context: &UploadedFileUploadContext,
limits: UploadedFileLimits,
) -> Result<UploadedFileRef, 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_uploaded_file(
&self.root.join(PASTE_ARTIFACTS_DIR),
file_name,
media_type,
content,
Some(context),
limits,
)
}
fn read_uploaded_file(
&self,
session_id: SessionId,
reference: &UploadedFileRef,
) -> Result<Vec<u8>, StoreError> {
self.ensure_session(session_id, false)?;
read_uploaded_file(&self.root.join(PASTE_ARTIFACTS_DIR), reference)
}
fn read_uploaded_file_by_id(
&self,
session_id: SessionId,
artifact_id: &str,
) -> Result<(UploadedFileRef, Vec<u8>), StoreError> {
self.ensure_session(session_id, false)?;
read_uploaded_file_by_id(&self.root.join(PASTE_ARTIFACTS_DIR), artifact_id)
}
fn bind_uploaded_file(
&self,
session_id: SessionId,
reference: &UploadedFileRef,
source_entry_id: &str,
) -> Result<UploadedFileRef, StoreError> {
self.ensure_session(session_id, false)?;
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
let dir = self.root.join(PASTE_ARTIFACTS_DIR);
match bind_uploaded_file(&dir, reference, source_entry_id) {
Err(StoreError::ArtifactAlreadyCommitted) => {
let (stored, _) = read_uploaded_file_by_id(&dir, &reference.artifact_id)?;
let previous_source = stored
.source_entry_id
.ok_or(StoreError::ArtifactIntegrityMismatch)?;
if self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
return Err(StoreError::ArtifactAlreadyCommitted);
}
clear_uploaded_file_binding(&dir, &reference.artifact_id, &previous_source)?;
bind_uploaded_file(&dir, reference, source_entry_id)
}
result => result,
}
}
fn pin_uploaded_file(
&self,
session_id: SessionId,
reference: &UploadedFileRef,
owner_id: &str,
) -> Result<(), StoreError> {
self.ensure_session(session_id, false)?;
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
pin_uploaded_file(&self.root.join(PASTE_ARTIFACTS_DIR), reference, owner_id)
}
fn release_uploaded_file_pin(
&self,
session_id: SessionId,
artifact_id: &str,
owner_id: &str,
) -> Result<(), StoreError> {
self.ensure_session(session_id, false)?;
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
release_uploaded_file_pin(&self.root.join(PASTE_ARTIFACTS_DIR), artifact_id, owner_id)
}
fn finalize_uploaded_file_binding(
&self,
session_id: SessionId,
artifact_id: &str,
source_entry_id: &str,
) -> Result<(), StoreError> {
self.ensure_session(session_id, false)?;
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
finalize_uploaded_file_binding(
&self.root.join(PASTE_ARTIFACTS_DIR),
artifact_id,
source_entry_id,
)
}
fn reconcile_uploaded_file_pins(
&self,
session_id: SessionId,
live_owner_ids: &[String],
) -> Result<u64, StoreError> {
self.ensure_session(session_id, false)?;
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
reconcile_uploaded_file_pins(&self.root.join(PASTE_ARTIFACTS_DIR), live_owner_ids)
}
fn delete_uploaded_file(
&self,
session_id: SessionId,
artifact_id: &str,
) -> Result<bool, StoreError> {
self.ensure_session(session_id, false)?;
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
delete_uploaded_file(&self.root.join(PASTE_ARTIFACTS_DIR), artifact_id)
}
fn delete_uncommitted_uploaded_files(&self, session_id: SessionId) -> Result<u64, StoreError> {
self.ensure_session(session_id, false)?;
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
let dir = self.root.join(PASTE_ARTIFACTS_DIR);
let mut removed = delete_uncommitted_uploaded_files(&dir)?;
for reference in list_uploaded_file_refs(&dir)? {
let Some(source_entry_id) = reference.source_entry_id.as_deref() else {
continue;
};
if self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
finalize_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
continue;
}
if uploaded_file_has_pending_owner(&dir, &reference.artifact_id)? {
continue;
}
clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
if delete_uploaded_file(&dir, &reference.artifact_id)? {
removed = removed
.checked_add(1)
.ok_or(StoreError::ArtifactQuotaExceeded)?;
}
}
Ok(removed)
}
fn append_trace(
&self,
session_id: SessionId,
@@ -662,6 +898,212 @@ mod tests {
}
}
#[test]
fn canonical_combined_store_supports_attachment_uploads() {
let root = tempfile::tempdir().unwrap();
let session = WorkerSessionStore::new(root.path().join("session")).unwrap();
let store = crate::CombinedStore::new(session.clone(), ());
let session_id = new_session_id();
store
.create_segment(session_id, new_segment_id(), &[])
.unwrap();
let context = crate::UploadedFileUploadContext {
upload_id: "upload-1".into(),
principal_id: "account-1".into(),
workspace_id: "workspace-1".into(),
runtime_id: "runtime-1".into(),
worker_id: "worker-1".into(),
};
let file = store
.write_uploaded_file_with_context(
session_id,
"image.png",
"image/png",
b"\x89PNG\r\n\x1a\n",
&context,
crate::UploadedFileLimits::default(),
)
.expect("canonical Runtime store must support attachment upload");
assert_eq!(
store.read_uploaded_file(session_id, &file).unwrap(),
b"\x89PNG\r\n\x1a\n"
);
assert_eq!(
session.read_uploaded_file(session_id, &file).unwrap(),
b"\x89PNG\r\n\x1a\n"
);
assert_eq!(
store
.write_uploaded_file_with_context(
session_id,
"image.png",
"image/png",
b"\x89PNG\r\n\x1a\n",
&context,
crate::UploadedFileLimits::default(),
)
.unwrap(),
file
);
assert!(
store
.write_uploaded_file_with_context(
session_id,
"image.png",
"image/png",
b"\x89PNG\r\n\x1a\n",
&crate::UploadedFileUploadContext {
principal_id: "different-account".into(),
..context
},
crate::UploadedFileLimits::default(),
)
.is_err(),
"CombinedStore must not discard authenticated upload context"
);
let reopened = WorkerSessionStore::new(root.path().join("session")).unwrap();
assert_eq!(
reopened
.read_uploaded_file_by_id(session_id, &file.artifact_id)
.unwrap()
.0,
file
);
assert!(matches!(
store.read_uploaded_file(new_session_id(), &file),
Err(StoreError::Corrupt { .. })
));
assert!(
store
.delete_uploaded_file(session_id, &file.artifact_id)
.unwrap()
);
}
#[test]
fn canonical_attachment_pins_binding_and_cleanup_survive_reopen() {
let root = tempfile::tempdir().unwrap();
let store = crate::CombinedStore::new(WorkerSessionStore::new(root.path()).unwrap(), ());
let id = new_session_id();
let limits = UploadedFileLimits::default();
store.create_segment(id, new_segment_id(), &[]).unwrap();
let file = store
.write_uploaded_file(id, "pending.txt", "text/plain", b"pending", limits)
.unwrap();
store.pin_uploaded_file(id, &file, "submit-1").unwrap();
assert!(store.delete_uploaded_file(id, &file.artifact_id).is_err());
drop(store);
let store = crate::CombinedStore::new(WorkerSessionStore::new(root.path()).unwrap(), ());
assert_eq!(
store
.reconcile_uploaded_file_pins(id, &["submit-1".into()])
.unwrap(),
0
);
assert_eq!(store.delete_uncommitted_uploaded_files(id).unwrap(), 0);
let bound = store.bind_uploaded_file(id, &file, "entry-1").unwrap();
store
.create_segment(
id,
new_segment_id(),
&[LogEntry::InputSegmentsCheckpoint {
ts: 1,
user_segments: vec![vec![protocol::Segment::UploadedFile {
file: bound.clone(),
}]],
}],
)
.unwrap();
store
.finalize_uploaded_file_binding(id, &file.artifact_id, "entry-1")
.unwrap();
assert_eq!(store.delete_uncommitted_uploaded_files(id).unwrap(), 0);
assert_eq!(store.read_uploaded_file(id, &bound).unwrap(), b"pending");
assert!(
store
.bind_uploaded_file(id, &file, "different-entry")
.is_err()
);
assert!(store.delete_uploaded_file(id, &file.artifact_id).is_err());
let cancelled = store
.write_uploaded_file(id, "cancelled.txt", "text/plain", b"cancelled", limits)
.unwrap();
store.pin_uploaded_file(id, &cancelled, "submit-2").unwrap();
store
.release_uploaded_file_pin(id, &cancelled.artifact_id, "submit-2")
.unwrap();
assert_eq!(store.delete_uncommitted_uploaded_files(id).unwrap(), 1);
let orphan = store
.write_uploaded_file(id, "orphan.txt", "text/plain", b"orphan", limits)
.unwrap();
store.pin_uploaded_file(id, &orphan, "lost-owner").unwrap();
assert_eq!(store.reconcile_uploaded_file_pins(id, &[]).unwrap(), 1);
assert_eq!(store.delete_uncommitted_uploaded_files(id).unwrap(), 1);
let retry = store
.write_uploaded_file(id, "retry.txt", "text/plain", b"retry", limits)
.unwrap();
store
.bind_uploaded_file(id, &retry, "failed-entry")
.unwrap();
store.bind_uploaded_file(id, &retry, "retry-entry").unwrap();
assert_eq!(store.delete_uncommitted_uploaded_files(id).unwrap(), 1);
assert_eq!(store.read_uploaded_file(id, &bound).unwrap(), b"pending");
}
#[test]
fn canonical_attachment_limits_integrity_and_session_fences_are_preserved() {
let root = tempfile::tempdir().unwrap();
let store = crate::CombinedStore::new(WorkerSessionStore::new(root.path()).unwrap(), ());
let id = new_session_id();
let limits = UploadedFileLimits {
max_file_bytes: 8,
max_session_bytes: 8,
};
store.create_segment(id, new_segment_id(), &[]).unwrap();
assert!(matches!(
store.write_uploaded_file(id, "../escape", "text/plain", b"x", limits),
Err(StoreError::InvalidUploadedFileName)
));
assert!(matches!(
store.write_uploaded_file(id, "image.png", "image/png", b"invalid", limits),
Err(StoreError::ArtifactIntegrityMismatch)
));
assert!(matches!(
store.write_uploaded_file(id, "large.txt", "text/plain", b"123456789", limits),
Err(StoreError::ArtifactTooLarge)
));
let file = store
.write_uploaded_file(id, "notes.txt", "text/plain", b"1234", limits)
.unwrap();
let mut forged = file.clone();
forged.sha256 = "invalid".into();
assert!(matches!(
store.read_uploaded_file(id, &forged),
Err(StoreError::ArtifactIntegrityMismatch)
));
let other = new_session_id();
assert!(
store
.write_uploaded_file(other, "other.txt", "text/plain", b"x", limits)
.is_err()
);
assert!(store.pin_uploaded_file(other, &file, "owner").is_err());
assert!(
store
.delete_uploaded_file(other, &file.artifact_id)
.is_err()
);
store
.write_paste_artifact(id, "paste-entry", "1234", PasteArtifactLimits::default())
.unwrap();
assert!(matches!(
store.write_uploaded_file(id, "overflow.txt", "text/plain", b"x", limits),
Err(StoreError::ArtifactQuotaExceeded)
));
}
#[test]
fn canonical_layout_and_single_session_invariant() {
let root = tempfile::tempdir().unwrap();