lash-core 0.1.0-alpha.89

Sans-IO turn machine and runtime kernel for the lash agent runtime.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
use super::*;
use crate::{
    AttachmentStore, AttachmentStoreError, AttachmentStorePersistence, DurabilityTier,
    DurableStoreFacet, InMemoryAttachmentStore, ProcessExecutionEnvRef, ProcessExecutionEnvStore,
    ProcessInput, ProcessRegistration, RuntimeEffectController, RuntimeError, StoredAttachment,
    TriggerStore,
};
use lash_sansio::{AttachmentCreateMeta, AttachmentId, AttachmentRef};

/// Effect controller that reports the durable tier; the worker boundary
/// only reads the tier, so the effect path is never exercised here.
#[derive(Default)]
struct DurableController;

impl crate::AwaitEventResolver for DurableController {
    fn durability_tier(&self) -> DurabilityTier {
        DurabilityTier::Durable
    }
}

#[async_trait::async_trait]
impl RuntimeEffectController for DurableController {
    async fn execute_effect(
        &self,
        _envelope: crate::RuntimeEffectEnvelope,
        _local_executor: crate::RuntimeEffectLocalExecutor<'_>,
    ) -> Result<crate::RuntimeEffectOutcome, crate::RuntimeEffectControllerError> {
        unreachable!("worker boundary rejects before executing any effect")
    }
}

/// Attachment store reporting a durable tier over in-memory storage.
#[derive(Default)]
struct DurableAttachmentStore {
    inner: InMemoryAttachmentStore,
}

#[async_trait::async_trait]
impl AttachmentStore for DurableAttachmentStore {
    fn persistence(&self) -> AttachmentStorePersistence {
        AttachmentStorePersistence::Durable
    }

    async fn put(
        &self,
        bytes: Vec<u8>,
        meta: AttachmentCreateMeta,
    ) -> Result<AttachmentRef, AttachmentStoreError> {
        self.inner.put(bytes, meta).await
    }

    async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
        self.inner.get(id).await
    }

    async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
        self.inner.delete(id).await
    }

    async fn list(&self) -> Result<Vec<crate::StoredBlobRef>, AttachmentStoreError> {
        self.inner.list().await
    }
}

/// Process env store reporting a durable tier over in-memory storage.
#[derive(Default)]
struct DurableProcessEnvStore {
    inner: crate::InMemoryProcessExecutionEnvStore,
}

#[async_trait::async_trait]
impl ProcessExecutionEnvStore for DurableProcessEnvStore {
    fn durability_tier(&self) -> DurabilityTier {
        DurabilityTier::Durable
    }

    async fn put_process_execution_env(
        &self,
        env_ref: &ProcessExecutionEnvRef,
        bytes: &[u8],
    ) -> Result<(), PluginError> {
        self.inner.put_process_execution_env(env_ref, bytes).await
    }

    async fn get_process_execution_env(
        &self,
        env_ref: &ProcessExecutionEnvRef,
    ) -> Result<Option<Vec<u8>>, PluginError> {
        self.inner.get_process_execution_env(env_ref).await
    }
}

/// Session store factory whose declared tier is configurable; it never has
/// to create a store because the worker boundary rejects first.
struct TierSessionStoreFactory {
    tier: DurabilityTier,
}

#[async_trait::async_trait]
impl SessionStoreFactory for TierSessionStoreFactory {
    fn durability_tier(&self) -> DurabilityTier {
        self.tier
    }

    async fn create_store(
        &self,
        _request: &crate::SessionStoreCreateRequest,
    ) -> Result<Arc<dyn crate::RuntimePersistence>, String> {
        unreachable!("worker boundary rejects before creating a session store")
    }

    async fn delete_session(&self, _session_id: &str) -> Result<(), String> {
        Ok(())
    }
}

struct TierTriggerStore {
    tier: DurabilityTier,
    inner: crate::InMemoryTriggerStore,
}

impl TierTriggerStore {
    fn new(tier: DurabilityTier) -> Self {
        Self {
            tier,
            inner: crate::InMemoryTriggerStore::default(),
        }
    }
}

#[async_trait::async_trait]
impl TriggerStore for TierTriggerStore {
    fn durability_tier(&self) -> DurabilityTier {
        self.tier
    }

    async fn register_subscription(
        &self,
        draft: crate::TriggerSubscriptionDraft,
    ) -> Result<crate::TriggerSubscriptionRecord, PluginError> {
        self.inner.register_subscription(draft).await
    }

    async fn list_subscriptions(
        &self,
        filter: crate::TriggerSubscriptionFilter,
    ) -> Result<Vec<crate::TriggerSubscriptionRecord>, PluginError> {
        self.inner.list_subscriptions(filter).await
    }

