Skip to content

Commit 08fbf9c

Browse files
committed
fix(desktop): defer capability changes until real time advances
Signed-off-by: Logan Johnson <loganj@squareup.com>
1 parent b573869 commit 08fbf9c

4 files changed

Lines changed: 141 additions & 10 deletions

File tree

crates/buzz-core/src/desktop_capabilities.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ impl DesktopCapabilities {
7575

7676
/// Encrypt/sign once, then persist these exact bytes for retries.
7777
pub fn sign(&self, keys: &Keys) -> Result<Event, String> {
78+
self.sign_at(keys, nostr::Timestamp::now())
79+
}
80+
81+
/// Sign at an observed wall-clock second, never a synthesized logical time.
82+
pub fn sign_at(&self, keys: &Keys, observed: nostr::Timestamp) -> Result<Event, String> {
7883
self.validate()?;
7984
let content = nip44::encrypt(
8085
keys.secret_key(),
@@ -85,6 +90,7 @@ impl DesktopCapabilities {
8590
.map_err(|e| e.to_string())?;
8691
let event = EventBuilder::new(Kind::Custom(KIND_DESKTOP_CAPABILITIES as u16), content)
8792
.tag(Tag::identifier(&self.id))
93+
.custom_created_at(observed)
8894
.sign_with_keys(keys)
8995
.map_err(|e| e.to_string())?;
9096
validate_envelope(&event)?;

desktop/src-tauri/src/commands/desktop_capabilities.rs

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,16 @@ pub async fn prepare_desktop_capabilities(
5050
let facts =
5151
project(super::agent_discovery::discover_acp_providers(app.clone(), Some(false)).await?)?;
5252
let scope = scope(&app, &state, &owner, &community)?;
53-
Ok(json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts)? }))
53+
Ok(
54+
json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts, nostr::Timestamp::now)? }),
55+
)
5456
}
5557

