@team-agent/installer 0.5.118 → 0.5.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
- //! Durable Enter-only recovery. A claimed/unknown job is never replayed as a paste.
1
+ //! Durable independent Enter-only watchdog. An uncertain effect is never replayed.
2
2
 
3
3
  use std::path::Path;
4
4
 
5
5
  use chrono::{DateTime, Duration, Utc};
6
- use rusqlite::{params, OptionalExtension};
6
+ use rusqlite::{params, OptionalExtension, Transaction};
7
7
  use serde::{Deserialize, Serialize};
8
8
  use sha2::{Digest, Sha256};
9
9
 
@@ -11,7 +11,7 @@ use super::delivery::{render_message, top_level_state_matches_owner_team};
11
11
  use super::MessagingError;
12
12
  use crate::event_log::EventLog;
13
13
  use crate::message_store::MessageStore;
14
- use crate::transport::native_input::{EnterRescueOutcome, NativeInputEvidence, NativeInputPhase};
14
+ use crate::transport::enter_rescue::{EnterRescueContext, EnterRescueOutcome};
15
15
  use crate::transport::Transport;
16
16
 
17
17
  #[cfg(test)]
@@ -23,7 +23,9 @@ const EXPIRE_SECONDS: i64 = 120;
23
23
 
24
24
  #[derive(Debug, Serialize, Deserialize)]
25
25
  struct RescueJob {
26
- evidence: NativeInputEvidence,
26
+ schema: u8,
27
+ context: EnterRescueContext,
28
+ registered_at: String,
27
29
  expires_at: String,
28
30
  }
29
31
 
@@ -31,105 +33,134 @@ fn digest(text: &str) -> String {
31
33
  format!("{:x}", Sha256::digest(text.as_bytes()))
32
34
  }
33
35
 