    async fn cancel_subscription(
        &self,
        registrant_scope_id: &str,
        handle: &str,
    ) -> Result<bool, PluginError> {
        self.inner
            .cancel_subscription(registrant_scope_id, handle)
            .await
    }

    async fn delete_session_subscriptions(&self, session_id: &str) -> Result<usize, PluginError> {
        self.inner.delete_session_subscriptions(session_id).await
    }

    async fn record_occurrence(
        &self,
        request: crate::TriggerOccurrenceRequest,
    ) -> Result<crate::TriggerOccurrenceRecord, PluginError> {
        self.inner.record_occurrence(request).await
    }

    async fn list_occurrences(
        &self,
        filter: crate::TriggerOccurrenceFilter,
    ) -> Result<Vec<crate::TriggerOccurrenceRecord>, PluginError> {
        self.inner.list_occurrences(filter).await
    }

    async fn reserve_matching_deliveries(
        &self,
        occurrence_id: &str,
    ) -> Result<Vec<crate::TriggerDeliveryReservation>, PluginError> {
        self.inner.reserve_matching_deliveries(occurrence_id).await
    }

    async fn list_deliveries_by_occurrence_id(
        &self,
        occurrence_id: &str,
    ) -> Result<Vec<crate::TriggerDeliveryReservation>, PluginError> {
        self.inner
            .list_deliveries_by_occurrence_id(occurrence_id)
            .await
    }

    async fn list_deliveries_by_subscription_id(
        &self,
        subscription_id: &str,
    ) -> Result<Vec<crate::TriggerDeliveryReservation>, PluginError> {
        self.inner
            .list_deliveries_by_subscription_id(subscription_id)
            .await
    }

    async fn list_deliveries_by_process_id(
        &self,
        process_id: &str,
    ) -> Result<Vec<crate::TriggerDeliveryReservation>, PluginError> {
        self.inner.list_deliveries_by_process_id(process_id).await
    }
}

/// Build a worker whose controller is durable but whose stores can be set
/// per-facet to durable/ephemeral, so each facet's loud rejection can be
/// exercised independently.
fn worker(
    attachment: Arc<dyn AttachmentStore>,
    process_env_store: Arc<dyn ProcessExecutionEnvStore>,
    session_store_tier: DurabilityTier,
) -> DurableProcessWorker {
    worker_with_store_tiers(
        attachment,
        process_env_store,
        session_store_tier,
        DurabilityTier::Durable,
        DurabilityTier::Durable,
    )
}

fn worker_with_store_tiers(
    attachment: Arc<dyn AttachmentStore>,
    process_env_store: Arc<dyn ProcessExecutionEnvStore>,
    session_store_tier: DurabilityTier,
    process_registry_tier: DurabilityTier,
    trigger_store_tier: DurabilityTier,
) -> DurableProcessWorker {
    let mut runtime_host = RuntimeHostConfig::in_memory();
    runtime_host.control.effect_host =
        Arc::new(crate::InlineEffectHost::new(Arc::new(DurableController)));
    runtime_host.durability.attachment_store =
        Arc::new(crate::SessionAttachmentStore::ephemeral(attachment));
    runtime_host.durability.process_env_store = process_env_store;
    let plugin_host = Arc::new(crate::PluginHost::new(Vec::new()));
    let factory: Arc<dyn SessionStoreFactory> = Arc::new(TierSessionStoreFactory {
        tier: session_store_tier,
    });
    let registry: Arc<dyn ProcessRegistry> = Arc::new(
        crate::TestLocalProcessRegistry::default().with_durability_tier(process_registry_tier),
    );
    let trigger_store: Arc<dyn TriggerStore> = Arc::new(TierTriggerStore::new(trigger_store_tier));
    DurableProcessWorker::new(
        DurableProcessWorkerConfig::new(plugin_host, runtime_host, factory, registry)
            .with_trigger_store(trigger_store),
    )
}

fn external_registration() -> ProcessRegistration {
    ProcessRegistration::new(
        "worker-boundary-process",
        ProcessInput::External {
            metadata: serde_json::json!({}),
        },
        crate::RecoveryDisposition::ExternallyOwned,
        crate::ProcessProvenance::host(),
    )
}

async fn run(worker: &DurableProcessWorker) -> Result<ProcessAwaitOutput, PluginError> {
    worker
        .run_process(
            external_registration(),
            ProcessExecutionContext::default(),
            CancellationToken::new(),
        )
        .await
}

fn assert_facet(err: PluginError, facet: DurableStoreFacet) {
    let PluginError::Session(message) = err else {
        panic!("expected PluginError::Session, got {err:?}");
    };
    let expected = RuntimeError::durable_store_required(facet).to_string();
    assert_eq!(message, expected, "worker must reject the {facet:?} facet");
}

