Add server-managed feature SQLite storage

This commit is contained in:
2026-10-01 16:27:30 +09:00
parent f5d8c94713
commit 7797a65d54
5 changed files with 1485 additions and 8 deletions
File diff suppressed because it is too large Load Diff
+5
View File
@@ -10,6 +10,7 @@ pub mod backend_job;
pub mod companion;
pub mod config;
pub mod config_source;
pub mod feature_storage;
pub mod hosts;
pub mod memory_backend;
pub mod memory_staging;
@@ -43,6 +44,10 @@ pub use authority::{
WorkspaceAuthority,
};
pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
pub use feature_storage::{
FeatureDatabase, FeatureMigration, FeatureRegistration, FeatureStorage, FeatureStorageError,
RegisteredFeature, WorkspaceFeatureStorage,
};
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
pub use server::{
+55 -8
View File
@@ -1387,6 +1387,7 @@ pub struct WorkspaceApi {
signing_identities: WorkspaceSigningIdentityService,
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
feature_storage: crate::WorkspaceFeatureStorage,
authority: SqliteWorkspaceAuthority,
_worker_projection_shutdown: Arc<WorkerProjectionShutdownGuard>,
runtime: Arc<RuntimeRegistry>,
@@ -2257,6 +2258,11 @@ impl WorkspaceServerApi {
None,
);
}
api.feature_storage.delete().map_err(|error| {
Error::Store(format!(
"failed to delete Workspace Feature storage: {error}"
))
})?;
WorkspaceSigningIdentityService::new(self.store.clone(), self.signing_materials.clone())
.delete_material(&operation.workspace_id)?;
let completed = self.store.finalize_workspace_deletion(operation_id)?;
@@ -2303,6 +2309,16 @@ impl WorkspaceServerApi {
Ok(Some(router))
}
async fn shutdown_feature_storage(&self) -> Result<()> {
let apis = self.apis.lock().await.values().cloned().collect::<Vec<_>>();
for api in apis {
api.feature_storage.shutdown().map_err(|error| {
Error::Store(format!("failed to shut down Feature storage: {error}"))
})?;
}
Ok(())
}
async fn preload(&self) -> Result<()> {
for workspace in self.store.list_workspaces()? {
let _ = self
@@ -2836,6 +2852,10 @@ pub async fn build_workspace_server_router(
let api = WorkspaceServerApi::new(template, store);
api.preload().await?;
api.recover_workspace_deletions().await?;
Ok(workspace_server_router(api))
}
fn workspace_server_router(api: WorkspaceServerApi) -> Router {
let contract_service = ServerApiContractService::Server(api.clone());
let catalog = Router::new()
.fallback(dispatch_workspace_request)
@@ -2847,10 +2867,10 @@ pub async fn build_workspace_server_router(
.merge(generated_workspace_catalog_contract_router(
contract_service,
));
Ok(catalog.layer(axum::middleware::from_fn_with_state(
catalog.layer(axum::middleware::from_fn_with_state(
api,
enforce_server_cookie_mutation_origin,
)))
))
}
#[cfg(test)]
@@ -2928,6 +2948,12 @@ fn embedded_runtime_request_audience(config: &ServerConfig) -> crate::Result<Str
}
impl WorkspaceApi {
/// Server-managed, Workspace-scoped storage for trusted Feature repositories.
/// Feature registration and SQL access stay inside the Server process.
pub fn feature_storage(&self) -> &crate::WorkspaceFeatureStorage {
&self.feature_storage
}
pub fn with_config_schema_provider(
mut self,
provider: Arc<dyn crate::config_source::WorkspaceConfigSchemaProvider>,
@@ -3184,6 +3210,11 @@ impl WorkspaceApi {
.await
.map_err(|error| Error::InvalidInput(error.to_string()))?;
}
let feature_storage = crate::FeatureStorage::for_server_database(&config.database_path)
.and_then(|storage| storage.workspace(&config.workspace_id))
.map_err(|error| {
Error::Store(format!("failed to initialize Feature storage: {error}"))
})?;
let api = Self {
config_store,
repository_secrets,
@@ -3191,6 +3222,7 @@ impl WorkspaceApi {
config_schema_registry,
prompt_projection_cache:
crate::prompt_settings::WorkspacePromptProjectionCache::default(),
feature_storage,
authority: SqliteWorkspaceAuthority::new(
config.database_path.clone(),
config.workspace_id.clone(),
@@ -10025,10 +10057,15 @@ pub async fn serve_workspace_catalog_with_shutdown<F>(
where
F: std::future::Future<Output = ()> + Send + 'static,
{
let router = build_workspace_server_router(template, store).await?;
axum::serve(listener, router)
let api = WorkspaceServerApi::new(template, store);
api.preload().await?;
api.recover_workspace_deletions().await?;
let result = axum::serve(listener, workspace_server_router(api.clone()))
.with_graceful_shutdown(shutdown)
.await?;
.await;
let storage_result = api.shutdown_feature_storage().await;
result?;
storage_result?;
Ok(())
}
@@ -10037,8 +10074,13 @@ pub async fn serve_workspace_catalog(
store: Arc<dyn ControlPlaneStore>,
listener: TcpListener,
) -> Result<()> {
let router = build_workspace_server_router(template, store).await?;
axum::serve(listener, router).await?;
let api = WorkspaceServerApi::new(template, store);
api.preload().await?;
api.recover_workspace_deletions().await?;
let result = axum::serve(listener, workspace_server_router(api.clone())).await;
let storage_result = api.shutdown_feature_storage().await;
result?;
storage_result?;
Ok(())
}
@@ -10056,9 +10098,14 @@ pub async fn serve(
) -> Result<()> {
let api = WorkspaceApi::new(config, store).await?;
let orchestrator_hook = tokio::spawn(run_orchestrator_turn_end_hook(api.clone()));
let result = axum::serve(listener, build_router(api)).await;
let result = axum::serve(listener, build_router(api.clone())).await;
orchestrator_hook.abort();
let storage_result = api
.feature_storage
.shutdown()
.map_err(|error| Error::Store(format!("failed to shut down Feature storage: {error}")));
result?;
storage_result?;
Ok(())
}
+1
View File
@@ -29,6 +29,7 @@ It is not a dumping ground for external research, old plans, API inventories, or
21. [`development/validation.md`](development/validation.md) — how to check changes.
22. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes.
23. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。
24. [`design/server-feature-storage.md`](design/server-feature-storage.md) — Server管理のWorkspace/Feature専用SQLite、migration、transaction、backup/restore、削除・shutdown境界。
## What belongs here
+143
View File
@@ -0,0 +1,143 @@
# Server-managed Feature storage
`yoi-workspace-server::feature_storage` provides the SQLite lifecycle boundary for trusted Server-side Features. It is not a Worker feature API and it is not a model-visible SQL tool.
## Ownership boundary
The Server owns:
- the physical layout under `feature-storage/<workspace-id>/features/<feature-id>.sqlite`;
- validated Workspace and Feature identifiers (no caller-provided database path);
- connection reuse, `foreign_keys`, WAL, `busy_timeout`, and write serialization;
- migration timing/history, shutdown admission, online backup/restore, and Workspace deletion fencing.
A Feature owns:
- its tables, migration bodies, queries, and domain transactions;
- validation and typed repository/API methods exposed to its Server handler;
- authorization before the repository is called.
A Feature database is an API/ownership isolation boundary inside the trusted Server process. It does not sandbox arbitrary native code and does not replace Workspace authorization. A `FeatureDatabase`, SQLite path, or general SQL endpoint must never be sent to a Worker/Runtime or added to a model tool contract.
## Registration and repository example
Registration is trusted startup code. Merely constructing or registering storage is lazy and creates no directory or database; the first `open` applies migrations.
```rust
use rusqlite::{Transaction, params};
use yoi_workspace_server::{
FeatureDatabase, FeatureMigration, FeatureRegistration, FeatureStorageError,
WorkspaceApi,
};
type StorageResult<T> =
std::result::Result<T, FeatureStorageError>;
fn create_subject_memory(tx: &Transaction<'_>) -> StorageResult<()> {
tx.execute_batch(
"CREATE TABLE subjects (
subject_id TEXT PRIMARY KEY,
revision INTEGER NOT NULL
);
CREATE TABLE memories (
subject_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
body TEXT NOT NULL,
PRIMARY KEY (subject_id, memory_id),
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id)
);
CREATE TABLE interventions (
intervention_id TEXT PRIMARY KEY,
subject_id TEXT NOT NULL,
detail TEXT NOT NULL,
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id)
);"
)?;
Ok(())
}
static MIGRATIONS: &[FeatureMigration] = &[
FeatureMigration::new(1, "subject memory baseline", create_subject_memory),
];
const REGISTRATION: FeatureRegistration =
FeatureRegistration::new("subjektiv", MIGRATIONS);
struct SubjektivRepository {
database: FeatureDatabase,
}
impl SubjektivRepository {
fn apply_candidate(
&self,
subject_id: &str,
memory_id: &str,
body: &str,
intervention_id: &str,
) -> StorageResult<()> {
// Candidate application, memory revision, and intervention recording are
// one atomic transaction in the subjektiv Feature database.
self.database.transaction(|tx| {
tx.execute(
"INSERT INTO memories (subject_id, memory_id, body)
VALUES (?1, ?2, ?3)",
params![subject_id, memory_id, body],
)?;
tx.execute(
"UPDATE subjects SET revision = revision + 1
WHERE subject_id = ?1",
[subject_id],
)?;
tx.execute(
"INSERT INTO interventions (intervention_id, subject_id, detail)
VALUES (?1, ?2, 'candidate applied')",
params![intervention_id, subject_id],
)?;
Ok(())
})
}
}
fn initialize_subjektiv_repository(api: &WorkspaceApi) -> StorageResult<SubjektivRepository> {
let registered = api.feature_storage().register(REGISTRATION)?;
let database = api.feature_storage().open(&registered)?;
Ok(SubjektivRepository { database })
}
```
A production Feature should register once while constructing its Server service and retain the returned `RegisteredFeature`/typed repository. Duplicate registration is rejected. `FeatureDatabase` methods are synchronous because `rusqlite` is synchronous; async HTTP/background handlers must run nontrivial repository work on a blocking executor rather than blocking a Tokio core thread.
## Migration and transaction contract
- Versions are contiguous from 1. Migration schema changes and the corresponding history rows are committed in one `BEGIN IMMEDIATE` transaction.
- Concurrent opens in one manager are single-flight. Independent Server managers/processes serialize migration writers through SQLite and reread history after acquiring the transaction.
- A migration callback error rolls back the complete migration batch. A later open retries it; failure is never recorded as success.
- Stored migration names are verified. A history mismatch is rejected, and a schema newer than the registered plan is never overwritten or downgraded.
- `FeatureDatabase::transaction` covers multiple tables in one Feature database. No atomic transaction is promised with the Server control-plane database or another Feature database.
- A domain rejection can return `FeatureStorageError::operation(...)`; the transaction then rolls back.
## Lifecycle and operations
### Disable and restart
Not registering/opening a disabled Feature creates nothing. Disabling a previously used Feature does not delete its database. On restart, registering and opening the same Feature/Workspace reuses the persisted file and verifies/applies its migrations.
### Backup and restore
`WorkspaceFeatureStorage::backup` uses SQLite's online backup API for each discovered Feature database, so a live WAL is not ignored or copied as a bare database file. Each image passes `integrity_check` and `foreign_key_check`, and a manifest binds the snapshot to its Workspace and Feature identifiers. The snapshot directory is published with an atomic rename only after all images succeed.
Each Feature image is internally consistent. There is deliberately no cross-database snapshot instant or distributed transaction between Feature databases or the Server DB. A caller that needs a higher-level coordinated backup must first stop relevant Feature writers.
`restore` rejects the wrong Workspace manifest, corrupt/foreign-key-invalid images, an existing destination, or any live connection. It builds validated databases in a temporary directory and atomically publishes the Workspace storage directory. Restore is an operator/Server lifecycle operation, not a model API.
### Workspace deletion
Workspace deletion calls `WorkspaceFeatureStorage::delete` after Worker/Workdir cleanup and before finalizing removal from the Server DB. It fences new opens, waits for connection-locked operations, closes handles, and removes only the validated Workspace subtree. The admission fence remains until the cached Workspace API is discarded, preventing a background task from recreating storage between filesystem deletion and control-plane finalization. Retrying deletion is idempotent.
### Shutdown
After HTTP graceful drain, Server serve paths explicitly call `shutdown`. Shutdown rejects new registration/open, waits for in-progress connection operations to release their locks, closes all managed connections, and makes retained handles fail with `DatabaseClosed`. Operations must not treat a shutdown error as success.
## Failure handling
Identifier, registration, migration-history, unsupported-schema, maintenance, shutdown, snapshot, I/O, and SQLite failures are typed as `FeatureStorageError`. Server handlers should map them to a bounded domain/API error without exposing paths, SQL, or unrestricted database access. Migration/restore/delete failures must stop the corresponding lifecycle transition; they are not warnings to ignore.