5658
fn prepare_report(
5759
conn: &mut Connection,
5860
scope: &RetentionScope,
5961
facts: Vec<RuntimeFact>,
62+
clock: impl FnOnce() -> nostr::Timestamp,
6063
) -> Result<Event, String> {
6164
let saved = prepare(conn, scope)?;
6265
let profile: Event =
@@ -90,8 +93,16 @@ fn prepare_report(
9093
.unwrap_or(false);
9194
let event = match previous {
9295
Some(event) if unchanged => event,
93-
_ => {
94-
let event = report.sign(&scope.owner_keys)?;
96+
previous => {
97+
let now = clock();
98+
// Keep the prior retry record until real time advances. Signing tied
99+
// ciphertext can lose NIP-33's lower-ID tie; never cache that loss or
100+
// future-date a replacement. The existing pulse/reconnect/Refresh
101+
// retries discovery, not a captured projection, without waiting here.
102+
if previous.as_ref().is_some_and(|e| now <= e.created_at) {
103+
return Err("Desktop capability facts deferred until the clock advances".into());
104+
}
105+
let event = report.sign_at(&scope.owner_keys, now)?;
95106
tx.execute(
96107
"INSERT OR REPLACE INTO desktop_capabilities VALUES (1, ?1)",
97108
[event.as_json()],
@@ -123,7 +134,7 @@ pub fn read_desktop_capabilities(
123134
mod tests {
124135
use super::*;
125136
#[test]
126-
fn unchanged_facts_reopen_exact_bytes_changed_facts_replace_atomically() {
137+
fn changed_facts_defer_until_real_clock_advances_then_win_signed_order() {
127138
let dir = tempfile::tempdir().unwrap();
128139
let scope = RetentionScope {
129140
db_path: dir.path().join("report.db"),
@@ -134,26 +145,80 @@ mod tests {
134145
&mut open_retention_db(&scope.db_path).unwrap(),
135146
&scope,
136147
vec![],
148+
|| nostr::Timestamp::from(1000),
137149
)
138150
.unwrap();
139151
let mut reopened = open_retention_db(&scope.db_path).unwrap();
140152
assert_eq!(
141-
prepare_report(&mut reopened, &scope, vec![]).unwrap(),
153+
prepare_report(&mut reopened, &scope, vec![], || panic!(
154+
"unchanged must not sign"
155+
))
156+
.unwrap(),
142157
first
143158
);
144159
assert_eq!(reopened.total_changes(), 0);
145-
let facts = vec![RuntimeFact {
160+
let mut facts = vec![RuntimeFact {
146161
id: "goose".into(),
147162
availability: "available".into(),
148163
requires_external_cli: true,
149164
max_parallelism: None,
150165
}];
151-
let changed = prepare_report(&mut reopened, &scope, facts).unwrap();
152-
assert_ne!(changed.id, first.id);
166+
for now in [1000, 990, 999, 1000] {
167+
let error = prepare_report(&mut reopened, &scope, facts.clone(), || {
168+
nostr::Timestamp::from(now)
169+
})
170+
.unwrap_err();
171+
assert!(error.contains("clock advances"));
172+
assert_eq!(reopened.total_changes(), 0, "deferral must not persist");
173+
// Returning to old facts cancels the proposed change, even after a
174+
// restart/rollback: no deferred payload or timestamp renewal survives.
175+
assert_eq!(
176+
prepare_report(&mut reopened, &scope, vec![], || panic!("exact retry")).unwrap(),
177+
first
178+
);
179+
reopened = open_retention_db(&scope.db_path).unwrap();
180+
}
181+
// The retry observes today's facts, not the projection first deferred.
182+
facts[0].availability = "cli_missing".into();
183+
let changed = prepare_report(&mut reopened, &scope, facts.clone(), || {
184+
nostr::Timestamp::from(1001)
185+
})
186+
.unwrap();
187+
first.verify().unwrap();
188+
changed.verify().unwrap();
189+
assert_eq!(changed.created_at.as_secs(), 1001, "no future timestamp");
190+
assert!(changed.created_at > first.created_at);
153191
assert_eq!(changed.tags, first.tags);
192+
for events in [
193+
vec![first.clone(), changed.clone()],
194+
vec![changed.clone(), first],
195+
] {
196+
let rows =
197+
DesktopCapabilities::read_latest(events, &scope.owner_keys, &scope.relay_url)
198+
.unwrap();
199+
assert_eq!(rows.len(), 1);
200+
assert_eq!(rows[0].0.runtimes, facts);
201+
assert_eq!(rows[0].1, 1001);
202+
}
203+
let mut reopened = open_retention_db(&scope.db_path).unwrap();
204+
let mut invalid = facts.clone();
205+
invalid[0].max_parallelism = Some(0);
206+
assert!(prepare_report(&mut reopened, &scope, invalid, || {
207+
nostr::Timestamp::from(1002)
208+
})
209+
.is_err());
210+
assert_eq!(
211+
prepare_report(&mut reopened, &scope, facts, || panic!("exact retry")).unwrap(),
212+
changed
213+
);
214+
assert_eq!(
215+
reopened.total_changes(),
216+
0,
217+
"failed signing must not persist"
218+
);
154219
reopened
155220
.execute("UPDATE desktop_capabilities SET raw = 'corrupt'", [])
156221
.unwrap();
157-
assert!(prepare_report(&mut reopened, &scope, vec![]).is_err());
222+
assert!(prepare_report(&mut reopened, &scope, vec![], nostr::Timestamp::now).is_err());
158223
}
159224
}

desktop/src/features/agents/desktopCapabilities.test.mjs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,34 @@ test("unchanged accepted report does not republish; failed publish retries exact
8080
await assert.rejects(f.refresh(), /unavailable/);
8181
});
8282

83+
test("deferred preparation settles with prior relay facts, never publishes, and honors cancellation", async () => {
84+
const f = fixture();
85+
const ipc = f.ipc;
86+
let active = true;
87+
let cancel = false;
88+
f.ipc = async (command, args) => {
89+
if (command === "prepare_desktop_capabilities") {
90+
if (cancel) active = false;
91+
throw Error("Desktop capability facts deferred until the clock advances");
92+
}
93+
return ipc(command, args);
94+
};
95+
const deferred = await f.refresh(() => active);
96+
assert.deepEqual(deferred.rows, [row]);
97+
assert.match(deferred.warning, /Will retry/);
98+
assert.ok(!f.calls.includes(event));
99+
cancel = true;
100+
await assert.rejects(
101+
f.refresh(() => active),
102+
/scope changed/,
103+
);
104+
assert.ok(!f.calls.includes(event));
105+
// A later, active attempt prepares afresh; no held promise or queued event.
106+
f.ipc = ipc;
107+
assert.equal((await f.refresh()).warning, "");
108+
assert.equal(f.calls.filter((c) => c === event).length, 1);
109+
});
110+
83111
test("all async boundaries fence cancellation, account/community switches and late ACK", async () => {
84112
for (const boundary of ["prepare", "read", "fetch", "transport", "ack"]) {
85113
const f = fixture(boundary);

desktop/src/features/agents/desktopList.test.mjs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
202202
const originalReconnect = relayClient.subscribeToReconnects;
203203
let reconnect;
204204
let pulses = 0;
205+
let reports = 0;
206+
let publishedReports = 0;
207+
let deferReport = true;
205208
relayClient.subscribeToReconnects = (callback) => {
206209
reconnect = callback;
207210
return () => {
@@ -210,6 +213,17 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
210213
};
211214
window.__TAURI_INTERNALS__ = {
212215
invoke: async (command, args) => {
216+
if (command === "prepare_desktop_capabilities") {
217+
reports++;
218+
if (deferReport) throw Error("clock has not advanced");
219+
return { event: { ...first, kind: 30182 } };
220+
}
221+
if (command === "read_desktop_capabilities")
222+
return args.events.map(() => ({
223+
id: "desktop-a",
224+
reported: 100,
225+
runtimes: [],
226+
}));
213227
if (command === "prepare_desktop_observation")
214228
return { event: { ...first, kind: 30181 } };
215229
if (command === "read_desktop_observations")
@@ -237,7 +251,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
237251
};
238252
relayClient.fetchEvents = async (filter) => {
239253
if (fail) throw Error("unavailable");
240-
if (filter.kinds[0] === 30181) return [];
254+
if ([30181, 30182].includes(filter.kinds[0])) return [];
241255
if (filter["#d"]) return [current];
242256
const rows = [current];
243257
if (hold) {
@@ -249,6 +263,10 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
249263
return rows;
250264
};
251265
relayClient.publishEvent = async (event) => {
266+
if (event.kind === 30182) {
267+
publishedReports++;
268+
return;
269+
}
252270
assert.equal(event.kind, 30181, "no profile heartbeat rewrite");
253271
pulses++;
254272
};
@@ -285,14 +303,28 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
285303
await settle();
286304
assert.match(text(), /owner-a-wss:\/\/a.example/);
287305
assert.match(text(), /Last heard: Recent/);
306+
assert.match(text(), /could not synchronize capability facts/);
288307
const beforeReconnect = pulses;
308+
const reportsBeforeReconnect = reports;
289309
await React.act(async () => reconnect());
290310
await settle();
291311
assert.ok(pulses > beforeReconnect, "reconnect reports a fresh pulse");
312+
assert.ok(
313+
reports > reportsBeforeReconnect,
314+
"reconnect retries deferred facts",
315+
);
292316
const beforeTimer = pulses;
317+
const reportsBeforeTimer = reports;
293318
await React.act(async () => t.mock.timers.tick(60_000));
294319
await settle();
295320
assert.ok(pulses > beforeTimer, "bounded periodic publisher runs");
321+
assert.ok(reports > reportsBeforeTimer, "periodic retry survives deferral");
322+
assert.equal(publishedReports, 0, "deferred facts are not published");
323+
deferReport = false;
324+
await React.act(async () => t.mock.timers.tick(60_000));
325+
await settle();
326+
assert.equal(publishedReports, 1, "later preparation is published");
327+
assert.doesNotMatch(text(), /could not synchronize capability facts/);
296328
hold = true;
297329
await React.act(async () => {
298330
void client.refetchQueries({ queryKey: ["desktop-profiles"] });

0 commit comments

Comments
 (0)