34
- fn expiration(job: &RescueJob) -> Result<DateTime<Utc>, MessagingError> {
35
- DateTime::parse_from_rfc3339(&job.expires_at)
36
+ fn timestamp(value: &str) -> Result<DateTime<Utc>, MessagingError> {
37
+ DateTime::parse_from_rfc3339(value)
36
38
  .map(|value| value.with_timezone(&Utc))
37
- .map_err(|_| MessagingError::Validation("enter_rescue: invalid expiry".into()))
39
+ .map_err(|_| MessagingError::Validation("enter_rescue: invalid timestamp".into()))
38
40
  }
39
41
 
40
- /// The claim consumed no physical attempt. Never use this after a paste intent.
41
- pub(super) fn release_no_effect(
42
- store: &MessageStore,
42
+ /// Called in the message-status transaction, even when no carrier/proof exists.
43
+ /// One original registration survives duplicates, unknown effects and restarts.
44
+ pub(crate) fn schedule_in_tx(
45
+ tx: &Transaction<'_>,
43
46
  message_id: &str,
44
- attempt: u32,
45
- ) -> Result<(), MessagingError> {
46
- let conn = crate::db::schema::open_db(store.db_path())?;
47
- conn.execute(
48
- "update messages set error='native_input_not_ready', delivery_attempts=delivery_attempts-1, updated_at=?3
49
- where message_id=?1 and status='target_resolved' and error is null and delivery_attempts=?2",
50
- params![message_id, attempt, Utc::now().to_rfc3339()],
51
- )?;
52
- Ok(())
53
- }
54
-
55
- /// Original uncertainty and its one job are committed together under the same CAS.
56
- pub(super) fn park(
57
- store: &MessageStore,
58
- evidence: &NativeInputEvidence,
59
- attempt: u32,
60
- reason: &str,
61
- log: &EventLog,
62
- ) -> Result<(), MessagingError> {
63
- park_at(store, evidence, attempt, reason, log, Utc::now())
64
- }
65
-
66
- fn park_at(
67
- store: &MessageStore,
68
- evidence: &NativeInputEvidence,
69
- attempt: u32,
70
- reason: &str,
71
- log: &EventLog,
72
47
  now: DateTime<Utc>,
73
- ) -> Result<(), MessagingError> {
74
- if !evidence.request.supported() || evidence.phase < NativeInputPhase::MayHavePasted {
75
- return Err(MessagingError::Validation(
76
- "enter_rescue: owned proof missing".into(),
77
- ));
78
- }
79
- let mut conn = crate::db::schema::open_db(store.db_path())?;
80
- let tx = conn.transaction()?;
81
- let changed = tx.execute(
82
- "update messages set status='submitted_unverified', error=?4, updated_at=?5
83
- where message_id=?1 and owner_team_id=?2 and recipient=?3
84
- and status='target_resolved' and error is null and delivery_attempts=?6",
85
- params![
86
- evidence.request.message_id,
87
- evidence.request.owner_team_id,
88
- evidence.request.recipient,
89
- reason,
90
- now.to_rfc3339(),
91
- attempt
92
- ],
93
- )?;
94
- if changed != 1 {
95
- return Err(MessagingError::Validation(
96
- "enter_rescue: claim changed".into(),
97
- ));
98
- }
48
+ ) -> Result<(), rusqlite::Error> {
49
+ let row = tx.query_row(
50
+ "select owner_team_id,coalesce(recipient,''),coalesce(sender,''),task_id,coalesce(content,'')
51
+ from messages where message_id=?1 and status='submitted_unverified'",
52
+ [message_id],
53
+ |row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, String>(1)?,
54
+ row.get::<_, String>(2)?, row.get::<_, Option<String>>(3)?, row.get::<_, String>(4)?)),
55
+ ).optional()?;
56
+ let Some((owner, recipient, sender, task, content)) = row else {
57
+ return Ok(());
58
+ };
99
59
  let job = RescueJob {
100
- evidence: evidence.clone(),
60
+ schema: 1,
61
+ context: EnterRescueContext {
62
+ message_id: message_id.into(),
63
+ owner_team_id: owner.clone().unwrap_or_default(),
64
+ recipient: recipient.clone(),
65
+ payload_sha256: digest(&render_message(
66
+ &sender,
67
+ task.as_deref(),
68
+ &content,
69
+ message_id,
70
+ )),
71
+ target: None,
72
+ proof: None,
73
+ },
74
+ registered_at: now.to_rfc3339(),
101
75
  expires_at: (now + Duration::seconds(EXPIRE_SECONDS)).to_rfc3339(),
102
76
  };
77
+ let payload = serde_json::to_string(&job)
78
+ .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
103
79
  tx.execute(
104
- "insert into scheduled_events(kind,target,payload_json,due_at,status,owner_team_id,created_at) values('enter_rescue',?1,?2,?3,'pending',?4,?5)",
105
- params![evidence.request.recipient, serde_json::to_string(&job)?,
106
- (now + Duration::seconds(DELAY_SECONDS)).to_rfc3339(), evidence.request.owner_team_id, now.to_rfc3339()],
80
+ "insert into scheduled_events(kind,target,payload_json,due_at,status,owner_team_id,created_at)
81
+ select 'enter_rescue',?1,?2,?3,'pending',?4,?5 where not exists(
82
+ select 1 from scheduled_events where kind='enter_rescue' and case when json_valid(payload_json)
83
+ then coalesce(json_extract(payload_json,'$.context.message_id'),json_extract(payload_json,'$.evidence.request.message_id')) end=?6)",
84
+ params![recipient, payload, (now + Duration::seconds(DELAY_SECONDS)).to_rfc3339(), owner,
85
+ now.to_rfc3339(), message_id],
107
86
  )?;
108
- tx.commit()?;
109
- log.write("enter_rescue.scheduled", serde_json::json!({
110
- "message_id": evidence.request.message_id, "recipient": evidence.request.recipient,
111
- "phase": evidence.phase, "delay_seconds": DELAY_SECONDS, "expire_seconds": EXPIRE_SECONDS,
112
- }))?;
113
87
  Ok(())
114
88
  }
