Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions .kanon-lint-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ RUST/as-cast:crates/syndesis/src/clock/**
RUST/as-cast:crates/syndesis/src/protocol/**
RUST/as-cast:crates/syndesis/src/client/**
RUST/as-cast:crates/syndesis/src/server/**
RUST/as-cast:crates/archon/src/render/**
RUST/as-cast:crates/paroche/src/subsonic/**
RUST/as-cast:crates/apotheke/src/repo/**
RUST/as-cast:crates/ergasia/src/**
Expand Down Expand Up @@ -160,10 +159,6 @@ RUST/struct-too-many-fields:crates/**
# now would force a cascade of inline-docstring writing for 1000+ public items.
ARCHITECTURE/no-deny-missing-docs:crates/**

# WHY: no-migration-checksum fires on sqlx migration files whose hashes
# are embedded in the migrations table. Altering the files would corrupt
# every existing database; migrations are immutable by protocol.
STORAGE/no-migration-checksum:crates/**/migrations/**
# WHY: the migration-checksum rule fires on a name heuristic — it flags every
# source file in apotheke because the crate carries a `migrate` module. The
# actual migration runner (apotheke::migrate) uses sqlx::migrate!() which
Expand Down Expand Up @@ -210,7 +205,6 @@ RUST/allow-not-expect:crates/**
# image in one workspace. Reducing requires architectural split across
# multiple binaries, tracked separately.
MANIFEST/dep-count:Cargo.lock
MANIFEST/dep-count:Cargo.toml
MANIFEST/dep-count:crates/theatron/desktop/Cargo.lock

# WHY: aggregate-status-without-detail applies to two health endpoints that
Expand Down Expand Up @@ -409,6 +403,20 @@ RUST/validate-returns-unit:crates/aitesis/src/workflow.rs
# tweak whose result every caller discards. Tracked in issue #696.
RUST/validate-returns-unit:crates/eksetasis/src/client/cardigann/template.rs

# WHY: the retry-after tests assert on tokio::time::Instant under an
# explicitly paused clock (tokio::time::pause()) — the measured "elapsed"
# is virtual time, exact and deterministic, never wall latency. The rule's
# flake concern does not apply to paused-clock assertions; rewriting them
# to advance()+is_finished polling would obscure the clamp contract they
# document.
TESTING/wall-clock-assertion:crates/eksetasis/src/search/tests.rs

# WHY: the hybrid-gate reusable workflow is called as @main BY FLEET DESIGN —
# forkwright/.github is operator-owned, so the supply-chain surface the
# unpinned-action rule guards is the fleet itself, and @main is the deliberate
# single-update channel that propagates gate fixes to every caller repo.
SHELL/unpinned-action:.github/workflows/gate-attestation.yml

# WHY: ergasia/pipeline.rs uses std::process::Command("df") to query available
# disk space before archive extraction. This is a POSIX system utility call with
# no injection surface (all arguments are typed Path/&str literals, not runtime
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Harmonia uses the self-hosted kanon forge as the authoritative PR surface. GitHu
## Push target

```
origin = http://kanon.lan/forkwright/harmonia.git (authoritative)
origin = http://<kanon-forge>/forkwright/harmonia.git (authoritative)
github = git@github.com:forkwright/harmonia.git (mirror)
```

Expand All @@ -15,7 +15,7 @@ Push to `origin`. The forge post-receive hook runs CI (`.kanon-ci.toml`) and mir

Two paths, same effect:

**Stoa UI.** Open `http://kanon.lan/prs/forkwright/harmonia`, click "New PR", pick base + head refs, review diff, submit.
**Stoa UI.** Open `http://<kanon-forge>/prs/forkwright/harmonia`, click "New PR", pick base + head refs, review diff, submit.

**CLI.**

Expand Down
24 changes: 13 additions & 11 deletions crates/archon/src/mcp_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,18 +373,19 @@ async fn handle_enqueue(arguments: &Value, ctx: &BridgeContext) -> Value {
// only in eksetasis's in-memory cache; persisting it here, ahead of
// enqueue, is what makes it a durable identifier rather than a
// process-local key.
let metadata = match paroche::routes::download::ReleaseMetadata::new(
indexer_id,
title,
size_bytes,
download_url.clone(),
protocol.clone(),
info_hash.clone(),
) {
Ok(metadata) => metadata,
Err(error) => return tool_error(format!("invalid release metadata: {error:?}")),
};
if let Err(error) = paroche::routes::download::persist_release_before_enqueue(
&ctx.db,
want_id,
release_id,
paroche::routes::download::ReleaseMetadata {
indexer_id,
title,
size_bytes,
download_url: download_url.clone(),
protocol: protocol.clone(),
info_hash: info_hash.clone(),
},
&ctx.db, want_id, release_id, metadata,
)
.await
{
Expand All @@ -398,6 +399,7 @@ async fn handle_enqueue(arguments: &Value, ctx: &BridgeContext) -> Value {
paroche::routes::download::ReleasePersistError::Database(e) => {
format!("failed to persist release {release_id}: {e}")
}
other => format!("failed to persist release {release_id}: {other:?}"),
});
}

Expand Down
16 changes: 7 additions & 9 deletions crates/eksetasis/src/rate_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,22 +205,20 @@ mod tests {
assert!(limiter.acquire(2, &CancellationToken::new()).await);
}

#[tokio::test]
#[tokio::test(start_paused = true)]
async fn acquire_unblocks_on_cancellation_before_refill() {
let limiter = RateLimiter::new(1, Duration::from_secs(600));
assert!(limiter.acquire(1, &CancellationToken::new()).await);

let ct = CancellationToken::new();
ct.cancel();
let start = Instant::now();
let acquired = limiter.acquire(1, &ct).await;
let elapsed = start.elapsed();

// WHY: under a paused clock a wrongly-pending acquire burns the full
// virtual second and surfaces as Elapsed; a working cancellation
// returns without waiting out any timer. No wall-clock measurement.
let acquired = tokio::time::timeout(Duration::from_secs(1), limiter.acquire(1, &ct))
.await
.expect("cancellation must not wait out a 600s refill");
assert!(!acquired, "expected cancellation, not acquisition");
assert!(
elapsed < Duration::from_secs(1),
"expected prompt unblock on cancel, got {elapsed:?}"
);
}

#[tokio::test]
Expand Down
5 changes: 0 additions & 5 deletions crates/horismos/.kanon-lint-ignore

This file was deleted.

17 changes: 10 additions & 7 deletions crates/komide/src/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,9 +455,16 @@ mod tests {
.build()
.unwrap();

let start = std::time::Instant::now();
let result = fetch_feed(&client, &format!("http://{addr}"), None, None, CAP).await;
let elapsed = start.elapsed();
// WHY: the outer timeout IS the bound — if the client-configured
// 100ms timeout fails to fire, this call hangs on the server's 5s
// stall and the wrapper returns Elapsed, failing the test without
// any wall-clock measurement.
let result = tokio::time::timeout(
std::time::Duration::from_secs(2),
fetch_feed(&client, &format!("http://{addr}"), None, None, CAP),
)
.await
.expect("the client timeout must fire long before the server's 5s hold");

// WHY: FetchResult (the Ok payload) does not derive Debug, so
// matching directly avoids requiring it just for this assertion.
Expand All @@ -468,10 +475,6 @@ mod tests {
matches!(err, KomideError::FeedFetch { .. }),
"a stalled body must time out as a FeedFetch error, not hang forever: {err:?}"
);
assert!(
elapsed < std::time::Duration::from_secs(2),
"the client-configured timeout must bound the stall well under the server's 5s hold, got {elapsed:?}"
);
}

#[tokio::test]
Expand Down
6 changes: 6 additions & 0 deletions crates/paroche/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ impl From<crate::routes::download::ReleasePersistError> for ParocheError {
crate::routes::download::ReleasePersistError::Database(source) => {
ParocheError::Database { source }
}
crate::routes::download::ReleasePersistError::EmptyMetadata => {
ParocheError::Validation {
message: "release metadata requires a non-empty title and download URL"
.to_string(),
}
}
}
}
}
Expand Down
40 changes: 35 additions & 5 deletions crates/paroche/src/routes/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,46 @@ pub struct ReleaseMetadata {
pub info_hash: Option<String>,
}

impl ReleaseMetadata {
/// The one construction path for a releases row. `title` and
/// `download_url` must be non-empty — every call site arrives here with
/// an SSRF-validated URL and a resolved-or-placeholder title, and this
/// is where that contract is enforced rather than re-stated.
pub fn new(
indexer_id: i64,
title: String,
size_bytes: Option<u64>,
download_url: String,
protocol: String,
info_hash: Option<String>,
) -> Result<Self, ReleasePersistError> {
if title.is_empty() || download_url.is_empty() {
return Err(ReleasePersistError::EmptyMetadata);
}
Ok(Self {
indexer_id,
title,
size_bytes,
download_url,
protocol,
info_hash,
})
}
}

/// Outcomes of `persist_release_before_enqueue` other than success. Kept
/// distinct from `ParocheError`/the MCP bridge's `tool_error` so this
/// module stays decoupled from either surface's error-rendering convention
/// — each caller maps these to its own shape.
#[derive(Debug)]
#[non_exhaustive]
pub enum ReleasePersistError {
/// `want_id` has no row in `wants` — the caller must create it first.
WantNotFound,
/// `release_id` already exists but is recorded under a DIFFERENT want.
ReleaseWantConflict,
/// Title or download URL arrived empty — the constructor's contract.
EmptyMetadata,
Database(apotheke::DbError),
}

Expand Down Expand Up @@ -414,14 +444,14 @@ pub async fn enqueue_download(
&state.db,
want_id,
release_id,
ReleaseMetadata {
ReleaseMetadata::new(
indexer_id,
title,
size_bytes,
download_url: download_url.clone(),
protocol: protocol.clone(),
info_hash: info_hash.clone(),
},
download_url.clone(),
protocol.clone(),
info_hash.clone(),
)?,
)
.await?;

Expand Down
6 changes: 3 additions & 3 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ KOSync is a simple HTTP protocol for syncing ebook reading position across devic
2. **Open KOReader settings** → **Syncing** → **KOSync** → **Enable**.

3. **Set custom server URL:**
- Server URL: `http://<harmonia-host>:<port>` (e.g., `http://harmonia.lan:7654`)
- Server URL: `http://<harmonia-host>:<port>` (e.g., `http://harmonia.example.com:7654`)
- Username: (your username from step 4)
- Password: (your password from step 4)

4. **Register user** (one time):
```bash
curl -X POST http://harmonia.lan:7654/kosync/users/create \
curl -X POST http://harmonia.example.com:7654/kosync/users/create \
-H "Content-Type: application/json" \
-d '{"username": "reader1", "password": "yourpassword"}'
```
Expand All @@ -59,7 +59,7 @@ PASSWORD="mypassword"
SHA1=$(echo -n "$PASSWORD" | sha1sum | cut -d' ' -f1)
curl -H "x-auth-user: reader1" \
-H "x-auth-key: $SHA1" \
http://harmonia.lan:7654/kosync/syncs/progress/5d41402abc4b2a76b9719d911017c592
http://harmonia.example.com:7654/kosync/syncs/progress/5d41402abc4b2a76b9719d911017c592
```

### Data Model
Expand Down