54 KiB
Archivr Web UI Foundation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Refactor Archivr into a workspace with reusable core archive logic, rename taxonomy concepts to hierarchical tags, and add the first multi-archive web server/UI foundation.
Architecture: Split the current single binary into archivr-core, archivr-cli, and archivr-server. Keep every mounted archive self-contained with its own .archivr/archivr.sqlite, and give the server a separate registry config for mounting many archives. The first UI renders the approved dense archive table, a contextual right rail, a runs view, and a secondary capture button.
Tech Stack: Rust 2024 workspace, rusqlite for archive DBs, axum/tokio for HTTP, tower-http for static assets, vanilla HTML/CSS/JS for the first web UI, serde/toml for server registry config.
Scope And Sequencing
This plan intentionally separates the foundational refactor from the first web UI slice:
- Create a workspace while keeping CLI behavior intact.
- Extract reusable archive logic into
archivr-core. - Rename taxonomy schema/API/tests to hierarchical tags.
- Add archive query APIs needed by the server.
- Add
archivr-serverwith a multi-archive registry. - Add server API routes.
- Add the first static web UI.
- Verify with unit tests, API smoke tests, and browser inspection.
Do not implement full OP search, production auth/session handling, browser capture flow, public publishing UI, full tag management UI, or final row-click/open behavior in this plan.
File Structure
Create this workspace structure:
Cargo.toml
Cargo.lock
crates/
archivr-core/
Cargo.toml
src/
lib.rs
archive.rs
database.rs
hash.rs
twitter.rs
downloader/
mod.rs
local.rs
store.rs
tweets.rs
ytdlp.rs
archivr-cli/
Cargo.toml
src/
main.rs
archivr-server/
Cargo.toml
src/
main.rs
registry.rs
routes.rs
static/
index.html
styles.css
app.js
docs/
superpowers/
specs/
2026-06-01-web-ui-design.md
plans/
2026-06-01-web-ui-foundation.md
Responsibilities:
archivr-core/src/archive.rs: archive directory discovery/opening, archive metadata, store path reading, entry/run query APIs.archivr-core/src/database.rs: SQLite schema and domain operations, including hierarchical tags.archivr-core/src/downloader/*: existing archive download/store behavior.archivr-cli/src/main.rs: command-line parsing and terminal behavior only.archivr-server/src/registry.rs: server-owned mounted archive registry config.archivr-server/src/routes.rs: API and static route construction.archivr-server/static/*: first browser UI.
Task 1: Convert To A Workspace With CLI Crate
Files:
-
Modify:
Cargo.toml -
Create:
crates/archivr-cli/Cargo.toml -
Move:
src/main.rs->crates/archivr-cli/src/main.rs -
Move:
src/database.rs->crates/archivr-cli/src/database.rs -
Move:
src/hash.rs->crates/archivr-cli/src/hash.rs -
Move:
src/twitter.rs->crates/archivr-cli/src/twitter.rs -
Move:
src/downloader/->crates/archivr-cli/src/downloader/ -
Step 1: Record the current baseline
Run:
cargo test
Expected: all current tests pass.
- Step 2: Replace root manifest with a workspace manifest
Replace Cargo.toml with:
[workspace]
members = [
"crates/archivr-cli",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2024"
[workspace.dependencies]
anyhow = "1.0.100"
chrono = "0.4.42"
clap = { version = "4.5.48", features = ["derive"] }
hex = "0.4.3"
regex = "1.12.2"
rusqlite = { version = "0.32.1", features = ["bundled"] }
serde_json = "1.0.132"
sha3 = "0.10.8"
uuid = { version = "1.18.1", features = ["v4"] }
- Step 3: Create the CLI crate manifest
Create crates/archivr-cli/Cargo.toml:
[package]
name = "archivr-cli"
version.workspace = true
edition.workspace = true
[[bin]]
name = "archivr"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
hex.workspace = true
regex.workspace = true
rusqlite.workspace = true
serde_json.workspace = true
sha3.workspace = true
uuid.workspace = true
- Step 4: Move current source files into the CLI crate
Run:
mkdir -p crates/archivr-cli/src
git mv src/main.rs crates/archivr-cli/src/main.rs
git mv src/database.rs crates/archivr-cli/src/database.rs
git mv src/hash.rs crates/archivr-cli/src/hash.rs
git mv src/twitter.rs crates/archivr-cli/src/twitter.rs
git mv src/downloader crates/archivr-cli/src/downloader
- Step 5: Verify the CLI crate still builds and tests
Run:
cargo test -p archivr-cli
Expected: all moved tests pass.
- Step 6: Commit the workspace conversion
git add Cargo.toml Cargo.lock crates/archivr-cli
git commit -m "chore: move cli into workspace crate"
Task 2: Extract Core Crate
Files:
-
Modify:
Cargo.toml -
Create:
crates/archivr-core/Cargo.toml -
Create:
crates/archivr-core/src/lib.rs -
Move:
crates/archivr-cli/src/database.rs->crates/archivr-core/src/database.rs -
Move:
crates/archivr-cli/src/hash.rs->crates/archivr-core/src/hash.rs -
Move:
crates/archivr-cli/src/twitter.rs->crates/archivr-core/src/twitter.rs -
Move:
crates/archivr-cli/src/downloader/->crates/archivr-core/src/downloader/ -
Modify:
crates/archivr-cli/src/main.rs -
Step 1: Add
archivr-coreto the workspace
Update root Cargo.toml:
[workspace]
members = [
"crates/archivr-core",
"crates/archivr-cli",
]
resolver = "2"
Keep the existing [workspace.package] and [workspace.dependencies] sections unchanged.
- Step 2: Create the core crate manifest
Create crates/archivr-core/Cargo.toml:
[package]
name = "archivr-core"
version.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
hex.workspace = true
regex.workspace = true
rusqlite.workspace = true
serde_json.workspace = true
sha3.workspace = true
uuid.workspace = true
- Step 3: Add the core crate as a CLI dependency
Modify crates/archivr-cli/Cargo.toml:
[dependencies]
archivr-core = { path = "../archivr-core" }
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
regex.workspace = true
rusqlite.workspace = true
serde_json.workspace = true
Remove hex, sha3, and uuid from archivr-cli dependencies after moving the modules that use them into core.
- Step 4: Move reusable modules into core
Run:
mkdir -p crates/archivr-core/src
git mv crates/archivr-cli/src/database.rs crates/archivr-core/src/database.rs
git mv crates/archivr-cli/src/hash.rs crates/archivr-core/src/hash.rs
git mv crates/archivr-cli/src/twitter.rs crates/archivr-core/src/twitter.rs
git mv crates/archivr-cli/src/downloader crates/archivr-core/src/downloader
- Step 5: Create
archivr-coremodule exports
Create crates/archivr-core/src/lib.rs:
pub mod database;
pub mod downloader;
pub mod hash;
pub mod twitter;
- Step 6: Update core module imports
In crates/archivr-core/src/downloader/tweets.rs, replace:
use crate::twitter::parse_tweet_id;
use super::store;
with:
use crate::{downloader::store, twitter::parse_tweet_id};
Confirm crates/archivr-core/src/downloader/local.rs, store.rs, and ytdlp.rs still refer to crate::hash::hash_file.
- Step 7: Update CLI imports
In crates/archivr-cli/src/main.rs, remove:
mod database;
mod downloader;
mod hash;
mod twitter;
Add:
use archivr_core::{
database, downloader,
twitter::parse_tweet_id,
};
Remove the old line:
use crate::twitter::parse_tweet_id;
- Step 8: Verify core and CLI tests
Run:
cargo test -p archivr-core
cargo test -p archivr-cli
Expected: core database/downloader/hash/twitter tests pass under archivr-core; CLI source-detection and initialization tests pass under archivr-cli.
- Step 9: Commit the core extraction
git add Cargo.toml Cargo.lock crates/archivr-core crates/archivr-cli
git commit -m "refactor: extract archive core crate"
Task 3: Add Archive Opening APIs To Core
Files:
-
Create:
crates/archivr-core/src/archive.rs -
Modify:
crates/archivr-core/src/lib.rs -
Modify:
crates/archivr-cli/src/main.rs -
Step 1: Write core archive API tests
Create crates/archivr-core/src/archive.rs with tests first:
use anyhow::{Context, Result, bail};
use std::{
env, fs,
path::{Path, PathBuf},
};
use crate::database;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchivePaths {
pub archive_path: PathBuf,
pub store_path: PathBuf,
pub name: String,
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_path(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
env::temp_dir().join(format!("{prefix}-{nanos}-{}", std::process::id()))
}
#[test]
fn find_archive_path_walks_up_to_dot_archivr() {
let root = unique_path("archivr-core-find");
let nested = root.join("a").join("b");
fs::create_dir_all(root.join(".archivr")).unwrap();
fs::create_dir_all(&nested).unwrap();
let found = find_archive_path_from(&nested).unwrap().unwrap();
assert_eq!(found, root.join(".archivr"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn read_archive_paths_returns_name_and_store_path() {
let root = unique_path("archivr-core-open");
let archive_path = root.join(".archivr");
let store_path = root.join("store");
fs::create_dir_all(&archive_path).unwrap();
fs::create_dir_all(&store_path).unwrap();
fs::write(archive_path.join("name"), "Personal").unwrap();
fs::write(archive_path.join("store_path"), store_path.display().to_string()).unwrap();
let paths = read_archive_paths(&archive_path).unwrap();
assert_eq!(paths.archive_path, archive_path);
assert_eq!(paths.store_path, store_path);
assert_eq!(paths.name, "Personal");
let _ = fs::remove_dir_all(root);
}
#[test]
fn initialize_archive_creates_database_store_and_metadata() {
let root = unique_path("archivr-core-init");
let archive_parent = root.join("archive");
let store_path = root.join("store");
let paths = initialize_archive(&archive_parent, &store_path, "Personal", false).unwrap();
assert_eq!(paths.archive_path, archive_parent.join(".archivr"));
assert!(paths.archive_path.join(database::DATABASE_FILE_NAME).is_file());
assert!(paths.store_path.join("raw").is_dir());
assert!(paths.store_path.join("raw_tweets").is_dir());
assert!(paths.store_path.join("structured").is_dir());
assert!(paths.store_path.join("temp").is_dir());
let _ = fs::remove_dir_all(root);
}
}
- Step 2: Run tests to verify they fail
Run:
cargo test -p archivr-core archive::
Expected: FAIL because find_archive_path_from, read_archive_paths, and initialize_archive are not defined.
- Step 3: Implement archive APIs
Add this implementation above the test module in crates/archivr-core/src/archive.rs:
pub fn find_archive_path_from(start: &Path) -> Result<Option<PathBuf>> {
let mut dir = start.to_path_buf();
loop {
let candidate = dir.join(".archivr");
if candidate.is_dir() {
return Ok(Some(candidate));
}
if !dir.pop() {
return Ok(None);
}
}
}
pub fn find_archive_path() -> Result<Option<PathBuf>> {
let cwd = env::current_dir().context("failed to read current working directory")?;
find_archive_path_from(&cwd)
}
pub fn read_archive_paths(archive_path: &Path) -> Result<ArchivePaths> {
if !archive_path.is_dir() {
bail!("archive path does not exist: {}", archive_path.display());
}
let name = fs::read_to_string(archive_path.join("name"))
.with_context(|| format!("failed to read archive name in {}", archive_path.display()))?
.trim()
.to_string();
let store_path = fs::read_to_string(archive_path.join("store_path"))
.with_context(|| format!("failed to read store path in {}", archive_path.display()))?;
Ok(ArchivePaths {
archive_path: archive_path.to_path_buf(),
store_path: PathBuf::from(store_path.trim()),
name,
})
}
pub fn initialize_archive(
archive_parent: &Path,
store_path: &Path,
archive_name: &str,
force_with_info_removal: bool,
) -> Result<ArchivePaths> {
let archive_path = archive_parent.join(".archivr");
if archive_path.exists() {
if !archive_path.is_dir() {
bail!("Archive path exists and is not a directory: {}", archive_path.display());
}
if force_with_info_removal {
fs::remove_dir_all(&archive_path)?;
} else if fs::read_dir(&archive_path)?.next().is_some() {
bail!(
"Archive already exists at {} and is not empty. Use --force-with-info-removal to reinitialize.",
archive_path.display()
);
}
}
if store_path.exists() && !force_with_info_removal {
bail!("Store path already exists at {}", store_path.display());
}
fs::create_dir_all(&archive_path)?;
fs::create_dir_all(store_path)?;
fs::write(archive_path.join("name"), archive_name)?;
let canonical_store_path = store_path
.canonicalize()
.with_context(|| format!("failed to canonicalize {}", store_path.display()))?;
fs::write(
archive_path.join("store_path"),
canonical_store_path
.to_str()
.context("store path is not valid UTF-8")?,
)?;
initialize_store_directories(&canonical_store_path)?;
let conn = database::open_or_initialize(&archive_path)?;
let _ = database::ensure_default_user(&conn)?;
Ok(ArchivePaths {
archive_path,
store_path: canonical_store_path,
name: archive_name.to_string(),
})
}
pub fn initialize_store_directories(store_path: &Path) -> Result<()> {
fs::create_dir_all(store_path.join("raw"))?;
fs::create_dir_all(store_path.join("raw_tweets"))?;
fs::create_dir_all(store_path.join("structured"))?;
fs::create_dir_all(store_path.join("temp"))?;
Ok(())
}
- Step 4: Export the archive module
Modify crates/archivr-core/src/lib.rs:
pub mod archive;
pub mod database;
pub mod downloader;
pub mod hash;
pub mod twitter;
- Step 5: Update CLI to call core archive APIs
In crates/archivr-cli/src/main.rs, add archive to the import:
use archivr_core::{
archive, database, downloader,
twitter::parse_tweet_id,
};
Replace the local get_archive_path function with calls to archive::find_archive_path().
Replace the local initialize_store_directories function with archive::initialize_store_directories or remove it if only archive::initialize_archive is used.
In the Init command branch, replace the manual .archivr creation block with:
let archive_parent = Path::new(&archive_path_string);
let store_path = if Path::new(&store_path_string).is_relative() {
env::current_dir()
.context("failed to read current working directory")?
.join(store_path_string)
} else {
Path::new(store_path_string).to_path_buf()
};
let paths = archive::initialize_archive(
archive_parent,
&store_path,
archive_name,
force_with_info_removal,
)?;
println!("Initialized empty archive in {}", paths.archive_path.display());
- Step 6: Verify tests
Run:
cargo test -p archivr-core archive::
cargo test -p archivr-cli
Expected: all tests pass.
- Step 7: Commit archive API extraction
git add crates/archivr-core crates/archivr-cli
git commit -m "refactor: add core archive opening APIs"
Task 4: Rename Taxonomy To Hierarchical Tags
Files:
-
Modify:
crates/archivr-core/src/database.rs -
Step 1: Rename test names first
In crates/archivr-core/src/database.rs, rename:
fn taxonomy_assignments_are_discoverable_through_ancestors()
to:
fn hierarchical_tag_assignments_are_discoverable_through_ancestors()
Update the test body to call the new API names:
let tag_id = create_tag_path(&conn, "/sciences/computer-science/compilers").unwrap();
assign_entry_to_tag(&conn, entry.id, tag_id).unwrap();
assert_eq!(
entry_count_for_tag_path(&conn, "/sciences/computer-science/compilers").unwrap(),
1
);
assert_eq!(
entry_count_for_tag_path(&conn, "/sciences/computer-science").unwrap(),
1
);
assert_eq!(entry_count_for_tag_path(&conn, "/sciences").unwrap(), 1);
- Step 2: Run test to verify it fails
Run:
cargo test -p archivr-core hierarchical_tag_assignments_are_discoverable_through_ancestors
Expected: FAIL because tag function names do not exist yet.
- Step 3: Rename schema tables and indexes
In initialize_schema, replace the taxonomy table block with:
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY,
tag_uid TEXT NOT NULL UNIQUE,
parent_tag_id INTEGER REFERENCES tags(id),
name TEXT NOT NULL,
slug TEXT NOT NULL,
full_path TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS entry_tag_assignments (
entry_id INTEGER NOT NULL REFERENCES archived_entries(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (entry_id, tag_id)
);
Replace:
CREATE INDEX IF NOT EXISTS idx_taxonomy_nodes_parent_id ON taxonomy_nodes(parent_id);
with:
CREATE INDEX IF NOT EXISTS idx_tags_parent_tag_id ON tags(parent_tag_id);
- Step 4: Rename helper functions
Replace create_taxonomy_path with:
#[cfg(test)]
pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result<i64> {
let segments = normalized_tag_segments(full_path)?;
let mut parent_tag_id = None;
let mut current_path = String::new();
let mut current_id = 0;
for segment in segments {
current_path.push('/');
current_path.push_str(segment);
if let Some(id) = conn
.query_row(
"SELECT id FROM tags WHERE full_path = ?1",
[current_path.as_str()],
|row| row.get(0),
)
.optional()?
{
current_id = id;
parent_tag_id = Some(id);
continue;
}
conn.execute(
"INSERT INTO tags (tag_uid, parent_tag_id, name, slug, full_path)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
public_id("tag"),
parent_tag_id,
humanize_slug(segment),
segment,
current_path
],
)?;
current_id = conn.last_insert_rowid();
parent_tag_id = Some(current_id);
}
Ok(current_id)
}
Replace assign_entry_to_taxonomy with:
#[cfg(test)]
pub fn assign_entry_to_tag(conn: &Connection, entry_id: i64, tag_id: i64) -> Result<()> {
conn.execute(
"INSERT OR IGNORE INTO entry_tag_assignments (entry_id, tag_id)
VALUES (?1, ?2)",
params![entry_id, tag_id],
)?;
Ok(())
}
Replace entry_count_for_taxonomy_path with:
#[cfg(test)]
pub fn entry_count_for_tag_path(conn: &Connection, full_path: &str) -> Result<i64> {
let count = conn.query_row(
"WITH RECURSIVE descendants(id) AS (
SELECT id FROM tags WHERE full_path = ?1
UNION ALL
SELECT child.id
FROM tags child
JOIN descendants parent ON child.parent_tag_id = parent.id
)
SELECT COUNT(DISTINCT eta.entry_id)
FROM entry_tag_assignments eta
JOIN descendants d ON eta.tag_id = d.id",
[full_path],
|row| row.get(0),
)?;
Ok(count)
}
Rename normalized_taxonomy_segments to normalized_tag_segments.
- Step 5: Verify there are no taxonomy identifiers left
Run:
rg "taxonomy|Taxonomy|category|Category" crates/archivr-core/src/database.rs
Expected: no output.
- Step 6: Run core tests
Run:
cargo test -p archivr-core
Expected: all core tests pass.
- Step 7: Commit the tag rename
git add crates/archivr-core/src/database.rs
git commit -m "refactor: rename taxonomy model to tags"
Task 5: Add Core Entry And Run Query APIs
Files:
-
Modify:
crates/archivr-core/src/archive.rs -
Modify:
crates/archivr-core/src/database.rs -
Step 1: Add query result types
In crates/archivr-core/src/archive.rs, add:
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EntrySummary {
pub entry_uid: String,
pub archived_at: String,
pub source_kind: String,
pub entity_kind: String,
pub title: Option<String>,
pub visibility: String,
pub original_url: Option<String>,
pub artifact_count: i64,
pub total_artifact_bytes: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EntryDetail {
pub summary: EntrySummary,
pub structured_root_relpath: String,
pub source_metadata_json: String,
pub display_metadata_json: Option<String>,
pub artifacts: Vec<EntryArtifactSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EntryArtifactSummary {
pub artifact_role: String,
pub storage_area: String,
pub relpath: String,
pub byte_size: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunSummary {
pub run_uid: String,
pub started_at: String,
pub finished_at: Option<String>,
pub status: String,
pub requested_count: i64,
pub discovered_count: i64,
pub completed_count: i64,
pub failed_count: i64,
pub error_summary: Option<String>,
}
- Step 2: Add failing query tests
In crates/archivr-core/src/archive.rs, add tests that initialize an in-memory DB, create a user/run/source/entry/artifact with existing database helpers, then call:
let entries = list_root_entries(&conn).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].title.as_deref(), Some("Saved Article"));
assert_eq!(entries[0].artifact_count, 1);
let detail = get_entry_detail(&conn, &entries[0].entry_uid).unwrap().unwrap();
assert_eq!(detail.artifacts.len(), 1);
assert_eq!(detail.artifacts[0].artifact_role, "primary_media");
let runs = list_runs(&conn).unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].status, "completed");
Use existing helpers database::ensure_default_user, create_archive_run, upsert_source_identity, create_archived_entry, add_entry_artifact, complete_archive_run_item, and finish_archive_run.
- Step 3: Run tests to verify they fail
Run:
cargo test -p archivr-core archive::tests::list_root_entries
Expected: FAIL because query functions are not defined.
- Step 4: Implement
list_root_entries
Add:
pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary>> {
let mut stmt = conn.prepare(
"SELECT
e.entry_uid,
e.archived_at,
e.source_kind,
e.entity_kind,
e.title,
e.visibility,
si.canonical_url,
COUNT(ea.id) AS artifact_count,
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes
FROM archived_entries e
JOIN source_identities si ON si.id = e.source_identity_id
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
LEFT JOIN blobs b ON b.id = ea.blob_id
WHERE e.parent_entry_id IS NULL
GROUP BY e.id
ORDER BY e.archived_at DESC, e.id DESC",
)?;
let entries = stmt
.query_map([], |row| {
Ok(EntrySummary {
entry_uid: row.get(0)?,
archived_at: row.get(1)?,
source_kind: row.get(2)?,
entity_kind: row.get(3)?,
title: row.get(4)?,
visibility: row.get(5)?,
original_url: row.get(6)?,
artifact_count: row.get(7)?,
total_artifact_bytes: row.get(8)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
- Step 5: Implement
get_entry_detail
Add:
pub fn get_entry_detail(
conn: &rusqlite::Connection,
entry_uid: &str,
) -> Result<Option<EntryDetail>> {
let Some((entry_id, structured_root_relpath, source_metadata_json, display_metadata_json)) =
conn.query_row(
"SELECT id, structured_root_relpath, source_metadata_json, display_metadata_json
FROM archived_entries
WHERE entry_uid = ?1",
[entry_uid],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
))
},
)
.optional()?
else {
return Ok(None);
};
let summary = list_root_entries(conn)?
.into_iter()
.find(|entry| entry.entry_uid == entry_uid)
.context("entry disappeared while loading detail")?;
let mut stmt = conn.prepare(
"SELECT ea.artifact_role, ea.storage_area, ea.relpath, b.byte_size
FROM entry_artifacts ea
LEFT JOIN blobs b ON b.id = ea.blob_id
WHERE ea.entry_id = ?1
ORDER BY ea.id ASC",
)?;
let artifacts = stmt
.query_map([entry_id], |row| {
Ok(EntryArtifactSummary {
artifact_role: row.get(0)?,
storage_area: row.get(1)?,
relpath: row.get(2)?,
byte_size: row.get(3)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(Some(EntryDetail {
summary,
structured_root_relpath,
source_metadata_json,
display_metadata_json,
artifacts,
}))
}
- Step 6: Implement
list_runs
Add:
pub fn list_runs(conn: &rusqlite::Connection) -> Result<Vec<RunSummary>> {
let mut stmt = conn.prepare(
"SELECT run_uid, started_at, finished_at, status, requested_count,
discovered_count, completed_count, failed_count, error_summary
FROM archive_runs
ORDER BY started_at DESC, id DESC",
)?;
let runs = stmt
.query_map([], |row| {
Ok(RunSummary {
run_uid: row.get(0)?,
started_at: row.get(1)?,
finished_at: row.get(2)?,
status: row.get(3)?,
requested_count: row.get(4)?,
discovered_count: row.get(5)?,
completed_count: row.get(6)?,
failed_count: row.get(7)?,
error_summary: row.get(8)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(runs)
}
- Step 7: Verify query tests
Run:
cargo test -p archivr-core archive::
Expected: all archive query tests pass.
- Step 8: Commit query APIs
git add crates/archivr-core/src/archive.rs
git commit -m "feat: add archive query APIs"
Task 6: Add Server Crate And Registry
Files:
-
Modify:
Cargo.toml -
Create:
crates/archivr-server/Cargo.toml -
Create:
crates/archivr-server/src/main.rs -
Create:
crates/archivr-server/src/registry.rs -
Step 1: Add server dependencies to workspace
In root Cargo.toml, add to [workspace.dependencies]:
axum = "0.7.9"
serde = { version = "1.0.228", features = ["derive"] }
tempfile = "3.13.0"
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "net"] }
toml = "0.8.19"
tower = "0.5.1"
tower-http = { version = "0.6.2", features = ["fs", "trace"] }
- Step 2: Add server crate to workspace
Update root Cargo.toml workspace members:
members = [
"crates/archivr-core",
"crates/archivr-cli",
"crates/archivr-server",
]
- Step 3: Create server manifest
Create crates/archivr-server/Cargo.toml:
[package]
name = "archivr-server"
version.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
archivr-core = { path = "../archivr-core" }
axum.workspace = true
serde.workspace = true
tokio.workspace = true
toml.workspace = true
tower-http.workspace = true
[dev-dependencies]
tempfile.workspace = true
- Step 4: Write registry tests
Create crates/archivr-server/src/registry.rs:
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::{fs, path::{Path, PathBuf}};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MountedArchive {
pub id: String,
pub label: String,
pub archive_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ServerRegistry {
pub archives: Vec<MountedArchive>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_round_trips_archives_from_toml() {
let temp = tempfile::tempdir().unwrap();
let archive_path = temp.path().join("personal").join(".archivr");
fs::create_dir_all(&archive_path).unwrap();
fs::write(archive_path.join("name"), "Personal").unwrap();
fs::write(archive_path.join("store_path"), temp.path().join("store").display().to_string()).unwrap();
let registry = ServerRegistry {
archives: vec![MountedArchive {
id: "personal".to_string(),
label: "Personal".to_string(),
archive_path: archive_path.clone(),
}],
};
let path = temp.path().join("server.toml");
save_registry(&path, ®istry).unwrap();
let loaded = load_registry(&path).unwrap();
assert_eq!(loaded, registry);
}
#[test]
fn registry_rejects_duplicate_archive_ids() {
let registry = ServerRegistry {
archives: vec![
MountedArchive {
id: "personal".to_string(),
label: "Personal".to_string(),
archive_path: PathBuf::from("/tmp/a/.archivr"),
},
MountedArchive {
id: "personal".to_string(),
label: "Duplicate".to_string(),
archive_path: PathBuf::from("/tmp/b/.archivr"),
},
],
};
let err = validate_registry(®istry).unwrap_err().to_string();
assert!(err.contains("duplicate archive id"));
}
}
- Step 5: Run registry tests to verify failure
Run:
cargo test -p archivr-server registry::
Expected: FAIL because save_registry, load_registry, and validate_registry are not defined.
- Step 6: Implement registry functions
Add above the test module in registry.rs:
pub fn load_registry(path: &Path) -> Result<ServerRegistry> {
let contents = fs::read_to_string(path)
.with_context(|| format!("failed to read server registry {}", path.display()))?;
let registry = toml::from_str::<ServerRegistry>(&contents)
.with_context(|| format!("failed to parse server registry {}", path.display()))?;
validate_registry(®istry)?;
Ok(registry)
}
pub fn save_registry(path: &Path, registry: &ServerRegistry) -> Result<()> {
validate_registry(registry)?;
let contents = toml::to_string_pretty(registry)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, contents)?;
Ok(())
}
pub fn validate_registry(registry: &ServerRegistry) -> Result<()> {
let mut ids = std::collections::HashSet::new();
for archive in ®istry.archives {
if archive.id.trim().is_empty() {
bail!("archive id must not be empty");
}
if !ids.insert(archive.id.as_str()) {
bail!("duplicate archive id: {}", archive.id);
}
if !archive.archive_path.ends_with(".archivr") {
bail!(
"mounted archive path must point at a .archivr directory: {}",
archive.archive_path.display()
);
}
}
Ok(())
}
- Step 7: Add minimal server main
Create crates/archivr-server/src/main.rs:
mod registry;
mod routes;
use anyhow::Result;
use std::{net::SocketAddr, path::PathBuf};
#[tokio::main]
async fn main() -> Result<()> {
let config_path = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("archivr-server.toml"));
let registry = registry::load_registry(&config_path)?;
let app = routes::app(registry);
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
let listener = tokio::net::TcpListener::bind(addr).await?;
println!("archivr-server listening on http://{addr}");
axum::serve(listener, app).await?;
Ok(())
}
Create temporary crates/archivr-server/src/routes.rs so the crate compiles:
use axum::{routing::get, Router};
use crate::registry::ServerRegistry;
pub fn app(_registry: ServerRegistry) -> Router {
Router::new().route("/health", get(|| async { "ok" }))
}
- Step 8: Verify server crate
Run:
cargo test -p archivr-server
Expected: registry tests pass.
- Step 9: Commit server registry
git add Cargo.toml Cargo.lock crates/archivr-server
git commit -m "feat: add web server registry"
Task 7: Add Server API Routes
Files:
-
Modify:
crates/archivr-server/src/routes.rs -
Modify:
crates/archivr-server/Cargo.toml -
Step 1: Add JSON serialization support
Add to crates/archivr-core/Cargo.toml:
serde.workspace = true
Derive serde::Serialize for these core query types in crates/archivr-core/src/archive.rs:
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct EntrySummary {
pub entry_uid: String,
pub archived_at: String,
pub source_kind: String,
pub entity_kind: String,
pub title: Option<String>,
pub visibility: String,
pub original_url: Option<String>,
pub artifact_count: i64,
pub total_artifact_bytes: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct EntryDetail {
pub summary: EntrySummary,
pub structured_root_relpath: String,
pub source_metadata_json: String,
pub display_metadata_json: Option<String>,
pub artifacts: Vec<EntryArtifactSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct EntryArtifactSummary {
pub artifact_role: String,
pub storage_area: String,
pub relpath: String,
pub byte_size: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct RunSummary {
pub run_uid: String,
pub started_at: String,
pub finished_at: Option<String>,
pub status: String,
pub requested_count: i64,
pub discovered_count: i64,
pub completed_count: i64,
pub failed_count: i64,
pub error_summary: Option<String>,
}
- Step 2: Write API route tests
In crates/archivr-server/src/routes.rs, add tests using tower::ServiceExt:
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
#[tokio::test]
async fn archives_endpoint_lists_mounted_archives() {
let registry = ServerRegistry {
archives: vec![MountedArchive {
id: "personal".to_string(),
label: "Personal".to_string(),
archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"),
}],
};
let response = app(registry)
.oneshot(Request::builder().uri("/api/archives").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn missing_archive_returns_404() {
let response = app(ServerRegistry::default())
.oneshot(Request::builder().uri("/api/archives/missing/entries").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
- Step 3: Run tests to verify failure
Run:
cargo test -p archivr-server routes::
Expected: FAIL until API routes exist.
- Step 4: Implement API route state and handlers
Replace crates/archivr-server/src/routes.rs with:
use std::sync::Arc;
use archivr_core::{archive, database};
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use crate::registry::{MountedArchive, ServerRegistry};
#[derive(Clone)]
pub struct AppState {
registry: Arc<ServerRegistry>,
}
pub fn app(registry: ServerRegistry) -> Router {
let state = AppState {
registry: Arc::new(registry),
};
Router::new()
.route("/health", get(|| async { "ok" }))
.route("/api/archives", get(list_archives))
.route("/api/archives/:archive_id/entries", get(list_entries))
.route("/api/archives/:archive_id/entries/:entry_uid", get(entry_detail))
.route("/api/archives/:archive_id/runs", get(list_runs))
.with_state(state)
}
async fn list_archives(State(state): State<AppState>) -> Json<Vec<MountedArchive>> {
Json(state.registry.archives.clone())
}
async fn list_entries(
State(state): State<AppState>,
Path(archive_id): Path<String>,
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
Ok(Json(archive::list_root_entries(&conn)?))
}
async fn entry_detail(
State(state): State<AppState>,
Path((archive_id, entry_uid)): Path<(String, String)>,
) -> Result<Json<archive::EntryDetail>, ApiError> {
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
let detail = archive::get_entry_detail(&conn, &entry_uid)?
.ok_or(ApiError::not_found("entry not found"))?;
Ok(Json(detail))
}
async fn list_runs(
State(state): State<AppState>,
Path(archive_id): Path<String>,
) -> Result<Json<Vec<archive::RunSummary>>, ApiError> {
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
Ok(Json(archive::list_runs(&conn)?))
}
fn mounted_archive<'a>(state: &'a AppState, archive_id: &str) -> Result<&'a MountedArchive, ApiError> {
state
.registry
.archives
.iter()
.find(|archive| archive.id == archive_id)
.ok_or(ApiError::not_found("archive not found"))
}
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn not_found(message: &str) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: message.to_string(),
}
}
}
impl<E> From<E> for ApiError
where
E: Into<anyhow::Error>,
{
fn from(error: E) -> Self {
let error = error.into();
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("{error:#}"),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, self.message).into_response()
}
}
- Step 5: Add server dev dependency for route tests
In crates/archivr-server/Cargo.toml, add:
[dev-dependencies]
tempfile.workspace = true
tower.workspace = true
- Step 6: Verify route tests
Run:
cargo test -p archivr-server
Expected: route tests pass.
- Step 7: Commit API routes
git add Cargo.toml Cargo.lock crates/archivr-core crates/archivr-server
git commit -m "feat: expose archive server APIs"
Task 8: Add Static Web UI
Files:
-
Modify:
crates/archivr-server/src/routes.rs -
Create:
crates/archivr-server/static/index.html -
Create:
crates/archivr-server/static/styles.css -
Create:
crates/archivr-server/static/app.js -
Step 1: Serve static assets
In crates/archivr-server/src/routes.rs, import:
use tower_http::services::{ServeDir, ServeFile};
Update app:
pub fn app(registry: ServerRegistry) -> Router {
let state = AppState {
registry: Arc::new(registry),
};
let static_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static");
Router::new()
.route("/health", get(|| async { "ok" }))
.route("/api/archives", get(list_archives))
.route("/api/archives/:archive_id/entries", get(list_entries))
.route("/api/archives/:archive_id/entries/:entry_uid", get(entry_detail))
.route("/api/archives/:archive_id/runs", get(list_runs))
.nest_service("/assets", ServeDir::new(&static_dir))
.fallback_service(ServeFile::new(static_dir.join("index.html")))
.with_state(state)
}
- Step 2: Create HTML shell
Create crates/archivr-server/static/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Archivr</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<header class="topbar">
<div class="brand">Archivr</div>
<select id="archive-switcher" class="archive-switcher" aria-label="Select archive"></select>
<nav class="nav">
<button class="nav-link is-active" data-view="archive">Archive</button>
<button class="nav-link" data-view="runs">Runs</button>
<button class="nav-link" data-view="admin">Admin</button>
</nav>
<button class="capture-button">+ Capture</button>
</header>
<main class="app-shell">
<section class="workspace">
<div class="search-row">
<input id="search" class="search-input" type="search" aria-label="Search archive" value="">
<div class="search-note">Advanced search later</div>
</div>
<section id="archive-view" class="view is-active">
<table class="entry-table">
<thead>
<tr>
<th>Added</th>
<th>Title</th>
<th>Type</th>
<th>Size</th>
<th>Original URL</th>
</tr>
</thead>
<tbody id="entries-body"></tbody>
</table>
</section>
<section id="runs-view" class="view">
<table class="entry-table">
<thead>
<tr>
<th>Started</th>
<th>Status</th>
<th>Requested</th>
<th>Completed</th>
<th>Failed</th>
</tr>
</thead>
<tbody id="runs-body"></tbody>
</table>
</section>
<section id="admin-view" class="view">
<div class="admin-note">
Mounted archive configuration will live here.
</div>
</section>
</section>
<aside class="context-rail">
<div class="rail-title">Context</div>
<div id="context-body" class="rail-body">Select an entry to inspect metadata and artifacts.</div>
</aside>
</main>
<script type="module" src="/assets/app.js"></script>
</body>
</html>
- Step 3: Create CSS
Create crates/archivr-server/static/styles.css:
:root {
color-scheme: light;
--ink: #1f2924;
--muted: #62665f;
--paper: #f7f3ea;
--paper-2: #eee8dc;
--paper-3: #fffdf8;
--line: #d4cabb;
--line-soft: #e3ddd3;
--accent: #8d3f30;
--link: #245f72;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--paper);
color: var(--ink);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.topbar {
height: 56px;
display: grid;
grid-template-columns: auto minmax(160px, 240px) 1fr auto;
align-items: center;
gap: 18px;
padding: 0 18px;
background: #151f1b;
color: #f5eee1;
border-bottom: 3px solid var(--accent);
}
.brand {
font-family: Georgia, "Times New Roman", serif;
font-size: 28px;
}
.archive-switcher,
.capture-button,
.nav-link {
font: inherit;
}
.archive-switcher {
width: 100%;
border: 1px solid rgba(245, 238, 225, 0.35);
background: #202c27;
color: #f5eee1;
padding: 7px 9px;
}
.nav {
display: flex;
gap: 14px;
justify-content: flex-end;
}
.nav-link {
border: 0;
background: transparent;
color: #d8d0c3;
cursor: pointer;
padding: 8px 0;
}
.nav-link.is-active {
color: #fffaf0;
border-bottom: 1px solid #fffaf0;
}
.capture-button {
border: 0;
background: #f5eee1;
color: #151f1b;
padding: 9px 12px;
cursor: pointer;
}
.app-shell {
height: calc(100vh - 56px);
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
}
.workspace {
min-width: 0;
overflow: auto;
}
.search-row {
min-height: 76px;
display: grid;
grid-template-columns: minmax(0, 1fr) 210px;
gap: 18px;
align-items: center;
padding: 14px 16px;
background: var(--paper-2);
border-bottom: 1px solid var(--line);
}
.search-input {
width: 100%;
height: 46px;
border: 2px solid var(--ink);
background: var(--paper-3);
color: var(--ink);
padding: 0 14px;
font: inherit;
font-size: 16px;
}
.search-note {
color: var(--muted);
font-size: 13px;
}
.view {
display: none;
}
.view.is-active {
display: block;
}
.entry-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 13px;
}
.entry-table th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 10px;
background: #e1dacd;
color: #5d625e;
border-bottom: 1px solid #d2cabe;
font-size: 11px;
text-transform: uppercase;
}
.entry-table td {
padding: 11px 10px;
border-bottom: 1px solid var(--line-soft);
vertical-align: top;
}
.entry-table tr:nth-child(even) td {
background: #f5f1e8;
}
.entry-table tr:nth-child(odd) td {
background: var(--paper-3);
}
.entry-table tr.is-selected td {
background: #efe8dc;
border-top: 2px solid var(--accent);
border-bottom: 2px solid var(--accent);
}
.entry-title {
color: var(--link);
font-weight: 750;
}
.url-cell {
color: #555;
word-break: break-all;
}
.context-rail {
border-left: 1px solid var(--line);
background: #f0ebe2;
padding: 18px;
overflow: auto;
}
.rail-title {
color: var(--accent);
font-weight: 800;
margin-bottom: 10px;
}
.rail-body {
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.admin-note {
padding: 24px;
color: var(--muted);
}
@media (max-width: 900px) {
.topbar {
grid-template-columns: 1fr auto;
height: auto;
min-height: 56px;
padding: 12px;
}
.archive-switcher,
.nav {
grid-column: 1 / -1;
}
.app-shell {
height: auto;
grid-template-columns: 1fr;
}
.context-rail {
border-left: 0;
border-top: 1px solid var(--line);
}
}
- Step 4: Create JavaScript
Create crates/archivr-server/static/app.js:
const state = {
archives: [],
archiveId: null,
entries: [],
};
const archiveSwitcher = document.querySelector("#archive-switcher");
const entriesBody = document.querySelector("#entries-body");
const runsBody = document.querySelector("#runs-body");
const contextBody = document.querySelector("#context-body");
const navButtons = document.querySelectorAll(".nav-link");
function formatBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function text(value) {
return value ?? "";
}
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
}
function renderArchives() {
archiveSwitcher.innerHTML = "";
for (const archive of state.archives) {
const option = document.createElement("option");
option.value = archive.id;
option.textContent = archive.label;
archiveSwitcher.append(option);
}
archiveSwitcher.value = state.archiveId ?? "";
}
function renderEntries() {
entriesBody.innerHTML = "";
for (const entry of state.entries) {
const row = document.createElement("tr");
row.tabIndex = 0;
row.innerHTML = `
<td>${text(entry.archived_at)}</td>
<td><span class="entry-title">${text(entry.title) || text(entry.entry_uid)}</span></td>
<td>${text(entry.entity_kind)}</td>
<td>${formatBytes(entry.total_artifact_bytes)}</td>
<td class="url-cell">${text(entry.original_url)}</td>
`;
row.addEventListener("click", () => selectEntry(entry, row));
entriesBody.append(row);
}
}
async function selectEntry(entry, row) {
document.querySelectorAll(".entry-table tr.is-selected").forEach((selected) => {
selected.classList.remove("is-selected");
});
row.classList.add("is-selected");
const detail = await getJson(`/api/archives/${state.archiveId}/entries/${entry.entry_uid}`);
contextBody.innerHTML = `
<div><strong>${text(detail.summary.title) || text(detail.summary.entry_uid)}</strong></div>
<div>Type: ${text(detail.summary.entity_kind)}</div>
<div>Visibility: ${text(detail.summary.visibility)}</div>
<div>Artifacts: ${detail.artifacts.length}</div>
<div>Structured root: ${text(detail.structured_root_relpath)}</div>
`;
}
async function loadRuns() {
const runs = await getJson(`/api/archives/${state.archiveId}/runs`);
runsBody.innerHTML = "";
for (const run of runs) {
const row = document.createElement("tr");
row.innerHTML = `
<td>${text(run.started_at)}</td>
<td>${text(run.status)}</td>
<td>${run.requested_count}</td>
<td>${run.completed_count}</td>
<td>${run.failed_count}</td>
`;
runsBody.append(row);
}
}
async function loadEntries() {
state.entries = await getJson(`/api/archives/${state.archiveId}/entries`);
renderEntries();
contextBody.textContent = "Select an entry to inspect metadata and artifacts.";
}
async function loadArchives() {
state.archives = await getJson("/api/archives");
state.archiveId = state.archives[0]?.id ?? null;
renderArchives();
if (state.archiveId) {
await loadEntries();
await loadRuns();
} else {
contextBody.textContent = "No archives are mounted.";
}
}
archiveSwitcher.addEventListener("change", async () => {
state.archiveId = archiveSwitcher.value;
await loadEntries();
await loadRuns();
});
navButtons.forEach((button) => {
button.addEventListener("click", () => {
navButtons.forEach((candidate) => candidate.classList.remove("is-active"));
document.querySelectorAll(".view").forEach((view) => view.classList.remove("is-active"));
button.classList.add("is-active");
document.querySelector(`#${button.dataset.view}-view`).classList.add("is-active");
});
});
loadArchives().catch((error) => {
contextBody.textContent = `Failed to load archives: ${error.message}`;
});
- Step 5: Verify static route
Run:
cargo test -p archivr-server
Expected: all server tests still pass.
- Step 6: Commit static UI
git add crates/archivr-server
git commit -m "feat: add archive table web UI"
Task 9: End-To-End Smoke Verification
Files:
-
No required source changes unless verification finds a bug.
-
Step 1: Run all Rust tests
Run:
cargo test
Expected: all workspace tests pass.
- Step 2: Create a temporary archive
Run:
tmp=$(mktemp -d)
cargo run -p archivr-cli --bin archivr -- init "$tmp/archive" "$tmp/store" --name Smoke
printf 'archive me' > "$tmp/source.txt"
cd "$tmp/archive"
cargo run --manifest-path /Users/thegeneralist/.codex/worktrees/0dd8/archivr/crates/archivr-cli/Cargo.toml --bin archivr -- archive "file://$tmp/source.txt"
Expected: archive initializes and one local file entry is recorded.
- Step 3: Create server config for the temporary archive
Run:
cat > "$tmp/server.toml" <<EOF
[[archives]]
id = "smoke"
label = "Smoke"
archive_path = "$tmp/archive/.archivr"
EOF
- Step 4: Start server
Run:
cargo run -p archivr-server -- "$tmp/server.toml"
Expected: server prints archivr-server listening on http://127.0.0.1:8080.
- Step 5: Verify API endpoints
In another terminal:
curl -s http://127.0.0.1:8080/api/archives
curl -s http://127.0.0.1:8080/api/archives/smoke/entries
curl -s http://127.0.0.1:8080/api/archives/smoke/runs
Expected:
-
/api/archivesincludes"id":"smoke". -
/entriesreturns one entry. -
/runsreturns at least one run. -
Step 6: Verify browser UI
Open http://127.0.0.1:8080 in the in-app browser.
Expected:
-
The archive switcher shows
Smoke. -
The archive table renders one row.
-
The table uses dense sans-serif rows.
-
The warm archive-ledger shell is visible.
-
+ Captureis present but secondary. -
Clicking the row populates the context rail.
-
Runs tab displays the run history.
-
Step 7: Commit verification fixes if needed
If verification required source changes:
git add crates Cargo.toml Cargo.lock
git commit -m "fix: complete web UI smoke path"
If no source changes were needed, do not create an empty commit.
Final Verification
Before marking implementation complete, run:
cargo fmt --check
cargo test
cargo run -p archivr-server -- --help
If archivr-server does not implement --help, run:
cargo run -p archivr-server -- /path/to/server.toml
with a real temporary server config and verify /health, /api/archives, and the static UI.
Handoff Notes
- Keep commits atomic in the task order above.
- Do not implement production auth in this plan.
- Do not implement advanced search in this plan.
- Do not add a top-level Public page in this plan.
- Do not add a top-level Tags page in this plan.
- Keep table typography sans-serif.
- Use
tagsandhierarchical tagsin user-facing and code/schema terminology.