Files
pbakaus_impeccable/crates/live/src/journal.rs
T
Paul BakausandClaude Fable 5.1 0547ed6a63 reorg C: the open Rust runtime joins this repo as one Cargo workspace
The engine no longer lives in a separate repo. `crates/` is a snapshot of the
open crates (foundation, core, common, context, live, hook, skills, comp,
comp-verbs, html, browser, detect, cli) plus `Cargo.lock`, taken as a git
archive of the engine repo at the commit that finished the boundary split.
None of that repo's history comes with it, and none of it should: the closed
half stays private.

The closed half is the rule engine. It ships as a prebuilt native archive per
target, `libimpeccable_detector.a`, published as a `detector-v<X>` GitHub
Release on this repo. `crates/core/build.rs` resolves and links it three ways:
`IMPECCABLE_DETECTOR_LIB=<dir>` for a local detector build, else the
`~/.impeccable/detector/<version>/<target>/` cache, else a download verified
against its `.sha256` sidecar. `crates/core` is a thin shim over a three-symbol
C ABI; nothing above it knows the boundary exists.

What changed versus the engine repo copy:

- Every crate manifest moves from `license-file.workspace` to
  `license.workspace` (this workspace declares Apache-2.0), and the workspace
  gains the `postcard` dependency the boundary encoding needs.
- The launcher contract test reads `skill/scripts/impeccable{,.cmd}` instead of
  a sibling `launcher/` dir, and `engine_binary` downloads from
  `github.com/pbakaus/impeccable/releases/download/engine-v<version>/` instead
  of the retired dist repo. No oracle golden carried the old URL, so no
  re-recording was owed.
- The tests that hunted for a public repo through `IMPECCABLE_PUBLIC_REPO`,
  `../impeccable-second` or a hardcoded home directory now resolve the root as
  `CARGO_MANIFEST_DIR/../..`, because they are in it. The env var stays as an
  override for an out-of-tree checkout.
- The in-page bundle (`detect-antipatterns-browser.js`, 2 MB of generated wasm
  glue) is no longer tracked. `crates/core/build.rs` resolves it beside the
  archive, hands the path to `impeccable_core::browser::IN_PAGE_BUNDLE_JS`, and
  live mode serves that. `scripts/check-detector-release.mjs` now requires it
  and its `.sha256` in a detector release.
- The live crate embeds `skill/scripts/live-browser*.js` and
  `modern-screenshot.umd.js` directly rather than through vendored copies, so
  the binary and the installed skill cannot drift.
- `crates/browser/assets/` (an unused second copy of the bundle) is gone.
- `tests/lib/engine-bin.mjs` also accepts `target/release/impeccable`, so a
  plain `cargo build --release -p impeccable` is enough to run `bun run test`.

Verified with the archive from a local detector build: `cargo test --workspace`
267 pass, oracle 795 pass / 0 fail / 0 missing, `bun run build` clean, the
default suite green, and the launcher's `engine-probe` handshake answering
through `skill/scripts/impeccable`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
2026-09-01 15:31:26 -07:00

210 lines
6.5 KiB
Rust