115
89
 
90
+ /// Supplement the original job at the original delivery boundary, never at fire.
91
+ /// This cannot change its scope, message digest, initial clock, or a bound carrier.
92
+ pub(crate) fn bind_pending_context(
93
+ store: &MessageStore,
94
+ context: &EnterRescueContext,
95
+ ) -> Result<bool, MessagingError> {
96
+ let conn = crate::db::schema::open_db(store.db_path())?;
97
+ let row = conn.query_row(
98
+ "select id,payload_json from scheduled_events where kind='enter_rescue' and status='pending'
99
+ and case when json_valid(payload_json) then json_extract(payload_json,'$.context.message_id') end=?1",
100
+ [&context.message_id], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
101
+ ).optional()?;
102
+ let Some((id, previous)) = row else {
103
+ return Ok(false);
104
+ };
105
+ let mut job: RescueJob = serde_json::from_str(&previous)?;
106
+ if job.schema != 1
107
+ || job.context.message_id != context.message_id
108
+ || job.context.owner_team_id != context.owner_team_id
109
+ || job.context.recipient != context.recipient
110
+ || job.context.payload_sha256 != context.payload_sha256
111
+ || job.context.target.as_ref().is_some_and(|target| {
112
+ !context
113
+ .target
114
+ .as_ref()
115
+ .is_some_and(|next| target.same_selection(next))
116
+ || (target.bound() && job.context != *context)
117
+ })
118
+ {
119
+ return Ok(false);
120
+ }
121
+ job.context = context.clone();
122
+ Ok(conn.execute(
123
+ "update scheduled_events set payload_json=?2 where id=?1 and status='pending' and payload_json=?3
124
+ and exists(select 1 from messages where message_id=?4 and status='submitted_unverified'
125
+ and owner_team_id=?5 and recipient=?6)",
126
+ params![id, serde_json::to_string(&job)?, previous, context.message_id,
127
+ context.owner_team_id, context.recipient],
128
+ )? == 1)
129
+ }
130
+
116
131
  fn canonical_valid(
117
132
  workspace: &Path,
118
133
  store: &MessageStore,
119
134
  job: &RescueJob,
120
135
  ) -> Result<bool, MessagingError> {
121
- let evidence = &job.evidence;
122
- let request = &evidence.request;
123
- if Utc::now() > expiration(job)? || !request.supported() {
136
+ let context = &job.context;
137
+ let registered = timestamp(&job.registered_at)?;
138
+ let expires = timestamp(&job.expires_at)?;
139
+ let now = Utc::now();
140
+ if job.schema != 1
141
+ || !context.valid_message()
142
+ || expires != registered + Duration::seconds(EXPIRE_SECONDS)
143
+ || now < registered + Duration::seconds(DELAY_SECONDS)
144
+ || now > expires
145
+ {
124
146
  return Ok(false);
125
147
  }
126
148
  let conn = crate::db::schema::open_db(store.db_path())?;
127
- let original = conn.query_row(
128
- "select sender, task_id, content, created_at from messages
129
- where message_id=?1 and owner_team_id=?2 and recipient=?3 and status='submitted_unverified'",
130
- params![request.message_id, request.owner_team_id, request.recipient],
131
- |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?)),
132
- ).optional()?;
149
+ let original = conn
150
+ .query_row(
151
+ "select sender,task_id,content,created_at from messages where message_id=?1
152
+ and owner_team_id=?2 and recipient=?3 and status='submitted_unverified'",
153
+ params![context.message_id, context.owner_team_id, context.recipient],
154
+ |row| {
155
+ Ok((
156
+ row.get::<_, String>(0)?,
157
+ row.get::<_, Option<String>>(1)?,
158
+ row.get::<_, String>(2)?,
159
+ row.get::<_, String>(3)?,
160
+ ))
161
+ },
162
+ )
163
+ .optional()?;
133
164
  let Some((sender, task, content, created_at)) = original else {
134
165
  return Ok(false);
135
166
  };
