fix: persist plugin snapshots for restore

This commit is contained in:
Keisuke Hirata 2026-06-16 00:15:04 +09:00
parent ede7acfdf6
commit 07978d2df5
No known key found for this signature in database
3 changed files with 143 additions and 33 deletions

View File

@ -1617,6 +1617,42 @@ mod tests {
assert_eq!(restored_snapshot.resolved[0].version, "0.1.0"); assert_eq!(restored_snapshot.resolved[0].version, "0.1.0");
} }
#[test]
fn currently_documented_manifest_shape_is_accepted() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().join("workspace");
let plugins = workspace.join(".yoi/plugins");
fs::create_dir_all(&plugins).unwrap();
let manifest = r#"
schema_version = 1
id = "example.summarizer"
name = "Example Summarizer"
version = "0.1.0"
description = "Adds a custom summary command."
surfaces = ["hook"]
[[hooks]]
id = "summary"
file = "hooks/summary.md"
"#;
write_stored_zip(
&plugins.join("documented.yoi-plugin"),
&[
("plugin.toml", manifest.as_bytes().to_vec(), 0),
("hooks/summary.md", b"summarize".to_vec(), 0),
],
);
let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace));
assert_eq!(report.diagnostics, vec![]);
assert_eq!(report.packages.len(), 1);
assert_eq!(
report.packages[0].identity.to_string(),
"project:example.summarizer"
);
}
#[test] #[test]
fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() { fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -924,11 +924,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
} }
fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> PodMetadata { fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> PodMetadata {
let mut metadata = PodMetadata::new(self.manifest.pod.name.clone(), active); pod_metadata_for_manifest(&self.manifest, active)
if self.manifest.profile.is_some() {
metadata.resolved_manifest_snapshot = serde_json::to_value(&self.manifest).ok();
}
metadata
} }
fn write_pod_metadata_pending(&self) -> Result<(), PodError> { fn write_pod_metadata_pending(&self) -> Result<(), PodError> {
@ -4321,6 +4317,21 @@ fn request_config_from_worker_manifest(wm: &WorkerManifest) -> RequestConfig {
config config
} }
fn pod_metadata_for_manifest(
manifest: &PodManifest,
active: Option<PodActiveSegmentRef>,
) -> PodMetadata {
let mut metadata = PodMetadata::new(manifest.pod.name.clone(), active);
if should_persist_resolved_manifest_snapshot(manifest) {
metadata.resolved_manifest_snapshot = serde_json::to_value(manifest).ok();
}
metadata
}
fn should_persist_resolved_manifest_snapshot(manifest: &PodManifest) -> bool {
manifest.profile.is_some() || manifest.plugins.has_resolved_plan()
}
fn restore_manifest_from_pod_metadata_snapshot( fn restore_manifest_from_pod_metadata_snapshot(
pod_name: &str, pod_name: &str,
snapshot: Option<serde_json::Value>, snapshot: Option<serde_json::Value>,
@ -5294,6 +5305,74 @@ permission = "write"
Permission::Write Permission::Write
); );
} }
#[test]
fn plugin_resolved_manifest_snapshot_is_persisted_without_profile() {
let mut manifest = PodManifest::from_toml(
r#"
[pod]
name = "plugin-snapshot"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[worker]
instruction = "saved"
[[scope.allow]]
target = "/snapshot/workspace"
permission = "read"
"#,
)
.unwrap();
assert!(manifest.profile.is_none());
assert!(
pod_metadata_for_manifest(&manifest, None)
.resolved_manifest_snapshot
.is_none()
);
manifest.plugins.resolved = vec![manifest::plugin::ResolvedPluginRecord {
identity: manifest::plugin::SourceQualifiedPluginId::new(
manifest::plugin::PluginSourceKind::Project,
"example",
),
source: manifest::plugin::PluginSourceKind::Project,
package_path: PathBuf::from("/snapshot/workspace/.yoi/plugins/example.yoi-plugin"),
package_label: "example.yoi-plugin".to_string(),
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.to_string(),
version: "0.1.0".to_string(),
manifest: manifest::plugin::PluginPackageManifest {
schema_version: 1,
id: "example".to_string(),
name: "Example".to_string(),
version: "0.1.0".to_string(),
description: None,
surfaces: vec![manifest::plugin::PluginSurface::Hook],
runtime: None,
hooks: vec![],
},
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
grants: manifest::plugin::PluginGrantConfig::default(),
config: None,
}];
let metadata = pod_metadata_for_manifest(&manifest, None);
let snapshot = metadata
.resolved_manifest_snapshot
.expect("plugin-resolved manifest should be snapshotted");
let restored: PodManifest = serde_json::from_value(snapshot).unwrap();
assert!(restored.profile.is_none());
assert_eq!(restored.plugins.resolved.len(), 1);
assert_eq!(
restored.plugins.resolved[0].digest,
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
assert_eq!(restored.plugins.resolved[0].version, "0.1.0");
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -22,51 +22,46 @@ LICENSE* # recommended license text
assets/** # optional non-executable data assets assets/** # optional non-executable data assets
``` ```
The package layout is intentionally data-first. Placing a package in a store must never execute `module.wasm`, register `hooks/*.toml`, or scan assets as prompts. Those steps happen only after explicit enablement and policy resolution. The package layout is intentionally data-first. Placing a package in a store must never execute `module.wasm`, register hook metadata, or scan assets as prompts. Those steps happen only after explicit enablement and policy resolution.
## `plugin.toml` ## `plugin.toml`
`plugin.toml` is the package authority for package identity and declared needs. It is not the authority for runtime grants. `plugin.toml` is the package authority for package identity and declared needs. It is not the authority for runtime grants.
Illustrative manifest shape: Currently implemented strict `plugin.toml` shape:
```toml ```toml
schema_version = 1 schema_version = 1
id = "example" id = "example.summarizer"
name = "Example Plugin" name = "Example Summarizer"
version = "0.1.0" version = "0.1.0"
description = "Demonstrates declarative hooks and an optional WASM module." description = "Adds a custom summary command."
surfaces = ["hook"]
[runtime]
kind = "wasm" # "declarative" or "wasm" for the initial plugin system
entry = "module.wasm"
abi = "yoi-plugin-wasm-1"
[package]
readme = "README.md"
license = "LICENSE"
[permissions]
tools = ["Bash"]
web = false
secrets = []
filesystem = []
[[hooks]] [[hooks]]
id = "summarize-ticket" id = "summary"
file = "hooks/summarize-ticket.toml" file = "hooks/summary.md"
``` ```
Fields proposed for the first implementation pass: The package archive must contain both root `plugin.toml` and the referenced `hooks/summary.md` entry. Optional WASM metadata is accepted only for the declared future runtime boundary and is not executed:
```toml
[runtime]
kind = "wasm"
entry = "plugin.wasm"
abi = "yoi-plugin-wasm-1"
```
First-pass fields accepted by the parser:
- `schema_version`: required integer; unsupported versions fail closed. - `schema_version`: required integer; unsupported versions fail closed.
- `id`: required unqualified local id. It is scoped by the source that discovered the package; it is not globally unique by itself. - `id`: required unqualified local id. It is scoped by the source that discovered the package; it is not globally unique by itself.
- `name`, `version`, `description`: human metadata used in listings and diagnostics. - `name`, `version`, `description`: human metadata used in listings and diagnostics.
- `runtime.kind`: required runtime family. Initial values should be `declarative` and `wasm`. - `surfaces`: optional declared contribution surface names.
- `runtime.entry`: required for `wasm`, forbidden or ignored for purely declarative packages. - `runtime`: optional WASM metadata only. Discovery records metadata and never executes it.
- `runtime.abi`: required for `wasm` so the host can reject incompatible modules before initialization. - `hooks`: optional hook metadata. Discovery records metadata and does not register hooks.
- `hooks`, `schemas`, `package.readme`, `package.license`: package-relative paths that must pass the same normalized-path validation as archive entries.
- `permissions`: requested authority. These declarations are requests only; they do not grant access. Future descriptor sections such as `[package]`, `[permissions]`, richer `contributions`, or `runtime.kind = "declarative"` are aspirational and are intentionally rejected by the current strict parser until implemented safely.
The `source` is not read from `plugin.toml`. It is assigned by the store that discovered the package. The `source` is not read from `plugin.toml`. It is assigned by the store that discovered the package.