#[tokio::test]
async fn durable_worker_rejects_ephemeral_attachment_store() {
    let worker = worker(
        Arc::new(InMemoryAttachmentStore::new()),
        Arc::new(DurableProcessEnvStore::default()),
        DurabilityTier::Durable,
    );
    let err = run(&worker)
        .await
        .expect_err("ephemeral attachment store must be rejected at the worker boundary");
    assert_facet(err, DurableStoreFacet::AttachmentStore);
}

#[tokio::test]
async fn durable_worker_rejects_ephemeral_process_env_store() {
    let worker = worker(
        Arc::new(DurableAttachmentStore::default()),
        Arc::new(crate::InMemoryProcessExecutionEnvStore::new()),
        DurabilityTier::Durable,
    );
    let err = run(&worker)
        .await
        .expect_err("ephemeral process env store must be rejected at the worker boundary");
    assert_facet(err, DurableStoreFacet::ProcessEnvStore);
}

#[tokio::test]
async fn durable_worker_rejects_ephemeral_session_store_factory() {
    let worker = worker(
        Arc::new(DurableAttachmentStore::default()),
        Arc::new(DurableProcessEnvStore::default()),
        DurabilityTier::Inline,
    );
    let err = run(&worker)
        .await
        .expect_err("ephemeral session store factory must be rejected at the worker boundary");
    assert_facet(err, DurableStoreFacet::SessionStore);
}

#[tokio::test]
async fn durable_worker_rejects_ephemeral_process_registry() {
    let worker = worker_with_store_tiers(
        Arc::new(DurableAttachmentStore::default()),
        Arc::new(DurableProcessEnvStore::default()),
        DurabilityTier::Durable,
        DurabilityTier::Inline,
        DurabilityTier::Durable,
    );
    let err = run(&worker)
        .await
        .expect_err("ephemeral process registry must be rejected at the worker boundary");
    assert_facet(err, DurableStoreFacet::ProcessRegistry);
}

#[tokio::test]
async fn durable_worker_rejects_ephemeral_trigger_store() {
    let worker = worker_with_store_tiers(
        Arc::new(DurableAttachmentStore::default()),
        Arc::new(DurableProcessEnvStore::default()),
        DurabilityTier::Durable,
        DurabilityTier::Durable,
        DurabilityTier::Inline,
    );
    let err = run(&worker)
        .await
        .expect_err("ephemeral trigger store must be rejected at the worker boundary");
    assert_facet(err, DurableStoreFacet::TriggerStore);
}

/// The store-facet guard passing is observed as the run reaching the
/// disposition guard next: the ExternallyOwned boundary registration is
/// rejected there, not at a facet, proving the facet guard cleared.
fn assert_reached_disposition_guard(err: PluginError) {
    let PluginError::Session(message) = err else {
        panic!("expected PluginError::Session, got {err:?}");
    };
    assert!(
        message.contains("externally-owned"),
        "run must clear the store-facet guard and reach the disposition guard, got: {message}"
    );
}

#[tokio::test]
async fn durable_worker_with_all_durable_stores_passes_store_facet_check() {
    // Positive control: a durable worker wired against fully durable stores
    // clears the store-facet guard and reaches the disposition guard, which
    // rejects the ExternallyOwned boundary registration (ADR 0019).
    let worker = worker(
        Arc::new(DurableAttachmentStore::default()),
        Arc::new(DurableProcessEnvStore::default()),
        DurabilityTier::Durable,
    );
    let err = run(&worker)
        .await
        .expect_err("externally-owned row is rejected after the facet guard");
    assert_reached_disposition_guard(err);
}

#[tokio::test]
async fn inline_worker_passes_store_facet_check_with_ephemeral_stores() {
    // Inline controllers impose no requirement, so an in-memory worker clears
    // the durable-first guard unchanged and reaches the disposition guard.
    let runtime_host = RuntimeHostConfig::in_memory();
    let plugin_host = Arc::new(crate::PluginHost::new(Vec::new()));
    let factory: Arc<dyn SessionStoreFactory> = Arc::new(TierSessionStoreFactory {
        tier: DurabilityTier::Inline,
    });
    let registry: Arc<dyn ProcessRegistry> = Arc::new(crate::TestLocalProcessRegistry::default());
    let worker = DurableProcessWorker::new(DurableProcessWorkerConfig::new(
        plugin_host,
        runtime_host,
        factory,
        registry,
    ));
    let err = run(&worker)
        .await
        .expect_err("externally-owned row is rejected after the facet guard");
    assert_reached_disposition_guard(err);
}