@@ -137,8 +168,8 @@ fn canonical_valid(
137
168
  &sender,
138
169
  task.as_deref(),
139
170
  &content,
140
- &request.message_id,
141
- )) != evidence.payload_sha256
171
+ &context.message_id,
172
+ )) != context.payload_sha256
142
173
  {
143
174
  return Ok(false);
144
175
  }
@@ -146,9 +177,9 @@ fn canonical_valid(
146
177
  "select exists(select 1 from messages where owner_team_id=?1 and recipient=?2
147
178
  and message_id<>?3 and created_at>=?4 and delivery_attempts>0)",
148
179
  params![
149
- request.owner_team_id,
150
- request.recipient,
151
- request.message_id,
180
+ context.owner_team_id,
181
+ context.recipient,
182
+ context.message_id,
152
183
  created_at
153
184
  ],
154
185
  |row| row.get(0),
@@ -157,56 +188,59 @@ fn canonical_valid(
157
188
  return Ok(false);
158
189
  }
159
190
  let raw = crate::state::persist::load_runtime_state(workspace)?;
160
- if !matches!(crate::state::projection::resolve_owner_team_id(&raw, &request.owner_team_id),
161
- crate::state::projection::OwnerTeamResolution::Canonical(team) if team == request.owner_team_id)
191
+ if !matches!(crate::state::projection::resolve_owner_team_id(&raw, &context.owner_team_id),
192
+ crate::state::projection::OwnerTeamResolution::Canonical(team) if team == context.owner_team_id)
162
193
  {
163
194
  return Ok(false);
164
195
  }
165
196
  let state = if raw
166
197
  .get("teams")
167
198
  .and_then(serde_json::Value::as_object)
168
- .is_some_and(|teams| teams.contains_key(&request.owner_team_id))
199
+ .is_some_and(|teams| teams.contains_key(&context.owner_team_id))
169
200
  {
170
- crate::state::projection::project_top_level_view(&raw, &request.owner_team_id)
171
- } else if top_level_state_matches_owner_team(&raw, &request.owner_team_id) {
201
+ crate::state::projection::project_top_level_view(&raw, &context.owner_team_id)
202
+ } else if top_level_state_matches_owner_team(&raw, &context.owner_team_id) {
172
203
  raw
173
204
  } else {
174
205
  return Ok(false);
175
206
  };
176
207
  let Some(agent) = state
177
208
  .get("agents")
178
- .and_then(|agents| agents.get(&request.recipient))
209
+ .and_then(|agents| agents.get(&context.recipient))
179
210
  else {
180
211
  return Ok(false);
181
212
  };
213
+ if agent.get("status").and_then(serde_json::Value::as_str) != Some("running")
214
+ || agent
215
+ .get("explicit_stop")
216
+ .and_then(serde_json::Value::as_bool)
217
+ == Some(true)
218
+ {
219
+ return Ok(false);
220
+ }
221
+ // An unbound job is still an observable scheduled job, but never key authority.
222
+ let Some(target) = context.target.as_ref() else {
223
+ return Ok(true);
224
+ };
182
225
  Ok(
183
- agent.get("status").and_then(serde_json::Value::as_str) == Some("running")
184
- && agent
185
- .get("explicit_stop")
186
- .and_then(serde_json::Value::as_bool)
187
- != Some(true)
188
- && agent.get("pane_id").and_then(serde_json::Value::as_str)
189
- == Some(request.pane_id.as_str())
226
+ agent.get("pane_id").and_then(serde_json::Value::as_str) == Some(target.pane_id.as_str())
190
227
  && agent.get("pane_pid").and_then(serde_json::Value::as_u64)
191
- == Some(u64::from(request.pane_pid))
192
- && request.pane_pid == evidence.binding.pane_pid
228
+ == Some(u64::from(target.pane_pid))
193
229
  && state
194
230
  .get("session_name")
195
231
  .and_then(serde_json::Value::as_str)
196
- == Some(request.session.as_str())
232
+ == Some(target.session.as_str())
197
233
  && agent
198
234
  .get("window")
199
235
  .and_then(serde_json::Value::as_str)
200
- .unwrap_or(&request.recipient)
201
- == request.window
236
+ .unwrap_or(&context.recipient)
237
+ == target.window
202
238
  && agent.get("provider").cloned().and_then(|value| {
203
239
  serde_json::from_value::<crate::model::enums::Provider>(value).ok()
204
- }) == Some(request.provider),
240
+ }) == Some(target.provider),
205
241
  )
