Skip to main content

agentstategraph_wasm/
lib.rs

1//! WASM bindings for AgentStateGraph — runs in browsers, Deno, and Node.
2//!
3//! Usage (browser/Deno):
4//!   import init, { WasmAgentStateGraph } from './agentstategraph_wasm.js'
5//!   await init()
6//!   const asg = new WasmAgentStateGraph()
7//!   asg.set("/name", "my-cluster", "Checkpoint", "init")
8//!   asg.get("/name")  // → '"my-cluster"'
9
10// Binding glue: exported wasm-bindgen functions mirror the JS-side
11// call shape which has no natural way to collapse into fewer args.
12#![allow(clippy::too_many_arguments)]
13
14use std::sync::Arc;
15
16use wasm_bindgen::prelude::*;
17
18// Session + SessionStatus moved to agentstategraph-core in 0.6.5;
19// import from the canonical location rather than the facade re-export.
20use agentstategraph::speculation::SpecHandle;
21use agentstategraph::{CommitOptions, Repository, SCHEMA_VERSION};
22use agentstategraph_core::SessionStatus;
23use agentstategraph_core::{IntentCategory, Object};
24use agentstategraph_migrate::{CheckResult, Registry, RunMode, StepStatus};
25use agentstategraph_policy::{ChangeProposal, Policy, PolicyStore as PolicyBackend, Situation};
26use agentstategraph_storage::IndexedDbStorage;
27use agentstategraph_taint::{
28    QuarantineParams, TaintEffect, TaintKind, TaintMetadata, TaintParams, TaintSeverity,
29    UntaintParams, UnwatchParams, WatchDirection, WatchParams,
30};
31use agentstategraph_tasks::{
32    AddTaskOptions, OnCompleteHook, Priority, Proof, ProofKind, TaskId, TaskStore,
33};
34use semver::Version;
35
36fn parse_category(s: &str) -> IntentCategory {
37    match s.to_lowercase().as_str() {
38        "explore" => IntentCategory::Explore,
39        "refine" => IntentCategory::Refine,
40        "fix" => IntentCategory::Fix,
41        "rollback" => IntentCategory::Rollback,
42        "checkpoint" => IntentCategory::Checkpoint,
43        "merge" => IntentCategory::Merge,
44        // SECURITY (threat model v2, finding C3): the WASM boundary runs
45        // untrusted browser/Deno code with no capability check. Map
46        // "migrate" to a Custom category so `/_meta/*` writes are rejected
47        // by the substrate's reserved-path guard. Migration tooling lives
48        // outside WASM.
49        "migrate" => IntentCategory::Custom("Migrate-requested".into()),
50        "plan" => IntentCategory::Plan,
51        other => IntentCategory::Custom(other.to_string()),
52    }
53}
54
55fn make_opts(
56    description: &str,
57    category: &str,
58    reasoning: Option<String>,
59    confidence: Option<f64>,
60) -> CommitOptions {
61    let cat = parse_category(category);
62    let mut opts = CommitOptions::new("wasm", cat, description);
63    if let Some(r) = reasoning {
64        opts = opts.with_reasoning(r);
65    }
66    if let Some(c) = confidence {
67        opts = opts.with_confidence(c);
68    }
69    opts
70}
71
72/// AgentStateGraph for WASM — uses IndexedDB for persistent browser storage.
73///
74/// Architecture: in-memory cache with write-through to IndexedDB.
75/// - All reads are instant (from memory)
76/// - All writes queue changes for IndexedDB flush
77/// - Call `drain_pending()` from JS to get queued writes, then persist to IndexedDB
78/// - Call `load_data()` on startup to hydrate from IndexedDB
79#[wasm_bindgen]
80pub struct WasmAgentStateGraph {
81    repo: Arc<Repository>,
82    storage: std::sync::Arc<IndexedDbStorage>,
83}
84
85#[wasm_bindgen]
86impl WasmAgentStateGraph {
87    /// Create a new AgentStateGraph with IndexedDB-backed storage.
88    /// After construction, call `load_data()` with data from IndexedDB to restore state.
89    #[wasm_bindgen(constructor)]
90    pub fn new(db_name: Option<String>) -> Result<WasmAgentStateGraph, JsValue> {
91        let name = db_name.unwrap_or_else(|| "agentstategraph".to_string());
92        // One storage for the repository and for the load/drain bridge. They
93        // were two separate instances, so loads never reached the repository
94        // and its writes never reached the drains: nothing persisted.
95        let storage = std::sync::Arc::new(IndexedDbStorage::new(&name));
96        let repo = Repository::from_shared(storage.clone());
97        repo.init()
98            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
99
100        Ok(Self {
101            repo: Arc::new(repo),
102            storage,
103        })
104    }
105
106    /// Load objects from IndexedDB dump. Call on startup.
107    /// Pass a JSON string: [["hex_id", "json"], ...]
108    pub fn load_objects(&self, json_pairs: &str) -> Result<(), JsValue> {
109        let pairs: Vec<(String, String)> = serde_json::from_str(json_pairs)
110            .map_err(|e| JsValue::from_str(&format!("parse error: {}", e)))?;
111        self.storage
112            .load_objects(&pairs)
113            .map_err(|e| JsValue::from_str(&format!("{}", e)))
114    }
115
116    /// Load commits from IndexedDB dump.
117    pub fn load_commits(&self, json_pairs: &str) -> Result<(), JsValue> {
118        let pairs: Vec<(String, String)> = serde_json::from_str(json_pairs)
119            .map_err(|e| JsValue::from_str(&format!("parse error: {}", e)))?;
120        self.storage
121            .load_commits(&pairs)
122            .map_err(|e| JsValue::from_str(&format!("{}", e)))
123    }
124
125    /// Load refs from IndexedDB dump.
126    pub fn load_refs(&self, json_pairs: &str) -> Result<(), JsValue> {
127        let pairs: Vec<(String, String)> = serde_json::from_str(json_pairs)
128            .map_err(|e| JsValue::from_str(&format!("parse error: {}", e)))?;
129        self.storage
130            .load_refs(&pairs)
131            .map_err(|e| JsValue::from_str(&format!("{}", e)))
132    }
133
134    /// Get pending object writes for flushing to IndexedDB. Returns JSON.
135    pub fn drain_pending_objects(&self) -> String {
136        let pending = self.storage.drain_pending_objects();
137        serde_json::to_string(&pending).unwrap_or_else(|_| "[]".to_string())
138    }
139
140    /// Get pending commit writes.
141    pub fn drain_pending_commits(&self) -> String {
142        let pending = self.storage.drain_pending_commits();
143        serde_json::to_string(&pending).unwrap_or_else(|_| "[]".to_string())
144    }
145
146    /// Get pending ref writes.
147    pub fn drain_pending_refs(&self) -> String {
148        let pending = self.storage.drain_pending_refs();
149        serde_json::to_string(&pending).unwrap_or_else(|_| "[]".to_string())
150    }
151
152    /// Get pending ref deletions (keys to delete from the refs store), so a
153    /// deleted branch does not come back on the next load.
154    pub fn drain_deleted_refs(&self) -> String {
155        let pending = self.storage.drain_deleted_refs();
156        serde_json::to_string(&pending).unwrap_or_else(|_| "[]".to_string())
157    }
158
159    /// Get the IndexedDB database name.
160    pub fn db_name(&self) -> String {
161        self.storage.db_name().to_string()
162    }
163
164    /// Get a JSON value at a path.
165    pub fn get(
166        &self,
167        path: &str,
168        reference: Option<String>,
169        namespace: Option<String>,
170    ) -> Result<String, JsValue> {
171        let ref_name = reference.unwrap_or_else(|| "main".to_string());
172        let forked;
173        let repo: &agentstategraph::Repository = if let Some(ns_str) = namespace {
174            let ns = agentstategraph_core::Namespace::new(&ns_str)
175                .map_err(|e| JsValue::from_str(&format!("Invalid namespace: {}", e)))?;
176            forked = self.repo.fork_namespace(ns);
177            &forked
178        } else {
179            &self.repo
180        };
181        let val = repo
182            .get_json(&ref_name, path)
183            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
184        Ok(serde_json::to_string(&val).unwrap_or_default())
185    }
186
187    /// Set a JSON value at a path.
188    pub fn set(
189        &self,
190        path: &str,
191        json_value: &str,
192        category: &str,
193        description: &str,
194        reference: Option<String>,
195        reasoning: Option<String>,
196        confidence: Option<f64>,
197        namespace: Option<String>,
198    ) -> Result<String, JsValue> {
199        let ref_name = reference.unwrap_or_else(|| "main".to_string());
200        let value: serde_json::Value = serde_json::from_str(json_value)
201            .map_err(|e| JsValue::from_str(&format!("JSON error: {}", e)))?;
202        let opts = make_opts(description, category, reasoning, confidence);
203        let forked;
204        let repo: &agentstategraph::Repository = if let Some(ns_str) = namespace {
205            let ns = agentstategraph_core::Namespace::new(&ns_str)
206                .map_err(|e| JsValue::from_str(&format!("Invalid namespace: {}", e)))?;
207            forked = self.repo.fork_namespace(ns);
208            &forked
209        } else {
210            &self.repo
211        };
212        let id = repo
213            .set_json(&ref_name, path, &value, opts)
214            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
215        Ok(id.to_string())
216    }
217
218    /// Delete a value at a path.
219    pub fn delete(
220        &self,
221        path: &str,
222        category: &str,
223        description: &str,
224        reference: Option<String>,
225        namespace: Option<String>,
226    ) -> Result<String, JsValue> {
227        let ref_name = reference.unwrap_or_else(|| "main".to_string());
228        let opts = make_opts(description, category, None, None);
229        let forked;
230        let repo: &agentstategraph::Repository = if let Some(ns_str) = namespace {
231            let ns = agentstategraph_core::Namespace::new(&ns_str)
232                .map_err(|e| JsValue::from_str(&format!("Invalid namespace: {}", e)))?;
233            forked = self.repo.fork_namespace(ns);
234            &forked
235        } else {
236            &self.repo
237        };
238        let id = repo
239            .delete(&ref_name, path, opts)
240            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
241        Ok(id.to_string())
242    }
243
244    /// Create a branch.
245    pub fn branch(
246        &self,
247        name: &str,
248        from: Option<String>,
249        namespace: Option<String>,
250    ) -> Result<String, JsValue> {
251        let from_ref = from.unwrap_or_else(|| "main".to_string());
252        let forked;
253        let repo: &agentstategraph::Repository = if let Some(ns_str) = namespace {
254            let ns = agentstategraph_core::Namespace::new(&ns_str)
255                .map_err(|e| JsValue::from_str(&format!("Invalid namespace: {}", e)))?;
256            forked = self.repo.fork_namespace(ns);
257            &forked
258        } else {
259            &self.repo
260        };
261        let id = repo
262            .branch(name, &from_ref)
263            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
264        Ok(id.to_string())
265    }
266
267    /// Merge source into target.
268    pub fn merge(
269        &self,
270        source: &str,
271        target: Option<String>,
272        description: Option<String>,
273        namespace: Option<String>,
274    ) -> Result<String, JsValue> {
275        let target_ref = target.unwrap_or_else(|| "main".to_string());
276        let desc = description.unwrap_or_else(|| "merge".to_string());
277        let opts = make_opts(&desc, "Merge", None, None);
278        let forked;
279        let repo: &agentstategraph::Repository = if let Some(ns_str) = namespace {
280            let ns = agentstategraph_core::Namespace::new(&ns_str)
281                .map_err(|e| JsValue::from_str(&format!("Invalid namespace: {}", e)))?;
282            forked = self.repo.fork_namespace(ns);
283            &forked
284        } else {
285            &self.repo
286        };
287        let id = repo
288            .merge(source, &target_ref, opts)
289            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
290        Ok(id.to_string())
291    }
292
293    /// Structured diff between two refs. Returns JSON.
294    pub fn diff(
295        &self,
296        ref_a: &str,
297        ref_b: &str,
298        namespace: Option<String>,
299    ) -> Result<String, JsValue> {
300        let forked;
301        let repo: &agentstategraph::Repository = if let Some(ns_str) = namespace {
302            let ns = agentstategraph_core::Namespace::new(&ns_str)
303                .map_err(|e| JsValue::from_str(&format!("Invalid namespace: {}", e)))?;
304            forked = self.repo.fork_namespace(ns);
305            &forked
306        } else {
307            &self.repo
308        };
309        let ops = repo
310            .diff(ref_a, ref_b)
311            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
312        Ok(serde_json::to_string(&ops).unwrap_or_default())
313    }
314
315    /// Commit log. Returns JSON.
316    pub fn log(
317        &self,
318        reference: Option<String>,
319        limit: Option<u32>,
320        namespace: Option<String>,
321    ) -> Result<String, JsValue> {
322        let ref_name = reference.unwrap_or_else(|| "main".to_string());
323        let max = limit.unwrap_or(10) as usize;
324        let forked;
325        let repo: &agentstategraph::Repository = if let Some(ns_str) = namespace {
326            let ns = agentstategraph_core::Namespace::new(&ns_str)
327                .map_err(|e| JsValue::from_str(&format!("Invalid namespace: {}", e)))?;
328            forked = self.repo.fork_namespace(ns);
329            &forked
330        } else {
331            &self.repo
332        };
333        let commits = repo
334            .log(&ref_name, max)
335            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
336        let entries: Vec<serde_json::Value> = commits
337            .iter()
338            .map(|c| {
339                serde_json::json!({
340                    "id": c.id.short(),
341                    "agent": c.agent_id,
342                    "intent": {
343                        "category": format!("{:?}", c.intent.category),
344                        "description": c.intent.description,
345                        "tags": c.intent.tags,
346                    },
347                    "reasoning": c.reasoning,
348                    "confidence": c.confidence,
349                    "timestamp": c.timestamp.to_rfc3339(),
350                })
351            })
352            .collect();
353        Ok(serde_json::to_string(&entries).unwrap_or_default())
354    }
355
356    /// Blame — who modified a path and why.
357    pub fn blame(
358        &self,
359        path: &str,
360        reference: Option<String>,
361        namespace: Option<String>,
362    ) -> Result<String, JsValue> {
363        let ref_name = reference.unwrap_or_else(|| "main".to_string());
364        let forked;
365        let repo: &agentstategraph::Repository = if let Some(ns_str) = namespace {
366            let ns = agentstategraph_core::Namespace::new(&ns_str)
367                .map_err(|e| JsValue::from_str(&format!("Invalid namespace: {}", e)))?;
368            forked = self.repo.fork_namespace(ns);
369            &forked
370        } else {
371            &self.repo
372        };
373        let entry = repo
374            .blame(&ref_name, path)
375            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
376        Ok(serde_json::to_string(&entry).unwrap_or_default())
377    }
378
379    /// Create a speculation. Returns handle ID.
380    pub fn speculate(
381        &self,
382        from: Option<String>,
383        label: Option<String>,
384        namespace: Option<String>,
385    ) -> Result<u32, JsValue> {
386        let from_ref = from.unwrap_or_else(|| "main".to_string());
387        let forked;
388        let repo: &agentstategraph::Repository = if let Some(ns_str) = namespace {
389            let ns = agentstategraph_core::Namespace::new(&ns_str)
390                .map_err(|e| JsValue::from_str(&format!("Invalid namespace: {}", e)))?;
391            forked = self.repo.fork_namespace(ns);
392            &forked
393        } else {
394            &self.repo
395        };
396        let handle = repo
397            .speculate(&from_ref, label)
398            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
399        Ok(handle.id() as u32)
400    }
401
402    /// Get from a speculation.
403    pub fn spec_get(&self, handle_id: u32, path: &str) -> Result<String, JsValue> {
404        let handle = SpecHandle::from_id(handle_id as u64);
405        let obj = self
406            .repo
407            .spec_get(handle, path)
408            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
409        let val = match &obj {
410            Object::Atom(a) => match a {
411                agentstategraph_core::Atom::Null => serde_json::Value::Null,
412                agentstategraph_core::Atom::Bool(b) => serde_json::json!(b),
413                agentstategraph_core::Atom::Int(i) => serde_json::json!(i),
414                agentstategraph_core::Atom::UInt(u) => serde_json::json!(u),
415                agentstategraph_core::Atom::Float(f) => serde_json::json!(f),
416                agentstategraph_core::Atom::String(s) => serde_json::json!(s),
417                _ => serde_json::json!(format!("{:?}", obj)),
418            },
419            _ => serde_json::json!(format!("{:?}", obj)),
420        };
421        Ok(serde_json::to_string(&val).unwrap_or_default())
422    }
423
424    /// Set in a speculation.
425    pub fn spec_set(&self, handle_id: u32, path: &str, json_value: &str) -> Result<(), JsValue> {
426        let handle = SpecHandle::from_id(handle_id as u64);
427        let value: serde_json::Value = serde_json::from_str(json_value)
428            .map_err(|e| JsValue::from_str(&format!("JSON: {}", e)))?;
429        // As JSON: a hand conversion stored objects and arrays as strings.
430        self.repo
431            .spec_set_json(handle, path, &value)
432            .map_err(|e| JsValue::from_str(&format!("{}", e)))
433    }
434
435    /// Commit a speculation.
436    pub fn commit_speculation(
437        &self,
438        handle_id: u32,
439        category: &str,
440        description: &str,
441        reasoning: Option<String>,
442        confidence: Option<f64>,
443    ) -> Result<String, JsValue> {
444        let handle = SpecHandle::from_id(handle_id as u64);
445        let opts = make_opts(description, category, reasoning, confidence);
446        let id = self
447            .repo
448            .commit_speculation(handle, opts)
449            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
450        Ok(id.to_string())
451    }
452
453    /// Discard a speculation.
454    pub fn discard_speculation(&self, handle_id: u32) -> Result<(), JsValue> {
455        let handle = SpecHandle::from_id(handle_id as u64);
456        self.repo
457            .discard_speculation(handle)
458            .map_err(|e| JsValue::from_str(&format!("{}", e)))
459    }
460
461    /// Create an epoch.
462    pub fn create_epoch(&self, id: &str, description: &str) -> Result<String, JsValue> {
463        self.repo
464            .create_epoch(id, description, vec![])
465            .map(|e| format!("Epoch '{}' created", e.id))
466            .map_err(|e| JsValue::from_str(&format!("{}", e)))
467    }
468
469    /// Seal an epoch.
470    pub fn seal_epoch(&self, id: &str, summary: &str) -> Result<(), JsValue> {
471        self.repo
472            .seal_epoch(id, summary)
473            .map_err(|e| JsValue::from_str(&format!("{}", e)))
474    }
475
476    // -----------------------------------------------------------------
477    // TaskStore surface
478    //
479    // Plans/tasks cross the boundary as JSON. Consumers deserialize on
480    // the JS side. See `agentstategraph-tasks` for schemas.
481    // -----------------------------------------------------------------
482
483    /// Create a plan. Returns the Plan as JSON.
484    #[wasm_bindgen(js_name = tasksCreatePlan)]
485    pub fn tasks_create_plan(
486        &self,
487        prefix: &str,
488        agent_id: &str,
489        ref_name: &str,
490        name: &str,
491        description: Option<String>,
492    ) -> Result<String, JsValue> {
493        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
494        let plan = store
495            .create_plan(ref_name, name, description)
496            .map_err(js_err)?;
497        serde_json::to_string(&plan).map_err(js_err)
498    }
499
500    #[wasm_bindgen(js_name = tasksListPlans)]
501    pub fn tasks_list_plans(
502        &self,
503        prefix: &str,
504        agent_id: &str,
505        ref_name: &str,
506    ) -> Result<String, JsValue> {
507        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
508        let plans = store.list_plans(ref_name).map_err(js_err)?;
509        serde_json::to_string(&plans).map_err(js_err)
510    }
511
512    #[wasm_bindgen(js_name = tasksListPlansByStatus)]
513    pub fn tasks_list_plans_by_status(
514        &self,
515        prefix: &str,
516        agent_id: &str,
517        ref_name: &str,
518        status: Option<String>,
519    ) -> Result<String, JsValue> {
520        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
521        let parsed = status.as_deref().and_then(parse_plan_status);
522        let plans = store
523            .list_plans_by_status(ref_name, parsed)
524            .map_err(js_err)?;
525        serde_json::to_string(&plans).map_err(js_err)
526    }
527
528    #[wasm_bindgen(js_name = tasksGetPlan)]
529    pub fn tasks_get_plan(
530        &self,
531        prefix: &str,
532        agent_id: &str,
533        ref_name: &str,
534        name: &str,
535    ) -> Result<String, JsValue> {
536        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
537        let plan = store.get_plan(ref_name, name).map_err(js_err)?;
538        serde_json::to_string(&plan).map_err(js_err)
539    }
540
541    #[wasm_bindgen(js_name = tasksArchivePlan)]
542    pub fn tasks_archive_plan(
543        &self,
544        prefix: &str,
545        agent_id: &str,
546        ref_name: &str,
547        name: &str,
548    ) -> Result<String, JsValue> {
549        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
550        let plan = store.archive_plan(ref_name, name).map_err(js_err)?;
551        serde_json::to_string(&plan).map_err(js_err)
552    }
553
554    #[wasm_bindgen(js_name = tasksDeletePlan)]
555    pub fn tasks_delete_plan(
556        &self,
557        prefix: &str,
558        agent_id: &str,
559        ref_name: &str,
560        name: &str,
561    ) -> Result<(), JsValue> {
562        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
563        store.delete_plan(ref_name, name).map_err(js_err)
564    }
565
566    /// Add a task. `priority` is "low"|"medium"|"high"|"critical".
567    /// `blockers_json` is a JSON array of task id strings (or null).
568    #[wasm_bindgen(js_name = tasksAddTask)]
569    #[allow(clippy::too_many_arguments)]
570    pub fn tasks_add_task(
571        &self,
572        prefix: &str,
573        agent_id: &str,
574        ref_name: &str,
575        plan: &str,
576        title: &str,
577        priority: &str,
578        parent_id: Option<String>,
579        blockers_json: Option<String>,
580        assigned_to: Option<String>,
581    ) -> Result<String, JsValue> {
582        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
583        let prio = parse_priority(priority);
584        let parent = parent_id.filter(|s| !s.is_empty()).map(TaskId);
585        let blockers: Vec<TaskId> = match blockers_json {
586            None => Vec::new(),
587            Some(s) if s.is_empty() => Vec::new(),
588            Some(s) => serde_json::from_str::<Vec<String>>(&s)
589                .map_err(js_err)?
590                .into_iter()
591                .map(TaskId)
592                .collect(),
593        };
594        let task = store
595            .add_task(ref_name, plan, title, prio, parent, blockers, assigned_to)
596            .map_err(js_err)?;
597        serde_json::to_string(&task).map_err(js_err)
598    }
599
600    /// Add a task with the 0.6.0 extension fields (`payload`,
601    /// `parent_change`, `on_complete`). All three are JSON strings; pass
602    /// `None` / null for unset. `payload` is an arbitrary JSON value,
603    /// `parent_change` a `"spec-id@version"` handle, `on_complete` a
604    /// JSON-serialized `OnCompleteHook`.
605    #[wasm_bindgen(js_name = tasksAddTaskWithExtensions)]
606    #[allow(clippy::too_many_arguments)]
607    pub fn tasks_add_task_with_extensions(
608        &self,
609        prefix: &str,
610        agent_id: &str,
611        ref_name: &str,
612        plan: &str,
613        title: &str,
614        priority: &str,
615        parent_id: Option<String>,
616        blockers_json: Option<String>,
617        assigned_to: Option<String>,
618        payload_json: Option<String>,
619        parent_change: Option<String>,
620        on_complete_json: Option<String>,
621    ) -> Result<String, JsValue> {
622        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
623        let prio = parse_priority(priority);
624        let parent = parent_id.filter(|s| !s.is_empty()).map(TaskId);
625        let blockers: Vec<TaskId> = match blockers_json {
626            None => Vec::new(),
627            Some(s) if s.is_empty() => Vec::new(),
628            Some(s) => serde_json::from_str::<Vec<String>>(&s)
629                .map_err(js_err)?
630                .into_iter()
631                .map(TaskId)
632                .collect(),
633        };
634        let payload: Option<serde_json::Value> = match payload_json {
635            None => None,
636            Some(s) if s.is_empty() => None,
637            Some(s) => Some(serde_json::from_str(&s).map_err(js_err)?),
638        };
639        let on_complete: Option<OnCompleteHook> = match on_complete_json {
640            None => None,
641            Some(s) if s.is_empty() => None,
642            Some(s) => Some(serde_json::from_str(&s).map_err(js_err)?),
643        };
644        let task = store
645            .add_task_with_extensions(
646                ref_name,
647                plan,
648                title,
649                prio,
650                parent,
651                blockers,
652                assigned_to,
653                AddTaskOptions {
654                    payload,
655                    parent_change,
656                    on_complete,
657                },
658            )
659            .map_err(js_err)?;
660        serde_json::to_string(&task).map_err(js_err)
661    }
662
663    #[wasm_bindgen(js_name = tasksListTasks)]
664    pub fn tasks_list_tasks(
665        &self,
666        prefix: &str,
667        agent_id: &str,
668        ref_name: &str,
669        plan: &str,
670    ) -> Result<String, JsValue> {
671        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
672        let tasks = store.list_tasks(ref_name, plan).map_err(js_err)?;
673        serde_json::to_string(&tasks).map_err(js_err)
674    }
675
676    #[wasm_bindgen(js_name = tasksTaskIds)]
677    pub fn tasks_task_ids(
678        &self,
679        prefix: &str,
680        agent_id: &str,
681        ref_name: &str,
682        plan: &str,
683    ) -> Result<String, JsValue> {
684        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
685        let ids: Vec<String> = store
686            .task_ids(ref_name, plan)
687            .map_err(js_err)?
688            .into_iter()
689            .map(|i| i.0)
690            .collect();
691        serde_json::to_string(&ids).map_err(js_err)
692    }
693
694    #[wasm_bindgen(js_name = tasksGetTask)]
695    pub fn tasks_get_task(
696        &self,
697        prefix: &str,
698        agent_id: &str,
699        ref_name: &str,
700        plan: &str,
701        task_id: &str,
702    ) -> Result<String, JsValue> {
703        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
704        let task = store
705            .get_task(ref_name, plan, &TaskId(task_id.to_string()))
706            .map_err(js_err)?;
707        serde_json::to_string(&task).map_err(js_err)
708    }
709
710    #[wasm_bindgen(js_name = tasksStartTask)]
711    pub fn tasks_start_task(
712        &self,
713        prefix: &str,
714        agent_id: &str,
715        ref_name: &str,
716        plan: &str,
717        task_id: &str,
718    ) -> Result<String, JsValue> {
719        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
720        let task = store
721            .start_task(ref_name, plan, &TaskId(task_id.to_string()))
722            .map_err(js_err)?;
723        serde_json::to_string(&task).map_err(js_err)
724    }
725
726    /// Complete a task. `proof_kind` is "commit"|"file"|"test"|"text".
727    #[wasm_bindgen(js_name = tasksCompleteTask)]
728    #[allow(clippy::too_many_arguments)]
729    pub fn tasks_complete_task(
730        &self,
731        prefix: &str,
732        agent_id: &str,
733        ref_name: &str,
734        plan: &str,
735        task_id: &str,
736        proof_kind: &str,
737        proof_value: &str,
738        proof_note: Option<String>,
739    ) -> Result<String, JsValue> {
740        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
741        let kind = parse_proof_kind(proof_kind)
742            .ok_or_else(|| JsValue::from_str(&format!("invalid proof kind: {proof_kind}")))?;
743        let proof = Proof {
744            kind,
745            value: proof_value.to_string(),
746            note: proof_note,
747        };
748        let task = store
749            .complete_task(ref_name, plan, &TaskId(task_id.to_string()), proof)
750            .map_err(js_err)?;
751        serde_json::to_string(&task).map_err(js_err)
752    }
753
754    #[wasm_bindgen(js_name = tasksAbandonTask)]
755    pub fn tasks_abandon_task(
756        &self,
757        prefix: &str,
758        agent_id: &str,
759        ref_name: &str,
760        plan: &str,
761        task_id: &str,
762        reason: &str,
763    ) -> Result<String, JsValue> {
764        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
765        let task = store
766            .abandon_task(ref_name, plan, &TaskId(task_id.to_string()), reason)
767            .map_err(js_err)?;
768        serde_json::to_string(&task).map_err(js_err)
769    }
770
771    #[wasm_bindgen(js_name = tasksSetPriority)]
772    pub fn tasks_set_priority(
773        &self,
774        prefix: &str,
775        agent_id: &str,
776        ref_name: &str,
777        plan: &str,
778        task_id: &str,
779        priority: &str,
780    ) -> Result<String, JsValue> {
781        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
782        let task = store
783            .set_priority(
784                ref_name,
785                plan,
786                &TaskId(task_id.to_string()),
787                parse_priority(priority),
788            )
789            .map_err(js_err)?;
790        serde_json::to_string(&task).map_err(js_err)
791    }
792
793    #[wasm_bindgen(js_name = tasksSetTitle)]
794    pub fn tasks_set_title(
795        &self,
796        prefix: &str,
797        agent_id: &str,
798        ref_name: &str,
799        plan: &str,
800        task_id: &str,
801        title: &str,
802    ) -> Result<String, JsValue> {
803        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
804        let task = store
805            .set_title(ref_name, plan, &TaskId(task_id.to_string()), title)
806            .map_err(js_err)?;
807        serde_json::to_string(&task).map_err(js_err)
808    }
809
810    #[wasm_bindgen(js_name = tasksSetBlockers)]
811    pub fn tasks_set_blockers(
812        &self,
813        prefix: &str,
814        agent_id: &str,
815        ref_name: &str,
816        plan: &str,
817        task_id: &str,
818        blockers_json: &str,
819    ) -> Result<String, JsValue> {
820        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
821        let blockers: Vec<TaskId> = if blockers_json.is_empty() {
822            Vec::new()
823        } else {
824            serde_json::from_str::<Vec<String>>(blockers_json)
825                .map_err(js_err)?
826                .into_iter()
827                .map(TaskId)
828                .collect()
829        };
830        let task = store
831            .set_blockers(ref_name, plan, &TaskId(task_id.to_string()), blockers)
832            .map_err(js_err)?;
833        serde_json::to_string(&task).map_err(js_err)
834    }
835
836    #[wasm_bindgen(js_name = tasksAssignTask)]
837    pub fn tasks_assign_task(
838        &self,
839        prefix: &str,
840        agent_id: &str,
841        ref_name: &str,
842        plan: &str,
843        task_id: &str,
844        agent: &str,
845    ) -> Result<String, JsValue> {
846        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
847        let task = store
848            .assign_task(ref_name, plan, &TaskId(task_id.to_string()), agent)
849            .map_err(js_err)?;
850        serde_json::to_string(&task).map_err(js_err)
851    }
852
853    #[wasm_bindgen(js_name = tasksUnassignTask)]
854    pub fn tasks_unassign_task(
855        &self,
856        prefix: &str,
857        agent_id: &str,
858        ref_name: &str,
859        plan: &str,
860        task_id: &str,
861    ) -> Result<String, JsValue> {
862        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
863        let task = store
864            .unassign_task(ref_name, plan, &TaskId(task_id.to_string()))
865            .map_err(js_err)?;
866        serde_json::to_string(&task).map_err(js_err)
867    }
868
869    #[wasm_bindgen(js_name = tasksNextTask)]
870    pub fn tasks_next_task(
871        &self,
872        prefix: &str,
873        agent_id: &str,
874        ref_name: &str,
875        plan: &str,
876    ) -> Result<String, JsValue> {
877        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
878        let task = store.next_task(ref_name, plan).map_err(js_err)?;
879        serde_json::to_string(&task).map_err(js_err)
880    }
881
882    #[wasm_bindgen(js_name = tasksNextTaskFor)]
883    pub fn tasks_next_task_for(
884        &self,
885        prefix: &str,
886        agent_id: &str,
887        ref_name: &str,
888        plan: &str,
889        agent: Option<String>,
890        include_unassigned: bool,
891    ) -> Result<String, JsValue> {
892        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
893        let task = store
894            .next_task_for(ref_name, plan, agent.as_deref(), include_unassigned)
895            .map_err(js_err)?;
896        serde_json::to_string(&task).map_err(js_err)
897    }
898
899    #[wasm_bindgen(js_name = tasksDerivedStatus)]
900    pub fn tasks_derived_status(
901        &self,
902        prefix: &str,
903        agent_id: &str,
904        ref_name: &str,
905        plan: &str,
906        parent_id: &str,
907    ) -> Result<String, JsValue> {
908        let store = TaskStore::new(self.repo.clone(), prefix, agent_id);
909        let status = store
910            .derived_status(ref_name, plan, &TaskId(parent_id.to_string()))
911            .map_err(js_err)?;
912        serde_json::to_string(&status).map_err(js_err)
913    }
914
915    // -----------------------------------------------------------------
916    // Migration surface
917    // -----------------------------------------------------------------
918
919    /// Check migration status. Returns a JSON report (see `CheckResult`).
920    #[wasm_bindgen(js_name = migrateCheck)]
921    pub fn migrate_check(&self, ref_name: &str, target: Option<String>) -> Result<String, JsValue> {
922        let target_str = target.unwrap_or_else(|| SCHEMA_VERSION.to_string());
923        let target_version = Version::parse(&target_str).map_err(js_err)?;
924        let registry = Registry::builtin();
925        let r = agentstategraph_migrate::check(&self.repo, ref_name, &target_version, &registry)
926            .map_err(js_err)?;
927        Ok(check_result_json(&r))
928    }
929
930    /// Run migrations. `mode` is `"apply"` or `"dry-run"`.
931    #[wasm_bindgen(js_name = migrateRun)]
932    pub fn migrate_run(
933        &self,
934        ref_name: &str,
935        target: Option<String>,
936        mode: &str,
937    ) -> Result<String, JsValue> {
938        let target_str = target.unwrap_or_else(|| SCHEMA_VERSION.to_string());
939        let target_version = Version::parse(&target_str).map_err(js_err)?;
940        let run_mode = match mode.to_lowercase().as_str() {
941            "apply" => RunMode::Apply,
942            "dry-run" | "dryrun" | "dry_run" => RunMode::DryRun,
943            other => return Err(JsValue::from_str(&format!("invalid mode: {other}"))),
944        };
945        let registry = Registry::builtin();
946        let r = registry
947            .run(&self.repo, ref_name, &target_version, run_mode)
948            .map_err(js_err)?;
949        Ok(report_json(&r))
950    }
951
952    // -----------------------------------------------------------------
953    // Session surface. Session + SessionStatus moved to
954    // agentstategraph-core in 0.6.5; the facade still re-exports them
955    // for back-compat, but new code imports from the canonical path
956    // (see the top-of-file use statement). Repository::sessions()
957    // returns a SessionManager. We surface the same shape as the
958    // Python binding so JS consumers can round-trip Session records.
959    // -----------------------------------------------------------------
960
961    /// Create a durable session record. Returns the Session as JSON.
962    #[wasm_bindgen(js_name = createSession)]
963    #[allow(clippy::too_many_arguments)]
964    pub fn create_session(
965        &self,
966        agent_id: &str,
967        working_branch: Option<String>,
968        parent_session: Option<String>,
969        delegated_intent: Option<String>,
970        report_to: Option<String>,
971        path_scope: Option<String>,
972    ) -> Result<String, JsValue> {
973        let branch = working_branch.unwrap_or_else(|| "main".to_string());
974        let log = self.repo.log(&branch, 1).map_err(js_err)?;
975        let head = log
976            .into_iter()
977            .next()
978            .map(|c| c.id)
979            .ok_or_else(|| JsValue::from_str(&format!("ref {branch:?} empty")))?;
980        let mgr = self.repo.sessions();
981        let s = mgr
982            .create(
983                agent_id,
984                &branch,
985                head,
986                agentstategraph::session::CreateSessionParams {
987                    parent_session,
988                    delegated_intent,
989                    report_to,
990                    path_scope,
991                    scope_namespace: None,
992                },
993            )
994            .map_err(js_err)?;
995        serde_json::to_string(&s).map_err(js_err)
996    }
997
998    /// Fetch a session by id. Returns JSON, or "null" if not found.
999    #[wasm_bindgen(js_name = getSession)]
1000    pub fn get_session(&self, id: &str) -> Result<String, JsValue> {
1001        let mgr = self.repo.sessions();
1002        let s = mgr.get(id).map_err(js_err)?;
1003        serde_json::to_string(&s).map_err(js_err)
1004    }
1005
1006    /// List sessions, optionally filtered by agent_id.
1007    #[wasm_bindgen(js_name = listSessions)]
1008    pub fn list_sessions(&self, agent_filter: Option<String>) -> Result<String, JsValue> {
1009        let mgr = self.repo.sessions();
1010        let sessions = mgr.list(agent_filter.as_deref()).map_err(js_err)?;
1011        serde_json::to_string(&sessions).map_err(js_err)
1012    }
1013
1014    /// End a session. `status` is "active" | "completed" | "abandoned".
1015    #[wasm_bindgen(js_name = endSession)]
1016    pub fn end_session(&self, id: &str, status: &str) -> Result<(), JsValue> {
1017        let st = parse_session_status(status)
1018            .ok_or_else(|| JsValue::from_str(&format!("invalid session status: {status}")))?;
1019        let mgr = self.repo.sessions();
1020        mgr.end(id, st).map_err(js_err)
1021    }
1022
1023    // -----------------------------------------------------------------
1024    // Taint / quarantine / watch surface (0.7.75 §9d).
1025    //
1026    // Mirrors the FFI binding (`agentstategraph-ffi` taint functions).
1027    // Each mutator takes a `params_json` string shaped like the FFI
1028    // payload; we deserialize into the Rust *Params types and route to
1029    // the Repository method. Returns the new taint id (for create
1030    // methods) or unit (for resolve methods), serialized as a bare JSON
1031    // string for id and empty Ok for unit.
1032    // -----------------------------------------------------------------
1033
1034    /// Apply a taint. `params_json` shape:
1035    /// `{"name","effect","reason","severity","expires","propagate","agent_id","metadata"}`.
1036    /// Returns the new taint id.
1037    pub fn taint(&self, ref_name: &str, path: &str, params_json: &str) -> Result<String, JsValue> {
1038        let params = parse_taint_params(params_json)?;
1039        self.repo.taint(ref_name, path, params).map_err(js_err)
1040    }
1041
1042    /// Resolve a taint by name on `path`. `params_json` shape:
1043    /// `{"reason","proof","agent_id"}`.
1044    pub fn untaint(
1045        &self,
1046        ref_name: &str,
1047        path: &str,
1048        name: &str,
1049        params_json: &str,
1050    ) -> Result<(), JsValue> {
1051        let params = parse_untaint_params(params_json)?;
1052        self.repo
1053            .untaint(ref_name, path, name, params)
1054            .map_err(js_err)
1055    }
1056
1057    /// Apply a quarantine. `params_json` shape:
1058    /// `{"name","reason","severity","authorized_agents","expires","propagate","agent_id"}`.
1059    pub fn quarantine(
1060        &self,
1061        ref_name: &str,
1062        path: &str,
1063        params_json: &str,
1064    ) -> Result<String, JsValue> {
1065        let params = parse_quarantine_params(params_json)?;
1066        self.repo.quarantine(ref_name, path, params).map_err(js_err)
1067    }
1068
1069    /// Release a quarantine. `params_json` shape matches `untaint`.
1070    pub fn unquarantine(
1071        &self,
1072        ref_name: &str,
1073        path: &str,
1074        name: &str,
1075        params_json: &str,
1076    ) -> Result<(), JsValue> {
1077        let params = parse_untaint_params(params_json)?;
1078        self.repo
1079            .unquarantine(ref_name, path, name, params)
1080            .map_err(js_err)
1081    }
1082
1083    /// Apply a watch. `params_json` shape:
1084    /// `{"name","reason","metric","threshold","direction","check_interval_secs","expires",
1085    ///   "severity","propagate","agent_id"}`.
1086    pub fn watch(&self, ref_name: &str, path: &str, params_json: &str) -> Result<String, JsValue> {
1087        let params = parse_watch_params(params_json)?;
1088        self.repo.watch_path(ref_name, path, params).map_err(js_err)
1089    }
1090
1091    /// Remove a watch. `params_json` shape: `{"reason","agent_id"}`.
1092    pub fn unwatch(
1093        &self,
1094        ref_name: &str,
1095        path: &str,
1096        name: &str,
1097        params_json: &str,
1098    ) -> Result<(), JsValue> {
1099        let params = parse_unwatch_params(params_json)?;
1100        self.repo
1101            .unwatch(ref_name, path, name, params)
1102            .map_err(js_err)
1103    }
1104
1105    /// List taints / quarantines / watches. Returns a JSON array of
1106    /// [`Taint`] records.
1107    #[wasm_bindgen(js_name = listTaints)]
1108    pub fn list_taints(
1109        &self,
1110        path_prefix: Option<String>,
1111        kind: Option<String>,
1112        include_resolved: bool,
1113    ) -> Result<String, JsValue> {
1114        let parsed_kind = match kind.as_deref() {
1115            None => None,
1116            Some(s) => match parse_taint_kind_wasm(s) {
1117                Some(k) => Some(k),
1118                None => return Err(JsValue::from_str(&format!("unknown kind: {s}"))),
1119            },
1120        };
1121        let list = self
1122            .repo
1123            .list_taints(path_prefix.as_deref(), parsed_kind, include_resolved)
1124            .map_err(js_err)?;
1125        serde_json::to_string(&list).map_err(js_err)
1126    }
1127
1128    /// Check taint status for `path` given `agent_id` + `confidence`.
1129    /// Returns a [`TaintCheck`] JSON object.
1130    #[wasm_bindgen(js_name = checkTaint)]
1131    pub fn check_taint(
1132        &self,
1133        path: &str,
1134        agent_id: Option<String>,
1135        confidence: Option<f64>,
1136    ) -> Result<String, JsValue> {
1137        let agent = agent_id.unwrap_or_default();
1138        let conf = confidence.unwrap_or(0.0);
1139        let check = self.repo.check_taint(path, &agent, conf).map_err(js_err)?;
1140        serde_json::to_string(&check).map_err(js_err)
1141    }
1142
1143    /// List epochs. Returns JSON.
1144    pub fn list_epochs(&self) -> Result<String, JsValue> {
1145        let entries = self
1146            .repo
1147            .list_epochs()
1148            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
1149        let json: Vec<serde_json::Value> = entries
1150            .iter()
1151            .map(|e| {
1152                serde_json::json!({
1153                    "id": e.id,
1154                    "description": e.description,
1155                    "status": format!("{:?}", e.status),
1156                    "commits": e.commit_count,
1157                })
1158            })
1159            .collect();
1160        Ok(serde_json::to_string(&json).unwrap_or_default())
1161    }
1162
1163    /// List epochs across every workspace. Returns JSON.
1164    ///
1165    /// `list_epochs` returns only the active workspace's epochs. This binding
1166    /// cannot select a workspace, so without this it could not reach epochs
1167    /// outside the default one at all.
1168    pub fn list_all_epochs(&self) -> Result<String, JsValue> {
1169        let entries = self
1170            .repo
1171            .list_all_epochs()
1172            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
1173        let json: Vec<serde_json::Value> = entries
1174            .iter()
1175            .map(|e| {
1176                serde_json::json!({
1177                    "id": e.id,
1178                    "description": e.description,
1179                    "status": format!("{:?}", e.status),
1180                    "commits": e.commit_count,
1181                    "scope": e.scope.as_storage_str(),
1182                    "namespace": e.namespace,
1183                })
1184            })
1185            .collect();
1186        Ok(serde_json::to_string(&json).unwrap_or_default())
1187    }
1188}
1189
1190// ---------------------------------------------------------------------------
1191// PolicyStore surface
1192//
1193// Mirrors the Python (§2) / TS (§3) idiom: complex types (Policy,
1194// Decision, ChangeProposal, Situation, Selector, ...) cross the
1195// boundary as JSON strings. Consumers `JSON.parse` on the JS side.
1196// See `agentstategraph-policy` for schemas.
1197// ---------------------------------------------------------------------------
1198
1199/// PolicyStore — situation-matching authorization + change-cost
1200/// policies. Wraps `agentstategraph_policy::PolicyStore`.
1201///
1202/// Construct from a `WasmAgentStateGraph`, a path prefix (e.g.
1203/// `/policies`), and an agent id.
1204#[wasm_bindgen]
1205pub struct WasmPolicyStore {
1206    inner: PolicyBackend,
1207}
1208
1209impl WasmPolicyStore {
1210    /// Construct directly from a `Repository` handle. Used by the
1211    /// integration tests that run under wasm-bindgen-test and need a
1212    /// SqliteStorage-backed store rather than the IndexedDB-backed
1213    /// `WasmAgentStateGraph`. Not exported to JS (plain Rust `impl`).
1214    #[doc(hidden)]
1215    pub fn from_repo(repo: Arc<Repository>, prefix: &str, agent_id: &str) -> Self {
1216        Self {
1217            inner: PolicyBackend::new(repo, prefix, agent_id),
1218        }
1219    }
1220}
1221
1222#[wasm_bindgen]
1223impl WasmPolicyStore {
1224    /// Create a PolicyStore bound to an AgentStateGraph instance.
1225    #[wasm_bindgen(constructor)]
1226    pub fn new(asg: &WasmAgentStateGraph, prefix: &str, agent_id: &str) -> WasmPolicyStore {
1227        WasmPolicyStore {
1228            inner: PolicyBackend::new(asg.repo.clone(), prefix, agent_id),
1229        }
1230    }
1231
1232    /// Write a proposed (unratified) policy. Returns `"path@version"`.
1233    /// `policy_json` is the serialized Policy struct.
1234    pub fn propose(&self, ref_name: &str, policy_json: &str) -> Result<String, JsValue> {
1235        let p: Policy = serde_json::from_str(policy_json).map_err(js_err)?;
1236        self.inner.propose(ref_name, p).map_err(js_err)
1237    }
1238
1239    /// Ratify an unratified proposal at `path`.
1240    pub fn ratify(
1241        &self,
1242        ref_name: &str,
1243        path: &str,
1244        ratifier: &str,
1245        reasoning: &str,
1246    ) -> Result<(), JsValue> {
1247        self.inner
1248            .ratify(ref_name, path, ratifier, reasoning)
1249            .map_err(js_err)
1250    }
1251
1252    /// Replace the active policy at `path` with `new_policy_json`.
1253    /// Returns the new `"path@version"` handle.
1254    pub fn supersede(
1255        &self,
1256        ref_name: &str,
1257        path: &str,
1258        new_policy_json: &str,
1259    ) -> Result<String, JsValue> {
1260        let p: Policy = serde_json::from_str(new_policy_json).map_err(js_err)?;
1261        self.inner.supersede(ref_name, path, p).map_err(js_err)
1262    }
1263
1264    /// List every policy (active versions, ratified or not). Returns a
1265    /// JSON array.
1266    ///
1267    /// `tenantFilter` (0.7.5 §3b / §5d): `None`/undefined keeps
1268    /// back-compat (all policies considered). When provided, routes
1269    /// through the Rust `list_scoped` variant so only policies whose
1270    /// `tenant_id` is unset (global) or equal to the filter are
1271    /// returned.
1272    pub fn list(
1273        &self,
1274        ref_name: &str,
1275        prefix_filter: Option<String>,
1276        tenant_filter: Option<String>,
1277    ) -> Result<String, JsValue> {
1278        let policies = self
1279            .inner
1280            .list_scoped(ref_name, prefix_filter.as_deref(), tenant_filter.as_deref())
1281            .map_err(js_err)?;
1282        serde_json::to_string(&policies).map_err(js_err)
1283    }
1284
1285    /// List currently-active policies (ratified AND `active_from <= now`).
1286    ///
1287    /// `tenantFilter` (0.7.5 §3b / §5d) matches [`Self::list`] semantics.
1288    pub fn active(
1289        &self,
1290        ref_name: &str,
1291        prefix_filter: Option<String>,
1292        tenant_filter: Option<String>,
1293    ) -> Result<String, JsValue> {
1294        let policies = self
1295            .inner
1296            .active_scoped(ref_name, prefix_filter.as_deref(), tenant_filter.as_deref())
1297            .map_err(js_err)?;
1298        serde_json::to_string(&policies).map_err(js_err)
1299    }
1300
1301    /// Fetch a policy at `path`. If `version` is provided, returns the
1302    /// pinned historical version; otherwise the current active version.
1303    pub fn get(&self, ref_name: &str, path: &str, version: Option<u64>) -> Result<String, JsValue> {
1304        let policy = self.inner.get(ref_name, path, version).map_err(js_err)?;
1305        serde_json::to_string(&policy).map_err(js_err)
1306    }
1307
1308    /// Walk the supersedes chain (oldest first → current).
1309    pub fn history(&self, ref_name: &str, path: &str) -> Result<String, JsValue> {
1310        let policies = self.inner.history(ref_name, path).map_err(js_err)?;
1311        serde_json::to_string(&policies).map_err(js_err)
1312    }
1313
1314    /// Authorization evaluation (POLICY_V1.md §5). Returns a Decision
1315    /// JSON object.
1316    ///
1317    /// `tenantFilter` (0.7.5 §3b / §5d) routes through the Rust
1318    /// `evaluate_scoped` variant: `None`/undefined considers every
1319    /// policy (back-compat); `Some("acme")` restricts matching to
1320    /// policies whose `tenant_id` is unset (global) or equal to
1321    /// `"acme"`.
1322    pub fn evaluate(
1323        &self,
1324        ref_name: &str,
1325        situation_json: &str,
1326        action: &str,
1327        agent_id: &str,
1328        tenant_filter: Option<String>,
1329    ) -> Result<String, JsValue> {
1330        let sit: Situation = serde_json::from_str(situation_json).map_err(js_err)?;
1331        let decision = self
1332            .inner
1333            .evaluate_scoped(ref_name, &sit, action, agent_id, tenant_filter.as_deref())
1334            .map_err(js_err)?;
1335        serde_json::to_string(&decision).map_err(js_err)
1336    }
1337
1338    /// Change-proposal evaluation (POLICY_V1.md §22.2). Returns a
1339    /// Decision JSON object.
1340    ///
1341    /// `tenantFilter` (0.7.5 §3b / §5d) matches [`Self::evaluate`] semantics.
1342    #[wasm_bindgen(js_name = evaluateChange)]
1343    pub fn evaluate_change(
1344        &self,
1345        ref_name: &str,
1346        proposal_json: &str,
1347        tenant_filter: Option<String>,
1348    ) -> Result<String, JsValue> {
1349        let prop: ChangeProposal = serde_json::from_str(proposal_json).map_err(js_err)?;
1350        let decision = self
1351            .inner
1352            .evaluate_change_scoped(ref_name, &prop, tenant_filter.as_deref())
1353            .map_err(js_err)?;
1354        serde_json::to_string(&decision).map_err(js_err)
1355    }
1356
1357    // ---- 0.7.75-beta.3 §9d follow-up: real sign / verify ----
1358    //
1359    // Mirrors the TypeScript wiring from 0.7.5-beta.2 (commit 8290bf4).
1360    // `set_external_evaluator` remains a stub per plan §4c — register
1361    // runners via the MCP server builders instead of the binding
1362    // boundary.
1363
1364    /// Sign the active policy at `path` with an Ed25519 private key.
1365    /// `private_key_hex` is a 32-byte seed encoded as 64 hex chars.
1366    /// Returns a JSON string `{algorithm, signer_key_id,
1367    /// signature_hex}`.
1368    pub fn sign(
1369        &self,
1370        ref_name: &str,
1371        path: &str,
1372        signer_key_id: &str,
1373        private_key_hex: &str,
1374    ) -> Result<String, JsValue> {
1375        use agentstategraph_policy::PolicySignature;
1376        use agentstategraph_policy_sign::{canonicalize, Ed25519Signer, PolicySigner};
1377
1378        let seed_vec = hex::decode(private_key_hex)
1379            .map_err(|e| js_err(format!("invalid private_key_hex: {e}")))?;
1380        let seed: [u8; 32] = seed_vec
1381            .as_slice()
1382            .try_into()
1383            .map_err(|_| js_err("private_key_hex must decode to 32 bytes"))?;
1384        let signer = Ed25519Signer::from_bytes(signer_key_id.to_string(), &seed);
1385
1386        let policy = self
1387            .inner
1388            .get(ref_name, path, None)
1389            .map_err(|e| js_err(e.to_string()))?;
1390        let canonical = canonicalize(&policy).map_err(|e| js_err(e.to_string()))?;
1391        let (key_id, sig_bytes) = signer.sign(&canonical).map_err(|e| js_err(e.to_string()))?;
1392        let sig_hex = hex::encode(&sig_bytes);
1393        let signature = PolicySignature::Ed25519 {
1394            signer_key_id: key_id.clone(),
1395            signature_hex: sig_hex.clone(),
1396        };
1397        self.inner
1398            .set_signature(ref_name, path, signature)
1399            .map_err(|e| js_err(e.to_string()))?;
1400        let envelope = serde_json::json!({
1401            "algorithm": "ed25519",
1402            "signer_key_id": key_id,
1403            "signature_hex": sig_hex,
1404        });
1405        serde_json::to_string(&envelope).map_err(js_err)
1406    }
1407
1408    /// Verify the Ed25519 signature on the policy at `path` using
1409    /// `public_key_hex` (64-char hex / 32-byte key). Returns
1410    /// `{valid: true, algorithm, signer_key_id}` on success,
1411    /// `{valid: false, reason}` otherwise.
1412    pub fn verify(
1413        &self,
1414        ref_name: &str,
1415        path: &str,
1416        public_key_hex: &str,
1417    ) -> Result<String, JsValue> {
1418        use agentstategraph_policy::PolicySignature;
1419        use agentstategraph_policy_sign::{
1420            canonicalize, Ed25519Verifier, InMemoryKeyRegistry, PolicyVerifier,
1421        };
1422
1423        let policy = self
1424            .inner
1425            .get(ref_name, path, None)
1426            .map_err(|e| js_err(e.to_string()))?;
1427        let Some(sig) = policy.signature.as_ref() else {
1428            let env = serde_json::json!({"valid": false, "reason": "unsigned"});
1429            return serde_json::to_string(&env).map_err(js_err);
1430        };
1431        let PolicySignature::Ed25519 {
1432            signer_key_id,
1433            signature_hex,
1434        } = sig;
1435
1436        let pk_vec = hex::decode(public_key_hex)
1437            .map_err(|e| js_err(format!("invalid public_key_hex: {e}")))?;
1438        let pk_bytes: [u8; 32] = pk_vec
1439            .as_slice()
1440            .try_into()
1441            .map_err(|_| js_err("public_key_hex must decode to 32 bytes"))?;
1442        let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&pk_bytes)
1443            .map_err(|e| js_err(format!("invalid verifying key: {e}")))?;
1444
1445        let mut registry = InMemoryKeyRegistry::new();
1446        registry.insert(signer_key_id.clone(), verifying_key);
1447        let verifier = Ed25519Verifier::new(registry);
1448
1449        let sig_bytes = match hex::decode(signature_hex) {
1450            Ok(b) => b,
1451            Err(e) => {
1452                let env = serde_json::json!({
1453                    "valid": false,
1454                    "reason": format!("invalid signature_hex: {e}"),
1455                });
1456                return serde_json::to_string(&env).map_err(js_err);
1457            }
1458        };
1459        let canonical = canonicalize(&policy).map_err(|e| js_err(e.to_string()))?;
1460        match verifier.verify(signer_key_id, &sig_bytes, &canonical) {
1461            Ok(()) => {
1462                let env = serde_json::json!({
1463                    "valid": true,
1464                    "algorithm": "ed25519",
1465                    "signer_key_id": signer_key_id,
1466                });
1467                serde_json::to_string(&env).map_err(js_err)
1468            }
1469            Err(e) => {
1470                let env = serde_json::json!({
1471                    "valid": false,
1472                    "reason": e.to_string(),
1473                });
1474                serde_json::to_string(&env).map_err(js_err)
1475            }
1476        }
1477    }
1478
1479    /// Attach or update the external evaluator reference on the
1480    /// policy at `path` (stub). Returns the same envelope as `sign` /
1481    /// `verify`. Until the runtime-side mutator lands, callers can set
1482    /// `external_evaluator` on the policy JSON before propose /
1483    /// supersede — the field is preserved by serde round-trip.
1484    #[wasm_bindgen(js_name = setExternalEvaluator)]
1485    #[allow(unused_variables)]
1486    pub fn set_external_evaluator(
1487        &self,
1488        ref_name: &str,
1489        path: &str,
1490        config_json: Option<String>,
1491    ) -> Result<String, JsValue> {
1492        let envelope = serde_json::json!({
1493            "error": "not yet wired",
1494            "hint": "set policy.external_evaluator before propose/supersede",
1495        });
1496        serde_json::to_string(&envelope).map_err(js_err)
1497    }
1498
1499    /// List ratified policies whose `triggers` intersect `tokens_json`
1500    /// (a JSON array of strings). Convenience wrapper exposing the
1501    /// filter `evaluate_change` uses internally.
1502    #[wasm_bindgen(js_name = checkTokens)]
1503    pub fn check_tokens(&self, ref_name: &str, tokens_json: &str) -> Result<String, JsValue> {
1504        let tokens: Vec<String> = serde_json::from_str(tokens_json).map_err(js_err)?;
1505        let token_set: std::collections::HashSet<&str> =
1506            tokens.iter().map(|s| s.as_str()).collect();
1507        let actives = self.inner.active(ref_name, None).map_err(js_err)?;
1508        let matched: Vec<Policy> = actives
1509            .into_iter()
1510            .filter(|p| p.triggers.iter().any(|t| token_set.contains(t.as_str())))
1511            .collect();
1512        serde_json::to_string(&matched).map_err(js_err)
1513    }
1514}
1515
1516// ---------------------------------------------------------------------------
1517// Helpers (non-exported)
1518// ---------------------------------------------------------------------------
1519
1520fn js_err<E: std::fmt::Display>(e: E) -> JsValue {
1521    JsValue::from_str(&format!("{}", e))
1522}
1523
1524fn parse_priority(s: &str) -> Priority {
1525    match s.to_lowercase().as_str() {
1526        "low" => Priority::Low,
1527        "high" => Priority::High,
1528        "critical" => Priority::Critical,
1529        _ => Priority::Medium,
1530    }
1531}
1532
1533fn parse_plan_status(s: &str) -> Option<agentstategraph_tasks::PlanStatus> {
1534    match s.to_lowercase().as_str() {
1535        "active" => Some(agentstategraph_tasks::PlanStatus::Active),
1536        "completed" => Some(agentstategraph_tasks::PlanStatus::Completed),
1537        "archived" => Some(agentstategraph_tasks::PlanStatus::Archived),
1538        _ => None,
1539    }
1540}
1541
1542fn parse_session_status(s: &str) -> Option<SessionStatus> {
1543    match s.to_lowercase().as_str() {
1544        "active" => Some(SessionStatus::Active),
1545        "completed" => Some(SessionStatus::Completed),
1546        "abandoned" => Some(SessionStatus::Abandoned),
1547        _ => None,
1548    }
1549}
1550
1551fn parse_proof_kind(s: &str) -> Option<ProofKind> {
1552    match s.to_lowercase().as_str() {
1553        "commit" => Some(ProofKind::Commit),
1554        "file" => Some(ProofKind::File),
1555        "test" => Some(ProofKind::Test),
1556        "text" => Some(ProofKind::Text),
1557        _ => None,
1558    }
1559}
1560
1561fn check_result_json(r: &CheckResult) -> String {
1562    let v = match r {
1563        CheckResult::UpToDate { version } => serde_json::json!({
1564            "status": "up_to_date",
1565            "version": version.to_string(),
1566        }),
1567        CheckResult::UpgradeAvailable {
1568            from,
1569            to,
1570            migrations,
1571        } => serde_json::json!({
1572            "status": "upgrade_available",
1573            "from": from.to_string(),
1574            "to": to.to_string(),
1575            "migrations": migrations,
1576        }),
1577        CheckResult::Downgrade { db, binary } => serde_json::json!({
1578            "status": "downgrade",
1579            "db": db.to_string(),
1580            "binary": binary.to_string(),
1581        }),
1582        CheckResult::Unversioned { implicit } => serde_json::json!({
1583            "status": "unversioned",
1584            "implicit": implicit.to_string(),
1585        }),
1586        CheckResult::Corrupt(msg) => serde_json::json!({
1587            "status": "corrupt",
1588            "message": msg,
1589        }),
1590    };
1591    serde_json::to_string(&v).unwrap_or_default()
1592}
1593
1594fn report_json(r: &agentstategraph_migrate::Report) -> String {
1595    let mode = match r.mode {
1596        RunMode::DryRun => "dry-run",
1597        RunMode::Apply => "apply",
1598    };
1599    let steps: Vec<serde_json::Value> = r
1600        .steps
1601        .iter()
1602        .map(|s| {
1603            serde_json::json!({
1604                "name": s.name,
1605                "describe": s.describe,
1606                "from": s.from.to_string(),
1607                "to": s.to.to_string(),
1608                "status": step_status_str(s.status),
1609                "commit_id": s.commit_id.as_ref().map(|c| c.to_string()),
1610                "notes": s.notes,
1611            })
1612        })
1613        .collect();
1614    let v = serde_json::json!({
1615        "from": r.from.to_string(),
1616        "target": r.target.to_string(),
1617        "final_version": r.final_version.to_string(),
1618        "mode": mode,
1619        "steps": steps,
1620    });
1621    serde_json::to_string(&v).unwrap_or_default()
1622}
1623
1624// ---------------------------------------------------------------------------
1625// Taint parameter parsers (mirrors the FFI taint binding). Accepts the
1626// FFI-shaped payload: `{"name","effect","reason","severity","expires",
1627// "propagate","agent_id","metadata"}` etc. Missing optional fields default
1628// the same way the FFI does.
1629// ---------------------------------------------------------------------------
1630
1631fn parse_taint_effect_wasm(s: &str) -> Option<TaintEffect> {
1632    match s.to_lowercase().as_str() {
1633        "warn" => Some(TaintEffect::Warn),
1634        "block" => Some(TaintEffect::Block),
1635        "review" => Some(TaintEffect::Review),
1636        "isolate" => Some(TaintEffect::Isolate),
1637        "advisory" => Some(TaintEffect::Advisory),
1638        _ => None,
1639    }
1640}
1641
1642fn parse_taint_severity_wasm(s: Option<&str>) -> TaintSeverity {
1643    match s.unwrap_or("medium").to_lowercase().as_str() {
1644        "low" => TaintSeverity::Low,
1645        "high" => TaintSeverity::High,
1646        "critical" => TaintSeverity::Critical,
1647        _ => TaintSeverity::Medium,
1648    }
1649}
1650
1651fn parse_taint_kind_wasm(s: &str) -> Option<TaintKind> {
1652    match s.to_lowercase().as_str() {
1653        "taint" => Some(TaintKind::Taint),
1654        "quarantine" => Some(TaintKind::Quarantine),
1655        "watch" => Some(TaintKind::Watch),
1656        _ => None,
1657    }
1658}
1659
1660fn parse_rfc3339(v: Option<&serde_json::Value>) -> Option<chrono::DateTime<chrono::Utc>> {
1661    v.and_then(|x| x.as_str())
1662        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1663        .map(|d| d.with_timezone(&chrono::Utc))
1664}
1665
1666fn parse_taint_params(json: &str) -> Result<TaintParams, JsValue> {
1667    let v: serde_json::Value =
1668        serde_json::from_str(json).map_err(|e| js_err(format!("invalid params: {e}")))?;
1669    let name = v
1670        .get("name")
1671        .and_then(|x| x.as_str())
1672        .ok_or_else(|| JsValue::from_str("missing 'name'"))?
1673        .to_string();
1674    let effect = v
1675        .get("effect")
1676        .and_then(|x| x.as_str())
1677        .and_then(parse_taint_effect_wasm)
1678        .ok_or_else(|| JsValue::from_str("missing or invalid 'effect'"))?;
1679    let metadata: TaintMetadata = match v.get("metadata") {
1680        Some(m) if !m.is_null() => serde_json::from_value(m.clone()).map_err(js_err)?,
1681        _ => TaintMetadata::new(),
1682    };
1683    Ok(TaintParams {
1684        name,
1685        effect,
1686        reason: v
1687            .get("reason")
1688            .and_then(|x| x.as_str())
1689            .unwrap_or("")
1690            .to_string(),
1691        severity: parse_taint_severity_wasm(v.get("severity").and_then(|x| x.as_str())),
1692        expires_at: parse_rfc3339(v.get("expires")),
1693        propagate: v.get("propagate").and_then(|x| x.as_bool()).unwrap_or(true),
1694        metadata,
1695        agent_id: v
1696            .get("agent_id")
1697            .and_then(|x| x.as_str())
1698            .unwrap_or("")
1699            .to_string(),
1700    })
1701}
1702
1703fn parse_untaint_params(json: &str) -> Result<UntaintParams, JsValue> {
1704    let v: serde_json::Value =
1705        serde_json::from_str(json).map_err(|e| js_err(format!("invalid params: {e}")))?;
1706    Ok(UntaintParams {
1707        reason: v
1708            .get("reason")
1709            .and_then(|x| x.as_str())
1710            .unwrap_or("")
1711            .to_string(),
1712        proof: v.get("proof").and_then(|x| x.as_str()).map(str::to_string),
1713        agent_id: v
1714            .get("agent_id")
1715            .and_then(|x| x.as_str())
1716            .unwrap_or("")
1717            .to_string(),
1718    })
1719}
1720
1721fn parse_quarantine_params(json: &str) -> Result<QuarantineParams, JsValue> {
1722    let v: serde_json::Value =
1723        serde_json::from_str(json).map_err(|e| js_err(format!("invalid params: {e}")))?;
1724    let name = v
1725        .get("name")
1726        .and_then(|x| x.as_str())
1727        .ok_or_else(|| JsValue::from_str("missing 'name'"))?
1728        .to_string();
1729    let authorized: Vec<String> = v
1730        .get("authorized_agents")
1731        .and_then(|x| x.as_array())
1732        .map(|arr| {
1733            arr.iter()
1734                .filter_map(|x| x.as_str().map(str::to_string))
1735                .collect()
1736        })
1737        .unwrap_or_default();
1738    Ok(QuarantineParams {
1739        name,
1740        reason: v
1741            .get("reason")
1742            .and_then(|x| x.as_str())
1743            .unwrap_or("")
1744            .to_string(),
1745        severity: parse_taint_severity_wasm(v.get("severity").and_then(|x| x.as_str())),
1746        authorized_agents: authorized,
1747        expires_at: parse_rfc3339(v.get("expires")),
1748        propagate: v.get("propagate").and_then(|x| x.as_bool()).unwrap_or(true),
1749        agent_id: v
1750            .get("agent_id")
1751            .and_then(|x| x.as_str())
1752            .unwrap_or("")
1753            .to_string(),
1754    })
1755}
1756
1757fn parse_watch_params(json: &str) -> Result<WatchParams, JsValue> {
1758    let v: serde_json::Value =
1759        serde_json::from_str(json).map_err(|e| js_err(format!("invalid params: {e}")))?;
1760    let name = v
1761        .get("name")
1762        .and_then(|x| x.as_str())
1763        .ok_or_else(|| JsValue::from_str("missing 'name'"))?
1764        .to_string();
1765    let direction = match v
1766        .get("direction")
1767        .and_then(|x| x.as_str())
1768        .unwrap_or("above")
1769    {
1770        "below" => WatchDirection::Below,
1771        _ => WatchDirection::Above,
1772    };
1773    Ok(WatchParams {
1774        name,
1775        reason: v
1776            .get("reason")
1777            .and_then(|x| x.as_str())
1778            .unwrap_or("")
1779            .to_string(),
1780        metric: v.get("metric").and_then(|x| x.as_str()).map(str::to_string),
1781        threshold: v.get("threshold").and_then(|x| x.as_f64()),
1782        direction,
1783        check_interval_secs: v.get("check_interval_secs").and_then(|x| x.as_u64()),
1784        expires_at: parse_rfc3339(v.get("expires")),
1785        severity: parse_taint_severity_wasm(v.get("severity").and_then(|x| x.as_str())),
1786        propagate: v.get("propagate").and_then(|x| x.as_bool()).unwrap_or(true),
1787        agent_id: v
1788            .get("agent_id")
1789            .and_then(|x| x.as_str())
1790            .unwrap_or("")
1791            .to_string(),
1792    })
1793}
1794
1795fn parse_unwatch_params(json: &str) -> Result<UnwatchParams, JsValue> {
1796    let v: serde_json::Value =
1797        serde_json::from_str(json).map_err(|e| js_err(format!("invalid params: {e}")))?;
1798    Ok(UnwatchParams {
1799        reason: v.get("reason").and_then(|x| x.as_str()).map(str::to_string),
1800        agent_id: v
1801            .get("agent_id")
1802            .and_then(|x| x.as_str())
1803            .unwrap_or("")
1804            .to_string(),
1805    })
1806}
1807
1808fn step_status_str(s: StepStatus) -> &'static str {
1809    match s {
1810        StepStatus::WouldApply => "would-apply",
1811        StepStatus::WouldSkip => "would-skip",
1812        StepStatus::Applied => "applied",
1813        StepStatus::Skipped => "skipped",
1814        StepStatus::Failed => "failed",
1815    }
1816}