1use std::collections::BTreeMap;
2use std::time::SystemTime;
3
4use serde::{Deserialize, Serialize};
5
6use super::model::{ProcessId, RecoveryDisposition, SessionScope, SessionScopeId};
7use super::validation::process_event_payload_hash;
8
9#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
10pub struct ProcessEventType {
11 pub name: String,
12 pub payload_schema: crate::LashSchema,
13 pub semantics: ProcessEventSemanticsSpec,
14}
15
16#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
17pub struct ProcessEventSemanticsSpec {
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub terminal: Option<ProcessTerminalSpec>,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub wake: Option<ProcessWakeSpec>,
22}
23
24#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
25pub struct ProcessTerminalSpec {
26 pub state: ProcessTerminalState,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub await_output: Option<ProcessValueSelector>,
29}
30
31#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
32pub struct ProcessWakeSpec {
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub when: Option<ProcessValueSelector>,
35 pub input: ProcessValueSelector,
36 #[serde(default)]
37 pub dedupe_key: ProcessWakeDedupeKey,
38}
39
40#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum ProcessWakeDedupeKey {
43 #[default]
44 EventIdentity,
45 Selector(ProcessValueSelector),
46 Const(String),
47}
48
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum ProcessValueSelector {
52 Payload,
53 Pointer(String),
54 Const(serde_json::Value),
55 Template {
56 template: String,
57 #[serde(default)]
58 fields: BTreeMap<String, ProcessValueSelector>,
59 },
60 Present(String),
61}
62
63#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
64pub struct ProcessEventSemantics {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub terminal: Option<ProcessTerminalSemantics>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub wake: Option<ProcessWake>,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum ProcessTerminalState {
74 Completed,
75 Failed,
76 Cancelled,
77 Abandoned,
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum AbandonWriter {
88 OwnerDrain,
91 Sweep,
94 ReconciledRequest,
97}
98
99#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
103pub struct AbandonEvidence {
104 pub writer: AbandonWriter,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub owner: Option<crate::LeaseOwnerIdentity>,
107 pub epoch_ms: u64,
108}
109
110#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(tag = "authority", rename_all = "snake_case")]
129pub enum ProcessCompletionAuthority {
130 ExternalOwner { granted_to: String },
136 WorkflowKey { workflow_key: String },
145 ReconciledAbandon,
152}
153
154impl ProcessCompletionAuthority {
155 pub fn external_owner(granted_to: impl Into<String>) -> Self {
158 Self::ExternalOwner {
159 granted_to: granted_to.into(),
160 }
161 }
162
163 pub fn workflow_key(workflow_key: impl Into<String>) -> Self {
166 Self::WorkflowKey {
167 workflow_key: workflow_key.into(),
168 }
169 }
170
171 pub fn label(&self) -> &'static str {
173 match self {
174 Self::ExternalOwner { .. } => "external-owner",
175 Self::WorkflowKey { .. } => "workflow-key",
176 Self::ReconciledAbandon => "reconciled-abandon",
177 }
178 }
179
180 pub fn validate(
187 &self,
188 process_id: &str,
189 disposition: RecoveryDisposition,
190 await_output: &ProcessAwaitOutput,
191 ) -> Result<(), crate::PluginError> {
192 let reject = |reason: &str| {
193 Err(crate::PluginError::Session(format!(
194 "process `{process_id}` cannot be completed with {} authority: {reason}",
195 self.label()
196 )))
197 };
198 match self {
199 Self::ExternalOwner { .. } => {
200 if disposition != RecoveryDisposition::ExternallyOwned {
201 return reject(
202 "only externally-owned rows may be completed by an external owner; a \
203 lash-executed row has a lease-fenced single writer",
204 );
205 }
206 }
207 Self::WorkflowKey { .. } => {
208 if disposition == RecoveryDisposition::ExternallyOwned {
209 return reject(
210 "externally-owned rows are never executed by a workflow substrate; they \
211 close through their external owner or a reconciled abandon request",
212 );
213 }
214 }
215 Self::ReconciledAbandon => {
216 if disposition != RecoveryDisposition::ExternallyOwned {
217 return reject(
218 "reconciled-abandon closes only externally-owned rows; a lash-executed \
219 row is abandoned under its lease",
220 );
221 }
222 if await_output.terminal_state() != ProcessTerminalState::Abandoned {
223 return reject("reconciled-abandon writes only an Abandoned terminal");
224 }
225 }
226 }
227 Ok(())
228 }
229}
230
231pub fn terminal_event_type_name(state: ProcessTerminalState) -> &'static str {
233 match state {
234 ProcessTerminalState::Completed => "process.completed",
235 ProcessTerminalState::Failed => "process.failed",
236 ProcessTerminalState::Cancelled => "process.cancelled",
237 ProcessTerminalState::Abandoned => "process.abandoned",
238 }
239}
240
241pub fn terminal_append_request(
252 process_id: &str,
253 await_output: &ProcessAwaitOutput,
254 authority: Option<&ProcessCompletionAuthority>,
255) -> ProcessEventAppendRequest {
256 let event_type = terminal_event_type_name(await_output.terminal_state());
257 let mut payload = serde_json::json!({ "await_output": await_output });
258 if let Some(authority) = authority {
259 payload["completion_authority"] =
260 serde_json::to_value(authority).expect("completion authority serializes");
261 }
262 ProcessEventAppendRequest::new(event_type, payload)
263 .with_replay_key(format!("process:{process_id}:terminal:{event_type}"))
264}
265
266#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
267pub struct ProcessTerminalSemantics {
268 pub state: ProcessTerminalState,
269 pub await_output: ProcessAwaitOutput,
270}
271
272#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
273#[serde(tag = "type", rename_all = "snake_case")]
274pub enum ProcessAwaitOutput {
275 Success {
276 value: serde_json::Value,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 control: Option<crate::ToolControl>,
279 },
280 Failure {
281 class: crate::ToolFailureClass,
282 code: String,
283 message: String,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
285 raw: Option<serde_json::Value>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 control: Option<crate::ToolControl>,
288 },
289 Cancelled {
290 message: String,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 raw: Option<serde_json::Value>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 control: Option<crate::ToolControl>,
295 },
296 Abandoned {
302 evidence: Box<AbandonEvidence>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 control: Option<crate::ToolControl>,
305 },
306}
307
308impl ProcessAwaitOutput {
309 pub fn terminal_state(&self) -> ProcessTerminalState {
310 match self {
311 Self::Success { .. } => ProcessTerminalState::Completed,
312 Self::Failure { .. } => ProcessTerminalState::Failed,
313 Self::Cancelled { .. } => ProcessTerminalState::Cancelled,
314 Self::Abandoned { .. } => ProcessTerminalState::Abandoned,
315 }
316 }
317
318 pub fn from_tool_output(output: crate::ToolCallOutput) -> Self {
319 let control = output.control;
320 match output.outcome {
321 crate::ToolCallOutcome::Success(value) => Self::Success {
322 value: value.to_json_value(),
323 control,
324 },
325 crate::ToolCallOutcome::Failure(failure) => Self::Failure {
326 class: failure.class,
327 code: failure.code,
328 message: failure.message,
329 raw: failure.raw.map(|value| value.to_json_value()),
330 control,
331 },
332 crate::ToolCallOutcome::Cancelled(cancellation) => Self::Cancelled {
333 message: cancellation.message,
334 raw: cancellation.raw.map(|value| value.to_json_value()),
335 control,
336 },
337 }
338 }
339
340 pub fn into_tool_output(self) -> crate::ToolCallOutput {
341 match self {
342 Self::Success { value, control } => {
343 let mut output = crate::ToolCallOutput::success(value);
344 output.control = control;
345 output
346 }
347 Self::Failure {
348 class,
349 code,
350 message,
351 raw,
352 control,
353 } => {
354 let mut failure = crate::ToolFailure::tool(class, code, message);
355 failure.raw = raw.map(crate::ToolValue::from);
356 let mut output = crate::ToolCallOutput::failure(failure);
357 output.control = control;
358 output
359 }
360 Self::Cancelled {
361 message,
362 raw,
363 control,
364 } => {
365 let mut cancellation = crate::ToolCancellation::runtime(message);
366 cancellation.raw = raw.map(crate::ToolValue::from);
367 let mut output = crate::ToolCallOutput::cancelled(cancellation);
368 output.control = control;
369 output
370 }
371 Self::Abandoned { evidence, control } => {
377 let raw = serde_json::to_value(&evidence)
378 .ok()
379 .map(crate::ToolValue::from);
380 let message = match evidence.writer {
381 AbandonWriter::OwnerDrain => {
382 "process abandoned: owner drained without recording an outcome".to_string()
383 }
384 AbandonWriter::Sweep => {
385 "process abandoned: recovery observed the owner provably dead".to_string()
386 }
387 AbandonWriter::ReconciledRequest => {
388 "process abandoned: reconciled abandon request after the lease lapsed"
389 .to_string()
390 }
391 };
392 let mut failure = crate::ToolFailure::tool(
393 crate::ToolFailureClass::External,
394 "process_abandoned",
395 message,
396 );
397 failure.raw = raw;
398 let mut output = crate::ToolCallOutput::failure(failure);
399 output.control = control;
400 output
401 }
402 }
403 }
404}
405
406#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
407pub struct ProcessWake {
408 pub input: String,
409 pub dedupe_key: String,
410}
411
412pub fn process_signal_event_type(signal_name: &str) -> Result<String, crate::PluginError> {
413 validate_process_signal_name(signal_name)?;
414 Ok(format!("signal.{signal_name}"))
415}
416
417pub fn process_signal_name_from_event_type(event_type: &str) -> Option<&str> {
418 event_type.strip_prefix("signal.")
419}
420
421pub fn process_signal_wait_key(process_id: &str, signal_name: &str, ordinal: u64) -> String {
422 format!("process:{process_id}:signal.{signal_name}:{ordinal}")
423}
424
425pub fn validate_process_signal_name(signal_name: &str) -> Result<(), crate::PluginError> {
426 let valid = !signal_name.is_empty()
427 && signal_name
428 .chars()
429 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-');
430 if valid {
431 Ok(())
432 } else {
433 Err(crate::PluginError::Session(format!(
434 "process signal name must be non-empty and contain only ASCII letters, digits, `_`, or `-`, got `{signal_name}`"
435 )))
436 }
437}
438
439#[derive(Clone, Debug, Serialize, Deserialize)]
440pub struct ProcessEvent {
441 pub process_id: ProcessId,
442 pub sequence: u64,
443 pub event_type: String,
444 pub payload: serde_json::Value,
445 pub invocation: crate::RuntimeInvocation,
446 pub semantics: ProcessEventSemantics,
447 pub occurred_at: SystemTime,
448}
449
450#[derive(Clone, Debug, Serialize, Deserialize)]
451pub struct ProcessEventAppendResult {
452 pub event: ProcessEvent,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub wake_delivery: Option<ProcessWakeDelivery>,
455}
456
457#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
458pub struct ProcessEventAppendRequest {
459 pub event_type: String,
460 pub payload: serde_json::Value,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub replay: Option<crate::RuntimeReplay>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub wake_target_scope: Option<SessionScope>,
465}
466
467impl ProcessEventAppendRequest {
468 pub fn new(event_type: impl Into<String>, payload: serde_json::Value) -> Self {
469 Self {
470 event_type: event_type.into(),
471 payload,
472 replay: None,
473 wake_target_scope: None,
474 }
475 }
476
477 pub fn with_replay_key(mut self, replay_key: impl Into<String>) -> Self {
478 self.replay = Some(crate::RuntimeReplay {
479 key: replay_key.into(),
480 });
481 self
482 }
483
484 pub fn with_optional_replay(mut self, replay: Option<crate::RuntimeReplay>) -> Self {
485 self.replay = replay;
486 self
487 }
488
489 pub fn with_wake_target_scope(mut self, scope: SessionScope) -> Self {
490 self.wake_target_scope = Some(scope);
491 self
492 }
493
494 pub fn with_optional_wake_target_scope(mut self, scope: Option<SessionScope>) -> Self {
495 self.wake_target_scope = scope;
496 self
497 }
498
499 pub fn cancel_requested(process_id: &str, reason: Option<String>) -> Self {
500 let payload = serde_json::json!({
501 "reason": reason,
502 });
503 let replay_key = process_event_payload_hash("process.cancel_requested", &payload)
504 .unwrap_or_else(|_| format!("process:{process_id}:cancel_requested"));
505 Self::new("process.cancel_requested", payload).with_replay_key(format!(
506 "process:{process_id}:cancel_requested:{replay_key}"
507 ))
508 }
509}
510
511#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
512pub struct ProcessWakeDelivery {
513 pub wake_id: String,
514 pub target_session_id: String,
515 pub target_scope_id: SessionScopeId,
516 pub process_id: ProcessId,
517 pub sequence: u64,
518 #[serde(default = "default_process_wake_event_type")]
519 pub event_type: String,
520 #[serde(default = "default_process_wake_event_invocation")]
521 pub event_invocation: crate::RuntimeInvocation,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub process_caused_by: Option<crate::CausalRef>,
524 pub dedupe_key: String,
525 pub input: String,
526 pub created_at_ms: u64,
527}
528
529fn default_process_wake_event_type() -> String {
530 "process.wake".to_string()
531}
532
533fn default_process_wake_event_invocation() -> crate::RuntimeInvocation {
534 crate::RuntimeInvocation {
535 scope: crate::RuntimeScope::new(""),
536 subject: crate::RuntimeSubject::ProcessEvent {
537 process_id: String::new(),
538 sequence: 0,
539 event_type: default_process_wake_event_type(),
540 },
541 caused_by: None,
542 replay: None,
543 }
544}
545
546pub(super) fn default_process_event_types() -> Vec<ProcessEventType> {
547 vec![
548 ProcessEventType {
549 name: "process.cancel_requested".to_string(),
550 payload_schema: crate::LashSchema::any(),
551 semantics: ProcessEventSemanticsSpec::default(),
552 },
553 ProcessEventType {
554 name: "process.waiting".to_string(),
555 payload_schema: crate::LashSchema::any(),
556 semantics: ProcessEventSemanticsSpec::default(),
557 },
558 ProcessEventType {
559 name: "process.resumed".to_string(),
560 payload_schema: crate::LashSchema::any(),
561 semantics: ProcessEventSemanticsSpec::default(),
562 },
563 terminal_event_type("process.completed", ProcessTerminalState::Completed),
564 terminal_event_type("process.failed", ProcessTerminalState::Failed),
565 terminal_event_type("process.cancelled", ProcessTerminalState::Cancelled),
566 terminal_event_type("process.abandoned", ProcessTerminalState::Abandoned),
567 ]
568}
569
570fn terminal_event_type(name: &str, state: ProcessTerminalState) -> ProcessEventType {
571 ProcessEventType {
572 name: name.to_string(),
573 payload_schema: crate::LashSchema::any(),
574 semantics: ProcessEventSemanticsSpec {
575 terminal: Some(ProcessTerminalSpec {
576 state,
577 await_output: Some(ProcessValueSelector::Pointer("/await_output".to_string())),
578 }),
579 ..ProcessEventSemanticsSpec::default()
580 },
581 }
582}