//! JS: live/frameworks/journal.mjs. Crash-safe injection journal at
//! `.impeccable/live/inject-journal.json`: what inject wrote, so a later
//! inject or `--remove` can heal orphans left by a session that never
//! stopped.
use crate::inject::undo_patch;
use crate::util::{
dir_entry_count, inside_project, iso_now, json_pretty, jsp, read_json, safe_read, write_file,
};
use serde_json::{json, Map, Value};
pub const INJECT_JOURNAL_VERSION: i64 = 1;
pub const INJECT_JOURNAL_RELPATH: &str = ".impeccable/live/inject-journal.json";
/// JS: injectJournalPath(cwd)
pub fn inject_journal_path(cwd: &str) -> String {
jsp::join(&[cwd, ".impeccable", "live", "inject-journal.json"])
}
/// JS: readInjectJournal(cwd): the parsed journal when it is an object with
/// an `artifacts` array.
pub fn read_inject_journal(cwd: &str) -> Option<Map<String, Value>> {
let raw = read_json(&inject_journal_path(cwd))?;
let obj = raw.as_object()?;
if !obj.get("artifacts").map(|a| a.is_array()).unwrap_or(false) {
return None;
}
Some(obj.clone())
}
/// JS: clearInjectJournal(cwd)
pub fn clear_inject_journal(cwd: &str) {
let _ = std::fs::remove_file(inject_journal_path(cwd));
}
fn write_inject_journal(cwd: &str, journal: &Value) -> String {
let file = inject_journal_path(cwd);
let _ = write_file(&file, &format!("{}\n", json_pretty(journal)));
file
}
/// JS: recordInjection(cwd, { framework, port, artifacts })
pub fn record_injection(
cwd: &str,
framework: Option<&str>,
port: Option<i64>,
artifacts: &[Value],
pid: u32,
) -> Option<String> {
if artifacts.is_empty() {
clear_inject_journal(cwd);
return None;
}
let journal = json!({
"version": INJECT_JOURNAL_VERSION,
"appRoot": jsp::resolve(cwd, &[]),
"framework": framework,
"port": port,
"pid": pid,
"recordedAt": iso_now(),
"artifacts": artifacts,
});
Some(write_inject_journal(cwd, &journal))
}
fn normalize_rel(cwd: &str, rel: &str) -> String {
jsp::to_posix(&jsp::resolve(cwd, &[rel]))
}
/// JS: pruneEmptyDirs(dir, stopDir) (journal flavour: `startsWith(stop + '/')`).
fn prune_empty_dirs(dir: &str, stop_dir: &str) {
let mut current = jsp::resolve(dir, &[]);
let stop = jsp::resolve(stop_dir, &[]);
while current != stop && current.starts_with(&format!("{}{}", stop, jsp::SEP)) {
match dir_entry_count(&current) {
Some(0) => {
if std::fs::remove_dir(&current).is_err() {
return;
}
}
_ => return,
}
current = jsp::dirname(&current);
}
}
/// One healed-artifact outcome `{ path, action }`.
#[derive(Debug, Clone)]
pub struct HealOutcome {
pub path: String,
pub action: &'static str,
}
fn heal_artifact(cwd: &str, artifact: &Value) -> Option<HealOutcome> {
let path = artifact.get("path")?.as_str()?.to_string();
let abs = jsp::resolve(cwd, &[&path]);
if !inside_project(cwd, &abs) {
return Some(HealOutcome {
path,
action: "refused_outside_project",
});
}
let content = safe_read(&abs)?;
let kind = artifact.get("kind").and_then(|k| k.as_str()).unwrap_or("");
if kind == "created" {
let marker = artifact
.get("marker")
.and_then(|m| m.as_str())
.unwrap_or("");
if marker.is_empty() || !content.contains(marker) {
return Some(HealOutcome {
path,
action: "disowned",
});
}
if std::fs::remove_file(&abs).is_err() {
return None;
}
if let Some(prune_to) = artifact.get("pruneTo") {
let prune_str = match prune_to {
Value::String(s) if !s.is_empty() => s.as_str(),
_ => ".",
};
let prune_root = jsp::resolve(cwd, &[prune_str]);
if inside_project(cwd, &prune_root) || prune_root == jsp::resolve(cwd, &[]) {
prune_empty_dirs(&jsp::dirname(&abs), &prune_root);
}
}
return Some(HealOutcome {
path,
action: "removed",
});
}
if kind == "patched" {
let markers: Vec<&str> = artifact
.get("markers")
.and_then(|m| m.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
if !markers.is_empty() && !markers.iter().any(|m| content.contains(m)) {
return Some(HealOutcome {
path,
action: "disowned",
});
}
let patch = artifact.get("patch").and_then(|p| p.as_str()).unwrap_or("");
let next = undo_patch(patch, &content)?;
if next == content {
return Some(HealOutcome {
path,
action: "disowned",
});
}
if write_file(&abs, &next).is_err() {
return None;
}
return Some(HealOutcome {
path,
action: "unpatched",
});
}
None
}
/// JS: healInjectJournal(cwd, { keep }) -> { healed, kept }
pub fn heal_inject_journal(cwd: &str, keep: &[String]) -> (Vec<HealOutcome>, Vec<Value>) {
let Some(journal) = read_inject_journal(cwd) else {
return (vec![], vec![]);
};
let keep_set: Vec<String> = keep.iter().map(|k| normalize_rel(cwd, k)).collect();
let mut healed = Vec::new();
let mut kept: Vec<Value> = Vec::new();
for artifact in journal
.get("artifacts")
.and_then(|a| a.as_array())
.cloned()
.unwrap_or_default()
{
let Some(path) = artifact.get("path").and_then(|p| p.as_str()) else {
continue;
};
if keep_set.contains(&normalize_rel(cwd, path)) {
kept.push(artifact.clone());
continue;
}
if let Some(outcome) = heal_artifact(cwd, &artifact) {
if outcome.action == "removed" || outcome.action == "unpatched" {
healed.push(outcome);
}
}
}
if !kept.is_empty() {
let mut next = journal.clone();
next.insert("artifacts".to_string(), Value::Array(kept.clone()));
write_inject_journal(cwd, &Value::Object(next));
} else {
clear_inject_journal(cwd);
}
(healed, kept)
}
pub fn healed_to_value(healed: &[HealOutcome]) -> Value {
Value::Array(
healed
.iter()
.map(|h| json!({ "path": h.path, "action": h.action }))
.collect(),
)
}