206
242
  }
207
243
 
208
- /// Called only for due jobs. CAS precedes every possible effect; stale `claimed`
209
- /// rows intentionally remain unknown after a crash, rather than becoming runnable.
210
244
  pub(super) fn fire(
211
245
  workspace: &Path,
212
246
  store: &MessageStore,
@@ -215,56 +249,39 @@ pub(super) fn fire(
215
249
  id: i64,
216
250
  payload: &str,
217
251
  ) -> Result<bool, MessagingError> {
218
- fire_with(
219
- workspace,
220
- store,
221
- log,
222
- id,
223
- payload,
224
- &|evidence, authorize| {
225
- super::delivery::with_rescue_submit_observer(
226
- workspace,
227
- store,
228
- evidence,
229
- log,
230
- |observer| {
231
- transport
232
- .rescue_enter(evidence, authorize, Some(observer))
233
- .map_err(Into::into)
234
- },
235
- )
236
- },
237
- )
252
+ fire_with(workspace, store, log, id, payload, &|context, authorize| {
253
+ super::delivery::with_rescue_submit_observer(workspace, store, context, log, |observer| {
254
+ transport
255
+ .rescue_enter_guarded(context, authorize, Some(observer))
256
+ .map_err(Into::into)
257
+ })
258
+ })
238
259
  }
239
260
 
261
+ type Executor<'a> = dyn Fn(&EnterRescueContext, &dyn Fn() -> bool) -> Result<EnterRescueOutcome, MessagingError>
262
+ + 'a;
263
+
240
264
  fn fire_with(
241
265
  workspace: &Path,
242
266
  store: &MessageStore,
243
267
  log: &EventLog,
244
268
  id: i64,
245
269
  payload: &str,
246
- execute: &dyn Fn(
247
- &NativeInputEvidence,
248
- &dyn Fn() -> bool,
249
- ) -> Result<EnterRescueOutcome, MessagingError>,
270
+ execute: &Executor<'_>,
250
271
  ) -> Result<bool, MessagingError> {
251
272
  let conn = crate::db::schema::open_db(store.db_path())?;
252
273
  if conn.execute(
253
- "update scheduled_events set status='claimed', fired_at=?2
254
- where id=?1 and kind='enter_rescue' and status='pending' and due_at<=?2 and payload_json=?3",
274
+ "update scheduled_events set status='claimed',fired_at=?2 where id=?1 and kind='enter_rescue'
275
+ and status='pending' and due_at<=?2 and payload_json=?3",
255
276
  params![id, Utc::now().to_rfc3339(), payload],
256
277
  )? != 1 { return Ok(false); }
257
- log.write("enter_rescue.claimed", serde_json::json!({"event_id": id}))?;
278
+ log.write("enter_rescue.claimed", serde_json::json!({"event_id":id}))?;
258
279
  let result = (|| -> Result<(String, serde_json::Value, Option<String>), MessagingError> {
259
280
  let job: RescueJob = serde_json::from_str(payload)?;
281
+ let context = &job.context;
260
282
  let scope_matches: bool = conn.query_row(
261
- "select owner_team_id=?2 and target=?3 from scheduled_events where id=?1",
262
- params![
263
- id,
264
- job.evidence.request.owner_team_id,
265
- job.evidence.request.recipient
266
- ],
267
- |row| row.get(0),
283
+ "select coalesce(owner_team_id,'')=?2 and target=?3 and created_at=?4 from scheduled_events where id=?1",
284
+ params![id, context.owner_team_id, context.recipient, job.registered_at], |row| row.get(0),
268
285
  )?;
269
286
  if !scope_matches {
270
287
  return Ok((
@@ -280,12 +297,26 @@ fn fire_with(
280
297
  None,
281
298
  ));
282
299
  }
283
- let authorize = || canonical_valid(workspace, store, &job).unwrap_or(false);
284
- let outcome = execute(&job.evidence, &authorize)?;
285
- match outcome {
300
+ if !context.target.as_ref().is_some_and(|target| target.bound()) || !context.valid_proof() {
301
+ return Ok((
302
+ "refused".into(),
303
+ serde_json::json!({"reason":"proof_missing","effect":"none"}),
304
+ None,
305
+ ));
306
+ }
307
+ let authorize = || {
308
+ let still_claimed: bool = conn.query_row(
309
+ "select exists(select 1 from scheduled_events where id=?1 and kind='enter_rescue'
310
+ and status='claimed' and payload_json=?2 and owner_team_id=?3 and target=?4)",
311
+ params![id, payload, context.owner_team_id, context.recipient], |row| row.get(0),
312
+ ).unwrap_or(false);
313
+ still_claimed && canonical_valid(workspace, store, &job).unwrap_or(false)
314
+ };
315
+ match execute(context, &authorize)? {
286
316
  EnterRescueOutcome::NoEffect { reason, reobserve } => {
287
317
  let next = Utc::now() + Duration::seconds(5);
288
- let due = (reobserve && next <= expiration(&job)?).then(|| next.to_rfc3339());
318
+ let due =
319
+ (reobserve && next <= timestamp(&job.expires_at)?).then(|| next.to_rfc3339());
289
320
  Ok((
290
321
  if due.is_some() { "pending" } else { "refused" }.into(),
291
322
  serde_json::json!({"reason":reason,"effect":"none"}),
@@ -293,12 +324,10 @@ fn fire_with(
293
324
  ))
294
325
  }
295
326
  EnterRescueOutcome::Consumed | EnterRescueOutcome::Entered { accepted: true } => {
296
- // Row-level CAS cannot downgrade an acknowledgement/other terminal owner.
297
327
  let changed = conn.execute(
298
328
  "update messages set status='delivered',delivered_at=?2,updated_at=?2,error=null
299
329
  where message_id=?1 and status='submitted_unverified' and owner_team_id=?3 and recipient=?4",
300
- params![job.evidence.request.message_id, Utc::now().to_rfc3339(),
301
- job.evidence.request.owner_team_id, job.evidence.request.recipient],
330
+ params![context.message_id, Utc::now().to_rfc3339(), context.owner_team_id, context.recipient],
302
331
  )?;
303
332
  Ok((
304
333
  "done".into(),
@@ -313,14 +342,13 @@ fn fire_with(
313
342
  )),
314
343
  }
315
344
  })();
316
- let (status, result, due) = match result {
317
- Ok(result) => result,
318
- Err(error) => (
345
+ let (status, result, due) = result.unwrap_or_else(|error| {
346
+ (
319
347
  "unknown".into(),
320
348
  serde_json::json!({"error":error.to_string(),"retry":false}),
321
349
  None,
322
- ),
323
- };
350
+ )
351
+ });
324
352
  conn.execute(
325
353
  "update scheduled_events set status=?2,result_json=?3,due_at=coalesce(?4,due_at)
326
354
  where id=?1 and kind='enter_rescue' and status='claimed'",