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
1 change: 1 addition & 0 deletions .claude/skills/resolving-issues/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ Coordinator owns this check (metadata work, allowed under "coordinator never cod
### Implementer-flagged out-of-scope rot
- When the implementer surfaces pre-existing rot it intentionally doesn't fix (e.g. unrelated wasm break under `--all-features`, dead-code warnings in untouched files), the coordinator files a follow-up GH issue using `mcp__github__issue_write` (metadata work, allowed under the Coordinator-never-codes rule). Cite the discovery context (which dispatch surfaced it, which commit + gate step) so the next run has full provenance.
- This is the same shape as the "implementer files follow-up" rule — coordinator just does the filing because the implementer is single-task and exits after commit.
- **Always reproduce the failure on coordinator HEAD before filing OR dismissing.** A prior run dismissing the same shape as a "sandbox-side flake" doesn't mean it stays a flake — rot accumulates as PRs merge, and a previously-flaky symptom can become a real regression once a related change lands. Run the failing command in the coordinator's checkout (single shot, no retry loops); if it reproduces, file the follow-up with the exact assertion + the git history that exposed it (e.g. `git log --oneline <last-fix-attempt>..HEAD -- <relevant-paths>` to find the PR that flipped flake → real). If it doesn't reproduce, don't file — but note the dismissal in the run-end Lessons Learned so the next run has the audit trail. Never rely on a prior dismissal alone; always re-verify.

### Fresh agent per issue
- New implementer each issue. No state leak.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions crates/agent/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@
//! Stdio transport skips auth (process isolation). SSE/HTTP transports
//! require a bearer token in the `Authorization` header.

use rand::Rng;
use rand::{rngs::OsRng, RngCore};

/// Token prefix for Willow agent tokens.
const TOKEN_PREFIX: &str = "wlw_";

/// Generate a 256-bit random bearer token with `wlw_` prefix.
pub fn generate_token() -> String {
let mut rng = rand::thread_rng();
let mut bytes = [0u8; 32];
rng.fill(&mut bytes);
OsRng.fill_bytes(&mut bytes);
format!("{}{}", TOKEN_PREFIX, hex::encode(&bytes))
}

Expand Down
4 changes: 4 additions & 0 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ mod tests_voice;
#[path = "tests/governance.rs"]
mod tests_governance;

#[cfg(all(test, not(target_arch = "wasm32")))]
#[path = "tests/search.rs"]
mod tests_search;

#[cfg(all(test, not(target_arch = "wasm32")))]
#[path = "tests/sync_reply_cache.rs"]
mod tests_sync_reply_cache;
Expand Down
6 changes: 3 additions & 3 deletions crates/client/src/listeners.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,9 +411,9 @@ async fn process_received_message<T: TopicHandle>(
}
crate::ops::WireMessage::SyncRequest { state_hash, .. } => {
let _ = state_hash; // Legacy field — can't filter by state hash in DAG model.
// TODO(#65): Migrate clients to worker's heads-based sync protocol
// (WorkerRequest::Sync { heads }) for efficient delta sync.
// For now, send the first SYNC_REPLY_LIMIT events from
// TODO(#382): Migrate clients to worker's heads-based sync
// protocol (WorkerRequest::Sync { heads }) for efficient delta
// sync. For now, send the first SYNC_REPLY_LIMIT events from
// topological sort. Receiver will dedup via InsertError::Duplicate.
// The reply Vec is cached on `DagState` and invalidated on
// every successful DAG insert; see GEN-08 / issue #268.
Expand Down
13 changes: 7 additions & 6 deletions crates/client/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,13 @@ pub struct DisplayMessage {
/// Queue-note state for this row (see [`QueueNote`]).
///
/// Populated by the view projection in
/// `views::compute_messages_view`. Today the projection defers the
/// real detection to the sync-queue crate and always returns
/// `None` — see `TODO(sync-queue.md)` in `views.rs`. The renderer
/// is already wired for the full tri-state so the UX will light up
/// once detection lands. The grouping predicate in `chat.rs`
/// treats any non-`None` variant as a run-break per
/// `views::compute_messages_view`. Phase 2b (see
/// `docs/plans/2026-04-21-ui-phase-2b-sync-queue.md`) closed the
/// original `TODO(sync-queue.md)` gate: the projection now derives
/// real `Pending` / `LateArrival` values from `QueueMeta`. The
/// renderer is wired for the full tri-state. The grouping
/// predicate in `chat.rs` treats any non-`None` variant as a
/// run-break per
/// `docs/specs/2026-04-19-ui-design/message-row.md` §Queue notes.
pub queue_note: QueueNote,
}
Expand Down
128 changes: 128 additions & 0 deletions crates/client/src/tests/search.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Focused contract tests for [`SearchIndexHandle::set_config`] —
//! audit F42 (issue #542).
//!
//! Prior to this file the only direct coverage of `set_config` lived in
//! `crates/client/src/search/tests.rs::handle_tests`, where each test
//! exercised one *side* of the contract (insert gating, recents gating,
//! grove opt-out gating). The audit asked for a focused test that locks
//! in `set_config` itself, independently of Effect wiring or the
//! `Insert` handler's own gating logic.
//!
//! Two complementary assertions live here:
//!
//! 1. [`set_config_round_trip`] — the minimum contract: after
//! `set_config(c)`, a subsequent `config().await` returns exactly
//! `c`. This pins the read/write symmetry of the actor's `SetConfig`
//! + `GetConfig` handler pair.
//!
//! 2. [`set_config_changes_rebuild_query_results`] — the only path by
//! which `set_config` produces an *observable query-result delta*
//! against a previously-indexed corpus is via a subsequent
//! `rebuild`, because the executor itself does not consult config
//! (gating happens at write time in `SearchActor::message_allowed`).
//! This test indexes a two-grove corpus, asserts both groves are
//! queryable, then opts grove `g1` out via `set_config`, rebuilds,
//! and asserts `g1`'s message no longer appears while `g0`'s still
//! does. That sequence proves `set_config` actually mutated the
//! actor's config snapshot in a way the next `rebuild` observes.
//!
//! Why not assert a query-result delta directly after `set_config`
//! without a rebuild? Because the executor in
//! `crates/client/src/search/execute.rs` reads only the index and the
//! query — never the config. Asserting an immediate delta would
//! require either changing production behaviour (queries consulting
//! config) or testing a contract the code doesn't make. We pick the
//! existing contract instead: rebuild reflects the latest config.

use willow_actor::System;
use willow_identity::Identity;

use crate::search::{
parse_query, IndexableMessage, SearchIndexConfig, SearchIndexHandle, SearchScope,
};

fn mk(id: &str, body: &str, grove: &str) -> IndexableMessage {
IndexableMessage {
message_id: id.into(),
channel_id: format!("c-{grove}"),
channel_name: "general".into(),
grove_id: Some(grove.into()),
letter_id: None,
author_peer_id: Identity::generate().endpoint_id(),
author_handle: "mira".into(),
author_display_name: "Mira".into(),
timestamp_ms: 100,
body: body.into(),
has_image: false,
has_file: false,
has_link: false,
}
}

/// Round-trip: writing a config and reading it back returns the same
/// value. Pins the `SetConfig` + `GetConfig` handler pair as a single
/// atomic read-after-write.
#[tokio::test]
async fn set_config_round_trip() {
let sys = System::new();
let h = SearchIndexHandle::new_in_memory(&sys.handle());

let mut per_grove_enabled = std::collections::HashMap::new();
per_grove_enabled.insert("g-quiet".into(), false);
per_grove_enabled.insert("g-loud".into(), true);
let cfg = SearchIndexConfig {
enabled: false,
horizon_days: 30,
remember_recents: false,
per_grove_enabled,
};

h.set_config(cfg.clone());
assert_eq!(h.config().await, cfg);
}

/// `set_config` followed by `rebuild` must reflect the new config in
/// query results: a grove opted out via `per_grove_enabled` after the
/// initial index build disappears from queries once the index is
/// rebuilt against the same corpus.
#[tokio::test]
async fn set_config_changes_rebuild_query_results() {
let sys = System::new();
let h = SearchIndexHandle::new_in_memory(&sys.handle());

// Two messages, one per grove, both containing the term `signal`.
let corpus = vec![
mk("m-keep", "signal here", "g0"),
mk("m-drop", "signal there", "g1"),
];
h.rebuild(corpus.clone()).await;

// Baseline: both groves visible.
let q = parse_query("signal");
let pre = h.query(&q, &SearchScope::AllGrovesAndLetters).await;
let pre_ids: Vec<_> = pre.iter().map(|r| r.message_id.clone()).collect();
assert_eq!(
pre.len(),
2,
"baseline must surface both groves: {pre_ids:?}"
);
assert!(pre_ids.contains(&"m-keep".into()));
assert!(pre_ids.contains(&"m-drop".into()));

// Opt grove `g1` out, then rebuild against the same corpus.
let mut cfg = h.config().await;
cfg.per_grove_enabled.insert("g1".into(), false);
h.set_config(cfg);
h.rebuild(corpus).await;

// Post-rebuild the opted-out grove's message is gone; the kept
// grove's message still hits.
let post = h.query(&q, &SearchScope::AllGrovesAndLetters).await;
let post_ids: Vec<_> = post.iter().map(|r| r.message_id.clone()).collect();
assert_eq!(
post.len(),
1,
"after set_config + rebuild only g0 must remain: {post_ids:?}"
);
assert_eq!(post[0].message_id, "m-keep");
}
43 changes: 24 additions & 19 deletions crates/client/src/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,13 @@ impl ServerRegistry {
/// exposes its full membership — see the multi-grove TODO on
/// `servers.rs`).
pub fn shared_groves(&self, _local: &EndpointId, _other: &EndpointId) -> Vec<String> {
// TODO(multi-grove): plumb `state.members` into `ServerEntry`
// so the intersection can walk every grove the local peer is
// in. Until then, the helper returns the active grove's name
// when we know both peers are members (check deferred to the
// UI which reads `MembersView` for the active server). Return
// an empty Vec rather than fabricating a match — the spec's
// edge case "no shared groves → omit section" covers this.
// TODO(#563): plumb `state.members` into `ServerEntry` so the
// intersection can walk every grove the local peer is in. Until
// then, the helper returns the active grove's name when we know
// both peers are members (check deferred to the UI which reads
// `MembersView` for the active server). Return an empty Vec rather
// than fabricating a match — the spec's edge case "no shared
// groves → omit section" covers this.
Vec::new()
}
}
Expand Down Expand Up @@ -477,8 +477,10 @@ impl ClientViewHandle {
/// Phase 2b: accepts a `queue_meta` snapshot so the projection can
/// derive real `QueueNote::Pending` / `QueueNote::LateArrival` values
/// for each row via [`crate::queue::derive_pending`] +
/// [`crate::queue::derive_late_arrival`]. Closes the
/// `TODO(sync-queue.md)` gate in this function and in the Phase 2a
/// [`crate::queue::derive_late_arrival`]. Closes the original
/// sync-queue gate (see plan
/// `docs/plans/2026-04-21-ui-phase-2b-sync-queue.md`) in this function
/// and in the Phase 2a plan
/// `docs/plans/2026-04-20-ui-phase-2a-message-row.md` at line 490.
pub fn compute_messages_view(
events: &Arc<willow_state::ServerState>,
Expand Down Expand Up @@ -508,8 +510,9 @@ pub fn compute_messages_view(
// track a distinct `@handle` (see `profile-card.md` for the target
// profile data model); as a stand-in we derive a handle from the
// display name via `display_name.to_lowercase().replace(' ', '.')`.
// TODO(profile-card.md): replace the display-name-derived handle
// with the real handle field once profile data is plumbed.
// TODO(plan: docs/plans/2026-04-21-ui-phase-2c-profile-card.md):
// replace the display-name-derived handle with the real handle field
// once profile data is plumbed.
let peer_refs: Vec<PeerRef> = events
.members
.keys()
Expand Down Expand Up @@ -597,11 +600,11 @@ pub fn compute_messages_view(
} else {
QueueNote::None
};
// TODO(whisper-mode.md): flip via WhisperStart event when
// that phase lands. Phase 2a Task 8 reserves the row
// styling surface (message--whisper class + whisper-badge)
// behind this always-false gate so later work only has to
// swap the projection lookup.
// TODO(#562): flip via WhisperStart event when that phase
// lands. Phase 2a Task 8 reserves the row styling surface
// (message--whisper class + whisper-badge) behind this
// always-false gate so later work only has to swap the
// projection lookup.
let whisper = false;
DisplayMessage {
id: m.id.to_string(),
Expand Down Expand Up @@ -697,8 +700,10 @@ pub fn compute_unread_view(
let mute = event_state.mute_state.get(&local_peer_id).cloned();

// Build a PeerRef list once for the mention parser. Mirrors the
// build in `compute_messages_view`; TODO(profile-card.md) tracks
// swapping display-name-derived handles for real profile handles.
// build in `compute_messages_view`; the
// TODO(plan: docs/plans/2026-04-21-ui-phase-2c-profile-card.md)
// there tracks swapping display-name-derived handles for real
// profile handles.
//
// `resolve_display_name` needs a `ProfileState` — we only have the
// event-state profiles here, so fall back to the event-state entry
Expand Down Expand Up @@ -789,7 +794,7 @@ pub fn compute_roles_view(events: &Arc<willow_state::ServerState>) -> RolesView
.map(|role| RoleEntry {
id: role.id.clone(),
name: role.name.clone(),
permissions: role.permissions.iter().map(|p| format!("{p:?}")).collect(),
permissions: role.permissions.iter().map(|p| p.to_string()).collect(),
})
.collect();
roles.sort_by(|a, b| a.name.cmp(&b.name));
Expand Down
1 change: 1 addition & 0 deletions crates/messaging/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ serde = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }

[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
Expand Down
Loading